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}