Skip to main content

oxicode_agent/
config.rs

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