Skip to main content

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