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