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