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