Skip to main content

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    /// Extended thinking blocks produced by the model in this turn.
627    ///
628    /// Available only when the model emits `thinking` content blocks
629    /// (Opus 4.7 adaptive thinking, Claude 3.7+ extended thinking, etc.).
630    /// The blocks are joined in arrival order.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub thinking: Option<String>,
633
634    /// `true` when the model emitted a `thinking` content block but the
635    /// text was redacted (only a signature is provided).
636    ///
637    /// Opus 4.7 adaptive thinking and the `display: "omitted"` setting both
638    /// produce signature-only thinking blocks: the model proves it reasoned
639    /// without exposing the chain of thought. The UI should still show a
640    /// badge so the user knows thinking happened.
641    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
642    pub thinking_redacted: bool,
643
644    /// Tool calls made by the assistant in this turn.
645    pub tool_calls: Vec<DebugToolCall>,
646
647    /// Tool results received from the user/runtime for the preceding tool calls.
648    ///
649    /// In the Claude stream-json format, tool results come as `"type":"user"`
650    /// messages whose content is a list of `tool_result` blocks. We attach
651    /// them to the turn that emitted the matching `tool_use` so the timeline
652    /// stays compact.
653    #[serde(default, skip_serializing_if = "Vec::is_empty")]
654    pub tool_results: Vec<DebugToolResult>,
655
656    /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
657    pub stop_reason: Option<String>,
658
659    /// Input tokens consumed by this turn, if reported.
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub input_tokens: Option<u64>,
662
663    /// Output tokens generated by this turn, if reported.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub output_tokens: Option<u64>,
666}
667
668impl fmt::Display for DebugMessage {
669    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670        if let Some(ref thinking) = self.thinking {
671            writeln!(f, "[thinking] {thinking}")?;
672        } else if self.thinking_redacted {
673            writeln!(f, "[thinking redacted]")?;
674        }
675        if let Some(ref text) = self.text {
676            writeln!(f, "[assistant] {text}")?;
677        }
678        for tc in &self.tool_calls {
679            write!(f, "{tc}")?;
680        }
681        for tr in &self.tool_results {
682            write!(f, "{tr}")?;
683        }
684        Ok(())
685    }
686}
687
688/// A single tool call captured during a verbose invocation.
689///
690/// Records the tool name and its input arguments as a raw JSON value.
691#[derive(Debug, Clone, Serialize, Deserialize)]
692#[non_exhaustive]
693pub struct DebugToolCall {
694    /// Stable identifier assigned by the model (`tool_use_id`).
695    ///
696    /// Used to correlate a call with its subsequent [`DebugToolResult`].
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub id: Option<String>,
699
700    /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
701    pub name: String,
702
703    /// Input arguments passed to the tool, as raw JSON.
704    pub input: Value,
705}
706
707impl fmt::Display for DebugToolCall {
708    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
709        writeln!(f, "  [tool_use] {} -> {}", self.name, self.input)
710    }
711}
712
713/// A tool result returned to the model after a tool call.
714///
715/// Carries the tool output (any JSON value: string, object, array) and
716/// an error flag if the tool failed.
717#[derive(Debug, Clone, Serialize, Deserialize)]
718#[non_exhaustive]
719pub struct DebugToolResult {
720    /// The `tool_use_id` this result answers, matching [`DebugToolCall::id`].
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub tool_use_id: Option<String>,
723
724    /// Raw content returned by the tool.
725    pub content: Value,
726
727    /// Whether the tool reported an error.
728    #[serde(default)]
729    pub is_error: bool,
730}
731
732impl fmt::Display for DebugToolResult {
733    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
734        let kind = if self.is_error {
735            "tool_error"
736        } else {
737            "tool_result"
738        };
739        writeln!(f, "  [{kind}] {}", self.content)
740    }
741}
742
743impl AgentOutput {
744    /// Create an `AgentOutput` with the given value and sensible defaults.
745    pub fn new(value: Value) -> Self {
746        Self {
747            value,
748            session_id: None,
749            cost_usd: None,
750            input_tokens: None,
751            output_tokens: None,
752            model: None,
753            duration_ms: 0,
754            debug_messages: None,
755        }
756    }
757}
758
759// ── Provider trait ─────────────────────────────────────────────────
760
761/// Trait for AI agent backends.
762///
763/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
764/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
765/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
766///
767/// # Examples
768///
769/// ```no_run
770/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
771///
772/// struct MyProvider;
773///
774/// impl AgentProvider for MyProvider {
775///     fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
776///         Box::pin(async move {
777///             // Call your custom backend here...
778///             todo!()
779///         })
780///     }
781/// }
782/// ```
783pub trait AgentProvider: Send + Sync {
784    /// Execute a single agent invocation with the given configuration.
785    ///
786    /// # Errors
787    ///
788    /// Returns [`AgentError`] if the underlying backend process fails,
789    /// times out, or produces output that does not match the requested schema.
790    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use serde_json::json;
797
798    fn full_config() -> AgentConfig {
799        AgentConfig {
800            system_prompt: Some("you are helpful".to_string()),
801            prompt: "do stuff".to_string(),
802            model: Model::OPUS.to_string(),
803            allowed_tools: vec!["Read".to_string(), "Write".to_string()],
804            disallowed_tools: vec!["Bash".to_string()],
805            max_turns: Some(10),
806            max_budget_usd: Some(2.5),
807            working_dir: Some("/tmp".to_string()),
808            mcp_config: Some("{}".to_string()),
809            strict_mcp_config: true,
810            bare: true,
811            permission_mode: PermissionMode::Auto,
812            json_schema: Some(r#"{"type":"object"}"#.to_string()),
813            resume_session_id: None,
814            verbose: false,
815            _marker: PhantomData,
816        }
817    }
818
819    #[test]
820    fn agent_config_serialize_deserialize_roundtrip() {
821        let config = full_config();
822        let json = serde_json::to_string(&config).unwrap();
823        let back: AgentConfig = serde_json::from_str(&json).unwrap();
824
825        assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
826        assert_eq!(back.prompt, "do stuff");
827        assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
828        assert_eq!(back.max_turns, Some(10));
829        assert_eq!(back.max_budget_usd, Some(2.5));
830        assert_eq!(back.working_dir, Some("/tmp".to_string()));
831        assert_eq!(back.mcp_config, Some("{}".to_string()));
832        assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
833    }
834
835    #[test]
836    fn agent_config_with_all_optional_fields_none() {
837        let config: AgentConfig = AgentConfig {
838            system_prompt: None,
839            prompt: "hello".to_string(),
840            model: Model::HAIKU.to_string(),
841            allowed_tools: vec![],
842            disallowed_tools: vec![],
843            max_turns: None,
844            max_budget_usd: None,
845            working_dir: None,
846            mcp_config: None,
847            strict_mcp_config: false,
848            bare: false,
849            permission_mode: PermissionMode::Default,
850            json_schema: None,
851            resume_session_id: None,
852            verbose: false,
853            _marker: PhantomData,
854        };
855        let json = serde_json::to_string(&config).unwrap();
856        let back: AgentConfig = serde_json::from_str(&json).unwrap();
857
858        assert_eq!(back.system_prompt, None);
859        assert_eq!(back.prompt, "hello");
860        assert!(back.allowed_tools.is_empty());
861        assert_eq!(back.max_turns, None);
862        assert_eq!(back.max_budget_usd, None);
863        assert_eq!(back.working_dir, None);
864        assert_eq!(back.mcp_config, None);
865        assert_eq!(back.json_schema, None);
866    }
867
868    #[test]
869    fn agent_output_serialize_deserialize_roundtrip() {
870        let output = AgentOutput {
871            value: json!({"key": "value"}),
872            session_id: Some("sess-abc".to_string()),
873            cost_usd: Some(0.01),
874            input_tokens: Some(500),
875            output_tokens: Some(200),
876            model: Some("claude-sonnet".to_string()),
877            duration_ms: 3000,
878            debug_messages: None,
879        };
880        let json = serde_json::to_string(&output).unwrap();
881        let back: AgentOutput = serde_json::from_str(&json).unwrap();
882
883        assert_eq!(back.value, json!({"key": "value"}));
884        assert_eq!(back.session_id, Some("sess-abc".to_string()));
885        assert_eq!(back.cost_usd, Some(0.01));
886        assert_eq!(back.input_tokens, Some(500));
887        assert_eq!(back.output_tokens, Some(200));
888        assert_eq!(back.model, Some("claude-sonnet".to_string()));
889        assert_eq!(back.duration_ms, 3000);
890    }
891
892    #[test]
893    fn agent_config_new_has_correct_defaults() {
894        let config = AgentConfig::new("test prompt");
895        assert_eq!(config.prompt, "test prompt");
896        assert_eq!(config.system_prompt, None);
897        assert_eq!(config.model, Model::SONNET);
898        assert!(config.allowed_tools.is_empty());
899        assert_eq!(config.max_turns, None);
900        assert_eq!(config.max_budget_usd, None);
901        assert_eq!(config.working_dir, None);
902        assert_eq!(config.mcp_config, None);
903        assert!(matches!(config.permission_mode, PermissionMode::Default));
904        assert_eq!(config.json_schema, None);
905        assert_eq!(config.resume_session_id, None);
906        assert!(!config.verbose);
907    }
908
909    #[test]
910    fn agent_output_new_has_correct_defaults() {
911        let output = AgentOutput::new(json!("test"));
912        assert_eq!(output.value, json!("test"));
913        assert_eq!(output.session_id, None);
914        assert_eq!(output.cost_usd, None);
915        assert_eq!(output.input_tokens, None);
916        assert_eq!(output.output_tokens, None);
917        assert_eq!(output.model, None);
918        assert_eq!(output.duration_ms, 0);
919        assert!(output.debug_messages.is_none());
920    }
921
922    #[test]
923    fn agent_config_resume_session_roundtrip() {
924        let mut config = AgentConfig::new("test");
925        config.resume_session_id = Some("sess-xyz".to_string());
926        let json = serde_json::to_string(&config).unwrap();
927        let back: AgentConfig = serde_json::from_str(&json).unwrap();
928        assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
929    }
930
931    #[test]
932    fn agent_output_debug_does_not_panic() {
933        let output = AgentOutput {
934            value: json!(null),
935            session_id: None,
936            cost_usd: None,
937            input_tokens: None,
938            output_tokens: None,
939            model: None,
940            duration_ms: 0,
941            debug_messages: None,
942        };
943        let debug_str = format!("{:?}", output);
944        assert!(!debug_str.is_empty());
945    }
946
947    #[test]
948    fn allow_tool_transitions_to_with_tools() {
949        let config = AgentConfig::new("test").allow_tool("Read");
950        assert_eq!(config.allowed_tools, vec!["Read"]);
951
952        // Can add more tools
953        let config = config.allow_tool("Write");
954        assert_eq!(config.allowed_tools, vec!["Read", "Write"]);
955    }
956
957    #[test]
958    fn output_schema_raw_transitions_to_with_schema() {
959        let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
960        assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
961    }
962
963    #[test]
964    fn with_tools_converts_to_base_type() {
965        let typed = AgentConfig::new("test").allow_tool("Read");
966        let base: AgentConfig = typed.into();
967        assert_eq!(base.allowed_tools, vec!["Read"]);
968    }
969
970    #[test]
971    fn with_schema_converts_to_base_type() {
972        let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
973        let base: AgentConfig = typed.into();
974        assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
975    }
976
977    #[test]
978    fn serde_roundtrip_ignores_marker() {
979        let config = AgentConfig::new("test").allow_tool("Read");
980        let json = serde_json::to_string(&config).unwrap();
981        assert!(!json.contains("marker"));
982
983        let back: AgentConfig = serde_json::from_str(&json).unwrap();
984        assert_eq!(back.allowed_tools, vec!["Read"]);
985    }
986
987    #[test]
988    fn bare_defaults_to_false() {
989        let config = AgentConfig::new("hello");
990        assert!(!config.bare, "bare must default to false");
991    }
992
993    #[test]
994    fn bare_builder_sets_flag() {
995        let config = AgentConfig::new("hello").bare(true);
996        assert!(config.bare, "bare(true) must enable the flag");
997
998        let config = config.bare(false);
999        assert!(!config.bare, "bare(false) must disable the flag");
1000    }
1001
1002    #[test]
1003    fn bare_serde_default_when_missing() {
1004        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1005        let config: AgentConfig = serde_json::from_str(raw).unwrap();
1006        assert!(
1007            !config.bare,
1008            "bare must default to false when absent from serialized payload"
1009        );
1010    }
1011
1012    #[test]
1013    fn bare_serde_roundtrip() {
1014        let mut config = AgentConfig::new("hello");
1015        config.bare = true;
1016        let json = serde_json::to_string(&config).unwrap();
1017        assert!(
1018            json.contains("\"bare\":true"),
1019            "serialized form must contain bare:true, got: {json}"
1020        );
1021
1022        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1023        assert!(back.bare, "bare must survive a serde roundtrip");
1024    }
1025
1026    #[test]
1027    fn disallowed_tools_defaults_to_empty() {
1028        let config = AgentConfig::new("hello");
1029        assert!(
1030            config.disallowed_tools.is_empty(),
1031            "disallowed_tools must default to empty"
1032        );
1033    }
1034
1035    #[test]
1036    fn disallowed_tools_builder_replaces_list() {
1037        let config = AgentConfig::new("hello").disallowed_tools(["Write", "Edit"]);
1038        assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
1039
1040        // Subsequent call fully replaces the list.
1041        let config = config.disallowed_tools(["Bash"]);
1042        assert_eq!(config.disallowed_tools, vec!["Bash"]);
1043
1044        // Empty input clears the list.
1045        let config = config.disallowed_tools(std::iter::empty::<String>());
1046        assert!(config.disallowed_tools.is_empty());
1047    }
1048
1049    #[test]
1050    fn disallowed_tools_compatible_with_output() {
1051        #[derive(serde::Deserialize, JsonSchema)]
1052        #[allow(dead_code)]
1053        struct Out {
1054            ok: bool,
1055        }
1056
1057        // Typestate compile check: .disallowed_tools(...) must be callable
1058        // before AND after .output::<T>() because it lives on
1059        // impl<Tools, Schema>, not impl<Tools, NoSchema>.
1060        let before: AgentConfig<NoTools, WithSchema> = AgentConfig::new("classify")
1061            .disallowed_tools(["Write", "Edit"])
1062            .output::<Out>();
1063        assert_eq!(before.disallowed_tools, vec!["Write", "Edit"]);
1064        assert!(before.json_schema.is_some());
1065
1066        let after: AgentConfig<NoTools, WithSchema> = AgentConfig::new("classify")
1067            .output::<Out>()
1068            .disallowed_tools(["Write"]);
1069        assert_eq!(after.disallowed_tools, vec!["Write"]);
1070        assert!(after.json_schema.is_some());
1071    }
1072
1073    #[test]
1074    fn disallowed_tools_serde_default_when_missing() {
1075        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1076        let config: AgentConfig = serde_json::from_str(raw).unwrap();
1077        assert!(
1078            config.disallowed_tools.is_empty(),
1079            "disallowed_tools must default to empty when absent from serialized payload"
1080        );
1081    }
1082
1083    #[test]
1084    fn disallowed_tools_serde_roundtrip() {
1085        let config = AgentConfig::new("hello").disallowed_tools(["Write", "Edit"]);
1086        let json = serde_json::to_string(&config).unwrap();
1087        assert!(
1088            json.contains("\"disallowed_tools\":[\"Write\",\"Edit\"]"),
1089            "serialized form must contain the disallowed_tools array, got: {json}"
1090        );
1091
1092        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1093        assert_eq!(back.disallowed_tools, vec!["Write", "Edit"]);
1094    }
1095}