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}