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