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