Expose executor skills through skill tools (#35184)

## What changed

- Add executor authority support to `skills.list` and `skills.read` for skills from selected capability roots.
- Allow `skills.read` to load package-relative resources referenced by an executor skill while keeping reads within the selected package.
- Paginate skill listings and resource contents with bounded response sizes and stale-cursor validation.
- Resolve executor filesystems per sampling step so skill tools use the active selected capability roots.

## Testing

- Extend the app-server executor skill test to list an executor skill, read its main `SKILL.md`, and read a paginated reference resource.
- Cover visibility filtering, metadata bounds, pagination, invalid cursors, package boundaries, and resource size limits.

GitOrigin-RevId: 1b5de95d5d1b9f79c34d2879d6e67f24401e9c29
This commit is contained in:
jif
2026-07-24 13:46:20 +00:00
committed by copyberry
parent 1a817bb95d
commit 7c71783135
22 changed files with 704 additions and 163 deletions
+1
View File
@@ -4033,6 +4033,7 @@ dependencies = [
"codex-utils-absolute-path",
"codex-utils-path-uri",
"codex-utils-string",
"futures",
"pretty_assertions",
"schemars 0.8.22",
"serde",
@@ -12,37 +12,25 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use codex_app_server_protocol::WarningNotification;
use codex_exec_server::CreateDirectoryOptions;
use codex_utils_path_uri::PathUri;
use core_test_support::responses;
use core_test_support::skip_if_remote;
use futures::StreamExt;
use futures::TryStreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const READ_TIMEOUT: Duration = Duration::from_secs(10);
const READ_TIMEOUT: Duration = Duration::from_secs(20);
const SKILL_NAME: &str = "demo-plugin:deploy";
const SKILL_MARKER: &str = "EXECUTOR_SKILL_BODY_MARKER";
const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER";
const REFERENCE_MARKER: &str = "EXECUTOR_SKILL_REFERENCE_MARKER";
#[tokio::test]
async fn selected_executor_root_exposes_plugin_skill_and_forwards_budget_warning() -> Result<()> {
// TODO(anp): Remove after selected capability-root fixtures can be materialized in remote exec.
skip_if_remote!(
Ok(()),
"selected capability root fixture is only materialized on the host"
);
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-selected"),
responses::ev_assistant_message("msg-selected", "Done"),
responses::ev_completed("resp-selected"),
]),
)
.await;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
@@ -74,40 +62,137 @@ stream_max_retries = 0
"---\nname: {SKILL_NAME}\ndescription: Colliding local skill.\n---\n\n# Local deploy\n\n{LOCAL_SKILL_MARKER}\n"
),
)?;
let plugin_dir = TempDir::new()?;
let manifest_dir = plugin_dir.path().join(".codex-plugin");
let skill_dir = plugin_dir.path().join("skills/deploy");
std::fs::create_dir_all(&manifest_dir)?;
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
manifest_dir.join("plugin.json"),
r#"{"name":"demo-plugin"}"#,
)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: deploy\ndescription: Deploy through the executor.\n---\n\n# Deploy\n\n{SKILL_MARKER}\n"
),
)?;
for index in 0..200 {
let skill_dir = plugin_dir
.path()
.join("skills")
.join(format!("skill-{index:03}"));
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: skill-{index:03}\ndescription: {}\n---\n",
"x".repeat(1_025)
),
)?;
}
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
let auto_env = app_server.auto_env()?;
let environment_id = auto_env.selection().environment_id.clone();
let plugin_dir = auto_env.selection().cwd.join("plugin")?;
let manifest_dir = plugin_dir.join(".codex-plugin")?;
let skill_dir = plugin_dir.join("skills/deploy")?;
let reference_dir = skill_dir.join("references")?;
let file_system = auto_env.environment().get_filesystem();
for directory in [&manifest_dir, &reference_dir] {
file_system
.create_directory(
directory,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
}
let manifest_path = manifest_dir.join("plugin.json")?;
let skill_path = skill_dir.join("SKILL.md")?;
let reference_path = reference_dir.join("details.md")?;
let reference_contents = format!("{REFERENCE_MARKER}\n{}", "x".repeat(600 * 1024));
tokio::try_join!(
file_system.write_file(
&manifest_path,
br#"{"name":"demo-plugin"}"#.to_vec(),
/*sandbox*/ None,
),
file_system.write_file(
&skill_path,
format!(
"---\nname: deploy\ndescription: Deploy through the executor.\n---\n\n# Deploy\n\n{SKILL_MARKER}\n\nRead references/details.md.\n"
)
.into_bytes(),
/*sandbox*/ None,
),
file_system.write_file(
&reference_path,
reference_contents.into_bytes(),
/*sandbox*/ None,
),
)?;
futures::stream::iter(0..200)
.map(|index| {
let file_system = file_system.clone();
let plugin_dir = plugin_dir.clone();
async move {
let relative = format!("skills/skill-{index:03}");
let skill_dir = plugin_dir.join(&relative)?;
file_system
.create_directory(
&skill_dir,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
file_system
.write_file(
&skill_dir.join("SKILL.md")?,
format!(
"---\nname: skill-{index:03}\ndescription: {}\n---\n",
"x".repeat(1_025)
)
.into_bytes(),
/*sandbox*/ None,
)
.await?;
Ok::<(), anyhow::Error>(())
}
})
.buffer_unordered(16)
.try_collect::<Vec<_>>()
.await?;
let authority_id = "demo-plugin@1";
let locator = |path: &PathUri| {
format!(
"skill://{authority_id}/{}",
path.inferred_native_path_string()
.replace('\\', "/")
.trim_start_matches('/')
)
};
let package = locator(&skill_dir);
let main_resource = locator(&skill_dir.join("SKILL.md")?);
let reference_resource = locator(&reference_dir.join("details.md")?);
let tool_response = |call_id: &str, tool: &str, arguments: serde_json::Value| {
responses::sse(vec![
responses::ev_response_created(&format!("resp-{call_id}")),
responses::ev_function_call_with_namespace(
call_id,
"skills",
tool,
&arguments.to_string(),
),
responses::ev_completed(&format!("resp-{call_id}")),
])
};
let response_mock = responses::mount_sse_sequence(
&server,
vec![
tool_response("list", "list", json!({"authority": {"kind": "executor"}})),
tool_response(
"main",
"read",
json!({
"authority": {"kind": "executor", "id": authority_id},
"package": package.clone(),
"resource": main_resource.clone(),
}),
),
tool_response(
"reference",
"read",
json!({
"authority": {"kind": "executor", "id": authority_id},
"package": package.clone(),
"resource": reference_resource.clone(),
}),
),
responses::sse(vec![
responses::ev_response_created("resp-done"),
responses::ev_assistant_message("msg-done", "Done"),
responses::ev_completed("resp-done"),
]),
],
)
.await;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
@@ -116,8 +201,8 @@ stream_max_retries = 0
selected_capability_roots: Some(vec![SelectedCapabilityRoot {
id: "demo-plugin@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "local".to_string(),
path: PathUri::from_host_native_path(plugin_dir.path())?,
environment_id,
path: plugin_dir,
},
}]),
..Default::default()
@@ -170,7 +255,8 @@ stream_max_retries = 0
)
.await??;
let request = response_mock.single_request();
let requests = response_mock.requests();
let request = &requests[0];
assert!(
request
.message_input_texts("developer")
@@ -189,6 +275,43 @@ stream_max_retries = 0
assert!(skill_fragment.contains(&format!("<name>{SKILL_NAME}</name>")));
assert!(skill_fragment.contains(SKILL_MARKER));
assert!(!skill_fragment.contains(LOCAL_SKILL_MARKER));
let list_output = serde_json::from_str::<serde_json::Value>(
&requests[1]
.function_call_output_text("list")
.expect("skills.list output"),
)?;
let deploy_skill = list_output["skills"]
.as_array()
.and_then(|skills| skills.iter().find(|skill| skill["name"] == SKILL_NAME))
.expect("skills.list should include the selected executor skill");
assert_eq!(
deploy_skill,
&json!({
"authority": {"kind": "executor", "id": authority_id},
"package": package,
"name": SKILL_NAME,
"description": "Deploy through the executor.",
"main_resource": main_resource,
})
);
assert!(list_output["next_cursor"].is_string());
assert!(
requests[2]
.function_call_output_text("main")
.expect("main skill output")
.contains(SKILL_MARKER)
);
let reference_output = serde_json::from_str::<serde_json::Value>(
&requests[3]
.function_call_output_text("reference")
.expect("referenced skill file output"),
)?;
assert!(
reference_output["contents"]
.as_str()
.is_some_and(|contents| contents.contains(REFERENCE_MARKER))
);
assert!(reference_output["next_cursor"].is_string());
Ok(())
}
@@ -288,6 +288,7 @@ async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() -
"main_resource": SKILL_MAIN_PROMPT_URI,
}],
"warnings": ["Orchestrator skill discovery stopped after 2 resource pages: failed to list orchestrator skill resources: resources/list failed for `codex_apps`: Mcp error: -32603: simulated later-page failure"],
"next_cursor": null,
})
);
@@ -299,6 +300,7 @@ async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() -
json!({
"resource": SKILL_REFERENCE_URI,
"contents": SKILL_REFERENCE_CONTENTS,
"next_cursor": null,
})
);
let repeated_read_output = requests[3]
+4 -4
View File
@@ -27,14 +27,14 @@ pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
"Exceeded skills context budget. All skill descriptions were removed and";
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `environment resource` locators are owned by an execution environment, `orchestrator resource` locators are opaque non-filesystem resources, and `custom resource` locators use their provider's access mechanism.";
const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + source locator). `file` entries live on the host filesystem, `environment resource` entries are owned by their execution environment, `orchestrator resource` entries must be accessed through `skills.list` and `skills.read`, and `custom resource` entries use their provider's access mechanism.
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + source locator). `file` entries live on the host filesystem, `environment resource` and `orchestrator resource` entries must be accessed through `skills.list` and `skills.read`, and `custom resource` entries use their provider's access mechanism.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or its source can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, the main agent must read its `SKILL.md` completely before taking task actions. For a `file` entry, open the listed path. For an `environment resource`, use the filesystem of the owning environment. For an `orchestrator resource`, call `skills.list` with `{"authority":{"kind":"orchestrator"}}`, select the matching package, and pass its `main_resource` to `skills.read`. If a read is truncated or paginated, continue until EOF.
2) When `SKILL.md` references another resource, use the same access mechanism. Resolve relative paths against a filesystem-backed skill directory. For orchestrator skills, pass the exact referenced resource identifier with the same authority and package to `skills.read`; do not treat `skill://` identifiers as filesystem paths.
1) After deciding to use a skill, the main agent must read its `SKILL.md` completely before taking task actions. For a `file` entry, open the listed path. For an `environment resource`, call `skills.list` with `{"authority":{"kind":"executor"}}`; for an `orchestrator resource`, use `{"authority":{"kind":"orchestrator"}}`. Select the matching package and pass its exact authority, package, and `main_resource` to `skills.read`. Follow `next_cursor`; if a read is paginated, continue until EOF.
2) When `SKILL.md` references another resource, use the same access mechanism. Resolve relative references beneath an executor skill's returned package and call `skills.read` with the same authority and package. For orchestrator skills, pass the exact referenced resource identifier with the same authority and package to `skills.read`; do not treat `skill://` identifiers as filesystem paths.
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the resources required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
4) For filesystem-backed skills, prefer running or patching provided scripts instead of retyping large code blocks. For orchestrator skills, use `skills.read` and the available tools; do not invent a local path.
4) For filesystem-backed skills, prefer running or patching provided scripts instead of retyping large code blocks. For environment and orchestrator skills, use `skills.read` and the available tools; do not invent a local path.
5) Reuse provided assets or templates through the same source access mechanism instead of recreating them.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
+3
View File
@@ -3032,6 +3032,8 @@ impl Session {
&ready_selected_capability_roots,
)
.await;
let extension_data = codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
extension_data.insert(selected_capability_roots.clone());
let mcp = self
.mcp_runtime_for_step(turn_context.as_ref(), &selected_capability_roots)
.or_cancel(cancellation_token)
@@ -3041,6 +3043,7 @@ impl Session {
turn_context.as_ref(),
&environments,
mcp.as_ref(),
&extension_data,
)
.or_cancel(cancellation_token)
.await?;
+3 -1
View File
@@ -76,6 +76,7 @@ use codex_analytics::build_track_events_context;
use codex_async_utils::OrCancelExt;
use codex_core_plugins::RecommendedPluginCandidatesInput;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_extension_api::ExtensionData;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputEnvironment;
use codex_features::Feature;
@@ -1279,6 +1280,7 @@ pub(crate) async fn built_tools(
turn_context: &TurnContext,
environments: &TurnEnvironmentSnapshot,
mcp: &codex_mcp::McpBinding,
step_store: &ExtensionData,
) -> (Vec<ToolInfo>, Arc<ToolRouter>) {
let all_mcp_tools = mcp.tools().to_vec();
let loaded_plugins = sess
@@ -1396,7 +1398,7 @@ pub(crate) async fn built_tools(
ToolRouterParams {
tool_runtimes: mcp_tool_runtimes,
tool_suggest_candidates,
extension_tool_executors: extension_tool_executors(sess),
extension_tool_executors: extension_tool_executors(sess, step_store),
wait_for_environment_tool_config: sess
.services
.thread_extension_data
+3 -1
View File
@@ -262,6 +262,7 @@ impl ToolRouter {
#[instrument(level = "trace", skip_all)]
pub(crate) fn extension_tool_executors(
session: &Session,
step_store: &codex_extension_api::ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ExtensionToolCall>>> {
session
.services
@@ -269,9 +270,10 @@ pub(crate) fn extension_tool_executors(
.tool_contributors()
.iter()
.flat_map(|contributor| {
contributor.tools(
contributor.tools_for_step(
&session.services.session_extension_data,
&session.services.thread_extension_data,
step_store,
)
})
.collect()
+4 -1
View File
@@ -410,7 +410,10 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow
ToolRouterParams {
tool_suggest_candidates: None,
tool_runtimes: Vec::new(),
extension_tool_executors: extension_tool_executors(&session),
extension_tool_executors: extension_tool_executors(
&session,
&codex_extension_api::ExtensionData::new(turn.sub_id.clone()),
),
wait_for_environment_tool_config: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
},
@@ -277,6 +277,16 @@ pub trait ToolContributor: Send + Sync {
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>>;
/// Returns native tools bound to one sampling step.
fn tools_for_step(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
_step_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
self.tools(session_store, thread_store)
}
}
/// Contributor for host-owned tool lifecycle gates.
+1
View File
@@ -23,6 +23,7 @@ codex-skills = { workspace = true }
codex-tools = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-string = { workspace = true }
futures = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+42
View File
@@ -59,6 +59,19 @@ impl SkillAuthority {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillPackageId(pub String);
impl SkillPackageId {
pub(crate) fn relative_resource_path<'a>(&self, resource: &'a str) -> Option<&'a str> {
let relative = resource
.strip_prefix(self.0.trim_end_matches('/'))?
.strip_prefix('/')?;
(!relative.is_empty()
&& relative
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..")))
.then_some(relative)
}
}
/// Opaque resource id inside a skill package, optionally bound to the
/// environment path that owns its contents.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -80,10 +93,12 @@ impl SkillResourceId {
environment_id: impl Into<String>,
path: PathUri,
) -> Self {
let package_root = path.parent().unwrap_or_else(|| path.clone());
Self {
id: id.into(),
environment_path: Some(EnvironmentSkillResource {
environment_id: environment_id.into(),
package_root,
path,
contents: None,
}),
@@ -96,10 +111,12 @@ impl SkillResourceId {
path: PathUri,
contents: String,
) -> Self {
let package_root = path.parent().unwrap_or_else(|| path.clone());
Self {
id: id.into(),
environment_path: Some(EnvironmentSkillResource {
environment_id: environment_id.into(),
package_root,
path,
contents: Some(contents.into()),
}),
@@ -110,6 +127,26 @@ impl SkillResourceId {
&self.id
}
pub(crate) fn bind_environment_package_resource(
&self,
package: &SkillPackageId,
resource: impl Into<String>,
) -> Option<Self> {
let resource = resource.into();
let relative = package.relative_resource_path(&resource)?;
let environment = self.environment_path.as_ref()?;
let path = environment.package_root.join(relative).ok()?;
path.starts_with(&environment.package_root).then(|| Self {
id: resource,
environment_path: Some(EnvironmentSkillResource {
environment_id: environment.environment_id.clone(),
package_root: environment.package_root.clone(),
path,
contents: None,
}),
})
}
pub(crate) fn environment_path(&self) -> Option<(&str, &PathUri)> {
self.environment_path
.as_ref()
@@ -126,6 +163,7 @@ impl SkillResourceId {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct EnvironmentSkillResource {
environment_id: String,
package_root: PathUri,
path: PathUri,
contents: Option<Arc<str>>,
}
@@ -207,6 +245,10 @@ impl SkillCatalogEntry {
self
}
pub(crate) fn is_model_visible(&self) -> bool {
self.enabled && self.prompt_visible
}
pub(crate) fn rendered_path(&self) -> &str {
self.display_path
.as_deref()
+59 -16
View File
@@ -4,6 +4,7 @@ use codex_core_skills::HostSkillsSnapshot;
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor;
use codex_extension_api::ContextualUserFragment;
@@ -157,6 +158,7 @@ where
SkillListQuery {
turn_id: thread_store.level_id().to_string(),
executor_roots: Vec::new(),
resolved_executor_roots: Vec::new(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: config.bundled_skills_enabled,
@@ -207,6 +209,7 @@ where
SkillListQuery {
turn_id: input.turn_id.to_string(),
executor_roots: input.ready_selected_capability_roots.to_vec(),
resolved_executor_roots: Vec::new(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: config.bundled_skills_enabled,
@@ -278,23 +281,34 @@ where
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
if !self.providers.has_orchestrator_provider()
|| !thread_state.orchestrator_skills_enabled()
{
return Vec::new();
}
self.build_skill_tools(session_store, thread_store, /*executor_query*/ None)
}
skill_tools(
self.providers.clone(),
session_store
.get::<SkillsSessionState>()
.and_then(|state| state.mcp_resources.clone()),
thread_state,
Arc::clone(&self.shadow_selection),
)
fn tools_for_step(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
step_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let resolved_executor_roots = step_store
.get::<Vec<ResolvedSelectedCapabilityRoot>>()
.map(|roots| roots.as_slice().to_vec())
.unwrap_or_default();
let executor_query = (!resolved_executor_roots.is_empty()).then(|| SkillListQuery {
turn_id: step_store.level_id().to_string(),
executor_roots: resolved_executor_roots
.iter()
.map(|root| root.selected_root().clone())
.collect(),
resolved_executor_roots,
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: false,
include_orchestrator_skills: false,
mcp_resources: None,
executor_capability_discovery: None,
});
self.build_skill_tools(session_store, thread_store, executor_query)
}
}
@@ -350,6 +364,7 @@ where
let query = SkillListQuery {
turn_id: input.turn_id.clone(),
executor_roots: Vec::new(),
resolved_executor_roots: Vec::new(),
host_snapshot: host_snapshot.clone(),
include_host_skills: !host_catalog_in_world_state,
include_bundled_skills: config.bundled_skills_enabled,
@@ -501,6 +516,33 @@ where
}
impl<C> SkillsExtension<C> {
fn build_skill_tools(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
executor_query: Option<SkillListQuery>,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
let orchestrator_available = self.providers.has_orchestrator_provider()
&& thread_state.orchestrator_skills_enabled();
if !orchestrator_available && executor_query.is_none() {
return Vec::new();
}
skill_tools(
self.providers.clone(),
session_store
.get::<SkillsSessionState>()
.and_then(|state| state.mcp_resources.clone()),
thread_state,
orchestrator_available,
executor_query,
Arc::clone(&self.shadow_selection),
)
}
#[tracing::instrument(level = "trace", skip_all)]
async fn list_skills(
&self,
@@ -541,6 +583,7 @@ impl<C> SkillsExtension<C> {
authority: entry.authority.clone(),
package: entry.id.clone(),
resource: entry.main_prompt.clone(),
resolved_executor_roots: Vec::new(),
host_snapshot,
mcp_resources,
},
+5
View File
@@ -8,6 +8,7 @@ mod orchestrator;
use codex_core_skills::HostSkillsSnapshot;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::McpResourceClient;
use codex_protocol::capabilities::SelectedCapabilityRoot;
@@ -23,10 +24,13 @@ pub use executor::ExecutorSkillProvider;
pub use host::HostSkillProvider;
pub use orchestrator::OrchestratorSkillProvider;
pub(crate) const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
#[derive(Clone, Debug)]
pub struct SkillListQuery {
pub turn_id: String,
pub executor_roots: Vec<SelectedCapabilityRoot>,
pub resolved_executor_roots: Vec<ResolvedSelectedCapabilityRoot>,
pub host_snapshot: Option<Arc<HostSkillsSnapshot>>,
pub include_host_skills: bool,
pub include_bundled_skills: bool,
@@ -41,6 +45,7 @@ pub struct SkillReadRequest {
pub authority: SkillAuthority,
pub package: SkillPackageId,
pub resource: SkillResourceId,
pub resolved_executor_roots: Vec<ResolvedSelectedCapabilityRoot>,
pub host_snapshot: Option<Arc<HostSkillsSnapshot>>,
pub mcp_resources: Option<Arc<McpResourceClient>>,
}
+100 -32
View File
@@ -7,6 +7,8 @@ use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::protocol::Product;
use codex_skills::EnvironmentSkillMetadata;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use futures::StreamExt;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
@@ -17,6 +19,7 @@ use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::MAX_SKILL_RESOURCE_CONTENT_BYTES;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
@@ -50,24 +53,32 @@ impl SkillProvider for ExecutorSkillProvider {
}
let mut catalog = SkillCatalog::default();
for selected_root in query.executor_roots {
let selected_root_id = selected_root.id;
let selected_root_id = &selected_root.id;
let CapabilityRootLocation::Environment {
environment_id,
path,
} = selected_root.location;
} = &selected_root.location;
let authority =
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
let Some(environment) = self.environment_manager.get_environment(&environment_id)
else {
let file_system = query
.resolved_executor_roots
.iter()
.find(|root| root.selected_root() == &selected_root)
.map(|root| root.environment().get_filesystem())
.or_else(|| {
self.environment_manager
.get_environment(environment_id)
.map(|environment| environment.get_filesystem())
});
let Some(file_system) = file_system else {
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
));
continue;
};
let file_system = environment.get_filesystem();
let outcome = load_environment_skills_from_root(
file_system.as_ref(),
&path,
path,
self.restriction_product,
)
.await;
@@ -76,8 +87,8 @@ impl SkillProvider for ExecutorSkillProvider {
catalog.push_entry(catalog_entry_from_skill(
&skill,
authority.clone(),
&selected_root_id,
&environment_id,
selected_root_id,
environment_id,
/*instructions*/ None,
));
}
@@ -95,7 +106,13 @@ impl SkillProvider for ExecutorSkillProvider {
request.authority.kind
)));
}
if request.package.0 != request.resource.as_str() {
let expected_package_prefix = format!("skill://{}/", request.authority.id);
if !request.package.0.starts_with(&expected_package_prefix)
|| request
.package
.relative_resource_path(request.resource.as_str())
.is_none()
{
return Err(SkillProviderError::new(
"executor skill resource does not match its package",
));
@@ -111,21 +128,27 @@ impl SkillProvider for ExecutorSkillProvider {
"executor skill resource is not bound to an environment",
));
};
let Some(environment) = self.environment_manager.get_environment(environment_id) else {
let file_system = request
.resolved_executor_roots
.iter()
.find(|root| root.selected_root().id == request.authority.id)
.map(|root| root.environment().get_filesystem())
.or_else(|| {
self.environment_manager
.get_environment(environment_id)
.map(|environment| environment.get_filesystem())
});
let Some(file_system) = file_system else {
return Err(SkillProviderError::new(format!(
"executor skill resource references unavailable environment `{environment_id}`"
)));
};
let contents = environment
.get_filesystem()
.read_file_text(resource_path, /*sandbox*/ None)
.await
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read executor skill resource {}: {err}",
request.resource.as_str()
))
})?;
let contents = read_bounded_text(
file_system.as_ref(),
resource_path,
request.resource.as_str(),
)
.await?;
Ok(SkillReadResult {
resource: request.resource,
@@ -184,37 +207,42 @@ fn catalog_entry_from_skill(
environment_id: &str,
instructions: Option<String>,
) -> SkillCatalogEntry {
let skill_path = skill.path_to_skills_md.inferred_native_path_string();
let normalized_path = match skill.path_to_skills_md.infer_path_convention() {
Some(PathConvention::Windows) => skill_path.replace('\\', "/"),
Some(PathConvention::Posix) | None => skill_path,
};
let display_path = format!(
"skill://{selected_root_id}/{}",
normalized_path.trim_start_matches('/')
let handle_prefix = format!("skill://{selected_root_id}/");
let normalized_main_path = normalized_environment_path(&skill.path_to_skills_md);
let normalized_package_path = skill.path_to_skills_md.parent().map_or_else(
|| normalized_main_path.clone(),
|path| normalized_environment_path(&path),
);
let package = format!(
"{handle_prefix}{}",
normalized_package_path.trim_start_matches('/')
);
let main_resource = format!(
"{handle_prefix}{}",
normalized_main_path.trim_start_matches('/')
);
let main_prompt = match instructions {
Some(contents) => SkillResourceId::environment_with_contents(
display_path.clone(),
main_resource.clone(),
environment_id,
skill.path_to_skills_md.clone(),
contents,
),
None => SkillResourceId::environment(
display_path.clone(),
main_resource.clone(),
environment_id,
skill.path_to_skills_md.clone(),
),
};
let entry = SkillCatalogEntry::new(
SkillPackageId(display_path.clone()),
SkillPackageId(package),
authority,
skill.name.clone(),
skill.description.clone(),
main_prompt,
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
.with_display_path(main_resource)
.with_dependencies(skill.dependencies.clone());
if skill.allows_implicit_invocation() {
@@ -223,3 +251,43 @@ fn catalog_entry_from_skill(
entry.hidden_from_prompt()
}
}
fn normalized_environment_path(path: &PathUri) -> String {
let convention = path.infer_path_convention();
let path = path.inferred_native_path_string();
match convention {
Some(PathConvention::Windows) => path.replace('\\', "/"),
Some(PathConvention::Posix) | None => path,
}
}
async fn read_bounded_text(
file_system: &dyn codex_exec_server::ExecutorFileSystem,
path: &PathUri,
resource: &str,
) -> Result<String, SkillProviderError> {
let read_error = |err| {
SkillProviderError::new(format!(
"failed to read executor skill resource {resource}: {err}"
))
};
let mut stream = file_system
.read_file_stream(path, /*sandbox*/ None)
.await
.map_err(&read_error)?;
let mut contents = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(&read_error)?;
if contents.len().saturating_add(chunk.len()) > MAX_SKILL_RESOURCE_CONTENT_BYTES {
return Err(SkillProviderError::new(format!(
"executor skill resource {resource} exceeds {MAX_SKILL_RESOURCE_CONTENT_BYTES} bytes"
)));
}
contents.extend_from_slice(&chunk);
}
String::from_utf8(contents).map_err(|_| {
SkillProviderError::new(format!(
"executor skill resource {resource} is not valid UTF-8"
))
})
}
@@ -15,6 +15,7 @@ use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::MAX_SKILL_RESOURCE_CONTENT_BYTES;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
@@ -30,8 +31,6 @@ const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024;
const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048;
const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
/// Discovers and reads skills owned by the orchestrator.
///
/// The provider uses session-scoped resources without exposing the transport or
+1 -1
View File
@@ -483,7 +483,7 @@ pub(crate) fn render_available_skills(
let mut entries = catalog
.entries
.iter()
.filter(|entry| entry.enabled && entry.prompt_visible)
.filter(|entry| entry.is_model_visible())
.collect::<Vec<_>>();
policy.order_entries(&mut entries);
if entries.is_empty() {
@@ -64,8 +64,7 @@ impl ShadowSelectionExperiment {
.iter()
.enumerate()
.filter(|(_, entry)| {
entry.enabled
&& entry.prompt_visible
entry.is_model_visible()
// Invocation observation currently exists only for host shell use and
// orchestrator reads. Keep the candidate set aligned with that universe.
&& matches!(
+76 -17
View File
@@ -1,3 +1,4 @@
use codex_extension_api::FunctionCallError;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolExecutorFuture;
@@ -8,27 +9,38 @@ use serde::Deserialize;
use serde::Serialize;
use crate::catalog::SkillCatalogEntry;
use crate::render::MAX_SKILL_NAME_BYTES;
use crate::render::truncate_catalog_skill_description;
use crate::render::truncate_utf8_to_bytes;
use crate::warnings::bounded_warnings;
use super::MAX_HANDLE_BYTES;
use super::SkillToolAuthority;
use super::SkillToolAuthoritySelector;
use super::SkillToolContext;
use super::external_json_output;
use super::is_bounded_handle;
use super::pagination_cursor;
use super::parse_args;
use super::parse_pagination_cursor;
use super::serialized_len;
use super::skill_function_tool;
use super::skill_json_output;
use super::skill_tool_name;
const TOOL_NAME: &str = "list";
const MAX_SKILLS_PER_PAGE: usize = 20;
const MAX_LIST_RESPONSE_BYTES: usize = 512 * 1024;
const OVERSIZED_ENTRY_WARNING: &str =
"Some skills were omitted because their metadata is too large.";
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListArgs {
authority: SkillToolAuthority,
authority: SkillToolAuthoritySelector,
cursor: Option<String>,
}
#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)]
#[derive(Clone, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize)]
#[schemars(deny_unknown_fields)]
struct ListedSkill {
authority: SkillToolAuthority,
@@ -43,6 +55,7 @@ struct ListedSkill {
struct ListResponse {
skills: Vec<ListedSkill>,
warnings: Vec<String>,
next_cursor: Option<String>,
}
#[derive(Clone)]
@@ -58,33 +71,79 @@ impl ToolExecutor<ToolCall> for ListTool {
fn spec(&self) -> ToolSpec {
skill_function_tool::<ListArgs, ListResponse>(
TOOL_NAME,
"List enabled skills owned by the requested authority. Only orchestrator-owned skills are currently supported. Returns the opaque package and main-resource handles required by skills.read.",
"List skills owned by the requested authority. Returns the exact authority, package, and main_resource values required by skills.read. Pass next_cursor back as cursor to continue.",
)
}
fn handle(&self, call: ToolCall) -> ToolExecutorFuture<'_> {
Box::pin(async move {
let args: ListArgs = parse_args(&call)?;
let authority = args.authority.into_authority();
let catalog = self.context.catalog(&call.turn_id, args.authority).await;
let response = ListResponse {
skills: catalog
.entries
.into_iter()
.filter(|entry| entry.enabled && entry.authority == authority)
.filter_map(listed_skill)
.collect(),
warnings: bounded_warnings(&catalog.warnings),
let mut omitted_oversized_entry = false;
let skills = catalog
.entries
.into_iter()
.filter(|entry| {
entry.is_model_visible() && args.authority.matches(&entry.authority)
})
.filter_map(|entry| {
let listed = listed_skill(entry).filter(single_entry_response_is_bounded);
omitted_oversized_entry |= listed.is_none();
listed
})
.collect::<Vec<_>>();
let start = parse_pagination_cursor(args.cursor.as_deref(), &skills, "skills.list")?;
if start > skills.len() {
return Err(FunctionCallError::RespondToModel(
"skills.list cursor is invalid".to_string(),
));
}
let mut warnings = if start == 0 {
let mut warnings = catalog.warnings;
if omitted_oversized_entry {
warnings.push(OVERSIZED_ENTRY_WARNING.to_string());
}
bounded_warnings(&warnings)
} else {
Vec::new()
};
external_json_output(&response)
let mut end = (start + MAX_SKILLS_PER_PAGE).min(skills.len());
loop {
let response = ListResponse {
skills: skills[start..end].to_vec(),
warnings: warnings.clone(),
next_cursor: (end < skills.len()).then(|| pagination_cursor(&skills, end)),
};
if serialized_len(&response)? <= MAX_LIST_RESPONSE_BYTES {
return skill_json_output(&response, args.authority);
}
if end.saturating_sub(start) > 1 {
end -= 1;
} else if !warnings.is_empty() {
warnings.clear();
} else {
return Err(FunctionCallError::RespondToModel(
"skill metadata is too large to list".to_string(),
));
}
}
})
}
}
fn single_entry_response_is_bounded(skill: &ListedSkill) -> bool {
serialized_len(&ListResponse {
skills: vec![skill.clone()],
warnings: Vec::new(),
next_cursor: Some(pagination_cursor(skill, usize::MAX)),
})
.is_ok_and(|size| size <= MAX_LIST_RESPONSE_BYTES)
}
fn listed_skill(entry: SkillCatalogEntry) -> Option<ListedSkill> {
let authority = SkillToolAuthority::from_authority(&entry.authority)?;
if !is_bounded_handle(&entry.id.0, MAX_HANDLE_BYTES)
if !is_bounded_handle(&entry.authority.id, MAX_HANDLE_BYTES)
|| !is_bounded_handle(&entry.id.0, MAX_HANDLE_BYTES)
|| !is_bounded_handle(entry.main_prompt.as_str(), MAX_HANDLE_BYTES)
{
return None;
@@ -93,7 +152,7 @@ fn listed_skill(entry: SkillCatalogEntry) -> Option<ListedSkill> {
Some(ListedSkill {
authority,
package: entry.id.0,
name: entry.name,
name: truncate_utf8_to_bytes(&entry.name, MAX_SKILL_NAME_BYTES).0,
description: truncate_catalog_skill_description(&entry.description).into_owned(),
main_resource: entry.main_prompt.as_str().to_string(),
})
+112 -12
View File
@@ -1,3 +1,6 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::Hash;
use std::hash::Hasher;
use std::sync::Arc;
use codex_extension_api::FunctionCallError;
@@ -18,6 +21,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use tokio::sync::OnceCell;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
@@ -38,12 +42,17 @@ pub(crate) fn skill_tools(
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
thread_state: Arc<SkillsThreadState>,
orchestrator_available: bool,
executor_query: Option<SkillListQuery>,
shadow_selection: Arc<ShadowSelectionExperiment>,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let context = SkillToolContext {
providers,
mcp_resources,
thread_state,
orchestrator_available,
executor_query,
executor_catalog: Arc::new(OnceCell::new()),
shadow_selection,
};
vec![
@@ -59,19 +68,26 @@ struct SkillToolContext {
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
thread_state: Arc<SkillsThreadState>,
orchestrator_available: bool,
executor_query: Option<SkillListQuery>,
executor_catalog: Arc<OnceCell<SkillCatalog>>,
shadow_selection: Arc<ShadowSelectionExperiment>,
}
impl SkillToolContext {
async fn catalog(&self, turn_id: &str, authority: SkillToolAuthority) -> SkillCatalog {
async fn catalog(&self, turn_id: &str, authority: SkillToolAuthoritySelector) -> SkillCatalog {
match authority {
SkillToolAuthority::Orchestrator => {
SkillToolAuthoritySelector::Orchestrator => {
if !self.orchestrator_available {
return SkillCatalog::default();
}
self.thread_state
.orchestrator_catalog_snapshot(
self.mcp_resources.as_deref(),
self.providers.list_orchestrator_for_turn(SkillListQuery {
turn_id: turn_id.to_string(),
executor_roots: Vec::new(),
resolved_executor_roots: Vec::new(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: false,
@@ -82,30 +98,73 @@ impl SkillToolContext {
)
.await
}
SkillToolAuthoritySelector::Executor => {
let Some(mut query) = self.executor_query.clone() else {
return SkillCatalog::default();
};
query.turn_id = turn_id.to_string();
self.executor_catalog
.get_or_init(|| self.providers.list_executor_for_turn(query))
.await
.clone()
}
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum SkillToolAuthoritySelector {
Orchestrator,
Executor,
}
impl SkillToolAuthoritySelector {
fn matches(self, authority: &SkillAuthority) -> bool {
match self {
Self::Orchestrator => authority.kind == SkillSourceKind::Orchestrator,
Self::Executor => authority.kind == SkillSourceKind::Executor,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum SkillToolAuthority {
Orchestrator,
Executor { id: String },
}
impl SkillToolAuthority {
fn from_authority(authority: &SkillAuthority) -> Option<Self> {
if authority
!= &SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
{
return None;
fn selector(&self) -> SkillToolAuthoritySelector {
match self {
Self::Orchestrator => SkillToolAuthoritySelector::Orchestrator,
Self::Executor { .. } => SkillToolAuthoritySelector::Executor,
}
Some(Self::Orchestrator)
}
fn into_authority(self) -> SkillAuthority {
fn from_authority(authority: &SkillAuthority) -> Option<Self> {
match &authority.kind {
SkillSourceKind::Orchestrator if authority.id == CODEX_APPS_MCP_SERVER_NAME => {
Some(Self::Orchestrator)
}
SkillSourceKind::Executor => Some(Self::Executor {
id: authority.id.clone(),
}),
SkillSourceKind::Host | SkillSourceKind::Orchestrator | SkillSourceKind::Custom(_) => {
None
}
}
}
fn matches(&self, authority: &SkillAuthority) -> bool {
match self {
Self::Orchestrator => {
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
authority.kind == SkillSourceKind::Orchestrator
&& authority.id == CODEX_APPS_MCP_SERVER_NAME
}
Self::Executor { id } => {
authority.kind == SkillSourceKind::Executor && authority.id == *id
}
}
}
@@ -158,9 +217,50 @@ fn is_bounded_handle(value: &str, max_bytes: usize) -> bool {
!value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)
}
fn external_json_output<T: Serialize>(value: &T) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
fn pagination_cursor(value: &(impl Hash + ?Sized), offset: usize) -> String {
format!("{:016x}:{offset}", value_fingerprint(value))
}
fn parse_pagination_cursor(
cursor: Option<&str>,
value: &(impl Hash + ?Sized),
tool: &str,
) -> Result<usize, FunctionCallError> {
let Some(cursor) = cursor else {
return Ok(0);
};
let invalid = || FunctionCallError::RespondToModel(format!("{tool} cursor is invalid"));
let (fingerprint, offset) = cursor.split_once(':').ok_or_else(invalid)?;
if u64::from_str_radix(fingerprint, 16).ok() != Some(value_fingerprint(value)) {
return Err(FunctionCallError::RespondToModel(format!(
"{tool} cursor is stale; restart from the first page"
)));
}
offset.parse::<usize>().map_err(|_| invalid())
}
fn value_fingerprint(value: &(impl Hash + ?Sized)) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn serialized_len(value: &impl Serialize) -> Result<usize, FunctionCallError> {
serde_json::to_vec(value)
.map(|value| value.len())
.map_err(|err| FunctionCallError::Fatal(err.to_string()))
}
fn skill_json_output<T: Serialize>(
value: &T,
authority: SkillToolAuthoritySelector,
) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let value = serde_json::to_value(value).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize tool output: {err}"))
})?;
Ok(Box::new(JsonToolOutput::new(value).with_external_context()))
let output = JsonToolOutput::new(value);
Ok(match authority {
SkillToolAuthoritySelector::Orchestrator => Box::new(output.with_external_context()),
SkillToolAuthoritySelector::Executor => Box::new(output),
})
}
+81 -18
View File
@@ -8,20 +8,23 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillResourceId;
use crate::provider::SkillReadRequest;
use super::MAX_HANDLE_BYTES;
use super::SkillToolAuthority;
use super::SkillToolContext;
use super::external_json_output;
use super::pagination_cursor;
use super::parse_args;
use super::parse_pagination_cursor;
use super::serialized_len;
use super::skill_function_tool;
use super::skill_json_output;
use super::skill_tool_name;
use super::validate_handle;
const TOOL_NAME: &str = "read";
const MAX_READ_RESPONSE_BYTES: usize = 512 * 1024;
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -29,6 +32,7 @@ struct ReadArgs {
authority: SkillToolAuthority,
package: String,
resource: String,
cursor: Option<String>,
}
#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)]
@@ -36,6 +40,7 @@ struct ReadArgs {
struct ReadResponse {
resource: String,
contents: String,
next_cursor: Option<String>,
}
#[derive(Clone)]
@@ -51,28 +56,46 @@ impl ToolExecutor<ToolCall> for ReadTool {
fn spec(&self) -> ToolSpec {
skill_function_tool::<ReadArgs, ReadResponse>(
TOOL_NAME,
"Read one complete resource from an enabled skill. Pass the exact authority and package returned by skills.list; resource identifiers remain opaque and are routed to that authority.",
"Read one page from a skill resource. Pass the exact authority and package from skills.list, plus its main_resource or a referenced resource beneath that package. Pass next_cursor back as cursor to continue.",
)
}
fn handle(&self, call: ToolCall) -> ToolExecutorFuture<'_> {
Box::pin(async move {
let args: ReadArgs = parse_args(&call)?;
let authority = args.authority.into_authority();
if let SkillToolAuthority::Executor { id } = &args.authority {
validate_handle("authority.id", id, MAX_HANDLE_BYTES)?;
}
validate_handle("package", &args.package, MAX_HANDLE_BYTES)?;
validate_handle("resource", &args.resource, MAX_HANDLE_BYTES)?;
let catalog = self.context.catalog(&call.turn_id, args.authority).await;
let output_authority = args.authority.selector();
let catalog = self.context.catalog(&call.turn_id, output_authority).await;
let Some(skill_entry) = catalog.entries.iter().find(|entry| {
entry.enabled && entry.authority == authority && entry.id.0 == args.package
entry.enabled
&& args.authority.matches(&entry.authority)
&& entry.id.0 == args.package
}) else {
return Err(FunctionCallError::RespondToModel(
"skill package is not available from the requested authority".to_string(),
));
};
let authority = skill_entry.authority.clone();
let package = skill_entry.id.clone();
let main_prompt = skill_entry.main_prompt.clone();
let requested_resource = SkillResourceId::new(args.resource);
let requested_resource = if args.resource == main_prompt.as_str() {
main_prompt.clone()
} else {
main_prompt
.bind_environment_package_resource(&package, args.resource.clone())
.unwrap_or_else(|| SkillResourceId::new(args.resource))
};
let resolved_executor_roots = self
.context
.executor_query
.as_ref()
.map(|query| query.resolved_executor_roots.clone())
.unwrap_or_default();
let result = self
.context
.thread_state
@@ -80,8 +103,9 @@ impl ToolExecutor<ToolCall> for ReadTool {
&self.context.providers,
SkillReadRequest {
authority,
package: SkillPackageId(args.package),
package,
resource: requested_resource.clone(),
resolved_executor_roots,
host_snapshot: None,
mcp_resources: self.context.mcp_resources.clone(),
},
@@ -102,21 +126,60 @@ impl ToolExecutor<ToolCall> for ReadTool {
"skill provider returned a different resource".to_string(),
));
}
if let Some(state) = self
.context
.thread_state
.shadow_selection_turn(&call.turn_id)
if output_authority == super::SkillToolAuthoritySelector::Orchestrator
&& let Some(state) = self
.context
.thread_state
.shadow_selection_turn(&call.turn_id)
{
self.context
.shadow_selection
.record_invocation(&state, main_prompt.as_str());
}
external_json_output(&ReadResponse {
resource: result.resource.as_str().to_string(),
contents: result.contents,
})
let start = parse_pagination_cursor(
args.cursor.as_deref(),
result.contents.as_str(),
"skills.read",
)?;
if start > result.contents.len() || !result.contents.is_char_boundary(start) {
return Err(FunctionCallError::RespondToModel(
"skills.read cursor is invalid".to_string(),
));
}
let response = page_response(result.resource.as_str(), &result.contents, start)?;
skill_json_output(&response, output_authority)
})
}
}
fn page_response(
resource: &str,
contents: &str,
start: usize,
) -> Result<ReadResponse, FunctionCallError> {
let response = |end, next_cursor| ReadResponse {
resource: resource.to_string(),
contents: contents[start..end].to_string(),
next_cursor,
};
let complete = response(contents.len(), None);
if serialized_len(&complete)? <= MAX_READ_RESPONSE_BYTES {
return Ok(complete);
}
let mut end = contents.len();
while end > start {
end = start + (end - start) / 2;
while !contents.is_char_boundary(end) {
end -= 1;
}
let candidate = response(end, Some(pagination_cursor(contents, end)));
if serialized_len(&candidate)? <= MAX_READ_RESPONSE_BYTES {
return Ok(candidate);
}
}
Err(FunctionCallError::Fatal(
"skill resource handle leaves no room for contents".to_string(),
))
}
@@ -265,6 +265,7 @@ async fn selected_root_id_distinguishes_identical_executor_paths() {
},
})
.collect(),
resolved_executor_roots: Vec::new(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: true,
@@ -327,6 +328,7 @@ async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
.list(SkillListQuery {
turn_id: "turn-1".to_string(),
executor_roots,
resolved_executor_roots: Vec::new(),
host_snapshot: None,
include_host_skills: false,
include_bundled_skills: true,
@@ -343,6 +345,7 @@ async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
authority: entry.authority.clone(),
package: entry.id.clone(),
resource: entry.main_prompt.clone(),
resolved_executor_roots: Vec::new(),
host_snapshot: None,
mcp_resources: None,
};
+18 -5
View File
@@ -560,20 +560,30 @@ async fn extreme_budget_pressure_removes_descriptions_before_omitting_entries()
}
#[tokio::test]
async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResult {
async fn skills_list_only_returns_model_visible_bounded_metadata() -> TestResult {
let description = "x".repeat(1_025);
let opaque_suffix = "\\".repeat(1_500);
let mut entry = test_entry(
SkillSourceKind::Orchestrator,
"codex_apps",
"orchestrator/long-description",
"skill://orchestrator/long-description/SKILL.md",
&format!("orchestrator/{opaque_suffix}"),
&format!("skill://orchestrator/{opaque_suffix}/SKILL.md"),
);
entry.description = description.clone();
let providers =
SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries: vec![entry],
warnings: Vec::new(),
entries: vec![
entry,
test_entry(
SkillSourceKind::Orchestrator,
"codex_apps",
"orchestrator/hidden",
"skill://orchestrator/hidden/SKILL.md",
)
.hidden_from_prompt(),
],
warnings: vec!["w".repeat(256); 4],
},
read_requests: Arc::new(Mutex::new(Vec::new())),
list_calls: None,
@@ -627,6 +637,9 @@ async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResu
.as_str()
.ok_or("skills.list response should include a description")?;
assert_eq!(response["skills"].as_array().map(Vec::len), Some(1));
assert_eq!(response["warnings"].as_array().map(Vec::len), Some(4));
assert_eq!(response["next_cursor"], serde_json::Value::Null);
assert_eq!(rendered_description, "x".repeat(1_021) + "...");
assert_ne!(rendered_description, description);