Skip to main content

oxicode/
behavior.rs

1//! CLI behavior-pack composition (`coding-omp-v1` reference consumer).
2//!
3//! The pack provides the canonical coding tool set through the SDK's host
4//! installer interception point; the CLI registers pack tools as-is to
5//! preserve today's composition. Hosts that need per-tool audit/approval
6//! wrap the tool in their [`oxicode_sdk::behavior::BehaviorToolInstaller`]
7//! before registration (design "Host policy boundary").
8
9use 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/// Manifest plus the requested AgentConfig patch produced by installing packs.
22#[derive(Clone)]
23pub struct BehaviorComposition {
24    /// What was actually installed (and degraded).
25    pub manifest: InstalledBehaviorManifest,
26    /// Requested config adjustments; the host validates before applying.
27    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
50/// Install `coding-omp-v1` into `tools`, overwriting the legacy instances of
51/// the same names with pack-constructed equivalents.
52///
53/// `allow` mirrors the `--tools` filter (already split and trimmed): pack
54/// tools not named are host-disabled. `disabled_tools` mirrors the
55/// `--no-*`/settings disable list. Returns `None` on resolution/install
56/// failure — the legacy builtin composition keeps running (logged loudly).
57pub 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(&registry, 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(&registry, 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}