diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d64db0723..51e4f1614 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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]] diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index d1d8d693d..74243b5be 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -64,8 +64,13 @@ const RAW_SKILL_DESCRIPTION: &str = "Deploy\nthrough the 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("")) @@ -186,6 +231,35 @@ async fn codex_apps_resources_support_orchestrator_skills_without_an_environment assert!(skill_fragments[0].contains(&format!("{SKILL_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::(&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::(&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}"), diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml index 6b4affc0c..3ba16fddc 100644 --- a/codex-rs/ext/skills/Cargo.toml +++ b/codex-rs/ext/skills/Cargo.toml @@ -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 } diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 84d7906eb..6bcfe4736 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -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>> { + if !self.providers.has_orchestrator_provider() { + return Vec::new(); + } + + skill_tools( + self.providers.clone(), + session_store.get::(), + ) + } +} + 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); } diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs index e12ff74ee..105c907f0 100644 --- a/codex-rs/ext/skills/src/lib.rs +++ b/codex-rs/ext/skills/src/lib.rs @@ -5,6 +5,7 @@ mod render; mod selection; mod sources; mod state; +mod tools; pub use extension::install; pub use extension::install_with_providers; diff --git a/codex-rs/ext/skills/src/provider/orchestrator.rs b/codex-rs/ext/skills/src/provider/orchestrator.rs index db511c7e0..045b67de6 100644 --- a/codex-rs/ext/skills/src/provider/orchestrator.rs +++ b/codex-rs/ext/skills/src/provider/orchestrator.rs @@ -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 { - 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 ) } -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 { + 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::>(); + !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::>(); + let resource_segments = resource_segments.collect::>(); + + 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 { diff --git a/codex-rs/ext/skills/src/sources.rs b/codex-rs/ext/skills/src/sources.rs index d2aca25e7..d558c13b3 100644 --- a/codex-rs/ext/skills/src/sources.rs +++ b/codex-rs/ext/skills/src/sources.rs @@ -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 diff --git a/codex-rs/ext/skills/src/tools/list.rs b/codex-rs/ext/skills/src/tools/list.rs new file mode 100644 index 000000000..31cb68003 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/list.rs @@ -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, + warnings: Vec, +} + +#[derive(Clone)] +pub(super) struct ListTool { + pub(super) context: SkillToolContext, +} + +impl ToolExecutor for ListTool { + fn tool_name(&self) -> ToolName { + skill_tool_name(TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + skill_function_tool::( + 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 { + 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) -> Vec { + warnings + .into_iter() + .take(MAX_WARNINGS) + .map(|warning| { + let (warning, _) = truncate_utf8_to_bytes(&warning, MAX_WARNING_BYTES); + warning + }) + .collect() +} diff --git a/codex-rs/ext/skills/src/tools/mod.rs b/codex-rs/ext/skills/src/tools/mod.rs new file mode 100644 index 000000000..12b050338 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/mod.rs @@ -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>, +) -> Vec>> { + 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>, +} + +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 { + 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(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::()) + .unwrap_or_else(|err| panic!("generated input schema for {name} should parse: {err}")), + output_schema: Some(schema::output_schema_for::()), + }; + + ToolSpec::Namespace(ResponsesApiNamespace { + name: SKILLS_NAMESPACE.to_string(), + description: default_namespace_description(SKILLS_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + }) +} + +fn parse_args Deserialize<'de>>(call: &ToolCall) -> Result { + 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(value: &T) -> Result, 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())) +} diff --git a/codex-rs/ext/skills/src/tools/read.rs b/codex-rs/ext/skills/src/tools/read.rs new file mode 100644 index 000000000..f58f750f7 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/read.rs @@ -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 for ReadTool { + fn tool_name(&self) -> ToolName { + skill_tool_name(TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + skill_function_tool::( + 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, + }) + }) + } +} diff --git a/codex-rs/ext/skills/src/tools/schema.rs b/codex-rs/ext/skills/src/tools/schema.rs new file mode 100644 index 000000000..d0e9425e4 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/schema.rs @@ -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() -> Value { + schema_for::(/*option_add_null_type*/ false) +} + +pub(super) fn output_schema_for() -> Value { + schema_for::(/*option_add_null_type*/ true) +} + +fn schema_for(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::(); + 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) +} diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index fe57f23ce..f2eed18fa 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -93,6 +93,7 @@ where pub struct JsonToolOutput { value: JsonValue, success: Option, + 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) -> 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()),