diff --git a/Cargo.lock b/Cargo.lock index 130e283..fb42aea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2554,7 +2554,7 @@ dependencies = [ [[package]] name = "kit" -version = "0.1.120" +version = "0.1.121" dependencies = [ "a2a-protocol-client", "a2a-protocol-server", diff --git a/Cargo.toml b/Cargo.toml index 0651894..d6c4d7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kit" -version = "0.1.120" +version = "0.1.121" edition = "2024" rust-version = "1.94.0" publish = false diff --git a/macos/Config/Version.xcconfig b/macos/Config/Version.xcconfig index 5049afe..5ff06b2 100644 --- a/macos/Config/Version.xcconfig +++ b/macos/Config/Version.xcconfig @@ -1,2 +1,2 @@ // Generated by scripts/generate-macos-project.sh from Cargo.toml. -KIT_VERSION = 0.1.120 +KIT_VERSION = 0.1.121 diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 1050713..0e35302 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -43,6 +43,7 @@ use tokio::{ time::timeout, }; +mod skill_catalog; pub mod v2; use crate::{ @@ -849,15 +850,19 @@ impl Server { let tasks = driver.tasks.clone(); let structured_completion = driver.structured_completion; let canonical_transcript = driver.canonical_transcript; + let skill_catalog = skill_catalog::SkillCatalogMonitor::new(&driver.skills) + .map_err(|error| record_acp_runtime_failure(&session_id, "skill_catalog", error))?; let (tx, rx) = mpsc::channel(8); let actor = SessionActor { session_id: session_id.clone(), + runtime: Arc::clone(&self.runtime), integration: Arc::clone(&self.integration), binding, driver: driver.driver, tasks: driver.tasks, background_jobs: background_jobs.clone(), structured_completion, + skill_catalog, adapter: driver.adapter, catalog, commands: rx, @@ -1077,12 +1082,14 @@ pub(super) async fn detach_compose_call( struct SessionActor { session_id: agentkit_acp::SessionId, + runtime: Arc, integration: Arc, binding: SessionBindingGuard, driver: LoopDriver, tasks: TaskManagerHandle, background_jobs: BackgroundJobs, structured_completion: bool, + skill_catalog: skill_catalog::SkillCatalogMonitor, adapter: SelectableAdapter, catalog: Vec, commands: mpsc::Receiver, @@ -1093,12 +1100,14 @@ struct SessionActor { async fn session_actor(actor: SessionActor) { let SessionActor { session_id, + runtime, integration, binding, mut driver, tasks, background_jobs, structured_completion, + mut skill_catalog, adapter, catalog, mut commands, @@ -1114,9 +1123,12 @@ async fn session_actor(actor: SessionActor) { biased; command = commands.recv() => match command { Some(Command::Prompt { request, reply }) => { + let skills = runtime.current_skills().await; let result = drive_prompt( &session_id, + &skills, &integration, + &mut skill_catalog, &mut driver, request, &tasks, @@ -1420,9 +1432,12 @@ fn record_acp_loop_failure( } } +#[allow(clippy::too_many_arguments)] async fn drive_prompt( session_id: &agentkit_acp::SessionId, + skills: &[agentkit_tool_skills::Skill], integration: &AcpIntegration, + skill_catalog: &mut skill_catalog::SkillCatalogMonitor, driver: &mut LoopDriver, request: PromptRequest, tasks: &TaskManagerHandle, @@ -1434,9 +1449,16 @@ async fn drive_prompt( } background_jobs.begin_turn(); let items = integration.input_port().prompt_to_items(&request)?; - driver - .submit_input(items) - .map_err(|error| record_acp_loop_failure(session_id, &error))?; + skill_catalog + .submit(skills, items, |items| driver.submit_input(items)) + .map_err(|error| match error { + skill_catalog::SubmitError::Catalog(error) => { + record_acp_runtime_failure(session_id, "skill_catalog", error) + } + skill_catalog::SubmitError::Submit(error) => { + record_acp_loop_failure(session_id, &error) + } + })?; let response = match drive_until_pause( session_id, integration, @@ -2705,9 +2727,12 @@ pub(super) mod tests { let task_manager = AsyncTaskManager::new(); let tasks = task_manager.handle(); let background_jobs = BackgroundJobs::default(); + let mut skill_catalog = skill_catalog::SkillCatalogMonitor::new(&[]).unwrap(); let response = drive_prompt( &acp_session_id, + &[], &integration, + &mut skill_catalog, &mut driver, PromptRequest::new( acp_session_id.clone(), @@ -2788,6 +2813,7 @@ pub(super) mod tests { .await .unwrap(); let background_jobs = BackgroundJobs::default(); + let mut skill_catalog = skill_catalog::SkillCatalogMonitor::new(&[]).unwrap(); let request = PromptRequest::new( acp_session_id.clone(), vec![agentkit_acp::ContentBlock::Text( @@ -2796,7 +2822,9 @@ pub(super) mod tests { ); let prompt = drive_prompt( &acp_session_id, + &[], &integration, + &mut skill_catalog, &mut driver, request, &tasks, @@ -2928,14 +2956,19 @@ pub(super) mod tests { let (turn_states_tx, mut turn_states_rx) = mpsc::unbounded_channel(); let test_mcp = crate::tools::mcp::empty(); let mcp_events = test_mcp.subscribe(acp_session_id.to_string()); + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::new(root.path(), "gpt-5.4").unwrap(); + let skills = runtime.current_skills().await; let actor = tokio::spawn(session_actor(SessionActor { session_id: acp_session_id.clone(), + runtime, integration: Arc::clone(&integration), binding: SessionBindingGuard::new(Arc::clone(&integration), acp_session_id.clone()), driver, tasks: tasks.clone(), background_jobs: background_jobs.clone(), structured_completion: false, + skill_catalog: skill_catalog::SkillCatalogMonitor::new(&skills).unwrap(), adapter: SelectableAdapter::new(crate::ProviderKind::OpenAiSubscription, "gpt-5.4") .unwrap(), catalog: Vec::new(), diff --git a/src/protocols/acp/skill_catalog.rs b/src/protocols/acp/skill_catalog.rs new file mode 100644 index 0000000..3e823e8 --- /dev/null +++ b/src/protocols/acp/skill_catalog.rs @@ -0,0 +1,387 @@ +use std::collections::BTreeMap; + +use agentkit_core::Item; +use agentkit_tool_skills::Skill; +use serde::Serialize; + +const MAX_NOTIFICATION_BYTES: usize = 2_048; +const MAX_REPORTED_CHANGES: usize = 64; + +type Catalog = BTreeMap; + +#[derive(Debug)] +pub(super) enum SubmitError { + Catalog(serde_json::Error), + Submit(E), +} + +#[derive(Serialize)] +struct Fingerprint<'a> { + description: &'a str, + body: &'a str, + frontmatter: FingerprintFrontmatter<'a>, +} + +#[derive(Serialize)] +struct FingerprintFrontmatter<'a> { + license: Option<&'a str>, + compatibility: Option<&'a str>, + metadata: &'a BTreeMap, + allowed_tools: Option<&'a str>, +} + +pub(super) struct SkillCatalogMonitor { + baseline: Catalog, +} + +impl SkillCatalogMonitor { + pub(super) fn new(skills: &[Skill]) -> serde_json::Result { + Ok(Self { + baseline: catalog(skills)?, + }) + } + + pub(super) fn submit( + &mut self, + skills: &[Skill], + mut user_items: Vec, + submit: impl FnOnce(Vec) -> Result<(), E>, + ) -> Result<(), SubmitError> { + let current = catalog(skills).map_err(SubmitError::Catalog)?; + if let Some(notification) = notification(&self.baseline, ¤t, skills) { + user_items.insert(0, Item::notification(notification)); + } + submit(user_items).map_err(SubmitError::Submit)?; + self.baseline = current; + Ok(()) + } +} + +fn catalog(skills: &[Skill]) -> serde_json::Result { + skills + .iter() + .map(|skill| Ok((skill.name.clone(), fingerprint(skill)?))) + .collect() +} + +fn fingerprint(skill: &Skill) -> serde_json::Result { + let mut resources = skill + .resources + .iter() + .map(|path| path.strip_prefix(&skill.base_dir).unwrap_or(path)) + .collect::>(); + resources.sort(); + let semantic = Fingerprint { + description: &skill.description, + body: &skill.body, + frontmatter: FingerprintFrontmatter { + license: skill.frontmatter.license.as_deref(), + compatibility: skill.frontmatter.compatibility.as_deref(), + metadata: &skill.frontmatter.metadata, + allowed_tools: skill.frontmatter.allowed_tools.as_deref(), + }, + }; + let mut fingerprint = blake3::Hasher::new(); + fingerprint.update(&serde_json::to_vec(&semantic)?); + for resource in resources { + let path = resource.as_os_str().as_encoded_bytes(); + fingerprint.update(&(path.len() as u64).to_le_bytes()); + fingerprint.update(path); + } + Ok(fingerprint.finalize()) +} + +fn notification(previous: &Catalog, current: &Catalog, skills: &[Skill]) -> Option { + if previous == current { + return None; + } + + let descriptions = skills + .iter() + .map(|skill| (skill.name.as_str(), skill.description.as_str())) + .collect::>(); + let added = current + .keys() + .filter(|name| !previous.contains_key(*name)) + .filter_map(|name| { + descriptions + .get(name.as_str()) + .map(|description| (name.as_str(), *description)) + }) + .collect::>(); + let changed = current + .iter() + .filter(|(name, fingerprint)| previous.get(*name).is_some_and(|old| old != *fingerprint)) + .filter_map(|(name, _)| { + descriptions + .get(name.as_str()) + .map(|description| (name.as_str(), *description)) + }) + .collect::>(); + let removed = previous + .keys() + .filter(|name| !current.contains_key(*name)) + .map(String::as_str) + .collect::>(); + let total = added.len() + changed.len() + removed.len(); + let omission_reserve = format!("\n{total} more change(s) omitted").len(); + let content_limit = MAX_NOTIFICATION_BYTES.saturating_sub(omission_reserve); + let mut message = String::from("Skill catalog update (informational)."); + let mut reported = 0; + append_skill_group( + &mut message, + "Added skills", + &added, + &mut reported, + content_limit, + ); + append_skill_group( + &mut message, + "Changed skills", + &changed, + &mut reported, + content_limit, + ); + append_removed_group(&mut message, &removed, &mut reported, content_limit); + if reported < total { + message.push_str(&format!("\n{} more change(s) omitted", total - reported)); + } + Some(message) +} + +fn append_skill_group( + message: &mut String, + label: &str, + skills: &[(&str, &str)], + reported: &mut usize, + content_limit: usize, +) { + if skills.is_empty() || *reported >= MAX_REPORTED_CHANGES { + return; + } + let heading = format!("\n{label}:"); + if message.len() + heading.len() > content_limit { + return; + } + let heading_start = message.len(); + message.push_str(&heading); + let mut group_count = 0; + for (name, description) in skills + .iter() + .take(MAX_REPORTED_CHANGES.saturating_sub(*reported)) + { + const ROW_OVERHEAD: usize = "\n- name: \"\"\n description: \"\"".len(); + if message.len() + ROW_OVERHEAD + name.len() + description.len() > content_limit { + continue; + } + let row = format!( + "\n- name: {}\n description: {}", + serde_json::Value::String((*name).to_owned()), + serde_json::Value::String((*description).to_owned()) + ); + if message.len() + row.len() > content_limit { + continue; + } + message.push_str(&row); + group_count += 1; + *reported += 1; + } + if group_count == 0 { + message.truncate(heading_start); + } +} + +fn append_removed_group( + message: &mut String, + names: &[&str], + reported: &mut usize, + content_limit: usize, +) { + if names.is_empty() || *reported >= MAX_REPORTED_CHANGES { + return; + } + const HEADING: &str = "\nRemoved skills:"; + if message.len() + HEADING.len() > content_limit { + return; + } + let heading_start = message.len(); + message.push_str(HEADING); + let mut group_count = 0; + for name in names + .iter() + .take(MAX_REPORTED_CHANGES.saturating_sub(*reported)) + { + let row = format!( + "\n- name: {}", + serde_json::Value::String((*name).to_owned()) + ); + if message.len() + row.len() > content_limit { + continue; + } + message.push_str(&row); + group_count += 1; + *reported += 1; + } + if group_count == 0 { + message.truncate(heading_start); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use agentkit_core::{ItemKind, Part, TextPart}; + use agentkit_tool_skills::SkillFrontmatter; + + use super::*; + + fn skill(name: &str, body: &str) -> Skill { + let base_dir = PathBuf::from("/skills").join(name); + Skill { + name: name.into(), + description: format!("Description for {name}"), + location: base_dir.join("SKILL.md"), + base_dir, + body: body.into(), + resources: Vec::new(), + frontmatter: SkillFrontmatter::default(), + } + } + + fn notification_text(items: &[Item]) -> Option<&str> { + let item = items + .first() + .filter(|item| item.kind == ItemKind::Notification)?; + match item.parts.first() { + Some(Part::Text(TextPart { text, .. })) => Some(text), + _ => None, + } + } + + #[test] + fn fingerprint_covers_semantic_fields_and_sorted_resource_paths() { + let mut original = skill("semantic", "body"); + original.resources = vec![ + original.base_dir.join("z.txt"), + original.base_dir.join("a.txt"), + ]; + + let mut reordered = original.clone(); + reordered.resources.reverse(); + assert_eq!( + fingerprint(&original).unwrap(), + fingerprint(&reordered).unwrap() + ); + + let mut changed = original.clone(); + changed.description.push_str(" changed"); + assert_ne!( + fingerprint(&original).unwrap(), + fingerprint(&changed).unwrap() + ); + changed = original.clone(); + changed.body.push_str(" changed"); + assert_ne!( + fingerprint(&original).unwrap(), + fingerprint(&changed).unwrap() + ); + changed = original.clone(); + changed + .frontmatter + .metadata + .insert("key".into(), "value".into()); + assert_ne!( + fingerprint(&original).unwrap(), + fingerprint(&changed).unwrap() + ); + changed = original.clone(); + changed.resources.push(changed.base_dir.join("new.txt")); + assert_ne!( + fingerprint(&original).unwrap(), + fingerprint(&changed).unwrap() + ); + } + + #[test] + fn baseline_and_unchanged_catalog_do_not_notify() { + let skills = vec![skill("existing", "body")]; + let mut monitor = SkillCatalogMonitor::new(&skills).unwrap(); + monitor + .submit( + &skills, + vec![Item::text(ItemKind::User, "hello")], + |items| { + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, ItemKind::User); + Ok::<_, ()>(()) + }, + ) + .unwrap(); + } + + #[test] + fn reports_added_changed_and_removed_names_in_the_user_batch() { + let before = vec![skill("removed", "old"), skill("changed", "old")]; + let after = vec![skill("added", "new"), skill("changed", "new")]; + let mut monitor = SkillCatalogMonitor::new(&before).unwrap(); + monitor + .submit(&after, vec![Item::text(ItemKind::User, "hello")], |items| { + assert_eq!(items.len(), 2); + assert_eq!(items[0].kind, ItemKind::Notification); + assert_eq!(items[1].kind, ItemKind::User); + let text = notification_text(&items).unwrap(); + assert!(text.starts_with("Skill catalog update (informational).")); + assert!(text.contains( + "Added skills:\n- name: \"added\"\n description: \"Description for added\"" + )); + assert!(text.contains( + "Changed skills:\n- name: \"changed\"\n description: \"Description for changed\"" + )); + assert!(text.contains("Removed skills:\n- name: \"removed\"")); + Ok::<_, ()>(()) + }) + .unwrap(); + } + + #[test] + fn notification_is_sorted_and_bounded() { + let after = (0..100) + .rev() + .map(|index| skill(&format!("skill-{index:03}"), "body")) + .collect::>(); + let mut monitor = SkillCatalogMonitor::new(&[]).unwrap(); + monitor + .submit(&after, vec![Item::text(ItemKind::User, "hello")], |items| { + let text = notification_text(&items).unwrap(); + assert!(text.len() <= MAX_NOTIFICATION_BYTES); + assert!(text.find("skill-000").unwrap() < text.find("skill-001").unwrap()); + assert!(text.contains("more change(s) omitted")); + Ok::<_, ()>(()) + }) + .unwrap(); + } + + #[test] + fn failed_submission_does_not_advance_baseline() { + let after = vec![skill("added", "body")]; + let mut monitor = SkillCatalogMonitor::new(&[]).unwrap(); + assert!( + monitor + .submit(&after, vec![Item::text(ItemKind::User, "first")], |_| Err( + () + )) + .is_err() + ); + monitor + .submit(&after, vec![Item::text(ItemKind::User, "retry")], |items| { + assert!( + notification_text(&items) + .unwrap() + .contains("Added skills:\n- name: \"added\"") + ); + Ok::<_, ()>(()) + }) + .unwrap(); + } +} diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 283ca82..9dd485f 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -34,7 +34,7 @@ use crate::{ use super::{ CancelBackgroundRequest, CancelBackgroundResponse, DetachComposeRequest, DetachComposeResponse, - SessionRegistry, + SessionRegistry, skill_catalog, }; const PAGE_SIZE: usize = 100; @@ -675,6 +675,8 @@ impl Server { let catalog = model_catalog(¤t).await; let config_options = v2_config_options(¤t, reasoning, &catalog); let canonical_transcript = driver.canonical_transcript; + let skill_catalog = skill_catalog::SkillCatalogMonitor::new(&driver.skills) + .map_err(|error| AcpRuntimeError::Loop(format!("skill catalog error: {error}")))?; let background_jobs = driver.background_jobs.clone(); let tasks = driver.tasks.clone(); let structured_completion = driver.structured_completion; @@ -683,6 +685,7 @@ impl Server { let busy = Arc::new(AtomicBool::new(false)); let actor = SessionActor { session_id: session_id.clone(), + runtime: Arc::clone(&self.runtime), integration: Arc::clone(&self.integration), handle: handle.clone(), busy: Arc::clone(&busy), @@ -692,6 +695,7 @@ impl Server { tasks: driver.tasks, background_jobs: background_jobs.clone(), structured_completion, + skill_catalog, adapter: driver.adapter, catalog, commands: rx, @@ -947,6 +951,7 @@ impl Server { struct SessionActor { session_id: wire::SessionId, + runtime: Arc, integration: Arc, handle: AcpSessionHandle, busy: Arc, @@ -956,6 +961,7 @@ struct SessionActor { tasks: TaskManagerHandle, background_jobs: BackgroundJobs, structured_completion: bool, + skill_catalog: skill_catalog::SkillCatalogMonitor, adapter: SelectableAdapter, catalog: Vec, commands: mpsc::Receiver, @@ -965,6 +971,7 @@ struct SessionActor { async fn session_actor(actor: SessionActor) { let SessionActor { session_id, + runtime, integration, handle, busy, @@ -974,6 +981,7 @@ async fn session_actor(actor: SessionActor) tasks, background_jobs, structured_completion, + mut skill_catalog, adapter, catalog, mut commands, @@ -985,10 +993,13 @@ async fn session_actor(actor: SessionActor) biased; command = commands.recv() => match command { Some(Command::Prompt(command)) => { + let skills = runtime.current_skills().await; let result = prepare_prompt( &session_id, + &skills, &integration, &handle, + &mut skill_catalog, &mut driver, command, &sink, @@ -1066,8 +1077,10 @@ async fn session_actor(actor: SessionActor) #[allow(clippy::too_many_arguments)] async fn prepare_prompt( session_id: &wire::SessionId, + skills: &[agentkit_tool_skills::Skill], integration: &AcpIntegration, handle: &AcpSessionHandle, + skill_catalog: &mut skill_catalog::SkillCatalogMonitor, driver: &mut LoopDriver, command: PromptCommand, sink: &impl AcpSessionUpdateSink, @@ -1089,9 +1102,14 @@ async fn prepare_prompt( } background_jobs.begin_turn(); let prepared = integration.prompt_to_items(&request).and_then(|items| { - driver - .submit_input(items) - .map_err(|error| map_loop_error(session_id, &error))?; + skill_catalog + .submit(skills, items, |items| driver.submit_input(items)) + .map_err(|error| match error { + skill_catalog::SubmitError::Catalog(error) => { + AcpRuntimeError::Loop(format!("skill catalog error: {error}")) + } + skill_catalog::SubmitError::Submit(error) => map_loop_error(session_id, &error), + })?; integration.begin_prompt(session_id) }); let user_message_id = match prepared { @@ -2684,11 +2702,14 @@ mod tests { let task_manager = AsyncTaskManager::new(); let tasks = task_manager.handle(); let background_jobs = BackgroundJobs::default(); + let mut skill_catalog = skill_catalog::SkillCatalogMonitor::new(&[]).unwrap(); let (result, ()) = tokio::join!( prepare_prompt( &session_id, + &[], &integration, &handle, + &mut skill_catalog, &mut driver, command, &sink, diff --git a/src/runtime.rs b/src/runtime.rs index 268f02b..4490b3a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -21,7 +21,7 @@ use agentkit_task_manager::{AsyncTaskManager, RoutingDecision, TaskManager, Task use agentkit_tool_compose::{ BackendRun, ComposeBackend, ComposeConfig, ComposeOutcome, ComposeTool, RunletBackend, }; -use agentkit_tool_skills::SkillRegistry; +use agentkit_tool_skills::{Skill, SkillRegistry}; use agentkit_tools_core::{ PermissionRequest, Tool, ToolContext, ToolError, ToolExecutionOutcome, ToolName, ToolRequest, ToolResult, ToolSource, ToolSpec, @@ -217,6 +217,7 @@ impl Drop for SessionClaim { pub(crate) struct AcpDriver { pub driver: LoopDriver, + pub skills: Vec, pub tasks: TaskManagerHandle, pub background_jobs: BackgroundJobs, pub structured_completion: bool, @@ -731,6 +732,17 @@ impl Runtime { ) } + pub(crate) async fn current_skills(&self) -> Vec { + let registry = build_skill_registry( + &self.root, + &self.skill_package_roots, + &self.skill_directories, + ) + .discover_skills() + .await; + registry.skills().into_iter().cloned().collect() + } + fn compose_with(&self, depth: usize, subagents: Subagents) -> ComposeOnly { self.compose_with_jobs( depth, @@ -773,11 +785,17 @@ impl Runtime { .register(Observed::new(ToolSearch::new(self.mcp.clone()))) .register(Observed::new(AuthTool::new(self.mcp.clone()))) .register(Observed::new(McpTool::new(self.mcp.clone()))); + let mut child_specs = children.specs(); let skill_tools = skills.tool_registry(); if let Some(skill_tool) = skill_tools.get(&ToolName::new("skill")) { + let frozen_skill_spec = skill_spec_with_open_name( + skill_tool + .current_spec() + .unwrap_or_else(|| skill_tool.spec().clone()), + ); children.register(observe_shared(skill_tool)); + child_specs.push(frozen_skill_spec); } - let child_specs = children.specs(); let compose = ComposeTool::wrap(children) .with_source(self.mcp.catalog().unadvertised()) .with_config( @@ -1051,6 +1069,7 @@ impl Runtime { ) .map_err(AcpRuntimeError::Loop)?; let skills = self.fresh_skills(); + let skill_catalog = self.current_skills().await; let compactor = crate::compaction::automatic( adapter.clone(), self.agentkit_telemetry(), @@ -1089,6 +1108,7 @@ impl Runtime { .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; let driver = AcpDriver { driver, + skills: skill_catalog, tasks, background_jobs, structured_completion: self.base_depth > 0, @@ -1144,6 +1164,14 @@ fn build_skill_tools( package_roots: &[PathBuf], skill_directories: &[PathBuf], ) -> Arc { + Arc::new(build_skill_registry(root, package_roots, skill_directories)) +} + +fn build_skill_registry( + root: &Path, + package_roots: &[PathBuf], + skill_directories: &[PathBuf], +) -> SkillRegistry { let default_roots = default_skill_roots(root); let canonical_defaults = default_roots .iter() @@ -1159,26 +1187,24 @@ fn build_skill_tools( .collect::>(); let mut roots = default_roots; roots.extend(skill_directories.iter().cloned()); - Arc::new(SkillRegistry::from_paths(roots).with_filter( - move |skill: &agentkit_tool_skills::Skill| { - let Ok(base) = skill.base_dir.canonicalize() else { - return false; - }; - if canonical_defaults + SkillRegistry::from_paths(roots).with_filter(move |skill: &agentkit_tool_skills::Skill| { + let Ok(base) = skill.base_dir.canonicalize() else { + return false; + }; + if canonical_defaults + .iter() + .any(|directory| base.starts_with(directory)) + { + return true; + } + let Ok(location) = skill.location.canonicalize() else { + return false; + }; + canonical_plugin_skills.contains(&base) + && canonical_package_roots .iter() - .any(|directory| base.starts_with(directory)) - { - return true; - } - let Ok(location) = skill.location.canonicalize() else { - return false; - }; - canonical_plugin_skills.contains(&base) - && canonical_package_roots - .iter() - .any(|package| location.starts_with(package)) - }, - )) + .any(|package| location.starts_with(package)) + }) } fn default_skill_roots(root: &Path) -> Vec { @@ -1743,6 +1769,17 @@ fn background_route(request: &ToolRequest) -> RoutingDecision { } } +fn skill_spec_with_open_name(mut spec: ToolSpec) -> ToolSpec { + if let Some(name_schema) = spec + .input_schema + .pointer_mut("/properties/name") + .and_then(Value::as_object_mut) + { + name_schema.remove("enum"); + } + spec +} + struct HiddenRunletBackend(Vec); #[async_trait] diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 15dd79b..0ffa73e 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -604,7 +604,7 @@ fn project_skills_take_precedence_over_plugin_skills() { } #[tokio::test] -async fn compose_can_load_a_skill_repeatedly() { +async fn compose_can_load_a_skill_added_after_its_spec_is_frozen() { let root = tempfile::tempdir().unwrap(); write_skill( &root.path().join(".agents/skills/reusable"), @@ -614,6 +614,15 @@ async fn compose_can_load_a_skill_repeatedly() { ); let runtime = Runtime::new(root.path(), "gpt-5.4").unwrap(); let compose = runtime.compose(0); + let frozen_description = &compose.specs()[0].description; + assert!(frozen_description.contains("- name: reusable")); + assert!(!frozen_description.contains(r#""enum":["reusable"]"#)); + write_skill( + &root.path().join(".agents/skills/added-later"), + "added-later", + "Added after the Compose schema was frozen.", + "new instructions", + ); let source: Arc = Arc::new(compose.compose.clone()); let executor: Arc = Arc::new(BasicToolExecutor::new([source])); let permissions = Arc::new(AllowAllPermissions); @@ -644,7 +653,7 @@ async fn compose_can_load_a_skill_repeatedly() { ToolCallId::new("call"), ToolName::new("compose"), json!({ - "script": "first = skill({ name: \"reusable\" })\nsecond = skill({ name: \"reusable\" })\nreturn [first, second]" + "script": "first = skill({ name: \"reusable\" })\nsecond = skill({ name: \"reusable\" })\nthird = skill({ name: \"added-later\" })\nreturn [first, second, third]" }), session_id, turn_id, @@ -660,12 +669,17 @@ async fn compose_can_load_a_skill_repeatedly() { panic!("compose did not return structured output"); }; let loaded = loaded.as_array().expect("compose returned an array"); - assert_eq!(loaded.len(), 2); - assert!(loaded.iter().all(|skill| { + assert_eq!(loaded.len(), 3); + assert!(loaded[..2].iter().all(|skill| { skill .as_str() .is_some_and(|text| text.contains("full instructions")) })); + assert!( + loaded[2] + .as_str() + .is_some_and(|text| text.contains("new instructions")) + ); } #[test]