Enable resource reads for explicit executor skills (#35198)

## Why

Executor skills that disallow implicit invocation are omitted from `skills.list`, so an explicitly selected skill did not provide the authority and package identifiers needed to read its referenced resources.

## What changed

- Add `resource_access` metadata to injected instructions for explicit-only executor skills.
- Teach the `skills.read` description to accept that metadata as well as values returned by `skills.list`.
- Keep discoverable executor skill instructions unchanged.

## Testing

Add an app-server integration scenario that selects an explicit-only executor skill, confirms it remains absent from `skills.list`, and reads a referenced file through `skills.read`.

GitOrigin-RevId: 97d3b93d1618c013363788452303c8a31e32cc56
This commit is contained in:
jif authored and copyberry committed 2026-07-24 14:48:04 +00:00
1 parent 5f6a2c3adb
commit fe8500c0a0
4 files changed
+166 -66

No files matched your search

@@ -28,8 +28,23 @@ const SKILL_MARKER: &str = "EXECUTOR_SKILL_BODY_MARKER";
const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER"; const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER";
const REFERENCE_MARKER: &str = "EXECUTOR_SKILL_REFERENCE_MARKER"; const REFERENCE_MARKER: &str = "EXECUTOR_SKILL_REFERENCE_MARKER";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ExecutorSkillScenario {
VisibleWithBudgetWarning,
ExplicitOnly,
}
#[tokio::test] #[tokio::test]
async fn selected_executor_root_exposes_plugin_skill_and_forwards_budget_warning() -> Result<()> { async fn selected_executor_root_exposes_plugin_skill_and_forwards_budget_warning() -> Result<()> {
exercise_executor_skill(ExecutorSkillScenario::VisibleWithBudgetWarning).await
}
#[tokio::test]
async fn explicit_executor_skill_can_read_referenced_file() -> Result<()> {
exercise_executor_skill(ExecutorSkillScenario::ExplicitOnly).await
}
async fn exercise_executor_skill(scenario: ExecutorSkillScenario) -> Result<()> {
let server = responses::start_mock_server().await; let server = responses::start_mock_server().await;
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
std::fs::write( std::fs::write(
@@ -71,9 +86,10 @@ stream_max_retries = 0
let plugin_dir = auto_env.selection().cwd.join("plugin")?; let plugin_dir = auto_env.selection().cwd.join("plugin")?;
let manifest_dir = plugin_dir.join(".codex-plugin")?; let manifest_dir = plugin_dir.join(".codex-plugin")?;
let skill_dir = plugin_dir.join("skills/deploy")?; let skill_dir = plugin_dir.join("skills/deploy")?;
let agents_dir = skill_dir.join("agents")?;
let reference_dir = skill_dir.join("references")?; let reference_dir = skill_dir.join("references")?;
let file_system = auto_env.environment().get_filesystem(); let file_system = auto_env.environment().get_filesystem();
for directory in [&manifest_dir, &reference_dir] { for directory in [&manifest_dir, &agents_dir, &reference_dir] {
file_system file_system
.create_directory( .create_directory(
directory, directory,
@@ -84,8 +100,14 @@ stream_max_retries = 0
} }
let manifest_path = manifest_dir.join("plugin.json")?; let manifest_path = manifest_dir.join("plugin.json")?;
let skill_path = skill_dir.join("SKILL.md")?; let skill_path = skill_dir.join("SKILL.md")?;
let openai_yaml_path = agents_dir.join("openai.yaml")?;
let reference_path = reference_dir.join("details.md")?; let reference_path = reference_dir.join("details.md")?;
let reference_contents = format!("{REFERENCE_MARKER}\n{}", "x".repeat(600 * 1024)); let reference_size = match scenario {
ExecutorSkillScenario::VisibleWithBudgetWarning => 600 * 1024,
ExecutorSkillScenario::ExplicitOnly => 40 * 1024,
};
let allow_implicit_invocation = scenario == ExecutorSkillScenario::VisibleWithBudgetWarning;
let reference_contents = format!("{REFERENCE_MARKER}\n{}", "x".repeat(reference_size));
tokio::try_join!( tokio::try_join!(
file_system.write_file( file_system.write_file(
&manifest_path, &manifest_path,
@@ -100,43 +122,53 @@ stream_max_retries = 0
.into_bytes(), .into_bytes(),
/*sandbox*/ None, /*sandbox*/ None,
), ),
file_system.write_file(
&openai_yaml_path,
format!(
"policy:\n allow_implicit_invocation: {allow_implicit_invocation}\n"
)
.into_bytes(),
/*sandbox*/ None,
),
file_system.write_file( file_system.write_file(
&reference_path, &reference_path,
reference_contents.into_bytes(), reference_contents.into_bytes(),
/*sandbox*/ None, /*sandbox*/ None,
), ),
)?; )?;
futures::stream::iter(0..200) if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning {
.map(|index| { futures::stream::iter(0..200)
let file_system = file_system.clone(); .map(|index| {
let plugin_dir = plugin_dir.clone(); let file_system = file_system.clone();
async move { let plugin_dir = plugin_dir.clone();
let relative = format!("skills/skill-{index:03}"); async move {
let skill_dir = plugin_dir.join(&relative)?; let relative = format!("skills/skill-{index:03}");
file_system let skill_dir = plugin_dir.join(&relative)?;
.create_directory( file_system
&skill_dir, .create_directory(
CreateDirectoryOptions { recursive: true }, &skill_dir,
/*sandbox*/ None, 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(), .await?;
/*sandbox*/ None, file_system
) .write_file(
.await?; &skill_dir.join("SKILL.md")?,
Ok::<(), anyhow::Error>(()) format!(
} "---\nname: skill-{index:03}\ndescription: {}\n---\n",
}) "x".repeat(1_025)
.buffer_unordered(16) )
.try_collect::<Vec<_>>() .into_bytes(),
.await?; /*sandbox*/ None,
)
.await?;
Ok::<(), anyhow::Error>(())
}
})
.buffer_unordered(16)
.try_collect::<Vec<_>>()
.await?;
}
let authority_id = "demo-plugin@1"; let authority_id = "demo-plugin@1";
let locator = |path: &PathUri| { let locator = |path: &PathUri| {
@@ -231,24 +263,26 @@ stream_max_retries = 0
app_server.read_stream_until_response_message(RequestId::Integer(request_id)), app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
) )
.await??; .await??;
let warning = timeout(READ_TIMEOUT, async { if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning {
loop { let warning = timeout(READ_TIMEOUT, async {
let warning: WarningNotification = app_server.read_notification("warning").await?; loop {
if warning let warning: WarningNotification = app_server.read_notification("warning").await?;
if warning
.message
.starts_with("Exceeded skills context budget.")
{
return Ok::<WarningNotification, anyhow::Error>(warning);
}
}
})
.await??;
assert_eq!(warning.thread_id, Some(thread_id));
assert!(
warning
.message .message
.starts_with("Exceeded skills context budget.") .starts_with("Exceeded skills context budget.")
{ );
return Ok::<WarningNotification, anyhow::Error>(warning); }
}
}
})
.await??;
assert_eq!(warning.thread_id, Some(thread_id));
assert!(
warning
.message
.starts_with("Exceeded skills context budget.")
);
timeout( timeout(
READ_TIMEOUT, READ_TIMEOUT,
app_server.read_stream_until_notification_message("turn/completed"), app_server.read_stream_until_notification_message("turn/completed"),
@@ -275,26 +309,54 @@ stream_max_retries = 0
assert!(skill_fragment.contains(&format!("<name>{SKILL_NAME}</name>"))); assert!(skill_fragment.contains(&format!("<name>{SKILL_NAME}</name>")));
assert!(skill_fragment.contains(SKILL_MARKER)); assert!(skill_fragment.contains(SKILL_MARKER));
assert!(!skill_fragment.contains(LOCAL_SKILL_MARKER)); assert!(!skill_fragment.contains(LOCAL_SKILL_MARKER));
match scenario {
ExecutorSkillScenario::VisibleWithBudgetWarning => {
assert!(!skill_fragment.contains("<resource_access>"));
}
ExecutorSkillScenario::ExplicitOnly => {
let resource_access = skill_fragment
.split_once("<resource_access>")
.and_then(|(_, rest)| rest.split_once("</resource_access>"))
.map(|(metadata, _)| serde_json::from_str::<serde_json::Value>(metadata))
.transpose()?
.expect("explicit executor skill should include resource access metadata");
assert_eq!(
resource_access,
json!({
"authority": {"kind": "executor", "id": authority_id},
"package": package,
"main_resource": main_resource,
})
);
}
}
let list_output = serde_json::from_str::<serde_json::Value>( let list_output = serde_json::from_str::<serde_json::Value>(
&requests[1] &requests[1]
.function_call_output_text("list") .function_call_output_text("list")
.expect("skills.list output"), .expect("skills.list output"),
)?; )?;
let deploy_skill = list_output["skills"] match scenario {
.as_array() ExecutorSkillScenario::VisibleWithBudgetWarning => {
.and_then(|skills| skills.iter().find(|skill| skill["name"] == SKILL_NAME)) let deploy_skill = list_output["skills"]
.expect("skills.list should include the selected executor skill"); .as_array()
assert_eq!( .and_then(|skills| skills.iter().find(|skill| skill["name"] == SKILL_NAME))
deploy_skill, .expect("skills.list should include the selected executor skill");
&json!({ assert_eq!(
"authority": {"kind": "executor", "id": authority_id}, deploy_skill,
"package": package, &json!({
"name": SKILL_NAME, "authority": {"kind": "executor", "id": authority_id},
"description": "Deploy through the executor.", "package": package,
"main_resource": main_resource, "name": SKILL_NAME,
}) "description": "Deploy through the executor.",
); "main_resource": main_resource,
assert!(list_output["next_cursor"].is_string()); })
);
assert!(list_output["next_cursor"].is_string());
}
ExecutorSkillScenario::ExplicitOnly => {
assert_eq!(list_output["skills"], json!([]));
}
}
assert!( assert!(
requests[2] requests[2]
.function_call_output_text("main") .function_call_output_text("main")
@@ -311,7 +373,14 @@ stream_max_retries = 0
.as_str() .as_str()
.is_some_and(|contents| contents.contains(REFERENCE_MARKER)) .is_some_and(|contents| contents.contains(REFERENCE_MARKER))
); );
assert!(reference_output["next_cursor"].is_string()); match scenario {
ExecutorSkillScenario::VisibleWithBudgetWarning => {
assert!(reference_output["next_cursor"].is_string());
}
ExecutorSkillScenario::ExplicitOnly => {
assert!(reference_output["next_cursor"].is_null());
}
}
Ok(()) Ok(())
} }
+8
View File
@@ -36,6 +36,7 @@ use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillReadResult; use crate::catalog::SkillReadResult;
use crate::catalog::SkillSourceKind; use crate::catalog::SkillSourceKind;
use crate::fragments::AvailableSkillsInstructions; use crate::fragments::AvailableSkillsInstructions;
use crate::fragments::ExecutorSkillResourceAccess;
use crate::fragments::SkillInstructions; use crate::fragments::SkillInstructions;
use crate::provider::HostSkillProvider; use crate::provider::HostSkillProvider;
use crate::provider::SkillListQuery; use crate::provider::SkillListQuery;
@@ -464,6 +465,13 @@ where
) )
.0, .0,
contents, contents,
executor_resource_access: (!entry.prompt_visible
&& entry.authority.kind == SkillSourceKind::Executor)
.then(|| ExecutorSkillResourceAccess {
authority_id: entry.authority.id.clone(),
package: entry.id.0.clone(),
main_resource: entry.main_prompt.as_str().to_string(),
}),
}; };
fragments.push(Box::new(fragment)); fragments.push(Box::new(fragment));
main_prompts_injected = true; main_prompts_injected = true;
+24 -1
View File
@@ -77,6 +77,14 @@ pub(crate) struct SkillInstructions {
pub(crate) name: String, pub(crate) name: String,
pub(crate) path: String, pub(crate) path: String,
pub(crate) contents: String, pub(crate) contents: String,
pub(crate) executor_resource_access: Option<ExecutorSkillResourceAccess>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ExecutorSkillResourceAccess {
pub(crate) authority_id: String,
pub(crate) package: String,
pub(crate) main_resource: String,
} }
impl ContextualUserFragment for SkillInstructions { impl ContextualUserFragment for SkillInstructions {
@@ -96,6 +104,21 @@ impl ContextualUserFragment for SkillInstructions {
let name = &self.name; let name = &self.name;
let path = &self.path; let path = &self.path;
let contents = &self.contents; let contents = &self.contents;
format!("\n<name>{name}</name>\n<path>{path}</path>\n{contents}\n") let resource_access = self
.executor_resource_access
.as_ref()
.map(|access| {
let metadata = serde_json::json!({
"authority": {
"kind": "executor",
"id": access.authority_id,
},
"package": access.package,
"main_resource": access.main_resource,
});
format!("\n<resource_access>{metadata}</resource_access>")
})
.unwrap_or_default();
format!("\n<name>{name}</name>\n<path>{path}</path>{resource_access}\n{contents}\n")
} }
} }
+1 -1
View File
@@ -56,7 +56,7 @@ impl ToolExecutor<ToolCall> for ReadTool {
fn spec(&self) -> ToolSpec { fn spec(&self) -> ToolSpec {
skill_function_tool::<ReadArgs, ReadResponse>( skill_function_tool::<ReadArgs, ReadResponse>(
TOOL_NAME, TOOL_NAME,
"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.", "Read one page from a skill resource. Pass the exact authority and package from skills.list or an explicitly selected skill's resource_access metadata, plus its main_resource or a referenced resource beneath that package. Pass next_cursor back as cursor to continue.",
) )
} }