mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
core: bundle settings diff updates into one dev/user envelope (#12417)
## Summary
- bundle contextual prompt injection into at most one developer message
plus one contextual user message in both:
- per-turn settings updates
- initial context insertion
- preserve `<model_switch>` across compaction by rebuilding it through
canonical initial-context injection, instead of relying on
strip/reattach hacks
- centralize contextual user fragment detection in one shared definition
table and reuse it for parsing/compaction logic
- keep `AGENTS.md` in its natural serialized format:
- `# AGENTS.md instructions for {dirname}`
- `<INSTRUCTIONS>...</INSTRUCTIONS>`
- simplify related tests/helpers and accept the expected snapshot/layout
updates from bundled multi-part messages
## Why
The goal is to converge toward a simpler, more intentional prompt shape
where contextual updates are consistently represented as one developer
envelope plus one contextual user envelope, while keeping parsing and
compaction behavior aligned with that representation.
## Notable details
- the temporary `SettingsUpdateEnvelope` wrapper was removed; these
paths now return `Vec<ResponseItem>` directly
- local/remote compaction no longer rely on model-switch strip/restore
helpers
- contextual user detection is now driven by shared fragment definitions
instead of ad hoc matcher assembly
- AGENTS/user instructions are still the same logical context; only the
synthetic `<user_instructions>` wrapper was replaced by the natural
AGENTS text format
## Testing
- `just fmt`
- `cargo test -p codex-app-server
codex_message_processor::tests::extract_conversation_summary_prefers_plain_user_messages
-- --exact`
- `cargo test -p codex-core
compact::tests::collect_user_messages_filters_session_prefix_entries
--lib -- --exact`
- `cargo test -p codex-core --test all
'suite::compact::snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch'
-- --exact`
- `cargo test -p codex-core --test all
'suite::compact_remote::snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model_switch'
-- --exact`
- `cargo test -p codex-core --test all
'suite::client::includes_apps_guidance_as_developer_message_when_enabled'
-- --exact`
- `cargo test -p codex-core --test all
'suite::client::includes_developer_instructions_message_in_request' --
--exact`
- `cargo test -p codex-core --test all
'suite::client::includes_user_instructions_message_in_request' --
--exact`
- `cargo test -p codex-core --test all
'suite::client::resume_includes_initial_messages_and_sends_prior_items'
-- --exact`
- `cargo test -p codex-core --test all
'suite::review::review_input_isolated_from_parent_history' -- --exact`
- `cargo test -p codex-exec --test all
'suite::resume::exec_resume_last_respects_cwd_filter_and_all_flag' --
--exact`
- `cargo test -p core_test_support
context_snapshot::tests::full_text_mode_preserves_unredacted_text --
--exact`
## Notes
- I also ran several targeted `compact`, `compact_remote`,
`prompt_caching`, `model_visible_layout`, and `event_mapping` tests
while iterating on prompt-shape changes.
- I have not claimed a clean full-workspace `cargo test` from this
environment because local sandbox/resource conditions have previously
produced unrelated failures in large workspace runs.
This commit is contained in:
committed by
GitHub
Unverified
parent
28bfbb8f2b
commit
07aefffb1f
@@ -319,8 +319,8 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use crate::features::Feature;
|
||||
use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
|
||||
+92
-52
@@ -1669,7 +1669,10 @@ impl Session {
|
||||
match conversation_history {
|
||||
InitialHistory::New => {
|
||||
// Build and record initial items (user instructions + environment context)
|
||||
let items = self.build_initial_context(&turn_context).await;
|
||||
// TODO(ccunningham): Defer initial context insertion until the first real turn
|
||||
// starts so it reflects the actual first-turn settings (permissions, etc.) and
|
||||
// we do not emit model-visible "diff" updates before the first user message.
|
||||
let items = self.build_initial_context(&turn_context, None).await;
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -1773,7 +1776,7 @@ impl Session {
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context).await;
|
||||
let initial_context = self.build_initial_context(&turn_context, None).await;
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
{
|
||||
@@ -2862,7 +2865,7 @@ impl Session {
|
||||
} else {
|
||||
let user_messages = collect_user_messages(history.raw_items());
|
||||
let rebuilt = compact::build_compacted_history(
|
||||
self.build_initial_context(turn_context).await,
|
||||
self.build_initial_context(turn_context, None).await,
|
||||
&user_messages,
|
||||
&compacted.message,
|
||||
);
|
||||
@@ -2990,10 +2993,20 @@ impl Session {
|
||||
pub(crate) async fn build_initial_context(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(4);
|
||||
let mut developer_sections = Vec::<String>::with_capacity(8);
|
||||
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
|
||||
let shell = self.user_shell();
|
||||
items.push(
|
||||
if let Some(model_switch_message) =
|
||||
crate::context_manager::updates::build_model_instructions_update_item(
|
||||
previous_user_turn_model,
|
||||
turn_context,
|
||||
)
|
||||
{
|
||||
developer_sections.push(model_switch_message.into_text());
|
||||
}
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
@@ -3001,17 +3014,17 @@ impl Session {
|
||||
&turn_context.cwd,
|
||||
turn_context.features.enabled(Feature::RequestPermissions),
|
||||
)
|
||||
.into(),
|
||||
.into_text(),
|
||||
);
|
||||
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
|
||||
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
|
||||
developer_sections.push(developer_instructions.to_string());
|
||||
}
|
||||
// Add developer instructions for memories.
|
||||
if let Some(memory_prompt) =
|
||||
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
|
||||
&& turn_context.features.enabled(Feature::MemoryTool)
|
||||
{
|
||||
items.push(DeveloperInstructions::new(memory_prompt).into());
|
||||
developer_sections.push(memory_prompt);
|
||||
}
|
||||
// Add developer instructions from collaboration_mode if they exist and are non-empty
|
||||
let (collaboration_mode, base_instructions) = {
|
||||
@@ -3024,7 +3037,7 @@ impl Session {
|
||||
if let Some(collab_instructions) =
|
||||
DeveloperInstructions::from_collaboration_mode(&collaboration_mode)
|
||||
{
|
||||
items.push(collab_instructions.into());
|
||||
developer_sections.push(collab_instructions.into_text());
|
||||
}
|
||||
if self.features.enabled(Feature::Personality)
|
||||
&& let Some(personality) = turn_context.personality
|
||||
@@ -3039,34 +3052,46 @@ impl Session {
|
||||
personality,
|
||||
)
|
||||
{
|
||||
items.push(
|
||||
DeveloperInstructions::personality_spec_message(personality_message).into(),
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::personality_spec_message(personality_message)
|
||||
.into_text(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if turn_context.features.enabled(Feature::Apps) {
|
||||
items.push(DeveloperInstructions::new(render_apps_section()).into());
|
||||
developer_sections.push(render_apps_section());
|
||||
}
|
||||
if turn_context.features.enabled(Feature::CodexGitCommit)
|
||||
&& let Some(commit_message_instruction) = commit_message_trailer_instruction(
|
||||
turn_context.config.commit_attribution.as_deref(),
|
||||
)
|
||||
{
|
||||
items.push(DeveloperInstructions::new(commit_message_instruction).into());
|
||||
developer_sections.push(commit_message_instruction);
|
||||
}
|
||||
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
|
||||
items.push(
|
||||
contextual_user_sections.push(
|
||||
UserInstructions {
|
||||
text: user_instructions.to_string(),
|
||||
directory: turn_context.cwd.to_string_lossy().into_owned(),
|
||||
}
|
||||
.into(),
|
||||
.serialize_to_text(),
|
||||
);
|
||||
}
|
||||
items.push(ResponseItem::from(EnvironmentContext::from_turn_context(
|
||||
turn_context,
|
||||
shell.as_ref(),
|
||||
)));
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref()).serialize_to_xml(),
|
||||
);
|
||||
|
||||
let mut items = Vec::with_capacity(2);
|
||||
if let Some(developer_message) =
|
||||
crate::context_manager::updates::build_developer_update_item(developer_sections)
|
||||
{
|
||||
items.push(developer_message);
|
||||
}
|
||||
if let Some(contextual_user_message) =
|
||||
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
|
||||
{
|
||||
items.push(contextual_user_message);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
@@ -3111,22 +3136,8 @@ impl Session {
|
||||
let reference_context_item = self.reference_context_item().await;
|
||||
let should_inject_full_context = reference_context_item.is_none();
|
||||
let context_items = if should_inject_full_context {
|
||||
let mut initial_context = self.build_initial_context(turn_context).await;
|
||||
// Full reinjection bypasses the settings-diff path, so add the model-switch
|
||||
// instruction explicitly when needed. Keep it before the rest of full context so
|
||||
// model-specific guidance is read first.
|
||||
if let Some(model_switch_item) =
|
||||
crate::context_manager::updates::build_model_instructions_update_item(
|
||||
previous_user_turn_model,
|
||||
turn_context,
|
||||
)
|
||||
{
|
||||
// TODO(ccunningham): When a model switch changes the effective personality
|
||||
// instructions, inject the updated personality spec alongside <model_switch>
|
||||
// here so resume/model-switch paths can avoid forcing full reinjection.
|
||||
initial_context.insert(0, model_switch_item);
|
||||
}
|
||||
initial_context
|
||||
self.build_initial_context(turn_context, previous_user_turn_model)
|
||||
.await
|
||||
} else {
|
||||
// Steady-state path: append only context diffs to minimize token overhead.
|
||||
self.build_settings_update_items(
|
||||
@@ -3516,9 +3527,9 @@ impl Session {
|
||||
use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
};
|
||||
{
|
||||
let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
cancel_guard.cancel();
|
||||
*cancel_guard = CancellationToken::new();
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
*guard = CancellationToken::new();
|
||||
}
|
||||
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
@@ -3532,11 +3543,11 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
if cancel_guard.is_cancelled() {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
if guard.is_cancelled() {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
*cancel_guard = cancel_token;
|
||||
*guard = cancel_token;
|
||||
}
|
||||
|
||||
let mut manager = self.services.mcp_connection_manager.write().await;
|
||||
@@ -5011,6 +5022,7 @@ pub(crate) async fn run_turn(
|
||||
&sess,
|
||||
&turn_context,
|
||||
InitialContextInjection::BeforeLastUserMessage,
|
||||
previous_model.as_deref(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
@@ -5134,7 +5146,13 @@ async fn run_pre_sampling_compact(
|
||||
.unwrap_or(i64::MAX);
|
||||
// Compact if the total usage tokens are greater than the auto compact limit
|
||||
if total_usage_tokens >= auto_compact_limit {
|
||||
run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?;
|
||||
run_auto_compact(
|
||||
sess,
|
||||
turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5177,6 +5195,7 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
sess,
|
||||
&previous_model_turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
@@ -5188,12 +5207,14 @@ async fn run_auto_compact(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
if should_use_remote_compact_task(&turn_context.provider) {
|
||||
run_inline_remote_auto_compact_task(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(turn_context),
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
@@ -5201,6 +5222,7 @@ async fn run_auto_compact(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(turn_context),
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -7438,7 +7460,7 @@ mod tests {
|
||||
session
|
||||
.record_context_updates_and_set_reference_context_item(&turn_context, None)
|
||||
.await;
|
||||
expected.extend(session.build_initial_context(&turn_context).await);
|
||||
expected.extend(session.build_initial_context(&turn_context, None).await);
|
||||
let history_after_seed = session.clone_history().await;
|
||||
assert_eq!(expected, history_after_seed.raw_items());
|
||||
|
||||
@@ -7600,7 +7622,7 @@ mod tests {
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
expected.extend(
|
||||
session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.build_initial_context(reconstruction_turn.as_ref(), None)
|
||||
.await,
|
||||
);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
@@ -7643,7 +7665,7 @@ mod tests {
|
||||
async fn thread_rollback_drops_last_turn_from_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7714,7 +7736,7 @@ mod tests {
|
||||
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7742,7 +7764,7 @@ mod tests {
|
||||
async fn thread_rollback_fails_when_turn_in_progress() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7763,7 +7785,7 @@ mod tests {
|
||||
async fn thread_rollback_fails_when_num_turns_is_zero() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -8787,7 +8809,7 @@ mod tests {
|
||||
.record_context_updates_and_set_reference_context_item(&turn_context, None)
|
||||
.await;
|
||||
let history = session.clone_history().await;
|
||||
let initial_context = session.build_initial_context(&turn_context).await;
|
||||
let initial_context = session.build_initial_context(&turn_context, None).await;
|
||||
assert_eq!(history.raw_items().to_vec(), initial_context);
|
||||
|
||||
let current_context = session.reference_context_item().await;
|
||||
@@ -8831,10 +8853,28 @@ mod tests {
|
||||
|
||||
let history = session.clone_history().await;
|
||||
let mut expected_history = vec![compacted_summary];
|
||||
expected_history.extend(session.build_initial_context(&turn_context).await);
|
||||
expected_history.extend(session.build_initial_context(&turn_context, None).await);
|
||||
assert_eq!(history.raw_items().to_vec(), expected_history);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_initial_context_prepends_model_switch_message() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
let initial_context = session
|
||||
.build_initial_context(&turn_context, Some("previous-regular-model"))
|
||||
.await;
|
||||
|
||||
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
|
||||
panic!("expected developer message");
|
||||
};
|
||||
assert_eq!(role, "developer");
|
||||
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
|
||||
panic!("expected developer text");
|
||||
};
|
||||
assert!(text.contains("<model_switch>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_user_shell_command_does_not_set_reference_context_item() {
|
||||
let (session, _turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
@@ -9156,7 +9196,7 @@ mod tests {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG)
|
||||
text.contains(crate::contextual_user_message::TURN_ABORTED_OPEN_TAG)
|
||||
})
|
||||
}),
|
||||
"expected a model-visible turn aborted marker in history after interrupt"
|
||||
@@ -9230,7 +9270,7 @@ mod tests {
|
||||
// personality_spec) matches reconstruction.
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
let mut initial_context = session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.build_initial_context(reconstruction_turn.as_ref(), None)
|
||||
.await;
|
||||
// Ensure personality_spec is present when Personality is enabled, so expected matches
|
||||
// what reconstruction produces (build_initial_context may omit it when baked into model).
|
||||
|
||||
+63
-155
@@ -7,7 +7,6 @@ use crate::client_common::ResponseEvent;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::get_last_assistant_message_from_turn;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::CompactedItem;
|
||||
@@ -51,47 +50,11 @@ pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bo
|
||||
provider.is_openai()
|
||||
}
|
||||
|
||||
fn is_model_switch_developer_message(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "developer" => {
|
||||
matches!(
|
||||
content.as_slice(),
|
||||
[ContentItem::InputText { text }] if text.starts_with("<model_switch>\n")
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_trailing_model_switch_update_for_compaction_request(
|
||||
history: &mut ContextManager,
|
||||
) -> Option<ResponseItem> {
|
||||
let history_items = history.raw_items();
|
||||
let last_user_turn_boundary_index = history_items
|
||||
.iter()
|
||||
.rposition(crate::context_manager::is_user_turn_boundary);
|
||||
let model_switch_index = history_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(i, item)| {
|
||||
let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary);
|
||||
if is_trailing && is_model_switch_developer_message(item) {
|
||||
Some(i)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
let mut replacement = history_items.to_vec();
|
||||
let model_switch_item = replacement.remove(model_switch_index);
|
||||
history.replace(replacement);
|
||||
Some(model_switch_item)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_inline_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
let prompt = turn_context.compact_prompt().to_string();
|
||||
let input = vec![UserInput::Text {
|
||||
@@ -100,7 +63,14 @@ pub(crate) async fn run_inline_auto_compact_task(
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
|
||||
run_compact_task_inner(sess, turn_context, input, initial_context_injection).await?;
|
||||
run_compact_task_inner(
|
||||
sess,
|
||||
turn_context,
|
||||
input,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -120,6 +90,7 @@ pub(crate) async fn run_compact_task(
|
||||
turn_context,
|
||||
input,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -129,6 +100,7 @@ async fn run_compact_task_inner(
|
||||
turn_context: Arc<TurnContext>,
|
||||
input: Vec<UserInput>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
|
||||
sess.emit_turn_item_started(&turn_context, &compaction_item)
|
||||
@@ -136,10 +108,6 @@ async fn run_compact_task_inner(
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
|
||||
let mut history = sess.clone_history().await;
|
||||
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
|
||||
// tail of history (between turns), exclude it from the compaction request payload.
|
||||
let stripped_model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
history.record_items(
|
||||
&[initial_input_for_turn.into()],
|
||||
turn_context.truncation_policy,
|
||||
@@ -240,15 +208,12 @@ async fn run_compact_task_inner(
|
||||
initial_context_injection,
|
||||
InitialContextInjection::BeforeLastUserMessage
|
||||
) {
|
||||
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
|
||||
let initial_context = sess
|
||||
.build_initial_context(turn_context.as_ref(), previous_user_turn_model)
|
||||
.await;
|
||||
new_history =
|
||||
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
|
||||
}
|
||||
// Reattach the stripped model-switch update only after successful compaction so the model
|
||||
// still sees the switch instructions on the next real sampling request.
|
||||
if let Some(model_switch_item) = stripped_model_switch_item {
|
||||
new_history.push(model_switch_item);
|
||||
}
|
||||
let ghost_snapshots: Vec<ResponseItem> = history_items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
|
||||
@@ -491,14 +456,18 @@ mod tests {
|
||||
|
||||
async fn process_compacted_history_with_test_session(
|
||||
compacted_history: Vec<ResponseItem>,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
|
||||
let (session, turn_context) = crate::codex::make_session_and_context().await;
|
||||
let initial_context = session.build_initial_context(&turn_context).await;
|
||||
let initial_context = session
|
||||
.build_initial_context(&turn_context, previous_user_turn_model)
|
||||
.await;
|
||||
let refreshed = crate::compact_remote::process_compacted_history(
|
||||
&session,
|
||||
&turn_context,
|
||||
compacted_history,
|
||||
InitialContextInjection::BeforeLastUserMessage,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await;
|
||||
(refreshed, initial_context)
|
||||
@@ -534,107 +503,6 @@ mod tests {
|
||||
assert_eq!(None, joined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_model_switch_update_for_compaction_request_removes_trailing_item() {
|
||||
let mut history = ContextManager::new();
|
||||
history.replace(vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "USER_MESSAGE".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "ASSISTANT_REPLY".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<model_switch>\nNEW_MODEL_INSTRUCTIONS".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
|
||||
assert_eq!(history.raw_items().len(), 2);
|
||||
assert!(model_switch_item.is_some());
|
||||
assert!(
|
||||
history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.all(|item| !is_model_switch_developer_message(item))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_model_switch_update_for_compaction_request_keeps_historical_item() {
|
||||
let mut history = ContextManager::new();
|
||||
history.replace(vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "FIRST_USER_MESSAGE".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<model_switch>\nOLDER_MODEL_INSTRUCTIONS".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "ASSISTANT_REPLY".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "SECOND_USER_MESSAGE".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
|
||||
assert_eq!(history.raw_items().len(), 4);
|
||||
assert!(model_switch_item.is_none());
|
||||
assert!(
|
||||
history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.any(is_model_switch_developer_message)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_user_messages_extracts_user_text_only() {
|
||||
let items = vec![
|
||||
@@ -802,7 +670,7 @@ do things
|
||||
},
|
||||
];
|
||||
let (refreshed, mut expected) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -827,7 +695,7 @@ do things
|
||||
phase: None,
|
||||
}];
|
||||
let (refreshed, mut expected) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -903,7 +771,7 @@ keep me updated
|
||||
},
|
||||
];
|
||||
let (refreshed, mut expected) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -949,7 +817,7 @@ keep me updated
|
||||
];
|
||||
|
||||
let (refreshed, initial_context) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
let mut expected = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -983,6 +851,46 @@ keep me updated
|
||||
assert_eq!(refreshed, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_compacted_history_reinjects_model_switch_message() {
|
||||
let compacted_history = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "summary".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}];
|
||||
|
||||
let (refreshed, initial_context) = process_compacted_history_with_test_session(
|
||||
compacted_history,
|
||||
Some("previous-regular-model"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
|
||||
panic!("expected developer message");
|
||||
};
|
||||
assert_eq!(role, "developer");
|
||||
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
|
||||
panic!("expected developer text");
|
||||
};
|
||||
assert!(text.contains("<model_switch>"));
|
||||
|
||||
let mut expected = initial_context;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "summary".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
});
|
||||
assert_eq!(refreshed, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() {
|
||||
let compacted_history = vec![
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::Prompt;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::compact::InitialContextInjection;
|
||||
use crate::compact::extract_trailing_model_switch_update_for_compaction_request;
|
||||
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::context_manager::TotalTokenUsageBreakdown;
|
||||
@@ -28,8 +27,15 @@ pub(crate) async fn run_inline_remote_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?;
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&turn_context,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,16 +50,28 @@ pub(crate) async fn run_remote_compact_task(
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
|
||||
run_remote_compact_task_inner(&sess, &turn_context, InitialContextInjection::DoNotInject).await
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_remote_compact_task_inner(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
if let Err(err) =
|
||||
run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await
|
||||
if let Err(err) = run_remote_compact_task_inner_impl(
|
||||
sess,
|
||||
turn_context,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running remote compact task".to_string())),
|
||||
@@ -68,15 +86,12 @@ async fn run_remote_compact_task_inner_impl(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
|
||||
sess.emit_turn_item_started(turn_context, &compaction_item)
|
||||
.await;
|
||||
let mut history = sess.clone_history().await;
|
||||
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
|
||||
// tail of history (between turns), exclude it from the compaction request payload.
|
||||
let stripped_model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let deleted_items = trim_function_call_history_to_fit_context_window(
|
||||
&mut history,
|
||||
@@ -134,13 +149,9 @@ async fn run_remote_compact_task_inner_impl(
|
||||
turn_context.as_ref(),
|
||||
new_history,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await;
|
||||
// Reattach the stripped model-switch update only after successful compaction so the model
|
||||
// still sees the switch instructions on the next real sampling request.
|
||||
if let Some(model_switch_item) = stripped_model_switch_item {
|
||||
new_history.push(model_switch_item);
|
||||
}
|
||||
|
||||
if !ghost_snapshots.is_empty() {
|
||||
new_history.extend(ghost_snapshots);
|
||||
@@ -170,6 +181,7 @@ pub(crate) async fn process_compacted_history(
|
||||
turn_context: &TurnContext,
|
||||
mut compacted_history: Vec<ResponseItem>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> Vec<ResponseItem> {
|
||||
// Mid-turn compaction is the only path that must inject initial context above the last user
|
||||
// message in the replacement history. Pre-turn compaction instead injects context after the
|
||||
@@ -178,7 +190,8 @@ pub(crate) async fn process_compacted_history(
|
||||
initial_context_injection,
|
||||
InitialContextInjection::BeforeLastUserMessage
|
||||
) {
|
||||
sess.build_initial_context(turn_context).await
|
||||
sess.build_initial_context(turn_context, previous_user_turn_model)
|
||||
.await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -205,10 +218,12 @@ pub(crate) async fn process_compacted_history(
|
||||
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, .. } if role == "developer" => false,
|
||||
ResponseItem::Message { role, .. } if role == "user" => matches!(
|
||||
crate::event_mapping::parse_turn_item(item),
|
||||
Some(TurnItem::UserMessage(_))
|
||||
),
|
||||
ResponseItem::Message { role, .. } if role == "user" => {
|
||||
matches!(
|
||||
crate::event_mapping::parse_turn_item(item),
|
||||
Some(TurnItem::UserMessage(_))
|
||||
)
|
||||
}
|
||||
ResponseItem::Message { role, .. } if role == "assistant" => true,
|
||||
ResponseItem::Message { .. } => false,
|
||||
ResponseItem::Compaction { .. } => true,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::session_prefix::is_session_prefix;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count_i64;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::truncate::truncate_text;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -554,33 +551,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
if role != "user" {
|
||||
return false;
|
||||
}
|
||||
|
||||
if UserInstructions::is_user_instructions(content)
|
||||
|| SkillInstructions::is_skill_instructions(content)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for content_item in content {
|
||||
match content_item {
|
||||
ContentItem::InputText { text } => {
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
if is_session_prefix(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ContentItem::InputImage { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
role == "user" && !is_contextual_user_message_content(content)
|
||||
}
|
||||
|
||||
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
|
||||
|
||||
@@ -563,7 +563,6 @@ fn drop_last_n_user_turns_preserves_prefix() {
|
||||
fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
let items = vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -586,7 +585,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let expected_prefix_and_first_turn = vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -608,7 +606,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let expected_prefix_only = vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -623,7 +620,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let mut history = create_history_with_items(vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -644,7 +640,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let mut history = create_history_with_items(vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::features::Feature;
|
||||
use crate::shell::Shell;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -30,7 +31,7 @@ fn build_permissions_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
exec_policy: &Policy,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
let prev = previous?;
|
||||
if prev.sandbox_policy == *next.sandbox_policy.get()
|
||||
&& prev.approval_policy == next.approval_policy.value()
|
||||
@@ -38,27 +39,26 @@ fn build_permissions_update_item(
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
DeveloperInstructions::from_policy(
|
||||
next.sandbox_policy.get(),
|
||||
next.approval_policy.value(),
|
||||
exec_policy,
|
||||
&next.cwd,
|
||||
next.features.enabled(Feature::RequestPermissions),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
Some(DeveloperInstructions::from_policy(
|
||||
next.sandbox_policy.get(),
|
||||
next.approval_policy.value(),
|
||||
exec_policy,
|
||||
&next.cwd,
|
||||
next.features.enabled(Feature::RequestPermissions),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_collaboration_mode_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
let prev = previous?;
|
||||
if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) {
|
||||
// If the next mode has empty developer instructions, this returns None and we emit no
|
||||
// update, so prior collaboration instructions remain in the prompt history.
|
||||
Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into())
|
||||
Some(DeveloperInstructions::from_collaboration_mode(
|
||||
&next.collaboration_mode,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -68,7 +68,7 @@ fn build_personality_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
if !personality_feature_enabled {
|
||||
return None;
|
||||
}
|
||||
@@ -82,8 +82,7 @@ fn build_personality_update_item(
|
||||
{
|
||||
let model_info = &next.model_info;
|
||||
let personality_message = personality_message_for(model_info, personality);
|
||||
personality_message
|
||||
.map(|message| DeveloperInstructions::personality_spec_message(message).into())
|
||||
personality_message.map(DeveloperInstructions::personality_spec_message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -103,7 +102,7 @@ pub(crate) fn personality_message_for(
|
||||
pub(crate) fn build_model_instructions_update_item(
|
||||
previous_user_turn_model: Option<&str>,
|
||||
next: &TurnContext,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
let previous_model = previous_user_turn_model?;
|
||||
if previous_model == next.model_info.slug {
|
||||
return None;
|
||||
@@ -114,7 +113,36 @@ pub(crate) fn build_model_instructions_update_item(
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DeveloperInstructions::model_switch_message(model_instructions).into())
|
||||
Some(DeveloperInstructions::model_switch_message(
|
||||
model_instructions,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn build_developer_update_item(text_sections: Vec<String>) -> Option<ResponseItem> {
|
||||
build_text_message("developer", text_sections)
|
||||
}
|
||||
|
||||
pub(crate) fn build_contextual_user_message(text_sections: Vec<String>) -> Option<ResponseItem> {
|
||||
build_text_message("user", text_sections)
|
||||
}
|
||||
|
||||
fn build_text_message(role: &str, text_sections: Vec<String>) -> Option<ResponseItem> {
|
||||
if text_sections.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = text_sections
|
||||
.into_iter()
|
||||
.map(|text| ContentItem::InputText { text })
|
||||
.collect();
|
||||
|
||||
Some(ResponseItem::Message {
|
||||
id: None,
|
||||
role: role.to_string(),
|
||||
content,
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_settings_update_items(
|
||||
@@ -125,29 +153,26 @@ pub(crate) fn build_settings_update_items(
|
||||
exec_policy: &Policy,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut update_items = Vec::new();
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell);
|
||||
let developer_update_sections = [
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
// any other context diffs on this turn.
|
||||
build_model_instructions_update_item(previous_user_turn_model, next),
|
||||
build_permissions_update_item(previous, next, exec_policy),
|
||||
build_collaboration_mode_update_item(previous, next),
|
||||
build_personality_update_item(previous, next, personality_feature_enabled),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(DeveloperInstructions::into_text)
|
||||
.collect();
|
||||
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
// any other context diffs on this turn.
|
||||
if let Some(model_instructions_item) =
|
||||
build_model_instructions_update_item(previous_user_turn_model, next)
|
||||
{
|
||||
update_items.push(model_instructions_item);
|
||||
let mut items = Vec::with_capacity(2);
|
||||
if let Some(developer_message) = build_developer_update_item(developer_update_sections) {
|
||||
items.push(developer_message);
|
||||
}
|
||||
if let Some(env_item) = build_environment_update_item(previous, next, shell) {
|
||||
update_items.push(env_item);
|
||||
if let Some(contextual_user_message) = contextual_user_message {
|
||||
items.push(contextual_user_message);
|
||||
}
|
||||
if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) {
|
||||
update_items.push(permissions_item);
|
||||
}
|
||||
if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) {
|
||||
update_items.push(collaboration_mode_item);
|
||||
}
|
||||
if let Some(personality_item) =
|
||||
build_personality_update_item(previous, next, personality_feature_enabled)
|
||||
{
|
||||
update_items.push(personality_item);
|
||||
}
|
||||
|
||||
update_items
|
||||
items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
|
||||
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
|
||||
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub(crate) fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub(crate) fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
USER_SHELL_COMMAND_OPEN_TAG,
|
||||
USER_SHELL_COMMAND_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const TURN_ABORTED_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(TURN_ABORTED_OPEN_TAG, TURN_ABORTED_CLOSE_TAG);
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
SUBAGENT_NOTIFICATION_OPEN_TAG,
|
||||
SUBAGENT_NOTIFICATION_CLOSE_TAG,
|
||||
);
|
||||
|
||||
const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[
|
||||
AGENTS_MD_FRAGMENT,
|
||||
ENVIRONMENT_CONTEXT_FRAGMENT,
|
||||
SKILL_FRAGMENT,
|
||||
USER_SHELL_COMMAND_FRAGMENT,
|
||||
TURN_ABORTED_FRAGMENT,
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT,
|
||||
];
|
||||
|
||||
pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
CONTEXTUAL_USER_FRAGMENTS
|
||||
.iter()
|
||||
.any(|definition| definition.matches_text(text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_agents_instructions_fragment() {
|
||||
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text: "# AGENTS.md instructions for /tmp\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_subagent_notification_fragment_case_insensitively() {
|
||||
assert!(
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT
|
||||
.matches_text("<SUBAGENT_NOTIFICATION>{}</subagent_notification>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_regular_user_text() {
|
||||
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text: "hello".to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT;
|
||||
use crate::shell::Shell;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use serde::Deserialize;
|
||||
@@ -40,7 +38,6 @@ impl EnvironmentContext {
|
||||
let EnvironmentContext {
|
||||
cwd,
|
||||
network,
|
||||
// should compare all fields except shell
|
||||
shell: _,
|
||||
} = other;
|
||||
self.cwd == *cwd && self.network == *network
|
||||
@@ -122,7 +119,7 @@ impl EnvironmentContext {
|
||||
/// </environment_context>
|
||||
/// ```
|
||||
pub fn serialize_to_xml(self) -> String {
|
||||
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
|
||||
let mut lines = Vec::new();
|
||||
if let Some(cwd) = self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
}
|
||||
@@ -145,22 +142,13 @@ impl EnvironmentContext {
|
||||
// lines.push(" <network enabled=\"false\" />".to_string());
|
||||
}
|
||||
}
|
||||
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
|
||||
lines.join("\n")
|
||||
ENVIRONMENT_CONTEXT_FRAGMENT.wrap(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnvironmentContext> for ResponseItem {
|
||||
fn from(ec: EnvironmentContext) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: ec.serialize_to_xml(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
ENVIRONMENT_CONTEXT_FRAGMENT.into_message(ec.serialize_to_xml())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,16 +18,15 @@ use codex_protocol::user_input::UserInput;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::session_prefix::is_session_prefix;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
use crate::contextual_user_message::is_contextual_user_fragment;
|
||||
use crate::web_search::web_search_action_detail;
|
||||
|
||||
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
|
||||
message.iter().any(is_contextual_user_fragment)
|
||||
}
|
||||
|
||||
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
if UserInstructions::is_user_instructions(message)
|
||||
|| SkillInstructions::is_skill_instructions(message)
|
||||
{
|
||||
if is_contextual_user_message_content(message) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -44,9 +43,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
return None;
|
||||
}
|
||||
content.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
// Model input content does not carry UI element ranges.
|
||||
@@ -59,9 +55,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
});
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
if is_session_prefix(text) {
|
||||
return None;
|
||||
}
|
||||
warn!("Output text in user message: {}", text);
|
||||
}
|
||||
}
|
||||
@@ -299,7 +292,7 @@ mod tests {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<user_instructions>test_text</user_instructions>".to_string(),
|
||||
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -341,6 +334,22 @@ mod tests {
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "<environment_context>ctx</environment_context>".to_string(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text:
|
||||
"# AGENTS.md instructions for dir\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
];
|
||||
|
||||
for item in items {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = "<user_instructions>";
|
||||
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
|
||||
use crate::contextual_user_message::SKILL_FRAGMENT;
|
||||
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
pub const SKILL_INSTRUCTIONS_PREFIX: &str = "<skill";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
@@ -16,31 +16,20 @@ pub(crate) struct UserInstructions {
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub fn is_user_instructions(message: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = message {
|
||||
text.starts_with(USER_INSTRUCTIONS_PREFIX)
|
||||
|| text.starts_with(USER_INSTRUCTIONS_OPEN_TAG_LEGACY)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
pub(crate) fn serialize_to_text(&self) -> String {
|
||||
format!(
|
||||
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
|
||||
prefix = AGENTS_MD_FRAGMENT.start_marker(),
|
||||
directory = self.directory,
|
||||
contents = self.text,
|
||||
suffix = AGENTS_MD_FRAGMENT.end_marker(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserInstructions> for ResponseItem {
|
||||
fn from(ui: UserInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"{USER_INSTRUCTIONS_PREFIX}{directory}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>",
|
||||
directory = ui.directory,
|
||||
contents = ui.text
|
||||
),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,36 +41,21 @@ pub(crate) struct SkillInstructions {
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl SkillInstructions {
|
||||
pub fn is_skill_instructions(message: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = message {
|
||||
text.starts_with(SKILL_INSTRUCTIONS_PREFIX)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SkillInstructions {}
|
||||
|
||||
impl From<SkillInstructions> for ResponseItem {
|
||||
fn from(si: SkillInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"<skill>\n<name>{}</name>\n<path>{}</path>\n{}\n</skill>",
|
||||
si.name, si.path, si.contents
|
||||
),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(
|
||||
"<name>{}</name>\n<path>{}</path>\n{}",
|
||||
si.name, si.path, si.contents
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -110,21 +84,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_user_instructions() {
|
||||
assert!(UserInstructions::is_user_instructions(
|
||||
&[ContentItem::InputText {
|
||||
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
|
||||
}]
|
||||
assert!(AGENTS_MD_FRAGMENT.matches_text(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>"
|
||||
));
|
||||
assert!(UserInstructions::is_user_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "<user_instructions>test_text</user_instructions>".to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(!UserInstructions::is_user_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "test_text".to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,16 +117,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_skill_instructions() {
|
||||
assert!(SkillInstructions::is_skill_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
|
||||
.to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(!SkillInstructions::is_skill_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "regular text".to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(SKILL_FRAGMENT.matches_text(
|
||||
"<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
|
||||
));
|
||||
assert!(!SKILL_FRAGMENT.matches_text("regular text"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod config;
|
||||
pub mod config_loader;
|
||||
pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub mod custom_prompts;
|
||||
pub mod env;
|
||||
mod environment_context;
|
||||
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut items = session.build_initial_context(&turn_context).await;
|
||||
let mut items = session.build_initial_context(&turn_context, None).await;
|
||||
items.push(user_msg("feature request"));
|
||||
items.push(assistant_msg("ack"));
|
||||
items.push(user_msg("second question"));
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
|
||||
/// Helpers for identifying model-visible "session prefix" messages.
|
||||
///
|
||||
/// A session prefix is a user-role message that carries configuration or state needed by
|
||||
/// follow-up turns (e.g. `<environment_context>`, `<turn_aborted>`). These items are persisted in
|
||||
/// history so the model can see them, but they are not user intent and must not create user-turn
|
||||
/// boundaries.
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
fn starts_with_ascii_case_insensitive(text: &str, prefix: &str) -> bool {
|
||||
text.get(..prefix.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
|
||||
}
|
||||
|
||||
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
|
||||
pub(crate) fn is_session_prefix(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
starts_with_ascii_case_insensitive(trimmed, ENVIRONMENT_CONTEXT_OPEN_TAG)
|
||||
|| starts_with_ascii_case_insensitive(trimmed, TURN_ABORTED_OPEN_TAG)
|
||||
|| starts_with_ascii_case_insensitive(trimmed, SUBAGENT_NOTIFICATION_OPEN_TAG)
|
||||
}
|
||||
/// Helpers for model-visible session state markers that are stored in user-role
|
||||
/// messages but are not user intent.
|
||||
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT;
|
||||
|
||||
pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &AgentStatus) -> String {
|
||||
let payload_json = serde_json::json!({
|
||||
@@ -30,19 +10,5 @@ pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &Agen
|
||||
"status": status,
|
||||
})
|
||||
.to_string();
|
||||
format!("{SUBAGENT_NOTIFICATION_OPEN_TAG}\n{payload_json}\n{SUBAGENT_NOTIFICATION_CLOSE_TAG}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn is_session_prefix_is_case_insensitive() {
|
||||
assert_eq!(
|
||||
is_session_prefix("<SUBAGENT_NOTIFICATION>{}</subagent_notification>"),
|
||||
true
|
||||
);
|
||||
}
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT.wrap(payload_json)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ use tracing::warn;
|
||||
use crate::AuthManager;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnAbortReason;
|
||||
use crate::protocol::TurnAbortedEvent;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::session_prefix::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::RunningTask;
|
||||
use crate::state::TaskKind;
|
||||
|
||||
@@ -690,7 +690,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ignores_session_prefix_messages_when_truncating() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut items = session.build_initial_context(&turn_context).await;
|
||||
let mut items = session.build_initial_context(&turn_context, None).await;
|
||||
items.push(user_msg("feature request"));
|
||||
items.push(assistant_msg("ack"));
|
||||
items.push(user_msg("second question"));
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::USER_SHELL_COMMAND_FRAGMENT;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
pub const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
|
||||
pub const USER_SHELL_COMMAND_CLOSE: &str = "</user_shell_command>";
|
||||
|
||||
pub fn is_user_shell_command_text(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(USER_SHELL_COMMAND_OPEN)
|
||||
}
|
||||
|
||||
fn format_duration_line(duration: Duration) -> String {
|
||||
let duration_seconds = duration.as_secs_f64();
|
||||
format!("Duration: {duration_seconds:.4} seconds")
|
||||
@@ -48,7 +39,7 @@ pub fn format_user_shell_command_record(
|
||||
turn_context: &TurnContext,
|
||||
) -> String {
|
||||
let body = format_user_shell_command_body(command, exec_output, turn_context);
|
||||
format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}")
|
||||
USER_SHELL_COMMAND_FRAGMENT.wrap(body)
|
||||
}
|
||||
|
||||
pub fn user_shell_command_record_item(
|
||||
@@ -56,15 +47,11 @@ pub fn user_shell_command_record_item(
|
||||
exec_output: &ExecToolCallOutput,
|
||||
turn_context: &TurnContext,
|
||||
) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format_user_shell_command_record(command, exec_output, turn_context),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
USER_SHELL_COMMAND_FRAGMENT.into_message(format_user_shell_command_record(
|
||||
command,
|
||||
exec_output,
|
||||
turn_context,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -72,14 +59,16 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::exec::StreamOutput;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn detects_user_shell_command_text_variants() {
|
||||
assert!(is_user_shell_command_text(
|
||||
"<user_shell_command>\necho hi\n</user_shell_command>"
|
||||
));
|
||||
assert!(!is_user_shell_command_text("echo hi"));
|
||||
assert!(
|
||||
USER_SHELL_COMMAND_FRAGMENT
|
||||
.matches_text("<user_shell_command>\necho hi\n</user_shell_command>")
|
||||
);
|
||||
assert!(!USER_SHELL_COMMAND_FRAGMENT.matches_text("echo hi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user