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    // Wire the persistent coding runtimes: all three spawn lazily on
85    // first use, so an idle session pays nothing. The pack routes bash/
86    // eval/debug through them; without them those tools keep the legacy
87    // per-invocation semantics and the manifest degrades honestly.
88    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(&registry, 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(&registry, 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}