Skip to main content

ironflow_core/
provider.rs

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