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;
23use std::time::Duration;
24
25use chrono::{DateTime, Utc};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::account::{AccountSession, RateLimitRecorder};
31use crate::auth_proxy::ProxiedSecret;
32use crate::error::AgentError;
33use crate::operations::agent::{Effort, Model, PermissionMode};
34use crate::retry::RetryPolicy;
35use crate::trace_context::WorkflowTraceContext;
36
37mod pod;
38mod system_prompt;
39mod tool;
40mod tool_profile;
41
42#[cfg(feature = "transport-k8s")]
43pub(crate) use pod::validate_environment_id;
44pub use pod::{
45    COMPONENT_ENVIRONMENT, EnvironmentVolume, LABEL_COMPONENT, LABEL_EGRESS_PROFILE,
46    LABEL_EXPIRES_AT, LABEL_MANAGED_BY, LABEL_ROOT_RUN_ID, LABEL_RUN_ID, LABEL_STEP,
47    MANAGED_BY_IRONFLOW, PodSettings, PodVolumeSource, PvcVolume, ReadOnlyVolume, SecretEnvVar,
48    StorageUnit, VolumeSize, assert_pod_label_allowed, is_reserved_pod_label, sanitize_label_value,
49    validate_pvc_sub_path,
50};
51pub(crate) use pod::{assert_environment_id_valid, upsert_proxied_secret, upsert_secret_env};
52pub use tool::Tool;
53pub use tool_profile::ToolProfile;
54
55/// Boxed future returned by [`AgentProvider::invoke`].
56pub type InvokeFuture<'a> =
57    Pin<Box<dyn Future<Output = Result<AgentOutput, AgentError>> + Send + 'a>>;
58
59/// Boxed future returned by [`AgentProvider::release_run`].
60pub type ReleaseFuture<'a> = Pin<Box<dyn Future<Output = Result<(), AgentError>> + Send + 'a>>;
61
62// ── Typestate markers ──────────────────────────────────────────────
63
64/// Marker: no tools have been added via the builder.
65#[derive(Debug, Clone, Copy)]
66pub struct NoTools;
67
68/// Marker: at least one tool has been added via [`AgentConfig::allow_tool`],
69/// or a tool profile selected via [`AgentConfig::tool_profile`].
70#[derive(Debug, Clone, Copy)]
71pub struct WithTools;
72
73/// Marker: no JSON schema has been set via the builder.
74#[derive(Debug, Clone, Copy)]
75pub struct NoSchema;
76
77/// Marker: a JSON schema derived from `T` has been set via
78/// [`AgentConfig::output`]. The step answers with a `T`.
79pub struct WithSchema<T>(PhantomData<fn() -> T>);
80
81// Written by hand: a derive would require `T: Debug + Clone + Copy` for a
82// marker that never holds a `T`.
83impl<T> fmt::Debug for WithSchema<T> {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.write_str("WithSchema")
86    }
87}
88
89impl<T> Clone for WithSchema<T> {
90    fn clone(&self) -> Self {
91        *self
92    }
93}
94
95impl<T> Copy for WithSchema<T> {}
96
97/// Marker: a pre-serialized JSON schema has been set via
98/// [`AgentConfig::output_schema_raw`]. The answer is not typed.
99#[derive(Debug, Clone, Copy)]
100pub struct RawSchema;
101
102// ── AgentInput ─────────────────────────────────────────────────────
103
104/// Declarative external input fetched into the agent's filesystem before invocation.
105///
106/// Each input is a URL that the provider must download and materialize at
107/// `mount_path` so the agent can read it via the `Read` tool.
108///
109/// Provider behavior:
110///
111/// * [`ClaudeCodeProvider`](crate::providers::claude::ClaudeCodeProvider) (local) -
112///   downloads via reqwest into a per-invocation temp directory and rewrites
113///   `mount_path` to the resolved local path.
114/// * `K8sEphemeralProvider` - injects a `curlimages/curl` initContainer that
115///   downloads each URL into a shared `emptyDir`, mounted on the main container
116///   at the parent directory of `mount_path`.
117///
118/// The `mount_path` must be an absolute path. Intermediate directories are
119/// created automatically.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct AgentInput {
122    /// Source URL to download (HTTP/HTTPS, including signed S3/R2 URLs).
123    pub url: String,
124
125    /// Absolute filesystem path where the file must be available inside the
126    /// agent's filesystem.
127    pub mount_path: String,
128}
129
130impl AgentInput {
131    /// Create a new input descriptor.
132    pub fn new(url: &str, mount_path: &str) -> Self {
133        Self {
134            url: url.to_string(),
135            mount_path: mount_path.to_string(),
136        }
137    }
138}
139
140// ── AgentConfig ────────────────────────────────────────────────────
141
142/// Serializable configuration passed to an [`AgentProvider`] for a single invocation.
143///
144/// Built by [`Agent::run`](crate::operations::agent::Agent::run) from the builder state.
145/// Provider implementations translate these fields into whatever format the underlying
146/// backend expects.
147///
148/// # Typestate: tools and structured output
149///
150/// `Tools` records whether tools were added ([`allow_tool`](Self::allow_tool),
151/// [`tool_profile`](Self::tool_profile)), `Schema` whether a schema was set
152/// ([`output`](Self::output), [`output_schema_raw`](Self::output_schema_raw)).
153/// Both can be set on one step, in any order: the agent uses its tools, then
154/// answers with the schema. `WithSchema<T>` types the answer of a workflow step.
155///
156/// ```
157/// use ironflow_core::provider::{AgentConfig, Tool, ToolProfile};
158///
159/// let _ = AgentConfig::new("x").output_schema_raw("{}").allow_tool(Tool::Read);
160/// let _ = AgentConfig::new("x").allow_tool(Tool::Read).output_schema_raw("{}");
161/// let _ = AgentConfig::new("x").tool_profile(ToolProfile::new("bug")).output_schema_raw("{}");
162/// ```
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(bound(serialize = "", deserialize = ""))]
165#[non_exhaustive]
166pub struct AgentConfig<Tools = NoTools, Schema = NoSchema> {
167    /// Optional system prompt that sets the agent's persona or constraints.
168    pub system_prompt: Option<String>,
169
170    /// Optional text appended to the system prompt instead of replacing it.
171    ///
172    /// On the Claude CLI this is `--append-system-prompt`: Claude Code keeps
173    /// its own system prompt (skills, slash commands, tools) and adds this
174    /// text after it. HTTP providers append it to
175    /// [`system_prompt`](Self::system_prompt), separated by a blank line.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub append_system_prompt: Option<String>,
178
179    /// The user prompt - the main instruction to the agent.
180    pub prompt: String,
181
182    /// Which model to use for this invocation.
183    ///
184    /// Accepts any string. Use [`Model`] constants for well-known Claude models
185    /// (e.g. `Model::SONNET`), or pass a custom identifier for other providers.
186    #[serde(default = "default_model")]
187    pub model: String,
188
189    /// Allowlist of tool names the agent may invoke (empty = provider default).
190    #[serde(default)]
191    pub allowed_tools: Vec<String>,
192
193    /// Denylist of tool names the agent MUST NOT invoke.
194    ///
195    /// Maps to `--disallowedTools` on the Claude CLI. Unlike
196    /// [`allowed_tools`](Self::allowed_tools), this does **not** activate any
197    /// tools; it only filters out tools that would otherwise be loaded by
198    /// default.
199    #[serde(default)]
200    pub disallowed_tools: Vec<String>,
201
202    /// Named tool profile the provider exposes to this step.
203    ///
204    /// Set it with [`AgentConfig::tool_profile`]. `None` means the provider's
205    /// default tools only (none unless it has some).
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub tool_profile: Option<ToolProfile>,
208
209    /// Maximum number of agentic turns before the provider should stop.
210    pub max_turns: Option<u32>,
211
212    /// Maximum number of tool calls executed concurrently within a single
213    /// turn's group of consecutive read-only calls (default 4). `1` restores
214    /// fully sequential tool execution.
215    #[serde(default = "default_max_parallel_tools")]
216    pub max_parallel_tools: usize,
217
218    /// Maximum spend in USD for this single invocation.
219    pub max_budget_usd: Option<f64>,
220
221    /// Working directory for the agent process.
222    pub working_dir: Option<String>,
223
224    /// Path to an MCP server configuration file.
225    pub mcp_config: Option<String>,
226
227    /// When `true`, pass `--strict-mcp-config` to the Claude CLI so it only
228    /// loads MCP servers from [`mcp_config`](Self::mcp_config) and ignores
229    /// any global/user MCP configuration (e.g. `~/.claude.json`).
230    ///
231    /// Useful to keep global MCP servers from leaking tools into a step that
232    /// should only see the tools it allows.
233    ///
234    /// Combine with `mcp_config` set to a file containing
235    /// `{"mcpServers":{}}` to disable every MCP server for the invocation.
236    #[serde(default)]
237    pub strict_mcp_config: bool,
238
239    /// When `true`, pass `--bare` to Claude CLI. Bare mode disables:
240    /// - auto-memory (automatic creation of `~/.claude/.../memory/*.md` files)
241    /// - `CLAUDE.md` auto-discovery (no global/project `CLAUDE.md` loaded)
242    /// - hooks, LSP, plugin sync, attribution, background prefetches
243    ///
244    /// Recommended for orchestrator agents that should not have any implicit
245    /// side effects on the user's filesystem or inherit user-level context.
246    ///
247    /// # Authentication requirement
248    ///
249    /// `--bare` is **only compatible with an Anthropic API key**
250    /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
251    /// OAuth authentication (`claude /login` / keychain-stored credentials),
252    /// because bare mode disables keychain reads.
253    #[serde(default)]
254    pub bare: bool,
255
256    /// Permission mode controlling how the agent handles tool-use approvals.
257    #[serde(default)]
258    pub permission_mode: PermissionMode,
259
260    /// Reasoning effort requested from the model.
261    ///
262    /// `None` emits no flag, so the model default applies. Honored by the
263    /// Claude providers only (`--effort` for the CLI, `output_config.effort`
264    /// for the Anthropic HTTP adapter); other HTTP adapters ignore it.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub effort: Option<Effort>,
267
268    /// Optional JSON Schema string. When set, the provider should request
269    /// structured (typed) output from the model.
270    #[serde(alias = "output_schema")]
271    pub json_schema: Option<String>,
272
273    /// Optional session ID to resume a previous conversation.
274    ///
275    /// When set, the provider should continue the conversation from the
276    /// specified session rather than starting a new one.
277    pub resume_session_id: Option<String>,
278
279    /// Session id to create (`--session-id <uuid>`).
280    ///
281    /// Fixes the id of the session the provider starts, so it can be resumed
282    /// later through [`AgentConfig::resume_session_id`]. Ignored when
283    /// `resume_session_id` is set. Providers without sessions ignore it.
284    #[serde(default)]
285    pub session_id: Option<String>,
286
287    /// Prompt the engine sends instead of `prompt` when it resumes an
288    /// interrupted step.
289    ///
290    /// Read by the engine/executor only; providers ignore it.
291    #[serde(default)]
292    pub resume_prompt: Option<String>,
293
294    /// Optional persistent environment ID to resume (K8s ephemeral provider only).
295    ///
296    /// The ID is the name of a PersistentVolumeClaim previously returned in
297    /// [`AgentOutput::environment_id`]. When set, the provider mounts that
298    /// volume again instead of creating a fresh one, so the files written by
299    /// a previous step are still there. Providers without persistent
300    /// environments ignore this field.
301    #[serde(default)]
302    pub resume_environment_id: Option<String>,
303
304    /// Enable verbose/debug mode to capture the full conversation trace.
305    ///
306    /// When `true`, the provider uses streaming output (`stream-json`) to
307    /// record every assistant message and tool call. The resulting
308    /// [`AgentOutput::debug_messages`] field will contain the conversation
309    /// trace for inspection.
310    #[serde(default)]
311    pub verbose: bool,
312
313    /// Custom labels applied to the pod (K8s providers only).
314    ///
315    /// Non-K8s providers ignore this field. Labels are merged with the
316    /// provider-level pod labels and the hardcoded ironflow labels. In case
317    /// of conflict, hardcoded labels always win, then invocation-level labels,
318    /// then provider-level defaults.
319    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
320    pub pod_labels: BTreeMap<String, String>,
321
322    /// Pod-level settings (K8s ephemeral provider only), merged with the provider's.
323    ///
324    /// Non-K8s providers ignore this field. Set it with
325    /// [`AgentConfig::env_from_secret`], [`AgentConfig::service_account`],
326    /// [`AgentConfig::read_only_volume`], [`AgentConfig::managed_settings`] and
327    /// [`AgentConfig::runtime_class`].
328    #[serde(default, skip_serializing_if = "PodSettings::is_empty")]
329    pub pod: PodSettings,
330
331    /// External inputs to materialize on the agent's filesystem before invocation.
332    ///
333    /// See [`AgentInput`] for the semantics. The provider is responsible for
334    /// fetching each URL and placing it at `mount_path` before the agent runs.
335    /// Add inputs with [`AgentConfig::input_file`].
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub inputs: Vec<AgentInput>,
338
339    /// When `true`, a failure of this step does not fail the run.
340    #[serde(default)]
341    pub allow_failure: bool,
342
343    /// Optional step-level retry policy.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub retry: Option<RetryPolicy>,
346
347    /// Optional W3C trace context for distributed tracing propagation.
348    ///
349    /// When set, providers can inject the `traceparent` header into
350    /// outgoing HTTP requests (LLM APIs, MCP servers) to correlate
351    /// workflow spans with downstream service spans.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub trace_context: Option<WorkflowTraceContext>,
354
355    /// Provider Account the invocation runs under, set by the worker.
356    ///
357    /// Providers whose [`AgentProvider::account_kind`] matches inject its
358    /// credential into the agent process and report observed rate-limit
359    /// windows to its recorder. Never serialized: it carries a secret.
360    #[serde(skip)]
361    pub account: Option<AccountSession>,
362
363    /// Secrets the agent reaches through the auth proxy (K8s ephemeral
364    /// provider only), merged with the provider's: a step entry overrides a
365    /// provider entry with the same `env`.
366    ///
367    /// Set it with [`AgentConfig::proxied_secret`]. Never serialized: it
368    /// carries a secret.
369    #[serde(skip)]
370    pub proxied_secrets: Vec<ProxiedSecret>,
371
372    /// Longest the run may sleep waiting for provider capacity when every
373    /// targeted account is rate limited.
374    ///
375    /// `None` uses the worker default (6 hours). `Duration::ZERO` fails the
376    /// step at once with [`AgentError::NoCapacity`]. Set it with
377    /// [`AgentConfig::max_capacity_wait`].
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub max_capacity_wait: Option<Duration>,
380
381    /// Name of the Provider Account the step must run under.
382    ///
383    /// Never falls back to another account: an unknown name fails with
384    /// [`AgentError::AccountNotFound`], a rate-limited one waits. Set it with
385    /// [`AgentConfig::account`].
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub account_name: Option<String>,
388
389    /// Tag restricting the step to the Provider Accounts carrying it.
390    ///
391    /// Set it with [`AgentConfig::account_pool`].
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub account_pool: Option<String>,
394
395    /// Recorder observed rate-limit windows go to when the invocation runs
396    /// without an [`AccountSession`] (the worker environment token).
397    ///
398    /// Set by the worker. Ignored when [`account`](Self::account) is set.
399    #[serde(skip)]
400    pub rate_limits: Option<RateLimitRecorder>,
401
402    /// When the step started waiting for provider capacity, set by the engine.
403    ///
404    /// Bounds the cumulative wait for an account freed by `max_concurrency`.
405    #[serde(skip)]
406    pub capacity_wait_since: Option<DateTime<Utc>>,
407
408    /// Zero-sized typestate marker (not serialized).
409    #[serde(skip)]
410    pub(crate) _marker: PhantomData<(Tools, Schema)>,
411}
412
413fn default_model() -> String {
414    Model::SONNET.to_string()
415}
416
417fn default_max_parallel_tools() -> usize {
418    4
419}
420
421// ── Constructor (base type only) ───────────────────────────────────
422
423impl AgentConfig {
424    /// Create an `AgentConfig` with required fields and defaults for the rest.
425    pub fn new(prompt: &str) -> Self {
426        Self {
427            system_prompt: None,
428            append_system_prompt: None,
429            prompt: prompt.to_string(),
430            model: Model::SONNET.to_string(),
431            allowed_tools: Vec::new(),
432            disallowed_tools: Vec::new(),
433            tool_profile: None,
434            max_turns: None,
435            max_parallel_tools: 4,
436            max_budget_usd: None,
437            working_dir: None,
438            mcp_config: None,
439            strict_mcp_config: false,
440            bare: false,
441            permission_mode: PermissionMode::Default,
442            effort: None,
443            json_schema: None,
444
445            resume_session_id: None,
446            session_id: None,
447            resume_prompt: None,
448            resume_environment_id: None,
449            verbose: false,
450            pod_labels: BTreeMap::new(),
451            pod: PodSettings::default(),
452            inputs: Vec::new(),
453            allow_failure: false,
454            retry: None,
455            trace_context: None,
456            account: None,
457            proxied_secrets: Vec::new(),
458            max_capacity_wait: None,
459            account_name: None,
460            account_pool: None,
461            rate_limits: None,
462            capacity_wait_since: None,
463            _marker: PhantomData,
464        }
465    }
466}
467
468// ── Methods available on ALL typestate variants ────────────────────
469
470impl<Tools, Schema> AgentConfig<Tools, Schema> {
471    /// Set the system prompt.
472    pub fn system_prompt(mut self, prompt: &str) -> Self {
473        self.system_prompt = Some(prompt.to_string());
474        self
475    }
476
477    /// Set the model name.
478    pub fn model(mut self, model: &str) -> Self {
479        self.model = model.to_string();
480        self
481    }
482
483    /// Set the maximum budget in USD.
484    pub fn max_budget_usd(mut self, budget: f64) -> Self {
485        self.max_budget_usd = Some(budget);
486        self
487    }
488
489    /// Set the maximum number of turns.
490    pub fn max_turns(mut self, turns: u32) -> Self {
491        self.max_turns = Some(turns);
492        self
493    }
494
495    /// Set the maximum number of tool calls executed concurrently within a
496    /// turn's group of consecutive read-only calls.
497    ///
498    /// # Panics
499    ///
500    /// Panics if `n` is `0`.
501    ///
502    /// # Examples
503    ///
504    /// ```
505    /// use ironflow_core::provider::AgentConfig;
506    ///
507    /// let config = AgentConfig::new("summarize the repo").max_parallel_tools(2);
508    /// assert_eq!(config.max_parallel_tools, 2);
509    /// ```
510    pub fn max_parallel_tools(mut self, n: usize) -> Self {
511        assert!(n > 0, "max_parallel_tools must be greater than 0");
512        self.max_parallel_tools = n;
513        self
514    }
515
516    /// Set the working directory.
517    pub fn working_dir(mut self, dir: &str) -> Self {
518        self.working_dir = Some(dir.to_string());
519        self
520    }
521
522    /// Set the permission mode.
523    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
524        self.permission_mode = mode;
525        self
526    }
527
528    /// Set the reasoning effort.
529    ///
530    /// Claude providers only; without this call no effort is requested.
531    ///
532    /// # Examples
533    ///
534    /// ```
535    /// use ironflow_core::prelude::*;
536    ///
537    /// let config = AgentConfig::new("review the diff").effort(Effort::High);
538    /// assert_eq!(config.effort, Some(Effort::High));
539    /// ```
540    pub fn effort(mut self, effort: Effort) -> Self {
541        self.effort = Some(effort);
542        self
543    }
544
545    /// Enable verbose/debug mode.
546    pub fn verbose(mut self, enabled: bool) -> Self {
547        self.verbose = enabled;
548        self
549    }
550
551    /// Set the MCP server configuration file path.
552    pub fn mcp_config(mut self, config: &str) -> Self {
553        self.mcp_config = Some(config.to_string());
554        self
555    }
556
557    /// Enable strict MCP config mode.
558    ///
559    /// When `true`, the Claude CLI is invoked with `--strict-mcp-config`,
560    /// which disables loading of any MCP server defined outside the
561    /// [`mcp_config`](Self::mcp_config) file (the global `~/.claude.json`
562    /// and user-level configs are ignored).
563    ///
564    /// This is the recommended way to keep global MCP servers from silently
565    /// injecting tools into a step.
566    ///
567    /// # Examples
568    ///
569    /// ```
570    /// use ironflow_core::provider::AgentConfig;
571    /// use schemars::JsonSchema;
572    ///
573    /// #[derive(serde::Deserialize, JsonSchema)]
574    /// struct Out { ok: bool }
575    ///
576    /// // Isolate the step from any global MCP server.
577    /// let config = AgentConfig::new("classify this")
578    ///     .strict_mcp_config(true)
579    ///     .mcp_config(r#"{"mcpServers":{}}"#)
580    ///     .output::<Out>();
581    /// ```
582    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
583        self.strict_mcp_config = strict;
584        self
585    }
586
587    /// Enable bare mode (minimal Claude Code environment, see `--bare`).
588    ///
589    /// When `true`, the Claude CLI is invoked with `--bare`, which disables:
590    /// - auto-memory (no automatic `~/.claude/.../memory/*.md` file creation)
591    /// - `CLAUDE.md` auto-discovery (neither global nor project-level)
592    /// - hooks, LSP, plugin sync, attribution, background prefetches,
593    ///   keychain reads
594    ///
595    /// Sets `CLAUDE_CODE_SIMPLE=1` in the child process.
596    ///
597    /// Recommended for orchestrator steps that should not have any implicit
598    /// side effects on the user's filesystem or inherit user-level context
599    /// (email, preferences, etc.).
600    ///
601    /// # Authentication requirement
602    ///
603    /// `--bare` is **only compatible with an Anthropic API key**
604    /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
605    /// OAuth authentication (`claude /login` / keychain-stored credentials),
606    /// because bare mode disables keychain reads. Invoking a bare agent on an
607    /// OAuth-only host will fail with an authentication error.
608    ///
609    /// # Examples
610    ///
611    /// ```
612    /// use ironflow_core::provider::AgentConfig;
613    ///
614    /// let config = AgentConfig::new("classify this")
615    ///     .bare(true);
616    /// ```
617    pub fn bare(mut self, enabled: bool) -> Self {
618        self.bare = enabled;
619        self
620    }
621
622    /// Mark this step as allowed to fail without stopping the run.
623    ///
624    /// # Examples
625    ///
626    /// ```
627    /// use ironflow_core::provider::AgentConfig;
628    ///
629    /// let config = AgentConfig::new("lint the code").allow_failure();
630    /// assert!(config.allow_failure);
631    /// ```
632    pub fn allow_failure(mut self) -> Self {
633        self.allow_failure = true;
634        self
635    }
636
637    /// Replace the entire disallowed-tools list.
638    ///
639    /// Maps to `--disallowedTools` on the Claude CLI. Unlike
640    /// [`allow_tool`](AgentConfig::allow_tool), `disallowed_tools` does not
641    /// activate any tool and leaves the typestate unchanged: it only filters
642    /// out tools that would otherwise be loaded by default.
643    ///
644    /// # Examples
645    ///
646    /// ```
647    /// use ironflow_core::provider::{AgentConfig, Tool};
648    /// use schemars::JsonSchema;
649    ///
650    /// #[derive(serde::Deserialize, JsonSchema)]
651    /// struct Out { ok: bool }
652    ///
653    /// let config = AgentConfig::new("classify this")
654    ///     .disallowed_tools([Tool::Write, Tool::Edit])
655    ///     .output::<Out>();
656    /// assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
657    /// ```
658    pub fn disallowed_tools<I>(mut self, tools: I) -> Self
659    where
660        I: IntoIterator<Item = Tool>,
661    {
662        self.disallowed_tools = tools.into_iter().map(|tool| tool.to_string()).collect();
663        self
664    }
665
666    /// Add a single custom pod label (K8s providers only).
667    ///
668    /// Can be called multiple times. Non-K8s providers ignore this field.
669    ///
670    /// # Examples
671    ///
672    /// ```
673    /// use ironflow_core::provider::AgentConfig;
674    ///
675    /// let config = AgentConfig::new("analyze")
676    ///     .pod_label("ironflow.io/network-profile", "grafana-only")
677    ///     .pod_label("team", "observability");
678    /// ```
679    pub fn pod_label(mut self, key: &str, value: &str) -> Self {
680        self.pod_labels.insert(key.to_string(), value.to_string());
681        self
682    }
683
684    /// Replace the entire custom pod labels map (K8s providers only).
685    ///
686    /// Non-K8s providers ignore this field.
687    ///
688    /// # Examples
689    ///
690    /// ```
691    /// use std::collections::BTreeMap;
692    /// use ironflow_core::provider::AgentConfig;
693    ///
694    /// let mut labels = BTreeMap::new();
695    /// labels.insert("env".to_string(), "staging".to_string());
696    /// let config = AgentConfig::new("deploy").pod_labels(labels);
697    /// ```
698    pub fn pod_labels(mut self, labels: BTreeMap<String, String>) -> Self {
699        self.pod_labels = labels;
700        self
701    }
702
703    /// Set a session ID to resume a previous conversation.
704    pub fn resume(mut self, session_id: &str) -> Self {
705        self.resume_session_id = Some(session_id.to_string());
706        self
707    }
708
709    /// Fix the id of the session the provider creates (`--session-id <uuid>`).
710    ///
711    /// Ignored when a session to resume is set with [`AgentConfig::resume`].
712    /// Claude Code requires a UUID.
713    ///
714    /// # Examples
715    ///
716    /// ```
717    /// use ironflow_core::provider::AgentConfig;
718    ///
719    /// let config = AgentConfig::new("review the code")
720    ///     .session_id("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d");
721    /// assert!(config.session_id.is_some());
722    /// ```
723    pub fn session_id(mut self, session_id: &str) -> Self {
724        self.session_id = Some(session_id.to_string());
725        self
726    }
727
728    /// Set the prompt sent when the engine resumes an interrupted step.
729    ///
730    /// The engine resumes the Claude Code session of an agent step
731    /// interrupted by a lost worker lease and sends this prompt instead of
732    /// the original one. Without it, the engine uses its default resume
733    /// prompt. Providers ignore this field.
734    ///
735    /// # Examples
736    ///
737    /// ```
738    /// use ironflow_core::provider::AgentConfig;
739    ///
740    /// let config = AgentConfig::new("migrate the schema")
741    ///     .resume_prompt("You were interrupted. Check the migration state and finish it.");
742    /// assert!(config.resume_prompt.is_some());
743    /// ```
744    pub fn resume_prompt(mut self, prompt: &str) -> Self {
745        self.resume_prompt = Some(prompt.to_string());
746        self
747    }
748
749    /// Resume a persistent environment created by a previous agent step.
750    ///
751    /// `environment_id` is the value of [`AgentOutput::environment_id`]
752    /// (the name of a PersistentVolumeClaim). Only the K8s ephemeral
753    /// provider configured with an environment volume honours it and fails
754    /// the step when the environment does not exist anymore. Other providers
755    /// ignore it.
756    ///
757    /// # Panics
758    ///
759    /// Panics if `environment_id` is empty, longer than 253 characters, or
760    /// contains characters other than lowercase ASCII letters, digits and
761    /// `-` (a DNS-1123 name).
762    ///
763    /// # Examples
764    ///
765    /// ```
766    /// use ironflow_core::provider::AgentConfig;
767    ///
768    /// let config = AgentConfig::new("run the tests again")
769    ///     .resume_environment("ironflow-env-0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d");
770    /// assert!(config.resume_environment_id.is_some());
771    /// ```
772    pub fn resume_environment(mut self, environment_id: &str) -> Self {
773        assert_environment_id_valid(environment_id);
774        self.resume_environment_id = Some(environment_id.to_string());
775        self
776    }
777
778    /// Set a step-level retry policy.
779    ///
780    /// # Examples
781    ///
782    /// ```
783    /// use ironflow_core::provider::AgentConfig;
784    /// use ironflow_core::retry::RetryPolicy;
785    ///
786    /// let config = AgentConfig::new("Summarize this document")
787    ///     .retry_policy(RetryPolicy::new(3));
788    /// assert!(config.retry.is_some());
789    /// ```
790    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
791        self.retry = Some(policy);
792        self
793    }
794
795    /// Attach a [`WorkflowTraceContext`] for distributed tracing.
796    ///
797    /// When set, providers can inject the `traceparent` header into
798    /// outgoing HTTP requests to correlate workflow spans with
799    /// downstream service spans.
800    ///
801    /// # Examples
802    ///
803    /// ```
804    /// use ironflow_core::provider::AgentConfig;
805    /// use ironflow_core::trace_context::WorkflowTraceContext;
806    ///
807    /// let ctx = WorkflowTraceContext::new_root();
808    /// let config = AgentConfig::new("classify this")
809    ///     .trace_context(ctx);
810    /// assert!(config.trace_context.is_some());
811    /// ```
812    pub fn trace_context(mut self, ctx: WorkflowTraceContext) -> Self {
813        self.trace_context = Some(ctx);
814        self
815    }
816
817    /// Declare an external input that the provider must materialize on the
818    /// agent's filesystem before invocation.
819    ///
820    /// `url` is fetched (HTTP/HTTPS) and written to `mount_path` (absolute
821    /// path) inside the agent's runtime. Each provider materializes inputs
822    /// in its own way:
823    ///
824    /// * Local provider: downloads to a temp dir on the host.
825    /// * K8s providers: spawn a `curlimages/curl` initContainer that downloads
826    ///   into a shared `emptyDir` mounted on the main container.
827    ///
828    /// Can be called multiple times to declare several inputs.
829    ///
830    /// # Examples
831    ///
832    /// ```
833    /// use ironflow_core::provider::{AgentConfig, Tool};
834    ///
835    /// let config = AgentConfig::new("Read /work/dossier.pdf and summarize")
836    ///     .allow_tool(Tool::Read)
837    ///     .input_file("https://r2.example.com/dossier.pdf", "/work/dossier.pdf");
838    /// ```
839    pub fn input_file(mut self, url: &str, mount_path: &str) -> Self {
840        self.inputs.push(AgentInput::new(url, mount_path));
841        self
842    }
843
844    /// Read an environment variable from a Kubernetes Secret (K8s ephemeral
845    /// provider only).
846    ///
847    /// The pod gets `valueFrom.secretKeyRef`, so the value never enters the
848    /// pod spec. Calling it again with the same `var` replaces the entry. A
849    /// step entry overrides a provider entry with the same name.
850    ///
851    /// # Examples
852    ///
853    /// ```
854    /// use ironflow_core::provider::AgentConfig;
855    ///
856    /// let config = AgentConfig::new("open the MR")
857    ///     .env_from_secret("GITLAB_TOKEN", "gitlab-bot", "token");
858    /// assert_eq!(config.pod.secret_env[0].secret, "gitlab-bot");
859    /// ```
860    pub fn env_from_secret(mut self, var: &str, secret: &str, key: &str) -> Self {
861        let entry = SecretEnvVar {
862            name: var.to_string(),
863            secret: secret.to_string(),
864            key: key.to_string(),
865        };
866        upsert_secret_env(&mut self.pod.secret_env, entry);
867        self
868    }
869
870    /// Hand a secret to the agent through the auth proxy (K8s ephemeral
871    /// provider with an auth proxy only).
872    ///
873    /// The pod gets `<env>` set to an opaque token and `<env>_URL` set to
874    /// `<proxy>/r`, never the value: the proxy injects the real secret on
875    /// requests to the allowlisted hosts. Calling it again with the same
876    /// `env` replaces the entry. A step entry overrides a provider entry
877    /// with the same `env`.
878    ///
879    /// # Panics
880    ///
881    /// Panics when [`ProxiedSecret::validate`] refuses `secret`: invalid
882    /// `env` or name, empty or invalid host allowlist, invalid injection.
883    /// The message never carries the value.
884    ///
885    /// # Examples
886    ///
887    /// ```no_run
888    /// use std::env::{VarError, var};
889    ///
890    /// use ironflow_core::auth_proxy::{ProxiedSecret, SecretInjection};
891    /// use ironflow_core::provider::AgentConfig;
892    ///
893    /// # fn example() -> Result<(), VarError> {
894    /// let config = AgentConfig::new("open the PR").proxied_secret(ProxiedSecret {
895    ///     name: "GITHUB_TOKEN".to_string(),
896    ///     env: "GITHUB_TOKEN".to_string(),
897    ///     value: var("GITHUB_TOKEN")?,
898    ///     injection: SecretInjection::Bearer,
899    ///     hosts: vec!["api.github.com".to_string()],
900    /// });
901    /// assert_eq!(config.proxied_secrets[0].env, "GITHUB_TOKEN");
902    /// # Ok(())
903    /// # }
904    /// ```
905    pub fn proxied_secret(mut self, secret: ProxiedSecret) -> Self {
906        upsert_proxied_secret(&mut self.proxied_secrets, secret);
907        self
908    }
909
910    /// Run the pod under a given Kubernetes service account (K8s ephemeral
911    /// provider only). Overrides the provider's service account.
912    ///
913    /// # Examples
914    ///
915    /// ```
916    /// use ironflow_core::provider::AgentConfig;
917    ///
918    /// let config = AgentConfig::new("read the cluster").service_account("reader");
919    /// assert_eq!(config.pod.service_account.as_deref(), Some("reader"));
920    /// ```
921    pub fn service_account(mut self, name: &str) -> Self {
922        self.pod.service_account = Some(name.to_string());
923        self
924    }
925
926    /// Run the pod under a given Kubernetes RuntimeClass, for instance
927    /// `gvisor` (K8s ephemeral provider only). Overrides the provider's.
928    ///
929    /// # Examples
930    ///
931    /// ```
932    /// use ironflow_core::provider::AgentConfig;
933    ///
934    /// let config = AgentConfig::new("x").runtime_class("gvisor");
935    /// assert_eq!(config.pod.runtime_class.as_deref(), Some("gvisor"));
936    /// ```
937    pub fn runtime_class(mut self, name: &str) -> Self {
938        self.pod.runtime_class = Some(name.to_string());
939        self
940    }
941
942    /// Mount a volume read-only into the agent container (K8s ephemeral
943    /// provider only). Step volumes come after the provider's.
944    ///
945    /// # Examples
946    ///
947    /// ```
948    /// use ironflow_core::provider::{AgentConfig, PodVolumeSource, ReadOnlyVolume};
949    ///
950    /// let config = AgentConfig::new("review").read_only_volume(ReadOnlyVolume {
951    ///     source: PodVolumeSource::PersistentVolumeClaim { claim_name: "repos".to_string() },
952    ///     mount_path: "/data/repos/api".to_string(),
953    ///     sub_path: Some("api".to_string()),
954    /// });
955    /// assert_eq!(config.pod.read_only_volumes.len(), 1);
956    /// ```
957    pub fn read_only_volume(mut self, volume: ReadOnlyVolume) -> Self {
958        self.pod.read_only_volumes.push(volume);
959        self
960    }
961
962    /// Drop the provider's `volume` and `pvc_volume` mounts for this step
963    /// (K8s ephemeral provider only). The provider's read-only volumes, its
964    /// profiles and its managed settings are kept.
965    ///
966    /// # Examples
967    ///
968    /// ```
969    /// use ironflow_core::provider::AgentConfig;
970    ///
971    /// let config = AgentConfig::new("review").without_provider_volumes();
972    /// assert!(config.pod.without_provider_volumes);
973    /// ```
974    pub fn without_provider_volumes(mut self) -> Self {
975        self.pod.without_provider_volumes = true;
976        self
977    }
978
979    /// Mount a PersistentVolumeClaim into the agent container, optionally
980    /// on a `sub_path` and read-only (K8s ephemeral provider only). Step
981    /// mounts are merged after the provider's volumes.
982    ///
983    /// # Panics
984    ///
985    /// Panics when the volume is refused by [`PvcVolume::validate`]: an empty
986    /// `claim` or a `sub_path` refused by [`validate_pvc_sub_path`].
987    ///
988    /// # Examples
989    ///
990    /// ```
991    /// use ironflow_core::provider::AgentConfig;
992    ///
993    /// let config = AgentConfig::new("build")
994    ///     .pvc_volume("workspace", "/work", Some("team-a"), false);
995    /// assert_eq!(config.pod.pvc_volumes[0].claim_name, "workspace");
996    /// assert_eq!(config.pod.pvc_volumes[0].sub_path.as_deref(), Some("team-a"));
997    /// ```
998    ///
999    /// ```should_panic
1000    /// use ironflow_core::provider::AgentConfig;
1001    ///
1002    /// let _ = AgentConfig::new("build").pvc_volume("workspace", "/work", Some("../x"), false);
1003    /// ```
1004    pub fn pvc_volume(
1005        mut self,
1006        claim: &str,
1007        mount_path: &str,
1008        sub_path: Option<&str>,
1009        read_only: bool,
1010    ) -> Self {
1011        let volume = PvcVolume {
1012            claim_name: claim.to_string(),
1013            mount_path: mount_path.to_string(),
1014            sub_path: sub_path.map(str::to_string),
1015            read_only,
1016        };
1017        if let Err(reason) = volume.validate() {
1018            panic!("invalid pvc_volume: {reason}");
1019        }
1020        self.pod.pvc_volumes.push(volume);
1021        self
1022    }
1023
1024    /// Mount a PersistentVolumeClaim read-only at `mount_path` (K8s ephemeral
1025    /// provider only).
1026    ///
1027    /// # Examples
1028    ///
1029    /// ```
1030    /// use ironflow_core::provider::AgentConfig;
1031    ///
1032    /// let config = AgentConfig::new("review").read_only_pvc("repos", "/data/repos");
1033    /// assert_eq!(config.pod.read_only_volumes[0].mount_path, "/data/repos");
1034    /// ```
1035    pub fn read_only_pvc(self, claim: &str, mount_path: &str) -> Self {
1036        self.read_only_volume(ReadOnlyVolume {
1037            source: PodVolumeSource::PersistentVolumeClaim {
1038                claim_name: claim.to_string(),
1039            },
1040            mount_path: mount_path.to_string(),
1041            sub_path: None,
1042        })
1043    }
1044
1045    /// Mount a node directory read-only at `mount_path` (K8s ephemeral
1046    /// provider only).
1047    ///
1048    /// # Examples
1049    ///
1050    /// ```
1051    /// use ironflow_core::provider::AgentConfig;
1052    ///
1053    /// let config = AgentConfig::new("review").read_only_host_path("/srv/repos", "/data/repos");
1054    /// assert_eq!(config.pod.read_only_volumes.len(), 1);
1055    /// ```
1056    pub fn read_only_host_path(self, host_path: &str, mount_path: &str) -> Self {
1057        self.read_only_volume(ReadOnlyVolume {
1058            source: PodVolumeSource::HostPath {
1059                path: host_path.to_string(),
1060            },
1061            mount_path: mount_path.to_string(),
1062            sub_path: None,
1063        })
1064    }
1065
1066    /// Mount a ConfigMap read-only at `mount_path` (K8s ephemeral provider
1067    /// only).
1068    ///
1069    /// # Examples
1070    ///
1071    /// ```
1072    /// use ironflow_core::provider::AgentConfig;
1073    ///
1074    /// let config = AgentConfig::new("review").read_only_config_map("guidelines", "/data/guidelines");
1075    /// assert_eq!(config.pod.read_only_volumes.len(), 1);
1076    /// ```
1077    pub fn read_only_config_map(self, name: &str, mount_path: &str) -> Self {
1078        self.read_only_volume(ReadOnlyVolume {
1079            source: PodVolumeSource::ConfigMap {
1080                name: name.to_string(),
1081            },
1082            mount_path: mount_path.to_string(),
1083            sub_path: None,
1084        })
1085    }
1086
1087    /// Select a managed-settings preset registered on the provider (K8s
1088    /// ephemeral provider only).
1089    ///
1090    /// The provider maps the preset to a ConfigMap holding
1091    /// `managed-settings.json`. An unknown preset fails the step.
1092    ///
1093    /// # Examples
1094    ///
1095    /// ```
1096    /// use ironflow_core::provider::AgentConfig;
1097    ///
1098    /// let config = AgentConfig::new("review").managed_settings("readonly");
1099    /// assert_eq!(config.pod.managed_settings.as_deref(), Some("readonly"));
1100    /// ```
1101    pub fn managed_settings(mut self, preset: &str) -> Self {
1102        self.pod.managed_settings = Some(preset.to_string());
1103        self
1104    }
1105
1106    /// Select the network egress profile of the pod (K8s providers only).
1107    ///
1108    /// Sets the [`LABEL_EGRESS_PROFILE`] pod label, which network policies
1109    /// select on. Overrides the provider's egress profile.
1110    ///
1111    /// # Examples
1112    ///
1113    /// ```
1114    /// use ironflow_core::provider::{AgentConfig, LABEL_EGRESS_PROFILE};
1115    ///
1116    /// let config = AgentConfig::new("open the MR").egress_profile("gitlab");
1117    /// assert_eq!(config.pod_labels[LABEL_EGRESS_PROFILE], "gitlab");
1118    /// ```
1119    pub fn egress_profile(mut self, profile: &str) -> Self {
1120        self.pod_labels
1121            .insert(LABEL_EGRESS_PROFILE.to_string(), profile.to_string());
1122        self
1123    }
1124
1125    /// Run the invocation under a Provider Account.
1126    ///
1127    /// The worker sets it after selecting an account; providers that support
1128    /// the account kind inject the credential and record rate-limit windows.
1129    ///
1130    /// # Examples
1131    ///
1132    /// ```
1133    /// use ironflow_core::account::{AccountCredential, AccountSession, RateLimitRecorder};
1134    /// use ironflow_core::provider::AgentConfig;
1135    ///
1136    /// let session = AccountSession::new(
1137    ///     AccountCredential::new("CLAUDE_CODE_OAUTH_TOKEN", "token".to_string()),
1138    ///     RateLimitRecorder::default(),
1139    /// );
1140    /// let config = AgentConfig::new("hello").account_session(session);
1141    /// assert!(config.account.is_some());
1142    /// ```
1143    pub fn account_session(mut self, session: AccountSession) -> Self {
1144        self.account = Some(session);
1145        self
1146    }
1147
1148    /// Set how long the run may sleep waiting for provider capacity.
1149    ///
1150    /// When every targeted account is rate limited, the run sleeps until the
1151    /// earliest reset if it comes within `wait`, and fails with
1152    /// [`AgentError::NoCapacity`] otherwise. `Duration::ZERO` fails at once.
1153    /// Overrides the worker default (6 hours).
1154    ///
1155    /// # Examples
1156    ///
1157    /// ```
1158    /// use std::time::Duration;
1159    /// use ironflow_core::provider::AgentConfig;
1160    ///
1161    /// let config = AgentConfig::new("review").max_capacity_wait(Duration::ZERO);
1162    /// assert_eq!(config.max_capacity_wait, Some(Duration::ZERO));
1163    /// ```
1164    pub fn max_capacity_wait(mut self, wait: Duration) -> Self {
1165        self.max_capacity_wait = Some(wait);
1166        self
1167    }
1168
1169    /// Fail the step at once when every targeted account is rate limited.
1170    ///
1171    /// Explicit form of [`max_capacity_wait`](Self::max_capacity_wait) with
1172    /// `Duration::ZERO`: the step fails with [`AgentError::NoCapacity`] instead
1173    /// of putting the run to sleep, whatever the worker default is.
1174    ///
1175    /// # Examples
1176    ///
1177    /// ```
1178    /// use std::time::Duration;
1179    /// use ironflow_core::provider::AgentConfig;
1180    ///
1181    /// let config = AgentConfig::new("review").fail_fast_on_capacity();
1182    /// assert_eq!(config.max_capacity_wait, Some(Duration::ZERO));
1183    /// ```
1184    pub fn fail_fast_on_capacity(self) -> Self {
1185        self.max_capacity_wait(Duration::ZERO)
1186    }
1187
1188    /// Run the step under the Provider Account named `name`, and only it.
1189    ///
1190    /// No failover: when the account is rate limited the run waits (bounded
1191    /// by [`max_capacity_wait`](Self::max_capacity_wait)), when it does not
1192    /// exist the step fails with [`AgentError::AccountNotFound`].
1193    ///
1194    /// # Examples
1195    ///
1196    /// ```
1197    /// use ironflow_core::provider::AgentConfig;
1198    ///
1199    /// let config = AgentConfig::new("review").account("team-a");
1200    /// assert_eq!(config.account_name.as_deref(), Some("team-a"));
1201    /// ```
1202    pub fn account(mut self, name: &str) -> Self {
1203        self.account_name = Some(name.to_string());
1204        self
1205    }
1206
1207    /// Restrict the step to the Provider Accounts tagged `tag`.
1208    ///
1209    /// The strategy picks among them and fails over between them when one
1210    /// is rate limited during the step.
1211    ///
1212    /// # Examples
1213    ///
1214    /// ```
1215    /// use ironflow_core::provider::AgentConfig;
1216    ///
1217    /// let config = AgentConfig::new("review").account_pool("batch");
1218    /// assert_eq!(config.account_pool.as_deref(), Some("batch"));
1219    /// ```
1220    pub fn account_pool(mut self, tag: &str) -> Self {
1221        self.account_pool = Some(tag.to_string());
1222        self
1223    }
1224
1225    /// Report observed rate-limit windows to `recorder` when the invocation
1226    /// runs without a Provider Account (the worker environment token).
1227    ///
1228    /// Set by the worker; providers prefer the recorder of
1229    /// [`account`](Self::account) when both are set.
1230    ///
1231    /// # Examples
1232    ///
1233    /// ```
1234    /// use ironflow_core::account::RateLimitRecorder;
1235    /// use ironflow_core::provider::AgentConfig;
1236    ///
1237    /// let config = AgentConfig::new("hello").rate_limit_recorder(RateLimitRecorder::default());
1238    /// assert!(config.rate_limits.is_some());
1239    /// ```
1240    pub fn rate_limit_recorder(mut self, recorder: RateLimitRecorder) -> Self {
1241        self.rate_limits = Some(recorder);
1242        self
1243    }
1244
1245    /// Record when the step started waiting for provider capacity.
1246    ///
1247    /// Set by the engine when a step resumes after a capacity sleep; bounds
1248    /// the cumulative wait for an account freed by `max_concurrency`.
1249    ///
1250    /// # Examples
1251    ///
1252    /// ```
1253    /// use chrono::Utc;
1254    /// use ironflow_core::provider::AgentConfig;
1255    ///
1256    /// let since = Utc::now();
1257    /// let config = AgentConfig::new("hello").capacity_wait_since(since);
1258    /// assert_eq!(config.capacity_wait_since, Some(since));
1259    /// ```
1260    pub fn capacity_wait_since(mut self, since: DateTime<Utc>) -> Self {
1261        self.capacity_wait_since = Some(since);
1262        self
1263    }
1264
1265    /// Tag the pod with the run id and step name (K8s providers only).
1266    ///
1267    /// Sets [`LABEL_RUN_ID`] and [`LABEL_STEP`], both passed through
1268    /// [`sanitize_label_value`]. The engine sets these labels on every agent
1269    /// step; call it yourself only when running an agent outside the engine.
1270    /// The ephemeral provider uses them to delete the pods of a previous
1271    /// attempt of the same step before starting a new one.
1272    ///
1273    /// # Examples
1274    ///
1275    /// ```
1276    /// use ironflow_core::provider::{AgentConfig, LABEL_RUN_ID, LABEL_STEP};
1277    ///
1278    /// let config = AgentConfig::new("investigate").run_scope("demo-run", "investigate");
1279    /// assert_eq!(config.pod_labels[LABEL_RUN_ID], "demo-run");
1280    /// assert_eq!(config.pod_labels[LABEL_STEP], "investigate");
1281    /// ```
1282    pub fn run_scope(mut self, run_id: &str, step: &str) -> Self {
1283        self.pod_labels
1284            .insert(LABEL_RUN_ID.to_string(), sanitize_label_value(run_id));
1285        self.pod_labels
1286            .insert(LABEL_STEP.to_string(), sanitize_label_value(step));
1287        self
1288    }
1289
1290    /// Convert to a different typestate by moving all fields.
1291    ///
1292    /// Safe because the marker is a zero-sized [`PhantomData`] -- no
1293    /// runtime data changes.
1294    fn change_state<T2, S2>(self) -> AgentConfig<T2, S2> {
1295        AgentConfig {
1296            system_prompt: self.system_prompt,
1297            append_system_prompt: self.append_system_prompt,
1298            prompt: self.prompt,
1299            model: self.model,
1300            allowed_tools: self.allowed_tools,
1301            disallowed_tools: self.disallowed_tools,
1302            tool_profile: self.tool_profile,
1303            max_turns: self.max_turns,
1304            max_parallel_tools: self.max_parallel_tools,
1305            max_budget_usd: self.max_budget_usd,
1306            working_dir: self.working_dir,
1307            mcp_config: self.mcp_config,
1308            strict_mcp_config: self.strict_mcp_config,
1309            bare: self.bare,
1310            permission_mode: self.permission_mode,
1311            effort: self.effort,
1312            json_schema: self.json_schema,
1313            resume_session_id: self.resume_session_id,
1314            session_id: self.session_id,
1315            resume_prompt: self.resume_prompt,
1316            resume_environment_id: self.resume_environment_id,
1317            verbose: self.verbose,
1318            pod_labels: self.pod_labels,
1319            pod: self.pod,
1320            inputs: self.inputs,
1321            allow_failure: self.allow_failure,
1322            retry: self.retry,
1323            trace_context: self.trace_context,
1324            account: self.account,
1325            proxied_secrets: self.proxied_secrets,
1326            max_capacity_wait: self.max_capacity_wait,
1327            account_name: self.account_name,
1328            account_pool: self.account_pool,
1329            rate_limits: self.rate_limits,
1330            capacity_wait_since: self.capacity_wait_since,
1331            _marker: PhantomData,
1332        }
1333    }
1334}
1335
1336// ── Tools: set the `Tools` marker, keep the schema ─────────────────
1337
1338impl<Tools, Schema> AgentConfig<Tools, Schema> {
1339    /// Add an allowed tool.
1340    ///
1341    /// Can be called multiple times to allow several tools. Returns an
1342    /// [`AgentConfig<WithTools, Schema>`]: a schema set before or after with
1343    /// [`output`](AgentConfig::output) is kept, and still types the answer.
1344    ///
1345    /// # Examples
1346    ///
1347    /// ```
1348    /// use ironflow_core::provider::{AgentConfig, Tool};
1349    ///
1350    /// let config = AgentConfig::new("search the web")
1351    ///     .allow_tool(Tool::WebSearch)
1352    ///     .allow_tool(Tool::Custom("mcp__docs__lookup".to_string()));
1353    /// assert_eq!(config.allowed_tools, vec!["WebSearch", "mcp__docs__lookup"]);
1354    /// ```
1355    ///
1356    /// Tools and a typed answer on the same step:
1357    ///
1358    /// ```
1359    /// use ironflow_core::provider::{AgentConfig, Tool};
1360    /// use schemars::JsonSchema;
1361    ///
1362    /// #[derive(serde::Deserialize, JsonSchema)]
1363    /// struct Verdict { approved: bool }
1364    ///
1365    /// let config = AgentConfig::new("Read src/lib.rs and say if it is documented")
1366    ///     .allow_tool(Tool::Read)
1367    ///     .output::<Verdict>();
1368    /// assert_eq!(config.allowed_tools, vec!["Read"]);
1369    /// assert!(config.json_schema.is_some());
1370    /// ```
1371    pub fn allow_tool(mut self, tool: Tool) -> AgentConfig<WithTools, Schema> {
1372        self.allowed_tools.push(tool.to_string());
1373        self.change_state()
1374    }
1375
1376    /// Select the named tool profile the provider exposes to this step.
1377    ///
1378    /// Profiles are registered with
1379    /// [`HttpAgentProvider::with_tool_profile`](crate::providers::http::HttpAgentProvider::with_tool_profile);
1380    /// declare each [`ToolProfile`] once as a constant and share it. A profile
1381    /// the provider does not have fails the step with
1382    /// [`AgentError::UnknownToolProfile`], never falling back to other tools.
1383    /// Claude CLI providers fail with [`AgentError::ToolProfileUnsupported`].
1384    /// Like [`allow_tool`](Self::allow_tool), it keeps the schema set with
1385    /// [`output`](AgentConfig::output); whether the model answers with it
1386    /// once it has used tools depends on the HTTP backend.
1387    ///
1388    /// # Examples
1389    ///
1390    /// ```
1391    /// use ironflow_core::provider::{AgentConfig, ToolProfile};
1392    ///
1393    /// const BUG: ToolProfile = ToolProfile::new("bug");
1394    ///
1395    /// let config = AgentConfig::new("Find the root cause").tool_profile(BUG);
1396    /// assert_eq!(config.tool_profile, Some(BUG));
1397    /// ```
1398    ///
1399    /// ```compile_fail,E0308
1400    /// use ironflow_core::provider::AgentConfig;
1401    /// // COMPILE ERROR: a profile is a `ToolProfile`, not a string
1402    /// let _ = AgentConfig::new("x").tool_profile("bug");
1403    /// ```
1404    pub fn tool_profile(mut self, profile: ToolProfile) -> AgentConfig<WithTools, Schema> {
1405        self.tool_profile = Some(profile);
1406        self.change_state()
1407    }
1408}
1409
1410// ── Schema: set the `Schema` marker, keep the tools ────────────────
1411
1412impl<Tools, Schema> AgentConfig<Tools, Schema> {
1413    /// Set structured output from a Rust type implementing [`JsonSchema`].
1414    ///
1415    /// The schema is serialized once at build time. When set, the provider
1416    /// will request typed output conforming to this schema.
1417    ///
1418    /// **Important:** structured output requires `max_turns >= 2`.
1419    ///
1420    /// Returns an [`AgentConfig<Tools, WithSchema<T>>`], which remembers
1421    /// `T` (a workflow step built from it answers with a `T`) and keeps the
1422    /// tools added with [`allow_tool`](AgentConfig::allow_tool): the agent can
1423    /// use them, then answer with a `T`. Claude Code fills `structured_output`
1424    /// with tools active (tested with 2.1.296 and 2.1.304), where
1425    /// [claude-code#18536](https://github.com/anthropics/claude-code/issues/18536)
1426    /// reported `null`.
1427    ///
1428    /// # Known limitations of Claude CLI structured output
1429    ///
1430    /// The Claude CLI does not guarantee strict schema conformance for
1431    /// structured output. The following upstream bugs affect the behavior:
1432    ///
1433    /// - **Schema flattening** ([anthropics/claude-agent-sdk-python#502]):
1434    ///   a schema like `{"type":"object","properties":{"items":{"type":"array",...}}}`
1435    ///   may return a bare array instead of the wrapper object. The CLI
1436    ///   non-deterministically flattens schemas with a single array field.
1437    /// - **Non-deterministic wrapping** ([anthropics/claude-agent-sdk-python#374]):
1438    ///   the same prompt can produce differently wrapped output across runs.
1439    /// - **No conformance guarantee** ([anthropics/claude-code#9058]):
1440    ///   the CLI does not validate output against the provided JSON schema.
1441    ///
1442    /// Because of these bugs, ironflow's provider layer applies multiple
1443    /// fallback strategies when extracting the structured value (see
1444    /// [`extract_structured_value`](crate::providers::claude::common::extract_structured_value)).
1445    ///
1446    /// [anthropics/claude-agent-sdk-python#502]: https://github.com/anthropics/claude-agent-sdk-python/issues/502
1447    /// [anthropics/claude-agent-sdk-python#374]: https://github.com/anthropics/claude-agent-sdk-python/issues/374
1448    /// [anthropics/claude-code#9058]: https://github.com/anthropics/claude-code/issues/9058
1449    ///
1450    /// # Examples
1451    ///
1452    /// ```
1453    /// use ironflow_core::provider::AgentConfig;
1454    /// use schemars::JsonSchema;
1455    ///
1456    /// #[derive(serde::Deserialize, JsonSchema)]
1457    /// struct Labels { labels: Vec<String> }
1458    ///
1459    /// let config = AgentConfig::new("classify this text")
1460    ///     .output::<Labels>();
1461    /// ```
1462    ///
1463    /// ```
1464    /// use ironflow_core::provider::{AgentConfig, Tool};
1465    /// use schemars::JsonSchema;
1466    ///
1467    /// #[derive(serde::Deserialize, JsonSchema)]
1468    /// struct Out { x: i32 }
1469    ///
1470    /// // Tools can come after the schema too.
1471    /// let config = AgentConfig::new("x").output::<Out>().allow_tool(Tool::Read);
1472    /// assert_eq!(config.allowed_tools, vec!["Read"]);
1473    /// ```
1474    ///
1475    /// # Panics
1476    ///
1477    /// Panics if the schema generated by `schemars` cannot be serialized
1478    /// to JSON. This indicates a bug in the type's `JsonSchema` derive,
1479    /// not a recoverable runtime error.
1480    pub fn output<T: JsonSchema>(mut self) -> AgentConfig<Tools, WithSchema<T>> {
1481        let schema = schemars::schema_for!(T);
1482        let serialized = serde_json::to_string(&schema).unwrap_or_else(|e| {
1483            panic!(
1484                "failed to serialize JSON schema for {}: {e}",
1485                std::any::type_name::<T>()
1486            )
1487        });
1488        self.json_schema = Some(serialized);
1489        self.change_state()
1490    }
1491
1492    /// Set structured output from a pre-serialized JSON Schema string.
1493    ///
1494    /// Returns an [`AgentConfig<Tools, RawSchema>`], whose answer is not
1495    /// typed, and which keeps the tools. Prefer [`output`](Self::output).
1496    pub fn output_schema_raw(mut self, schema: &str) -> AgentConfig<Tools, RawSchema> {
1497        self.json_schema = Some(schema.to_string());
1498        self.change_state()
1499    }
1500}
1501
1502// ── From conversions to base type ──────────────────────────────────
1503
1504impl From<AgentConfig<WithTools, NoSchema>> for AgentConfig {
1505    fn from(config: AgentConfig<WithTools, NoSchema>) -> Self {
1506        config.change_state()
1507    }
1508}
1509
1510impl<T> From<AgentConfig<NoTools, WithSchema<T>>> for AgentConfig {
1511    fn from(config: AgentConfig<NoTools, WithSchema<T>>) -> Self {
1512        config.change_state()
1513    }
1514}
1515
1516impl From<AgentConfig<NoTools, RawSchema>> for AgentConfig {
1517    fn from(config: AgentConfig<NoTools, RawSchema>) -> Self {
1518        config.change_state()
1519    }
1520}
1521
1522impl<T> From<AgentConfig<WithTools, WithSchema<T>>> for AgentConfig {
1523    fn from(config: AgentConfig<WithTools, WithSchema<T>>) -> Self {
1524        config.change_state()
1525    }
1526}
1527
1528impl From<AgentConfig<WithTools, RawSchema>> for AgentConfig {
1529    fn from(config: AgentConfig<WithTools, RawSchema>) -> Self {
1530        config.change_state()
1531    }
1532}
1533
1534// ── AgentOutput ────────────────────────────────────────────────────
1535
1536/// Raw output returned by an [`AgentProvider`] after a successful invocation.
1537///
1538/// Carries the agent's response value together with usage and billing metadata.
1539#[derive(Clone, Debug, Serialize, Deserialize)]
1540#[non_exhaustive]
1541pub struct AgentOutput {
1542    /// The agent's response. A plain [`Value::String`] for text mode, or an
1543    /// arbitrary JSON value when a JSON schema was requested.
1544    pub value: Value,
1545
1546    /// Provider-assigned session identifier, useful for resuming conversations.
1547    pub session_id: Option<String>,
1548
1549    /// Total cost in USD for this invocation, if reported by the provider.
1550    pub cost_usd: Option<f64>,
1551
1552    /// Uncached input tokens (excludes cache reads and writes), if reported.
1553    pub input_tokens: Option<u64>,
1554
1555    /// Input tokens served from the prompt cache, if reported.
1556    #[serde(default)]
1557    pub cache_read_input_tokens: Option<u64>,
1558
1559    /// Input tokens written to the prompt cache, if reported.
1560    #[serde(default)]
1561    pub cache_creation_input_tokens: Option<u64>,
1562
1563    /// Number of output tokens generated, if reported.
1564    pub output_tokens: Option<u64>,
1565
1566    /// The concrete model identifier used (e.g. `"claude-sonnet-4-20250514"`).
1567    pub model: Option<String>,
1568
1569    /// Wall-clock duration of the invocation in milliseconds.
1570    pub duration_ms: u64,
1571
1572    /// Conversation trace captured when [`AgentConfig::verbose`] is `true`.
1573    ///
1574    /// Contains every assistant message and tool call made during the
1575    /// invocation, in chronological order. `None` when verbose mode is off.
1576    pub debug_messages: Option<Vec<DebugMessage>>,
1577
1578    /// Identifier of the Provider Account the invocation ran under, if any.
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub account_id: Option<String>,
1581
1582    /// Persistent environment the invocation ran in, if any.
1583    ///
1584    /// The name of the PersistentVolumeClaim mounted in the agent pod. Only
1585    /// the K8s ephemeral provider configured with an environment volume sets
1586    /// it. Pass it to [`AgentConfig::resume_environment`] in a later step to
1587    /// find the same files again.
1588    #[serde(default)]
1589    pub environment_id: Option<String>,
1590}
1591
1592/// A single assistant turn captured during a verbose invocation.
1593///
1594/// Each `DebugMessage` represents one assistant response, which may contain
1595/// free-form text, tool calls, or both.
1596///
1597/// # Examples
1598///
1599/// ```no_run
1600/// use ironflow_core::prelude::*;
1601///
1602/// # async fn example() -> Result<(), OperationError> {
1603/// let provider = ClaudeCodeProvider::new();
1604/// let result = Agent::new()
1605///     .prompt("List files in src/")
1606///     .verbose()
1607///     .run(&provider)
1608///     .await?;
1609///
1610/// if let Some(messages) = result.debug_messages() {
1611///     for msg in messages {
1612///         println!("{msg}");
1613///     }
1614/// }
1615/// # Ok(())
1616/// # }
1617/// ```
1618#[derive(Debug, Clone, Serialize, Deserialize)]
1619#[non_exhaustive]
1620pub struct DebugMessage {
1621    /// Free-form text produced by the assistant in this turn, if any.
1622    pub text: Option<String>,
1623
1624    /// Extended thinking blocks produced by the model in this turn.
1625    ///
1626    /// Available only when the model emits `thinking` content blocks
1627    /// (Opus 4.7 adaptive thinking, Claude 3.7+ extended thinking, etc.).
1628    /// The blocks are joined in arrival order.
1629    #[serde(default, skip_serializing_if = "Option::is_none")]
1630    pub thinking: Option<String>,
1631
1632    /// `true` when the model emitted a `thinking` content block but the
1633    /// text was redacted (only a signature is provided).
1634    ///
1635    /// Opus 4.7 adaptive thinking and the `display: "omitted"` setting both
1636    /// produce signature-only thinking blocks: the model proves it reasoned
1637    /// without exposing the chain of thought. The UI should still show a
1638    /// badge so the user knows thinking happened.
1639    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1640    pub thinking_redacted: bool,
1641
1642    /// Tool calls made by the assistant in this turn.
1643    pub tool_calls: Vec<DebugToolCall>,
1644
1645    /// Tool results received from the user/runtime for the preceding tool calls.
1646    ///
1647    /// In the Claude stream-json format, tool results come as `"type":"user"`
1648    /// messages whose content is a list of `tool_result` blocks. We attach
1649    /// them to the turn that emitted the matching `tool_use` so the timeline
1650    /// stays compact.
1651    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1652    pub tool_results: Vec<DebugToolResult>,
1653
1654    /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
1655    pub stop_reason: Option<String>,
1656
1657    /// Input tokens consumed by this turn, if reported.
1658    #[serde(default, skip_serializing_if = "Option::is_none")]
1659    pub input_tokens: Option<u64>,
1660
1661    /// Output tokens generated by this turn, if reported.
1662    #[serde(default, skip_serializing_if = "Option::is_none")]
1663    pub output_tokens: Option<u64>,
1664}
1665
1666impl fmt::Display for DebugMessage {
1667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1668        if let Some(ref thinking) = self.thinking {
1669            writeln!(f, "[thinking] {thinking}")?;
1670        } else if self.thinking_redacted {
1671            writeln!(f, "[thinking redacted]")?;
1672        }
1673        if let Some(ref text) = self.text {
1674            writeln!(f, "[assistant] {text}")?;
1675        }
1676        for tc in &self.tool_calls {
1677            write!(f, "{tc}")?;
1678        }
1679        for tr in &self.tool_results {
1680            write!(f, "{tr}")?;
1681        }
1682        Ok(())
1683    }
1684}
1685
1686/// A single tool call captured during a verbose invocation.
1687///
1688/// Records the tool name and its input arguments as a raw JSON value.
1689#[derive(Debug, Clone, Serialize, Deserialize)]
1690#[non_exhaustive]
1691pub struct DebugToolCall {
1692    /// Stable identifier assigned by the model (`tool_use_id`).
1693    ///
1694    /// Used to correlate a call with its subsequent [`DebugToolResult`].
1695    #[serde(default, skip_serializing_if = "Option::is_none")]
1696    pub id: Option<String>,
1697
1698    /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
1699    pub name: String,
1700
1701    /// Input arguments passed to the tool, as raw JSON.
1702    pub input: Value,
1703}
1704
1705impl fmt::Display for DebugToolCall {
1706    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1707        writeln!(f, "  [tool_use] {} -> {}", self.name, self.input)
1708    }
1709}
1710
1711/// A tool result returned to the model after a tool call.
1712///
1713/// Carries the tool output (any JSON value: string, object, array) and
1714/// an error flag if the tool failed.
1715#[derive(Debug, Clone, Serialize, Deserialize)]
1716#[non_exhaustive]
1717pub struct DebugToolResult {
1718    /// The `tool_use_id` this result answers, matching [`DebugToolCall::id`].
1719    #[serde(default, skip_serializing_if = "Option::is_none")]
1720    pub tool_use_id: Option<String>,
1721
1722    /// Raw content returned by the tool.
1723    pub content: Value,
1724
1725    /// Whether the tool reported an error.
1726    #[serde(default)]
1727    pub is_error: bool,
1728}
1729
1730impl fmt::Display for DebugToolResult {
1731    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1732        let kind = if self.is_error {
1733            "tool_error"
1734        } else {
1735            "tool_result"
1736        };
1737        writeln!(f, "  [{kind}] {}", self.content)
1738    }
1739}
1740
1741impl AgentOutput {
1742    /// Create an `AgentOutput` with the given value and sensible defaults.
1743    pub fn new(value: Value) -> Self {
1744        Self {
1745            value,
1746            session_id: None,
1747            cost_usd: None,
1748            input_tokens: None,
1749            cache_read_input_tokens: None,
1750            cache_creation_input_tokens: None,
1751            output_tokens: None,
1752            model: None,
1753            duration_ms: 0,
1754            debug_messages: None,
1755            account_id: None,
1756            environment_id: None,
1757        }
1758    }
1759}
1760
1761// ── Log sink ──────────────────────────────────────────────────────
1762
1763/// Sink for streaming log lines from provider invocations in real time.
1764///
1765/// Providers that support live log streaming (e.g. K8s ephemeral) call
1766/// [`log`](LogSink::log) for each output line as it is produced, enabling
1767/// downstream consumers (SSE endpoints, log pushers) to display progress
1768/// before the invocation completes.
1769///
1770/// This trait lives in `ironflow-core` so providers can emit logs without
1771/// depending on higher-level crates.
1772///
1773/// # Examples
1774///
1775/// ```
1776/// use std::sync::{Arc, Mutex};
1777/// use ironflow_core::provider::LogSink;
1778///
1779/// struct VecSink(Mutex<Vec<(String, String)>>);
1780///
1781/// impl LogSink for VecSink {
1782///     fn log(&self, stream: &str, line: &str) {
1783///         self.0.lock().unwrap().push((stream.to_string(), line.to_string()));
1784///     }
1785/// }
1786///
1787/// let sink = Arc::new(VecSink(Mutex::new(Vec::new())));
1788/// sink.log("stdout", "hello world");
1789/// assert_eq!(sink.0.lock().unwrap().len(), 1);
1790/// ```
1791pub trait LogSink: Send + Sync {
1792    /// Emit a single log line on the given stream.
1793    ///
1794    /// `stream` is one of `"stdout"`, `"stderr"`, or `"system"`.
1795    /// Implementations should silently drop lines if the receiver is closed.
1796    fn log(&self, stream: &str, line: &str);
1797}
1798
1799// ── Provider trait ─────────────────────────────────────────────────
1800
1801/// Trait for AI agent backends.
1802///
1803/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
1804/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
1805/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
1806///
1807/// # Examples
1808///
1809/// ```no_run
1810/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
1811///
1812/// struct MyProvider;
1813///
1814/// impl AgentProvider for MyProvider {
1815///     fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
1816///         Box::pin(async move {
1817///             // Call your custom backend here...
1818///             todo!()
1819///         })
1820///     }
1821/// }
1822/// ```
1823pub trait AgentProvider: Send + Sync {
1824    /// Execute a single agent invocation with the given configuration.
1825    ///
1826    /// # Errors
1827    ///
1828    /// Returns [`AgentError`] if the underlying backend process fails,
1829    /// times out, or produces output that does not match the requested schema.
1830    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
1831
1832    /// Execute an agent invocation with real-time log streaming.
1833    ///
1834    /// Providers that support live output streaming should override this
1835    /// method to pipe each output line to the [`LogSink`] as it arrives.
1836    /// The default implementation ignores the sink and delegates to
1837    /// [`invoke`](AgentProvider::invoke).
1838    ///
1839    /// # Errors
1840    ///
1841    /// Returns [`AgentError`] if the underlying backend process fails,
1842    /// times out, or produces output that does not match the requested schema.
1843    fn invoke_with_logs<'a>(
1844        &'a self,
1845        config: &'a AgentConfig,
1846        log_sink: Arc<dyn LogSink>,
1847    ) -> InvokeFuture<'a> {
1848        let _ = log_sink;
1849        self.invoke(config)
1850    }
1851
1852    /// Stop whatever a previous execution of the run `run_id` left running
1853    /// outside the worker process, before the run executes again.
1854    ///
1855    /// The engine calls it before every execution of a run, the first one
1856    /// included. The default does nothing; the Kubernetes ephemeral provider
1857    /// deletes the run's pods and waits until they are gone.
1858    ///
1859    /// # Errors
1860    ///
1861    /// Returns [`AgentError`] when the release fails; the engine then fails
1862    /// the execution with a replayable error.
1863    ///
1864    /// # Examples
1865    ///
1866    /// ```
1867    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
1868    /// use ironflow_core::provider::AgentProvider;
1869    ///
1870    /// # async fn example() -> Result<(), ironflow_core::error::AgentError> {
1871    /// ClaudeCodeProvider::new().release_run("run-1").await?;
1872    /// # Ok(())
1873    /// # }
1874    /// ```
1875    fn release_run<'a>(&'a self, run_id: &'a str) -> ReleaseFuture<'a> {
1876        let _ = run_id;
1877        Box::pin(async { Ok(()) })
1878    }
1879
1880    /// The Provider Account kind whose credential this provider can inject.
1881    ///
1882    /// `None` (the default) means the provider ignores
1883    /// [`AgentConfig::account`] and always runs with its own environment.
1884    ///
1885    /// # Examples
1886    ///
1887    /// ```
1888    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
1889    /// use ironflow_core::provider::AgentProvider;
1890    ///
1891    /// assert_eq!(ClaudeCodeProvider::new().account_kind(), Some("claude_subscription"));
1892    /// ```
1893    fn account_kind(&self) -> Option<&'static str> {
1894        None
1895    }
1896
1897    /// The Provider Account kind of the credential this provider would use
1898    /// for this specific `config`.
1899    ///
1900    /// Defaults to [`AgentProvider::account_kind`]. It differs only for
1901    /// providers that dispatch to other providers (routers): the kind then
1902    /// depends on the provider the config is routed to.
1903    ///
1904    /// # Examples
1905    ///
1906    /// ```
1907    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
1908    /// use ironflow_core::provider::{AgentConfig, AgentProvider};
1909    ///
1910    /// let provider = ClaudeCodeProvider::new();
1911    /// assert_eq!(
1912    ///     provider.account_kind_for(&AgentConfig::new("hi")),
1913    ///     Some("claude_subscription")
1914    /// );
1915    /// ```
1916    fn account_kind_for(&self, _config: &AgentConfig) -> Option<&'static str> {
1917        self.account_kind()
1918    }
1919}
1920
1921// The decision abstraction lives beside `AgentProvider`: re-exported here so
1922// `ironflow_core::provider::DecisionProvider` resolves alongside it, while the
1923// types themselves live in the `decision` module.
1924pub use crate::decision::{
1925    ChoiceAnswer, DecideFuture, DecisionAnswer, DecisionOutput, DecisionProvider, DecisionQuestion,
1926    DecisionRequest, DecisionUsage, NoulAnswer, NoulCriteria, ScoreAnswer,
1927};
1928
1929#[cfg(test)]
1930mod tests {
1931    use super::*;
1932    use serde_json::{from_str, json, to_string};
1933
1934    use crate::auth_proxy::SecretInjection;
1935
1936    fn full_config() -> AgentConfig {
1937        AgentConfig {
1938            system_prompt: Some("you are helpful".to_string()),
1939            append_system_prompt: Some("project rules".to_string()),
1940            prompt: "do stuff".to_string(),
1941            model: Model::OPUS.to_string(),
1942            allowed_tools: vec!["Read".to_string(), "Write".to_string()],
1943            disallowed_tools: vec!["Bash".to_string()],
1944            tool_profile: None,
1945            max_turns: Some(10),
1946            max_parallel_tools: 2,
1947            max_budget_usd: Some(2.5),
1948            working_dir: Some("/tmp".to_string()),
1949            mcp_config: Some("{}".to_string()),
1950            strict_mcp_config: true,
1951            bare: true,
1952            permission_mode: PermissionMode::Auto,
1953            json_schema: Some(r#"{"type":"object"}"#.to_string()),
1954
1955            resume_session_id: None,
1956            session_id: None,
1957            resume_prompt: None,
1958            resume_environment_id: None,
1959            effort: None,
1960            verbose: false,
1961            pod_labels: BTreeMap::new(),
1962            pod: PodSettings::default(),
1963            inputs: Vec::new(),
1964            allow_failure: false,
1965            retry: None,
1966            trace_context: None,
1967            account: None,
1968            proxied_secrets: Vec::new(),
1969            max_capacity_wait: None,
1970            account_name: None,
1971            account_pool: None,
1972            rate_limits: None,
1973            capacity_wait_since: None,
1974            _marker: PhantomData,
1975        }
1976    }
1977
1978    #[test]
1979    fn agent_config_serialize_deserialize_roundtrip() {
1980        let config = full_config();
1981        let json = serde_json::to_string(&config).unwrap();
1982        let back: AgentConfig = serde_json::from_str(&json).unwrap();
1983
1984        assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
1985        assert_eq!(back.prompt, "do stuff");
1986        assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
1987        assert_eq!(back.max_turns, Some(10));
1988        assert_eq!(back.max_parallel_tools, 2);
1989        assert_eq!(back.max_budget_usd, Some(2.5));
1990        assert_eq!(back.working_dir, Some("/tmp".to_string()));
1991        assert_eq!(back.mcp_config, Some("{}".to_string()));
1992        assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
1993    }
1994
1995    #[test]
1996    fn agent_config_with_all_optional_fields_none() {
1997        let config: AgentConfig = AgentConfig {
1998            system_prompt: None,
1999            append_system_prompt: None,
2000            prompt: "hello".to_string(),
2001            model: Model::HAIKU.to_string(),
2002            allowed_tools: vec![],
2003            disallowed_tools: vec![],
2004            tool_profile: None,
2005            max_turns: None,
2006            max_parallel_tools: 4,
2007            max_budget_usd: None,
2008            working_dir: None,
2009            mcp_config: None,
2010            strict_mcp_config: false,
2011            bare: false,
2012            permission_mode: PermissionMode::Default,
2013            json_schema: None,
2014
2015            resume_session_id: None,
2016            session_id: None,
2017            resume_prompt: None,
2018            resume_environment_id: None,
2019            effort: None,
2020            verbose: false,
2021            pod_labels: BTreeMap::new(),
2022            pod: PodSettings::default(),
2023            inputs: Vec::new(),
2024            allow_failure: false,
2025            retry: None,
2026            trace_context: None,
2027            account: None,
2028            proxied_secrets: Vec::new(),
2029            max_capacity_wait: None,
2030            account_name: None,
2031            account_pool: None,
2032            rate_limits: None,
2033            capacity_wait_since: None,
2034            _marker: PhantomData,
2035        };
2036        let json = serde_json::to_string(&config).unwrap();
2037        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2038
2039        assert_eq!(back.system_prompt, None);
2040        assert_eq!(back.prompt, "hello");
2041        assert!(back.allowed_tools.is_empty());
2042        assert_eq!(back.max_turns, None);
2043        assert_eq!(back.max_budget_usd, None);
2044        assert_eq!(back.working_dir, None);
2045        assert_eq!(back.mcp_config, None);
2046        assert_eq!(back.json_schema, None);
2047    }
2048
2049    #[test]
2050    fn agent_output_serialize_deserialize_roundtrip() {
2051        let output = AgentOutput {
2052            value: json!({"key": "value"}),
2053            session_id: Some("sess-abc".to_string()),
2054            cost_usd: Some(0.01),
2055            input_tokens: Some(500),
2056            cache_read_input_tokens: Some(4000),
2057            cache_creation_input_tokens: Some(120),
2058            output_tokens: Some(200),
2059            model: Some("claude-sonnet".to_string()),
2060            duration_ms: 3000,
2061            debug_messages: None,
2062            account_id: None,
2063            environment_id: None,
2064        };
2065        let json = serde_json::to_string(&output).unwrap();
2066        let back: AgentOutput = serde_json::from_str(&json).unwrap();
2067
2068        assert_eq!(back.value, json!({"key": "value"}));
2069        assert_eq!(back.session_id, Some("sess-abc".to_string()));
2070        assert_eq!(back.cost_usd, Some(0.01));
2071        assert_eq!(back.input_tokens, Some(500));
2072        assert_eq!(back.cache_read_input_tokens, Some(4000));
2073        assert_eq!(back.cache_creation_input_tokens, Some(120));
2074        assert_eq!(back.output_tokens, Some(200));
2075        assert_eq!(back.model, Some("claude-sonnet".to_string()));
2076        assert_eq!(back.duration_ms, 3000);
2077    }
2078
2079    #[test]
2080    fn agent_output_deserializes_without_cache_fields() {
2081        let raw = json!({
2082            "value": "ok",
2083            "session_id": null,
2084            "cost_usd": 0.01,
2085            "input_tokens": 10,
2086            "output_tokens": 5,
2087            "model": null,
2088            "duration_ms": 100,
2089            "debug_messages": null
2090        });
2091        let back: AgentOutput = serde_json::from_value(raw).unwrap();
2092        assert_eq!(back.input_tokens, Some(10));
2093        assert_eq!(back.cache_read_input_tokens, None);
2094        assert_eq!(back.cache_creation_input_tokens, None);
2095    }
2096
2097    #[test]
2098    fn agent_config_effort_defaults_to_none_and_is_omitted_from_json() {
2099        let config = AgentConfig::new("p");
2100        assert_eq!(config.effort, None);
2101        let json = to_string(&config).unwrap();
2102        assert!(!json.contains("effort"));
2103    }
2104
2105    #[test]
2106    fn agent_config_effort_builder_serializes_and_survives_change_state() {
2107        let config = AgentConfig::new("p").effort(Effort::High);
2108        assert_eq!(config.effort, Some(Effort::High));
2109        assert!(to_string(&config).unwrap().contains("\"effort\":\"high\""));
2110        let config = config.allow_tool(Tool::Read);
2111        assert_eq!(config.effort, Some(Effort::High));
2112    }
2113
2114    #[test]
2115    fn agent_config_new_has_correct_defaults() {
2116        let config = AgentConfig::new("test prompt");
2117        assert_eq!(config.prompt, "test prompt");
2118        assert_eq!(config.system_prompt, None);
2119        assert_eq!(config.model, Model::SONNET);
2120        assert!(config.allowed_tools.is_empty());
2121        assert_eq!(config.max_turns, None);
2122        assert_eq!(config.max_budget_usd, None);
2123        assert_eq!(config.working_dir, None);
2124        assert_eq!(config.mcp_config, None);
2125        assert!(matches!(config.permission_mode, PermissionMode::Default));
2126        assert_eq!(config.json_schema, None);
2127        assert_eq!(config.resume_session_id, None);
2128        assert!(!config.verbose);
2129    }
2130
2131    #[test]
2132    fn agent_output_new_has_correct_defaults() {
2133        let output = AgentOutput::new(json!("test"));
2134        assert_eq!(output.value, json!("test"));
2135        assert_eq!(output.session_id, None);
2136        assert_eq!(output.cost_usd, None);
2137        assert_eq!(output.input_tokens, None);
2138        assert_eq!(output.cache_read_input_tokens, None);
2139        assert_eq!(output.cache_creation_input_tokens, None);
2140        assert_eq!(output.output_tokens, None);
2141        assert_eq!(output.model, None);
2142        assert_eq!(output.duration_ms, 0);
2143        assert!(output.debug_messages.is_none());
2144    }
2145
2146    #[test]
2147    fn agent_config_resume_session_roundtrip() {
2148        let mut config = AgentConfig::new("test");
2149        config.resume_session_id = Some("sess-xyz".to_string());
2150        let json = serde_json::to_string(&config).unwrap();
2151        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2152        assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
2153    }
2154
2155    #[test]
2156    fn agent_config_session_id_and_resume_prompt_roundtrip() {
2157        let config = AgentConfig::new("test")
2158            .session_id("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d")
2159            .resume_prompt("keep going");
2160        let json = serde_json::to_string(&config).unwrap();
2161        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2162        assert_eq!(
2163            back.session_id.as_deref(),
2164            Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d")
2165        );
2166        assert_eq!(back.resume_prompt.as_deref(), Some("keep going"));
2167    }
2168
2169    #[test]
2170    fn agent_config_without_session_id_fields_deserializes() {
2171        let mut raw = serde_json::to_value(AgentConfig::new("test")).unwrap();
2172        let object = raw.as_object_mut().unwrap();
2173        object.remove("session_id");
2174        object.remove("resume_prompt");
2175        let back: AgentConfig = serde_json::from_value(raw).unwrap();
2176        assert_eq!(back.session_id, None);
2177        assert_eq!(back.resume_prompt, None);
2178    }
2179
2180    #[test]
2181    fn agent_config_session_id_survives_typestate_change() {
2182        let config: AgentConfig = AgentConfig::new("test")
2183            .session_id("sid-1")
2184            .resume_prompt("again")
2185            .allow_tool(Tool::Read)
2186            .into();
2187        assert_eq!(config.session_id.as_deref(), Some("sid-1"));
2188        assert_eq!(config.resume_prompt.as_deref(), Some("again"));
2189    }
2190
2191    #[test]
2192    fn agent_config_new_has_no_session_id() {
2193        let config = AgentConfig::new("test");
2194        assert_eq!(config.session_id, None);
2195        assert_eq!(config.resume_prompt, None);
2196    }
2197
2198    #[test]
2199    fn agent_config_resume_environment_roundtrip() {
2200        let config = AgentConfig::new("test").resume_environment("ironflow-env-1");
2201        let json = serde_json::to_string(&config).unwrap();
2202        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2203        assert_eq!(
2204            back.resume_environment_id,
2205            Some("ironflow-env-1".to_string())
2206        );
2207    }
2208
2209    #[test]
2210    fn agent_config_without_resume_environment_field_deserializes() {
2211        let mut raw = serde_json::to_value(AgentConfig::new("test")).unwrap();
2212        raw.as_object_mut().unwrap().remove("resume_environment_id");
2213        let back: AgentConfig = serde_json::from_value(raw).unwrap();
2214        assert_eq!(back.resume_environment_id, None);
2215    }
2216
2217    #[test]
2218    fn agent_config_resume_environment_survives_typestate_change() {
2219        let config: AgentConfig = AgentConfig::new("test")
2220            .resume_environment("ironflow-env-2")
2221            .allow_tool(Tool::Read)
2222            .into();
2223        assert_eq!(
2224            config.resume_environment_id.as_deref(),
2225            Some("ironflow-env-2")
2226        );
2227    }
2228
2229    #[test]
2230    #[should_panic(expected = "environment_id must not be empty")]
2231    fn agent_config_resume_environment_empty_panics() {
2232        let _ = AgentConfig::new("test").resume_environment("");
2233    }
2234
2235    #[test]
2236    fn agent_output_environment_id_roundtrip_and_default() {
2237        let mut output = AgentOutput::new(json!("ok"));
2238        output.environment_id = Some("ironflow-env-3".to_string());
2239        let json = serde_json::to_string(&output).unwrap();
2240        let back: AgentOutput = serde_json::from_str(&json).unwrap();
2241        assert_eq!(back.environment_id, Some("ironflow-env-3".to_string()));
2242
2243        let raw = json!({
2244            "value": "ok",
2245            "session_id": null,
2246            "cost_usd": null,
2247            "input_tokens": null,
2248            "output_tokens": null,
2249            "model": null,
2250            "duration_ms": 1,
2251            "debug_messages": null
2252        });
2253        let old: AgentOutput = serde_json::from_value(raw).unwrap();
2254        assert_eq!(old.environment_id, None);
2255    }
2256
2257    #[test]
2258    fn agent_output_debug_does_not_panic() {
2259        let output = AgentOutput {
2260            value: json!(null),
2261            session_id: None,
2262            cost_usd: None,
2263            input_tokens: None,
2264            cache_read_input_tokens: None,
2265            cache_creation_input_tokens: None,
2266            output_tokens: None,
2267            model: None,
2268            duration_ms: 0,
2269            debug_messages: None,
2270            account_id: None,
2271            environment_id: None,
2272        };
2273        let debug_str = format!("{:?}", output);
2274        assert!(!debug_str.is_empty());
2275    }
2276
2277    #[test]
2278    fn allow_tool_transitions_to_with_tools() {
2279        let config = AgentConfig::new("test").allow_tool(Tool::Read);
2280        assert_eq!(config.allowed_tools, vec!["Read"]);
2281
2282        // Can add more tools, known or custom.
2283        let config = config
2284            .allow_tool(Tool::Write)
2285            .allow_tool(Tool::Custom("mcp__github__search".to_string()));
2286        assert_eq!(
2287            config.allowed_tools,
2288            vec!["Read", "Write", "mcp__github__search"]
2289        );
2290    }
2291
2292    #[test]
2293    fn output_carries_the_output_type_in_the_typestate() {
2294        #[derive(serde::Deserialize, JsonSchema)]
2295        #[allow(dead_code)]
2296        struct Verdict {
2297            approved: bool,
2298        }
2299
2300        let config: AgentConfig<NoTools, WithSchema<Verdict>> =
2301            AgentConfig::new("review").output::<Verdict>();
2302        assert!(
2303            config
2304                .json_schema
2305                .as_deref()
2306                .is_some_and(|s| s.contains("approved"))
2307        );
2308    }
2309
2310    #[test]
2311    fn output_schema_raw_transitions_to_with_schema() {
2312        let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
2313        assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
2314    }
2315
2316    #[test]
2317    fn with_tools_converts_to_base_type() {
2318        let typed = AgentConfig::new("test").allow_tool(Tool::Read);
2319        let base: AgentConfig = typed.into();
2320        assert_eq!(base.allowed_tools, vec!["Read"]);
2321    }
2322
2323    #[test]
2324    fn with_schema_converts_to_base_type() {
2325        let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
2326        let base: AgentConfig = typed.into();
2327        assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
2328    }
2329
2330    #[test]
2331    fn serde_roundtrip_ignores_marker() {
2332        let config = AgentConfig::new("test").allow_tool(Tool::Read);
2333        let json = serde_json::to_string(&config).unwrap();
2334        assert!(!json.contains("marker"));
2335
2336        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2337        assert_eq!(back.allowed_tools, vec!["Read"]);
2338    }
2339
2340    #[test]
2341    fn bare_defaults_to_false() {
2342        let config = AgentConfig::new("hello");
2343        assert!(!config.bare, "bare must default to false");
2344    }
2345
2346    #[test]
2347    fn bare_builder_sets_flag() {
2348        let config = AgentConfig::new("hello").bare(true);
2349        assert!(config.bare, "bare(true) must enable the flag");
2350
2351        let config = config.bare(false);
2352        assert!(!config.bare, "bare(false) must disable the flag");
2353    }
2354
2355    #[test]
2356    fn bare_serde_default_when_missing() {
2357        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
2358        let config: AgentConfig = serde_json::from_str(raw).unwrap();
2359        assert!(
2360            !config.bare,
2361            "bare must default to false when absent from serialized payload"
2362        );
2363    }
2364
2365    #[test]
2366    fn bare_serde_roundtrip() {
2367        let mut config = AgentConfig::new("hello");
2368        config.bare = true;
2369        let json = serde_json::to_string(&config).unwrap();
2370        assert!(
2371            json.contains("\"bare\":true"),
2372            "serialized form must contain bare:true, got: {json}"
2373        );
2374
2375        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2376        assert!(back.bare, "bare must survive a serde roundtrip");
2377    }
2378
2379    #[test]
2380    fn disallowed_tools_defaults_to_empty() {
2381        let config = AgentConfig::new("hello");
2382        assert!(
2383            config.disallowed_tools.is_empty(),
2384            "disallowed_tools must default to empty"
2385        );
2386    }
2387
2388    #[test]
2389    fn disallowed_tools_builder_replaces_list() {
2390        let config = AgentConfig::new("hello").disallowed_tools([Tool::Write, Tool::Edit]);
2391        assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
2392
2393        // Subsequent call fully replaces the list.
2394        let config = config.disallowed_tools([Tool::Bash]);
2395        assert_eq!(config.disallowed_tools, vec!["Bash"]);
2396
2397        // Empty input clears the list.
2398        let config = config.disallowed_tools([]);
2399        assert!(config.disallowed_tools.is_empty());
2400    }
2401
2402    #[test]
2403    fn disallowed_tools_compatible_with_output() {
2404        #[derive(serde::Deserialize, JsonSchema)]
2405        #[allow(dead_code)]
2406        struct Out {
2407            ok: bool,
2408        }
2409
2410        // Typestate compile check: .disallowed_tools(...) must be callable
2411        // before AND after .output::<T>() because it lives on
2412        // impl<Tools, Schema>, not impl<Tools, NoSchema>.
2413        let before: AgentConfig<NoTools, WithSchema<Out>> = AgentConfig::new("classify")
2414            .disallowed_tools([Tool::Write, Tool::Edit])
2415            .output::<Out>();
2416        assert_eq!(before.disallowed_tools, vec!["Write", "Edit"]);
2417        assert!(before.json_schema.is_some());
2418
2419        let after: AgentConfig<NoTools, WithSchema<Out>> = AgentConfig::new("classify")
2420            .output::<Out>()
2421            .disallowed_tools([Tool::Write]);
2422        assert_eq!(after.disallowed_tools, vec!["Write"]);
2423        assert!(after.json_schema.is_some());
2424    }
2425
2426    #[test]
2427    fn disallowed_tools_serde_default_when_missing() {
2428        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
2429        let config: AgentConfig = serde_json::from_str(raw).unwrap();
2430        assert!(
2431            config.disallowed_tools.is_empty(),
2432            "disallowed_tools must default to empty when absent from serialized payload"
2433        );
2434    }
2435
2436    #[test]
2437    fn disallowed_tools_serde_roundtrip() {
2438        let config = AgentConfig::new("hello").disallowed_tools([Tool::Write, Tool::Edit]);
2439        let json = serde_json::to_string(&config).unwrap();
2440        assert!(
2441            json.contains("\"disallowed_tools\":[\"Write\",\"Edit\"]"),
2442            "serialized form must contain the disallowed_tools array, got: {json}"
2443        );
2444
2445        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2446        assert_eq!(back.disallowed_tools, vec!["Write", "Edit"]);
2447    }
2448
2449    #[test]
2450    fn pod_labels_defaults_to_empty() {
2451        let config = AgentConfig::new("test");
2452        assert!(config.pod_labels.is_empty());
2453    }
2454
2455    #[test]
2456    fn pod_label_builder_adds_entry() {
2457        let config = AgentConfig::new("test").pod_label("k", "v");
2458        assert_eq!(config.pod_labels.len(), 1);
2459        assert_eq!(config.pod_labels["k"], "v");
2460    }
2461
2462    #[test]
2463    fn pod_labels_builder_replaces_map() {
2464        let config = AgentConfig::new("test").pod_label("old", "value");
2465        let mut new_map = BTreeMap::new();
2466        new_map.insert("new".to_string(), "value".to_string());
2467        let config = config.pod_labels(new_map);
2468        assert_eq!(config.pod_labels.len(), 1);
2469        assert_eq!(config.pod_labels["new"], "value");
2470        assert!(!config.pod_labels.contains_key("old"));
2471    }
2472
2473    #[test]
2474    fn pod_labels_serde_default_when_missing() {
2475        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
2476        let config: AgentConfig = serde_json::from_str(raw).unwrap();
2477        assert!(
2478            config.pod_labels.is_empty(),
2479            "pod_labels must default to empty when absent from serialized payload"
2480        );
2481    }
2482
2483    #[test]
2484    fn pod_labels_serde_skip_when_empty() {
2485        let config = AgentConfig::new("hello");
2486        let json = serde_json::to_string(&config).unwrap();
2487        assert!(
2488            !json.contains("pod_labels"),
2489            "empty pod_labels must be skipped during serialization, got: {json}"
2490        );
2491    }
2492
2493    #[test]
2494    fn pod_labels_serde_roundtrip() {
2495        let config = AgentConfig::new("hello")
2496            .pod_label("ironflow.io/network-profile", "grafana-only")
2497            .pod_label("team", "observability");
2498        let json = serde_json::to_string(&config).unwrap();
2499        assert!(
2500            json.contains("pod_labels"),
2501            "non-empty pod_labels must be present in serialized form, got: {json}"
2502        );
2503
2504        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2505        assert_eq!(back.pod_labels.len(), 2);
2506        assert_eq!(
2507            back.pod_labels["ironflow.io/network-profile"],
2508            "grafana-only"
2509        );
2510        assert_eq!(back.pod_labels["team"], "observability");
2511    }
2512
2513    // ── Pod settings (K8s) ────────────────────────────────────────
2514
2515    #[test]
2516    fn k8s_env_from_secret_replaces_same_var() {
2517        let config = AgentConfig::new("x")
2518            .env_from_secret("TOKEN", "old-secret", "a")
2519            .env_from_secret("OTHER", "other", "b")
2520            .env_from_secret("TOKEN", "new-secret", "c");
2521        assert_eq!(config.pod.secret_env.len(), 2);
2522        assert_eq!(config.pod.secret_env[0].name, "TOKEN");
2523        assert_eq!(config.pod.secret_env[0].secret, "new-secret");
2524        assert_eq!(config.pod.secret_env[0].key, "c");
2525        assert_eq!(config.pod.secret_env[1].name, "OTHER");
2526    }
2527
2528    fn github_secret(value: &str, host: &str) -> ProxiedSecret {
2529        ProxiedSecret {
2530            name: "GITHUB_TOKEN".to_string(),
2531            env: "GITHUB_TOKEN".to_string(),
2532            value: value.to_string(),
2533            injection: SecretInjection::Bearer,
2534            hosts: vec![host.to_string()],
2535        }
2536    }
2537
2538    #[test]
2539    fn proxied_secret_replaces_same_env() {
2540        let gitlab = ProxiedSecret {
2541            name: "GITLAB_TOKEN".to_string(),
2542            env: "GITLAB_TOKEN".to_string(),
2543            value: "glpat-x".to_string(),
2544            injection: SecretInjection::PrivateToken,
2545            hosts: vec!["gitlab.com".to_string()],
2546        };
2547        let config = AgentConfig::new("x")
2548            .proxied_secret(github_secret("ghp_old", "api.github.com"))
2549            .proxied_secret(gitlab)
2550            .proxied_secret(github_secret("ghp_new", "*.github.com"));
2551        assert_eq!(config.proxied_secrets.len(), 2);
2552        assert_eq!(config.proxied_secrets[0].env, "GITHUB_TOKEN");
2553        assert_eq!(config.proxied_secrets[0].value, "ghp_new");
2554        assert_eq!(config.proxied_secrets[0].hosts, ["*.github.com"]);
2555        assert_eq!(config.proxied_secrets[1].env, "GITLAB_TOKEN");
2556    }
2557
2558    #[test]
2559    #[should_panic(expected = "invalid proxied secret")]
2560    fn proxied_secret_invalid_host_panics() {
2561        let _ = AgentConfig::new("x").proxied_secret(github_secret("ghp_x", ".*github.com"));
2562    }
2563
2564    #[test]
2565    fn proxied_secret_is_never_serialized() {
2566        let config = AgentConfig::new("x")
2567            .proxied_secret(github_secret("ghp_never_serialized", "api.github.com"));
2568        let json = to_string(&config).unwrap();
2569        assert!(!json.contains("ghp_never_serialized"), "{json}");
2570        assert!(!json.contains("proxied_secrets"), "{json}");
2571        let back: AgentConfig = from_str(&json).unwrap();
2572        assert!(back.proxied_secrets.is_empty());
2573    }
2574
2575    #[test]
2576    fn k8s_runtime_class_setter_and_serde_round_trip() {
2577        let config = AgentConfig::new("x").runtime_class("gvisor");
2578        assert_eq!(config.pod.runtime_class.as_deref(), Some("gvisor"));
2579        let json = serde_json::to_string(&config).unwrap();
2580        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2581        assert_eq!(back.pod.runtime_class.as_deref(), Some("gvisor"));
2582        assert!(AgentConfig::new("x").pod.runtime_class.is_none());
2583    }
2584
2585    #[test]
2586    fn k8s_pod_settings_builders() {
2587        let config = AgentConfig::new("x")
2588            .service_account("reader")
2589            .read_only_pvc("repos", "/data/repos")
2590            .read_only_host_path("/srv", "/data/srv")
2591            .read_only_config_map("cm", "/data/cm")
2592            .managed_settings("locked")
2593            .egress_profile("gitlab");
2594        assert_eq!(config.pod.service_account.as_deref(), Some("reader"));
2595        assert_eq!(config.pod.read_only_volumes.len(), 3);
2596        assert_eq!(
2597            config.pod.read_only_volumes[0].source,
2598            PodVolumeSource::PersistentVolumeClaim {
2599                claim_name: "repos".to_string(),
2600            }
2601        );
2602        assert_eq!(config.pod.read_only_volumes[0].mount_path, "/data/repos");
2603        assert_eq!(
2604            config.pod.read_only_volumes[1].source,
2605            PodVolumeSource::HostPath {
2606                path: "/srv".to_string(),
2607            }
2608        );
2609        assert_eq!(
2610            config.pod.read_only_volumes[2].source,
2611            PodVolumeSource::ConfigMap {
2612                name: "cm".to_string(),
2613            }
2614        );
2615        assert_eq!(config.pod.managed_settings.as_deref(), Some("locked"));
2616        assert_eq!(config.pod_labels[LABEL_EGRESS_PROFILE], "gitlab");
2617    }
2618
2619    #[test]
2620    fn k8s_pvc_volume_builder_pushes_volume() {
2621        let config = AgentConfig::new("x")
2622            .pvc_volume("ws", "/work", Some("a/b"), true)
2623            .pvc_volume("ws", "/other", None, false);
2624        assert_eq!(config.pod.pvc_volumes.len(), 2);
2625        assert_eq!(config.pod.pvc_volumes[0].claim_name, "ws");
2626        assert_eq!(config.pod.pvc_volumes[0].mount_path, "/work");
2627        assert_eq!(config.pod.pvc_volumes[0].sub_path.as_deref(), Some("a/b"));
2628        assert!(config.pod.pvc_volumes[0].read_only);
2629        assert_eq!(config.pod.pvc_volumes[1].sub_path, None);
2630        assert!(!config.pod.pvc_volumes[1].read_only);
2631    }
2632
2633    #[test]
2634    fn k8s_without_provider_volumes_sets_flag() {
2635        assert!(!AgentConfig::new("x").pod.without_provider_volumes);
2636        let config = AgentConfig::new("x").without_provider_volumes();
2637        assert!(config.pod.without_provider_volumes);
2638    }
2639
2640    #[test]
2641    #[should_panic(expected = "sub_path")]
2642    fn k8s_pvc_volume_rejects_parent_sub_path() {
2643        let _ = AgentConfig::new("x").pvc_volume("ws", "/work", Some("../x"), false);
2644    }
2645
2646    #[test]
2647    #[should_panic(expected = "sub_path")]
2648    fn k8s_pvc_volume_rejects_absolute_sub_path() {
2649        let _ = AgentConfig::new("x").pvc_volume("ws", "/work", Some("/abs"), false);
2650    }
2651
2652    #[test]
2653    #[should_panic(expected = "sub_path")]
2654    fn k8s_pvc_volume_rejects_empty_segment_sub_path() {
2655        let _ = AgentConfig::new("x").pvc_volume("ws", "/work", Some("a//b"), false);
2656    }
2657
2658    #[test]
2659    #[should_panic(expected = "claim_name")]
2660    fn k8s_pvc_volume_rejects_empty_claim() {
2661        let _ = AgentConfig::new("x").pvc_volume("", "/work", None, false);
2662    }
2663
2664    #[test]
2665    fn k8s_run_scope_sets_sanitized_labels() {
2666        let config = AgentConfig::new("x").run_scope("run-1", "fix bug/42");
2667        assert_eq!(config.pod_labels[LABEL_RUN_ID], "run-1");
2668        assert_eq!(
2669            config.pod_labels[LABEL_STEP],
2670            sanitize_label_value("fix bug/42")
2671        );
2672        assert!(config.pod_labels[LABEL_STEP].starts_with("fix-bug-42-"));
2673    }
2674
2675    #[test]
2676    fn k8s_pod_settings_serde_skip_when_empty() {
2677        let json = serde_json::to_value(AgentConfig::new("hello")).unwrap();
2678        assert!(json.get("pod").is_none(), "empty pod must be skipped");
2679    }
2680
2681    #[test]
2682    fn k8s_pod_settings_serde_roundtrip() {
2683        let config = AgentConfig::new("hello")
2684            .env_from_secret("TOKEN", "s", "k")
2685            .service_account("sa")
2686            .read_only_pvc("repos", "/data/repos")
2687            .managed_settings("locked");
2688        let json = serde_json::to_string(&config).unwrap();
2689        let back: AgentConfig = serde_json::from_str(&json).unwrap();
2690        assert_eq!(back.pod, config.pod);
2691    }
2692
2693    #[test]
2694    fn k8s_pod_settings_serde_default_when_missing() {
2695        let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
2696        let config: AgentConfig = serde_json::from_str(raw).unwrap();
2697        assert!(config.pod.is_empty());
2698    }
2699
2700    // ── LogSink tests ─────────────────────────────────────────────
2701
2702    use crate::test_support::VecSink;
2703
2704    #[test]
2705    fn log_sink_collects_lines() {
2706        let sink = VecSink::new();
2707        sink.log("stdout", "line 1");
2708        sink.log("stderr", "err!");
2709        sink.log("system", "done");
2710
2711        let lines = sink.0.lock().unwrap();
2712        assert_eq!(lines.len(), 3);
2713        assert_eq!(lines[0], ("stdout".to_string(), "line 1".to_string()));
2714        assert_eq!(lines[1], ("stderr".to_string(), "err!".to_string()));
2715        assert_eq!(lines[2], ("system".to_string(), "done".to_string()));
2716    }
2717
2718    #[test]
2719    fn log_sink_arc_is_clone_and_send() {
2720        let sink: Arc<dyn LogSink> = VecSink::new();
2721        let cloned = sink.clone();
2722        sink.log("stdout", "from original");
2723        cloned.log("stdout", "from clone");
2724    }
2725
2726    // ── invoke_with_logs default impl ─────────────────────────────
2727
2728    struct FixedProvider {
2729        output: AgentOutput,
2730    }
2731
2732    impl AgentProvider for FixedProvider {
2733        fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
2734            Box::pin(async {
2735                Ok(AgentOutput {
2736                    value: self.output.value.clone(),
2737                    session_id: self.output.session_id.clone(),
2738                    cost_usd: self.output.cost_usd,
2739                    input_tokens: self.output.input_tokens,
2740                    cache_read_input_tokens: self.output.cache_read_input_tokens,
2741                    cache_creation_input_tokens: self.output.cache_creation_input_tokens,
2742                    output_tokens: self.output.output_tokens,
2743                    model: self.output.model.clone(),
2744                    duration_ms: self.output.duration_ms,
2745                    debug_messages: None,
2746                    account_id: None,
2747                    environment_id: None,
2748                })
2749            })
2750        }
2751    }
2752
2753    #[tokio::test]
2754    async fn release_run_default_does_nothing() {
2755        let provider = FixedProvider {
2756            output: AgentOutput::new(json!("ok")),
2757        };
2758        assert!(provider.release_run("run-1").await.is_ok());
2759        assert!(provider.release_run("").await.is_ok());
2760    }
2761
2762    #[tokio::test]
2763    async fn invoke_with_logs_default_delegates_to_invoke() {
2764        let provider = FixedProvider {
2765            output: AgentOutput::new(json!("ok")),
2766        };
2767        let config = AgentConfig::new("test");
2768        let sink: Arc<dyn LogSink> = VecSink::new();
2769
2770        let result = provider.invoke_with_logs(&config, sink.clone()).await;
2771        assert!(result.is_ok());
2772        assert_eq!(result.unwrap().value, json!("ok"));
2773    }
2774
2775    #[tokio::test]
2776    async fn invoke_with_logs_default_ignores_sink() {
2777        let provider = FixedProvider {
2778            output: AgentOutput::new(json!("ok")),
2779        };
2780        let config = AgentConfig::new("test");
2781        let sink = VecSink::new();
2782
2783        let _ = provider
2784            .invoke_with_logs(&config, sink.clone() as Arc<dyn LogSink>)
2785            .await;
2786
2787        let lines = sink.0.lock().unwrap();
2788        assert!(lines.is_empty(), "default impl should not emit any logs");
2789    }
2790
2791    #[test]
2792    fn max_parallel_tools_defaults_to_four() {
2793        assert_eq!(AgentConfig::new("hi").max_parallel_tools, 4);
2794    }
2795
2796    #[test]
2797    fn max_parallel_tools_builder_sets_value() {
2798        let config = AgentConfig::new("hi").max_parallel_tools(2);
2799        assert_eq!(config.max_parallel_tools, 2);
2800    }
2801
2802    #[test]
2803    #[should_panic(expected = "max_parallel_tools must be greater than 0")]
2804    fn max_parallel_tools_zero_panics() {
2805        let _ = AgentConfig::new("hi").max_parallel_tools(0);
2806    }
2807
2808    #[test]
2809    fn max_parallel_tools_missing_from_json_defaults_to_four() {
2810        let json = serde_json::to_value(AgentConfig::new("hi")).unwrap();
2811        let mut obj = json.as_object().unwrap().clone();
2812        obj.remove("max_parallel_tools");
2813        let back: AgentConfig = serde_json::from_value(Value::Object(obj)).unwrap();
2814        assert_eq!(back.max_parallel_tools, 4);
2815    }
2816}