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    /// Whether to inject stop-time reminders for incomplete todos.
259    /// Default `true`. Threaded to `AgentLoopConfig::todo_reminders_enabled`.
260    #[serde(default = "default_true_bool")]
261    pub todo_reminders_enabled: bool,
262    /// Max stop-time todo reminders per run. Default
263    /// [`crate::tools::todo::MAX_TODO_STOP_REMINDERS`].
264    #[serde(default = "default_todo_reminders_max")]
265    pub todo_reminders_max: u32,
266    /// Eager first-turn todo-list creation policy. Default `Off` preserves
267    /// today's behavior. Threaded to `AgentLoopConfig::todo_eager_mode`.
268    #[serde(default = "default_todo_eager_mode")]
269    pub todo_eager_mode: crate::agent_loop::todo_policy::TodoEagerMode,
270    /// Agent pool for Hub display and sub-agent matching.
271    #[serde(skip, default)]
272    pub agent_pool: Option<std::sync::Arc<dyn crate::tools::AgentPoolProvider>>,
273    /// URL resolver for internal protocol schemes (`issue://`, `pr://`, etc.).
274    /// Threaded through to [`crate::agent_loop::config::AgentLoopConfig::url_resolver`].
275    /// When `None`, URL-prefixed paths are treated as regular file paths.
276    #[serde(skip, default)]
277    pub url_resolver: Option<std::sync::Arc<dyn crate::tools::UrlResolver>>,
278    /// LSP provider for the `lsp` tool.
279    /// Threaded through to [`crate::agent_loop::config::AgentLoopConfig::lsp`].
280    /// When `None`, the `lsp` tool returns an error.
281    #[serde(skip, default)]
282    pub lsp: Option<std::sync::Arc<dyn crate::tools::LspProvider>>,
283
284    /// Maximum bytes of a tool result's text content before truncation
285    /// (#28 gap 1, surfaced as #32). Threaded through to
286    /// [`crate::agent_loop::config::AgentLoopConfig::max_tool_result_bytes`].
287    ///
288    /// When set, tool results exceeding this limit are truncated and a
289    /// `"... [truncated: N bytes omitted]"` marker is appended, preventing a
290    /// single large tool output from consuming the context window.
291    ///
292    /// `None` (default) = no limit. Opt-in.
293    #[serde(skip, default)]
294    pub max_tool_result_bytes: Option<usize>,
295
296    /// In-process sub-agent runner (#28 gap 3, surfaced as #32). When set,
297    /// the `subagent` tool prefers an in-process isolated run over shelling
298    /// out. Threaded through to
299    /// [`crate::agent_loop::config::AgentLoopConfig::subagent_runner`].
300    #[serde(skip, default)]
301    pub subagent_runner: Option<std::sync::Arc<dyn crate::tools::SubagentRunner>>,
302
303    /// Current sub-agent nesting depth (#28 gap 3, surfaced as #32). Default
304    /// `0` (top-level). The `subagent` tool increments this when forking a
305    /// child config to cap recursion.
306    #[serde(skip, default)]
307    pub subagent_depth: u8,
308    /// Snapshot store for hashline line-anchored edit mode.
309    ///
310    /// When `Some`, the `read` tool records file snapshots and emits
311    /// `[path#TAG]` headers, and the `edit` tool validates edits against
312    /// them. When `None` (default), hashline anchoring is disabled and the
313    /// edit tool falls back to plain text replacement.
314    #[serde(skip, default)]
315    pub snapshot_store: Option<std::sync::Arc<dyn oxicode_hashline::SnapshotStore>>,
316}
317
318fn default_true_bool() -> bool {
319    true
320}
321
322fn default_todo_reminders_max() -> u32 {
323    crate::tools::todo::MAX_TODO_STOP_REMINDERS
324}
325fn default_todo_eager_mode() -> crate::agent_loop::todo_policy::TodoEagerMode {
326    crate::agent_loop::todo_policy::TodoEagerMode::Off
327}
328
329impl Default for AgentConfig {
330    fn default() -> Self {
331        Self {
332            name: "oxicode-agent".to_string(),
333            todo_reminders_enabled: true,
334            todo_reminders_max: crate::tools::todo::MAX_TODO_STOP_REMINDERS,
335            todo_eager_mode: crate::agent_loop::todo_policy::TodoEagerMode::Off,
336            description: None,
337            model_id: "claude-sonnet-4-20250514".to_string(),
338            system_prompt: None,
339            timeout_seconds: 300,
340            temperature: None,
341            max_tokens: None,
342            compaction_strategy: CompactionStrategy::default(),
343            compaction_instruction: None,
344            context_window: 128_000,
345            workspace_dir: None,
346            output_mode: None,
347            provider_options: None,
348            mode: Mode::Default,
349            session_id: None,
350            ttsr_engine: None,
351            memory: None,
352            todo: None,
353            agent_pool: None,
354            url_resolver: None,
355            lsp: None,
356            max_tool_result_bytes: None,
357            subagent_runner: None,
358            subagent_depth: 0,
359            snapshot_store: None,
360        }
361    }
362}
363
364impl AgentConfig {
365    /// Create a new config with the given model ID.
366    pub fn new(model_id: impl Into<String>) -> Self {
367        Self {
368            model_id: model_id.into(),
369            ..Default::default()
370        }
371    }
372
373    /// Set the agent name.
374    pub fn with_name(mut self, name: impl Into<String>) -> Self {
375        self.name = name.into();
376        self
377    }
378
379    /// Set the system prompt.
380    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
381        self.system_prompt = Some(prompt.into());
382        self
383    }
384
385    /// Set the timeout in seconds for the entire agent run.
386    pub fn with_timeout(mut self, seconds: u64) -> Self {
387        self.timeout_seconds = seconds;
388        self
389    }
390
391    /// Set the compaction strategy for long conversations.
392    pub fn with_compaction_strategy(mut self, strategy: CompactionStrategy) -> Self {
393        self.compaction_strategy = strategy;
394        self
395    }
396
397    /// Set a custom instruction passed to the compactor.
398    pub fn with_compaction_instruction(mut self, instruction: impl Into<String>) -> Self {
399        self.compaction_instruction = Some(instruction.into());
400        self
401    }
402
403    /// Set the session identity threaded into [`crate::tools::ToolContext::session_id`].
404    ///
405    /// Tools that gate behavior on liveness (e.g. an `issue` tool's
406    /// `start`/`close` ownership checks) use this to identify the caller.
407    /// Leaving it `None` causes those tools to see an empty caller id and
408    /// reject ownership-gated operations (defensive default).
409    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
410        self.session_id = Some(session_id.into());
411        self
412    }
413
414    /// Set the hashline snapshot store — enables line-anchored edit mode in
415    /// the `read`/`edit` tools (emits `[path#TAG]` headers, validates edits).
416    pub fn with_snapshot_store(
417        mut self,
418        store: std::sync::Arc<dyn oxicode_hashline::SnapshotStore>,
419    ) -> Self {
420        self.snapshot_store = Some(store);
421        self
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn session_id_defaults_to_none() {
431        let c = AgentConfig::default();
432        assert!(c.session_id.is_none(), "default session_id must be None");
433    }
434
435    #[test]
436    fn with_session_id_sets_the_field() {
437        let c = AgentConfig::new("m").with_session_id("proc-42");
438        assert_eq!(c.session_id.as_deref(), Some("proc-42"));
439    }
440
441    #[test]
442    fn session_id_round_trips_through_serde() {
443        // Forward-compat: a serialized config with the new field deserializes back.
444        let with = AgentConfig::new("m").with_session_id("proc-7");
445        let json = serde_json::to_string(&with).unwrap();
446        assert!(json.contains("\"session_id\":"));
447        let back: AgentConfig = serde_json::from_str(&json).unwrap();
448        assert_eq!(back.session_id.as_deref(), Some("proc-7"));
449
450        // Backward-compat: a payload WITHOUT the session_id key must still
451        // deserialize and default the field to None. We build that payload by
452        // serializing a config, then stripping the key with serde_json::Value.
453        let mut v: serde_json::Value =
454            serde_json::from_str(&json).expect("config serializes to valid JSON");
455        if let Some(obj) = v.as_object_mut() {
456            obj.remove("session_id");
457        }
458        let stripped = serde_json::to_string(&v).unwrap();
459        let legacy: AgentConfig = serde_json::from_str(&stripped).unwrap();
460        assert!(
461            legacy.session_id.is_none(),
462            "payload missing session_id must default to None"
463        );
464    }
465
466    #[test]
467    fn loop_passthrough_fields_default() {
468        // issue #32: the three AgentLoopConfig passthrough fields default to
469        // their no-op values, preserving pre-#32 behavior for consumers that
470        // don't set them.
471        let c = AgentConfig::default();
472        assert!(c.max_tool_result_bytes.is_none());
473        assert!(c.subagent_runner.is_none());
474        assert_eq!(c.subagent_depth, 0);
475    }
476
477    #[test]
478    fn loop_passthrough_fields_are_serde_skipped() {
479        // issue #32: the passthrough fields are #[serde(skip, default)].
480        // (1) They must NOT appear in serialized output — this is what lets
481        //     the non-serializable `Arc<dyn SubagentRunner>` coexist with
482        //     `#[derive(Serialize)]` on AgentConfig.
483        // (2) Legacy payloads missing the keys must deserialize to defaults,
484        //     so existing serialized configs are unaffected.
485        let c = AgentConfig::new("m");
486        let json = serde_json::to_string(&c).expect("serializes");
487        assert!(!json.contains("max_tool_result_bytes"));
488        assert!(!json.contains("subagent_runner"));
489        assert!(!json.contains("subagent_depth"));
490
491        let legacy: AgentConfig =
492            serde_json::from_str(r#"{"name":"x","model_id":"m","timeout_seconds":300}"#)
493                .expect("deserializes");
494        assert!(legacy.max_tool_result_bytes.is_none());
495        assert!(legacy.subagent_runner.is_none());
496        assert_eq!(legacy.subagent_depth, 0);
497    }
498
499    #[test]
500    fn loop_passthrough_fields_set_and_clone() {
501        // issue #32 verification: consumers can set the passthrough fields
502        // and they survive Clone (AgentConfig derives Clone).
503        let c = AgentConfig {
504            max_tool_result_bytes: Some(8192),
505            subagent_depth: 3,
506            ..AgentConfig::new("m")
507        };
508        let cloned = c.clone();
509        assert_eq!(cloned.max_tool_result_bytes, Some(8192));
510        assert_eq!(cloned.subagent_depth, 3);
511        assert!(cloned.subagent_runner.is_none());
512    }
513}