Files
codex/codex-rs/ext/skills/src/tools/read.rs
T
jifandGitHub 0afe559318 skills: cache orchestrator resources per thread (#28336)
## Why

Hosted orchestrator skills are read through the remote MCP resource
server. Within one thread, the same catalog or skill resource can be
requested multiple times by prompt injection and the `skills.list` /
`skills.read` tools. Re-fetching adds latency and can make those
surfaces observe different remote contents during the same thread.

This is a follow-up to #28333: orchestrator skills remain limited to
threads without a local executor, and those threads now get a stable
per-thread view of the remote skill data they use.

## What changed

- Reuse the existing per-thread orchestrator catalog snapshot for
`skills.list` and `skills.read` availability checks.
- Cache successful orchestrator resource reads by authority, package,
and resource so prompt injection and tool calls share the same contents.
- Keep the cache memory-only and bounded to 100 resources and 8 MiB per
thread.
- Leave host and executor skill reads unchanged, and do not cache failed
remote reads.

## Verification

- Extended the app-server MCP resource integration test to read the same
hosted skill resource twice and verify that the remote server receives
one read.
- The same test verifies that catalog discovery and the selected skill's
main prompt are each fetched only once per thread.
2026-06-15 20:20:19 +02:00

113 lines
3.8 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::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::parse_args;
use super::skill_function_tool;
use super::skill_tool_name;
use super::validate_handle;
const TOOL_NAME: &str = "read";
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ReadArgs {
authority: SkillToolAuthority,
package: String,
resource: String,
}
#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)]
#[schemars(deny_unknown_fields)]
struct ReadResponse {
resource: String,
contents: 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 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.",
)
}
fn handle(&self, call: ToolCall) -> ToolExecutorFuture<'_> {
Box::pin(async move {
let args: ReadArgs = parse_args(&call)?;
let authority = args.authority.into_authority();
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 package_is_available = catalog.entries.iter().any(|entry| {
entry.enabled && entry.authority == authority && entry.id.0 == args.package
});
if !package_is_available {
return Err(FunctionCallError::RespondToModel(
"skill package is not available from the requested authority".to_string(),
));
}
let requested_resource = SkillResourceId::new(args.resource);
let result = self
.context
.thread_state
.read_skill(
&self.context.providers,
SkillReadRequest {
authority,
package: SkillPackageId(args.package),
resource: requested_resource.clone(),
host: 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(),
));
}
external_json_output(&ReadResponse {
resource: result.resource.as_str().to_string(),
contents: result.contents,
})
})
}
}