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<oxi_ai::Message> + 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<oxi_ai::Message> + 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 /// Working directory for file tools. Defaults to current directory if None.
151 #[serde(default)]
152 pub workspace_dir: Option<std::path::PathBuf>,
153 /// Output mode for agent responses.
154 ///
155 /// When set, the agent extracts structured output from the final response.
156 /// See [`OutputMode`] for available modes.
157 ///
158 /// [`OutputMode`]: crate::structured_output::OutputMode
159 #[serde(default)]
160 pub output_mode: Option<String>,
161 /// Session identity used by tools that gate behavior on liveness (e.g. the
162 /// `issue` tool's `start`/`close` ownership checks). When `Some`, this value
163 /// is threaded through to [`crate::tools::ToolContext::session_id`].
164 /// `None` means the tool receives `session_id == None` and ownership-gated
165 /// operations will reject the call (defensive default).
166 #[serde(default)]
167 pub session_id: Option<String>,
168
169 /// Per-provider options for fine-grained control.
170 ///
171 /// When set, these are passed through to [`oxi_ai::StreamOptions::provider_options`]
172 /// so the provider can read provider-specific settings (e.g. Anthropic adaptive
173 /// thinking, OpenAI reasoning_effort, Google thinkingConfig).
174 #[serde(default)]
175 pub provider_options: Option<oxi_ai::ProviderOptions>,
176
177 /// TTSR engine for stream rule checking. When set, streaming output
178 /// is checked against registered rules and violations trigger
179 /// [`crate::agent_loop::StreamOutcome::RuleInterrupt`].
180 #[serde(skip, default)]
181 pub ttsr_engine: Option<std::sync::Arc<crate::agent_loop::ttsr::TtsrEngine>>,
182
183 /// Memory backend for `memory_*` tools.
184 #[serde(skip, default)]
185 pub memory: Option<std::sync::Arc<dyn crate::tools::MemoryBackend>>,
186 /// Todo state provider for the `todo` tool.
187 #[serde(skip, default)]
188 pub todo: Option<std::sync::Arc<dyn crate::tools::TodoStateProvider>>,
189 /// Agent pool for Hub display and sub-agent matching.
190 #[serde(skip, default)]
191 pub agent_pool: Option<std::sync::Arc<dyn crate::tools::AgentPoolProvider>>,
192 /// URL resolver for internal protocol schemes (`issue://`, `pr://`, etc.).
193 /// Threaded through to [`crate::agent_loop::config::AgentLoopConfig::url_resolver`].
194 /// When `None`, URL-prefixed paths are treated as regular file paths.
195 #[serde(skip, default)]
196 pub url_resolver: Option<std::sync::Arc<dyn crate::tools::UrlResolver>>,
197 /// LSP provider for the `lsp` tool.
198 /// Threaded through to [`crate::agent_loop::config::AgentLoopConfig::lsp`].
199 /// When `None`, the `lsp` tool returns an error.
200 #[serde(skip, default)]
201 pub lsp: Option<std::sync::Arc<dyn crate::tools::LspProvider>>,
202
203 /// Maximum bytes of a tool result's text content before truncation
204 /// (#28 gap 1, surfaced as #32). Threaded through to
205 /// [`crate::agent_loop::config::AgentLoopConfig::max_tool_result_bytes`].
206 ///
207 /// When set, tool results exceeding this limit are truncated and a
208 /// `"... [truncated: N bytes omitted]"` marker is appended, preventing a
209 /// single large tool output from consuming the context window.
210 ///
211 /// `None` (default) = no limit. Opt-in.
212 #[serde(skip, default)]
213 pub max_tool_result_bytes: Option<usize>,
214
215 /// In-process sub-agent runner (#28 gap 3, surfaced as #32). When set,
216 /// the `subagent` tool prefers an in-process isolated run over shelling
217 /// out. Threaded through to
218 /// [`crate::agent_loop::config::AgentLoopConfig::subagent_runner`].
219 #[serde(skip, default)]
220 pub subagent_runner: Option<std::sync::Arc<dyn crate::tools::SubagentRunner>>,
221
222 /// Current sub-agent nesting depth (#28 gap 3, surfaced as #32). Default
223 /// `0` (top-level). The `subagent` tool increments this when forking a
224 /// child config to cap recursion.
225 #[serde(skip, default)]
226 pub subagent_depth: u8,
227 /// Snapshot store for hashline line-anchored edit mode.
228 ///
229 /// When `Some`, the `read` tool records file snapshots and emits
230 /// `[path#TAG]` headers, and the `edit` tool validates edits against
231 /// them. When `None` (default), hashline anchoring is disabled and the
232 /// edit tool falls back to plain text replacement.
233 #[serde(skip, default)]
234 pub snapshot_store: Option<std::sync::Arc<dyn oxi_hashline::SnapshotStore>>,
235}
236
237impl Default for AgentConfig {
238 fn default() -> Self {
239 Self {
240 name: "oxi-agent".to_string(),
241 description: None,
242 model_id: "claude-sonnet-4-20250514".to_string(),
243 system_prompt: None,
244 timeout_seconds: 300,
245 temperature: None,
246 max_tokens: None,
247 compaction_strategy: CompactionStrategy::default(),
248 compaction_instruction: None,
249 context_window: 128_000,
250 workspace_dir: None,
251 output_mode: None,
252 provider_options: None,
253 session_id: None,
254 ttsr_engine: None,
255 memory: None,
256 todo: None,
257 agent_pool: None,
258 url_resolver: None,
259 lsp: None,
260 max_tool_result_bytes: None,
261 subagent_runner: None,
262 subagent_depth: 0,
263 snapshot_store: None,
264 }
265 }
266}
267
268impl AgentConfig {
269 /// Create a new config with the given model ID.
270 pub fn new(model_id: impl Into<String>) -> Self {
271 Self {
272 model_id: model_id.into(),
273 ..Default::default()
274 }
275 }
276
277 /// Set the agent name.
278 pub fn with_name(mut self, name: impl Into<String>) -> Self {
279 self.name = name.into();
280 self
281 }
282
283 /// Set the system prompt.
284 pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
285 self.system_prompt = Some(prompt.into());
286 self
287 }
288
289 /// Set the timeout in seconds for the entire agent run.
290 pub fn with_timeout(mut self, seconds: u64) -> Self {
291 self.timeout_seconds = seconds;
292 self
293 }
294
295 /// Set the compaction strategy for long conversations.
296 pub fn with_compaction_strategy(mut self, strategy: CompactionStrategy) -> Self {
297 self.compaction_strategy = strategy;
298 self
299 }
300
301 /// Set a custom instruction passed to the compactor.
302 pub fn with_compaction_instruction(mut self, instruction: impl Into<String>) -> Self {
303 self.compaction_instruction = Some(instruction.into());
304 self
305 }
306
307 /// Set the session identity threaded into [`crate::tools::ToolContext::session_id`].
308 ///
309 /// Tools that gate behavior on liveness (e.g. an `issue` tool's
310 /// `start`/`close` ownership checks) use this to identify the caller.
311 /// Leaving it `None` causes those tools to see an empty caller id and
312 /// reject ownership-gated operations (defensive default).
313 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
314 self.session_id = Some(session_id.into());
315 self
316 }
317
318 /// Set the hashline snapshot store — enables line-anchored edit mode in
319 /// the `read`/`edit` tools (emits `[path#TAG]` headers, validates edits).
320 pub fn with_snapshot_store(
321 mut self,
322 store: std::sync::Arc<dyn oxi_hashline::SnapshotStore>,
323 ) -> Self {
324 self.snapshot_store = Some(store);
325 self
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn session_id_defaults_to_none() {
335 let c = AgentConfig::default();
336 assert!(c.session_id.is_none(), "default session_id must be None");
337 }
338
339 #[test]
340 fn with_session_id_sets_the_field() {
341 let c = AgentConfig::new("m").with_session_id("proc-42");
342 assert_eq!(c.session_id.as_deref(), Some("proc-42"));
343 }
344
345 #[test]
346 fn session_id_round_trips_through_serde() {
347 // Forward-compat: a serialized config with the new field deserializes back.
348 let with = AgentConfig::new("m").with_session_id("proc-7");
349 let json = serde_json::to_string(&with).unwrap();
350 assert!(json.contains("\"session_id\":"));
351 let back: AgentConfig = serde_json::from_str(&json).unwrap();
352 assert_eq!(back.session_id.as_deref(), Some("proc-7"));
353
354 // Backward-compat: a payload WITHOUT the session_id key must still
355 // deserialize and default the field to None. We build that payload by
356 // serializing a config, then stripping the key with serde_json::Value.
357 let mut v: serde_json::Value =
358 serde_json::from_str(&json).expect("config serializes to valid JSON");
359 if let Some(obj) = v.as_object_mut() {
360 obj.remove("session_id");
361 }
362 let stripped = serde_json::to_string(&v).unwrap();
363 let legacy: AgentConfig = serde_json::from_str(&stripped).unwrap();
364 assert!(
365 legacy.session_id.is_none(),
366 "payload missing session_id must default to None"
367 );
368 }
369
370 #[test]
371 fn loop_passthrough_fields_default() {
372 // issue #32: the three AgentLoopConfig passthrough fields default to
373 // their no-op values, preserving pre-#32 behavior for consumers that
374 // don't set them.
375 let c = AgentConfig::default();
376 assert!(c.max_tool_result_bytes.is_none());
377 assert!(c.subagent_runner.is_none());
378 assert_eq!(c.subagent_depth, 0);
379 }
380
381 #[test]
382 fn loop_passthrough_fields_are_serde_skipped() {
383 // issue #32: the passthrough fields are #[serde(skip, default)].
384 // (1) They must NOT appear in serialized output — this is what lets
385 // the non-serializable `Arc<dyn SubagentRunner>` coexist with
386 // `#[derive(Serialize)]` on AgentConfig.
387 // (2) Legacy payloads missing the keys must deserialize to defaults,
388 // so existing serialized configs are unaffected.
389 let c = AgentConfig::new("m");
390 let json = serde_json::to_string(&c).expect("serializes");
391 assert!(!json.contains("max_tool_result_bytes"));
392 assert!(!json.contains("subagent_runner"));
393 assert!(!json.contains("subagent_depth"));
394
395 let legacy: AgentConfig =
396 serde_json::from_str(r#"{"name":"x","model_id":"m","timeout_seconds":300}"#)
397 .expect("deserializes");
398 assert!(legacy.max_tool_result_bytes.is_none());
399 assert!(legacy.subagent_runner.is_none());
400 assert_eq!(legacy.subagent_depth, 0);
401 }
402
403 #[test]
404 fn loop_passthrough_fields_set_and_clone() {
405 // issue #32 verification: consumers can set the passthrough fields
406 // and they survive Clone (AgentConfig derives Clone).
407 let c = AgentConfig {
408 max_tool_result_bytes: Some(8192),
409 subagent_depth: 3,
410 ..AgentConfig::new("m")
411 };
412 let cloned = c.clone();
413 assert_eq!(cloned.max_tool_result_bytes, Some(8192));
414 assert_eq!(cloned.subagent_depth, 3);
415 assert!(cloned.subagent_runner.is_none());
416 }
417}