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
33mod pod;
34mod tool;
35mod tool_profile;
36
37pub(crate) use pod::upsert_secret_env;
38pub use pod::{
39    LABEL_COMPONENT, LABEL_EGRESS_PROFILE, LABEL_EXPIRES_AT, LABEL_MANAGED_BY, LABEL_ROOT_RUN_ID,
40    LABEL_RUN_ID, LABEL_STEP, MANAGED_BY_IRONFLOW, PodSettings, PodVolumeSource, ReadOnlyVolume,
41    SecretEnvVar, assert_pod_label_allowed, is_reserved_pod_label, sanitize_label_value,
42};
43pub use tool::Tool;
44pub use tool_profile::ToolProfile;
45
46/// Boxed future returned by [`AgentProvider::invoke`].
47pub type InvokeFuture<'a> =
48    Pin<Box<dyn Future<Output = Result<AgentOutput, AgentError>> + Send + 'a>>;
49
50/// Boxed future returned by [`AgentProvider::release_run`].
51pub type ReleaseFuture<'a> = Pin<Box<dyn Future<Output = Result<(), AgentError>> + Send + 'a>>;
52
53// ── Typestate markers ──────────────────────────────────────────────
54
55/// Marker: no tools have been added via the builder.
56#[derive(Debug, Clone, Copy)]
57pub struct NoTools;
58
59/// Marker: at least one tool has been added via [`AgentConfig::allow_tool`],
60/// or a tool profile selected via [`AgentConfig::tool_profile`].
61#[derive(Debug, Clone, Copy)]
62pub struct WithTools;
63
64/// Marker: no JSON schema has been set via the builder.
65#[derive(Debug, Clone, Copy)]
66pub struct NoSchema;
67
68/// Marker: a JSON schema derived from `T` has been set via
69/// [`AgentConfig::output`]. The step answers with a `T`.
70pub struct WithSchema<T>(PhantomData<fn() -> T>);
71
72// Written by hand: a derive would require `T: Debug + Clone + Copy` for a
73// marker that never holds a `T`.
74impl<T> fmt::Debug for WithSchema<T> {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str("WithSchema")
77    }
78}
79
80impl<T> Clone for WithSchema<T> {
81    fn clone(&self) -> Self {
82        *self
83    }
84}
85
86impl<T> Copy for WithSchema<T> {}
87
88/// Marker: a pre-serialized JSON schema has been set via
89/// [`AgentConfig::output_schema_raw`]. The answer is not typed.
90#[derive(Debug, Clone, Copy)]
91pub struct RawSchema;
92
93// ── AgentInput ─────────────────────────────────────────────────────
94
95/// Declarative external input fetched into the agent's filesystem before invocation.
96///
97/// Each input is a URL that the provider must download and materialize at
98/// `mount_path` so the agent can read it via the `Read` tool.
99///
100/// Provider behavior:
101///
102/// * [`ClaudeCodeProvider`](crate::providers::claude::ClaudeCodeProvider) (local) -
103///   downloads via reqwest into a per-invocation temp directory and rewrites
104///   `mount_path` to the resolved local path.
105/// * `K8sEphemeralProvider` - injects a `curlimages/curl` initContainer that
106///   downloads each URL into a shared `emptyDir`, mounted on the main container
107///   at the parent directory of `mount_path`.
108///
109/// The `mount_path` must be an absolute path. Intermediate directories are
110/// created automatically.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct AgentInput {
113    /// Source URL to download (HTTP/HTTPS, including signed S3/R2 URLs).
114    pub url: String,
115
116    /// Absolute filesystem path where the file must be available inside the
117    /// agent's filesystem.
118    pub mount_path: String,
119}
120
121impl AgentInput {
122    /// Create a new input descriptor.
123    pub fn new(url: &str, mount_path: &str) -> Self {
124        Self {
125            url: url.to_string(),
126            mount_path: mount_path.to_string(),
127        }
128    }
129}
130
131// ── AgentConfig ────────────────────────────────────────────────────
132
133/// Serializable configuration passed to an [`AgentProvider`] for a single invocation.
134///
135/// Built by [`Agent::run`](crate::operations::agent::Agent::run) from the builder state.
136/// Provider implementations translate these fields into whatever format the underlying
137/// backend expects.
138///
139/// # Typestate: tools vs structured output
140///
141/// Claude CLI has a [known bug](https://github.com/anthropics/claude-code/issues/18536)
142/// where combining `--json-schema` with `--allowedTools` always returns
143/// `structured_output: null`. To prevent this at compile time, [`allow_tool`](Self::allow_tool)
144/// and [`output`](Self::output) / [`output_schema_raw`](Self::output_schema_raw) are mutually
145/// exclusive: using one removes the other from the available API.
146///
147/// ```
148/// use ironflow_core::provider::{AgentConfig, Tool};
149///
150/// // OK: tools only
151/// let _ = AgentConfig::new("search").allow_tool(Tool::WebSearch);
152///
153/// // OK: structured output only
154/// let _ = AgentConfig::new("classify").output_schema_raw(r#"{"type":"object"}"#);
155/// ```
156///
157/// ```compile_fail,E0599
158/// use ironflow_core::provider::{AgentConfig, Tool};
159/// // COMPILE ERROR: cannot add tools after setting structured output
160/// let _ = AgentConfig::new("x").output_schema_raw("{}").allow_tool(Tool::Read);
161/// ```
162///
163/// ```compile_fail,E0599
164/// use ironflow_core::provider::{AgentConfig, Tool};
165/// // COMPILE ERROR: cannot set structured output after adding tools
166/// let _ = AgentConfig::new("x").allow_tool(Tool::Read).output_schema_raw("{}");
167/// ```
168///
169/// ```compile_fail,E0599
170/// use ironflow_core::provider::{AgentConfig, ToolProfile};
171/// // COMPILE ERROR: a tool profile counts as tools
172/// let _ = AgentConfig::new("x").tool_profile(ToolProfile::new("bug")).output_schema_raw("{}");
173/// ```
174///
175/// **Workaround**: split the work into two steps -- one agent with tools to
176/// gather data, then a second agent with `.output::<T>()` to structure the result.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(bound(serialize = "", deserialize = ""))]
179#[non_exhaustive]
180pub struct AgentConfig<Tools = NoTools, Schema = NoSchema> {
181    /// Optional system prompt that sets the agent's persona or constraints.
182    pub system_prompt: Option<String>,
183
184    /// The user prompt - the main instruction to the agent.
185    pub prompt: String,
186
187    /// Which model to use for this invocation.
188    ///
189    /// Accepts any string. Use [`Model`] constants for well-known Claude models
190    /// (e.g. `Model::SONNET`), or pass a custom identifier for other providers.
191    #[serde(default = "default_model")]
192    pub model: String,
193
194    /// Allowlist of tool names the agent may invoke (empty = provider default).
195    #[serde(default)]
196    pub allowed_tools: Vec<String>,
197
198    /// Denylist of tool names the agent MUST NOT invoke.
199    ///
200    /// Maps to `--disallowedTools` on the Claude CLI. Unlike
201    /// [`allowed_tools`](Self::allowed_tools), this does **not** activate any
202    /// tools; it only filters out tools that would otherwise be loaded by
203    /// default. As such, it is safe to combine with structured output
204    /// ([`output`](Self::output)) without triggering the Claude CLI bug that
205    /// affects `--json-schema` + `--allowedTools`.
206    #[serde(default)]
207    pub disallowed_tools: Vec<String>,
208
209    /// Named tool profile the provider exposes to this step.
210    ///
211    /// Set it with [`AgentConfig::tool_profile`]. `None` means the provider's
212    /// default tools only (none unless it has some).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub tool_profile: Option<ToolProfile>,
215
216    /// Maximum number of agentic turns before the provider should stop.
217    pub max_turns: Option<u32>,
218
219    /// Maximum number of tool calls executed concurrently within a single
220    /// turn's group of consecutive read-only calls (default 4). `1` restores
221    /// fully sequential tool execution.
222    #[serde(default = "default_max_parallel_tools")]
223    pub max_parallel_tools: usize,
224
225    /// Maximum spend in USD for this single invocation.
226    pub max_budget_usd: Option<f64>,
227
228    /// Working directory for the agent process.
229    pub working_dir: Option<String>,
230
231    /// Path to an MCP server configuration file.
232    pub mcp_config: Option<String>,
233
234    /// When `true`, pass `--strict-mcp-config` to the Claude CLI so it only
235    /// loads MCP servers from [`mcp_config`](Self::mcp_config) and ignores
236    /// any global/user MCP configuration (e.g. `~/.claude.json`).
237    ///
238    /// Useful to prevent global MCP servers from leaking tools into steps
239    /// that request `structured_output`, which triggers the Claude CLI bug
240    /// where `--json-schema` combined with any active tool returns
241    /// `structured_output: null`. See
242    /// <https://github.com/anthropics/claude-code/issues/18536>.
243    ///
244    /// Combine with `mcp_config` set to a file containing
245    /// `{"mcpServers":{}}` to disable every MCP server for the invocation.
246    #[serde(default)]
247    pub strict_mcp_config: bool,
248
249    /// When `true`, pass `--bare` to Claude CLI. Bare mode disables:
250    /// - auto-memory (automatic creation of `~/.claude/.../memory/*.md` files)
251    /// - `CLAUDE.md` auto-discovery (no global/project `CLAUDE.md` loaded)
252    /// - hooks, LSP, plugin sync, attribution, background prefetches
253    ///
254    /// Recommended for orchestrator agents that should not have any implicit
255    /// side effects on the user's filesystem or inherit user-level context.
256    ///
257    /// # Authentication requirement
258    ///
259    /// `--bare` is **only compatible with an Anthropic API key**
260    /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
261    /// OAuth authentication (`claude /login` / keychain-stored credentials),
262    /// because bare mode disables keychain reads.
263    #[serde(default)]
264    pub bare: bool,
265
266    /// Permission mode controlling how the agent handles tool-use approvals.
267    #[serde(default)]
268    pub permission_mode: PermissionMode,
269
270    /// Optional JSON Schema string. When set, the provider should request
271    /// structured (typed) output from the model.
272    #[serde(alias = "output_schema")]
273    pub json_schema: Option<String>,
274
275    /// Optional session ID to resume a previous conversation.
276    ///
277    /// When set, the provider should continue the conversation from the
278    /// specified session rather than starting a new one.
279    pub resume_session_id: Option<String>,
280
281    /// Enable verbose/debug mode to capture the full conversation trace.
282    ///
283    /// When `true`, the provider uses streaming output (`stream-json`) to
284    /// record every assistant message and tool call. The resulting
285    /// [`AgentOutput::debug_messages`] field will contain the conversation
286    /// trace for inspection.
287    #[serde(default)]
288    pub verbose: bool,
289
290    /// Custom labels applied to the pod (K8s providers only).
291    ///
292    /// Non-K8s providers ignore this field. Labels are merged with the
293    /// provider-level pod labels and the hardcoded ironflow labels. In case
294    /// of conflict, hardcoded labels always win, then invocation-level labels,
295    /// then provider-level defaults.
296    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
297    pub pod_labels: BTreeMap<String, String>,
298
299    /// Pod-level settings (K8s ephemeral provider only), merged with the provider's.
300    ///
301    /// Non-K8s providers ignore this field. Set it with
302    /// [`AgentConfig::env_from_secret`], [`AgentConfig::service_account`],
303    /// [`AgentConfig::read_only_volume`] and [`AgentConfig::managed_settings`].
304    #[serde(default, skip_serializing_if = "PodSettings::is_empty")]
305    pub pod: PodSettings,
306
307    /// External inputs to materialize on the agent's filesystem before invocation.
308    ///
309    /// See [`AgentInput`] for the semantics. The provider is responsible for
310    /// fetching each URL and placing it at `mount_path` before the agent runs.
311    /// Add inputs with [`AgentConfig::input_file`].
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub inputs: Vec<AgentInput>,
314
315    /// When `true`, a failure of this step does not fail the run.
316    #[serde(default)]
317    pub allow_failure: bool,
318
319    /// Optional step-level retry policy.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub retry: Option<RetryPolicy>,
322
323    /// Optional W3C trace context for distributed tracing propagation.
324    ///
325    /// When set, providers can inject the `traceparent` header into
326    /// outgoing HTTP requests (LLM APIs, MCP servers) to correlate
327    /// workflow spans with downstream service spans.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub trace_context: Option<WorkflowTraceContext>,
330
331    /// Zero-sized typestate marker (not serialized).
332    #[serde(skip)]
333    pub(crate) _marker: PhantomData<(Tools, Schema)>,
334}
335
336fn default_model() -> String {
337    Model::SONNET.to_string()
338}
339
340fn default_max_parallel_tools() -> usize {
341    4
342}
343
344// ── Constructor (base type only) ───────────────────────────────────
345
346impl AgentConfig {
347    /// Create an `AgentConfig` with required fields and defaults for the rest.
348    pub fn new(prompt: &str) -> Self {
349        Self {
350            system_prompt: None,
351            prompt: prompt.to_string(),
352            model: Model::SONNET.to_string(),
353            allowed_tools: Vec::new(),
354            disallowed_tools: Vec::new(),
355            tool_profile: None,
356            max_turns: None,
357            max_parallel_tools: 4,
358            max_budget_usd: None,
359            working_dir: None,
360            mcp_config: None,
361            strict_mcp_config: false,
362            bare: false,
363            permission_mode: PermissionMode::Default,
364            json_schema: None,
365
366            resume_session_id: None,
367            verbose: false,
368            pod_labels: BTreeMap::new(),
369            pod: PodSettings::default(),
370            inputs: Vec::new(),
371            allow_failure: false,
372            retry: None,
373            trace_context: None,
374            _marker: PhantomData,
375        }
376    }
377}
378
379// ── Methods available on ALL typestate variants ────────────────────
380
381impl<Tools, Schema> AgentConfig<Tools, Schema> {
382    /// Set the system prompt.
383    pub fn system_prompt(mut self, prompt: &str) -> Self {
384        self.system_prompt = Some(prompt.to_string());
385        self
386    }
387
388    /// Set the model name.
389    pub fn model(mut self, model: &str) -> Self {
390        self.model = model.to_string();
391        self
392    }
393
394    /// Set the maximum budget in USD.
395    pub fn max_budget_usd(mut self, budget: f64) -> Self {
396        self.max_budget_usd = Some(budget);
397        self
398    }
399
400    /// Set the maximum number of turns.
401    pub fn max_turns(mut self, turns: u32) -> Self {
402        self.max_turns = Some(turns);
403        self
404    }
405
406    /// Set the maximum number of tool calls executed concurrently within a
407    /// turn's group of consecutive read-only calls.
408    ///
409    /// # Panics
410    ///
411    /// Panics if `n` is `0`.
412    ///
413    /// # Examples
414    ///
415    /// ```
416    /// use ironflow_core::provider::AgentConfig;
417    ///
418    /// let config = AgentConfig::new("summarize the repo").max_parallel_tools(2);
419    /// assert_eq!(config.max_parallel_tools, 2);
420    /// ```
421    pub fn max_parallel_tools(mut self, n: usize) -> Self {
422        assert!(n > 0, "max_parallel_tools must be greater than 0");
423        self.max_parallel_tools = n;
424        self
425    }
426
427    /// Set the working directory.
428    pub fn working_dir(mut self, dir: &str) -> Self {
429        self.working_dir = Some(dir.to_string());
430        self
431    }
432
433    /// Set the permission mode.
434    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
435        self.permission_mode = mode;
436        self
437    }
438
439    /// Enable verbose/debug mode.
440    pub fn verbose(mut self, enabled: bool) -> Self {
441        self.verbose = enabled;
442        self
443    }
444
445    /// Set the MCP server configuration file path.
446    pub fn mcp_config(mut self, config: &str) -> Self {
447        self.mcp_config = Some(config.to_string());
448        self
449    }
450
451    /// Enable strict MCP config mode.
452    ///
453    /// When `true`, the Claude CLI is invoked with `--strict-mcp-config`,
454    /// which disables loading of any MCP server defined outside the
455    /// [`mcp_config`](Self::mcp_config) file (the global `~/.claude.json`
456    /// and user-level configs are ignored).
457    ///
458    /// This is the recommended way to prevent global MCP servers from
459    /// silently injecting tools into a structured-output step and
460    /// triggering the Claude CLI bug that returns `structured_output: null`
461    /// whenever any tool is active. See
462    /// <https://github.com/anthropics/claude-code/issues/18536>.
463    ///
464    /// # Examples
465    ///
466    /// ```
467    /// use ironflow_core::provider::AgentConfig;
468    /// use schemars::JsonSchema;
469    ///
470    /// #[derive(serde::Deserialize, JsonSchema)]
471    /// struct Out { ok: bool }
472    ///
473    /// // Isolate the step from any global MCP server so structured output works.
474    /// let config = AgentConfig::new("classify this")
475    ///     .strict_mcp_config(true)
476    ///     .mcp_config(r#"{"mcpServers":{}}"#)
477    ///     .output::<Out>();
478    /// ```
479    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
480        self.strict_mcp_config = strict;
481        self
482    }
483
484    /// Enable bare mode (minimal Claude Code environment, see `--bare`).
485    ///
486    /// When `true`, the Claude CLI is invoked with `--bare`, which disables:
487    /// - auto-memory (no automatic `~/.claude/.../memory/*.md` file creation)
488    /// - `CLAUDE.md` auto-discovery (neither global nor project-level)
489    /// - hooks, LSP, plugin sync, attribution, background prefetches,
490    ///   keychain reads
491    ///
492    /// Sets `CLAUDE_CODE_SIMPLE=1` in the child process.
493    ///
494    /// Recommended for orchestrator steps that should not have any implicit
495    /// side effects on the user's filesystem or inherit user-level context
496    /// (email, preferences, etc.).
497    ///
498    /// # Authentication requirement
499    ///
500    /// `--bare` is **only compatible with an Anthropic API key**
501    /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
502    /// OAuth authentication (`claude /login` / keychain-stored credentials),
503    /// because bare mode disables keychain reads. Invoking a bare agent on an
504    /// OAuth-only host will fail with an authentication error.
505    ///
506    /// # Examples
507    ///
508    /// ```
509    /// use ironflow_core::provider::AgentConfig;
510    ///
511    /// let config = AgentConfig::new("classify this")
512    ///     .bare(true);
513    /// ```
514    pub fn bare(mut self, enabled: bool) -> Self {
515        self.bare = enabled;
516        self
517    }
518
519    /// Mark this step as allowed to fail without stopping the run.
520    ///
521    /// # Examples
522    ///
523    /// ```
524    /// use ironflow_core::provider::AgentConfig;
525    ///
526    /// let config = AgentConfig::new("lint the code").allow_failure();
527    /// assert!(config.allow_failure);
528    /// ```
529    pub fn allow_failure(mut self) -> Self {
530        self.allow_failure = true;
531        self
532    }
533
534    /// Replace the entire disallowed-tools list.
535    ///
536    /// Maps to `--disallowedTools` on the Claude CLI. This method is available
537    /// on **every** typestate variant (including
538    /// [`AgentConfig<NoTools, WithSchema<T>>`]) because, unlike
539    /// [`allow_tool`](AgentConfig::allow_tool), `disallowed_tools` does not
540    /// activate any tool -- it only filters out tools that would otherwise be
541    /// loaded by default.
542    ///
543    /// As such, it is safe to combine with structured output:
544    ///
545    /// # Examples
546    ///
547    /// ```
548    /// use ironflow_core::provider::{AgentConfig, Tool};
549    /// use schemars::JsonSchema;
550    ///
551    /// #[derive(serde::Deserialize, JsonSchema)]
552    /// struct Out { ok: bool }
553    ///
554    /// let config = AgentConfig::new("classify this")
555    ///     .disallowed_tools([Tool::Write, Tool::Edit])
556    ///     .output::<Out>();
557    /// assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
558    /// ```
559    pub fn disallowed_tools<I>(mut self, tools: I) -> Self
560    where
561        I: IntoIterator<Item = Tool>,
562    {
563        self.disallowed_tools = tools.into_iter().map(|tool| tool.to_string()).collect();
564        self
565    }
566
567    /// Add a single custom pod label (K8s providers only).
568    ///
569    /// Can be called multiple times. Non-K8s providers ignore this field.
570    ///
571    /// # Examples
572    ///
573    /// ```
574    /// use ironflow_core::provider::AgentConfig;
575    ///
576    /// let config = AgentConfig::new("analyze")
577    ///     .pod_label("ironflow.io/network-profile", "grafana-only")
578    ///     .pod_label("team", "observability");
579    /// ```
580    pub fn pod_label(mut self, key: &str, value: &str) -> Self {
581        self.pod_labels.insert(key.to_string(), value.to_string());
582        self
583    }
584
585    /// Replace the entire custom pod labels map (K8s providers only).
586    ///
587    /// Non-K8s providers ignore this field.
588    ///
589    /// # Examples
590    ///
591    /// ```
592    /// use std::collections::BTreeMap;
593    /// use ironflow_core::provider::AgentConfig;
594    ///
595    /// let mut labels = BTreeMap::new();
596    /// labels.insert("env".to_string(), "staging".to_string());
597    /// let config = AgentConfig::new("deploy").pod_labels(labels);
598    /// ```
599    pub fn pod_labels(mut self, labels: BTreeMap<String, String>) -> Self {
600        self.pod_labels = labels;
601        self
602    }
603
604    /// Set a session ID to resume a previous conversation.
605    pub fn resume(mut self, session_id: &str) -> Self {
606        self.resume_session_id = Some(session_id.to_string());
607        self
608    }
609
610    /// Set a step-level retry policy.
611    ///
612    /// # Examples
613    ///
614    /// ```
615    /// use ironflow_core::provider::AgentConfig;
616    /// use ironflow_core::retry::RetryPolicy;
617    ///
618    /// let config = AgentConfig::new("Summarize this document")
619    ///     .retry_policy(RetryPolicy::new(3));
620    /// assert!(config.retry.is_some());
621    /// ```
622    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
623        self.retry = Some(policy);
624        self
625    }
626
627    /// Attach a [`WorkflowTraceContext`] for distributed tracing.
628    ///
629    /// When set, providers can inject the `traceparent` header into
630    /// outgoing HTTP requests to correlate workflow spans with
631    /// downstream service spans.
632    ///
633    /// # Examples
634    ///
635    /// ```
636    /// use ironflow_core::provider::AgentConfig;
637    /// use ironflow_core::trace_context::WorkflowTraceContext;
638    ///
639    /// let ctx = WorkflowTraceContext::new_root();
640    /// let config = AgentConfig::new("classify this")
641    ///     .trace_context(ctx);
642    /// assert!(config.trace_context.is_some());
643    /// ```
644    pub fn trace_context(mut self, ctx: WorkflowTraceContext) -> Self {
645        self.trace_context = Some(ctx);
646        self
647    }
648
649    /// Declare an external input that the provider must materialize on the
650    /// agent's filesystem before invocation.
651    ///
652    /// `url` is fetched (HTTP/HTTPS) and written to `mount_path` (absolute
653    /// path) inside the agent's runtime. Each provider materializes inputs
654    /// in its own way:
655    ///
656    /// * Local provider: downloads to a temp dir on the host.
657    /// * K8s providers: spawn a `curlimages/curl` initContainer that downloads
658    ///   into a shared `emptyDir` mounted on the main container.
659    ///
660    /// Can be called multiple times to declare several inputs.
661    ///
662    /// # Examples
663    ///
664    /// ```
665    /// use ironflow_core::provider::{AgentConfig, Tool};
666    ///
667    /// let config = AgentConfig::new("Read /work/dossier.pdf and summarize")
668    ///     .allow_tool(Tool::Read)
669    ///     .input_file("https://r2.example.com/dossier.pdf", "/work/dossier.pdf");
670    /// ```
671    pub fn input_file(mut self, url: &str, mount_path: &str) -> Self {
672        self.inputs.push(AgentInput::new(url, mount_path));
673        self
674    }
675
676    /// Read an environment variable from a Kubernetes Secret (K8s ephemeral
677    /// provider only).
678    ///
679    /// The pod gets `valueFrom.secretKeyRef`, so the value never enters the
680    /// pod spec. Calling it again with the same `var` replaces the entry. A
681    /// step entry overrides a provider entry with the same name.
682    ///
683    /// # Examples
684    ///
685    /// ```
686    /// use ironflow_core::provider::AgentConfig;
687    ///
688    /// let config = AgentConfig::new("open the MR")
689    ///     .env_from_secret("GITLAB_TOKEN", "gitlab-bot", "token");
690    /// assert_eq!(config.pod.secret_env[0].secret, "gitlab-bot");
691    /// ```
692    pub fn env_from_secret(mut self, var: &str, secret: &str, key: &str) -> Self {
693        let entry = SecretEnvVar {
694            name: var.to_string(),
695            secret: secret.to_string(),
696            key: key.to_string(),
697        };
698        upsert_secret_env(&mut self.pod.secret_env, entry);
699        self
700    }
701
702    /// Run the pod under a given Kubernetes service account (K8s ephemeral
703    /// provider only). Overrides the provider's service account.
704    ///
705    /// # Examples
706    ///
707    /// ```
708    /// use ironflow_core::provider::AgentConfig;
709    ///
710    /// let config = AgentConfig::new("read the cluster").service_account("reader");
711    /// assert_eq!(config.pod.service_account.as_deref(), Some("reader"));
712    /// ```
713    pub fn service_account(mut self, name: &str) -> Self {
714        self.pod.service_account = Some(name.to_string());
715        self
716    }
717
718    /// Mount a volume read-only into the agent container (K8s ephemeral
719    /// provider only). Step volumes come after the provider's.
720    ///
721    /// # Examples
722    ///
723    /// ```
724    /// use ironflow_core::provider::{AgentConfig, PodVolumeSource, ReadOnlyVolume};
725    ///
726    /// let config = AgentConfig::new("review").read_only_volume(ReadOnlyVolume {
727    ///     source: PodVolumeSource::PersistentVolumeClaim { claim_name: "repos".to_string() },
728    ///     mount_path: "/data/repos/api".to_string(),
729    ///     sub_path: Some("api".to_string()),
730    /// });
731    /// assert_eq!(config.pod.read_only_volumes.len(), 1);
732    /// ```
733    pub fn read_only_volume(mut self, volume: ReadOnlyVolume) -> Self {
734        self.pod.read_only_volumes.push(volume);
735        self
736    }
737
738    /// Mount a PersistentVolumeClaim read-only at `mount_path` (K8s ephemeral
739    /// provider only).
740    ///
741    /// # Examples
742    ///
743    /// ```
744    /// use ironflow_core::provider::AgentConfig;
745    ///
746    /// let config = AgentConfig::new("review").read_only_pvc("repos", "/data/repos");
747    /// assert_eq!(config.pod.read_only_volumes[0].mount_path, "/data/repos");
748    /// ```
749    pub fn read_only_pvc(self, claim: &str, mount_path: &str) -> Self {
750        self.read_only_volume(ReadOnlyVolume {
751            source: PodVolumeSource::PersistentVolumeClaim {
752                claim_name: claim.to_string(),
753            },
754            mount_path: mount_path.to_string(),
755            sub_path: None,
756        })
757    }
758
759    /// Mount a node directory read-only at `mount_path` (K8s ephemeral
760    /// provider only).
761    ///
762    /// # Examples
763    ///
764    /// ```
765    /// use ironflow_core::provider::AgentConfig;
766    ///
767    /// let config = AgentConfig::new("review").read_only_host_path("/srv/repos", "/data/repos");
768    /// assert_eq!(config.pod.read_only_volumes.len(), 1);
769    /// ```
770    pub fn read_only_host_path(self, host_path: &str, mount_path: &str) -> Self {
771        self.read_only_volume(ReadOnlyVolume {
772            source: PodVolumeSource::HostPath {
773                path: host_path.to_string(),
774            },
775            mount_path: mount_path.to_string(),
776            sub_path: None,
777        })
778    }
779
780    /// Mount a ConfigMap read-only at `mount_path` (K8s ephemeral provider
781    /// only).
782    ///
783    /// # Examples
784    ///
785    /// ```
786    /// use ironflow_core::provider::AgentConfig;
787    ///
788    /// let config = AgentConfig::new("review").read_only_config_map("guidelines", "/data/guidelines");
789    /// assert_eq!(config.pod.read_only_volumes.len(), 1);
790    /// ```
791    pub fn read_only_config_map(self, name: &str, mount_path: &str) -> Self {
792        self.read_only_volume(ReadOnlyVolume {
793            source: PodVolumeSource::ConfigMap {
794                name: name.to_string(),
795            },
796            mount_path: mount_path.to_string(),
797            sub_path: None,
798        })
799    }
800
801    /// Select a managed-settings preset registered on the provider (K8s
802    /// ephemeral provider only).
803    ///
804    /// The provider maps the preset to a ConfigMap holding
805    /// `managed-settings.json`. An unknown preset fails the step.
806    ///
807    /// # Examples
808    ///
809    /// ```
810    /// use ironflow_core::provider::AgentConfig;
811    ///
812    /// let config = AgentConfig::new("review").managed_settings("readonly");
813    /// assert_eq!(config.pod.managed_settings.as_deref(), Some("readonly"));
814    /// ```
815    pub fn managed_settings(mut self, preset: &str) -> Self {
816        self.pod.managed_settings = Some(preset.to_string());
817        self
818    }
819
820    /// Select the network egress profile of the pod (K8s providers only).
821    ///
822    /// Sets the [`LABEL_EGRESS_PROFILE`] pod label, which network policies
823    /// select on. Overrides the provider's egress profile.
824    ///
825    /// # Examples
826    ///
827    /// ```
828    /// use ironflow_core::provider::{AgentConfig, LABEL_EGRESS_PROFILE};
829    ///
830    /// let config = AgentConfig::new("open the MR").egress_profile("gitlab");
831    /// assert_eq!(config.pod_labels[LABEL_EGRESS_PROFILE], "gitlab");
832    /// ```
833    pub fn egress_profile(mut self, profile: &str) -> Self {
834        self.pod_labels
835            .insert(LABEL_EGRESS_PROFILE.to_string(), profile.to_string());
836        self
837    }
838
839    /// Tag the pod with the run id and step name (K8s providers only).
840    ///
841    /// Sets [`LABEL_RUN_ID`] and [`LABEL_STEP`], both passed through
842    /// [`sanitize_label_value`]. The engine sets these labels on every agent
843    /// step; call it yourself only when running an agent outside the engine.
844    /// The ephemeral provider uses them to delete the pods of a previous
845    /// attempt of the same step before starting a new one.
846    ///
847    /// # Examples
848    ///
849    /// ```
850    /// use ironflow_core::provider::{AgentConfig, LABEL_RUN_ID, LABEL_STEP};
851    ///
852    /// let config = AgentConfig::new("investigate").run_scope("demo-run", "investigate");
853    /// assert_eq!(config.pod_labels[LABEL_RUN_ID], "demo-run");
854    /// assert_eq!(config.pod_labels[LABEL_STEP], "investigate");
855    /// ```
856    pub fn run_scope(mut self, run_id: &str, step: &str) -> Self {
857        self.pod_labels
858            .insert(LABEL_RUN_ID.to_string(), sanitize_label_value(run_id));
859        self.pod_labels
860            .insert(LABEL_STEP.to_string(), sanitize_label_value(step));
861        self
862    }
863
864    /// Convert to a different typestate by moving all fields.
865    ///
866    /// Safe because the marker is a zero-sized [`PhantomData`] -- no
867    /// runtime data changes.
868    fn change_state<T2, S2>(self) -> AgentConfig<T2, S2> {
869        AgentConfig {
870            system_prompt: self.system_prompt,
871            prompt: self.prompt,
872            model: self.model,
873            allowed_tools: self.allowed_tools,
874            disallowed_tools: self.disallowed_tools,
875            tool_profile: self.tool_profile,
876            max_turns: self.max_turns,
877            max_parallel_tools: self.max_parallel_tools,
878            max_budget_usd: self.max_budget_usd,
879            working_dir: self.working_dir,
880            mcp_config: self.mcp_config,
881            strict_mcp_config: self.strict_mcp_config,
882            bare: self.bare,
883            permission_mode: self.permission_mode,
884            json_schema: self.json_schema,
885            resume_session_id: self.resume_session_id,
886            verbose: self.verbose,
887            pod_labels: self.pod_labels,
888            pod: self.pod,
889            inputs: self.inputs,
890            allow_failure: self.allow_failure,
891            retry: self.retry,
892            trace_context: self.trace_context,
893            _marker: PhantomData,
894        }
895    }
896}
897
898// ── allow_tool: only when no schema is set ─────────────────────────
899
900impl<Tools> AgentConfig<Tools, NoSchema> {
901    /// Add an allowed tool.
902    ///
903    /// Can be called multiple times to allow several tools. Returns an
904    /// [`AgentConfig<WithTools, NoSchema>`], which **cannot** call
905    /// [`output`](AgentConfig::output) or [`output_schema_raw`](AgentConfig::output_schema_raw).
906    ///
907    /// This restriction exists because Claude CLI has a
908    /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
909    /// where `--json-schema` combined with `--allowedTools` always returns
910    /// `structured_output: null`.
911    ///
912    /// **Workaround**: use two sequential agent steps -- one with tools to
913    /// gather data, then one with `.output::<T>()` to structure the result.
914    ///
915    /// # Examples
916    ///
917    /// ```
918    /// use ironflow_core::provider::{AgentConfig, Tool};
919    ///
920    /// let config = AgentConfig::new("search the web")
921    ///     .allow_tool(Tool::WebSearch)
922    ///     .allow_tool(Tool::Custom("mcp__docs__lookup".to_string()));
923    /// assert_eq!(config.allowed_tools, vec!["WebSearch", "mcp__docs__lookup"]);
924    /// ```
925    ///
926    /// ```compile_fail,E0599
927    /// use ironflow_core::provider::{AgentConfig, Tool};
928    /// // ERROR: cannot set structured output after adding tools
929    /// let _ = AgentConfig::new("x")
930    ///     .allow_tool(Tool::Read)
931    ///     .output_schema_raw(r#"{"type":"object"}"#);
932    /// ```
933    pub fn allow_tool(mut self, tool: Tool) -> AgentConfig<WithTools, NoSchema> {
934        self.allowed_tools.push(tool.to_string());
935        self.change_state()
936    }
937
938    /// Select the named tool profile the provider exposes to this step.
939    ///
940    /// Profiles are registered with
941    /// [`HttpAgentProvider::with_tool_profile`](crate::providers::http::HttpAgentProvider::with_tool_profile);
942    /// declare each [`ToolProfile`] once as a constant and share it. A profile
943    /// the provider does not have fails the step with
944    /// [`AgentError::UnknownToolProfile`], never falling back to other tools.
945    /// Claude CLI providers fail with [`AgentError::ToolProfileUnsupported`].
946    /// Like [`allow_tool`](Self::allow_tool), it rules out
947    /// [`output`](AgentConfig::output): split into two steps.
948    ///
949    /// # Examples
950    ///
951    /// ```
952    /// use ironflow_core::provider::{AgentConfig, ToolProfile};
953    ///
954    /// const BUG: ToolProfile = ToolProfile::new("bug");
955    ///
956    /// let config = AgentConfig::new("Find the root cause").tool_profile(BUG);
957    /// assert_eq!(config.tool_profile, Some(BUG));
958    /// ```
959    ///
960    /// ```compile_fail,E0308
961    /// use ironflow_core::provider::AgentConfig;
962    /// // COMPILE ERROR: a profile is a `ToolProfile`, not a string
963    /// let _ = AgentConfig::new("x").tool_profile("bug");
964    /// ```
965    pub fn tool_profile(mut self, profile: ToolProfile) -> AgentConfig<WithTools, NoSchema> {
966        self.tool_profile = Some(profile);
967        self.change_state()
968    }
969}
970
971// ── output: only when no tools are set ─────────────────────────────
972
973impl<Schema> AgentConfig<NoTools, Schema> {
974    /// Set structured output from a Rust type implementing [`JsonSchema`].
975    ///
976    /// The schema is serialized once at build time. When set, the provider
977    /// will request typed output conforming to this schema.
978    ///
979    /// **Important:** structured output requires `max_turns >= 2`.
980    ///
981    /// Returns an [`AgentConfig<NoTools, WithSchema<T>>`], which remembers
982    /// `T` (a workflow step built from it answers with a `T`) and **cannot**
983    /// call [`allow_tool`](AgentConfig::allow_tool).
984    ///
985    /// This restriction exists because Claude CLI has a
986    /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
987    /// where `--json-schema` combined with `--allowedTools` always returns
988    /// `structured_output: null`.
989    ///
990    /// **Workaround**: use two sequential agent steps -- one with tools to
991    /// gather data, then one with `.output::<T>()` to structure the result.
992    ///
993    /// # Known limitations of Claude CLI structured output
994    ///
995    /// The Claude CLI does not guarantee strict schema conformance for
996    /// structured output. The following upstream bugs affect the behavior:
997    ///
998    /// - **Schema flattening** ([anthropics/claude-agent-sdk-python#502]):
999    ///   a schema like `{"type":"object","properties":{"items":{"type":"array",...}}}`
1000    ///   may return a bare array instead of the wrapper object. The CLI
1001    ///   non-deterministically flattens schemas with a single array field.
1002    /// - **Non-deterministic wrapping** ([anthropics/claude-agent-sdk-python#374]):
1003    ///   the same prompt can produce differently wrapped output across runs.
1004    /// - **No conformance guarantee** ([anthropics/claude-code#9058]):
1005    ///   the CLI does not validate output against the provided JSON schema.
1006    ///
1007    /// Because of these bugs, ironflow's provider layer applies multiple
1008    /// fallback strategies when extracting the structured value (see
1009    /// [`extract_structured_value`](crate::providers::claude::common::extract_structured_value)).
1010    ///
1011    /// [anthropics/claude-agent-sdk-python#502]: https://github.com/anthropics/claude-agent-sdk-python/issues/502
1012    /// [anthropics/claude-agent-sdk-python#374]: https://github.com/anthropics/claude-agent-sdk-python/issues/374
1013    /// [anthropics/claude-code#9058]: https://github.com/anthropics/claude-code/issues/9058
1014    ///
1015    /// # Examples
1016    ///
1017    /// ```
1018    /// use ironflow_core::provider::AgentConfig;
1019    /// use schemars::JsonSchema;
1020    ///
1021    /// #[derive(serde::Deserialize, JsonSchema)]
1022    /// struct Labels { labels: Vec<String> }
1023    ///
1024    /// let config = AgentConfig::new("classify this text")
1025    ///     .output::<Labels>();
1026    /// ```
1027    ///
1028    /// ```compile_fail,E0599
1029    /// use ironflow_core::provider::{AgentConfig, Tool};
1030    /// use schemars::JsonSchema;
1031    /// #[derive(serde::Deserialize, JsonSchema)]
1032    /// struct Out { x: i32 }
1033    /// // ERROR: cannot add tools after setting structured output
1034    /// let _ = AgentConfig::new("x").output::<Out>().allow_tool(Tool::Read);
1035    /// ```
1036    /// # Panics
1037    ///
1038    /// Panics if the schema generated by `schemars` cannot be serialized
1039    /// to JSON. This indicates a bug in the type's `JsonSchema` derive,
1040    /// not a recoverable runtime error.
1041    pub fn output<T: JsonSchema>(mut self) -> AgentConfig<NoTools, WithSchema<T>> {
1042        let schema = schemars::schema_for!(T);
1043        let serialized = serde_json::to_string(&schema).unwrap_or_else(|e| {
1044            panic!(
1045                "failed to serialize JSON schema for {}: {e}",
1046                std::any::type_name::<T>()
1047            )
1048        });
1049        self.json_schema = Some(serialized);
1050        self.change_state()
1051    }
1052
1053    /// Set structured output from a pre-serialized JSON Schema string.
1054    ///
1055    /// Returns an [`AgentConfig<NoTools, RawSchema>`], whose answer is not
1056    /// typed, and which **cannot** call [`allow_tool`](AgentConfig::allow_tool).
1057    /// Prefer [`output`](Self::output). See it for the rationale and
1058    /// workaround.
1059    pub fn output_schema_raw(mut self, schema: &str) -> AgentConfig<NoTools, RawSchema> {
1060        self.json_schema = Some(schema.to_string());
1061        self.change_state()
1062    }
1063}
1064
1065// ── From conversions to base type ──────────────────────────────────
1066
1067impl From<AgentConfig<WithTools, NoSchema>> for AgentConfig {
1068    fn from(config: AgentConfig<WithTools, NoSchema>) -> Self {
1069        config.change_state()
1070    }
1071}
1072
1073impl<T> From<AgentConfig<NoTools, WithSchema<T>>> for AgentConfig {
1074    fn from(config: AgentConfig<NoTools, WithSchema<T>>) -> Self {
1075        config.change_state()
1076    }
1077}
1078
1079impl From<AgentConfig<NoTools, RawSchema>> for AgentConfig {
1080    fn from(config: AgentConfig<NoTools, RawSchema>) -> Self {
1081        config.change_state()
1082    }
1083}
1084
1085// ── AgentOutput ────────────────────────────────────────────────────
1086
1087/// Raw output returned by an [`AgentProvider`] after a successful invocation.
1088///
1089/// Carries the agent's response value together with usage and billing metadata.
1090#[derive(Clone, Debug, Serialize, Deserialize)]
1091#[non_exhaustive]
1092pub struct AgentOutput {
1093    /// The agent's response. A plain [`Value::String`] for text mode, or an
1094    /// arbitrary JSON value when a JSON schema was requested.
1095    pub value: Value,
1096
1097    /// Provider-assigned session identifier, useful for resuming conversations.
1098    pub session_id: Option<String>,
1099
1100    /// Total cost in USD for this invocation, if reported by the provider.
1101    pub cost_usd: Option<f64>,
1102
1103    /// Uncached input tokens (excludes cache reads and writes), if reported.
1104    pub input_tokens: Option<u64>,
1105
1106    /// Input tokens served from the prompt cache, if reported.
1107    #[serde(default)]
1108    pub cache_read_input_tokens: Option<u64>,
1109
1110    /// Input tokens written to the prompt cache, if reported.
1111    #[serde(default)]
1112    pub cache_creation_input_tokens: Option<u64>,
1113
1114    /// Number of output tokens generated, if reported.
1115    pub output_tokens: Option<u64>,
1116
1117    /// The concrete model identifier used (e.g. `"claude-sonnet-4-20250514"`).
1118    pub model: Option<String>,
1119
1120    /// Wall-clock duration of the invocation in milliseconds.
1121    pub duration_ms: u64,
1122
1123    /// Conversation trace captured when [`AgentConfig::verbose`] is `true`.
1124    ///
1125    /// Contains every assistant message and tool call made during the
1126    /// invocation, in chronological order. `None` when verbose mode is off.
1127    pub debug_messages: Option<Vec<DebugMessage>>,
1128}
1129
1130/// A single assistant turn captured during a verbose invocation.
1131///
1132/// Each `DebugMessage` represents one assistant response, which may contain
1133/// free-form text, tool calls, or both.
1134///
1135/// # Examples
1136///
1137/// ```no_run
1138/// use ironflow_core::prelude::*;
1139///
1140/// # async fn example() -> Result<(), OperationError> {
1141/// let provider = ClaudeCodeProvider::new();
1142/// let result = Agent::new()
1143///     .prompt("List files in src/")
1144///     .verbose()
1145///     .run(&provider)
1146///     .await?;
1147///
1148/// if let Some(messages) = result.debug_messages() {
1149///     for msg in messages {
1150///         println!("{msg}");
1151///     }
1152/// }
1153/// # Ok(())
1154/// # }
1155/// ```
1156#[derive(Debug, Clone, Serialize, Deserialize)]
1157#[non_exhaustive]
1158pub struct DebugMessage {
1159    /// Free-form text produced by the assistant in this turn, if any.
1160    pub text: Option<String>,
1161
1162    /// Extended thinking blocks produced by the model in this turn.
1163    ///
1164    /// Available only when the model emits `thinking` content blocks
1165    /// (Opus 4.7 adaptive thinking, Claude 3.7+ extended thinking, etc.).
1166    /// The blocks are joined in arrival order.
1167    #[serde(default, skip_serializing_if = "Option::is_none")]
1168    pub thinking: Option<String>,
1169
1170    /// `true` when the model emitted a `thinking` content block but the
1171    /// text was redacted (only a signature is provided).
1172    ///
1173    /// Opus 4.7 adaptive thinking and the `display: "omitted"` setting both
1174    /// produce signature-only thinking blocks: the model proves it reasoned
1175    /// without exposing the chain of thought. The UI should still show a
1176    /// badge so the user knows thinking happened.
1177    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1178    pub thinking_redacted: bool,
1179
1180    /// Tool calls made by the assistant in this turn.
1181    pub tool_calls: Vec<DebugToolCall>,
1182
1183    /// Tool results received from the user/runtime for the preceding tool calls.
1184    ///
1185    /// In the Claude stream-json format, tool results come as `"type":"user"`
1186    /// messages whose content is a list of `tool_result` blocks. We attach
1187    /// them to the turn that emitted the matching `tool_use` so the timeline
1188    /// stays compact.
1189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1190    pub tool_results: Vec<DebugToolResult>,
1191
1192    /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
1193    pub stop_reason: Option<String>,
1194
1195    /// Input tokens consumed by this turn, if reported.
1196    #[serde(default, skip_serializing_if = "Option::is_none")]
1197    pub input_tokens: Option<u64>,
1198
1199    /// Output tokens generated by this turn, if reported.
1200    #[serde(default, skip_serializing_if = "Option::is_none")]
1201    pub output_tokens: Option<u64>,
1202}
1203
1204impl fmt::Display for DebugMessage {
1205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1206        if let Some(ref thinking) = self.thinking {
1207            writeln!(f, "[thinking] {thinking}")?;
1208        } else if self.thinking_redacted {
1209            writeln!(f, "[thinking redacted]")?;
1210        }
1211        if let Some(ref text) = self.text {
1212            writeln!(f, "[assistant] {text}")?;
1213        }
1214        for tc in &self.tool_calls {
1215            write!(f, "{tc}")?;
1216        }
1217        for tr in &self.tool_results {
1218            write!(f, "{tr}")?;
1219        }
1220        Ok(())
1221    }
1222}
1223
1224/// A single tool call captured during a verbose invocation.
1225///
1226/// Records the tool name and its input arguments as a raw JSON value.
1227#[derive(Debug, Clone, Serialize, Deserialize)]
1228#[non_exhaustive]
1229pub struct DebugToolCall {
1230    /// Stable identifier assigned by the model (`tool_use_id`).
1231    ///
1232    /// Used to correlate a call with its subsequent [`DebugToolResult`].
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub id: Option<String>,
1235
1236    /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
1237    pub name: String,
1238
1239    /// Input arguments passed to the tool, as raw JSON.
1240    pub input: Value,
1241}
1242
1243impl fmt::Display for DebugToolCall {
1244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1245        writeln!(f, "  [tool_use] {} -> {}", self.name, self.input)
1246    }
1247}
1248
1249/// A tool result returned to the model after a tool call.
1250///
1251/// Carries the tool output (any JSON value: string, object, array) and
1252/// an error flag if the tool failed.
1253#[derive(Debug, Clone, Serialize, Deserialize)]
1254#[non_exhaustive]
1255pub struct DebugToolResult {
1256    /// The `tool_use_id` this result answers, matching [`DebugToolCall::id`].
1257    #[serde(default, skip_serializing_if = "Option::is_none")]
1258    pub tool_use_id: Option<String>,
1259
1260    /// Raw content returned by the tool.
1261    pub content: Value,
1262
1263    /// Whether the tool reported an error.
1264    #[serde(default)]
1265    pub is_error: bool,
1266}
1267
1268impl fmt::Display for DebugToolResult {
1269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1270        let kind = if self.is_error {
1271            "tool_error"
1272        } else {
1273            "tool_result"
1274        };
1275        writeln!(f, "  [{kind}] {}", self.content)
1276    }
1277}
1278
1279impl AgentOutput {
1280    /// Create an `AgentOutput` with the given value and sensible defaults.
1281    pub fn new(value: Value) -> Self {
1282        Self {
1283            value,
1284            session_id: None,
1285            cost_usd: None,
1286            input_tokens: None,
1287            cache_read_input_tokens: None,
1288            cache_creation_input_tokens: None,
1289            output_tokens: None,
1290            model: None,
1291            duration_ms: 0,
1292            debug_messages: None,
1293        }
1294    }
1295}
1296
1297// ── Log sink ──────────────────────────────────────────────────────
1298
1299/// Sink for streaming log lines from provider invocations in real time.
1300///
1301/// Providers that support live log streaming (e.g. K8s ephemeral) call
1302/// [`log`](LogSink::log) for each output line as it is produced, enabling
1303/// downstream consumers (SSE endpoints, log pushers) to display progress
1304/// before the invocation completes.
1305///
1306/// This trait lives in `ironflow-core` so providers can emit logs without
1307/// depending on higher-level crates.
1308///
1309/// # Examples
1310///
1311/// ```
1312/// use std::sync::{Arc, Mutex};
1313/// use ironflow_core::provider::LogSink;
1314///
1315/// struct VecSink(Mutex<Vec<(String, String)>>);
1316///
1317/// impl LogSink for VecSink {
1318///     fn log(&self, stream: &str, line: &str) {
1319///         self.0.lock().unwrap().push((stream.to_string(), line.to_string()));
1320///     }
1321/// }
1322///
1323/// let sink = Arc::new(VecSink(Mutex::new(Vec::new())));
1324/// sink.log("stdout", "hello world");
1325/// assert_eq!(sink.0.lock().unwrap().len(), 1);
1326/// ```
1327pub trait LogSink: Send + Sync {
1328    /// Emit a single log line on the given stream.
1329    ///
1330    /// `stream` is one of `"stdout"`, `"stderr"`, or `"system"`.
1331    /// Implementations should silently drop lines if the receiver is closed.
1332    fn log(&self, stream: &str, line: &str);
1333}
1334
1335// ── Provider trait ─────────────────────────────────────────────────
1336
1337/// Trait for AI agent backends.
1338///
1339/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
1340/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
1341/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
1342///
1343/// # Examples
1344///
1345/// ```no_run
1346/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
1347///
1348/// struct MyProvider;
1349///
1350/// impl AgentProvider for MyProvider {
1351///     fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
1352///         Box::pin(async move {
1353///             // Call your custom backend here...
1354///             todo!()
1355///         })
1356///     }
1357/// }
1358/// ```
1359pub trait AgentProvider: Send + Sync {
1360    /// Execute a single agent invocation with the given configuration.
1361    ///
1362    /// # Errors
1363    ///
1364    /// Returns [`AgentError`] if the underlying backend process fails,
1365    /// times out, or produces output that does not match the requested schema.
1366    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
1367
1368    /// Execute an agent invocation with real-time log streaming.
1369    ///
1370    /// Providers that support live output streaming should override this
1371    /// method to pipe each output line to the [`LogSink`] as it arrives.
1372    /// The default implementation ignores the sink and delegates to
1373    /// [`invoke`](AgentProvider::invoke).
1374    ///
1375    /// # Errors
1376    ///
1377    /// Returns [`AgentError`] if the underlying backend process fails,
1378    /// times out, or produces output that does not match the requested schema.
1379    fn invoke_with_logs<'a>(
1380        &'a self,
1381        config: &'a AgentConfig,
1382        log_sink: Arc<dyn LogSink>,
1383    ) -> InvokeFuture<'a> {
1384        let _ = log_sink;
1385        self.invoke(config)
1386    }
1387
1388    /// Stop whatever a previous execution of the run `run_id` left running
1389    /// outside the worker process, before the run executes again.
1390    ///
1391    /// The engine calls it before every execution of a run, the first one
1392    /// included. The default does nothing; the Kubernetes ephemeral provider
1393    /// deletes the run's pods and waits until they are gone.
1394    ///
1395    /// # Errors
1396    ///
1397    /// Returns [`AgentError`] when the release fails; the engine then fails
1398    /// the execution with a replayable error.
1399    ///
1400    /// # Examples
1401    ///
1402    /// ```
1403    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
1404    /// use ironflow_core::provider::AgentProvider;
1405    ///
1406    /// # async fn example() -> Result<(), ironflow_core::error::AgentError> {
1407    /// ClaudeCodeProvider::new().release_run("run-1").await?;
1408    /// # Ok(())
1409    /// # }
1410    /// ```
1411    fn release_run<'a>(&'a self, run_id: &'a str) -> ReleaseFuture<'a> {
1412        let _ = run_id;
1413        Box::pin(async { Ok(()) })
1414    }
1415}
1416
1417// The decision abstraction lives beside `AgentProvider`: re-exported here so
1418// `ironflow_core::provider::DecisionProvider` resolves alongside it, while the
1419// types themselves live in the `decision` module.
1420pub use crate::decision::{
1421    ChoiceAnswer, DecideFuture, DecisionAnswer, DecisionOutput, DecisionProvider, DecisionQuestion,
1422    DecisionRequest, DecisionUsage, NoulAnswer, NoulCriteria, ScoreAnswer,
1423};
1424
1425#[cfg(test)]
1426mod tests {
1427    use super::*;
1428    use serde_json::json;
1429
1430    fn full_config() -> AgentConfig {
1431        AgentConfig {
1432            system_prompt: Some("you are helpful".to_string()),
1433            prompt: "do stuff".to_string(),
1434            model: Model::OPUS.to_string(),
1435            allowed_tools: vec!["Read".to_string(), "Write".to_string()],
1436            disallowed_tools: vec!["Bash".to_string()],
1437            tool_profile: None,
1438            max_turns: Some(10),
1439            max_parallel_tools: 2,
1440            max_budget_usd: Some(2.5),
1441            working_dir: Some("/tmp".to_string()),
1442            mcp_config: Some("{}".to_string()),
1443            strict_mcp_config: true,
1444            bare: true,
1445            permission_mode: PermissionMode::Auto,
1446            json_schema: Some(r#"{"type":"object"}"#.to_string()),
1447
1448            resume_session_id: None,
1449            verbose: false,
1450            pod_labels: BTreeMap::new(),
1451            pod: PodSettings::default(),
1452            inputs: Vec::new(),
1453            allow_failure: false,
1454            retry: None,
1455            trace_context: None,
1456            _marker: PhantomData,
1457        }
1458    }
1459
1460    #[test]
1461    fn agent_config_serialize_deserialize_roundtrip() {
1462        let config = full_config();
1463        let json = serde_json::to_string(&config).unwrap();
1464        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1465
1466        assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
1467        assert_eq!(back.prompt, "do stuff");
1468        assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
1469        assert_eq!(back.max_turns, Some(10));
1470        assert_eq!(back.max_parallel_tools, 2);
1471        assert_eq!(back.max_budget_usd, Some(2.5));
1472        assert_eq!(back.working_dir, Some("/tmp".to_string()));
1473        assert_eq!(back.mcp_config, Some("{}".to_string()));
1474        assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
1475    }
1476
1477    #[test]
1478    fn agent_config_with_all_optional_fields_none() {
1479        let config: AgentConfig = AgentConfig {
1480            system_prompt: None,
1481            prompt: "hello".to_string(),
1482            model: Model::HAIKU.to_string(),
1483            allowed_tools: vec![],
1484            disallowed_tools: vec![],
1485            tool_profile: None,
1486            max_turns: None,
1487            max_parallel_tools: 4,
1488            max_budget_usd: None,
1489            working_dir: None,
1490            mcp_config: None,
1491            strict_mcp_config: false,
1492            bare: false,
1493            permission_mode: PermissionMode::Default,
1494            json_schema: None,
1495
1496            resume_session_id: None,
1497            verbose: false,
1498            pod_labels: BTreeMap::new(),
1499            pod: PodSettings::default(),
1500            inputs: Vec::new(),
1501            allow_failure: false,
1502            retry: None,
1503            trace_context: None,
1504            _marker: PhantomData,
1505        };
1506        let json = serde_json::to_string(&config).unwrap();
1507        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1508
1509        assert_eq!(back.system_prompt, None);
1510        assert_eq!(back.prompt, "hello");
1511        assert!(back.allowed_tools.is_empty());
1512        assert_eq!(back.max_turns, None);
1513        assert_eq!(back.max_budget_usd, None);
1514        assert_eq!(back.working_dir, None);
1515        assert_eq!(back.mcp_config, None);
1516        assert_eq!(back.json_schema, None);
1517    }
1518
1519    #[test]
1520    fn agent_output_serialize_deserialize_roundtrip() {
1521        let output = AgentOutput {
1522            value: json!({"key": "value"}),
1523            session_id: Some("sess-abc".to_string()),
1524            cost_usd: Some(0.01),
1525            input_tokens: Some(500),
1526            cache_read_input_tokens: Some(4000),
1527            cache_creation_input_tokens: Some(120),
1528            output_tokens: Some(200),
1529            model: Some("claude-sonnet".to_string()),
1530            duration_ms: 3000,
1531            debug_messages: None,
1532        };
1533        let json = serde_json::to_string(&output).unwrap();
1534        let back: AgentOutput = serde_json::from_str(&json).unwrap();
1535
1536        assert_eq!(back.value, json!({"key": "value"}));
1537        assert_eq!(back.session_id, Some("sess-abc".to_string()));
1538        assert_eq!(back.cost_usd, Some(0.01));
1539        assert_eq!(back.input_tokens, Some(500));
1540        assert_eq!(back.cache_read_input_tokens, Some(4000));
1541        assert_eq!(back.cache_creation_input_tokens, Some(120));
1542        assert_eq!(back.output_tokens, Some(200));
1543        assert_eq!(back.model, Some("claude-sonnet".to_string()));
1544        assert_eq!(back.duration_ms, 3000);
1545    }
1546
1547    #[test]
1548    fn agent_output_deserializes_without_cache_fields() {
1549        let raw = json!({
1550            "value": "ok",
1551            "session_id": null,
1552            "cost_usd": 0.01,
1553            "input_tokens": 10,
1554            "output_tokens": 5,
1555            "model": null,
1556            "duration_ms": 100,
1557            "debug_messages": null
1558        });
1559        let back: AgentOutput = serde_json::from_value(raw).unwrap();
1560        assert_eq!(back.input_tokens, Some(10));
1561        assert_eq!(back.cache_read_input_tokens, None);
1562        assert_eq!(back.cache_creation_input_tokens, None);
1563    }
1564
1565    #[test]
1566    fn agent_config_new_has_correct_defaults() {
1567        let config = AgentConfig::new("test prompt");
1568        assert_eq!(config.prompt, "test prompt");
1569        assert_eq!(config.system_prompt, None);
1570        assert_eq!(config.model, Model::SONNET);
1571        assert!(config.allowed_tools.is_empty());
1572        assert_eq!(config.max_turns, None);
1573        assert_eq!(config.max_budget_usd, None);
1574        assert_eq!(config.working_dir, None);
1575        assert_eq!(config.mcp_config, None);
1576        assert!(matches!(config.permission_mode, PermissionMode::Default));
1577        assert_eq!(config.json_schema, None);
1578        assert_eq!(config.resume_session_id, None);
1579        assert!(!config.verbose);
1580    }
1581
1582    #[test]
1583    fn agent_output_new_has_correct_defaults() {
1584        let output = AgentOutput::new(json!("test"));
1585        assert_eq!(output.value, json!("test"));
1586        assert_eq!(output.session_id, None);
1587        assert_eq!(output.cost_usd, None);
1588        assert_eq!(output.input_tokens, None);
1589        assert_eq!(output.cache_read_input_tokens, None);
1590        assert_eq!(output.cache_creation_input_tokens, None);
1591        assert_eq!(output.output_tokens, None);
1592        assert_eq!(output.model, None);
1593        assert_eq!(output.duration_ms, 0);
1594        assert!(output.debug_messages.is_none());
1595    }
1596
1597    #[test]
1598    fn agent_config_resume_session_roundtrip() {
1599        let mut config = AgentConfig::new("test");
1600        config.resume_session_id = Some("sess-xyz".to_string());
1601        let json = serde_json::to_string(&config).unwrap();
1602        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1603        assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
1604    }
1605
1606    #[test]
1607    fn agent_output_debug_does_not_panic() {
1608        let output = AgentOutput {
1609            value: json!(null),
1610            session_id: None,
1611            cost_usd: None,
1612            input_tokens: None,
1613            cache_read_input_tokens: None,
1614            cache_creation_input_tokens: None,
1615            output_tokens: None,
1616            model: None,
1617            duration_ms: 0,
1618            debug_messages: None,
1619        };
1620        let debug_str = format!("{:?}", output);
1621        assert!(!debug_str.is_empty());
1622    }
1623
1624    #[test]
1625    fn allow_tool_transitions_to_with_tools() {
1626        let config = AgentConfig::new("test").allow_tool(Tool::Read);
1627        assert_eq!(config.allowed_tools, vec!["Read"]);
1628
1629        // Can add more tools, known or custom.
1630        let config = config
1631            .allow_tool(Tool::Write)
1632            .allow_tool(Tool::Custom("mcp__github__search".to_string()));
1633        assert_eq!(
1634            config.allowed_tools,
1635            vec!["Read", "Write", "mcp__github__search"]
1636        );
1637    }
1638
1639    #[test]
1640    fn output_carries_the_output_type_in_the_typestate() {
1641        #[derive(serde::Deserialize, JsonSchema)]
1642        #[allow(dead_code)]
1643        struct Verdict {
1644            approved: bool,
1645        }
1646
1647        let config: AgentConfig<NoTools, WithSchema<Verdict>> =
1648            AgentConfig::new("review").output::<Verdict>();
1649        assert!(
1650            config
1651                .json_schema
1652                .as_deref()
1653                .is_some_and(|s| s.contains("approved"))
1654        );
1655    }
1656
1657    #[test]
1658    fn output_schema_raw_transitions_to_with_schema() {
1659        let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
1660        assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
1661    }
1662
1663    #[test]
1664    fn with_tools_converts_to_base_type() {
1665        let typed = AgentConfig::new("test").allow_tool(Tool::Read);
1666        let base: AgentConfig = typed.into();
1667        assert_eq!(base.allowed_tools, vec!["Read"]);
1668    }
1669
1670    #[test]
1671    fn with_schema_converts_to_base_type() {
1672        let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
1673        let base: AgentConfig = typed.into();
1674        assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
1675    }
1676
1677    #[test]
1678    fn serde_roundtrip_ignores_marker() {
1679        let config = AgentConfig::new("test").allow_tool(Tool::Read);
1680        let json = serde_json::to_string(&config).unwrap();
1681        assert!(!json.contains("marker"));
1682
1683        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1684        assert_eq!(back.allowed_tools, vec!["Read"]);
1685    }
1686
1687    #[test]
1688    fn bare_defaults_to_false() {
1689        let config = AgentConfig::new("hello");
1690        assert!(!config.bare, "bare must default to false");
1691    }
1692
1693    #[test]
1694    fn bare_builder_sets_flag() {
1695        let config = AgentConfig::new("hello").bare(true);
1696        assert!(config.bare, "bare(true) must enable the flag");
1697
1698        let config = config.bare(false);
1699        assert!(!config.bare, "bare(false) must disable the flag");
1700    }
1701
1702    #[test]
1703    fn bare_serde_default_when_missing() {
1704        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1705        let config: AgentConfig = serde_json::from_str(raw).unwrap();
1706        assert!(
1707            !config.bare,
1708            "bare must default to false when absent from serialized payload"
1709        );
1710    }
1711
1712    #[test]
1713    fn bare_serde_roundtrip() {
1714        let mut config = AgentConfig::new("hello");
1715        config.bare = true;
1716        let json = serde_json::to_string(&config).unwrap();
1717        assert!(
1718            json.contains("\"bare\":true"),
1719            "serialized form must contain bare:true, got: {json}"
1720        );
1721
1722        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1723        assert!(back.bare, "bare must survive a serde roundtrip");
1724    }
1725
1726    #[test]
1727    fn disallowed_tools_defaults_to_empty() {
1728        let config = AgentConfig::new("hello");
1729        assert!(
1730            config.disallowed_tools.is_empty(),
1731            "disallowed_tools must default to empty"
1732        );
1733    }
1734
1735    #[test]
1736    fn disallowed_tools_builder_replaces_list() {
1737        let config = AgentConfig::new("hello").disallowed_tools([Tool::Write, Tool::Edit]);
1738        assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
1739
1740        // Subsequent call fully replaces the list.
1741        let config = config.disallowed_tools([Tool::Bash]);
1742        assert_eq!(config.disallowed_tools, vec!["Bash"]);
1743
1744        // Empty input clears the list.
1745        let config = config.disallowed_tools([]);
1746        assert!(config.disallowed_tools.is_empty());
1747    }
1748
1749    #[test]
1750    fn disallowed_tools_compatible_with_output() {
1751        #[derive(serde::Deserialize, JsonSchema)]
1752        #[allow(dead_code)]
1753        struct Out {
1754            ok: bool,
1755        }
1756
1757        // Typestate compile check: .disallowed_tools(...) must be callable
1758        // before AND after .output::<T>() because it lives on
1759        // impl<Tools, Schema>, not impl<Tools, NoSchema>.
1760        let before: AgentConfig<NoTools, WithSchema<Out>> = AgentConfig::new("classify")
1761            .disallowed_tools([Tool::Write, Tool::Edit])
1762            .output::<Out>();
1763        assert_eq!(before.disallowed_tools, vec!["Write", "Edit"]);
1764        assert!(before.json_schema.is_some());
1765
1766        let after: AgentConfig<NoTools, WithSchema<Out>> = AgentConfig::new("classify")
1767            .output::<Out>()
1768            .disallowed_tools([Tool::Write]);
1769        assert_eq!(after.disallowed_tools, vec!["Write"]);
1770        assert!(after.json_schema.is_some());
1771    }
1772
1773    #[test]
1774    fn disallowed_tools_serde_default_when_missing() {
1775        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1776        let config: AgentConfig = serde_json::from_str(raw).unwrap();
1777        assert!(
1778            config.disallowed_tools.is_empty(),
1779            "disallowed_tools must default to empty when absent from serialized payload"
1780        );
1781    }
1782
1783    #[test]
1784    fn disallowed_tools_serde_roundtrip() {
1785        let config = AgentConfig::new("hello").disallowed_tools([Tool::Write, Tool::Edit]);
1786        let json = serde_json::to_string(&config).unwrap();
1787        assert!(
1788            json.contains("\"disallowed_tools\":[\"Write\",\"Edit\"]"),
1789            "serialized form must contain the disallowed_tools array, got: {json}"
1790        );
1791
1792        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1793        assert_eq!(back.disallowed_tools, vec!["Write", "Edit"]);
1794    }
1795
1796    #[test]
1797    fn pod_labels_defaults_to_empty() {
1798        let config = AgentConfig::new("test");
1799        assert!(config.pod_labels.is_empty());
1800    }
1801
1802    #[test]
1803    fn pod_label_builder_adds_entry() {
1804        let config = AgentConfig::new("test").pod_label("k", "v");
1805        assert_eq!(config.pod_labels.len(), 1);
1806        assert_eq!(config.pod_labels["k"], "v");
1807    }
1808
1809    #[test]
1810    fn pod_labels_builder_replaces_map() {
1811        let config = AgentConfig::new("test").pod_label("old", "value");
1812        let mut new_map = BTreeMap::new();
1813        new_map.insert("new".to_string(), "value".to_string());
1814        let config = config.pod_labels(new_map);
1815        assert_eq!(config.pod_labels.len(), 1);
1816        assert_eq!(config.pod_labels["new"], "value");
1817        assert!(!config.pod_labels.contains_key("old"));
1818    }
1819
1820    #[test]
1821    fn pod_labels_serde_default_when_missing() {
1822        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1823        let config: AgentConfig = serde_json::from_str(raw).unwrap();
1824        assert!(
1825            config.pod_labels.is_empty(),
1826            "pod_labels must default to empty when absent from serialized payload"
1827        );
1828    }
1829
1830    #[test]
1831    fn pod_labels_serde_skip_when_empty() {
1832        let config = AgentConfig::new("hello");
1833        let json = serde_json::to_string(&config).unwrap();
1834        assert!(
1835            !json.contains("pod_labels"),
1836            "empty pod_labels must be skipped during serialization, got: {json}"
1837        );
1838    }
1839
1840    #[test]
1841    fn pod_labels_serde_roundtrip() {
1842        let config = AgentConfig::new("hello")
1843            .pod_label("ironflow.io/network-profile", "grafana-only")
1844            .pod_label("team", "observability");
1845        let json = serde_json::to_string(&config).unwrap();
1846        assert!(
1847            json.contains("pod_labels"),
1848            "non-empty pod_labels must be present in serialized form, got: {json}"
1849        );
1850
1851        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1852        assert_eq!(back.pod_labels.len(), 2);
1853        assert_eq!(
1854            back.pod_labels["ironflow.io/network-profile"],
1855            "grafana-only"
1856        );
1857        assert_eq!(back.pod_labels["team"], "observability");
1858    }
1859
1860    // ── Pod settings (K8s) ────────────────────────────────────────
1861
1862    #[test]
1863    fn k8s_env_from_secret_replaces_same_var() {
1864        let config = AgentConfig::new("x")
1865            .env_from_secret("TOKEN", "old-secret", "a")
1866            .env_from_secret("OTHER", "other", "b")
1867            .env_from_secret("TOKEN", "new-secret", "c");
1868        assert_eq!(config.pod.secret_env.len(), 2);
1869        assert_eq!(config.pod.secret_env[0].name, "TOKEN");
1870        assert_eq!(config.pod.secret_env[0].secret, "new-secret");
1871        assert_eq!(config.pod.secret_env[0].key, "c");
1872        assert_eq!(config.pod.secret_env[1].name, "OTHER");
1873    }
1874
1875    #[test]
1876    fn k8s_pod_settings_builders() {
1877        let config = AgentConfig::new("x")
1878            .service_account("reader")
1879            .read_only_pvc("repos", "/data/repos")
1880            .read_only_host_path("/srv", "/data/srv")
1881            .read_only_config_map("cm", "/data/cm")
1882            .managed_settings("locked")
1883            .egress_profile("gitlab");
1884        assert_eq!(config.pod.service_account.as_deref(), Some("reader"));
1885        assert_eq!(config.pod.read_only_volumes.len(), 3);
1886        assert_eq!(
1887            config.pod.read_only_volumes[0].source,
1888            PodVolumeSource::PersistentVolumeClaim {
1889                claim_name: "repos".to_string(),
1890            }
1891        );
1892        assert_eq!(config.pod.read_only_volumes[0].mount_path, "/data/repos");
1893        assert_eq!(
1894            config.pod.read_only_volumes[1].source,
1895            PodVolumeSource::HostPath {
1896                path: "/srv".to_string(),
1897            }
1898        );
1899        assert_eq!(
1900            config.pod.read_only_volumes[2].source,
1901            PodVolumeSource::ConfigMap {
1902                name: "cm".to_string(),
1903            }
1904        );
1905        assert_eq!(config.pod.managed_settings.as_deref(), Some("locked"));
1906        assert_eq!(config.pod_labels[LABEL_EGRESS_PROFILE], "gitlab");
1907    }
1908
1909    #[test]
1910    fn k8s_run_scope_sets_sanitized_labels() {
1911        let config = AgentConfig::new("x").run_scope("run-1", "fix bug/42");
1912        assert_eq!(config.pod_labels[LABEL_RUN_ID], "run-1");
1913        assert_eq!(
1914            config.pod_labels[LABEL_STEP],
1915            sanitize_label_value("fix bug/42")
1916        );
1917        assert!(config.pod_labels[LABEL_STEP].starts_with("fix-bug-42-"));
1918    }
1919
1920    #[test]
1921    fn k8s_pod_settings_serde_skip_when_empty() {
1922        let json = serde_json::to_value(AgentConfig::new("hello")).unwrap();
1923        assert!(json.get("pod").is_none(), "empty pod must be skipped");
1924    }
1925
1926    #[test]
1927    fn k8s_pod_settings_serde_roundtrip() {
1928        let config = AgentConfig::new("hello")
1929            .env_from_secret("TOKEN", "s", "k")
1930            .service_account("sa")
1931            .read_only_pvc("repos", "/data/repos")
1932            .managed_settings("locked");
1933        let json = serde_json::to_string(&config).unwrap();
1934        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1935        assert_eq!(back.pod, config.pod);
1936    }
1937
1938    #[test]
1939    fn k8s_pod_settings_serde_default_when_missing() {
1940        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1941        let config: AgentConfig = serde_json::from_str(raw).unwrap();
1942        assert!(config.pod.is_empty());
1943    }
1944
1945    // ── LogSink tests ─────────────────────────────────────────────
1946
1947    use crate::test_support::VecSink;
1948
1949    #[test]
1950    fn log_sink_collects_lines() {
1951        let sink = VecSink::new();
1952        sink.log("stdout", "line 1");
1953        sink.log("stderr", "err!");
1954        sink.log("system", "done");
1955
1956        let lines = sink.0.lock().unwrap();
1957        assert_eq!(lines.len(), 3);
1958        assert_eq!(lines[0], ("stdout".to_string(), "line 1".to_string()));
1959        assert_eq!(lines[1], ("stderr".to_string(), "err!".to_string()));
1960        assert_eq!(lines[2], ("system".to_string(), "done".to_string()));
1961    }
1962
1963    #[test]
1964    fn log_sink_arc_is_clone_and_send() {
1965        let sink: Arc<dyn LogSink> = VecSink::new();
1966        let cloned = sink.clone();
1967        sink.log("stdout", "from original");
1968        cloned.log("stdout", "from clone");
1969    }
1970
1971    // ── invoke_with_logs default impl ─────────────────────────────
1972
1973    struct FixedProvider {
1974        output: AgentOutput,
1975    }
1976
1977    impl AgentProvider for FixedProvider {
1978        fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1979            Box::pin(async {
1980                Ok(AgentOutput {
1981                    value: self.output.value.clone(),
1982                    session_id: self.output.session_id.clone(),
1983                    cost_usd: self.output.cost_usd,
1984                    input_tokens: self.output.input_tokens,
1985                    cache_read_input_tokens: self.output.cache_read_input_tokens,
1986                    cache_creation_input_tokens: self.output.cache_creation_input_tokens,
1987                    output_tokens: self.output.output_tokens,
1988                    model: self.output.model.clone(),
1989                    duration_ms: self.output.duration_ms,
1990                    debug_messages: None,
1991                })
1992            })
1993        }
1994    }
1995
1996    #[tokio::test]
1997    async fn release_run_default_does_nothing() {
1998        let provider = FixedProvider {
1999            output: AgentOutput::new(json!("ok")),
2000        };
2001        assert!(provider.release_run("run-1").await.is_ok());
2002        assert!(provider.release_run("").await.is_ok());
2003    }
2004
2005    #[tokio::test]
2006    async fn invoke_with_logs_default_delegates_to_invoke() {
2007        let provider = FixedProvider {
2008            output: AgentOutput::new(json!("ok")),
2009        };
2010        let config = AgentConfig::new("test");
2011        let sink: Arc<dyn LogSink> = VecSink::new();
2012
2013        let result = provider.invoke_with_logs(&config, sink.clone()).await;
2014        assert!(result.is_ok());
2015        assert_eq!(result.unwrap().value, json!("ok"));
2016    }
2017
2018    #[tokio::test]
2019    async fn invoke_with_logs_default_ignores_sink() {
2020        let provider = FixedProvider {
2021            output: AgentOutput::new(json!("ok")),
2022        };
2023        let config = AgentConfig::new("test");
2024        let sink = VecSink::new();
2025
2026        let _ = provider
2027            .invoke_with_logs(&config, sink.clone() as Arc<dyn LogSink>)
2028            .await;
2029
2030        let lines = sink.0.lock().unwrap();
2031        assert!(lines.is_empty(), "default impl should not emit any logs");
2032    }
2033
2034    #[test]
2035    fn max_parallel_tools_defaults_to_four() {
2036        assert_eq!(AgentConfig::new("hi").max_parallel_tools, 4);
2037    }
2038
2039    #[test]
2040    fn max_parallel_tools_builder_sets_value() {
2041        let config = AgentConfig::new("hi").max_parallel_tools(2);
2042        assert_eq!(config.max_parallel_tools, 2);
2043    }
2044
2045    #[test]
2046    #[should_panic(expected = "max_parallel_tools must be greater than 0")]
2047    fn max_parallel_tools_zero_panics() {
2048        let _ = AgentConfig::new("hi").max_parallel_tools(0);
2049    }
2050
2051    #[test]
2052    fn max_parallel_tools_missing_from_json_defaults_to_four() {
2053        let json = serde_json::to_value(AgentConfig::new("hi")).unwrap();
2054        let mut obj = json.as_object().unwrap().clone();
2055        obj.remove("max_parallel_tools");
2056        let back: AgentConfig = serde_json::from_value(Value::Object(obj)).unwrap();
2057        assert_eq!(back.max_parallel_tools, 4);
2058    }
2059}