Attribute command and plugin analytics to the invoking model (#45445)

## Why

Command execution and plugin measurement events lack model and reasoning-effort labels. Attribution needs to reflect the step that invoked the command, even when model settings change before a background process finishes.

## What changed

- Add `model_slug` and `reasoning_effort` to command execution and plugin measurement analytics.
- Capture model context from resolved step settings and carry it through execution, approval, Guardian review, and plugin metrics collection.
- Preserve the first command-start model context when subsequent start notifications arrive.
- Keep the carried context out of serialized protocol items and generated schemas.

## Testing

Extend analytics tests to cover model switches before invocation and during background execution, default reasoning effort, Guardian-denied commands, and repeated start notifications retaining the original model context.

GitOrigin-RevId: af90e1c0d39bab625f2e89786085b61a9b96c0ce
This commit is contained in:
jwang-openai
2026-09-14 15:30:22 +00:00
committed by copyberry
parent b6a5d5bb14
commit 4d8eca1ff3
40 changed files with 355 additions and 43 deletions
+13
View File
@@ -20,6 +20,7 @@ mod review_session;
mod reviewer_config;
mod runtime;
use codex_protocol::items::ModelInvocationContext;
use std::sync::Arc;
use codex_protocol::config_types::ApprovalsReviewer;
@@ -82,6 +83,7 @@ pub(crate) struct GuardianReviewContext {
pub(crate) parent_response_id: Option<String>,
turn: Arc<TurnContext>,
environments: TurnEnvironmentSnapshot,
// Model and reasoning inputs are carried for the follow-up Guardian and V2 migrations.
pub(crate) model_info: Arc<ModelInfo>,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) reasoning_summary: ReasoningSummary,
@@ -91,6 +93,17 @@ pub(crate) struct GuardianReviewContext {
}
impl GuardianReviewContext {
pub(crate) fn model_context(&self) -> ModelInvocationContext {
ModelInvocationContext {
model_slug: self.model_info.slug.clone(),
reasoning_effort: self
.reasoning_effort
.as_ref()
.or(self.model_info.default_reasoning_level.as_ref())
.map(ToString::to_string),
}
}
pub(crate) fn from_resolved_settings(
turn: Arc<TurnContext>,
settings: &ResolvedStepSettings,
@@ -44,6 +44,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
Ok(request) => request.clone(),
Err(decision) => return Err(decision),
};
let model_context = context.model_context();
let turn = Arc::clone(context.turn());
let GuardianReviewOptions {
plugin_attribution_override,
@@ -101,6 +102,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
reviewed_action: guardian_reviewed_action(&request),
action: guardian_assessment_action(&request),
review_reason,
model_context,
});
session
.send_event(
+4
View File
@@ -4,6 +4,7 @@ use crate::tools::sandboxing::ToolCtx;
use codex_analytics::PluginMeasurementsInput;
use codex_core_plugins::PluginMetricsSidecar;
use codex_exec_server::Environment;
use codex_protocol::items::ModelInvocationContext;
use codex_utils_path_uri::PathUri;
/// Creates a metrics sidecar for one plugin command.
@@ -34,6 +35,7 @@ pub(crate) async fn finish_and_track_measurements(
exit_code: i32,
session: &Session,
turn: &TurnContext,
model_context: &ModelInvocationContext,
item_id: &str,
) {
let Some(metrics_sidecar) = metrics_sidecar else {
@@ -50,6 +52,8 @@ pub(crate) async fn finish_and_track_measurements(
turn_id: turn.sub_id.clone(),
item_id: item_id.to_string(),
originator: turn.originator.clone(),
model_slug: Some(model_context.model_slug.clone()),
reasoning_effort: model_context.reasoning_effort.clone(),
plugin_id: batch.plugin_id,
execution_id: batch.execution_id,
operation: batch.operation,
@@ -52,6 +52,8 @@ async fn command_approval_holds_an_elicitation_until_response() {
.request_command_approval(
turn_context.as_ref(),
ExecApprovalKind::Command,
crate::guardian::GuardianReviewContext::from(turn_context.clone())
.model_context(),
"call-1".to_string(),
/*approval_id*/ None,
/*environment_id*/ None,
+4 -1
View File
@@ -1,4 +1,3 @@
use crate::context::GuardianContextMode;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -23,6 +22,7 @@ use crate::config::ManagedFeatures;
use crate::config::resolve_tool_suggest_config_from_layer_stack;
use crate::context::ContextualUserFragment;
use crate::context::DeveloperInstructions;
use crate::context::GuardianContextMode;
use crate::context::GuardianPolicy;
use crate::context::ManagedDeveloperInstructions;
use crate::context::ModelSwitchInstructions;
@@ -107,6 +107,7 @@ use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::EnteredReviewModeItem;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
@@ -2790,6 +2791,7 @@ impl Session {
&self,
turn_context: &TurnContext,
kind: ExecApprovalKind,
model_context: ModelInvocationContext,
call_id: String,
approval_id: Option<String>,
environment_id: Option<String>,
@@ -2853,6 +2855,7 @@ impl Session {
.map(PluginCommandAttribution::serialized_fields)
.unzip();
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
model_context: Some(model_context),
kind,
call_id,
plugin_id,
+11
View File
@@ -12,6 +12,7 @@ use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::McpBinding;
use codex_otel::SessionTelemetry;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::protocol::TurnContextItem;
/// Request-scoped state that may change between model sampling requests.
@@ -43,4 +44,14 @@ impl StepContext {
item.summary = self.settings.reasoning_summary;
item
}
pub(crate) fn model_context(&self) -> ModelInvocationContext {
ModelInvocationContext {
model_slug: self.settings.model_info.slug.clone(),
reasoning_effort: self
.settings
.effective_reasoning_effort()
.map(|effort| effort.to_string()),
}
}
}
+4
View File
@@ -188,6 +188,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_started(
turn_context.as_ref(),
&TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id.clone(),
plugin_id: None,
script_path: None,
@@ -271,6 +272,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_completed(
turn_context.as_ref(),
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id,
plugin_id: None,
script_path: None,
@@ -296,6 +298,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_completed(
turn_context.as_ref(),
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id.clone(),
plugin_id: None,
script_path: None,
@@ -341,6 +344,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_completed(
turn_context.as_ref(),
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id,
plugin_id: None,
script_path: None,
+4
View File
@@ -701,6 +701,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.review_context.model_context(),
ctx.call_id.clone(),
/*approval_id*/ None,
Some(environment_id.clone()),
@@ -730,6 +731,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::WriteStdin,
ctx.review_context.model_context(),
id.clone(),
Some(approval_id.clone()),
Some(environment_id.clone()),
@@ -761,6 +763,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.review_context.model_context(),
ctx.call_id.clone(),
Some(approval_id.clone()),
Some(environment_id.clone()),
@@ -833,6 +836,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.review_context.model_context(),
ctx.call_id.clone(),
/*approval_id*/ None,
Some(environment_id.clone()),
+5
View File
@@ -17,6 +17,7 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::items::CommandExecutionItem;
use codex_protocol::items::CommandExecutionStatus;
use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::parse_command::ParsedCommand;
@@ -43,6 +44,7 @@ pub(super) fn truncate_rejection_message(message: &str) -> String {
#[derive(Clone, Copy)]
pub(crate) struct ToolEventCtx<'a> {
pub model_context: Option<&'a ModelInvocationContext>,
pub session: &'a Session,
pub turn: &'a TurnContext,
/// Model captured by the step that issued this call, including delayed completion events.
@@ -60,6 +62,7 @@ impl<'a> ToolEventCtx<'a> {
turn_diff_tracker: Option<&'a SharedTurnDiffTracker>,
) -> Self {
Self {
model_context: None,
session,
turn,
model_info,
@@ -168,6 +171,7 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand
ctx.turn,
&TurnItem::CommandExecution(CommandExecutionItem {
id: ctx.call_id.to_string(),
model_context: ctx.model_context.cloned(),
plugin_id,
script_path,
process_id: exec_input.process_id.map(str::to_owned),
@@ -581,6 +585,7 @@ async fn emit_exec_end(
ctx.turn,
TurnItem::CommandExecution(CommandExecutionItem {
id: ctx.call_id.to_string(),
model_context: ctx.model_context.cloned(),
plugin_id,
script_path,
process_id: exec_input.process_id.map(str::to_owned),
@@ -178,6 +178,7 @@ pub(crate) fn spawn_exit_watcher(
let session_ref = Arc::clone(&context.session);
let turn_ref = Arc::clone(&context.step_context.turn);
let model_info = Arc::clone(&context.step_context.settings.model_info);
let model_context = context.step_context.model_context();
let call_id = context.call_id.clone();
let exit_token = process.cancellation_token();
let output_drained = process.output_drained_notify();
@@ -223,6 +224,7 @@ pub(crate) fn spawn_exit_watcher(
exit_code,
&session_ref,
&turn_ref,
&model_context,
&call_id,
)
.await;
@@ -276,11 +276,13 @@ struct InitialExecCommandGuard {
impl InitialExecCommandGuard {
async fn finish_plugin_metrics(&mut self, context: &UnifiedExecContext, exit_code: i32) {
let model_context = context.step_context.model_context();
finish_and_track_measurements(
self.metrics_sidecar.take(),
exit_code,
&context.session,
&context.step_context.turn,
&model_context,
&context.call_id,
)
.await;
@@ -530,13 +532,15 @@ impl UnifiedExecProcessManager {
});
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
let event_ctx = ToolEventCtx::new(
let model_context = context.step_context.model_context();
let mut event_ctx = ToolEventCtx::new(
context.session.as_ref(),
context.step_context.turn.as_ref(),
&context.step_context.settings.model_info,
&context.call_id,
/*turn_diff_tracker*/ None,
);
event_ctx.model_context = Some(&model_context);
let plugin_attribution = if request.turn_environment.environment.is_remote() {
let file_system = request.turn_environment.environment.get_filesystem();
context
@@ -727,6 +731,7 @@ impl UnifiedExecProcessManager {
exit_code.unwrap_or(-1),
&context.session,
&context.step_context.turn,
&model_context,
&context.call_id,
)
.await;