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;
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use crate::error::AgentError;
28use crate::operations::agent::{Model, PermissionMode};
29
30/// Boxed future returned by [`AgentProvider::invoke`].
31pub type InvokeFuture<'a> =
32 Pin<Box<dyn Future<Output = Result<AgentOutput, AgentError>> + Send + 'a>>;
33
34// ── Typestate markers ──────────────────────────────────────────────
35
36/// Marker: no tools have been added via the builder.
37#[derive(Debug, Clone, Copy)]
38pub struct NoTools;
39
40/// Marker: at least one tool has been added via [`AgentConfig::allow_tool`].
41#[derive(Debug, Clone, Copy)]
42pub struct WithTools;
43
44/// Marker: no JSON schema has been set via the builder.
45#[derive(Debug, Clone, Copy)]
46pub struct NoSchema;
47
48/// Marker: a JSON schema has been set via [`AgentConfig::output`] or
49/// [`AgentConfig::output_schema_raw`].
50#[derive(Debug, Clone, Copy)]
51pub struct WithSchema;
52
53// ── AgentConfig ────────────────────────────────────────────────────
54
55/// Serializable configuration passed to an [`AgentProvider`] for a single invocation.
56///
57/// Built by [`Agent::run`](crate::operations::agent::Agent::run) from the builder state.
58/// Provider implementations translate these fields into whatever format the underlying
59/// backend expects.
60///
61/// # Typestate: tools vs structured output
62///
63/// Claude CLI has a [known bug](https://github.com/anthropics/claude-code/issues/18536)
64/// where combining `--json-schema` with `--allowedTools` always returns
65/// `structured_output: null`. To prevent this at compile time, [`allow_tool`](Self::allow_tool)
66/// and [`output`](Self::output) / [`output_schema_raw`](Self::output_schema_raw) are mutually
67/// exclusive: using one removes the other from the available API.
68///
69/// ```
70/// use ironflow_core::provider::AgentConfig;
71///
72/// // OK: tools only
73/// let _ = AgentConfig::new("search").allow_tool("WebSearch");
74///
75/// // OK: structured output only
76/// let _ = AgentConfig::new("classify").output_schema_raw(r#"{"type":"object"}"#);
77/// ```
78///
79/// ```compile_fail
80/// use ironflow_core::provider::AgentConfig;
81/// // COMPILE ERROR: cannot add tools after setting structured output
82/// let _ = AgentConfig::new("x").output_schema_raw("{}").allow_tool("Read");
83/// ```
84///
85/// ```compile_fail
86/// use ironflow_core::provider::AgentConfig;
87/// // COMPILE ERROR: cannot set structured output after adding tools
88/// let _ = AgentConfig::new("x").allow_tool("Read").output_schema_raw("{}");
89/// ```
90///
91/// **Workaround**: split the work into two steps -- one agent with tools to
92/// gather data, then a second agent with `.output::<T>()` to structure the result.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(bound(serialize = "", deserialize = ""))]
95#[non_exhaustive]
96pub struct AgentConfig<Tools = NoTools, Schema = NoSchema> {
97 /// Optional system prompt that sets the agent's persona or constraints.
98 pub system_prompt: Option<String>,
99
100 /// The user prompt - the main instruction to the agent.
101 pub prompt: String,
102
103 /// Which model to use for this invocation.
104 ///
105 /// Accepts any string. Use [`Model`] constants for well-known Claude models
106 /// (e.g. `Model::SONNET`), or pass a custom identifier for other providers.
107 #[serde(default = "default_model")]
108 pub model: String,
109
110 /// Allowlist of tool names the agent may invoke (empty = provider default).
111 #[serde(default)]
112 pub allowed_tools: Vec<String>,
113
114 /// Denylist of tool names the agent MUST NOT invoke.
115 ///
116 /// Maps to `--disallowedTools` on the Claude CLI. Unlike
117 /// [`allowed_tools`](Self::allowed_tools), this does **not** activate any
118 /// tools; it only filters out tools that would otherwise be loaded by
119 /// default. As such, it is safe to combine with structured output
120 /// ([`output`](Self::output)) without triggering the Claude CLI bug that
121 /// affects `--json-schema` + `--allowedTools`.
122 #[serde(default)]
123 pub disallowed_tools: Vec<String>,
124
125 /// Maximum number of agentic turns before the provider should stop.
126 pub max_turns: Option<u32>,
127
128 /// Maximum spend in USD for this single invocation.
129 pub max_budget_usd: Option<f64>,
130
131 /// Working directory for the agent process.
132 pub working_dir: Option<String>,
133
134 /// Path to an MCP server configuration file.
135 pub mcp_config: Option<String>,
136
137 /// When `true`, pass `--strict-mcp-config` to the Claude CLI so it only
138 /// loads MCP servers from [`mcp_config`](Self::mcp_config) and ignores
139 /// any global/user MCP configuration (e.g. `~/.claude.json`).
140 ///
141 /// Useful to prevent global MCP servers from leaking tools into steps
142 /// that request `structured_output`, which triggers the Claude CLI bug
143 /// where `--json-schema` combined with any active tool returns
144 /// `structured_output: null`. See
145 /// <https://github.com/anthropics/claude-code/issues/18536>.
146 ///
147 /// Combine with `mcp_config` set to a file containing
148 /// `{"mcpServers":{}}` to disable every MCP server for the invocation.
149 #[serde(default)]
150 pub strict_mcp_config: bool,
151
152 /// When `true`, pass `--bare` to Claude CLI. Bare mode disables:
153 /// - auto-memory (automatic creation of `~/.claude/.../memory/*.md` files)
154 /// - `CLAUDE.md` auto-discovery (no global/project `CLAUDE.md` loaded)
155 /// - hooks, LSP, plugin sync, attribution, background prefetches
156 ///
157 /// Recommended for orchestrator agents that should not have any implicit
158 /// side effects on the user's filesystem or inherit user-level context.
159 ///
160 /// # Authentication requirement
161 ///
162 /// `--bare` is **only compatible with an Anthropic API key**
163 /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
164 /// OAuth authentication (`claude /login` / keychain-stored credentials),
165 /// because bare mode disables keychain reads.
166 #[serde(default)]
167 pub bare: bool,
168
169 /// Permission mode controlling how the agent handles tool-use approvals.
170 #[serde(default)]
171 pub permission_mode: PermissionMode,
172
173 /// Optional JSON Schema string. When set, the provider should request
174 /// structured (typed) output from the model.
175 #[serde(alias = "output_schema")]
176 pub json_schema: Option<String>,
177
178 /// Optional session ID to resume a previous conversation.
179 ///
180 /// When set, the provider should continue the conversation from the
181 /// specified session rather than starting a new one.
182 pub resume_session_id: Option<String>,
183
184 /// Enable verbose/debug mode to capture the full conversation trace.
185 ///
186 /// When `true`, the provider uses streaming output (`stream-json`) to
187 /// record every assistant message and tool call. The resulting
188 /// [`AgentOutput::debug_messages`] field will contain the conversation
189 /// trace for inspection.
190 #[serde(default)]
191 pub verbose: bool,
192
193 /// Custom labels applied to the pod (K8s providers only).
194 ///
195 /// Non-K8s providers ignore this field. Labels are merged with the
196 /// provider-level pod labels and the hardcoded ironflow labels. In case
197 /// of conflict, hardcoded labels always win, then invocation-level labels,
198 /// then provider-level defaults.
199 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
200 pub pod_labels: BTreeMap<String, String>,
201
202 /// Zero-sized typestate marker (not serialized).
203 #[serde(skip)]
204 pub(crate) _marker: PhantomData<(Tools, Schema)>,
205}
206
207fn default_model() -> String {
208 Model::SONNET.to_string()
209}
210
211// ── Constructor (base type only) ───────────────────────────────────
212
213impl AgentConfig {
214 /// Create an `AgentConfig` with required fields and defaults for the rest.
215 pub fn new(prompt: &str) -> Self {
216 Self {
217 system_prompt: None,
218 prompt: prompt.to_string(),
219 model: Model::SONNET.to_string(),
220 allowed_tools: Vec::new(),
221 disallowed_tools: Vec::new(),
222 max_turns: None,
223 max_budget_usd: None,
224 working_dir: None,
225 mcp_config: None,
226 strict_mcp_config: false,
227 bare: false,
228 permission_mode: PermissionMode::Default,
229 json_schema: None,
230 resume_session_id: None,
231 verbose: false,
232 pod_labels: BTreeMap::new(),
233 _marker: PhantomData,
234 }
235 }
236}
237
238// ── Methods available on ALL typestate variants ────────────────────
239
240impl<Tools, Schema> AgentConfig<Tools, Schema> {
241 /// Set the system prompt.
242 pub fn system_prompt(mut self, prompt: &str) -> Self {
243 self.system_prompt = Some(prompt.to_string());
244 self
245 }
246
247 /// Set the model name.
248 pub fn model(mut self, model: &str) -> Self {
249 self.model = model.to_string();
250 self
251 }
252
253 /// Set the maximum budget in USD.
254 pub fn max_budget_usd(mut self, budget: f64) -> Self {
255 self.max_budget_usd = Some(budget);
256 self
257 }
258
259 /// Set the maximum number of turns.
260 pub fn max_turns(mut self, turns: u32) -> Self {
261 self.max_turns = Some(turns);
262 self
263 }
264
265 /// Set the working directory.
266 pub fn working_dir(mut self, dir: &str) -> Self {
267 self.working_dir = Some(dir.to_string());
268 self
269 }
270
271 /// Set the permission mode.
272 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
273 self.permission_mode = mode;
274 self
275 }
276
277 /// Enable verbose/debug mode.
278 pub fn verbose(mut self, enabled: bool) -> Self {
279 self.verbose = enabled;
280 self
281 }
282
283 /// Set the MCP server configuration file path.
284 pub fn mcp_config(mut self, config: &str) -> Self {
285 self.mcp_config = Some(config.to_string());
286 self
287 }
288
289 /// Enable strict MCP config mode.
290 ///
291 /// When `true`, the Claude CLI is invoked with `--strict-mcp-config`,
292 /// which disables loading of any MCP server defined outside the
293 /// [`mcp_config`](Self::mcp_config) file (the global `~/.claude.json`
294 /// and user-level configs are ignored).
295 ///
296 /// This is the recommended way to prevent global MCP servers from
297 /// silently injecting tools into a structured-output step and
298 /// triggering the Claude CLI bug that returns `structured_output: null`
299 /// whenever any tool is active. See
300 /// <https://github.com/anthropics/claude-code/issues/18536>.
301 ///
302 /// # Examples
303 ///
304 /// ```
305 /// use ironflow_core::provider::AgentConfig;
306 /// use schemars::JsonSchema;
307 ///
308 /// #[derive(serde::Deserialize, JsonSchema)]
309 /// struct Out { ok: bool }
310 ///
311 /// // Isolate the step from any global MCP server so structured output works.
312 /// let config = AgentConfig::new("classify this")
313 /// .strict_mcp_config(true)
314 /// .mcp_config(r#"{"mcpServers":{}}"#)
315 /// .output::<Out>();
316 /// ```
317 pub fn strict_mcp_config(mut self, strict: bool) -> Self {
318 self.strict_mcp_config = strict;
319 self
320 }
321
322 /// Enable bare mode (minimal Claude Code environment, see `--bare`).
323 ///
324 /// When `true`, the Claude CLI is invoked with `--bare`, which disables:
325 /// - auto-memory (no automatic `~/.claude/.../memory/*.md` file creation)
326 /// - `CLAUDE.md` auto-discovery (neither global nor project-level)
327 /// - hooks, LSP, plugin sync, attribution, background prefetches,
328 /// keychain reads
329 ///
330 /// Sets `CLAUDE_CODE_SIMPLE=1` in the child process.
331 ///
332 /// Recommended for orchestrator steps that should not have any implicit
333 /// side effects on the user's filesystem or inherit user-level context
334 /// (email, preferences, etc.).
335 ///
336 /// # Authentication requirement
337 ///
338 /// `--bare` is **only compatible with an Anthropic API key**
339 /// (`ANTHROPIC_API_KEY` environment variable). It does **not** work with
340 /// OAuth authentication (`claude /login` / keychain-stored credentials),
341 /// because bare mode disables keychain reads. Invoking a bare agent on an
342 /// OAuth-only host will fail with an authentication error.
343 ///
344 /// # Examples
345 ///
346 /// ```
347 /// use ironflow_core::provider::AgentConfig;
348 ///
349 /// let config = AgentConfig::new("classify this")
350 /// .bare(true);
351 /// ```
352 pub fn bare(mut self, enabled: bool) -> Self {
353 self.bare = enabled;
354 self
355 }
356
357 /// Replace the entire disallowed-tools list.
358 ///
359 /// Maps to `--disallowedTools` on the Claude CLI. This method is available
360 /// on **every** typestate variant (including
361 /// [`AgentConfig<NoTools, WithSchema>`]) because, unlike
362 /// [`allow_tool`](AgentConfig::allow_tool), `disallowed_tools` does not
363 /// activate any tool -- it only filters out tools that would otherwise be
364 /// loaded by default.
365 ///
366 /// As such, it is safe to combine with structured output:
367 ///
368 /// # Examples
369 ///
370 /// ```
371 /// use ironflow_core::provider::AgentConfig;
372 /// use schemars::JsonSchema;
373 ///
374 /// #[derive(serde::Deserialize, JsonSchema)]
375 /// struct Out { ok: bool }
376 ///
377 /// let config = AgentConfig::new("classify this")
378 /// .disallowed_tools(["Write", "Edit"])
379 /// .output::<Out>();
380 /// ```
381 pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
382 where
383 I: IntoIterator<Item = S>,
384 S: Into<String>,
385 {
386 self.disallowed_tools = tools.into_iter().map(Into::into).collect();
387 self
388 }
389
390 /// Add a single custom pod label (K8s providers only).
391 ///
392 /// Can be called multiple times. Non-K8s providers ignore this field.
393 ///
394 /// # Examples
395 ///
396 /// ```
397 /// use ironflow_core::provider::AgentConfig;
398 ///
399 /// let config = AgentConfig::new("analyze")
400 /// .pod_label("ironflow.io/network-profile", "grafana-only")
401 /// .pod_label("team", "observability");
402 /// ```
403 pub fn pod_label(mut self, key: &str, value: &str) -> Self {
404 self.pod_labels.insert(key.to_string(), value.to_string());
405 self
406 }
407
408 /// Replace the entire custom pod labels map (K8s providers only).
409 ///
410 /// Non-K8s providers ignore this field.
411 ///
412 /// # Examples
413 ///
414 /// ```
415 /// use std::collections::BTreeMap;
416 /// use ironflow_core::provider::AgentConfig;
417 ///
418 /// let mut labels = BTreeMap::new();
419 /// labels.insert("env".to_string(), "staging".to_string());
420 /// let config = AgentConfig::new("deploy").pod_labels(labels);
421 /// ```
422 pub fn pod_labels(mut self, labels: BTreeMap<String, String>) -> Self {
423 self.pod_labels = labels;
424 self
425 }
426
427 /// Set a session ID to resume a previous conversation.
428 pub fn resume(mut self, session_id: &str) -> Self {
429 self.resume_session_id = Some(session_id.to_string());
430 self
431 }
432
433 /// Convert to a different typestate by moving all fields.
434 ///
435 /// Safe because the marker is a zero-sized [`PhantomData`] -- no
436 /// runtime data changes.
437 fn change_state<T2, S2>(self) -> AgentConfig<T2, S2> {
438 AgentConfig {
439 system_prompt: self.system_prompt,
440 prompt: self.prompt,
441 model: self.model,
442 allowed_tools: self.allowed_tools,
443 disallowed_tools: self.disallowed_tools,
444 max_turns: self.max_turns,
445 max_budget_usd: self.max_budget_usd,
446 working_dir: self.working_dir,
447 mcp_config: self.mcp_config,
448 strict_mcp_config: self.strict_mcp_config,
449 bare: self.bare,
450 permission_mode: self.permission_mode,
451 json_schema: self.json_schema,
452 resume_session_id: self.resume_session_id,
453 verbose: self.verbose,
454 pod_labels: self.pod_labels,
455 _marker: PhantomData,
456 }
457 }
458}
459
460// ── allow_tool: only when no schema is set ─────────────────────────
461
462impl<Tools> AgentConfig<Tools, NoSchema> {
463 /// Add an allowed tool.
464 ///
465 /// Can be called multiple times to allow several tools. Returns an
466 /// [`AgentConfig<WithTools, NoSchema>`], which **cannot** call
467 /// [`output`](AgentConfig::output) or [`output_schema_raw`](AgentConfig::output_schema_raw).
468 ///
469 /// This restriction exists because Claude CLI has a
470 /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
471 /// where `--json-schema` combined with `--allowedTools` always returns
472 /// `structured_output: null`.
473 ///
474 /// **Workaround**: use two sequential agent steps -- one with tools to
475 /// gather data, then one with `.output::<T>()` to structure the result.
476 ///
477 /// # Examples
478 ///
479 /// ```
480 /// use ironflow_core::provider::AgentConfig;
481 ///
482 /// let config = AgentConfig::new("search the web")
483 /// .allow_tool("WebSearch")
484 /// .allow_tool("WebFetch");
485 /// ```
486 ///
487 /// ```compile_fail
488 /// use ironflow_core::provider::AgentConfig;
489 /// // ERROR: cannot set structured output after adding tools
490 /// let _ = AgentConfig::new("x")
491 /// .allow_tool("Read")
492 /// .output_schema_raw(r#"{"type":"object"}"#);
493 /// ```
494 pub fn allow_tool(mut self, tool: &str) -> AgentConfig<WithTools, NoSchema> {
495 self.allowed_tools.push(tool.to_string());
496 self.change_state()
497 }
498}
499
500// ── output: only when no tools are set ─────────────────────────────
501
502impl<Schema> AgentConfig<NoTools, Schema> {
503 /// Set structured output from a Rust type implementing [`JsonSchema`].
504 ///
505 /// The schema is serialized once at build time. When set, the provider
506 /// will request typed output conforming to this schema.
507 ///
508 /// **Important:** structured output requires `max_turns >= 2`.
509 ///
510 /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
511 /// call [`allow_tool`](AgentConfig::allow_tool).
512 ///
513 /// This restriction exists because Claude CLI has a
514 /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
515 /// where `--json-schema` combined with `--allowedTools` always returns
516 /// `structured_output: null`.
517 ///
518 /// **Workaround**: use two sequential agent steps -- one with tools to
519 /// gather data, then one with `.output::<T>()` to structure the result.
520 ///
521 /// # Known limitations of Claude CLI structured output
522 ///
523 /// The Claude CLI does not guarantee strict schema conformance for
524 /// structured output. The following upstream bugs affect the behavior:
525 ///
526 /// - **Schema flattening** ([anthropics/claude-agent-sdk-python#502]):
527 /// a schema like `{"type":"object","properties":{"items":{"type":"array",...}}}`
528 /// may return a bare array instead of the wrapper object. The CLI
529 /// non-deterministically flattens schemas with a single array field.
530 /// - **Non-deterministic wrapping** ([anthropics/claude-agent-sdk-python#374]):
531 /// the same prompt can produce differently wrapped output across runs.
532 /// - **No conformance guarantee** ([anthropics/claude-code#9058]):
533 /// the CLI does not validate output against the provided JSON schema.
534 ///
535 /// Because of these bugs, ironflow's provider layer applies multiple
536 /// fallback strategies when extracting the structured value (see
537 /// [`extract_structured_value`](crate::providers::claude::common::extract_structured_value)).
538 ///
539 /// [anthropics/claude-agent-sdk-python#502]: https://github.com/anthropics/claude-agent-sdk-python/issues/502
540 /// [anthropics/claude-agent-sdk-python#374]: https://github.com/anthropics/claude-agent-sdk-python/issues/374
541 /// [anthropics/claude-code#9058]: https://github.com/anthropics/claude-code/issues/9058
542 ///
543 /// # Examples
544 ///
545 /// ```
546 /// use ironflow_core::provider::AgentConfig;
547 /// use schemars::JsonSchema;
548 ///
549 /// #[derive(serde::Deserialize, JsonSchema)]
550 /// struct Labels { labels: Vec<String> }
551 ///
552 /// let config = AgentConfig::new("classify this text")
553 /// .output::<Labels>();
554 /// ```
555 ///
556 /// ```compile_fail
557 /// use ironflow_core::provider::AgentConfig;
558 /// use schemars::JsonSchema;
559 /// #[derive(serde::Deserialize, JsonSchema)]
560 /// struct Out { x: i32 }
561 /// // ERROR: cannot add tools after setting structured output
562 /// let _ = AgentConfig::new("x").output::<Out>().allow_tool("Read");
563 /// ```
564 /// # Panics
565 ///
566 /// Panics if the schema generated by `schemars` cannot be serialized
567 /// to JSON. This indicates a bug in the type's `JsonSchema` derive,
568 /// not a recoverable runtime error.
569 pub fn output<T: JsonSchema>(mut self) -> AgentConfig<NoTools, WithSchema> {
570 let schema = schemars::schema_for!(T);
571 let serialized = serde_json::to_string(&schema).unwrap_or_else(|e| {
572 panic!(
573 "failed to serialize JSON schema for {}: {e}",
574 std::any::type_name::<T>()
575 )
576 });
577 self.json_schema = Some(serialized);
578 self.change_state()
579 }
580
581 /// Set structured output from a pre-serialized JSON Schema string.
582 ///
583 /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
584 /// call [`allow_tool`](AgentConfig::allow_tool). See [`output`](Self::output)
585 /// for the rationale and workaround.
586 pub fn output_schema_raw(mut self, schema: &str) -> AgentConfig<NoTools, WithSchema> {
587 self.json_schema = Some(schema.to_string());
588 self.change_state()
589 }
590}
591
592// ── From conversions to base type ──────────────────────────────────
593
594impl From<AgentConfig<WithTools, NoSchema>> for AgentConfig {
595 fn from(config: AgentConfig<WithTools, NoSchema>) -> Self {
596 config.change_state()
597 }
598}
599
600impl From<AgentConfig<NoTools, WithSchema>> for AgentConfig {
601 fn from(config: AgentConfig<NoTools, WithSchema>) -> Self {
602 config.change_state()
603 }
604}
605
606// ── AgentOutput ────────────────────────────────────────────────────
607
608/// Raw output returned by an [`AgentProvider`] after a successful invocation.
609///
610/// Carries the agent's response value together with usage and billing metadata.
611#[derive(Clone, Debug, Serialize, Deserialize)]
612#[non_exhaustive]
613pub struct AgentOutput {
614 /// The agent's response. A plain [`Value::String`] for text mode, or an
615 /// arbitrary JSON value when a JSON schema was requested.
616 pub value: Value,
617
618 /// Provider-assigned session identifier, useful for resuming conversations.
619 pub session_id: Option<String>,
620
621 /// Total cost in USD for this invocation, if reported by the provider.
622 pub cost_usd: Option<f64>,
623
624 /// Number of input tokens consumed, if reported.
625 pub input_tokens: Option<u64>,
626
627 /// Number of output tokens generated, if reported.
628 pub output_tokens: Option<u64>,
629
630 /// The concrete model identifier used (e.g. `"claude-sonnet-4-20250514"`).
631 pub model: Option<String>,
632
633 /// Wall-clock duration of the invocation in milliseconds.
634 pub duration_ms: u64,
635
636 /// Conversation trace captured when [`AgentConfig::verbose`] is `true`.
637 ///
638 /// Contains every assistant message and tool call made during the
639 /// invocation, in chronological order. `None` when verbose mode is off.
640 pub debug_messages: Option<Vec<DebugMessage>>,
641}
642
643/// A single assistant turn captured during a verbose invocation.
644///
645/// Each `DebugMessage` represents one assistant response, which may contain
646/// free-form text, tool calls, or both.
647///
648/// # Examples
649///
650/// ```no_run
651/// use ironflow_core::prelude::*;
652///
653/// # async fn example() -> Result<(), OperationError> {
654/// let provider = ClaudeCodeProvider::new();
655/// let result = Agent::new()
656/// .prompt("List files in src/")
657/// .verbose()
658/// .run(&provider)
659/// .await?;
660///
661/// if let Some(messages) = result.debug_messages() {
662/// for msg in messages {
663/// println!("{msg}");
664/// }
665/// }
666/// # Ok(())
667/// # }
668/// ```
669#[derive(Debug, Clone, Serialize, Deserialize)]
670#[non_exhaustive]
671pub struct DebugMessage {
672 /// Free-form text produced by the assistant in this turn, if any.
673 pub text: Option<String>,
674
675 /// Extended thinking blocks produced by the model in this turn.
676 ///
677 /// Available only when the model emits `thinking` content blocks
678 /// (Opus 4.7 adaptive thinking, Claude 3.7+ extended thinking, etc.).
679 /// The blocks are joined in arrival order.
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub thinking: Option<String>,
682
683 /// `true` when the model emitted a `thinking` content block but the
684 /// text was redacted (only a signature is provided).
685 ///
686 /// Opus 4.7 adaptive thinking and the `display: "omitted"` setting both
687 /// produce signature-only thinking blocks: the model proves it reasoned
688 /// without exposing the chain of thought. The UI should still show a
689 /// badge so the user knows thinking happened.
690 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
691 pub thinking_redacted: bool,
692
693 /// Tool calls made by the assistant in this turn.
694 pub tool_calls: Vec<DebugToolCall>,
695
696 /// Tool results received from the user/runtime for the preceding tool calls.
697 ///
698 /// In the Claude stream-json format, tool results come as `"type":"user"`
699 /// messages whose content is a list of `tool_result` blocks. We attach
700 /// them to the turn that emitted the matching `tool_use` so the timeline
701 /// stays compact.
702 #[serde(default, skip_serializing_if = "Vec::is_empty")]
703 pub tool_results: Vec<DebugToolResult>,
704
705 /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
706 pub stop_reason: Option<String>,
707
708 /// Input tokens consumed by this turn, if reported.
709 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub input_tokens: Option<u64>,
711
712 /// Output tokens generated by this turn, if reported.
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub output_tokens: Option<u64>,
715}
716
717impl fmt::Display for DebugMessage {
718 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
719 if let Some(ref thinking) = self.thinking {
720 writeln!(f, "[thinking] {thinking}")?;
721 } else if self.thinking_redacted {
722 writeln!(f, "[thinking redacted]")?;
723 }
724 if let Some(ref text) = self.text {
725 writeln!(f, "[assistant] {text}")?;
726 }
727 for tc in &self.tool_calls {
728 write!(f, "{tc}")?;
729 }
730 for tr in &self.tool_results {
731 write!(f, "{tr}")?;
732 }
733 Ok(())
734 }
735}
736
737/// A single tool call captured during a verbose invocation.
738///
739/// Records the tool name and its input arguments as a raw JSON value.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741#[non_exhaustive]
742pub struct DebugToolCall {
743 /// Stable identifier assigned by the model (`tool_use_id`).
744 ///
745 /// Used to correlate a call with its subsequent [`DebugToolResult`].
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub id: Option<String>,
748
749 /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
750 pub name: String,
751
752 /// Input arguments passed to the tool, as raw JSON.
753 pub input: Value,
754}
755
756impl fmt::Display for DebugToolCall {
757 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
758 writeln!(f, " [tool_use] {} -> {}", self.name, self.input)
759 }
760}
761
762/// A tool result returned to the model after a tool call.
763///
764/// Carries the tool output (any JSON value: string, object, array) and
765/// an error flag if the tool failed.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767#[non_exhaustive]
768pub struct DebugToolResult {
769 /// The `tool_use_id` this result answers, matching [`DebugToolCall::id`].
770 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub tool_use_id: Option<String>,
772
773 /// Raw content returned by the tool.
774 pub content: Value,
775
776 /// Whether the tool reported an error.
777 #[serde(default)]
778 pub is_error: bool,
779}
780
781impl fmt::Display for DebugToolResult {
782 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
783 let kind = if self.is_error {
784 "tool_error"
785 } else {
786 "tool_result"
787 };
788 writeln!(f, " [{kind}] {}", self.content)
789 }
790}
791
792impl AgentOutput {
793 /// Create an `AgentOutput` with the given value and sensible defaults.
794 pub fn new(value: Value) -> Self {
795 Self {
796 value,
797 session_id: None,
798 cost_usd: None,
799 input_tokens: None,
800 output_tokens: None,
801 model: None,
802 duration_ms: 0,
803 debug_messages: None,
804 }
805 }
806}
807
808// ── Provider trait ─────────────────────────────────────────────────
809
810/// Trait for AI agent backends.
811///
812/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
813/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
814/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
815///
816/// # Examples
817///
818/// ```no_run
819/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
820///
821/// struct MyProvider;
822///
823/// impl AgentProvider for MyProvider {
824/// fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
825/// Box::pin(async move {
826/// // Call your custom backend here...
827/// todo!()
828/// })
829/// }
830/// }
831/// ```
832pub trait AgentProvider: Send + Sync {
833 /// Execute a single agent invocation with the given configuration.
834 ///
835 /// # Errors
836 ///
837 /// Returns [`AgentError`] if the underlying backend process fails,
838 /// times out, or produces output that does not match the requested schema.
839 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use serde_json::json;
846
847 fn full_config() -> AgentConfig {
848 AgentConfig {
849 system_prompt: Some("you are helpful".to_string()),
850 prompt: "do stuff".to_string(),
851 model: Model::OPUS.to_string(),
852 allowed_tools: vec!["Read".to_string(), "Write".to_string()],
853 disallowed_tools: vec!["Bash".to_string()],
854 max_turns: Some(10),
855 max_budget_usd: Some(2.5),
856 working_dir: Some("/tmp".to_string()),
857 mcp_config: Some("{}".to_string()),
858 strict_mcp_config: true,
859 bare: true,
860 permission_mode: PermissionMode::Auto,
861 json_schema: Some(r#"{"type":"object"}"#.to_string()),
862 resume_session_id: None,
863 verbose: false,
864 pod_labels: BTreeMap::new(),
865 _marker: PhantomData,
866 }
867 }
868
869 #[test]
870 fn agent_config_serialize_deserialize_roundtrip() {
871 let config = full_config();
872 let json = serde_json::to_string(&config).unwrap();
873 let back: AgentConfig = serde_json::from_str(&json).unwrap();
874
875 assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
876 assert_eq!(back.prompt, "do stuff");
877 assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
878 assert_eq!(back.max_turns, Some(10));
879 assert_eq!(back.max_budget_usd, Some(2.5));
880 assert_eq!(back.working_dir, Some("/tmp".to_string()));
881 assert_eq!(back.mcp_config, Some("{}".to_string()));
882 assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
883 }
884
885 #[test]
886 fn agent_config_with_all_optional_fields_none() {
887 let config: AgentConfig = AgentConfig {
888 system_prompt: None,
889 prompt: "hello".to_string(),
890 model: Model::HAIKU.to_string(),
891 allowed_tools: vec![],
892 disallowed_tools: vec![],
893 max_turns: None,
894 max_budget_usd: None,
895 working_dir: None,
896 mcp_config: None,
897 strict_mcp_config: false,
898 bare: false,
899 permission_mode: PermissionMode::Default,
900 json_schema: None,
901 resume_session_id: None,
902 verbose: false,
903 pod_labels: BTreeMap::new(),
904 _marker: PhantomData,
905 };
906 let json = serde_json::to_string(&config).unwrap();
907 let back: AgentConfig = serde_json::from_str(&json).unwrap();
908
909 assert_eq!(back.system_prompt, None);
910 assert_eq!(back.prompt, "hello");
911 assert!(back.allowed_tools.is_empty());
912 assert_eq!(back.max_turns, None);
913 assert_eq!(back.max_budget_usd, None);
914 assert_eq!(back.working_dir, None);
915 assert_eq!(back.mcp_config, None);
916 assert_eq!(back.json_schema, None);
917 }
918
919 #[test]
920 fn agent_output_serialize_deserialize_roundtrip() {
921 let output = AgentOutput {
922 value: json!({"key": "value"}),
923 session_id: Some("sess-abc".to_string()),
924 cost_usd: Some(0.01),
925 input_tokens: Some(500),
926 output_tokens: Some(200),
927 model: Some("claude-sonnet".to_string()),
928 duration_ms: 3000,
929 debug_messages: None,
930 };
931 let json = serde_json::to_string(&output).unwrap();
932 let back: AgentOutput = serde_json::from_str(&json).unwrap();
933
934 assert_eq!(back.value, json!({"key": "value"}));
935 assert_eq!(back.session_id, Some("sess-abc".to_string()));
936 assert_eq!(back.cost_usd, Some(0.01));
937 assert_eq!(back.input_tokens, Some(500));
938 assert_eq!(back.output_tokens, Some(200));
939 assert_eq!(back.model, Some("claude-sonnet".to_string()));
940 assert_eq!(back.duration_ms, 3000);
941 }
942
943 #[test]
944 fn agent_config_new_has_correct_defaults() {
945 let config = AgentConfig::new("test prompt");
946 assert_eq!(config.prompt, "test prompt");
947 assert_eq!(config.system_prompt, None);
948 assert_eq!(config.model, Model::SONNET);
949 assert!(config.allowed_tools.is_empty());
950 assert_eq!(config.max_turns, None);
951 assert_eq!(config.max_budget_usd, None);
952 assert_eq!(config.working_dir, None);
953 assert_eq!(config.mcp_config, None);
954 assert!(matches!(config.permission_mode, PermissionMode::Default));
955 assert_eq!(config.json_schema, None);
956 assert_eq!(config.resume_session_id, None);
957 assert!(!config.verbose);
958 }
959
960 #[test]
961 fn agent_output_new_has_correct_defaults() {
962 let output = AgentOutput::new(json!("test"));
963 assert_eq!(output.value, json!("test"));
964 assert_eq!(output.session_id, None);
965 assert_eq!(output.cost_usd, None);
966 assert_eq!(output.input_tokens, None);
967 assert_eq!(output.output_tokens, None);
968 assert_eq!(output.model, None);
969 assert_eq!(output.duration_ms, 0);
970 assert!(output.debug_messages.is_none());
971 }
972
973 #[test]
974 fn agent_config_resume_session_roundtrip() {
975 let mut config = AgentConfig::new("test");
976 config.resume_session_id = Some("sess-xyz".to_string());
977 let json = serde_json::to_string(&config).unwrap();
978 let back: AgentConfig = serde_json::from_str(&json).unwrap();
979 assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
980 }
981
982 #[test]
983 fn agent_output_debug_does_not_panic() {
984 let output = AgentOutput {
985 value: json!(null),
986 session_id: None,
987 cost_usd: None,
988 input_tokens: None,
989 output_tokens: None,
990 model: None,
991 duration_ms: 0,
992 debug_messages: None,
993 };
994 let debug_str = format!("{:?}", output);
995 assert!(!debug_str.is_empty());
996 }
997
998 #[test]
999 fn allow_tool_transitions_to_with_tools() {
1000 let config = AgentConfig::new("test").allow_tool("Read");
1001 assert_eq!(config.allowed_tools, vec!["Read"]);
1002
1003 // Can add more tools
1004 let config = config.allow_tool("Write");
1005 assert_eq!(config.allowed_tools, vec!["Read", "Write"]);
1006 }
1007
1008 #[test]
1009 fn output_schema_raw_transitions_to_with_schema() {
1010 let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
1011 assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
1012 }
1013
1014 #[test]
1015 fn with_tools_converts_to_base_type() {
1016 let typed = AgentConfig::new("test").allow_tool("Read");
1017 let base: AgentConfig = typed.into();
1018 assert_eq!(base.allowed_tools, vec!["Read"]);
1019 }
1020
1021 #[test]
1022 fn with_schema_converts_to_base_type() {
1023 let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
1024 let base: AgentConfig = typed.into();
1025 assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
1026 }
1027
1028 #[test]
1029 fn serde_roundtrip_ignores_marker() {
1030 let config = AgentConfig::new("test").allow_tool("Read");
1031 let json = serde_json::to_string(&config).unwrap();
1032 assert!(!json.contains("marker"));
1033
1034 let back: AgentConfig = serde_json::from_str(&json).unwrap();
1035 assert_eq!(back.allowed_tools, vec!["Read"]);
1036 }
1037
1038 #[test]
1039 fn bare_defaults_to_false() {
1040 let config = AgentConfig::new("hello");
1041 assert!(!config.bare, "bare must default to false");
1042 }
1043
1044 #[test]
1045 fn bare_builder_sets_flag() {
1046 let config = AgentConfig::new("hello").bare(true);
1047 assert!(config.bare, "bare(true) must enable the flag");
1048
1049 let config = config.bare(false);
1050 assert!(!config.bare, "bare(false) must disable the flag");
1051 }
1052
1053 #[test]
1054 fn bare_serde_default_when_missing() {
1055 let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1056 let config: AgentConfig = serde_json::from_str(raw).unwrap();
1057 assert!(
1058 !config.bare,
1059 "bare must default to false when absent from serialized payload"
1060 );
1061 }
1062
1063 #[test]
1064 fn bare_serde_roundtrip() {
1065 let mut config = AgentConfig::new("hello");
1066 config.bare = true;
1067 let json = serde_json::to_string(&config).unwrap();
1068 assert!(
1069 json.contains("\"bare\":true"),
1070 "serialized form must contain bare:true, got: {json}"
1071 );
1072
1073 let back: AgentConfig = serde_json::from_str(&json).unwrap();
1074 assert!(back.bare, "bare must survive a serde roundtrip");
1075 }
1076
1077 #[test]
1078 fn disallowed_tools_defaults_to_empty() {
1079 let config = AgentConfig::new("hello");
1080 assert!(
1081 config.disallowed_tools.is_empty(),
1082 "disallowed_tools must default to empty"
1083 );
1084 }
1085
1086 #[test]
1087 fn disallowed_tools_builder_replaces_list() {
1088 let config = AgentConfig::new("hello").disallowed_tools(["Write", "Edit"]);
1089 assert_eq!(config.disallowed_tools, vec!["Write", "Edit"]);
1090
1091 // Subsequent call fully replaces the list.
1092 let config = config.disallowed_tools(["Bash"]);
1093 assert_eq!(config.disallowed_tools, vec!["Bash"]);
1094
1095 // Empty input clears the list.
1096 let config = config.disallowed_tools(std::iter::empty::<String>());
1097 assert!(config.disallowed_tools.is_empty());
1098 }
1099
1100 #[test]
1101 fn disallowed_tools_compatible_with_output() {
1102 #[derive(serde::Deserialize, JsonSchema)]
1103 #[allow(dead_code)]
1104 struct Out {
1105 ok: bool,
1106 }
1107
1108 // Typestate compile check: .disallowed_tools(...) must be callable
1109 // before AND after .output::<T>() because it lives on
1110 // impl<Tools, Schema>, not impl<Tools, NoSchema>.
1111 let before: AgentConfig<NoTools, WithSchema> = AgentConfig::new("classify")
1112 .disallowed_tools(["Write", "Edit"])
1113 .output::<Out>();
1114 assert_eq!(before.disallowed_tools, vec!["Write", "Edit"]);
1115 assert!(before.json_schema.is_some());
1116
1117 let after: AgentConfig<NoTools, WithSchema> = AgentConfig::new("classify")
1118 .output::<Out>()
1119 .disallowed_tools(["Write"]);
1120 assert_eq!(after.disallowed_tools, vec!["Write"]);
1121 assert!(after.json_schema.is_some());
1122 }
1123
1124 #[test]
1125 fn disallowed_tools_serde_default_when_missing() {
1126 let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1127 let config: AgentConfig = serde_json::from_str(raw).unwrap();
1128 assert!(
1129 config.disallowed_tools.is_empty(),
1130 "disallowed_tools must default to empty when absent from serialized payload"
1131 );
1132 }
1133
1134 #[test]
1135 fn disallowed_tools_serde_roundtrip() {
1136 let config = AgentConfig::new("hello").disallowed_tools(["Write", "Edit"]);
1137 let json = serde_json::to_string(&config).unwrap();
1138 assert!(
1139 json.contains("\"disallowed_tools\":[\"Write\",\"Edit\"]"),
1140 "serialized form must contain the disallowed_tools array, got: {json}"
1141 );
1142
1143 let back: AgentConfig = serde_json::from_str(&json).unwrap();
1144 assert_eq!(back.disallowed_tools, vec!["Write", "Edit"]);
1145 }
1146
1147 #[test]
1148 fn pod_labels_defaults_to_empty() {
1149 let config = AgentConfig::new("test");
1150 assert!(config.pod_labels.is_empty());
1151 }
1152
1153 #[test]
1154 fn pod_label_builder_adds_entry() {
1155 let config = AgentConfig::new("test").pod_label("k", "v");
1156 assert_eq!(config.pod_labels.len(), 1);
1157 assert_eq!(config.pod_labels["k"], "v");
1158 }
1159
1160 #[test]
1161 fn pod_labels_builder_replaces_map() {
1162 let config = AgentConfig::new("test").pod_label("old", "value");
1163 let mut new_map = BTreeMap::new();
1164 new_map.insert("new".to_string(), "value".to_string());
1165 let config = config.pod_labels(new_map);
1166 assert_eq!(config.pod_labels.len(), 1);
1167 assert_eq!(config.pod_labels["new"], "value");
1168 assert!(!config.pod_labels.contains_key("old"));
1169 }
1170
1171 #[test]
1172 fn pod_labels_serde_default_when_missing() {
1173 let raw = r#"{"prompt":"hello","model":"sonnet"}"#;
1174 let config: AgentConfig = serde_json::from_str(raw).unwrap();
1175 assert!(
1176 config.pod_labels.is_empty(),
1177 "pod_labels must default to empty when absent from serialized payload"
1178 );
1179 }
1180
1181 #[test]
1182 fn pod_labels_serde_skip_when_empty() {
1183 let config = AgentConfig::new("hello");
1184 let json = serde_json::to_string(&config).unwrap();
1185 assert!(
1186 !json.contains("pod_labels"),
1187 "empty pod_labels must be skipped during serialization, got: {json}"
1188 );
1189 }
1190
1191 #[test]
1192 fn pod_labels_serde_roundtrip() {
1193 let config = AgentConfig::new("hello")
1194 .pod_label("ironflow.io/network-profile", "grafana-only")
1195 .pod_label("team", "observability");
1196 let json = serde_json::to_string(&config).unwrap();
1197 assert!(
1198 json.contains("pod_labels"),
1199 "non-empty pod_labels must be present in serialized form, got: {json}"
1200 );
1201
1202 let back: AgentConfig = serde_json::from_str(&json).unwrap();
1203 assert_eq!(back.pod_labels.len(), 2);
1204 assert_eq!(
1205 back.pod_labels["ironflow.io/network-profile"],
1206 "grafana-only"
1207 );
1208 assert_eq!(back.pod_labels["team"], "observability");
1209 }
1210}