Skip to main content

oxi_agent/
config.rs

1/// Agent configuration
2use oxi_ai::CompactionStrategy;
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5
6fn default_context_window() -> usize {
7    128_000
8}
9
10/// Hook context for `shouldStopAfterTurn`.
11#[derive(Debug, Clone)]
12pub struct ShouldStopAfterTurnContext {
13    /// The assistant message that completed the turn.
14    pub message: oxi_ai::AssistantMessage,
15    /// Tool result messages from this turn.
16    pub tool_results: Vec<oxi_ai::ToolResultMessage>,
17    /// Current iteration number.
18    pub iteration: usize,
19}
20
21/// Result of `beforeToolCall` hook.
22#[derive(Debug, Clone, Default)]
23pub struct BeforeToolCallResult {
24    /// If `true`, the tool call is blocked and an error result is returned.
25    pub block: bool,
26    /// Human-readable reason for blocking.
27    pub reason: Option<String>,
28}
29
30/// Result of `afterToolCall` hook.
31#[derive(Debug, Clone, Default)]
32pub struct AfterToolCallResult {
33    /// Override content for the tool result.
34    pub content: Option<String>,
35    /// Override error status.
36    pub is_error: Option<bool>,
37    /// Signal that the agent should stop after this batch.
38    pub terminate: Option<bool>,
39    /// Arbitrary structured details returned by the hook.
40    ///
41    /// Consumers (e.g. telemetry, middleware) can use this to attach
42    /// extra context without extending the struct.
43    pub details: Option<serde_json::Value>,
44}
45
46/// Hook context for `beforeToolCall`.
47#[derive(Debug, Clone)]
48pub struct BeforeToolCallContext {
49    /// The tool call being made.
50    pub tool_call_id: String,
51    /// Tool name.
52    pub tool_name: String,
53    /// Validated arguments.
54    pub args: serde_json::Value,
55}
56
57/// Hook context for `afterToolCall`.
58#[derive(Debug, Clone)]
59pub struct AfterToolCallContext {
60    /// The tool call that was made.
61    pub tool_call_id: String,
62    /// Tool name.
63    pub tool_name: String,
64    /// The tool result content.
65    pub result: String,
66    /// Whether the result is an error.
67    pub is_error: bool,
68    /// Arbitrary structured details provided to the hook.
69    ///
70    /// Set by the agent loop before invoking the hook so that consumers
71    /// receive extra context (e.g. execution timing, tool-specific metadata).
72    pub details: Option<serde_json::Value>,
73}
74
75/// Callback hooks for the agent loop.
76///
77/// These mirror pi-mono's `AgentLoopConfig` hooks, allowing callers to
78/// inject custom logic at key points in the agentic loop.
79#[derive(Default)]
80#[allow(clippy::type_complexity)]
81pub struct AgentHooks {
82    /// Called after each turn completes. Return `true` to stop the agent loop.
83    ///
84    /// Wrapped in `Arc` so the hook can be invoked multiple times without
85    /// being consumed (unlike `Box<dyn Fn>` which requires `take()`).
86    pub should_stop_after_turn:
87        Option<Arc<dyn Fn(&ShouldStopAfterTurnContext) -> bool + Send + Sync>>,
88
89    /// Called before a tool is executed. Return a `BeforeToolCallResult` with
90    /// `block: true` to prevent execution.
91    #[allow(clippy::type_complexity)]
92    pub before_tool_call:
93        Option<Box<dyn Fn(&BeforeToolCallContext) -> BeforeToolCallResult + Send + Sync>>,
94
95    /// Called after a tool execution completes. Can override the result.
96    #[allow(clippy::type_complexity)]
97    pub after_tool_call:
98        Option<Box<dyn Fn(&AfterToolCallContext) -> AfterToolCallResult + Send + Sync>>,
99
100    /// Returns steering messages to inject mid-run. Called after each turn
101    /// (unless stopped).
102    #[allow(clippy::type_complexity)]
103    pub get_steering_messages: Option<Arc<dyn Fn() -> Vec<String> + Send + Sync>>,
104
105    /// Returns follow-up messages to process after the agent would stop.
106    /// Called when the agent has no more tool calls and no steering messages.
107    #[allow(clippy::type_complexity)]
108    pub get_follow_up_messages: Option<Arc<dyn Fn() -> Vec<String> + Send + Sync>>,
109
110    /// Tool execution mode.
111    pub tool_execution: ToolExecutionMode,
112}
113
114/// How tool calls are executed within a single assistant turn.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum ToolExecutionMode {
117    /// Execute tool calls sequentially, one at a time.
118    Sequential,
119    /// Execute tool calls concurrently (in parallel).
120    #[default]
121    Parallel,
122}
123
124/// Agent runtime configuration
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AgentConfig {
127    /// Agent name
128    pub name: String,
129    /// Agent description
130    pub description: Option<String>,
131    /// Model ID to use
132    pub model_id: String,
133    /// System prompt
134    pub system_prompt: Option<String>,
135    /// Timeout in seconds for the entire agent run
136    pub timeout_seconds: u64,
137    /// Temperature for generation (0.0 to 1.0)
138    pub temperature: Option<f64>,
139    /// Maximum tokens to generate
140    pub max_tokens: Option<usize>,
141    /// Compaction strategy for long conversations
142    #[serde(default)]
143    pub compaction_strategy: CompactionStrategy,
144    /// Custom instruction passed to the compactor
145    #[serde(default)]
146    pub compaction_instruction: Option<String>,
147    /// Model context window size (used for threshold-based compaction)
148    #[serde(default = "default_context_window")]
149    pub context_window: usize,
150    /// API key override for the provider.
151    ///
152    /// When set, this is injected into [`oxi_ai::StreamOptions`] so the
153    /// provider uses it instead of an environment variable.
154    #[serde(default)]
155    pub api_key: Option<String>,
156    /// Working directory for file tools. Defaults to current directory if None.
157    #[serde(default)]
158    pub workspace_dir: Option<std::path::PathBuf>,
159    /// Output mode for agent responses.
160    ///
161    /// When set, the agent extracts structured output from the final response.
162    /// See [`OutputMode`] for available modes.
163    ///
164    /// [`OutputMode`]: crate::structured_output::OutputMode
165    #[serde(default)]
166    pub output_mode: Option<String>,
167    /// Session identity used by tools that gate behavior on liveness (e.g. the
168    /// `issue` tool's `start`/`close` ownership checks). When `Some`, this value
169    /// is threaded through to [`crate::tools::ToolContext::session_id`].
170    /// `None` means the tool receives `session_id == None` and ownership-gated
171    /// operations will reject the call (defensive default).
172    #[serde(default)]
173    pub session_id: Option<String>,
174
175    /// Per-provider options for fine-grained control.
176    ///
177    /// When set, these are passed through to [`oxi_ai::StreamOptions::provider_options`]
178    /// so the provider can read provider-specific settings (e.g. Anthropic adaptive
179    /// thinking, OpenAI reasoning_effort, Google thinkingConfig).
180    #[serde(default)]
181    pub provider_options: Option<oxi_ai::ProviderOptions>,
182
183    /// TTSR engine for stream rule checking. When set, streaming output
184    /// is checked against registered rules and violations trigger
185    /// [`crate::agent_loop::StreamOutcome::RuleInterrupt`].
186    #[serde(skip, default)]
187    pub ttsr_engine: Option<std::sync::Arc<crate::agent_loop::ttsr::TtsrEngine>>,
188
189    /// Memory backend for `memory_*` tools.
190    #[serde(skip, default)]
191    pub memory: Option<std::sync::Arc<dyn crate::tools::MemoryBackend>>,
192    /// Todo state provider for the `todo` tool.
193    #[serde(skip, default)]
194    pub todo: Option<std::sync::Arc<dyn crate::tools::TodoStateProvider>>,
195    /// Agent pool for Hub display and sub-agent matching.
196    #[serde(skip, default)]
197    pub agent_pool: Option<std::sync::Arc<dyn crate::tools::AgentPoolProvider>>,
198
199    /// Maximum bytes of a tool result's text content before truncation
200    /// (#28 gap 1, surfaced as #32). Threaded through to
201    /// [`crate::agent_loop::config::AgentLoopConfig::max_tool_result_bytes`].
202    ///
203    /// When set, tool results exceeding this limit are truncated and a
204    /// `"... [truncated: N bytes omitted]"` marker is appended, preventing a
205    /// single large tool output from consuming the context window.
206    ///
207    /// `None` (default) = no limit. Opt-in.
208    #[serde(skip, default)]
209    pub max_tool_result_bytes: Option<usize>,
210
211    /// In-process sub-agent runner (#28 gap 3, surfaced as #32). When set,
212    /// the `subagent` tool prefers an in-process isolated run over shelling
213    /// out. Threaded through to
214    /// [`crate::agent_loop::config::AgentLoopConfig::subagent_runner`].
215    #[serde(skip, default)]
216    pub subagent_runner: Option<std::sync::Arc<dyn crate::tools::SubagentRunner>>,
217
218    /// Current sub-agent nesting depth (#28 gap 3, surfaced as #32). Default
219    /// `0` (top-level). The `subagent` tool increments this when forking a
220    /// child config to cap recursion.
221    #[serde(skip, default)]
222    pub subagent_depth: u8,
223}
224
225impl Default for AgentConfig {
226    fn default() -> Self {
227        Self {
228            name: "oxi-agent".to_string(),
229            description: None,
230            model_id: "claude-sonnet-4-20250514".to_string(),
231            system_prompt: None,
232            timeout_seconds: 300,
233            temperature: None,
234            max_tokens: None,
235            compaction_strategy: CompactionStrategy::default(),
236            compaction_instruction: None,
237            context_window: 128_000,
238            api_key: None,
239            workspace_dir: None,
240            output_mode: None,
241            provider_options: None,
242            session_id: None,
243            ttsr_engine: None,
244            memory: None,
245            todo: None,
246            agent_pool: None,
247            max_tool_result_bytes: None,
248            subagent_runner: None,
249            subagent_depth: 0,
250        }
251    }
252}
253
254impl AgentConfig {
255    /// Create a new config with the given model ID.
256    pub fn new(model_id: impl Into<String>) -> Self {
257        Self {
258            model_id: model_id.into(),
259            ..Default::default()
260        }
261    }
262
263    /// Set the agent name.
264    pub fn with_name(mut self, name: impl Into<String>) -> Self {
265        self.name = name.into();
266        self
267    }
268
269    /// Set the system prompt.
270    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
271        self.system_prompt = Some(prompt.into());
272        self
273    }
274
275    /// Set the timeout in seconds for the entire agent run.
276    pub fn with_timeout(mut self, seconds: u64) -> Self {
277        self.timeout_seconds = seconds;
278        self
279    }
280
281    /// Set the compaction strategy for long conversations.
282    pub fn with_compaction_strategy(mut self, strategy: CompactionStrategy) -> Self {
283        self.compaction_strategy = strategy;
284        self
285    }
286
287    /// Set a custom instruction passed to the compactor.
288    pub fn with_compaction_instruction(mut self, instruction: impl Into<String>) -> Self {
289        self.compaction_instruction = Some(instruction.into());
290        self
291    }
292
293    /// Set the session identity threaded into [`crate::tools::ToolContext::session_id`].
294    ///
295    /// Tools that gate behavior on liveness (e.g. an `issue` tool's
296    /// `start`/`close` ownership checks) use this to identify the caller.
297    /// Leaving it `None` causes those tools to see an empty caller id and
298    /// reject ownership-gated operations (defensive default).
299    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
300        self.session_id = Some(session_id.into());
301        self
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn session_id_defaults_to_none() {
311        let c = AgentConfig::default();
312        assert!(c.session_id.is_none(), "default session_id must be None");
313    }
314
315    #[test]
316    fn with_session_id_sets_the_field() {
317        let c = AgentConfig::new("m").with_session_id("proc-42");
318        assert_eq!(c.session_id.as_deref(), Some("proc-42"));
319    }
320
321    #[test]
322    fn session_id_round_trips_through_serde() {
323        // Forward-compat: a serialized config with the new field deserializes back.
324        let with = AgentConfig::new("m").with_session_id("proc-7");
325        let json = serde_json::to_string(&with).unwrap();
326        assert!(json.contains("\"session_id\":"));
327        let back: AgentConfig = serde_json::from_str(&json).unwrap();
328        assert_eq!(back.session_id.as_deref(), Some("proc-7"));
329
330        // Backward-compat: a payload WITHOUT the session_id key must still
331        // deserialize and default the field to None. We build that payload by
332        // serializing a config, then stripping the key with serde_json::Value.
333        let mut v: serde_json::Value =
334            serde_json::from_str(&json).expect("config serializes to valid JSON");
335        if let Some(obj) = v.as_object_mut() {
336            obj.remove("session_id");
337        }
338        let stripped = serde_json::to_string(&v).unwrap();
339        let legacy: AgentConfig = serde_json::from_str(&stripped).unwrap();
340        assert!(
341            legacy.session_id.is_none(),
342            "payload missing session_id must default to None"
343        );
344    }
345
346    #[test]
347    fn loop_passthrough_fields_default() {
348        // issue #32: the three AgentLoopConfig passthrough fields default to
349        // their no-op values, preserving pre-#32 behavior for consumers that
350        // don't set them.
351        let c = AgentConfig::default();
352        assert!(c.max_tool_result_bytes.is_none());
353        assert!(c.subagent_runner.is_none());
354        assert_eq!(c.subagent_depth, 0);
355    }
356
357    #[test]
358    fn loop_passthrough_fields_are_serde_skipped() {
359        // issue #32: the passthrough fields are #[serde(skip, default)].
360        // (1) They must NOT appear in serialized output — this is what lets
361        //     the non-serializable `Arc<dyn SubagentRunner>` coexist with
362        //     `#[derive(Serialize)]` on AgentConfig.
363        // (2) Legacy payloads missing the keys must deserialize to defaults,
364        //     so existing serialized configs are unaffected.
365        let c = AgentConfig::new("m");
366        let json = serde_json::to_string(&c).expect("serializes");
367        assert!(!json.contains("max_tool_result_bytes"));
368        assert!(!json.contains("subagent_runner"));
369        assert!(!json.contains("subagent_depth"));
370
371        let legacy: AgentConfig =
372            serde_json::from_str(r#"{"name":"x","model_id":"m","timeout_seconds":300}"#)
373                .expect("deserializes");
374        assert!(legacy.max_tool_result_bytes.is_none());
375        assert!(legacy.subagent_runner.is_none());
376        assert_eq!(legacy.subagent_depth, 0);
377    }
378
379    #[test]
380    fn loop_passthrough_fields_set_and_clone() {
381        // issue #32 verification: consumers can set the passthrough fields
382        // and they survive Clone (AgentConfig derives Clone).
383        let c = AgentConfig {
384            max_tool_result_bytes: Some(8192),
385            subagent_depth: 3,
386            ..AgentConfig::new("m")
387        };
388        let cloned = c.clone();
389        assert_eq!(cloned.max_tool_result_bytes, Some(8192));
390        assert_eq!(cloned.subagent_depth, 3);
391        assert!(cloned.subagent_runner.is_none());
392    }
393}