Skip to main content

ironflow_core/
provider.rs

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