1use std::collections::HashSet;
10use std::path::Path;
11use std::sync::Arc;
12
13use oxicode_agent::{AgentTool, ToolRegistry};
14use oxicode_hashline::{InMemorySnapshotStore, SnapshotStore};
15use oxicode_sdk::behavior::{
16 AgentConfigPatch, BehaviorInstallError, BehaviorPackId, BehaviorPackResolver,
17 BehaviorSessionServices, BehaviorToolDescriptor, BehaviorToolInstaller,
18 InstalledBehaviorManifest,
19};
20
21#[derive(Clone)]
23pub struct BehaviorComposition {
24 pub manifest: InstalledBehaviorManifest,
26 pub patch: AgentConfigPatch,
28}
29
30struct CliToolInstaller<'a> {
31 tools: &'a ToolRegistry,
32}
33
34impl BehaviorToolInstaller for CliToolInstaller<'_> {
35 fn install(
36 &mut self,
37 descriptor: &BehaviorToolDescriptor,
38 tool: Arc<dyn AgentTool>,
39 ) -> Result<(), BehaviorInstallError> {
40 self.tools.register_arc(tool);
41 tracing::debug!(
42 tool = %descriptor.exposed_name,
43 id = %descriptor.id.0,
44 "behavior pack tool installed"
45 );
46 Ok(())
47 }
48}
49
50pub fn install_coding_omp_v1(
58 tools: &ToolRegistry,
59 cwd: &Path,
60 allow: Option<&[String]>,
61 disabled_tools: &[String],
62) -> Option<BehaviorComposition> {
63 let resolver = match BehaviorPackResolver::with_builtin_packs() {
64 Ok(r) => r,
65 Err(e) => {
66 tracing::warn!("behavior pack registry failed: {e}");
67 return None;
68 }
69 };
70 let pack_id = BehaviorPackId::coding_omp_v1();
71 let Some(pack) = resolver.pack(&pack_id) else {
72 tracing::warn!("coding-omp-v1 missing from builtin packs");
73 return None;
74 };
75 let mut disabled: Vec<String> = disabled_tools.to_vec();
76 if let Some(allow) = allow {
77 let allowed: HashSet<&str> = allow.iter().map(String::as_str).collect();
78 for t in &pack.tools {
79 if !allowed.contains(t.exposed_name.as_str()) {
80 disabled.push(t.exposed_name.clone());
81 }
82 }
83 }
84 let services = BehaviorSessionServices::new(cwd.to_path_buf())
85 .with_snapshot_store(Arc::new(InMemorySnapshotStore::new()) as Arc<dyn SnapshotStore>)
86 .with_disabled_tools(disabled);
87 let resolved = match resolver.resolve(&[pack_id], &services) {
88 Ok(r) => r,
89 Err(e) => {
90 tracing::warn!("behavior pack resolve failed: {e}");
91 return None;
92 }
93 };
94 let patch = resolved.patch.clone();
95 let mut installer = CliToolInstaller { tools };
96 match resolved.install(&services, &mut installer) {
97 Ok(manifest) => Some(BehaviorComposition { manifest, patch }),
98 Err(e) => {
99 tracing::warn!("behavior pack install failed: {e}");
100 None
101 }
102 }
103}
104
105#[cfg(test)]
106mod behavior_tests {
107 use super::*;
108 use oxicode_sdk::behavior::{DegradationReason, FeatureStatus};
109
110 #[test]
111 fn pack_names_are_subset_of_legacy_builtins() {
112 let tmp = tempfile::tempdir().unwrap();
113 let legacy = ToolRegistry::with_builtins_cwd(tmp.path().to_path_buf(), &[]);
114 let names: HashSet<String> = legacy.names().into_iter().collect();
115 let resolver = BehaviorPackResolver::with_builtin_packs().unwrap();
116 let pack = resolver.pack(&BehaviorPackId::coding_omp_v1()).unwrap();
117 for t in &pack.tools {
118 assert!(
119 names.contains(&t.exposed_name),
120 "pack tool '{}' missing from legacy builtins",
121 t.exposed_name
122 );
123 }
124 }
125
126 #[test]
127 fn composition_installs_manifest_and_overwrites_names() {
128 let tmp = tempfile::tempdir().unwrap();
129 let registry = ToolRegistry::new();
130 let comp =
131 install_coding_omp_v1(®istry, tmp.path(), None, &[]).expect("install succeeds");
132 assert_eq!(comp.manifest.packs, vec![BehaviorPackId::coding_omp_v1()]);
133 assert_eq!(comp.manifest.tools.len(), 16);
134 for t in &comp.manifest.tools {
135 assert!(
136 registry.get(&t.exposed_name).is_some(),
137 "{} not registered",
138 t.exposed_name
139 );
140 }
141 let degraded: HashSet<&str> = comp
142 .manifest
143 .degraded
144 .iter()
145 .map(|d| d.feature.as_str())
146 .collect();
147 let expected: HashSet<&str> = [
148 "shell-session",
149 "eval-kernel",
150 "debug-service",
151 "ttsr-engine",
152 "lsp-host",
153 "delegation",
154 ]
155 .into();
156 assert_eq!(degraded, expected);
157 assert_eq!(comp.manifest.compatibility_level(), FeatureStatus::Partial);
158 assert_eq!(comp.patch.prompt_layers.len(), 1);
159 }
160
161 #[test]
162 fn allow_filter_disables_unselected_pack_tools() {
163 let tmp = tempfile::tempdir().unwrap();
164 let registry = ToolRegistry::new();
165 let allow = vec!["read".to_string(), "grep".to_string()];
166 let comp = install_coding_omp_v1(®istry, tmp.path(), Some(&allow), &[])
167 .expect("install succeeds");
168 assert!(registry.get("read").is_some() && registry.get("grep").is_some());
169 assert!(
170 registry.get("bash").is_none(),
171 "non-allowed tools must not be installed"
172 );
173 assert!(
174 comp.manifest
175 .degraded
176 .iter()
177 .any(|d| matches!(d.reason, DegradationReason::DisabledByHost))
178 );
179 }
180}