Files
codex/codex-rs/ext/skills/src/tools/read.rs
T
jif 09f47c8785 Simplify package-based skill reads (#37808)
## What changed

- Make `package` the only required argument to `skills.read`, resolving the
  owning orchestrator or executor catalog automatically.
- Default omitted `resource` values to the package's main `SKILL.md`.
- Render orchestrator skills as package locators and instruct the model to read
  them directly without first calling `skills.list`.
- Update skill extension and app-server coverage for direct main-resource
  reads, referenced resources, aliases, and the revised tool schema.

GitOrigin-RevId: 9faf57a8d0935566a15094931eaabaa8f0613e9e
2026-08-10 11:10:01 +00:00

215 lines
8.1 KiB
Rust

use codex_extension_api::FunctionCallError;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolExecutorFuture;
use codex_extension_api::ToolName;
use codex_extension_api::ToolSpec;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::catalog::SkillResourceId;
use crate::provider::SkillReadRequest;
use super::MAX_HANDLE_BYTES;
use super::SkillToolAuthority;
use super::SkillToolContext;
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)]
struct ReadArgs {
package: String,
resource: Option<String>,
cursor: Option<String>,
}
#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)]
#[schemars(deny_unknown_fields)]
struct ReadResponse {
resource: String,
contents: String,
next_cursor: Option<String>,
}
#[derive(Clone)]
pub(super) struct ReadTool {
pub(super) context: SkillToolContext,
}
impl ToolExecutor<ToolCall> for ReadTool {
fn tool_name(&self) -> ToolName {
skill_tool_name(TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
skill_function_tool::<ReadArgs, ReadResponse>(
TOOL_NAME,
"Read one page from a skill resource. Pass the exact orchestrator package shown in the skill catalog, or the executor package from skills.list or resource_access metadata. The package determines its owning authority. Omit resource to read the main SKILL.md, or pass the exact identifier of another 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)?;
validate_handle("package", &args.package, MAX_HANDLE_BYTES)?;
if let Some(resource) = args.resource.as_deref() {
validate_handle("resource", resource, MAX_HANDLE_BYTES)?;
}
let mut selected_skill = None;
for selector in [
super::SkillToolAuthoritySelector::Orchestrator,
super::SkillToolAuthoritySelector::Executor,
] {
let catalog = self.context.catalog(&call.turn_id, selector).await;
if let Some(entry) = catalog.entries.into_iter().find(|entry| {
entry.enabled
&& entry.id.0 == args.package
&& SkillToolAuthority::from_authority(&entry.authority)
.is_some_and(|authority| authority.selector() == selector)
}) {
selected_skill = Some((entry, selector));
break;
}
}
let Some((skill_entry, output_authority)) = selected_skill else {
return Err(FunctionCallError::RespondToModel(
"skill package is not available".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 = match args.resource {
None => main_prompt.clone(),
Some(resource) if resource == main_prompt.as_str() => main_prompt.clone(),
Some(resource) => main_prompt
.bind_environment_package_resource(&package, resource.clone())
.unwrap_or_else(|| SkillResourceId::new(resource)),
};
let resolved_executor_roots = self
.context
.executor_query
.as_ref()
.map(|query| query.resolved_executor_roots.clone())
.unwrap_or_default();
let sandbox = requested_resource
.environment_path()
.and_then(|(environment_id, _)| {
self.context.sandbox_contexts.as_ref().and_then(|contexts| {
contexts.get(environment_id).map(|captured| {
call.environments
.iter()
.find(|environment| environment.environment_id == environment_id)
.map(|environment| environment.file_system_sandbox_context.clone())
.unwrap_or_else(|| captured.clone())
})
})
});
if self.context.sandbox_contexts.is_some()
&& requested_resource.environment_path().is_some()
&& sandbox.is_none()
{
return Err(FunctionCallError::RespondToModel(
"failed to read skill resource".to_string(),
));
}
let result = self
.context
.thread_state
.read_skill(
&self.context.providers,
SkillReadRequest {
authority,
package,
resource: requested_resource.clone(),
resolved_executor_roots,
sandbox,
host_snapshot: None,
mcp_resources: self.context.mcp_resources.clone(),
},
)
.await
.map_err(|err| {
tracing::warn!(
error = %err,
turn_id = %call.turn_id,
call_id = %call.call_id,
resource = requested_resource.as_str(),
"skills.read provider request failed"
);
FunctionCallError::RespondToModel("failed to read skill resource".to_string())
})?;
if result.resource != requested_resource {
return Err(FunctionCallError::Fatal(
"skill provider returned a different resource".to_string(),
));
}
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());
}
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(),
))
}