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