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