mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
Generated
+5
@@ -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 <hosted> 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}"),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user