Skip to main content

ironflow_core/
provider.rs

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