mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## Why Repository skill discovery must reflect current filesystem access, including newly approved grants and narrower permissions. Previously listed skills can remain in conversation history after a turn-scoped grant expires, so catalog updates need to tell the model when availability changes. ## What changed - Discover repository skills through the selected executor's permission-aware filesystem accessor and refresh snapshots when the working directory or access changes. Use the current step's snapshot for skill selection and invocation. - Preserve repository boundaries even when project markers are hidden by read restrictions, and report unsupported sandbox backends as skill-loading errors. - Track grant-dependent catalog entries and emit availability notices when skills disappear or return, even when the metadata budget is exhausted. - Resolve implicit skill paths through their owning filesystem and exclude remote repository skills from local implicit command attribution. ## Testing Add coverage for turn and session grants, grant expiry and restoration, hidden and newly created repository markers, cached read denials on Windows, unsupported remote sandboxes, and executor-backed implicit skill paths. GitOrigin-RevId: ac1af483e73e3130524aeeabe218630435054028
341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
use crate::config::Config;
|
|
use crate::environment_selection::TurnEnvironmentSnapshot;
|
|
use crate::session::session::Session;
|
|
use crate::session::turn_context::TurnContext;
|
|
use crate::session::turn_context::TurnEnvironment;
|
|
use codex_analytics::InvocationType;
|
|
use codex_analytics::SkillInvocation;
|
|
use codex_analytics::SkillInvocationLocation;
|
|
use codex_analytics::TrackEventsContext;
|
|
use codex_analytics::build_track_events_context;
|
|
use codex_core_plugins::PluginsManager;
|
|
use codex_extension_api::SkillInvocationInput;
|
|
use codex_extension_api::SkillInvocationKind;
|
|
use codex_file_system::EnvironmentAccess;
|
|
use codex_file_system::EnvironmentAccessKey;
|
|
use codex_otel::SkillInvocationEvent;
|
|
use codex_otel::SkillInvocationType;
|
|
use codex_otel::sanitize_metric_tag_value;
|
|
use codex_protocol::protocol::SkillScope;
|
|
use codex_skills::SkillMetadata;
|
|
use codex_skills_extension::HostSkillsLoadInput;
|
|
use codex_skills_extension::HostSkillsSnapshot;
|
|
use codex_skills_extension::InjectedHostSkillPrompts;
|
|
use codex_skills_extension::detect_implicit_skill_invocation;
|
|
use codex_skills_extension::record_plugin_turn_usage;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use codex_utils_plugins::PluginSkillRoot;
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ImplicitSkillInvocations(Mutex<HashSet<String>>);
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn skills_load_input_from_config(
|
|
config: &Config,
|
|
effective_skill_roots: Vec<PluginSkillRoot>,
|
|
) -> HostSkillsLoadInput {
|
|
HostSkillsLoadInput::new(
|
|
config.cwd.clone(),
|
|
effective_skill_roots,
|
|
config.config_layer_stack.clone(),
|
|
)
|
|
}
|
|
|
|
/// Pair repository discovery inputs with their captured filesystem owner.
|
|
/// Keep preparation synchronous so it adds no nested startup or resume future.
|
|
pub(crate) fn skills_load_input_for_environments<'a>(
|
|
config: &Config,
|
|
plugins_manager: &PluginsManager,
|
|
effective_skill_roots: Vec<PluginSkillRoot>,
|
|
environments: &'a TurnEnvironmentSnapshot,
|
|
) -> (HostSkillsLoadInput, Option<&'a TurnEnvironment>) {
|
|
// Preserve primary-repository discovery independently of the host config cwd.
|
|
let workspace = environments.primary().and_then(|environment| {
|
|
environment
|
|
.cwd()
|
|
.to_abs_path()
|
|
.ok()
|
|
.map(|cwd| (cwd, environment))
|
|
});
|
|
let (cwd, fs) = match workspace {
|
|
Some((cwd, fs)) => (cwd, Some(fs)),
|
|
None => (config.cwd.clone(), None),
|
|
};
|
|
let input = HostSkillsLoadInput::new(
|
|
cwd,
|
|
effective_skill_roots,
|
|
config.config_layer_stack.clone(),
|
|
)
|
|
.with_plugin_skill_snapshots(
|
|
plugins_manager.plugin_skill_snapshots_for_config(&config.plugins_config_input()),
|
|
);
|
|
let input = match fs {
|
|
Some(environment) => input.with_environment_id(&environment.selection.environment_id),
|
|
None => input,
|
|
};
|
|
(input, fs)
|
|
}
|
|
|
|
/// The snapshot and configuration resolved with one turn's filesystem access.
|
|
pub(crate) struct HostSkillDiscovery {
|
|
pub(crate) input: Arc<HostSkillsLoadInput>,
|
|
pub(crate) repository: Option<(PathUri, EnvironmentAccessKey)>,
|
|
pub(crate) snapshot: Arc<HostSkillsSnapshot>,
|
|
}
|
|
|
|
impl Session {
|
|
pub(crate) async fn host_skills_for_step(
|
|
&self,
|
|
turn: &TurnContext,
|
|
environments: &TurnEnvironmentSnapshot,
|
|
) -> Arc<HostSkillsSnapshot> {
|
|
let Some(cached) = turn.extension_data.get::<HostSkillDiscovery>() else {
|
|
return turn.skills_snapshot();
|
|
};
|
|
let workspace = environments.primary().and_then(|environment| {
|
|
let cwd = environment.cwd().to_abs_path().ok()?;
|
|
let grants = turn.granted_permissions(&environment.selection.environment_id);
|
|
Some((environment, cwd, environment.fs_accessor(grants)))
|
|
});
|
|
let repository = workspace
|
|
.as_ref()
|
|
.map(|(environment, _, access)| (environment.cwd().clone(), access.cache_key()));
|
|
if cached.repository == repository {
|
|
return Arc::clone(&cached.snapshot);
|
|
}
|
|
|
|
let cwd = workspace
|
|
.as_ref()
|
|
.map_or_else(|| turn.config.cwd.clone(), |(_, cwd, _)| cwd.clone());
|
|
let input = cached.input.for_cwd(cwd);
|
|
let input = Arc::new(match &workspace {
|
|
Some((environment, _, _)) => {
|
|
input.with_environment_id(&environment.selection.environment_id)
|
|
}
|
|
None => input,
|
|
});
|
|
let snapshot = Box::pin(
|
|
self.services
|
|
.skills_service
|
|
.snapshot_for_config_on_executor(
|
|
&input,
|
|
workspace.as_ref().map(|(environment, _, access)| {
|
|
(
|
|
environment.environment().filesystem_ref(),
|
|
access as &dyn EnvironmentAccess,
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
.await;
|
|
let snapshot = Arc::new(snapshot);
|
|
turn.extension_data.insert(HostSkillDiscovery {
|
|
input,
|
|
repository,
|
|
snapshot: Arc::clone(&snapshot),
|
|
});
|
|
snapshot
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn emit_explicit_skill_invocations(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
mentioned_skills: &[SkillMetadata],
|
|
injected_skills: &[SkillMetadata],
|
|
tracking: TrackEventsContext,
|
|
) {
|
|
let injected_skill_paths = injected_skills
|
|
.iter()
|
|
.map(|skill| &skill.path_to_skills_md)
|
|
.collect::<HashSet<_>>();
|
|
let model_slug_tag = sanitize_metric_tag_value(turn_context.model_info().slug.as_str());
|
|
let reasoning_effort = turn_context.effective_reasoning_effort_for_tracing();
|
|
for skill in mentioned_skills {
|
|
let skill_name_tag = sanitize_metric_tag_value(skill.name.as_str());
|
|
let plugin_id_tag =
|
|
sanitize_metric_tag_value(skill.plugin_id.as_deref().unwrap_or("unattributed"));
|
|
let status = if injected_skill_paths.contains(&skill.path_to_skills_md) {
|
|
record_plugin_turn_usage(
|
|
turn_context.extension_data.as_ref(),
|
|
skill.plugin_id.as_deref(),
|
|
);
|
|
"ok"
|
|
} else {
|
|
"error"
|
|
};
|
|
turn_context.session_telemetry.counter(
|
|
"codex.skill.injected",
|
|
/*inc*/ 1,
|
|
&[
|
|
("status", status),
|
|
("skill", skill_name_tag.as_str()),
|
|
("invoke_type", "explicit"),
|
|
("plugin_id", plugin_id_tag.as_str()),
|
|
("model_slug", model_slug_tag.as_str()),
|
|
("reasoning_effort", reasoning_effort.as_str()),
|
|
],
|
|
);
|
|
}
|
|
|
|
let injected_host_skill_prompts = turn_context
|
|
.extension_data
|
|
.get::<InjectedHostSkillPrompts>();
|
|
for skill in injected_skills {
|
|
let skill_resource = skill.path_to_skills_md.inferred_native_path_string();
|
|
if injected_host_skill_prompts
|
|
.as_ref()
|
|
.is_some_and(|prompts| prompts.is_superseded_path(&skill_resource))
|
|
{
|
|
continue;
|
|
}
|
|
turn_context
|
|
.session_telemetry
|
|
.skill_invocation(SkillInvocationEvent {
|
|
turn_id: &turn_context.sub_id,
|
|
skill_name: &skill.name,
|
|
scope: Some(skill.scope),
|
|
plugin_id: skill.plugin_id.as_deref(),
|
|
invocation_type: SkillInvocationType::Explicit,
|
|
});
|
|
for contributor in sess.services.extensions.skill_invocation_contributors() {
|
|
contributor
|
|
.on_skill_invocation(SkillInvocationInput {
|
|
session_store: &sess.services.session_extension_data,
|
|
thread_store: &sess.services.thread_extension_data,
|
|
turn_store: turn_context.extension_data.as_ref(),
|
|
turn_id: turn_context.sub_id.as_str(),
|
|
skill_resource: skill_resource.as_ref(),
|
|
kind: SkillInvocationKind::Explicit,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
let invocations = injected_skills
|
|
.iter()
|
|
.map(|skill| SkillInvocation {
|
|
skill_name: skill.name.clone(),
|
|
location: SkillInvocationLocation::Host {
|
|
path: skill.path_to_skills_md.clone(),
|
|
scope: skill.scope,
|
|
},
|
|
plugin_id: skill.plugin_id.clone(),
|
|
remote_plugin_id: skill.remote_plugin_id.clone(),
|
|
invocation_type: InvocationType::Explicit,
|
|
})
|
|
.collect();
|
|
sess.services
|
|
.analytics_events_client
|
|
.track_skill_invocations(tracking, invocations);
|
|
}
|
|
|
|
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
|
sess: &Session,
|
|
step_context: &crate::session::step_context::StepContext,
|
|
command: &str,
|
|
workdir: &PathUri,
|
|
native_workdir: Option<&AbsolutePathBuf>,
|
|
environment_id: &str,
|
|
) {
|
|
let turn_context = step_context.turn.as_ref();
|
|
let Some(invocation) = detect_implicit_skill_invocation(
|
|
turn_context.extension_data.as_ref(),
|
|
&step_context.extension_data,
|
|
environment_id,
|
|
command,
|
|
workdir,
|
|
native_workdir,
|
|
) else {
|
|
return;
|
|
};
|
|
let skill_name = invocation.skill_name.clone();
|
|
let (skill_resource, seen_key) = match &invocation.location {
|
|
SkillInvocationLocation::Host { path, scope } => {
|
|
let skill_scope = match scope {
|
|
SkillScope::User => "user",
|
|
SkillScope::Repo => "repo",
|
|
SkillScope::System => "system",
|
|
SkillScope::Admin => "admin",
|
|
};
|
|
let skill_path = path.inferred_native_path_string();
|
|
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
|
|
(skill_path, seen_key)
|
|
}
|
|
SkillInvocationLocation::Resource { id, .. } => (id.clone(), format!("resource:{id}")),
|
|
};
|
|
let inserted = {
|
|
let skill_invocations = turn_context
|
|
.extension_data
|
|
.get_or_init(ImplicitSkillInvocations::default);
|
|
let mut seen_skills = skill_invocations.0.lock().await;
|
|
seen_skills.insert(seen_key)
|
|
};
|
|
if !inserted {
|
|
return;
|
|
}
|
|
turn_context
|
|
.session_telemetry
|
|
.skill_invocation(SkillInvocationEvent {
|
|
turn_id: &turn_context.sub_id,
|
|
skill_name: &skill_name,
|
|
scope: match &invocation.location {
|
|
SkillInvocationLocation::Host { scope, .. } => Some(*scope),
|
|
SkillInvocationLocation::Resource { scope, .. } => *scope,
|
|
},
|
|
plugin_id: invocation.plugin_id.as_deref(),
|
|
invocation_type: SkillInvocationType::Implicit,
|
|
});
|
|
let skill_name_tag = sanitize_metric_tag_value(skill_name.as_str());
|
|
let plugin_id_tag =
|
|
sanitize_metric_tag_value(invocation.plugin_id.as_deref().unwrap_or("unattributed"));
|
|
let model_slug_tag = sanitize_metric_tag_value(turn_context.model_info().slug.as_str());
|
|
let reasoning_effort = turn_context.effective_reasoning_effort_for_tracing();
|
|
record_plugin_turn_usage(
|
|
turn_context.extension_data.as_ref(),
|
|
invocation.plugin_id.as_deref(),
|
|
);
|
|
|
|
for contributor in sess.services.extensions.skill_invocation_contributors() {
|
|
contributor
|
|
.on_skill_invocation(SkillInvocationInput {
|
|
session_store: &sess.services.session_extension_data,
|
|
thread_store: &sess.services.thread_extension_data,
|
|
turn_store: turn_context.extension_data.as_ref(),
|
|
turn_id: turn_context.sub_id.as_str(),
|
|
skill_resource: skill_resource.as_str(),
|
|
kind: SkillInvocationKind::Implicit,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
turn_context.session_telemetry.counter(
|
|
"codex.skill.injected",
|
|
/*inc*/ 1,
|
|
&[
|
|
("status", "ok"),
|
|
("skill", skill_name_tag.as_str()),
|
|
("invoke_type", "implicit"),
|
|
("plugin_id", plugin_id_tag.as_str()),
|
|
("model_slug", model_slug_tag.as_str()),
|
|
("reasoning_effort", reasoning_effort.as_str()),
|
|
],
|
|
);
|
|
sess.services
|
|
.analytics_events_client
|
|
.track_skill_invocations(
|
|
build_track_events_context(
|
|
turn_context.model_info().slug.clone(),
|
|
sess.thread_id.to_string(),
|
|
turn_context.sub_id.clone(),
|
|
turn_context.originator.clone(),
|
|
Some(turn_context.turn_metadata_state.clone()),
|
|
),
|
|
vec![invocation],
|
|
);
|
|
}
|