diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 249017125..928d3651b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3710,7 +3710,12 @@ dependencies = [ "codex-core-skills", "codex-extension-api", "codex-protocol", + "codex-tools", + "codex-utils-output-truncation", "pretty_assertions", + "schemars 1.2.1", + "serde", + "serde_json", "tokio", ] diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml index 29767d58f..b52a110c4 100644 --- a/codex-rs/ext/skills/Cargo.toml +++ b/codex-rs/ext/skills/Cargo.toml @@ -19,7 +19,12 @@ codex-core = { workspace = true } codex-core-skills = { workspace = true } codex-extension-api = { workspace = true } codex-protocol = { workspace = true } +codex-tools = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } [dev-dependencies] +codex-utils-output-truncation = { workspace = true } pretty_assertions = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 3961d7d85..154f57273 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -12,8 +12,15 @@ use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; 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::TurnAbortInput; +use codex_extension_api::TurnErrorInput; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; +use codex_extension_api::TurnLifecycleContributor; +use codex_extension_api::TurnStopInput; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::WarningEvent; @@ -32,6 +39,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 { @@ -67,6 +75,35 @@ impl ConfigContributor for SkillsExtension { } } +impl ToolContributor for SkillsExtension { + fn tools( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>> { + let Some(thread_state) = thread_store.get::() else { + return Vec::new(); + }; + + skill_tools(thread_state) + } +} + +#[async_trait::async_trait] +impl TurnLifecycleContributor for SkillsExtension { + async fn on_turn_stop(&self, input: TurnStopInput<'_>) { + clear_tool_snapshot(input.thread_store, input.turn_store.level_id()); + } + + async fn on_turn_abort(&self, input: TurnAbortInput<'_>) { + clear_tool_snapshot(input.thread_store, input.turn_store.level_id()); + } + + async fn on_turn_error(&self, input: TurnErrorInput<'_>) { + clear_tool_snapshot(input.thread_store, input.turn_id); + } +} + #[async_trait::async_trait] impl TurnInputContributor for SkillsExtension { async fn contribute( @@ -99,10 +136,16 @@ impl TurnInputContributor for SkillsExtension { include_bundled_skills: config.bundled_skills_enabled, include_remote_skills: true, }; - let catalog = self.providers.list_for_turn(query).await; + let (catalog, routes) = self.providers.list_for_turn_with_routes(query).await; for warning in &catalog.warnings { self.emit_warning(&input.turn_id, warning.clone()); } + thread_state.set_tool_snapshot( + input.turn_id.clone(), + catalog.clone(), + host_loaded_skills.clone(), + routes.clone(), + ); let selected_entries = collect_explicit_skill_mentions(&input.user_input, &catalog); let mut fragments: Vec> = Vec::new(); @@ -117,7 +160,7 @@ impl TurnInputContributor for SkillsExtension { let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { match self - .read_main_prompt(entry, host_loaded_skills.clone()) + .read_main_prompt(entry, host_loaded_skills.clone(), &routes) .await { Ok(read_result) => { @@ -169,8 +212,9 @@ impl SkillsExtension { &self, entry: &SkillCatalogEntry, host_loaded_skills: Option>, + routes: &crate::sources::SkillProviderRoutes, ) -> Result { - self.providers + routes .read(SkillReadRequest { authority: entry.authority.clone(), package: entry.id.clone(), @@ -189,6 +233,12 @@ impl SkillsExtension { } } +fn clear_tool_snapshot(thread_store: &ExtensionData, turn_id: &str) { + if let Some(thread_state) = thread_store.get::() { + thread_state.clear_tool_snapshot(turn_id); + } +} + pub fn install(registry: &mut ExtensionRegistryBuilder) { install_with_providers( registry, @@ -205,6 +255,8 @@ pub fn install_with_providers( event_sink: registry.event_sink(), }); registry.thread_lifecycle_contributor(extension.clone()); + registry.turn_lifecycle_contributor(extension.clone()); registry.config_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 3a28c5fbd..404793f9c 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/render.rs b/codex-rs/ext/skills/src/render.rs index 187f908ca..41c14c82a 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -45,7 +45,7 @@ pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option MAX_AVAILABLE_SKILLS_CHARS { omitted = omitted.saturating_add(1); @@ -70,11 +70,15 @@ pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option String { +fn render_skill_line(entry: &crate::catalog::SkillCatalogEntry, description: &str) -> String { + let file = format!("file: {}", entry.rendered_path()); + let handles = crate::tools::catalog_tool_handles(entry).map_or(file.clone(), |tool_handles| { + format!("{file}; {tool_handles}") + }); if description.is_empty() { - format!("- {name}: (file: {path})") + format!("- {}: ({handles})", entry.name) } else { - format!("- {name}: {description} (file: {path})") + format!("- {}: {description} ({handles})", entry.name) } } diff --git a/codex-rs/ext/skills/src/sources.rs b/codex-rs/ext/skills/src/sources.rs index 9049fcb55..753557b8c 100644 --- a/codex-rs/ext/skills/src/sources.rs +++ b/codex-rs/ext/skills/src/sources.rs @@ -1,7 +1,9 @@ use std::fmt; use std::sync::Arc; +use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalog; +use crate::catalog::SkillPackageId; use crate::catalog::SkillProviderError; use crate::catalog::SkillReadResult; use crate::catalog::SkillSearchResult; @@ -72,6 +74,84 @@ pub struct SkillProviders { sources: Vec, } +#[derive(Clone, Default)] +pub(crate) struct SkillProviderRoutes { + routes: Vec, +} + +impl fmt::Debug for SkillProviderRoutes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SkillProviderRoutes") + .field("route_count", &self.routes.len()) + .finish() + } +} + +#[derive(Clone)] +struct SkillProviderRoute { + authority: SkillAuthority, + package: SkillPackageId, + provider: Arc, +} + +impl SkillProviderRoutes { + fn push( + &mut self, + authority: SkillAuthority, + package: SkillPackageId, + provider: Arc, + ) { + if self + .routes + .iter() + .any(|route| route.authority == authority && route.package == package) + { + return; + } + self.routes.push(SkillProviderRoute { + authority, + package, + provider, + }); + } + + fn provider( + &self, + authority: &SkillAuthority, + package: &SkillPackageId, + ) -> Option> { + self.routes + .iter() + .find(|route| &route.authority == authority && &route.package == package) + .map(|route| Arc::clone(&route.provider)) + } + + pub(crate) async fn read( + &self, + request: SkillReadRequest, + ) -> Result { + let Some(provider) = self.provider(&request.authority, &request.package) else { + return Err(SkillProviderError::new( + "skill package is not available from the requested authority", + )); + }; + provider.read(request).await + } + + pub(crate) async fn search( + &self, + request: SkillSearchRequest, + ) -> Result { + let Some(provider) = self.provider(&request.authority, &request.package) else { + return Err(SkillProviderError::new( + "skill package is not available from the requested authority", + )); + }; + provider.search(request).await + } +} + impl SkillProviders { pub fn new() -> Self { Self::default() @@ -100,47 +180,43 @@ impl SkillProviders { self } - pub(crate) async fn list_for_turn(&self, query: SkillListQuery) -> SkillCatalog { + pub(crate) async fn list_for_turn_with_routes( + &self, + query: SkillListQuery, + ) -> (SkillCatalog, SkillProviderRoutes) { let mut catalog = SkillCatalog::default(); + let mut routes = SkillProviderRoutes::default(); for source in self .sources .iter() .filter(|source| source.should_list(&query)) { - extend_catalog( - &mut catalog, - source.provider.list(query.clone()).await, - source.label.as_str(), - ); - } - - catalog - } - - pub(crate) async fn read( - &self, - request: SkillReadRequest, - ) -> Result { - let mut last_error = None; - for source in self - .sources - .iter() - .filter(|source| source.owns_kind(&request.authority.kind)) - { - match source.provider.read(request.clone()).await { - Ok(result) => return Ok(result), - Err(err) => last_error = Some(err), + match source.provider.list(query.clone()).await { + Ok(source_catalog) => { + for entry in source_catalog.entries { + let entry_is_new = !catalog.entries.iter().any(|existing| { + existing.authority == entry.authority && existing.id == entry.id + }); + if entry_is_new { + routes.push( + entry.authority.clone(), + entry.id.clone(), + Arc::clone(&source.provider), + ); + catalog.push_entry(entry); + } + } + catalog.warnings.extend(source_catalog.warnings); + } + Err(err) => catalog.warnings.push(format!( + "{} skills unavailable: {}", + source.label, err.message + )), } } - match last_error { - Some(err) => Err(err), - None => Err(SkillProviderError::new(format!( - "{} skill provider is not configured", - request.authority.kind - ))), - } + (catalog, routes) } pub async fn search( @@ -168,16 +244,3 @@ impl SkillProviders { } } } - -fn extend_catalog( - catalog: &mut SkillCatalog, - result: Result, - label: &str, -) { - match result { - Ok(source_catalog) => catalog.extend(source_catalog), - Err(err) => catalog - .warnings - .push(format!("{label} skills unavailable: {}", err.message)), - } -} diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index 4a639eb5f..16430cfe9 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -1,8 +1,11 @@ use codex_core::config::Config; +use codex_core_skills::HostLoadedSkills; +use std::sync::Arc; use std::sync::Mutex; use crate::catalog::SkillCatalog; use crate::catalog::SkillCatalogEntry; +use crate::sources::SkillProviderRoutes; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SkillsExtensionConfig { @@ -22,12 +25,14 @@ impl SkillsExtensionConfig { #[derive(Debug)] pub(crate) struct SkillsThreadState { config: Mutex, + tool_snapshot: Mutex>, } impl SkillsThreadState { pub(crate) fn new(config: SkillsExtensionConfig) -> Self { Self { config: Mutex::new(config), + tool_snapshot: Mutex::new(None), } } @@ -44,6 +49,54 @@ impl SkillsThreadState { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = config; } + + pub(crate) fn set_tool_snapshot( + &self, + turn_id: String, + catalog: SkillCatalog, + host: Option>, + routes: SkillProviderRoutes, + ) { + *self + .tool_snapshot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(SkillsToolSnapshot { + turn_id, + catalog, + host, + routes, + }); + } + + pub(crate) fn tool_snapshot(&self, turn_id: &str) -> Option { + self.tool_snapshot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .filter(|snapshot| snapshot.turn_id == turn_id) + .cloned() + } + + pub(crate) fn clear_tool_snapshot(&self, turn_id: &str) { + let mut snapshot = self + .tool_snapshot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.turn_id == turn_id) + { + *snapshot = None; + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SkillsToolSnapshot { + pub(crate) turn_id: String, + pub(crate) catalog: SkillCatalog, + pub(crate) host: Option>, + pub(crate) routes: SkillProviderRoutes, } #[derive(Clone, Debug, Default, PartialEq, Eq)] 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..dd7f19ca0 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/mod.rs @@ -0,0 +1,239 @@ +use std::sync::Arc; + +use codex_extension_api::FunctionCallError; +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::ToolPayload; +use codex_extension_api::ToolSpec; +use codex_extension_api::parse_tool_input_schema; +use codex_protocol::models::ResponseInputItem; +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::SkillCatalogEntry; +use crate::catalog::SkillSourceKind; +use crate::state::SkillsThreadState; +use crate::state::SkillsToolSnapshot; + +mod read; +mod schema; +mod search; + +const SKILLS_NAMESPACE: &str = "skills"; +const MAX_ARGUMENT_BYTES: usize = 16 * 1024; +const MAX_HANDLE_BYTES: usize = 2_048; +const MAX_OUTPUT_BYTES: usize = 32 * 1024; + +pub(crate) fn skill_tools( + thread_state: Arc, +) -> Vec>> { + let context = SkillToolContext { thread_state }; + vec![ + Arc::new(search::SearchTool { + context: context.clone(), + }), + Arc::new(read::ReadTool { context }), + ] +} + +#[derive(Clone)] +struct SkillToolContext { + thread_state: Arc, +} + +impl SkillToolContext { + fn snapshot(&self, turn_id: &str) -> Result { + self.thread_state.tool_snapshot(turn_id).ok_or_else(|| { + FunctionCallError::RespondToModel( + "skill resources are unavailable because the current turn catalog is not loaded" + .to_string(), + ) + }) + } + + fn available_package<'a>( + &self, + snapshot: &'a SkillsToolSnapshot, + authority: &SkillAuthority, + package: &str, + ) -> Option<&'a SkillCatalogEntry> { + snapshot.catalog.entries.iter().find(|entry| { + entry.is_explicitly_loadable() && &entry.authority == authority && entry.id.0 == package + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[schemars(deny_unknown_fields)] +#[serde(deny_unknown_fields)] +pub(crate) struct SkillToolAuthority { + kind: SkillToolAuthorityKind, + id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +enum SkillToolAuthorityKind { + Host, + Executor, + Remote, + Custom(String), +} + +impl SkillToolAuthority { + pub(crate) fn from_authority(authority: &SkillAuthority) -> Self { + Self { + kind: match &authority.kind { + SkillSourceKind::Host => SkillToolAuthorityKind::Host, + SkillSourceKind::Executor => SkillToolAuthorityKind::Executor, + SkillSourceKind::Remote => SkillToolAuthorityKind::Remote, + SkillSourceKind::Custom(kind) => SkillToolAuthorityKind::Custom(kind.clone()), + }, + id: authority.id.clone(), + } + } + + fn to_authority(&self) -> Result { + validate_handle("authority.id", &self.id, MAX_HANDLE_BYTES)?; + let kind = match &self.kind { + SkillToolAuthorityKind::Host => SkillSourceKind::Host, + SkillToolAuthorityKind::Executor => SkillSourceKind::Executor, + SkillToolAuthorityKind::Remote => SkillSourceKind::Remote, + SkillToolAuthorityKind::Custom(kind) => { + validate_handle("authority.kind.value", kind, MAX_HANDLE_BYTES)?; + SkillSourceKind::custom(kind.clone()) + } + }; + Ok(SkillAuthority::new(kind, self.id.clone())) + } +} + +pub(crate) fn catalog_tool_handles(entry: &SkillCatalogEntry) -> Option { + let authority = SkillToolAuthority::from_authority(&entry.authority); + authority.to_authority().ok()?; + if !is_bounded_handle(&entry.id.0, MAX_HANDLE_BYTES) + || !is_bounded_handle(&entry.main_prompt.0, MAX_HANDLE_BYTES) + { + return None; + } + let authority = serde_json::to_string(&authority).ok()?; + let package = serde_json::to_string(&entry.id.0).ok()?; + let main_resource = serde_json::to_string(&entry.main_prompt.0).ok()?; + Some(format!( + "authority: {authority}; package: {package}; main resource: {main_resource}" + )) +} + +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()?; + if arguments.len() > MAX_ARGUMENT_BYTES { + return Err(FunctionCallError::RespondToModel(format!( + "skill tool arguments must be at most {MAX_ARGUMENT_BYTES} bytes" + ))); + } + 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 bounded_text(value: &str, max_bytes: usize) -> (String, bool) { + if value.len() <= max_bytes { + return (value.to_string(), false); + } + + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end = end.saturating_sub(1); + } + (value[..end].to_string(), true) +} + +fn serialized_len(value: &T) -> Result { + serde_json::to_vec(value) + .map(|value| value.len()) + .map_err(|err| FunctionCallError::Fatal(format!("failed to serialize tool output: {err}"))) +} + +fn 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(SkillJsonToolOutput { value })) +} + +struct SkillJsonToolOutput { + value: Value, +} + +impl ToolOutput for SkillJsonToolOutput { + fn log_preview(&self) -> String { + "[skill resource output]".to_string() + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + codex_extension_api::JsonToolOutput::new(self.value.clone()) + .to_response_item(call_id, payload) + } + + fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option { + codex_extension_api::JsonToolOutput::new(self.value.clone()) + .post_tool_use_response(call_id, payload) + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> Value { + self.value.clone() + } +} 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..c179a02e9 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/read.rs @@ -0,0 +1,137 @@ +use codex_extension_api::FunctionCallError; +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_tools::ToolExposure; +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::MAX_OUTPUT_BYTES; +use super::SkillToolAuthority; +use super::SkillToolContext; +use super::bounded_text; +use super::json_output; +use super::parse_args; +use super::serialized_len; +use super::skill_function_tool; +use super::skill_tool_name; +use super::validate_handle; + +const TOOL_NAME: &str = "read"; +const MAX_CONTENT_BYTES: usize = 24 * 1024; + +#[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 { + authority: SkillToolAuthority, + package: String, + resource: String, + contents: String, + truncated: bool, +} + +#[derive(Clone)] +pub(super) struct ReadTool { + pub(super) context: SkillToolContext, +} + +#[async_trait::async_trait] +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 resource from an available skill package. Pass the exact opaque authority, package, and resource identifiers returned by the skills catalog or skills.search; never convert them into local paths. The returned contents are bounded.", + ) + } + + fn exposure(&self) -> ToolExposure { + ToolExposure::DirectModelOnly + } + + fn supports_parallel_tool_calls(&self) -> bool { + true + } + + async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { + let args: ReadArgs = parse_args(&call)?; + let authority = args.authority.to_authority()?; + validate_handle("package", &args.package, MAX_HANDLE_BYTES)?; + validate_handle("resource", &args.resource, MAX_HANDLE_BYTES)?; + let snapshot = self.context.snapshot(&call.turn_id)?; + if self + .context + .available_package(&snapshot, &authority, &args.package) + .is_none() + { + return Err(FunctionCallError::RespondToModel( + "skill package is not available from the requested authority in this turn" + .to_string(), + )); + } + + let requested_resource = SkillResourceId(args.resource); + let routes = snapshot.routes.clone(); + let result = routes + .read(SkillReadRequest { + authority: authority.clone(), + package: SkillPackageId(args.package.clone()), + resource: requested_resource.clone(), + host: snapshot.host.clone(), + }) + .await + .map_err(|_| { + FunctionCallError::RespondToModel( + "skill provider could not read the requested resource".to_string(), + ) + })?; + if result.resource != requested_resource { + return Err(FunctionCallError::Fatal( + "skill provider returned a different resource".to_string(), + )); + } + + let (contents, truncated) = bounded_text(&result.contents, MAX_CONTENT_BYTES); + let mut response = ReadResponse { + authority: SkillToolAuthority::from_authority(&authority), + package: args.package, + resource: result.resource.0, + contents, + truncated, + }; + while serialized_len(&response)? > MAX_OUTPUT_BYTES { + let serialized_bytes = serialized_len(&response)?; + let bytes_to_remove = serialized_bytes.saturating_sub(MAX_OUTPUT_BYTES).max(1); + let next_max = response.contents.len().saturating_sub(bytes_to_remove); + let (contents, _) = bounded_text(&response.contents, next_max); + if contents.len() == response.contents.len() { + return Err(FunctionCallError::Fatal( + "bounded skill read metadata exceeds the tool output limit".to_string(), + )); + } + response.contents = contents; + response.truncated = true; + } + + json_output(&response) + } +} 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..d52c3db12 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/schema.rs @@ -0,0 +1,41 @@ +use schemars::JsonSchema; +use schemars::generate::SchemaSettings; +use serde_json::Map; +use serde_json::Value; + +pub(super) fn input_schema_for() -> Value { + schema_for::() +} + +pub(super) fn output_schema_for() -> Value { + schema_for::() +} + +fn schema_for() -> Value { + let schema = SchemaSettings::draft2019_09() + .with(|settings| { + settings.inline_subschemas = true; + }) + .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/ext/skills/src/tools/search.rs b/codex-rs/ext/skills/src/tools/search.rs new file mode 100644 index 000000000..50f07d7b7 --- /dev/null +++ b/codex-rs/ext/skills/src/tools/search.rs @@ -0,0 +1,163 @@ +use codex_extension_api::FunctionCallError; +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_tools::ToolExposure; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +use crate::catalog::SkillPackageId; +use crate::provider::SkillSearchRequest; + +use super::MAX_HANDLE_BYTES; +use super::MAX_OUTPUT_BYTES; +use super::SkillToolAuthority; +use super::SkillToolContext; +use super::bounded_text; +use super::is_bounded_handle; +use super::json_output; +use super::parse_args; +use super::serialized_len; +use super::skill_function_tool; +use super::skill_tool_name; +use super::validate_handle; + +const TOOL_NAME: &str = "search"; +const MAX_QUERY_BYTES: usize = 1_024; +const MAX_MATCHES: usize = 20; +const MAX_INSPECTED_MATCHES: usize = 100; +const MAX_TITLE_BYTES: usize = 512; +const MAX_SNIPPET_BYTES: usize = 2_048; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SearchArgs { + authority: SkillToolAuthority, + package: String, + query: String, +} + +#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)] +#[schemars(deny_unknown_fields)] +struct SearchMatch { + resource: String, + title: String, + snippet: String, +} + +#[derive(Debug, Eq, JsonSchema, PartialEq, Serialize)] +#[schemars(deny_unknown_fields)] +struct SearchResponse { + authority: SkillToolAuthority, + package: String, + matches: Vec, + truncated: bool, +} + +#[derive(Clone)] +pub(super) struct SearchTool { + pub(super) context: SkillToolContext, +} + +#[async_trait::async_trait] +impl ToolExecutor for SearchTool { + fn tool_name(&self) -> ToolName { + skill_tool_name(TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + skill_function_tool::( + TOOL_NAME, + "Search one available skill package for supporting resources. Copy the opaque authority and package identifiers from the current skills catalog; do not derive paths from them. Results are bounded and return opaque resource identifiers for skills.read.", + ) + } + + fn exposure(&self) -> ToolExposure { + ToolExposure::DirectModelOnly + } + + fn supports_parallel_tool_calls(&self) -> bool { + true + } + + async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { + let args: SearchArgs = parse_args(&call)?; + let authority = args.authority.to_authority()?; + validate_handle("package", &args.package, MAX_HANDLE_BYTES)?; + validate_query(&args.query)?; + let snapshot = self.context.snapshot(&call.turn_id)?; + if self + .context + .available_package(&snapshot, &authority, &args.package) + .is_none() + { + return Err(FunctionCallError::RespondToModel( + "skill package is not available from the requested authority in this turn" + .to_string(), + )); + } + + let result = snapshot + .routes + .search(SkillSearchRequest { + authority: authority.clone(), + package: SkillPackageId(args.package.clone()), + query: args.query, + }) + .await + .map_err(|_| { + FunctionCallError::RespondToModel( + "skill provider could not search the requested package".to_string(), + ) + })?; + + let mut response = SearchResponse { + authority: SkillToolAuthority::from_authority(&authority), + package: args.package, + matches: Vec::new(), + truncated: result.matches.len() > MAX_INSPECTED_MATCHES, + }; + for search_match in result.matches.into_iter().take(MAX_INSPECTED_MATCHES) { + if response.matches.len() == MAX_MATCHES { + response.truncated = true; + break; + } + if !is_bounded_handle(&search_match.resource.0, MAX_HANDLE_BYTES) { + response.truncated = true; + continue; + } + let (title, title_truncated) = bounded_text(&search_match.title, MAX_TITLE_BYTES); + let (snippet, snippet_truncated) = + bounded_text(&search_match.snippet, MAX_SNIPPET_BYTES); + response.matches.push(SearchMatch { + resource: search_match.resource.0, + title, + snippet, + }); + response.truncated |= title_truncated || snippet_truncated; + if serialized_len(&response)? > MAX_OUTPUT_BYTES { + response.matches.pop(); + response.truncated = true; + break; + } + } + + json_output(&response) + } +} + +fn validate_query(query: &str) -> Result<(), FunctionCallError> { + if !query.trim().is_empty() + && query.len() <= MAX_QUERY_BYTES + && !query.chars().any(char::is_control) + { + return Ok(()); + } + + Err(FunctionCallError::RespondToModel(format!( + "query must contain non-whitespace text, contain no control characters, and be at most {MAX_QUERY_BYTES} bytes" + ))) +} diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 283bd2272..2f9f76ef6 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -11,22 +11,33 @@ use codex_core_skills::SkillsLoadInput; use codex_core_skills::SkillsManager; use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::FunctionCallError; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolPayload; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; +use codex_extension_api::TurnStopInput; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use codex_skills_extension::HostSkillProvider; +use codex_skills_extension::SkillProviderSource; use codex_skills_extension::SkillProviders; use codex_skills_extension::catalog::SkillAuthority; use codex_skills_extension::catalog::SkillAvailability; use codex_skills_extension::catalog::SkillCatalog; use codex_skills_extension::catalog::SkillCatalogEntry; use codex_skills_extension::catalog::SkillPackageId; +use codex_skills_extension::catalog::SkillProviderError; use codex_skills_extension::catalog::SkillReadResult; use codex_skills_extension::catalog::SkillResourceId; +use codex_skills_extension::catalog::SkillSearchMatch; use codex_skills_extension::catalog::SkillSearchResult; use codex_skills_extension::catalog::SkillSourceKind; use codex_skills_extension::install; @@ -36,7 +47,10 @@ use codex_skills_extension::provider::SkillProvider; use codex_skills_extension::provider::SkillProviderFuture; use codex_skills_extension::provider::SkillReadRequest; use codex_skills_extension::provider::SkillSearchRequest; +use codex_utils_output_truncation::TruncationPolicy; use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; type TestResult = Result<(), Box>; @@ -125,6 +139,28 @@ async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult .ok_or("host skill prompt marker should be set")?; assert!(injected_host_skill_prompts.contains_path(&skill_path_string)); + let read_tool = find_tool( + ®istry, + &session_store, + &thread_store, + ToolName::namespaced("skills", "read"), + ); + let read_output = call_tool( + read_tool, + "turn-1", + json!({ + "authority": { "kind": { "type": "host" }, "id": "host" }, + "package": &skill_path_string, + "resource": &skill_path_string, + }), + ) + .await?; + assert_eq!( + read_output["contents"].as_str(), + Some("---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n") + ); + assert_eq!(read_output["truncated"], false); + std::fs::remove_dir_all(codex_home)?; Ok(()) } @@ -429,6 +465,445 @@ async fn deferred_skill_is_searchable_and_loadable_but_disabled_skill_is_not() - Ok(()) } +#[tokio::test] +async fn model_tools_route_exact_packages_and_bound_results() -> TestResult { + let first_provider = Arc::new(ToolSkillProvider::new( + test_entry( + SkillSourceKind::Remote, + "catalog-a", + "package-a", + "resource-a/SKILL.md", + ), + "first provider contents", + vec![SkillSearchMatch { + resource: SkillResourceId("resource-a/reference.md".to_string()), + title: "first".to_string(), + snippet: "first".to_string(), + }], + )); + let second_matches = (0..30) + .map(|index| SkillSearchMatch { + resource: SkillResourceId(format!("resource-b/reference-{index}.md")), + title: format!("Reference {index}"), + snippet: "large \\\"snippet\\\" ".repeat(300), + }) + .collect(); + let second_provider = Arc::new(ToolSkillProvider::new( + test_entry( + SkillSourceKind::Remote, + "catalog-b", + "package-b", + "resource-b/SKILL.md", + ), + &"large \\\"contents\\\" ".repeat(4_000), + second_matches, + )); + let custom_provider = Arc::new(ToolSkillProvider::new( + test_entry( + SkillSourceKind::custom("host"), + "custom-catalog", + "custom-package", + "custom/SKILL.md", + ), + "custom provider contents", + vec![SkillSearchMatch { + resource: SkillResourceId("custom/reference.md".to_string()), + title: "custom".to_string(), + snippet: "custom".to_string(), + }], + )); + let invalid_provider = Arc::new(ToolSkillProvider::new( + test_entry( + SkillSourceKind::Remote, + "invalid-catalog", + "invalid-package", + "invalid/SKILL.md", + ), + "invalid provider contents", + (0..101) + .map(|_| SkillSearchMatch { + resource: SkillResourceId("x".repeat(2_049)), + title: "invalid".to_string(), + snippet: "invalid".to_string(), + }) + .collect(), + )); + let providers = SkillProviders::new() + .with_remote_provider(first_provider.clone()) + .with_remote_provider(second_provider.clone()) + .with_provider(SkillProviderSource::new( + SkillSourceKind::custom("host"), + "custom", + custom_provider.clone(), + )) + .with_remote_provider(invalid_provider); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config().await?; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + let turn_store = ExtensionData::new("turn-tools"); + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-tools".to_string(), + user_input: vec![UserInput::Text { + text: "inspect the package".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &turn_store, + ) + .await; + assert!(fragments[0].render().contains( + r#"authority: {"kind":{"type":"custom","value":"host"},"id":"custom-catalog"}; package: "custom-package""# + )); + + let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store); + assert_eq!( + tools + .iter() + .map(|tool| tool.tool_name()) + .collect::>(), + vec![ + ToolName::namespaced("skills", "search"), + ToolName::namespaced("skills", "read"), + ] + ); + for tool in &tools { + assert_eq!(tool.exposure(), codex_tools::ToolExposure::DirectModelOnly); + assert!(tool.supports_parallel_tool_calls()); + let codex_extension_api::ToolSpec::Namespace(spec) = tool.spec() else { + panic!("skill model tools should share a namespace"); + }; + assert_eq!(spec.name, "skills"); + assert_eq!(spec.tools.len(), 1); + } + + let custom_output = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { + "kind": { "type": "custom", "value": "host" }, + "id": "custom-catalog" + }, + "package": "custom-package", + "query": "custom reference", + }), + ) + .await?; + assert_eq!( + custom_output["matches"][0]["resource"], + "custom/reference.md" + ); + assert_eq!( + custom_provider.search_requests(), + vec![SkillSearchRequest { + authority: SkillAuthority::new(SkillSourceKind::custom("host"), "custom-catalog"), + package: SkillPackageId("custom-package".to_string()), + query: "custom reference".to_string(), + }] + ); + + let provider_search_error = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "query": "provider-error", + }), + ) + .await; + assert_eq!( + provider_search_error, + Err(FunctionCallError::RespondToModel( + "skill provider could not search the requested package".to_string() + )) + ); + + let provider_read_error = call_tool( + Arc::clone(&tools[1]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "resource": "provider-error", + }), + ) + .await; + assert_eq!( + provider_read_error, + Err(FunctionCallError::RespondToModel( + "skill provider could not read the requested resource".to_string() + )) + ); + + let invalid_flood = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "invalid-catalog" }, + "package": "invalid-package", + "query": "invalid resources", + }), + ) + .await?; + assert_eq!(invalid_flood["matches"], json!([])); + assert_eq!(invalid_flood["truncated"], true); + + let oversized_arguments = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "query": "x".repeat(17 * 1024), + }), + ) + .await; + assert_eq!( + oversized_arguments, + Err(FunctionCallError::RespondToModel( + "skill tool arguments must be at most 16384 bytes".to_string() + )) + ); + + let wrong_resource = call_tool( + Arc::clone(&tools[1]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "resource": "mismatch", + }), + ) + .await; + assert_eq!( + wrong_resource, + Err(FunctionCallError::Fatal( + "skill provider returned a different resource".to_string() + )) + ); + + let search_output = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "query": "deployment references", + }), + ) + .await?; + assert_eq!( + search_output["authority"], + json!({ "kind": { "type": "remote" }, "id": "catalog-b" }) + ); + assert_eq!(search_output["package"], "package-b"); + assert_eq!(search_output["truncated"], true); + assert!( + search_output["matches"] + .as_array() + .is_some_and(|matches| !matches.is_empty() && matches.len() <= 20) + ); + assert!(serde_json::to_vec(&search_output)?.len() <= 32 * 1024); + assert!(first_provider.search_requests().is_empty()); + assert_eq!( + second_provider.search_requests(), + vec![ + SkillSearchRequest { + authority: SkillAuthority::new(SkillSourceKind::Remote, "catalog-b"), + package: SkillPackageId("package-b".to_string()), + query: "provider-error".to_string(), + }, + SkillSearchRequest { + authority: SkillAuthority::new(SkillSourceKind::Remote, "catalog-b"), + package: SkillPackageId("package-b".to_string()), + query: "deployment references".to_string(), + }, + ] + ); + + let read_output = call_tool( + Arc::clone(&tools[1]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "resource": "resource-b/reference-0.md", + }), + ) + .await?; + assert_eq!(read_output["resource"], "resource-b/reference-0.md"); + assert_eq!(read_output["truncated"], true); + assert!(serde_json::to_vec(&read_output)?.len() <= 32 * 1024); + assert!(first_provider.read_requests().is_empty()); + assert_eq!( + read_request_keys(&second_provider.read_requests), + vec![ + ( + SkillAuthority::new(SkillSourceKind::Remote, "catalog-b"), + SkillPackageId("package-b".to_string()), + SkillResourceId("provider-error".to_string()), + ), + ( + SkillAuthority::new(SkillSourceKind::Remote, "catalog-b"), + SkillPackageId("package-b".to_string()), + SkillResourceId("mismatch".to_string()), + ), + ( + SkillAuthority::new(SkillSourceKind::Remote, "catalog-b"), + SkillPackageId("package-b".to_string()), + SkillResourceId("resource-b/reference-0.md".to_string()), + ), + ] + ); + + let unavailable = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-a" }, + "package": "package-b", + "query": "wrong authority", + }), + ) + .await; + assert_eq!( + unavailable, + Err(FunctionCallError::RespondToModel( + "skill package is not available from the requested authority in this turn".to_string() + )) + ); + + registry.turn_lifecycle_contributors()[0] + .on_turn_stop(TurnStopInput { + session_store: &session_store, + thread_store: &thread_store, + turn_store: &turn_store, + }) + .await; + let stale_turn = call_tool( + Arc::clone(&tools[0]), + "turn-tools", + json!({ + "authority": { "kind": { "type": "remote" }, "id": "catalog-b" }, + "package": "package-b", + "query": "stale turn", + }), + ) + .await; + assert_eq!( + stale_turn, + Err(FunctionCallError::RespondToModel( + "skill resources are unavailable because the current turn catalog is not loaded" + .to_string() + )) + ); + + Ok(()) +} + +#[derive(Clone)] +struct ToolSkillProvider { + catalog: SkillCatalog, + read_requests: Arc>>, + search_requests: Arc>>, + read_contents: String, + search_matches: Vec, +} + +impl ToolSkillProvider { + fn new( + entry: SkillCatalogEntry, + read_contents: &str, + search_matches: Vec, + ) -> Self { + Self { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + search_requests: Arc::new(Mutex::new(Vec::new())), + read_contents: read_contents.to_string(), + search_matches, + } + } + + fn read_requests(&self) -> Vec { + self.read_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn search_requests(&self) -> Vec { + self.search_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +impl SkillProvider for ToolSkillProvider { + fn list(&self, _query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { + let catalog = self.catalog.clone(); + Box::pin(async move { Ok(catalog) }) + } + + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { + let read_requests = Arc::clone(&self.read_requests); + let contents = self.read_contents.clone(); + Box::pin(async move { + read_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request.clone()); + if request.resource.0 == "provider-error" { + return Err(SkillProviderError::new("provider error ".repeat(10_000))); + } + let resource = if request.resource.0 == "mismatch" { + SkillResourceId("different-resource".to_string()) + } else { + request.resource + }; + Ok(SkillReadResult { resource, contents }) + }) + } + + fn search(&self, request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> { + let search_requests = Arc::clone(&self.search_requests); + let matches = self.search_matches.clone(); + Box::pin(async move { + search_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request.clone()); + if request.query == "provider-error" { + return Err(SkillProviderError::new("provider error ".repeat(10_000))); + } + Ok(SkillSearchResult { matches }) + }) + } +} + #[derive(Clone)] struct StaticSkillProvider { catalog: SkillCatalog, @@ -464,6 +939,43 @@ impl SkillProvider for StaticSkillProvider { } } +fn find_tool( + registry: &ExtensionRegistry, + session_store: &ExtensionData, + thread_store: &ExtensionData, + tool_name: ToolName, +) -> Arc> { + registry.tool_contributors()[0] + .tools(session_store, thread_store) + .into_iter() + .find(|tool| tool.tool_name() == tool_name) + .unwrap_or_else(|| panic!("{tool_name} should be registered")) +} + +async fn call_tool( + tool: Arc>, + turn_id: &str, + arguments: Value, +) -> Result { + let payload = ToolPayload::Function { + arguments: arguments.to_string(), + }; + let output = tool + .handle(ToolCall { + turn_id: turn_id.to_string(), + call_id: "call-skill".to_string(), + tool_name: tool.tool_name(), + model: "test-model".to_string(), + truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), + payload: payload.clone(), + }) + .await?; + assert_eq!(output.log_preview(), "[skill resource output]"); + Ok(output.code_mode_result(&payload)) +} + fn test_entry( kind: SkillSourceKind, authority_id: &str,