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