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}