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