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())
89 .with_snapshot_store(Arc::new(InMemorySnapshotStore::new()) as Arc<dyn SnapshotStore>)
90 .with_shell_session(Arc::new(
91 oxicode_agent::runtime::PersistentShellSession::new(cwd.to_path_buf()),
92 ))
93 .with_eval_kernel(Arc::new(oxicode_agent::runtime::PythonEvalKernel::new()))
94 .with_eval_kernel(Arc::new(oxicode_agent::runtime::JavaScriptEvalKernel::new()))
95 .with_debug_service(Arc::new(oxicode_agent::runtime::DapDebugService::new()))
96 .with_disabled_tools(disabled);
97 let resolved = match resolver.resolve(&[pack_id], &services) {
98 Ok(r) => r,
99 Err(e) => {
100 tracing::warn!("behavior pack resolve failed: {e}");
101 return None;
102 }
103 };
104 let patch = resolved.patch.clone();
105 let mut installer = CliToolInstaller { tools };
106 match resolved.install(&services, &mut installer) {
107 Ok(manifest) => Some(BehaviorComposition { manifest, patch }),
108 Err(e) => {
109 tracing::warn!("behavior pack install failed: {e}");
110 None
111 }
112 }
113}
114
115#[cfg(test)]
116mod behavior_tests {
117 use super::*;
118 use oxicode_sdk::behavior::{DegradationReason, FeatureStatus};
119
120 #[test]
121 fn pack_names_are_subset_of_legacy_builtins() {
122 let tmp = tempfile::tempdir().unwrap();
123 let legacy = ToolRegistry::with_builtins_cwd(tmp.path().to_path_buf(), &[]);
124 let names: HashSet<String> = legacy.names().into_iter().collect();
125 let resolver = BehaviorPackResolver::with_builtin_packs().unwrap();
126 let pack = resolver.pack(&BehaviorPackId::coding_omp_v1()).unwrap();
127 for t in &pack.tools {
128 assert!(
129 names.contains(&t.exposed_name),
130 "pack tool '{}' missing from legacy builtins",
131 t.exposed_name
132 );
133 }
134 }
135
136 #[test]
137 fn composition_installs_manifest_and_overwrites_names() {
138 let tmp = tempfile::tempdir().unwrap();
139 let registry = ToolRegistry::new();
140 let comp =
141 install_coding_omp_v1(®istry, tmp.path(), None, &[]).expect("install succeeds");
142 assert_eq!(comp.manifest.packs, vec![BehaviorPackId::coding_omp_v1()]);
143 assert_eq!(comp.manifest.tools.len(), 16);
144 for t in &comp.manifest.tools {
145 assert!(
146 registry.get(&t.exposed_name).is_some(),
147 "{} not registered",
148 t.exposed_name
149 );
150 }
151 let degraded: HashSet<&str> = comp
152 .manifest
153 .degraded
154 .iter()
155 .map(|d| d.feature.as_str())
156 .collect();
157 let expected: HashSet<&str> = ["ttsr-engine", "lsp-host", "delegation"].into();
158 assert_eq!(degraded, expected);
159 assert_eq!(comp.manifest.compatibility_level(), FeatureStatus::Partial);
160 assert_eq!(comp.patch.prompt_layers.len(), 1);
161 }
162
163 #[test]
164 fn allow_filter_disables_unselected_pack_tools() {
165 let tmp = tempfile::tempdir().unwrap();
166 let registry = ToolRegistry::new();
167 let allow = vec!["read".to_string(), "grep".to_string()];
168 let comp = install_coding_omp_v1(®istry, tmp.path(), Some(&allow), &[])
169 .expect("install succeeds");
170 assert!(registry.get("read").is_some() && registry.get("grep").is_some());
171 assert!(
172 registry.get("bash").is_none(),
173 "non-allowed tools must not be installed"
174 );
175 assert!(
176 comp.manifest
177 .degraded
178 .iter()
179 .any(|d| matches!(d.reason, DegradationReason::DisabledByHost))
180 );
181 }
182}