Track resource-backed skill invocations (#38066)

## What changed

- Emit skill invocation analytics when a resource-backed skill is explicitly selected or its main resource is successfully read from the first page.
- Preserve provider-supplied skill IDs and user scope when available, and derive a stable ID from the main resource otherwise.
- Deduplicate repeated implicit reads of the same main resource within a turn.

## Testing

- Extend the orchestrator skill integration test to cover explicit and implicit invocation events, stable fallback IDs, failed reads, and repeated-read deduplication.

GitOrigin-RevId: 7d8453b31b5a2205847e65cda2c46183d6c97b45
This commit is contained in:
rphilizaire-openai authored and copyberry committed 2026-08-11 19:52:54 +00:00
1 parent 104e25ac5a
commit c8f673fddc
14 files changed
+311 -71

No files matched your search

+4
View File
@@ -178,6 +178,8 @@ pub struct SkillCatalogEntry {
pub short_description: Option<String>,
pub main_prompt: SkillResourceId,
pub display_path: Option<String>,
pub(crate) canonical_skill_id: Option<String>,
pub(crate) analytics_scope: Option<SkillScope>,
alias_root: Option<String>,
alias_root_order: Option<usize>,
prompt_scope: Option<SkillScope>,
@@ -202,6 +204,8 @@ impl SkillCatalogEntry {
short_description: None,
main_prompt,
display_path: None,
canonical_skill_id: None,
analytics_scope: None,
alias_root: None,
alias_root_order: None,
prompt_scope: None,
+14 -4
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::HostSkillsSnapshot;
use crate::InjectedHostSkillPrompts;
use codex_analytics::InvocationType;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
@@ -65,6 +66,7 @@ use crate::state::HostSkillsStepState;
use crate::state::SkillsSessionState;
use crate::state::SkillsThreadState;
use crate::state::SkillsTurnState;
use crate::tools::SkillAnalytics;
use crate::tools::SkillToolAuthority;
use crate::tools::skill_tools;
use crate::warnings::bounded_warnings;
@@ -437,6 +439,7 @@ where
let mut warnings = catalog.warnings.clone();
let mut main_prompts_injected = false;
let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default();
let analytics = SkillAnalytics::from_stores(session_store, thread_store);
for entry in &selected_entries {
match self
.read_main_prompt(
@@ -483,6 +486,15 @@ where
main_prompts_injected = true;
if entry.authority.kind == SkillSourceKind::Host {
injected_host_skill_prompts.insert_path(entry.main_prompt.as_str());
} else if let Some(analytics) = analytics.as_ref()
&& let Some(model_info) = thread_store.get::<ModelInfo>()
{
analytics.track_skill_invocation(
entry,
model_info.slug.clone(),
input.turn_id.clone(),
InvocationType::Explicit,
);
}
}
Err(message) => {
@@ -548,10 +560,8 @@ impl<C> SkillsExtension<C> {
skill_tools(
self.providers.clone(),
session_store
.get::<SkillsSessionState>()
.and_then(|state| state.mcp_resources.clone()),
thread_state,
session_store,
thread_store,
orchestrator_available,
executor_query,
sandbox_contexts,
@@ -4,6 +4,7 @@ use std::time::Duration;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceContent;
use codex_protocol::protocol::SkillScope;
use url::Url;
use crate::catalog::SkillAuthority;
@@ -253,7 +254,8 @@ fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry>
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
let skill_name = normalized_label(meta.get("skill_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
let name = if meta.get("source").and_then(|value| value.as_str()) == Some("user") {
let user_owned = meta.get("source").and_then(|value| value.as_str()) == Some("user");
let name = if user_owned {
skill_name
} else {
let plugin_name =
@@ -265,7 +267,7 @@ fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry>
let description = normalized_description(resource.description.as_deref().unwrap_or_default())?;
let main_prompt = main_prompt_uri(uri);
let entry = SkillCatalogEntry::new(
let mut entry = SkillCatalogEntry::new(
SkillPackageId(uri.to_string()),
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME),
name,
@@ -274,6 +276,11 @@ fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry>
)
.with_display_path(uri)
.with_alias_root(format!("skill://{namespace}"));
entry.canonical_skill_id = meta
.get("skill_id")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
entry.analytics_scope = user_owned.then_some(SkillScope::User);
Some(if allow_implicit_invocation {
entry
+72 -2
View File
@@ -4,10 +4,17 @@ use std::hash::Hash;
use std::hash::Hasher;
use std::sync::Arc;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::InvocationType;
use codex_analytics::SkillInvocation;
use codex_analytics::SkillInvocationLocation;
use codex_analytics::build_track_events_context;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::ExtensionData;
use codex_extension_api::FunctionCallError;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ThreadOriginator;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
@@ -27,10 +34,12 @@ use tokio::sync::OnceCell;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::shadow_selection_experiment::ShadowSelectionExperiment;
use crate::sources::SkillProviders;
use crate::state::SkillsSessionState;
use crate::state::SkillsThreadState;
mod list;
@@ -42,17 +51,25 @@ const MAX_HANDLE_BYTES: usize = 2_048;
pub(crate) fn skill_tools(
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
thread_state: Arc<SkillsThreadState>,
session_store: &ExtensionData,
thread_store: &ExtensionData,
orchestrator_available: bool,
executor_query: Option<SkillListQuery>,
sandbox_contexts: Option<Arc<HashMap<String, FileSystemSandboxContext>>>,
shadow_selection: Arc<ShadowSelectionExperiment>,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
let mcp_resources = session_store
.get::<SkillsSessionState>()
.and_then(|state| state.mcp_resources.clone());
let analytics = SkillAnalytics::from_stores(session_store, thread_store);
let context = SkillToolContext {
providers,
mcp_resources,
thread_state,
analytics,
orchestrator_available,
executor_query,
sandbox_contexts,
@@ -67,11 +84,64 @@ pub(crate) fn skill_tools(
]
}
#[derive(Clone)]
pub(crate) struct SkillAnalytics {
client: AnalyticsEventsClient,
thread_id: String,
product_client_id: String,
}
impl SkillAnalytics {
pub(crate) fn from_stores(
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Option<Self> {
let client = session_store.get::<AnalyticsEventsClient>()?;
let originator = thread_store.get::<ThreadOriginator>()?;
Some(Self {
client: client.as_ref().clone(),
thread_id: thread_store.level_id().to_string(),
product_client_id: originator.0.clone(),
})
}
pub(crate) fn track_skill_invocation(
&self,
skill: &SkillCatalogEntry,
model: String,
turn_id: String,
invocation_type: InvocationType,
) {
self.client.track_skill_invocations(
build_track_events_context(
model,
self.thread_id.clone(),
turn_id,
self.product_client_id.clone(),
),
vec![SkillInvocation {
skill_name: skill.name.clone(),
location: SkillInvocationLocation::Resource {
id: skill.main_prompt.as_str().to_string(),
skill_id: skill.canonical_skill_id.clone(),
scope: skill.analytics_scope,
},
// TODO: Include plugin identifiers once skills can be attributed to their plugin.
plugin_id: None,
remote_plugin_id: None,
invocation_type,
}],
);
}
}
#[derive(Clone)]
struct SkillToolContext {
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
thread_state: Arc<SkillsThreadState>,
analytics: Option<SkillAnalytics>,
orchestrator_available: bool,
executor_query: Option<SkillListQuery>,
sandbox_contexts: Option<Arc<HashMap<String, FileSystemSandboxContext>>>,
+16 -1
View File
@@ -1,3 +1,4 @@
use codex_analytics::InvocationType;
use codex_extension_api::FunctionCallError;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
@@ -177,7 +178,21 @@ impl ToolExecutor<ToolCall> for ReadTool {
));
}
let response = page_response(result.resource.as_str(), &result.contents, start)?;
skill_json_output(&response, output_authority)
let output = skill_json_output(&response, output_authority)?;
if requested_resource == main_prompt
&& args.cursor.is_none()
&& let Some(analytics) = self.context.analytics.as_ref()
{
analytics.track_skill_invocation(
&skill_entry,
call.model.clone(),
call.turn_id.clone(),
InvocationType::Implicit,
);
}
Ok(output)
})
}
}