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