mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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(())
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user