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