ironflow_core/provider.rs
1//! Provider trait and configuration types for agent invocations.
2//!
3//! The [`AgentProvider`] trait is the primary extension point in ironflow: implement it
4//! to plug in any AI backend (local model, HTTP API, mock, etc.) without changing
5//! your workflow code.
6//!
7//! Built-in implementations:
8//!
9//! * [`ClaudeCodeProvider`](crate::providers::claude::ClaudeCodeProvider) - local `claude` CLI.
10//! * `SshProvider` - remote via SSH (requires `transport-ssh` feature).
11//! * `DockerProvider` - Docker container (requires `transport-docker` feature).
12//! * `K8sEphemeralProvider` - ephemeral K8s pod (requires `transport-k8s` feature).
13//! * `K8sPersistentProvider` - persistent K8s pod (requires `transport-k8s` feature).
14//! * [`RecordReplayProvider`](crate::providers::record_replay::RecordReplayProvider) -
15//! records and replays fixtures for deterministic testing.
16
17use std::fmt;
18use std::future::Future;
19use std::marker::PhantomData;
20use std::pin::Pin;
21
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::error::AgentError;
27use crate::operations::agent::{Model, PermissionMode};
28
29/// Boxed future returned by [`AgentProvider::invoke`].
30pub type InvokeFuture<'a> =
31 Pin<Box<dyn Future<Output = Result<AgentOutput, AgentError>> + Send + 'a>>;
32
33// ── Typestate markers ──────────────────────────────────────────────
34
35/// Marker: no tools have been added via the builder.
36#[derive(Debug, Clone, Copy)]
37pub struct NoTools;
38
39/// Marker: at least one tool has been added via [`AgentConfig::allow_tool`].
40#[derive(Debug, Clone, Copy)]
41pub struct WithTools;
42
43/// Marker: no JSON schema has been set via the builder.
44#[derive(Debug, Clone, Copy)]
45pub struct NoSchema;
46
47/// Marker: a JSON schema has been set via [`AgentConfig::output`] or
48/// [`AgentConfig::output_schema_raw`].
49#[derive(Debug, Clone, Copy)]
50pub struct WithSchema;
51
52// ── AgentConfig ────────────────────────────────────────────────────
53
54/// Serializable configuration passed to an [`AgentProvider`] for a single invocation.
55///
56/// Built by [`Agent::run`](crate::operations::agent::Agent::run) from the builder state.
57/// Provider implementations translate these fields into whatever format the underlying
58/// backend expects.
59///
60/// # Typestate: tools vs structured output
61///
62/// Claude CLI has a [known bug](https://github.com/anthropics/claude-code/issues/18536)
63/// where combining `--json-schema` with `--allowedTools` always returns
64/// `structured_output: null`. To prevent this at compile time, [`allow_tool`](Self::allow_tool)
65/// and [`output`](Self::output) / [`output_schema_raw`](Self::output_schema_raw) are mutually
66/// exclusive: using one removes the other from the available API.
67///
68/// ```
69/// use ironflow_core::provider::AgentConfig;
70///
71/// // OK: tools only
72/// let _ = AgentConfig::new("search").allow_tool("WebSearch");
73///
74/// // OK: structured output only
75/// let _ = AgentConfig::new("classify").output_schema_raw(r#"{"type":"object"}"#);
76/// ```
77///
78/// ```compile_fail
79/// use ironflow_core::provider::AgentConfig;
80/// // COMPILE ERROR: cannot add tools after setting structured output
81/// let _ = AgentConfig::new("x").output_schema_raw("{}").allow_tool("Read");
82/// ```
83///
84/// ```compile_fail
85/// use ironflow_core::provider::AgentConfig;
86/// // COMPILE ERROR: cannot set structured output after adding tools
87/// let _ = AgentConfig::new("x").allow_tool("Read").output_schema_raw("{}");
88/// ```
89///
90/// **Workaround**: split the work into two steps -- one agent with tools to
91/// gather data, then a second agent with `.output::<T>()` to structure the result.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(bound(serialize = "", deserialize = ""))]
94#[non_exhaustive]
95pub struct AgentConfig<Tools = NoTools, Schema = NoSchema> {
96 /// Optional system prompt that sets the agent's persona or constraints.
97 pub system_prompt: Option<String>,
98
99 /// The user prompt - the main instruction to the agent.
100 pub prompt: String,
101
102 /// Which model to use for this invocation.
103 ///
104 /// Accepts any string. Use [`Model`] constants for well-known Claude models
105 /// (e.g. `Model::SONNET`), or pass a custom identifier for other providers.
106 #[serde(default = "default_model")]
107 pub model: String,
108
109 /// Allowlist of tool names the agent may invoke (empty = provider default).
110 #[serde(default)]
111 pub allowed_tools: Vec<String>,
112
113 /// Denylist of tool names the agent MUST NOT invoke.
114 ///
115 /// Maps to `--disallowedTools` on the Claude CLI. Unlike
116 /// [`allowed_tools`](Self::allowed_tools), this does **not** activate any
117 /// tools; it only filters out tools that would otherwise be loaded by
118 /// default. As such, it is safe to combine with structured output
119 /// ([`output`](Self::output)) without triggering the Claude CLI bug that
120 /// affects `--json-schema` + `--allowedTools`.
121 #[serde(default)]
122 pub disallowed_tools: Vec<String>,
123
124 /// Maximum number of agentic turns before the provider should stop.
125 pub max_turns: Option<u32>,
126
127 /// Maximum spend in USD for this single invocation.
128 pub max_budget_usd: Option<f64>,
129
130 /// Working directory for the agent process.
131 pub working_dir: Option<String>,
132
133 /// Path to an MCP server configuration file.
134 pub mcp_config: Option<String>,
135
136 /// When `true`, pass `--strict-mcp-config` to the Claude CLI so it only
137 /// loads MCP servers from [`mcp_config`](Self::mcp_config) and ignores
138 /// any global/user MCP configuration (e.g. `~/.claude.json`).
139 ///
140 /// Useful to prevent global MCP servers from leaking tools into steps
141 /// that request `structured_output`, which triggers the Claude CLI bug
142 /// where `--json-schema` combined with any active tool returns
143 /// `structured_output: null`. See
144 /// <https://github.com/anthropics/claude-code/issues/18536>.
145 ///
146 /// Combine with `mcp_config` set to a file containing
147 /// `{"mcpServers":{}}` to disable every MCP server for the invocation.
148 #[serde(default)]
149 pub strict_mcp_config: bool,
150
151 /// When `true`, pass `--bare` to Claude CLI. Bare mode disables:
152 /// - auto-memory (automatic creation of `~/.claude/.../memory/*.md` files)
153 /// - `CLAUDE.md` auto-discovery (no global/project `CLAUDE.md` loaded)
154 /// - hooks, LSP, plugin sync, attribution, background prefetches
155 ///
156 /// Recommended for orchestrator agents that should not have any implicit
157 /// side effects on the user's filesystem or inherit user-level context.
158 ///
159 /// # Authentication requirement
160 ///
161 /// `--bare` is **only compatible with an Anthropic API key**
162 /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
163 /// OAuth authentication (`claude /login` / keychain-stored credentials),
164 /// because bare mode disables keychain reads.
165 #[serde(default)]
166 pub bare: bool,
167
168 /// Permission mode controlling how the agent handles tool-use approvals.
169 #[serde(default)]
170 pub permission_mode: PermissionMode,
171
172 /// Optional JSON Schema string. When set, the provider should request
173 /// structured (typed) output from the model.
174 #[serde(alias = "output_schema")]
175 pub json_schema: Option<String>,
176
177 /// Optional session ID to resume a previous conversation.
178 ///
179 /// When set, the provider should continue the conversation from the
180 /// specified session rather than starting a new one.
181 pub resume_session_id: Option<String>,
182
183 /// Enable verbose/debug mode to capture the full conversation trace.
184 ///
185 /// When `true`, the provider uses streaming output (`stream-json`) to
186 /// record every assistant message and tool call. The resulting
187 /// [`AgentOutput::debug_messages`] field will contain the conversation
188 /// trace for inspection.
189 #[serde(default)]
190 pub verbose: bool,
191
192 /// Zero-sized typestate marker (not serialized).
193 #[serde(skip)]
194 pub(crate) _marker: PhantomData<(Tools, Schema)>,
195}
196
197fn default_model() -> String {
198 Model::SONNET.to_string()
199}
200
201// ── Constructor (base type only) ───────────────────────────────────
202
203impl AgentConfig {
204 /// Create an `AgentConfig` with required fields and defaults for the rest.
205 pub fn new(prompt: &str) -> Self {
206 Self {
207 system_prompt: None,
208 prompt: prompt.to_string(),
209 model: Model::SONNET.to_string(),
210 allowed_tools: Vec::new(),
211 disallowed_tools: Vec::new(),
212 max_turns: None,
213 max_budget_usd: None,
214 working_dir: None,
215 mcp_config: None,
216 strict_mcp_config: false,
217 bare: false,
218 permission_mode: PermissionMode::Default,
219 json_schema: None,
220 resume_session_id: None,
221 verbose: false,
222 _marker: PhantomData,
223 }
224 }
225}
226
227// ── Methods available on ALL typestate variants ────────────────────
228
229impl<Tools, Schema> AgentConfig<Tools, Schema> {
230 /// Set the system prompt.
231 pub fn system_prompt(mut self, prompt: &str) -> Self {
232 self.system_prompt = Some(prompt.to_string());
233 self
234 }
235
236 /// Set the model name.
237 pub fn model(mut self, model: &str) -> Self {
238 self.model = model.to_string();
239 self
240 }
241
242 /// Set the maximum budget in USD.
243 pub fn max_budget_usd(mut self, budget: f64) -> Self {
244 self.max_budget_usd = Some(budget);
245 self
246 }
247
248 /// Set the maximum number of turns.
249 pub fn max_turns(mut self, turns: u32) -> Self {
250 self.max_turns = Some(turns);
251 self
252 }
253
254 /// Set the working directory.
255 pub fn working_dir(mut self, dir: &str) -> Self {
256 self.working_dir = Some(dir.to_string());
257 self
258 }
259
260 /// Set the permission mode.
261 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
262 self.permission_mode = mode;
263 self
264 }
265
266 /// Enable verbose/debug mode.
267 pub fn verbose(mut self, enabled: bool) -> Self {
268 self.verbose = enabled;
269 self
270 }
271
272 /// Set the MCP server configuration file path.
273 pub fn mcp_config(mut self, config: &str) -> Self {
274 self.mcp_config = Some(config.to_string());
275 self
276 }
277
278 /// Enable strict MCP config mode.
279 ///
280 /// When `true`, the Claude CLI is invoked with `--strict-mcp-config`,
281 /// which disables loading of any MCP server defined outside the
282 /// [`mcp_config`](Self::mcp_config) file (the global `~/.claude.json`
283 /// and user-level configs are ignored).
284 ///
285 /// This is the recommended way to prevent global MCP servers from
286 /// silently injecting tools into a structured-output step and
287 /// triggering the Claude CLI bug that returns `structured_output: null`
288 /// whenever any tool is active. See
289 /// <https://github.com/anthropics/claude-code/issues/18536>.
290 ///
291 /// # Examples
292 ///
293 /// ```
294 /// use ironflow_core::provider::AgentConfig;
295 /// use schemars::JsonSchema;
296 ///
297 /// #[derive(serde::Deserialize, JsonSchema)]
298 /// struct Out { ok: bool }
299 ///
300 /// // Isolate the step from any global MCP server so structured output works.
301 /// let config = AgentConfig::new("classify this")
302 /// .strict_mcp_config(true)
303 /// .mcp_config(r#"{"mcpServers":{}}"#)
304 /// .output::<Out>();
305 /// ```
306 pub fn strict_mcp_config(mut self, strict: bool) -> Self {
307 self.strict_mcp_config = strict;
308 self
309 }
310
311 /// Enable bare mode (minimal Claude Code environment, see `--bare`).
312 ///
313 /// When `true`, the Claude CLI is invoked with `--bare`, which disables:
314 /// - auto-memory (no automatic `~/.claude/.../memory/*.md` file creation)
315 /// - `CLAUDE.md` auto-discovery (neither global nor project-level)
316 /// - hooks, LSP, plugin sync, attribution, background prefetches,
317 /// keychain reads
318 ///
319 /// Sets `CLAUDE_CODE_SIMPLE=1` in the child process.
320 ///
321 /// Recommended for orchestrator steps that should not have any implicit
322 /// side effects on the user's filesystem or inherit user-level context
323 /// (email, preferences, etc.).
324 ///
325 /// # Authentication requirement
326 ///
327 /// `--bare` is **only compatible with an Anthropic API key**
328 /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
329 /// OAuth authentication (`claude /login` / keychain-stored credentials),
330 /// because bare mode disables keychain reads. Invoking a bare agent on an
331 /// OAuth-only host will fail with an authentication error.
332 ///
333 /// # Examples
334 ///
335 /// ```
336 /// use ironflow_core::provider::AgentConfig;
337 ///
338 /// let config = AgentConfig::new("classify this")
339 /// .bare(true);
340 /// ```
341 pub fn bare(mut self, enabled: bool) -> Self {
342 self.bare = enabled;
343 self
344 }
345
346 /// Replace the entire disallowed-tools list.
347 ///
348 /// Maps to `--disallowedTools` on the Claude CLI. This method is available
349 /// on **every** typestate variant (including
350 /// [`AgentConfig<NoTools, WithSchema>`]) because, unlike
351 /// [`allow_tool`](AgentConfig::allow_tool), `disallowed_tools` does not
352 /// activate any tool -- it only filters out tools that would otherwise be
353 /// loaded by default.
354 ///
355 /// As such, it is safe to combine with structured output:
356 ///
357 /// # Examples
358 ///
359 /// ```
360 /// use ironflow_core::provider::AgentConfig;
361 /// use schemars::JsonSchema;
362 ///
363 /// #[derive(serde::Deserialize, JsonSchema)]
364 /// struct Out { ok: bool }
365 ///
366 /// let config = AgentConfig::new("classify this")
367 /// .disallowed_tools(["Write", "Edit"])
368 /// .output::<Out>();
369 /// ```
370 pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
371 where
372 I: IntoIterator<Item = S>,
373 S: Into<String>,
374 {
375 self.disallowed_tools = tools.into_iter().map(Into::into).collect();
376 self
377 }
378
379 /// Set a session ID to resume a previous conversation.
380 pub fn resume(mut self, session_id: &str) -> Self {
381 self.resume_session_id = Some(session_id.to_string());
382 self
383 }
384
385 /// Convert to a different typestate by moving all fields.
386 ///
387 /// Safe because the marker is a zero-sized [`PhantomData`] -- no
388 /// runtime data changes.
389 fn change_state<T2, S2>(self) -> AgentConfig<T2, S2> {
390 AgentConfig {
391 system_prompt: self.system_prompt,
392 prompt: self.prompt,
393 model: self.model,
394 allowed_tools: self.allowed_tools,
395 disallowed_tools: self.disallowed_tools,
396 max_turns: self.max_turns,
397 max_budget_usd: self.max_budget_usd,
398 working_dir: self.working_dir,
399 mcp_config: self.mcp_config,
400 strict_mcp_config: self.strict_mcp_config,
401 bare: self.bare,
402 permission_mode: self.permission_mode,
403 json_schema: self.json_schema,
404 resume_session_id: self.resume_session_id,
405 verbose: self.verbose,
406 _marker: PhantomData,
407 }
408 }
409}
410
411// ── allow_tool: only when no schema is set ─────────────────────────
412
413impl<Tools> AgentConfig<Tools, NoSchema> {
414 /// Add an allowed tool.
415 ///
416 /// Can be called multiple times to allow several tools. Returns an
417 /// [`AgentConfig<WithTools, NoSchema>`], which **cannot** call
418 /// [`output`](AgentConfig::output) or [`output_schema_raw`](AgentConfig::output_schema_raw).
419 ///
420 /// This restriction exists because Claude CLI has a
421 /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
422 /// where `--json-schema` combined with `--allowedTools` always returns
423 /// `structured_output: null`.
424 ///
425 /// **Workaround**: use two sequential agent steps -- one with tools to
426 /// gather data, then one with `.output::<T>()` to structure the result.
427 ///
428 /// # Examples
429 ///
430 /// ```
431 /// use ironflow_core::provider::AgentConfig;
432 ///
433 /// let config = AgentConfig::new("search the web")
434 /// .allow_tool("WebSearch")
435 /// .allow_tool("WebFetch");
436 /// ```
437 ///
438 /// ```compile_fail
439 /// use ironflow_core::provider::AgentConfig;
440 /// // ERROR: cannot set structured output after adding tools
441 /// let _ = AgentConfig::new("x")
442 /// .allow_tool("Read")
443 /// .output_schema_raw(r#"{"type":"object"}"#);
444 /// ```
445 pub fn allow_tool(mut self, tool: &str) -> AgentConfig<WithTools, NoSchema> {
446 self.allowed_tools.push(tool.to_string());
447 self.change_state()
448 }
449}
450
451// ── output: only when no tools are set ─────────────────────────────
452
453impl<Schema> AgentConfig<NoTools, Schema> {
454 /// Set structured output from a Rust type implementing [`JsonSchema`].
455 ///
456 /// The schema is serialized once at build time. When set, the provider
457 /// will request typed output conforming to this schema.
458 ///
459 /// **Important:** structured output requires `max_turns >= 2`.
460 ///
461 /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
462 /// call [`allow_tool`](AgentConfig::allow_tool).
463 ///
464 /// This restriction exists because Claude CLI has a
465 /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
466 /// where `--json-schema` combined with `--allowedTools` always returns
467 /// `structured_output: null`.
468 ///
469 /// **Workaround**: use two sequential agent steps -- one with tools to
470 /// gather data, then one with `.output::<T>()` to structure the result.
471 ///
472 /// # Known limitations of Claude CLI structured output
473 ///
474 /// The Claude CLI does not guarantee strict schema conformance for
475 /// structured output. The following upstream bugs affect the behavior:
476 ///
477 /// - **Schema flattening** ([anthropics/claude-agent-sdk-python#502]):
478 /// a schema like `{"type":"object","properties":{"items":{"type":"array",...}}}`
479 /// may return a bare array instead of the wrapper object. The CLI
480 /// non-deterministically flattens schemas with a single array field.
481 /// - **Non-deterministic wrapping** ([anthropics/claude-agent-sdk-python#374]):
482 /// the same prompt can produce differently wrapped output across runs.
483 /// - **No conformance guarantee** ([anthropics/claude-code#9058]):
484 /// the CLI does not validate output against the provided JSON schema.
485 ///
486 /// Because of these bugs, ironflow's provider layer applies multiple
487 /// fallback strategies when extracting the structured value (see
488 /// [`extract_structured_value`](crate::providers::claude::common::extract_structured_value)).
489 ///
490 /// [anthropics/claude-agent-sdk-python#502]: https://github.com/anthropics/claude-agent-sdk-python/issues/502
491 /// [anthropics/claude-agent-sdk-python#374]: https://github.com/anthropics/claude-agent-sdk-python/issues/374
492 /// [anthropics/claude-code#9058]: https://github.com/anthropics/claude-code/issues/9058
493 ///
494 /// # Examples
495 ///
496 /// ```
497 /// use ironflow_core::provider::AgentConfig;
498 /// use schemars::JsonSchema;
499 ///
500 /// #[derive(serde::Deserialize, JsonSchema)]
501 /// struct Labels { labels: Vec<String> }
502 ///
503 /// let config = AgentConfig::new("classify this text")
504 /// .output::<Labels>();
505 /// ```
506 ///
507 /// ```compile_fail
508 /// use ironflow_core::provider::AgentConfig;
509 /// use schemars::JsonSchema;
510 /// #[derive(serde::Deserialize, JsonSchema)]
511 /// struct Out { x: i32 }
512 /// // ERROR: cannot add tools after setting structured output
513 /// let _ = AgentConfig::new("x").output::<Out>().allow_tool("Read");
514 /// ```
515 /// # Panics
516 ///
517 /// Panics if the schema generated by `schemars` cannot be serialized
518 /// to JSON. This indicates a bug in the type's `JsonSchema` derive,
519 /// not a recoverable runtime error.
520 pub fn output<T: JsonSchema>(mut self) -> AgentConfig<NoTools, WithSchema> {
521 let schema = schemars::schema_for!(T);
522 let serialized = serde_json::to_string(&schema).unwrap_or_else(|e| {
523 panic!(
524 "failed to serialize JSON schema for {}: {e}",
525 std::any::type_name::<T>()
526 )
527 });
528 self.json_schema = Some(serialized);
529 self.change_state()
530 }
531
532 /// Set structured output from a pre-serialized JSON Schema string.
533 ///
534 /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
535 /// call [`allow_tool`](AgentConfig::allow_tool). See [`output`](Self::output)
536 /// for the rationale and workaround.
537 pub fn output_schema_raw(mut self, schema: &str) -> AgentConfig<NoTools, WithSchema> {
538 self.json_schema = Some(schema.to_string());
539 self.change_state()
540 }
541}
542
543// ── From conversions to base type ──────────────────────────────────
544
545impl From<AgentConfig<WithTools, NoSchema>> for AgentConfig {
546 fn from(config: AgentConfig<WithTools, NoSchema>) -> Self {
547 config.change_state()
548 }
549}
550
551impl From<AgentConfig<NoTools, WithSchema>> for AgentConfig {
552 fn from(config: AgentConfig<NoTools, WithSchema>) -> Self {
553 config.change_state()
554 }
555}
556
557// ── AgentOutput ────────────────────────────────────────────────────
558
559/// Raw output returned by an [`AgentProvider`] after a successful invocation.
560///
561/// Carries the agent's response value together with usage and billing metadata.
562#[derive(Clone, Debug, Serialize, Deserialize)]
563#[non_exhaustive]
564pub struct AgentOutput {
565 /// The agent's response. A plain [`Value::String`] for text mode, or an
566 /// arbitrary JSON value when a JSON schema was requested.
567 pub value: Value,
568
569 /// Provider-assigned session identifier, useful for resuming conversations.
570 pub session_id: Option<String>,
571
572 /// Total cost in USD for this invocation, if reported by the provider.
573 pub cost_usd: Option<f64>,
574
575 /// Number of input tokens consumed, if reported.
576 pub input_tokens: Option<u64>,
577
578 /// Number of output tokens generated, if reported.
579 pub output_tokens: Option<u64>,
580
581 /// The concrete model identifier used (e.g. `"claude-sonnet-4-20250514"`).
582 pub model: Option<String>,
583
584 /// Wall-clock duration of the invocation in milliseconds.
585 pub duration_ms: u64,
586
587 /// Conversation trace captured when [`AgentConfig::verbose`] is `true`.
588 ///
589 /// Contains every assistant message and tool call made during the
590 /// invocation, in chronological order. `None` when verbose mode is off.
591 pub debug_messages: Option<Vec<DebugMessage>>,
592}
593
594/// A single assistant turn captured during a verbose invocation.
595///
596/// Each `DebugMessage` represents one assistant response, which may contain
597/// free-form text, tool calls, or both.
598///
599/// # Examples
600///
601/// ```no_run
602/// use ironflow_core::prelude::*;
603///
604/// # async fn example() -> Result<(), OperationError> {
605/// let provider = ClaudeCodeProvider::new();
606/// let result = Agent::new()
607/// .prompt("List files in src/")
608/// .verbose()
609/// .run(&provider)
610/// .await?;
611///
612/// if let Some(messages) = result.debug_messages() {
613/// for msg in messages {
614/// println!("{msg}");
615/// }
616/// }
617/// # Ok(())
618/// # }
619/// ```
620#[derive(Debug, Clone, Serialize, Deserialize)]
621#[non_exhaustive]
622pub struct DebugMessage {
623 /// Free-form text produced by the assistant in this turn, if any.
624 pub text: Option<String>,
625
626 /// Tool calls made by the assistant in this turn.
627 pub tool_calls: Vec<DebugToolCall>,
628
629 /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
630 pub stop_reason: Option<String>,
631}
632
633impl fmt::Display for DebugMessage {
634 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635 if let Some(ref text) = self.text {
636 writeln!(f, "[assistant] {text}")?;
637 }
638 for tc in &self.tool_calls {
639 write!(f, "{tc}")?;
640 }
641 Ok(())
642 }
643}
644
645/// A single tool call captured during a verbose invocation.
646///
647/// Records the tool name and its input arguments as a raw JSON value.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649#[non_exhaustive]
650pub struct DebugToolCall {
651 /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
652 pub name: String,
653
654 /// Input arguments passed to the tool, as raw JSON.
655 pub input: Value,
656}
657
658impl fmt::Display for DebugToolCall {
659 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
660 writeln!(f, " [tool_use] {} -> {}", self.name, self.input)
661 }
662}
663
664impl AgentOutput {
665 /// Create an `AgentOutput` with the given value and sensible defaults.
666 pub fn new(value: Value) -> Self {
667 Self {
668 value,
669 session_id: None,
670 cost_usd: None,
671 input_tokens: None,
672 output_tokens: None,
673 model: None,
674 duration_ms: 0,
675 debug_messages: None,
676 }
677 }
678}
679
680// ── Provider trait ─────────────────────────────────────────────────
681
682/// Trait for AI agent backends.
683///
684/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
685/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
686/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
687///
688/// # Examples
689///
690/// ```no_run
691/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
692///
693/// struct MyProvider;
694///
695/// impl AgentProvider for MyProvider {
696/// fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
697/// Box::pin(async move {
698/// // Call your custom backend here...
699/// todo!()
700/// })
701/// }
702/// }
703/// ```
704pub trait AgentProvider: Send + Sync {
705 /// Execute a single agent invocation with the given configuration.
706 ///
707 /// # Errors
708 ///
709 /// Returns [`AgentError`] if the underlying backend process fails,
710 /// times out, or produces output that does not match the requested schema.
711 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use serde_json::json;
718
719 fn full_config() -> AgentConfig {
720 AgentConfig {
721 system_prompt: Some("you are helpful".to_string()),
722 prompt: "do stuff".to_string(),
723 model: Model::OPUS.to_string(),
724 allowed_tools: vec!["Read".to_string(), "Write".to_string()],
725 disallowed_tools: vec!["Bash".to_string()],
726 max_turns: Some(10),
727 max_budget_usd: Some(2.5),
728 working_dir: Some("/tmp".to_string()),
729 mcp_config: Some("{}".to_string()),
730 strict_mcp_config: true,
731 bare: true,
732 permission_mode: PermissionMode::Auto,
733 json_schema: Some(r#"{"type":"object"}"#.to_string()),
734 resume_session_id: None,
735 verbose: false,
736 _marker: PhantomData,
737 }
738 }
739
740 #[test]
741 fn agent_config_serialize_deserialize_roundtrip() {
742 let config = full_config();
743 let json = serde_json::to_string(&config).unwrap();
744 let back: AgentConfig = serde_json::from_str(&json).unwrap();
745
746 assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
747 assert_eq!(back.prompt, "do stuff");
748 assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
749 assert_eq!(back.max_turns, Some(10));
750 assert_eq!(back.max_budget_usd, Some(2.5));
751 assert_eq!(back.working_dir, Some("/tmp".to_string()));
752 assert_eq!(back.mcp_config, Some("{}".to_string()));
753 assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
754 }
755
756 #[test]
757 fn agent_config_with_all_optional_fields_none() {
758 let config: AgentConfig = AgentConfig {
759 system_prompt: None,
760 prompt: "hello".to_string(),
761 model: Model::HAIKU.to_string(),
762 allowed_tools: vec![],
763 disallowed_tools: vec![],
764 max_turns: None,
765 max_budget_usd: None,
766 working_dir: None,
767 mcp_config: None,
768 strict_mcp_config: false,
769 bare: false,
770 permission_mode: PermissionMode::Default,
771 json_schema: None,
772 resume_session_id: None,
773 verbose: false,
774 _marker: PhantomData,
775 };
776 let json = serde_json::to_string(&config).unwrap();
777 let back: AgentConfig = serde_json::from_str(&json).unwrap();
778
779 assert_eq!(back.system_prompt, None);
780 assert_eq!(back.prompt, "hello");
781 assert!(back.allowed_tools.is_empty());
782 assert_eq!(back.max_turns, None);
783 assert_eq!(back.max_budget_usd, None);
784 assert_eq!(back.working_dir, None);
785 assert_eq!(back.mcp_config, None);
786 assert_eq!(back.json_schema, None);
787 }
788
789 #[test]
790 fn agent_output_serialize_deserialize_roundtrip() {
791 let output = AgentOutput {
792 value: json!({"key": "value"}),
793 session_id: Some("sess-abc".to_string()),
794 cost_usd: Some(0.01),
795 input_tokens: Some(500),
796 output_tokens: Some(200),
797 model: Some("claude-sonnet".to_string()),
798 duration_ms: 3000,
799 debug_messages: None,
800 };
801 let json = serde_json::to_string(&output).unwrap();
802 let back: AgentOutput = serde_json::from_str(&json).unwrap();
803
804 assert_eq!(back.value, json!({"key": "value"}));
805 assert_eq!(back.session_id, Some("sess-abc".to_string()));
806 assert_eq!(back.cost_usd, Some(0.01));
807 assert_eq!(back.input_tokens, Some(500));
808 assert_eq!(back.output_tokens, Some(200));
809 assert_eq!(back.model, Some("claude-sonnet".to_string()));
810 assert_eq!(back.duration_ms, 3000);
811 }
812
813 #[test]
814 fn agent_config_new_has_correct_defaults() {
815 let config = AgentConfig::new("test prompt");
816 assert_eq!(config.prompt, "test prompt");
817 assert_eq!(config.system_prompt, None);
818 assert_eq!(config.model, Model::SONNET);
819 assert!(config.allowed_tools.is_empty());
820 assert_eq!(config.max_turns, None);
821 assert_eq!(config.max_budget_usd, None);
822 assert_eq!(config.working_dir, None);
823 assert_eq!(config.mcp_config, None);
824 assert!(matches!(config.permission_mode, PermissionMode::Default));
825 assert_eq!(config.json_schema, None);
826 assert_eq!(config.resume_session_id, None);
827 assert!(!config.verbose);
828 }
829
830 #[test]
831 fn agent_output_new_has_correct_defaults() {
832 let output = AgentOutput::new(json!("test"));
833 assert_eq!(output.value, json!("test"));
834 assert_eq!(output.session_id, None);
835 assert_eq!(output.cost_usd, None);
836 assert_eq!(output.input_tokens, None);
837 assert_eq!(output.output_tokens, None);
838 assert_eq!(output.model, None);
839 assert_eq!(output.duration_ms, 0);
840 assert!(output.debug_messages.is_none());
841 }
842
843 #[test]
844 fn agent_config_resume_session_roundtrip() {
845 let mut config = AgentConfig::new("test");
846 config.resume_session_id = Some("sess-xyz".to_string());
847 let json = serde_json::to_string(&config).unwrap();
848 let back: AgentConfig = serde_json::from_str(&json).unwrap();
849 assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
850 }
851
852 #[test]
853 fn agent_output_debug_does_not_panic() {
854 let output = AgentOutput {
855 value: json!(null),
856 session_id: None,
857 cost_usd: None,
858 input_tokens: None,
859 output_tokens: None,
860 model: None,
861 duration_ms: 0,
862 debug_messages: None,
863 };
864 let debug_str = format!("{:?}", output);
865 assert!(!debug_str.is_empty());
866 }
867
868 #[test]
869 fn allow_tool_transitions_to_with_tools() {
870 let config = AgentConfig::new("test").allow_tool("Read");
871 assert_eq!(config.allowed_tools, vec!["Read"]);
872
873 // Can add more tools
874 let config = config.allow_tool("Write");
875 assert_eq!(config.allowed_tools, vec!["Read", "Write"]);
876 }
877
878 #[test]
879 fn output_schema_raw_transitions_to_with_schema() {
880 let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
881 assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
882 }
883
884 #[test]
885 fn with_tools_converts_to_base_type() {
886 let typed = AgentConfig::new("test").allow_tool("Read");
887 let base: AgentConfig = typed.into();
888 assert_eq!(base.allowed_tools, vec!["Read"]);
889 }
890
891 #[test]
892 fn with_schema_converts_to_base_type() {
893 let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
894 let base: AgentConfig = typed.into();
895 assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
896 }
897
898 #[test]
899 fn serde_roundtrip_ignores_marker() {
900 let config = AgentConfig::new("test").allow_tool("Read");
901 let json = serde_json::to_string(&config).unwrap();
902 assert!(!json.contains("marker"));
903
904 let back: AgentConfig = serde_json::from_str(&json).unwrap();
905 assert_eq!(back.allowed_tools, vec!["Read"]);
906 }
907
908 #[test]
909 fn bare_defaults_to_false() {
910 let config = AgentConfig::new("hello");
911 assert!(!config.bare, "bare must default to false");
912 }
913
914 #[test]
915 fn bare_builder_sets_flag() {
916 let config = AgentConfig::new("hello").bare(true);
917 assert!(config.bare, "bare(true) must enable the flag");
918
919 let config = config.bare(false);
920 assert!(!config.bare, "bare(false) must disable the flag");
921 }
922
923 #[test]
924 fn bare_serde_default_when_missing() {
925 let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
926 let config: AgentConfig = serde_json::from_str(raw).unwrap();
927 assert!(
928 !config.bare,
929 "bare must default to false when absent from serialized payload"
930 );
931 }
932
933 #[test]
934 fn bare_serde_roundtrip() {
935 let mut config = AgentConfig::new("hello");
936 config.bare = true;
937 let json = serde_json::to_string(&config).unwrap();
938 assert!(
939 json.contains("\"bare\":true"),
940 "serialized form must contain bare:true, got: {json}"
941 );
942
943 let back: AgentConfig = serde_json::from_str(&json).unwrap();
944 assert!(back.bare, "bare must survive a serde roundtrip");
945 }
946
947 #[test]
948 fn disallowed_tools_defaults_to_empty() {
949 let config = AgentConfig::new("hello");
950 assert!(
951 config.disallowed_tools.is_empty(),
952 "disallowed_tools must default to empty"
953 );
954 }
955
956 #[test]
957 fn disallowed_tools_builder_replaces_list() {
958 let config = AgentConfig::new("hello").disallowed_tools(["Write", "Edit"]);
959 assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
960
961 // Subsequent call fully replaces the list.
962 let config = config.disallowed_tools(["Bash"]);
963 assert_eq!(config.disallowed_tools, vec!["Bash"]);
964
965 // Empty input clears the list.
966 let config = config.disallowed_tools(std::iter::empty::<String>());
967 assert!(config.disallowed_tools.is_empty());
968 }
969
970 #[test]
971 fn disallowed_tools_compatible_with_output() {
972 #[derive(serde::Deserialize, JsonSchema)]
973 #[allow(dead_code)]
974 struct Out {
975 ok: bool,
976 }
977
978 // Typestate compile check: .disallowed_tools(...) must be callable
979 // before AND after .output::<T>() because it lives on
980 // impl<Tools, Schema>, not impl<Tools, NoSchema>.
981 let before: AgentConfig<NoTools, WithSchema> = AgentConfig::new("classify")
982 .disallowed_tools(["Write", "Edit"])
983 .output::<Out>();
984 assert_eq!(before.disallowed_tools, vec!["Write", "Edit"]);
985 assert!(before.json_schema.is_some());
986
987 let after: AgentConfig<NoTools, WithSchema> = AgentConfig::new("classify")
988 .output::<Out>()
989 .disallowed_tools(["Write"]);
990 assert_eq!(after.disallowed_tools, vec!["Write"]);
991 assert!(after.json_schema.is_some());
992 }
993
994 #[test]
995 fn disallowed_tools_serde_default_when_missing() {
996 let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
997 let config: AgentConfig = serde_json::from_str(raw).unwrap();
998 assert!(
999 config.disallowed_tools.is_empty(),
1000 "disallowed_tools must default to empty when absent from serialized payload"
1001 );
1002 }
1003
1004 #[test]
1005 fn disallowed_tools_serde_roundtrip() {
1006 let config = AgentConfig::new("hello").disallowed_tools(["Write", "Edit"]);
1007 let json = serde_json::to_string(&config).unwrap();
1008 assert!(
1009 json.contains("\"disallowed_tools\":[\"Write\",\"Edit\"]"),
1010 "serialized form must contain the disallowed_tools array, got: {json}"
1011 );
1012
1013 let back: AgentConfig = serde_json::from_str(&json).unwrap();
1014 assert_eq!(back.disallowed_tools, vec!["Write", "Edit"]);
1015 }
1016}