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