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