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