skills: expose remote skill resource tools (#27388)

## Why

PR #27387 makes backend plugin skills discoverable and invocable without
an executor, but resources referenced by those skills still sit behind
the generic MCP resource surface. The model needs a skills-owned API
that preserves the provider authority and package boundary instead of
treating remote resources like local files.

This is stacked on #27387.

## What

- Adds one `skills` namespace with bounded `list` and `read` tools for
remote skill providers.
- Revalidates `authority + package` against the live remote catalog on
every read, then routes the opaque resource ID back through that
provider.
- Allows the backend provider to read canonical child `skill://`
resources while rejecting cross-package, non-canonical, query, fragment,
and traversal-shaped URIs.
- Caps each serialized tool result at 8 KB. Lists are paginated; reads
return an opaque continuation cursor.
- Marks the JSON output as external context so memory generation can
apply its normal suppression policy.
- Deliberately does not add `skills.search`; that waits for a bounded
plugin-service search contract.

## Tool contract

Pseudo-Python matching the wire shape:

```python
from typing import Literal, NotRequired, TypedDict


class RemoteSkillAuthority(TypedDict):
    kind: Literal["remote"]
    id: str  # e.g. "codex_apps"


class RemoteSkill(TypedDict):
    authority: RemoteSkillAuthority
    package: str  # opaque provider-owned package ID
    name: str
    description: str
    main_resource: str  # opaque provider-owned SKILL.md ID


class SkillsListParams(TypedDict):
    cursor: NotRequired[str]


class SkillsListResult(TypedDict):
    skills: list[RemoteSkill]
    next_cursor: str | None
    warnings: list[str]
    truncated: bool


class SkillsReadParams(TypedDict):
    authority: RemoteSkillAuthority  # copied from skills.list
    package: str  # copied from skills.list
    resource: str  # provider-owned child resource ID
    cursor: NotRequired[str]  # copy next_cursor to continue


class SkillsReadResult(TypedDict):
    resource: str
    contents: str
    next_cursor: str | None
    truncated: bool


class Skills:
    def list(self, params: SkillsListParams) -> SkillsListResult: ...
    def read(self, params: SkillsReadParams) -> SkillsReadResult: ...
```

There is one namespace for all remote skills, not one tool or MCP server
per skill. No resource ID is converted into a filesystem path.

## Backend dependency

`/ps/mcp` must support direct reads of child resources such as
`skill://plugin_demo/deploy/references/deploy.md`. This PR implements
and tests the Codex side of that contract; production child reads remain
dependent on the corresponding plugin-service support. Search remains
out of scope until that service exposes a bounded search/resource API.

## Validation

- Added an app-server integration test covering `skills.list` followed
by `skills.read` with no executor.
- Ran `just fmt`.
- Ran `just bazel-lock-update` and `just bazel-lock-check`.
- Did not run Rust tests or Clippy locally, per request; CI will run
them.
This commit is contained in:
jif
2026-06-11 11:38:04 +01:00
committed by GitHub
Unverified
parent 273a4aa4f2
commit 7b3eb8e4bc
12 changed files with 624 additions and 21 deletions
+5
View File
@@ -3821,10 +3821,15 @@ dependencies = [
"codex-extension-api",
"codex-mcp",
"codex-protocol",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-string",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
@@ -64,8 +64,13 @@ const RAW_SKILL_DESCRIPTION: &str = "Deploy\nthrough the <hosted> orchestrator."
const SKILL_DESCRIPTION: &str = "Deploy through the &lt;hosted&gt; orchestrator.";
const SKILL_RESOURCE_URI: &str = "skill://plugin_demo/deploy";
const SKILL_MAIN_PROMPT_URI: &str = "skill://plugin_demo/deploy/SKILL.md";
const SKILL_REFERENCE_URI: &str = "skill://plugin_demo/deploy/references/deploy.md";
const SKILL_MARKER: &str = "ORCHESTRATOR_SKILL_BODY_MARKER";
const SKILL_CONTENTS: &str = "---\nname: deploy\ndescription: Deploy through the orchestrator.\n---\n\n# Deploy\n\nORCHESTRATOR_SKILL_BODY_MARKER\n";
const SKILL_REFERENCE_CONTENTS: &str =
"# Deploy reference\n\nUse the orchestrator deployment API.\n";
const SKILLS_LIST_CALL_ID: &str = "skills-list";
const SKILLS_READ_CALL_ID: &str = "skills-read";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
@@ -132,13 +137,47 @@ async fn codex_apps_resources_support_orchestrator_skills_without_an_environment
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let response_mock = responses::mount_sse_once(
let response_mock = responses::mount_sse_sequence(
&responses_server,
responses::sse(vec![
responses::ev_response_created("resp-orchestrator-skill"),
responses::ev_assistant_message("msg-orchestrator-skill", "Done"),
responses::ev_completed("resp-orchestrator-skill"),
]),
vec![
responses::sse(vec![
responses::ev_response_created("resp-skills-list"),
responses::ev_function_call_with_namespace(
SKILLS_LIST_CALL_ID,
"skills",
"list",
&json!({
"authority": {
"kind": "orchestrator",
},
})
.to_string(),
),
responses::ev_completed("resp-skills-list"),
]),
responses::sse(vec![
responses::ev_response_created("resp-skills-read"),
responses::ev_function_call_with_namespace(
SKILLS_READ_CALL_ID,
"skills",
"read",
&json!({
"authority": {
"kind": "orchestrator",
},
"package": SKILL_RESOURCE_URI,
"resource": SKILL_REFERENCE_URI,
})
.to_string(),
),
responses::ev_completed("resp-skills-read"),
]),
responses::sse(vec![
responses::ev_response_created("resp-orchestrator-skill"),
responses::ev_assistant_message("msg-orchestrator-skill", "Done"),
responses::ev_completed("resp-orchestrator-skill"),
]),
],
)
.await;
let turn_start_id = mcp
@@ -162,8 +201,14 @@ async fn codex_apps_resources_support_orchestrator_skills_without_an_environment
)
.await??;
let request = response_mock.single_request();
let developer_messages = request.message_input_texts("developer");
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let first_request = &requests[0];
assert!(first_request.tool_by_name("skills", "list").is_some());
assert!(first_request.tool_by_name("skills", "read").is_some());
assert!(first_request.tool_by_name("skills", "search").is_none());
let developer_messages = first_request.message_input_texts("developer");
let catalog_line = format!("- {SKILL_NAME}: {SKILL_DESCRIPTION} (file: {SKILL_RESOURCE_URI})");
assert_eq!(
1,
@@ -177,7 +222,7 @@ async fn codex_apps_resources_support_orchestrator_skills_without_an_environment
.iter()
.all(|text| !text.contains("ignored-plugin:ignored"))
);
let skill_fragments = request
let skill_fragments = first_request
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<skill>"))
@@ -186,6 +231,35 @@ async fn codex_apps_resources_support_orchestrator_skills_without_an_environment
assert!(skill_fragments[0].contains(&format!("<name>{SKILL_NAME}</name>")));
assert!(skill_fragments[0].contains(SKILL_MARKER));
let list_output = requests[1]
.function_call_output_text(SKILLS_LIST_CALL_ID)
.ok_or_else(|| anyhow::anyhow!("skills.list output should be sent to the model"))?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&list_output)?,
json!({
"skills": [{
"authority": {
"kind": "orchestrator",
},
"package": SKILL_RESOURCE_URI,
"name": SKILL_NAME,
"description": SKILL_DESCRIPTION,
"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"],
})
);
let read_output = requests[2]
.function_call_output_text(SKILLS_READ_CALL_ID)
.ok_or_else(|| anyhow::anyhow!("skills.read output should be sent to the model"))?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&read_output)?,
json!({
"resource": SKILL_REFERENCE_URI,
"contents": SKILL_REFERENCE_CONTENTS,
})
);
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
@@ -461,6 +535,16 @@ impl ServerHandler for ResourceAppsMcpServer {
},
]));
}
if uri == SKILL_REFERENCE_URI {
return Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
uri: SKILL_REFERENCE_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: SKILL_REFERENCE_CONTENTS.to_string(),
meta: None,
},
]));
}
if uri != TEST_RESOURCE_URI {
return Err(rmcp::ErrorData::resource_not_found(
format!("resource not found: {uri}"),
+5
View File
@@ -20,9 +20,14 @@ codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-mcp = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-string = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync", "time"] }
url = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
+23 -1
View File
@@ -15,6 +15,9 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputContributor;
use codex_mcp::McpResourceClient;
@@ -40,6 +43,7 @@ use crate::sources::SkillProviders;
use crate::state::SkillsExtensionConfig;
use crate::state::SkillsThreadState;
use crate::state::SkillsTurnState;
use crate::tools::skill_tools;
#[derive(Clone)]
struct SkillsExtension {
@@ -122,6 +126,23 @@ impl ContextContributor for SkillsExtension {
}
}
impl ToolContributor for SkillsExtension {
fn tools(
&self,
session_store: &ExtensionData,
_thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
if !self.providers.has_orchestrator_provider() {
return Vec::new();
}
skill_tools(
self.providers.clone(),
session_store.get::<McpResourceClient>(),
)
}
}
impl TurnInputContributor for SkillsExtension {
fn contribute<'a>(
&'a self,
@@ -305,5 +326,6 @@ pub fn install_with_providers(
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.prompt_contributor(extension.clone());
registry.turn_input_contributor(extension);
registry.turn_input_contributor(extension.clone());
registry.tool_contributor(extension);
}
+1
View File
@@ -5,6 +5,7 @@ mod render;
mod selection;
mod sources;
mod state;
mod tools;
pub use extension::install;
pub use extension::install_with_providers;
@@ -4,6 +4,7 @@ use std::time::Duration;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceContent;
use url::Url;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
@@ -28,7 +29,9 @@ const MAX_ORCHESTRATOR_SKILLS: usize = 100;
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
const MAX_SKILL_URI_CHARS: usize = 1_024;
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.
///
@@ -156,8 +159,7 @@ impl SkillProvider for OrchestratorSkillProvider {
request.authority.id
)));
}
let expected_resource = main_prompt_uri(&request.package.0);
if request.resource.as_str() != expected_resource {
if !resource_belongs_to_package(&request.package.0, request.resource.as_str()) {
return Err(SkillProviderError::new(
"orchestrator skill resource does not match its package",
));
@@ -199,6 +201,12 @@ impl SkillProvider for OrchestratorSkillProvider {
request.resource.as_str()
)));
};
if contents.len() > MAX_SKILL_RESOURCE_CONTENT_BYTES {
return Err(SkillProviderError::new(format!(
"orchestrator skill resource {} exceeds the {MAX_SKILL_RESOURCE_CONTENT_BYTES}-byte read limit",
request.resource.as_str()
)));
}
Ok(SkillReadResult {
resource: request.resource,
@@ -213,7 +221,7 @@ impl SkillProvider for OrchestratorSkillProvider {
}
fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry> {
let uri = validated_skill_uri(resource.uri.as_str())?;
let uri = validated_skill_uri(resource.uri.as_str(), MAX_SKILL_PACKAGE_URI_CHARS)?;
let meta = resource.meta.as_ref()?.as_object()?;
let skill_name = normalized_label(meta.get("skill_name")?.as_str()?, MAX_SKILL_NAME_CHARS)?;
let name = if meta.get("source").and_then(|value| value.as_str()) == Some("user") {
@@ -240,14 +248,57 @@ fn catalog_entry_from_resource(resource: &Resource) -> Option<SkillCatalogEntry>
)
}
fn validated_skill_uri(uri: &str) -> Option<&str> {
let path = uri.strip_prefix("skill://")?;
let invalid = path.is_empty()
|| uri.chars().count() > MAX_SKILL_URI_CHARS
fn validated_skill_uri(uri: &str, max_chars: usize) -> Option<&str> {
validated_skill_url(uri, max_chars).map(|_| uri)
}
fn validated_skill_url(uri: &str, max_chars: usize) -> Option<Url> {
if uri.chars().count() > max_chars
|| uri
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '<' | '>'));
(!invalid).then_some(uri)
.any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '<' | '>'))
{
return None;
}
let url = Url::parse(uri).ok()?;
let path_is_valid = url.path_segments().is_some_and(|segments| {
let segments = segments.collect::<Vec<_>>();
!segments.is_empty() && segments.iter().all(|segment| !segment.is_empty())
});
(url.scheme() == "skill"
&& url.as_str() == uri
&& url.host_str().is_some_and(|host| !host.is_empty())
&& url.username().is_empty()
&& url.password().is_none()
&& url.port().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
&& path_is_valid)
.then_some(url)
}
fn resource_belongs_to_package(package: &str, resource: &str) -> bool {
let Some(package) = validated_skill_url(package, MAX_SKILL_PACKAGE_URI_CHARS) else {
return false;
};
let Some(resource) = validated_skill_url(resource, MAX_SKILL_RESOURCE_URI_CHARS) else {
return false;
};
let Some(package_segments) = package.path_segments() else {
return false;
};
let Some(resource_segments) = resource.path_segments() else {
return false;
};
let package_segments = package_segments.collect::<Vec<_>>();
let resource_segments = resource_segments.collect::<Vec<_>>();
package.scheme() == resource.scheme()
&& package.host_str() == resource.host_str()
&& resource_segments.len() > package_segments.len()
&& resource_segments.starts_with(&package_segments)
}
fn normalized_label(value: &str, max_chars: usize) -> Option<String> {
+6
View File
@@ -101,6 +101,12 @@ impl SkillProviders {
self
}
pub(crate) fn has_orchestrator_provider(&self) -> bool {
self.sources
.iter()
.any(|source| source.kind == SkillSourceKind::Orchestrator)
}
pub(crate) async fn list_for_turn(&self, query: SkillListQuery) -> SkillCatalog {
self.list_matching(&query, |source| source.should_list(&query))
.await
+112
View File
@@ -0,0 +1,112 @@
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::SkillCatalogEntry;
use crate::render::truncate_utf8_to_bytes;
use super::MAX_HANDLE_BYTES;
use super::SkillToolAuthority;
use super::SkillToolContext;
use super::external_json_output;
use super::is_bounded_handle;
use super::parse_args;
use super::skill_function_tool;
use super::skill_tool_name;
const TOOL_NAME: &str = "list";
const MAX_WARNINGS: usize = 4;
const MAX_WARNING_BYTES: usize = 256;
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListArgs {
authority: SkillToolAuthority,
}
#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)]
#[schemars(deny_unknown_fields)]
struct ListedSkill {
authority: SkillToolAuthority,
package: String,
name: String,
description: String,
main_resource: String,
}
#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)]
#[schemars(deny_unknown_fields)]
struct ListResponse {
skills: Vec<ListedSkill>,
warnings: Vec<String>,
}
#[derive(Clone)]
pub(super) struct ListTool {
pub(super) context: SkillToolContext,
}
impl ToolExecutor<ToolCall> for ListTool {
fn tool_name(&self) -> ToolName {
skill_tool_name(TOOL_NAME)
}
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.",
)
}
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),
};
external_json_output(&response)
})
}
}
fn listed_skill(entry: SkillCatalogEntry) -> Option<ListedSkill> {
let authority = SkillToolAuthority::from_authority(&entry.authority)?;
if !is_bounded_handle(&entry.id.0, MAX_HANDLE_BYTES)
|| !is_bounded_handle(entry.main_prompt.as_str(), MAX_HANDLE_BYTES)
{
return None;
}
Some(ListedSkill {
authority,
package: entry.id.0,
name: entry.name,
description: entry.description,
main_resource: entry.main_prompt.as_str().to_string(),
})
}
fn bounded_warnings(warnings: Vec<String>) -> Vec<String> {
warnings
.into_iter()
.take(MAX_WARNINGS)
.map(|warning| {
let (warning, _) = truncate_utf8_to_bytes(&warning, MAX_WARNING_BYTES);
warning
})
.collect()
}
+160
View File
@@ -0,0 +1,160 @@
use std::sync::Arc;
use codex_extension_api::FunctionCallError;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolSpec;
use codex_extension_api::parse_tool_input_schema;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpResourceClient;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::default_namespace_description;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::sources::SkillProviders;
mod list;
mod read;
mod schema;
const SKILLS_NAMESPACE: &str = "skills";
const MAX_HANDLE_BYTES: usize = 2_048;
pub(crate) fn skill_tools(
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let context = SkillToolContext {
providers,
mcp_resources,
};
vec![
Arc::new(list::ListTool {
context: context.clone(),
}),
Arc::new(read::ReadTool { context }),
]
}
#[derive(Clone)]
struct SkillToolContext {
providers: SkillProviders,
mcp_resources: Option<Arc<McpResourceClient>>,
}
impl SkillToolContext {
async fn catalog(&self, turn_id: &str, authority: SkillToolAuthority) -> SkillCatalog {
match authority {
SkillToolAuthority::Orchestrator => match self
.providers
.list_orchestrator_for_turn(SkillListQuery {
turn_id: turn_id.to_string(),
executor_roots: Vec::new(),
host: None,
include_host_skills: false,
include_bundled_skills: false,
include_orchestrator_skills: true,
mcp_resources: self.mcp_resources.clone(),
})
.await
{
Ok(catalog) => catalog,
Err(err) => SkillCatalog {
warnings: vec![err.message],
..Default::default()
},
},
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum SkillToolAuthority {
Orchestrator,
}
impl SkillToolAuthority {
fn from_authority(authority: &SkillAuthority) -> Option<Self> {
if authority
!= &SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
{
return None;
}
Some(Self::Orchestrator)
}
fn into_authority(self) -> SkillAuthority {
match self {
Self::Orchestrator => {
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
}
}
}
}
fn skill_tool_name(name: &str) -> ToolName {
ToolName::namespaced(SKILLS_NAMESPACE, name)
}
fn skill_function_tool<I: JsonSchema, O: JsonSchema>(name: &str, description: &str) -> ToolSpec {
let tool = ResponsesApiTool {
name: name.to_string(),
description: description.to_string(),
strict: false,
defer_loading: None,
parameters: parse_tool_input_schema(&schema::input_schema_for::<I>())
.unwrap_or_else(|err| panic!("generated input schema for {name} should parse: {err}")),
output_schema: Some(schema::output_schema_for::<O>()),
};
ToolSpec::Namespace(ResponsesApiNamespace {
name: SKILLS_NAMESPACE.to_string(),
description: default_namespace_description(SKILLS_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
})
}
fn parse_args<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> Result<T, FunctionCallError> {
let arguments = call.function_arguments()?;
let value = if arguments.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(arguments)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?
};
serde_json::from_value(value).map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
}
fn validate_handle(name: &str, value: &str, max_bytes: usize) -> Result<(), FunctionCallError> {
if is_bounded_handle(value, max_bytes) {
return Ok(());
}
Err(FunctionCallError::RespondToModel(format!(
"{name} must be non-empty, contain no control characters, and be at most {max_bytes} bytes"
)))
}
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> {
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()))
}
+100
View File
@@ -0,0 +1,100 @@
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
.providers
.read(SkillReadRequest {
authority,
package: SkillPackageId(args.package),
resource: requested_resource.clone(),
host: None,
mcp_resources: self.context.mcp_resources.clone(),
})
.await
.map_err(|err| FunctionCallError::RespondToModel(err.message))?;
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,
})
})
}
}
+42
View File
@@ -0,0 +1,42 @@
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
use serde_json::Map;
use serde_json::Value;
pub(super) fn input_schema_for<T: JsonSchema>() -> Value {
schema_for::<T>(/*option_add_null_type*/ false)
}
pub(super) fn output_schema_for<T: JsonSchema>() -> Value {
schema_for::<T>(/*option_add_null_type*/ true)
}
fn schema_for<T: JsonSchema>(option_add_null_type: bool) -> Value {
let schema = SchemaSettings::draft2019_09()
.with(|settings| {
settings.inline_subschemas = true;
settings.option_add_null_type = option_add_null_type;
})
.into_generator()
.into_root_schema_for::<T>();
let schema_value = serde_json::to_value(schema)
.unwrap_or_else(|err| panic!("generated skill tool schema should serialize: {err}"));
let Value::Object(mut schema_object) = schema_value else {
unreachable!("root tool schema must be an object");
};
let mut tool_schema = Map::new();
for key in [
"properties",
"required",
"type",
"additionalProperties",
"$defs",
"definitions",
] {
if let Some(value) = schema_object.remove(key) {
tool_schema.insert(key.to_string(), value);
}
}
Value::Object(tool_schema)
}
+16 -1
View File
@@ -93,6 +93,7 @@ where
pub struct JsonToolOutput {
value: JsonValue,
success: Option<bool>,
contains_external_context: bool,
}
impl JsonToolOutput {
@@ -100,11 +101,21 @@ impl JsonToolOutput {
Self {
value,
success: Some(true),
contains_external_context: false,
}
}
pub fn with_success(value: JsonValue, success: Option<bool>) -> Self {
Self { value, success }
Self {
value,
success,
contains_external_context: false,
}
}
pub fn with_external_context(mut self) -> Self {
self.contains_external_context = true;
self
}
}
@@ -117,6 +128,10 @@ impl ToolOutput for JsonToolOutput {
self.success.unwrap_or(true)
}
fn contains_external_context(&self) -> bool {
self.contains_external_context
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
let output = FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(self.value.to_string()),