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}