core: load aggregated hook-backed user instructions

This commit is contained in:
Adam Perry
2026-07-07 01:04:18 +00:00
parent e9e1a2922c
commit 3a16fc6c19
12 changed files with 757 additions and 60 deletions
+6 -2
View File
@@ -33,6 +33,9 @@ use std::io;
use toml::Value as TomlValue;
use tracing::error;
mod hook_loading;
pub(crate) use hook_loading::resolve_user_instructions;
/// Default filename scanned for AGENTS.md instructions.
pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
/// Preferred local override for AGENTS.md instructions.
@@ -255,7 +258,7 @@ impl LoadedAgentsMd {
Self {
user_instructions: Some(UserInstructions {
text: contents,
source: path,
sources: vec![path],
}),
entries: Vec::new(),
}
@@ -396,7 +399,8 @@ impl LoadedAgentsMd {
pub fn sources(&self) -> impl Iterator<Item = PathUri> + '_ {
self.user_instructions
.iter()
.map(|instructions| PathUri::from_abs_path(&instructions.source))
.flat_map(|instructions| instructions.sources.iter())
.map(PathUri::from_abs_path)
.chain(
self.entries
.iter()
@@ -0,0 +1,93 @@
use codex_extension_api::UserInstructions;
use codex_hooks::Hooks;
use codex_hooks::UserInstructionsRequest;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::WarningEvent;
use crate::session::INITIAL_SUBMIT_ID;
pub(crate) struct UserInstructionsResolution {
pub instructions: Option<UserInstructions>,
pub events: Vec<Event>,
}
/// Resolves a fresh user-instruction snapshot from the configured hook.
pub(crate) async fn resolve_user_instructions(
hooks: &Hooks,
request: UserInstructionsRequest,
mut instructions: Option<UserInstructions>,
mut on_completed: impl FnMut(&HookCompletedEvent),
) -> UserInstructionsResolution {
let mut events = hooks
.preview_user_instructions(&request)
.into_iter()
.map(|run| {
event(EventMsg::HookStarted(HookStartedEvent {
turn_id: None,
run,
}))
})
.collect::<Vec<_>>();
let outcome = hooks.run_user_instructions(request).await;
for completed in outcome.hook_events {
on_completed(&completed);
events.push(event(EventMsg::HookCompleted(completed)));
}
events.extend(
outcome
.warnings
.into_iter()
.map(|message| event(EventMsg::Warning(WarningEvent { message }))),
);
let mut texts = Vec::new();
let mut sources = Vec::new();
for result in outcome.results {
match result.source_path.to_abs_path() {
Ok(source) => {
texts.push(result.text);
if !sources.contains(&source) {
sources.push(source);
}
}
Err(error) => events.push(event(EventMsg::Warning(WarningEvent {
message: format!(
"UserInstructions hook source `{}` cannot be resolved on this host: {error}; ignoring hook output",
result.source_path
),
}))),
}
}
if !texts.is_empty() {
if let Some(existing) = &instructions
&& let Some(source) = existing.sources.first()
{
events.push(event(EventMsg::Warning(WarningEvent {
message: format!(
"UserInstructions hook output overrides user-level instructions from `{}`.",
source.display()
),
})));
}
instructions = Some(UserInstructions {
text: texts.join("\n\n"),
sources,
});
}
UserInstructionsResolution {
instructions,
events,
}
}
fn event(msg: EventMsg) -> Event {
Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg,
}
}
+4 -4
View File
@@ -426,7 +426,7 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -
let user_instructions = instructions.map(|text| UserInstructions {
text: text.to_owned(),
source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
sources: vec![config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME)],
});
TestConfig {
config,
@@ -475,7 +475,7 @@ async fn make_config_with_project_root_markers(
config.project_doc_max_bytes = limit;
let user_instructions = instructions.map(|text| UserInstructions {
text: text.to_owned(),
source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
sources: vec![config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME)],
});
TestConfig {
config,
@@ -944,7 +944,7 @@ secondary doc"#,
.user_instructions
.as_ref()
.expect("global instructions")
.source,
.sources[0],
),
PathUri::from_abs_path(&primary.path().join("AGENTS.md").abs()),
PathUri::from_abs_path(&primary_nested.join("AGENTS.md").abs()),
@@ -1278,7 +1278,7 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
let expected = LoadedAgentsMd {
user_instructions: Some(UserInstructions {
text: "global doc".to_string(),
source: global_agents.clone(),
sources: vec![global_agents.clone()],
}),
entries: vec![InstructionEntry {
contents: "project doc".to_string(),
+62 -23
View File
@@ -2,6 +2,7 @@ use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::CompactionTrigger;
use codex_analytics::HookRunFact;
use codex_analytics::build_track_events_context;
@@ -21,6 +22,8 @@ use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
use codex_otel::HOOK_RUN_METRIC;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::ResponseItem;
@@ -133,7 +136,7 @@ pub(crate) async fn run_pending_session_start_hooks(
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),
permission_mode: hook_permission_mode(turn_context.approval_policy.value()),
target,
};
let hooks = sess.hooks();
@@ -175,7 +178,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
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),
permission_mode: hook_permission_mode(turn_context.approval_policy.value()),
tool_name: tool_name.name().to_string(),
matcher_aliases: tool_name.matcher_aliases().to_vec(),
tool_use_id,
@@ -236,7 +239,7 @@ pub(crate) async fn run_permission_request_hooks(
cwd: turn_context.cwd.to_path_buf(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
permission_mode: hook_permission_mode(turn_context.approval_policy.value()),
tool_name: payload.tool_name.name().to_string(),
matcher_aliases: payload.tool_name.matcher_aliases().to_vec(),
run_id_suffix: run_id_suffix.to_string(),
@@ -278,7 +281,7 @@ pub(crate) async fn run_post_tool_use_hooks(
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),
permission_mode: hook_permission_mode(turn_context.approval_policy.value()),
tool_name,
matcher_aliases,
tool_use_id,
@@ -352,7 +355,7 @@ pub(crate) async fn run_turn_stop_hooks(
cwd: turn_context.cwd.clone(),
transcript_path,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
permission_mode: hook_permission_mode(turn_context.approval_policy.value()),
stop_hook_active,
last_assistant_message,
target,
@@ -512,7 +515,7 @@ pub(crate) async fn inspect_pending_input(
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),
permission_mode: hook_permission_mode(turn_context.approval_policy.value()),
prompt: UserMessageItem::new(content).message(),
};
let hooks = sess.hooks();
@@ -637,22 +640,47 @@ pub(crate) async fn emit_hook_completed_events(
completed_events: Vec<HookCompletedEvent>,
) {
for completed in completed_events {
emit_hook_completed_metrics(turn_context, &completed);
emit_hook_completed_metrics(&turn_context.session_telemetry, &completed);
track_hook_completed_analytics(sess, turn_context, &completed);
sess.send_event(turn_context, EventMsg::HookCompleted(completed))
.await;
}
}
fn emit_hook_completed_metrics(turn_context: &TurnContext, completed: &HookCompletedEvent) {
/// Records a thread-scoped hook completion before a [`TurnContext`] exists.
///
/// Ordinary hooks flow through [`emit_hook_completed_events`]. Instruction
/// resolution happens earlier, so it needs the same metrics and analytics
/// without routing an unrelated queued event through the startup event loop.
pub(crate) fn record_hook_completed_without_turn_context(
session_telemetry: &SessionTelemetry,
analytics_events_client: &AnalyticsEventsClient,
thread_id: ThreadId,
model_slug: &str,
originator: &str,
completed: &HookCompletedEvent,
) {
emit_hook_completed_metrics(session_telemetry, completed);
let (tracking, hook) = hook_run_analytics_payload_from_parts(
model_slug.to_string(),
thread_id.to_string(),
completed.turn_id.clone().unwrap_or_default(),
originator.to_string(),
completed,
);
analytics_events_client.track_hook_run(tracking, hook);
}
fn emit_hook_completed_metrics(
session_telemetry: &SessionTelemetry,
completed: &HookCompletedEvent,
) {
let tags = hook_run_metric_tags(&completed.run);
turn_context
.session_telemetry
.counter(HOOK_RUN_METRIC, /*inc*/ 1, &tags);
session_telemetry.counter(HOOK_RUN_METRIC, /*inc*/ 1, &tags);
if let Some(duration_ms) = completed.run.duration_ms
&& let Ok(duration_ms) = u64::try_from(duration_ms)
{
turn_context.session_telemetry.record_duration(
session_telemetry.record_duration(
HOOK_RUN_DURATION_METRIC,
Duration::from_millis(duration_ms),
&tags,
@@ -676,17 +704,28 @@ fn hook_run_analytics_payload(
thread_id: String,
turn_context: &TurnContext,
completed: &HookCompletedEvent,
) -> (codex_analytics::TrackEventsContext, HookRunFact) {
hook_run_analytics_payload_from_parts(
turn_context.model_info.slug.clone(),
thread_id,
completed
.turn_id
.clone()
.unwrap_or_else(|| turn_context.sub_id.clone()),
turn_context.originator.clone(),
completed,
)
}
fn hook_run_analytics_payload_from_parts(
model_slug: String,
thread_id: String,
turn_id: String,
originator: String,
completed: &HookCompletedEvent,
) -> (codex_analytics::TrackEventsContext, HookRunFact) {
(
build_track_events_context(
turn_context.model_info.slug.clone(),
thread_id,
completed
.turn_id
.clone()
.unwrap_or_else(|| turn_context.sub_id.clone()),
turn_context.originator.clone(),
),
build_track_events_context(model_slug, thread_id, turn_id, originator),
HookRunFact {
event_name: completed.run.event_name,
hook_source: completed.run.source,
@@ -737,8 +776,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
]
}
fn hook_permission_mode(turn_context: &TurnContext) -> String {
match turn_context.approval_policy.value() {
pub(crate) fn hook_permission_mode(approval_policy: AskForApproval) -> String {
match approval_policy {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted | AskForApproval::OnRequest | AskForApproval::Granular(_) => {
"default"
+59 -24
View File
@@ -1,9 +1,12 @@
use super::input_queue::InputQueue;
use super::*;
use crate::agents_md::resolve_user_instructions;
use crate::agents_md_manager::AgentsMdManager;
use crate::config::ConstraintError;
use crate::environment_selection::ThreadEnvironments;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::hook_runtime::hook_permission_mode;
use crate::hook_runtime::record_hook_completed_without_turn_context;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillError;
use crate::state::ActiveTurn;
@@ -480,7 +483,7 @@ impl Session {
pub(crate) async fn new(
mut session_configuration: SessionConfiguration,
config: Arc<Config>,
user_instructions: Option<codex_extension_api::UserInstructions>,
mut user_instructions: Option<codex_extension_api::UserInstructions>,
installation_id: String,
auth_manager: Arc<AuthManager>,
models_manager: SharedModelsManager,
@@ -819,6 +822,13 @@ impl Session {
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
session_telemetry = session_telemetry.with_metrics_service_name(service_name);
}
let analytics_events_client = analytics_events_client.unwrap_or_else(|| {
AnalyticsEventsClient::new(
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
config.analytics_enabled,
)
});
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(thread_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
@@ -899,6 +909,54 @@ impl Session {
));
turn_environments.update_selections(session_configuration.environment_selections());
let resolved_environments = turn_environments.snapshot().await;
let hooks = build_hooks_for_config(
&config,
plugins_manager.as_ref(),
resolved_environments.single_local_environment(),
)
.await;
for warning in hooks.startup_warnings() {
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::Warning(WarningEvent {
message: warning.clone(),
}),
});
}
validate_config_lock_if_configured(&session_configuration).await?;
let model = session_configuration.collaboration_mode.model().to_string();
let originator = session_configuration.originator.clone();
let request = codex_hooks::UserInstructionsRequest {
session_id: session_id.into(),
cwd: PathUri::from_abs_path(session_configuration.cwd()),
command_cwd: resolved_environments
.single_local_environment_cwd()
.unwrap_or_else(|| config.codex_home.clone()),
transcript_path: rollout_path.clone(),
model: model.clone(),
permission_mode: hook_permission_mode(
session_configuration.approval_policy.value(),
),
};
let resolution = resolve_user_instructions(
&hooks,
request,
user_instructions,
|completed| {
record_hook_completed_without_turn_context(
&session_telemetry,
&analytics_events_client,
thread_id,
&model,
&originator,
completed,
);
},
)
.await;
user_instructions = resolution.instructions;
post_session_configured_events.extend(resolution.events);
let agents_md_manager = Arc::new(AgentsMdManager::new(user_instructions));
agents_md_manager
.refresh(config.as_ref(), &resolved_environments)
@@ -929,7 +987,6 @@ impl Session {
))
.await;
session_configuration.thread_name = thread_name.clone();
validate_config_lock_if_configured(&session_configuration).await?;
export_config_lock_if_configured(&session_configuration, thread_id).await?;
let state = SessionState::new_with_auto_compact_window_ids(
session_configuration.clone(),
@@ -994,28 +1051,6 @@ impl Session {
(None, None)
};
let hooks = build_hooks_for_config(
&config,
plugins_manager.as_ref(),
resolved_environments.single_local_environment(),
)
.await;
for warning in hooks.startup_warnings() {
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::Warning(WarningEvent {
message: warning.clone(),
}),
});
}
let analytics_events_client = analytics_events_client.unwrap_or_else(|| {
AnalyticsEventsClient::new(
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
config.analytics_enabled,
)
});
// Keep one stable manager handle for the session so extension resource clients
// automatically observe the manager installed at startup and on later refreshes.
let mcp_connection_manager = Arc::new(arc_swap::ArcSwap::from_pointee(