mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
[hooks] use a user message > developer message for prompt continuation (#14867)
## Summary Persist Stop-hook continuation prompts as `user` messages instead of hidden `developer` messages + some requested integration tests This is a followup to @pakrym 's comment in https://github.com/openai/codex/pull/14532 to make sure stop-block continuation prompts match training for turn loops - Stop continuation now writes `<hook_prompt hook_run_id="...">stop hook's user prompt<hook_prompt>` - Introduces quick-xml dependency, though we already indirectly depended on it anyway via syntect - This PR only has about 500 lines of actual logic changes, the rest is tests/schema ## Testing Example run (with a sessionstart hook and 3 stop hooks) - this shows context added by session start, then two stop hooks sending their own additional prompts in a new turn. The model responds with a single message addressing both. Then when that turn ends, the hooks detect that they just ran using `stop_hook_active` and decide not to infinite loop test files for this (unzip, move codex -> .codex): [codex.zip](https://github.com/user-attachments/files/26075806/codex.zip) ``` › cats • Running SessionStart hook: lighting the observatory SessionStart hook (completed) warning: Hi, I'm a session start hook for wizard-tower (startup). hook context: A wimboltine stonpet is an exotic cuisine from hyperspace • Cats are tiny zen wizards, my friend: equal parts nap, mystery, and chaos. If you want, we can talk cat facts, cat breeds, cat names, or build something cat-themed in this repo. • Running Stop hook: checking the tower wards • Running Stop hook: sacking the guards • Running Stop hook: hiring the guards Stop hook (completed) warning: Wizard Tower Stop hook reviewed the completed reply (177 chars). Stop hook (blocked) warning: Wizard Tower Stop hook continuing conversation feedback: cook the stonpet Stop hook (blocked) warning: Wizard Tower Stop hook continuing conversation feedback: eat the cooked stonpet • Stonpet’s cooked, aloha style: flash-seared over a blue quasiflame, glazed with nebula salt, and rested until the hyperspace juices settle. Now we eat with gratitude, my friend. One mindful bite in, and the flavor is pure cosmic surf: smoky, bright, and totally out of this dimension. • Running Stop hook: checking the tower wards • Running Stop hook: sacking the guards • Running Stop hook: hiring the guards Stop hook (completed) warning: Wizard Tower Stop hook reviewed the completed reply (285 chars). Stop hook (completed) warning: Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop. Stop hook (completed) warning: Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop. ```
This commit is contained in:
@@ -87,6 +87,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::items::PlanItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -5734,13 +5735,12 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
}
|
||||
if stop_outcome.should_block {
|
||||
if let Some(continuation_prompt) = stop_outcome.continuation_prompt.clone()
|
||||
if let Some(hook_prompt_message) =
|
||||
build_hook_prompt_message(&stop_outcome.continuation_fragments)
|
||||
{
|
||||
let developer_message: ResponseItem =
|
||||
DeveloperInstructions::new(continuation_prompt).into();
|
||||
sess.record_conversation_items(
|
||||
&turn_context,
|
||||
std::slice::from_ref(&developer_message),
|
||||
std::slice::from_ref(&hook_prompt_message),
|
||||
)
|
||||
.await;
|
||||
stop_hook_active = true;
|
||||
|
||||
@@ -196,7 +196,7 @@ pub(crate) async fn process_compacted_history(
|
||||
/// - `developer` messages because remote output can include stale/duplicated
|
||||
/// instruction content.
|
||||
/// - non-user-content `user` messages (session prefix/instruction wrappers),
|
||||
/// keeping only real user messages as parsed by `parse_turn_item`.
|
||||
/// while preserving real user messages and persisted hook prompts.
|
||||
///
|
||||
/// This intentionally keeps:
|
||||
/// - `assistant` messages (future remote compaction models may emit them)
|
||||
@@ -208,7 +208,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
ResponseItem::Message { role, .. } if role == "user" => {
|
||||
matches!(
|
||||
crate::event_mapping::parse_turn_item(item),
|
||||
Some(TurnItem::UserMessage(_))
|
||||
Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_))
|
||||
)
|
||||
}
|
||||
ResponseItem::Message { role, .. } if role == "assistant" => true,
|
||||
|
||||
@@ -177,8 +177,7 @@ impl ContextManager {
|
||||
/// Returns true when a tool image was replaced, false otherwise.
|
||||
pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool {
|
||||
let Some(index) = self.items.iter().rposition(|item| {
|
||||
matches!(item, ResponseItem::FunctionCallOutput { .. })
|
||||
|| matches!(item, ResponseItem::Message { role, .. } if role == "user")
|
||||
matches!(item, ResponseItem::FunctionCallOutput { .. }) || is_user_turn_boundary(item)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
@@ -200,7 +199,7 @@ impl ContextManager {
|
||||
}
|
||||
replaced
|
||||
}
|
||||
ResponseItem::Message { role, .. } if role == "user" => false,
|
||||
ResponseItem::Message { .. } => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -250,11 +249,7 @@ impl ContextManager {
|
||||
|
||||
fn get_non_last_reasoning_items_tokens(&self) -> i64 {
|
||||
// Get reasoning items excluding all the ones after the last user message.
|
||||
let Some(last_user_index) = self
|
||||
.items
|
||||
.iter()
|
||||
.rposition(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user"))
|
||||
else {
|
||||
let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use codex_protocol::items::HookPromptItem;
|
||||
use codex_protocol::items::parse_hook_prompt_fragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
@@ -94,10 +96,7 @@ const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT,
|
||||
];
|
||||
|
||||
pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
fn is_standard_contextual_user_text(text: &str) -> bool {
|
||||
CONTEXTUAL_USER_FRAGMENTS
|
||||
.iter()
|
||||
.any(|definition| definition.matches_text(text))
|
||||
@@ -118,6 +117,40 @@ pub(crate) fn is_memory_excluded_contextual_user_fragment(content_item: &Content
|
||||
AGENTS_MD_FRAGMENT.matches_text(text) || SKILL_FRAGMENT.matches_text(text)
|
||||
}
|
||||
|
||||
pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
parse_hook_prompt_fragment(text).is_some() || is_standard_contextual_user_text(text)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_visible_hook_prompt_message(
|
||||
id: Option<&String>,
|
||||
content: &[ContentItem],
|
||||
) -> Option<HookPromptItem> {
|
||||
let mut fragments = Vec::new();
|
||||
|
||||
for content_item in content {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return None;
|
||||
};
|
||||
if let Some(fragment) = parse_hook_prompt_fragment(text) {
|
||||
fragments.push(fragment);
|
||||
continue;
|
||||
}
|
||||
if is_standard_contextual_user_text(text) {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if fragments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HookPromptItem::from_fragments(id, fragments))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "contextual_user_message_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
@@ -61,3 +63,36 @@ fn classifies_memory_excluded_fragments() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_hook_prompt_fragment_and_roundtrips_escaping() {
|
||||
let message = build_hook_prompt_message(&[HookPromptFragment::from_single_hook(
|
||||
r#"Retry with "waves" & <tides>"#,
|
||||
"hook-run-1",
|
||||
)])
|
||||
.expect("hook prompt message");
|
||||
|
||||
let ResponseItem::Message { content, .. } = message else {
|
||||
panic!("expected hook prompt response item");
|
||||
};
|
||||
|
||||
let [content_item] = content.as_slice() else {
|
||||
panic!("expected a single content item");
|
||||
};
|
||||
|
||||
assert!(is_contextual_user_fragment(content_item));
|
||||
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
panic!("expected input text content item");
|
||||
};
|
||||
let parsed =
|
||||
parse_visible_hook_prompt_message(None, content.as_slice()).expect("visible hook prompt");
|
||||
assert_eq!(
|
||||
parsed.fragments,
|
||||
vec![HookPromptFragment {
|
||||
text: r#"Retry with "waves" & <tides>"#.to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
}],
|
||||
);
|
||||
assert!(!text.contains(""waves" & <tides>"));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::contextual_user_message::is_contextual_user_fragment;
|
||||
use crate::contextual_user_message::parse_visible_hook_prompt_message;
|
||||
use crate::web_search::web_search_action_detail;
|
||||
|
||||
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
|
||||
@@ -100,7 +101,9 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
|
||||
phase,
|
||||
..
|
||||
} => match role.as_str() {
|
||||
"user" => parse_user_message(content).map(TurnItem::UserMessage),
|
||||
"user" => parse_visible_hook_prompt_message(id.as_ref(), content)
|
||||
.map(TurnItem::HookPrompt)
|
||||
.or_else(|| parse_user_message(content).map(TurnItem::UserMessage)),
|
||||
"assistant" => Some(TurnItem::AgentMessage(parse_agent_message(
|
||||
id.as_ref(),
|
||||
content,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::parse_turn_item;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
@@ -208,6 +210,67 @@ fn skips_user_instructions_and_env() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hook_prompt_message_as_distinct_turn_item() {
|
||||
let item = build_hook_prompt_message(&[HookPromptFragment::from_single_hook(
|
||||
"Retry with exactly the phrase meow meow meow.",
|
||||
"hook-run-1",
|
||||
)])
|
||||
.expect("hook prompt message");
|
||||
|
||||
let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item");
|
||||
|
||||
match turn_item {
|
||||
TurnItem::HookPrompt(hook_prompt) => {
|
||||
assert_eq!(hook_prompt.fragments.len(), 1);
|
||||
assert_eq!(
|
||||
hook_prompt.fragments[0],
|
||||
HookPromptFragment {
|
||||
text: "Retry with exactly the phrase meow meow meow.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
other => panic!("expected TurnItem::HookPrompt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hook_prompt_and_hides_other_contextual_fragments() {
|
||||
let item = ResponseItem::Message {
|
||||
id: Some("msg-1".to_string()),
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "<environment_context>ctx</environment_context>".to_string(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text:
|
||||
"<hook_prompt hook_run_id=\"hook-run-1\">Retry with care & joy.</hook_prompt>"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
|
||||
let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item");
|
||||
|
||||
match turn_item {
|
||||
TurnItem::HookPrompt(hook_prompt) => {
|
||||
assert_eq!(hook_prompt.id, "msg-1");
|
||||
assert_eq!(
|
||||
hook_prompt.fragments,
|
||||
vec![HookPromptFragment {
|
||||
text: "Retry with care & joy.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
other => panic!("expected TurnItem::HookPrompt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_message() {
|
||||
let item = ResponseItem::Message {
|
||||
|
||||
Reference in New Issue
Block a user