Skip to main content

oxicode_sdk/
delegation.rs

1//! Library-native sub-agent delegation (issue #28 gap 3).
2//!
3//! [`SdkSubagentRunner`] implements [`oxicode_agent::SubagentRunner`] by wrapping
4//! an [`crate::Oxicode`] instance. Each `run_isolated` call builds a fresh
5//! [`oxicode_agent::Agent`] with an empty context (full isolation from the
6//! parent), runs it, and returns only the final text + usage.
7//!
8//! This is the in-process alternative to shelling out to the `oxicode` CLI
9//! binary. Library consumers (e.g. Oxios) wire it into
10//! [`oxicode_agent::AgentLoopConfig::subagent_runner`] so the `subagent` tool
11//! delegates in-process.
12//!
13//! # Depth safety
14//!
15//! The CLI backend tracks recursion depth via env vars
16//! (`OXICODE_SUBAGENT_DEPTH`), which is safe because each subprocess has its
17//! own env. In-process forking **cannot** use env vars — concurrent
18//! `std::env::set_var` is UB, and state leaks between sequential forks.
19//! Instead, the runner sets the forked agent's
20//! [`oxicode_agent::AgentLoopConfig::subagent_depth`] to `depth + 1`, and the
21//! fork's `subagent` tool reads that field to enforce the cap.
22
23use std::path::Path;
24
25use async_trait::async_trait;
26use oxicode_agent::{AgentConfig, AgentEvent, ForkResult, SubagentRunner};
27
28use crate::Oxicode;
29
30/// SDK-provided [`SubagentRunner`] — wraps an [`Oxicode`] instance and forks
31/// isolated in-process sub-agent runs.
32///
33/// Clone-cheap (inner [`Oxicode`] is `Arc`-backed). Safe to share across
34/// concurrent tasks (parallel sub-agent mode spawns up to 8 concurrent
35/// forks — the runner is `Send + Sync`).
36#[derive(Clone)]
37pub struct SdkSubagentRunner {
38    oxicode: Oxicode,
39}
40
41impl std::fmt::Debug for SdkSubagentRunner {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("SdkSubagentRunner")
44            .field("oxicode", &"<Oxicode>")
45            .finish()
46    }
47}
48
49impl SdkSubagentRunner {
50    /// Create from an [`Oxicode`] engine instance.
51    pub fn new(oxicode: Oxicode) -> Self {
52        Self { oxicode }
53    }
54}
55
56#[async_trait]
57impl SubagentRunner for SdkSubagentRunner {
58    async fn run_isolated(
59        &self,
60        _agent_name: &str,
61        task: &str,
62        system_prompt: Option<&str>,
63        model: Option<&str>,
64        _tools: &[String],
65        cwd: &Path,
66        _depth: u8,
67    ) -> anyhow::Result<ForkResult> {
68        // Build a fresh AgentConfig — empty context, full isolation.
69        let mut config = AgentConfig::default();
70        if let Some(m) = model {
71            config.model_id = m.to_string();
72        }
73        if let Some(sp) = system_prompt {
74            config.system_prompt = Some(sp.to_string());
75        }
76        config.workspace_dir = Some(cwd.to_path_buf());
77
78        // Build the agent via Oxicode (resolves provider + model from the
79        // SDK's registries, not global state).
80        let agent = self.oxicode.agent(config).build()?;
81
82        // Run with the prompt. Each Agent has its own SharedState —
83        // completely isolated from the parent's context.
84        let prompt = task.to_string();
85        let (response, events) = agent.run(prompt).await?;
86
87        // Extract usage from events. The last Usage event carries the
88        // most recent provider-reported counts.
89        let (mut input_tokens, mut output_tokens, mut turns) = (0usize, 0usize, 0u32);
90        for event in &events {
91            match event {
92                AgentEvent::Usage {
93                    input_tokens: i,
94                    output_tokens: o,
95                } => {
96                    input_tokens = *i;
97                    output_tokens = *o;
98                }
99                AgentEvent::TurnStart { .. } => turns += 1,
100                _ => {}
101            }
102        }
103
104        Ok(ForkResult {
105            text: response.content,
106            input_tokens,
107            output_tokens,
108            turns,
109            model: model.map(|m| m.to_string()),
110            error: None,
111        })
112    }
113}