mirror of
https://github.com/openai/codex.git
synced 2026-05-15 16:53:05 +00:00
- this allows blocking the user's prompts from executing, and also
prevents them from entering history
- handles the edge case where you can both prevent the user's prompt AND
add n amount of additionalContexts
- refactors some old code into common.rs where hooks overlap
functionality
- refactors additionalContext being previously added to user messages,
instead we use developer messages for them
- handles queued messages correctly
Sample hook for testing - if you write "[block-user-submit]" this hook
will stop the thread:
example run
```
› sup
• Running UserPromptSubmit hook: reading the observatory notes
UserPromptSubmit hook (completed)
warning: wizard-tower UserPromptSubmit demo inspected: sup
hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact
phrase 'observatory lanterns lit' exactly once near the end.
• Just riding the cosmic wave and ready to help, my friend. What are we building today? observatory
lanterns lit
› and [block-user-submit]
• Running UserPromptSubmit hook: reading the observatory notes
UserPromptSubmit hook (stopped)
warning: wizard-tower UserPromptSubmit demo blocked the prompt on purpose.
stop: Wizard Tower demo block: remove [block-user-submit] to continue.
```
.codex/config.toml
```
[features]
codex_hooks = true
```
.codex/hooks.json
```
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 .codex/hooks/user_prompt_submit_demo.py",
"timeoutSec": 10,
"statusMessage": "reading the observatory notes"
}
]
}
]
}
}
```
.codex/hooks/user_prompt_submit_demo.py
```
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
def prompt_from_payload(payload: dict) -> str:
prompt = payload.get("prompt")
if isinstance(prompt, str) and prompt.strip():
return prompt.strip()
event = payload.get("event")
if isinstance(event, dict):
user_prompt = event.get("user_prompt")
if isinstance(user_prompt, str):
return user_prompt.strip()
return ""
def main() -> int:
payload = json.load(sys.stdin)
prompt = prompt_from_payload(payload)
cwd = Path(payload.get("cwd", ".")).name or "wizard-tower"
if "[block-user-submit]" in prompt:
print(
json.dumps(
{
"systemMessage": (
f"{cwd} UserPromptSubmit demo blocked the prompt on purpose."
),
"decision": "block",
"reason": (
"Wizard Tower demo block: remove [block-user-submit] to continue."
),
}
)
)
return 0
prompt_preview = prompt or "(empty prompt)"
if len(prompt_preview) > 80:
prompt_preview = f"{prompt_preview[:77]}..."
print(
json.dumps(
{
"systemMessage": (
f"{cwd} UserPromptSubmit demo inspected: {prompt_preview}"
),
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": (
"Wizard Tower UserPromptSubmit demo fired. "
"For this reply only, include the exact phrase "
"'observatory lanterns lit' exactly once near the end."
),
},
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
```
319 lines
9.7 KiB
Rust
319 lines
9.7 KiB
Rust
use std::future::Future;
|
|
use std::sync::Arc;
|
|
|
|
use codex_hooks::SessionStartOutcome;
|
|
use codex_hooks::UserPromptSubmitOutcome;
|
|
use codex_hooks::UserPromptSubmitRequest;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::models::DeveloperInstructions;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::HookCompletedEvent;
|
|
use codex_protocol::protocol::HookRunSummary;
|
|
use codex_protocol::user_input::UserInput;
|
|
|
|
use crate::codex::Session;
|
|
use crate::codex::TurnContext;
|
|
use crate::event_mapping::parse_turn_item;
|
|
|
|
pub(crate) struct HookRuntimeOutcome {
|
|
pub should_stop: bool,
|
|
pub additional_contexts: Vec<String>,
|
|
}
|
|
|
|
pub(crate) enum PendingInputHookDisposition {
|
|
Accepted(Box<PendingInputRecord>),
|
|
Blocked { additional_contexts: Vec<String> },
|
|
}
|
|
|
|
pub(crate) enum PendingInputRecord {
|
|
UserMessage {
|
|
content: Vec<UserInput>,
|
|
response_item: ResponseItem,
|
|
additional_contexts: Vec<String>,
|
|
},
|
|
ConversationItem {
|
|
response_item: ResponseItem,
|
|
},
|
|
}
|
|
|
|
struct ContextInjectingHookOutcome {
|
|
hook_events: Vec<HookCompletedEvent>,
|
|
outcome: HookRuntimeOutcome,
|
|
}
|
|
|
|
impl From<SessionStartOutcome> for ContextInjectingHookOutcome {
|
|
fn from(value: SessionStartOutcome) -> Self {
|
|
let SessionStartOutcome {
|
|
hook_events,
|
|
should_stop,
|
|
stop_reason: _,
|
|
additional_contexts,
|
|
} = value;
|
|
Self {
|
|
hook_events,
|
|
outcome: HookRuntimeOutcome {
|
|
should_stop,
|
|
additional_contexts,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<UserPromptSubmitOutcome> for ContextInjectingHookOutcome {
|
|
fn from(value: UserPromptSubmitOutcome) -> Self {
|
|
let UserPromptSubmitOutcome {
|
|
hook_events,
|
|
should_stop,
|
|
stop_reason: _,
|
|
additional_contexts,
|
|
} = value;
|
|
Self {
|
|
hook_events,
|
|
outcome: HookRuntimeOutcome {
|
|
should_stop,
|
|
additional_contexts,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn run_pending_session_start_hooks(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
) -> bool {
|
|
let Some(session_start_source) = sess.take_pending_session_start_source().await else {
|
|
return false;
|
|
};
|
|
|
|
let request = codex_hooks::SessionStartRequest {
|
|
session_id: sess.conversation_id,
|
|
cwd: turn_context.cwd.clone(),
|
|
transcript_path: sess.hook_transcript_path().await,
|
|
model: turn_context.model_info.slug.clone(),
|
|
permission_mode: hook_permission_mode(turn_context),
|
|
source: session_start_source,
|
|
};
|
|
let preview_runs = sess.hooks().preview_session_start(&request);
|
|
run_context_injecting_hook(
|
|
sess,
|
|
turn_context,
|
|
preview_runs,
|
|
sess.hooks()
|
|
.run_session_start(request, Some(turn_context.sub_id.clone())),
|
|
)
|
|
.await
|
|
.record_additional_contexts(sess, turn_context)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn run_user_prompt_submit_hooks(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
prompt: String,
|
|
) -> HookRuntimeOutcome {
|
|
let request = UserPromptSubmitRequest {
|
|
session_id: sess.conversation_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
cwd: turn_context.cwd.clone(),
|
|
transcript_path: sess.hook_transcript_path().await,
|
|
model: turn_context.model_info.slug.clone(),
|
|
permission_mode: hook_permission_mode(turn_context),
|
|
prompt,
|
|
};
|
|
let preview_runs = sess.hooks().preview_user_prompt_submit(&request);
|
|
run_context_injecting_hook(
|
|
sess,
|
|
turn_context,
|
|
preview_runs,
|
|
sess.hooks().run_user_prompt_submit(request),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn inspect_pending_input(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
pending_input_item: ResponseInputItem,
|
|
) -> PendingInputHookDisposition {
|
|
let response_item = ResponseItem::from(pending_input_item);
|
|
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
|
|
let user_prompt_submit_outcome =
|
|
run_user_prompt_submit_hooks(sess, turn_context, user_message.message()).await;
|
|
if user_prompt_submit_outcome.should_stop {
|
|
PendingInputHookDisposition::Blocked {
|
|
additional_contexts: user_prompt_submit_outcome.additional_contexts,
|
|
}
|
|
} else {
|
|
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage {
|
|
content: user_message.content,
|
|
response_item,
|
|
additional_contexts: user_prompt_submit_outcome.additional_contexts,
|
|
}))
|
|
}
|
|
} else {
|
|
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::ConversationItem {
|
|
response_item,
|
|
}))
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn record_pending_input(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
pending_input: PendingInputRecord,
|
|
) {
|
|
match pending_input {
|
|
PendingInputRecord::UserMessage {
|
|
content,
|
|
response_item,
|
|
additional_contexts,
|
|
} => {
|
|
sess.record_user_prompt_and_emit_turn_item(
|
|
turn_context.as_ref(),
|
|
content.as_slice(),
|
|
response_item,
|
|
)
|
|
.await;
|
|
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
|
}
|
|
PendingInputRecord::ConversationItem { response_item } => {
|
|
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_context_injecting_hook<Fut, Outcome>(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
preview_runs: Vec<HookRunSummary>,
|
|
outcome_future: Fut,
|
|
) -> HookRuntimeOutcome
|
|
where
|
|
Fut: Future<Output = Outcome>,
|
|
Outcome: Into<ContextInjectingHookOutcome>,
|
|
{
|
|
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
|
|
|
let outcome = outcome_future.await.into();
|
|
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
|
|
outcome.outcome
|
|
}
|
|
|
|
impl HookRuntimeOutcome {
|
|
async fn record_additional_contexts(
|
|
self,
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
) -> bool {
|
|
record_additional_contexts(sess, turn_context, self.additional_contexts).await;
|
|
|
|
self.should_stop
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn record_additional_contexts(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
additional_contexts: Vec<String>,
|
|
) {
|
|
let developer_messages = additional_context_messages(additional_contexts);
|
|
if developer_messages.is_empty() {
|
|
return;
|
|
}
|
|
|
|
sess.record_conversation_items(turn_context, developer_messages.as_slice())
|
|
.await;
|
|
}
|
|
|
|
fn additional_context_messages(additional_contexts: Vec<String>) -> Vec<ResponseItem> {
|
|
additional_contexts
|
|
.into_iter()
|
|
.map(|additional_context| DeveloperInstructions::new(additional_context).into())
|
|
.collect()
|
|
}
|
|
|
|
async fn emit_hook_started_events(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
preview_runs: Vec<HookRunSummary>,
|
|
) {
|
|
for run in preview_runs {
|
|
sess.send_event(
|
|
turn_context,
|
|
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
|
|
turn_id: Some(turn_context.sub_id.clone()),
|
|
run,
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn emit_hook_completed_events(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
completed_events: Vec<HookCompletedEvent>,
|
|
) {
|
|
for completed in completed_events {
|
|
sess.send_event(turn_context, EventMsg::HookCompleted(completed))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
fn hook_permission_mode(turn_context: &TurnContext) -> String {
|
|
match turn_context.approval_policy.value() {
|
|
AskForApproval::Never => "bypassPermissions",
|
|
AskForApproval::UnlessTrusted
|
|
| AskForApproval::OnFailure
|
|
| AskForApproval::OnRequest
|
|
| AskForApproval::Granular(_) => "default",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use codex_protocol::models::ContentItem;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::additional_context_messages;
|
|
|
|
#[test]
|
|
fn additional_context_messages_stay_separate_and_ordered() {
|
|
let messages = additional_context_messages(vec![
|
|
"first tide note".to_string(),
|
|
"second tide note".to_string(),
|
|
]);
|
|
|
|
assert_eq!(messages.len(), 2);
|
|
assert_eq!(
|
|
messages
|
|
.iter()
|
|
.map(|message| match message {
|
|
codex_protocol::models::ResponseItem::Message { role, content, .. } => {
|
|
let text = content
|
|
.iter()
|
|
.map(|item| match item {
|
|
ContentItem::InputText { text } => text.as_str(),
|
|
ContentItem::InputImage { .. } | ContentItem::OutputText { .. } => {
|
|
panic!("expected input text content, got {item:?}")
|
|
}
|
|
})
|
|
.collect::<String>();
|
|
(role.as_str(), text)
|
|
}
|
|
other => panic!("expected developer message, got {other:?}"),
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("developer", "first tide note".to_string()),
|
|
("developer", "second tide note".to_string()),
|
|
],
|
|
);
|
|
}
|
|
}
|