Skip to main content

ironflow_engine/config/
agent_role.rs

1//! Reusable agent with a fixed role.
2//!
3//! An [`AgentRole`] is an agent defined once: its persona, tools, rights and
4//! output type. A workflow calls it with
5//! `ctx.agent("step", Role.task(input))`. The task is an [`AgentStep`], so it is
6//! persisted, replayed and run by the worker exactly like a hand-built
7//! [`AgentStepConfig`].
8
9use std::collections::BTreeMap;
10use std::fmt;
11use std::marker::PhantomData;
12
13use ironflow_core::operations::agent::{Effort, PermissionMode};
14use ironflow_core::retry::RetryPolicy;
15use schemars::JsonSchema;
16use serde::de::DeserializeOwned;
17
18use super::agent::{AgentStep, AgentStepConfig, Tool};
19use crate::error::EngineError;
20use crate::executor::StepOutput;
21
22/// A reusable agent with a fixed role: persona, tools, rights and output type.
23///
24/// Only [`prompt`](Self::prompt) is required. The persona, tools, permission
25/// mode, MCP config and output type are locked by the role: the caller of
26/// [`task`](Self::task) cannot change them (a role with more rights is another
27/// role). The model, budget, turn limit, effort and per-call settings are
28/// defaults the caller may override on the [`AgentTask`].
29///
30/// # Examples
31///
32/// ```
33/// use ironflow_engine::config::{AgentRole, AgentStep, Tool};
34/// use schemars::JsonSchema;
35/// use serde::Deserialize;
36///
37/// #[derive(Deserialize, JsonSchema)]
38/// struct Verdict {
39///     approved: bool,
40/// }
41///
42/// struct Reviewer;
43///
44/// impl AgentRole for Reviewer {
45///     type Input = String;
46///     type Output = Verdict;
47///
48///     fn prompt(&self, input: String) -> String {
49///         format!("Review: {input}")
50///     }
51///
52///     fn append_system_prompt(&self) -> Option<&str> {
53///         Some("You are a strict reviewer.")
54///     }
55///
56///     fn tools(&self) -> Vec<Tool> {
57///         vec![Tool::Read]
58///     }
59///
60///     fn max_turns(&self) -> Option<u32> {
61///         Some(4)
62///     }
63/// }
64///
65/// let config = Reviewer.task("diff".to_string()).max_budget_usd(0.10).into_config();
66/// assert_eq!(config.append_system_prompt.as_deref(), Some("You are a strict reviewer."));
67/// assert_eq!(config.allowed_tools, vec!["Read"]);
68/// assert!(config.json_schema.is_some());
69/// assert_eq!(config.max_budget_usd, Some(0.10));
70/// ```
71///
72/// Calling it from a workflow:
73///
74/// ```no_run
75/// # use ironflow_engine::config::AgentRole;
76/// # use ironflow_engine::context::WorkflowContext;
77/// # use ironflow_engine::error::EngineError;
78/// # use schemars::JsonSchema;
79/// # use serde::Deserialize;
80/// # #[derive(Deserialize, JsonSchema)]
81/// # struct Verdict { approved: bool }
82/// # struct Reviewer;
83/// # impl AgentRole for Reviewer {
84/// #     type Input = String;
85/// #     type Output = Verdict;
86/// #     fn prompt(&self, input: String) -> String { input }
87/// # }
88/// # async fn example(ctx: &mut WorkflowContext, diff: String) -> Result<(), EngineError> {
89/// let verdict: Verdict = ctx.agent("review", Reviewer.task(diff)).await?;
90/// # Ok(())
91/// # }
92/// ```
93pub trait AgentRole {
94    /// What the caller passes to [`task`](Self::task) to build the prompt.
95    type Input;
96
97    /// What the role answers; `ctx.agent` returns it.
98    type Output: DeserializeOwned + JsonSchema;
99
100    /// The prompt of one task. Required.
101    fn prompt(&self, input: Self::Input) -> String;
102
103    /// Replaces the provider system prompt. Claude Code loses its own (tools,
104    /// skills) when it is replaced. `None` by default.
105    fn system_prompt(&self) -> Option<&str> {
106        None
107    }
108
109    /// Persona appended to the provider system prompt. Prefer it to
110    /// [`system_prompt`](Self::system_prompt) for a role with tools. `None` by
111    /// default.
112    fn append_system_prompt(&self) -> Option<&str> {
113        None
114    }
115
116    /// Tools the role may use. None by default.
117    fn tools(&self) -> Vec<Tool> {
118        Vec::new()
119    }
120
121    /// Permission mode of the role; `None` keeps the
122    /// `AgentConfig::new` default.
123    fn permission_mode(&self) -> Option<PermissionMode> {
124        None
125    }
126
127    /// MCP configuration, as JSON or a path. `None` by default.
128    fn mcp_config(&self) -> Option<&str> {
129        None
130    }
131
132    /// Restrict the MCP servers to [`mcp_config`](Self::mcp_config). `false` by
133    /// default.
134    fn strict_mcp_config(&self) -> bool {
135        false
136    }
137
138    /// Default model; `None` keeps the `AgentConfig::new` default. The
139    /// caller may override it.
140    fn model(&self) -> Option<&str> {
141        None
142    }
143
144    /// Default budget in USD. The caller may override it.
145    fn max_budget_usd(&self) -> Option<f64> {
146        None
147    }
148
149    /// Default turn limit. The caller may override it.
150    ///
151    /// A structured output needs at least 2 turns; like [`AgentStepConfig`],
152    /// the role does not validate it.
153    fn max_turns(&self) -> Option<u32> {
154        None
155    }
156
157    /// Default effort. The caller may override it.
158    fn effort(&self) -> Option<Effort> {
159        None
160    }
161
162    /// Build one task of this role.
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// # use ironflow_engine::config::{AgentRole, AgentStep};
168    /// # use schemars::JsonSchema;
169    /// # use serde::Deserialize;
170    /// # #[derive(Deserialize, JsonSchema)]
171    /// # struct Verdict { approved: bool }
172    /// # struct Reviewer;
173    /// # impl AgentRole for Reviewer {
174    /// #     type Input = String;
175    /// #     type Output = Verdict;
176    /// #     fn prompt(&self, input: String) -> String { format!("Review: {input}") }
177    /// # }
178    /// let config = Reviewer.task("diff".to_string()).into_config();
179    /// assert_eq!(config.prompt, "Review: diff");
180    /// ```
181    fn task(&self, input: Self::Input) -> AgentTask<Self::Output> {
182        AgentTask::from_role(self, input)
183    }
184}
185
186/// One call of an [`AgentRole`], answering with the `T` of the role.
187///
188/// The role locks its persona, tools, permission mode, MCP config and output
189/// type: none of them has a setter here, and each of these calls does not
190/// compile.
191///
192/// The system prompt is locked:
193///
194/// ```compile_fail,E0599
195/// # use ironflow_engine::config::AgentRole;
196/// # use schemars::JsonSchema;
197/// # use serde::Deserialize;
198/// # #[derive(Deserialize, JsonSchema)]
199/// # struct Verdict { approved: bool }
200/// # struct Reviewer;
201/// # impl AgentRole for Reviewer {
202/// #     type Input = String;
203/// #     type Output = Verdict;
204/// #     fn prompt(&self, input: String) -> String { input }
205/// # }
206/// let _ = Reviewer.task("diff".to_string()).system_prompt("x");
207/// ```
208///
209/// The persona is locked:
210///
211/// ```compile_fail,E0599
212/// # use ironflow_engine::config::AgentRole;
213/// # use schemars::JsonSchema;
214/// # use serde::Deserialize;
215/// # #[derive(Deserialize, JsonSchema)]
216/// # struct Verdict { approved: bool }
217/// # struct Reviewer;
218/// # impl AgentRole for Reviewer {
219/// #     type Input = String;
220/// #     type Output = Verdict;
221/// #     fn prompt(&self, input: String) -> String { input }
222/// # }
223/// let _ = Reviewer.task("diff".to_string()).append_system_prompt("x");
224/// ```
225///
226/// The tools are locked:
227///
228/// ```compile_fail,E0599
229/// # use ironflow_engine::config::{AgentRole, Tool};
230/// # use schemars::JsonSchema;
231/// # use serde::Deserialize;
232/// # #[derive(Deserialize, JsonSchema)]
233/// # struct Verdict { approved: bool }
234/// # struct Reviewer;
235/// # impl AgentRole for Reviewer {
236/// #     type Input = String;
237/// #     type Output = Verdict;
238/// #     fn prompt(&self, input: String) -> String { input }
239/// # }
240/// let _ = Reviewer.task("diff".to_string()).allow_tool(Tool::Bash);
241/// ```
242///
243/// The permission mode is locked:
244///
245/// ```compile_fail,E0599
246/// # use ironflow_core::operations::agent::PermissionMode;
247/// # use ironflow_engine::config::AgentRole;
248/// # use schemars::JsonSchema;
249/// # use serde::Deserialize;
250/// # #[derive(Deserialize, JsonSchema)]
251/// # struct Verdict { approved: bool }
252/// # struct Reviewer;
253/// # impl AgentRole for Reviewer {
254/// #     type Input = String;
255/// #     type Output = Verdict;
256/// #     fn prompt(&self, input: String) -> String { input }
257/// # }
258/// let _ = Reviewer
259///     .task("diff".to_string())
260///     .permission_mode(PermissionMode::BypassPermissions);
261/// ```
262///
263/// The MCP config is locked:
264///
265/// ```compile_fail,E0599
266/// # use ironflow_engine::config::AgentRole;
267/// # use schemars::JsonSchema;
268/// # use serde::Deserialize;
269/// # #[derive(Deserialize, JsonSchema)]
270/// # struct Verdict { approved: bool }
271/// # struct Reviewer;
272/// # impl AgentRole for Reviewer {
273/// #     type Input = String;
274/// #     type Output = Verdict;
275/// #     fn prompt(&self, input: String) -> String { input }
276/// # }
277/// let _ = Reviewer.task("diff".to_string()).mcp_config("{}");
278/// ```
279///
280/// The output type is locked:
281///
282/// ```compile_fail,E0599
283/// # use ironflow_engine::config::AgentRole;
284/// # use schemars::JsonSchema;
285/// # use serde::Deserialize;
286/// # #[derive(Deserialize, JsonSchema)]
287/// # struct Verdict { approved: bool }
288/// # #[derive(Deserialize, JsonSchema)]
289/// # struct Other { n: u8 }
290/// # struct Reviewer;
291/// # impl AgentRole for Reviewer {
292/// #     type Input = String;
293/// #     type Output = Verdict;
294/// #     fn prompt(&self, input: String) -> String { input }
295/// # }
296/// let _ = Reviewer.task("diff".to_string()).output::<Other>();
297/// ```
298///
299/// Taking the raw config with [`AgentStep::into_config`] and editing it loses
300/// the typed answer (`ctx.agent` then returns a [`StepOutput`]), so there is no
301/// silent override.
302///
303/// A call-site override changes the step config, so it changes the hash of the
304/// `RecordReplayProvider` fixture: a fixture recorded for the role defaults
305/// does not match an overridden task.
306///
307/// # Examples
308///
309/// ```
310/// # use ironflow_engine::config::{AgentRole, AgentStep};
311/// # use schemars::JsonSchema;
312/// # use serde::Deserialize;
313/// # #[derive(Deserialize, JsonSchema)]
314/// # struct Verdict { approved: bool }
315/// # struct Reviewer;
316/// # impl AgentRole for Reviewer {
317/// #     type Input = String;
318/// #     type Output = Verdict;
319/// #     fn prompt(&self, input: String) -> String { input }
320/// # }
321/// let task = Reviewer.task("diff".to_string()).model("haiku").allow_failure();
322/// let config = task.into_config();
323/// assert_eq!(config.model, "haiku");
324/// assert!(config.allow_failure);
325/// ```
326pub struct AgentTask<T> {
327    config: AgentStepConfig,
328    _output: PhantomData<fn() -> T>,
329}
330
331// Manual impls: a derive would require `T: Debug` / `T: Clone`, though `T` is
332// only a marker (same reasoning as `WithSchema` in `ironflow-core`).
333impl<T> fmt::Debug for AgentTask<T> {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        f.debug_struct("AgentTask")
336            .field("config", &self.config)
337            .finish()
338    }
339}
340
341impl<T> Clone for AgentTask<T> {
342    fn clone(&self) -> Self {
343        Self {
344            config: self.config.clone(),
345            _output: PhantomData,
346        }
347    }
348}
349
350impl<T: JsonSchema> AgentTask<T> {
351    /// Builds the task from the role defaults. `pub(crate)`: no other crate can
352    /// wrap an arbitrary config in an [`AgentTask`].
353    pub(crate) fn from_role<R: AgentRole<Output = T> + ?Sized>(role: &R, input: R::Input) -> Self {
354        let mut config = AgentStepConfig::new(&role.prompt(input));
355        if let Some(prompt) = role.system_prompt() {
356            config = config.system_prompt(prompt);
357        }
358        if let Some(prompt) = role.append_system_prompt() {
359            config = config.append_system_prompt(prompt);
360        }
361        if let Some(mcp) = role.mcp_config() {
362            config = config.mcp_config(mcp);
363        }
364        if role.strict_mcp_config() {
365            config = config.strict_mcp_config(true);
366        }
367        if let Some(model) = role.model() {
368            config = config.model(model);
369        }
370        if let Some(budget) = role.max_budget_usd() {
371            config = config.max_budget_usd(budget);
372        }
373        if let Some(turns) = role.max_turns() {
374            config = config.max_turns(turns);
375        }
376        if let Some(effort) = role.effort() {
377            config = config.effort(effort);
378        }
379        if let Some(mode) = role.permission_mode() {
380            config = config.permission_mode(mode);
381        }
382        for tool in role.tools() {
383            config = config.allow_tool(tool).into();
384        }
385        config = config.output::<T>().into();
386        Self {
387            config,
388            _output: PhantomData,
389        }
390    }
391}
392
393impl<T> AgentTask<T> {
394    /// Override the model of the role for this call.
395    ///
396    /// # Examples
397    ///
398    /// ```
399    /// # use ironflow_engine::config::{AgentRole, AgentStep};
400    /// # use schemars::JsonSchema;
401    /// # use serde::Deserialize;
402    /// # #[derive(Deserialize, JsonSchema)]
403    /// # struct Verdict { approved: bool }
404    /// # struct Reviewer;
405    /// # impl AgentRole for Reviewer {
406    /// #     type Input = String;
407    /// #     type Output = Verdict;
408    /// #     fn prompt(&self, input: String) -> String { input }
409    /// # }
410    /// let config = Reviewer.task("diff".to_string()).model("haiku").into_config();
411    /// assert_eq!(config.model, "haiku");
412    /// ```
413    pub fn model(mut self, model: &str) -> Self {
414        self.config = self.config.model(model);
415        self
416    }
417
418    /// Override the budget of the role for this call.
419    ///
420    /// # Examples
421    ///
422    /// ```
423    /// # use ironflow_engine::config::{AgentRole, AgentStep};
424    /// # use schemars::JsonSchema;
425    /// # use serde::Deserialize;
426    /// # #[derive(Deserialize, JsonSchema)]
427    /// # struct Verdict { approved: bool }
428    /// # struct Reviewer;
429    /// # impl AgentRole for Reviewer {
430    /// #     type Input = String;
431    /// #     type Output = Verdict;
432    /// #     fn prompt(&self, input: String) -> String { input }
433    /// # }
434    /// let config = Reviewer.task("diff".to_string()).max_budget_usd(0.10).into_config();
435    /// assert_eq!(config.max_budget_usd, Some(0.10));
436    /// ```
437    pub fn max_budget_usd(mut self, budget: f64) -> Self {
438        self.config = self.config.max_budget_usd(budget);
439        self
440    }
441
442    /// Override the turn limit of the role for this call.
443    ///
444    /// # Examples
445    ///
446    /// ```
447    /// # use ironflow_engine::config::{AgentRole, AgentStep};
448    /// # use schemars::JsonSchema;
449    /// # use serde::Deserialize;
450    /// # #[derive(Deserialize, JsonSchema)]
451    /// # struct Verdict { approved: bool }
452    /// # struct Reviewer;
453    /// # impl AgentRole for Reviewer {
454    /// #     type Input = String;
455    /// #     type Output = Verdict;
456    /// #     fn prompt(&self, input: String) -> String { input }
457    /// # }
458    /// let config = Reviewer.task("diff".to_string()).max_turns(6).into_config();
459    /// assert_eq!(config.max_turns, Some(6));
460    /// ```
461    pub fn max_turns(mut self, turns: u32) -> Self {
462        self.config = self.config.max_turns(turns);
463        self
464    }
465
466    /// Override the effort of the role for this call.
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// # use ironflow_engine::config::{AgentRole, AgentStep};
472    /// # use ironflow_core::operations::agent::Effort;
473    /// # use schemars::JsonSchema;
474    /// # use serde::Deserialize;
475    /// # #[derive(Deserialize, JsonSchema)]
476    /// # struct Verdict { approved: bool }
477    /// # struct Reviewer;
478    /// # impl AgentRole for Reviewer {
479    /// #     type Input = String;
480    /// #     type Output = Verdict;
481    /// #     fn prompt(&self, input: String) -> String { input }
482    /// # }
483    /// let config = Reviewer.task("diff".to_string()).effort(Effort::High).into_config();
484    /// assert!(matches!(config.effort, Some(Effort::High)));
485    /// ```
486    pub fn effort(mut self, effort: Effort) -> Self {
487        self.config = self.config.effort(effort);
488        self
489    }
490
491    /// Set the working directory of the call.
492    ///
493    /// # Examples
494    ///
495    /// ```
496    /// # use ironflow_engine::config::{AgentRole, AgentStep};
497    /// # use schemars::JsonSchema;
498    /// # use serde::Deserialize;
499    /// # #[derive(Deserialize, JsonSchema)]
500    /// # struct Verdict { approved: bool }
501    /// # struct Reviewer;
502    /// # impl AgentRole for Reviewer {
503    /// #     type Input = String;
504    /// #     type Output = Verdict;
505    /// #     fn prompt(&self, input: String) -> String { input }
506    /// # }
507    /// let config = Reviewer.task("diff".to_string()).working_dir("/repo").into_config();
508    /// assert_eq!(config.working_dir.as_deref(), Some("/repo"));
509    /// ```
510    pub fn working_dir(mut self, dir: &str) -> Self {
511        self.config = self.config.working_dir(dir);
512        self
513    }
514
515    /// Resume a Claude Code session.
516    ///
517    /// # Examples
518    ///
519    /// ```
520    /// # use ironflow_engine::config::{AgentRole, AgentStep};
521    /// # use schemars::JsonSchema;
522    /// # use serde::Deserialize;
523    /// # #[derive(Deserialize, JsonSchema)]
524    /// # struct Verdict { approved: bool }
525    /// # struct Reviewer;
526    /// # impl AgentRole for Reviewer {
527    /// #     type Input = String;
528    /// #     type Output = Verdict;
529    /// #     fn prompt(&self, input: String) -> String { input }
530    /// # }
531    /// let config = Reviewer.task("diff".to_string()).resume("sess").into_config();
532    /// assert_eq!(config.resume_session_id.as_deref(), Some("sess"));
533    /// ```
534    pub fn resume(mut self, session_id: &str) -> Self {
535        self.config = self.config.resume(session_id);
536        self
537    }
538
539    /// Pin the session id of the call.
540    ///
541    /// # Examples
542    ///
543    /// ```
544    /// # use ironflow_engine::config::{AgentRole, AgentStep};
545    /// # use schemars::JsonSchema;
546    /// # use serde::Deserialize;
547    /// # #[derive(Deserialize, JsonSchema)]
548    /// # struct Verdict { approved: bool }
549    /// # struct Reviewer;
550    /// # impl AgentRole for Reviewer {
551    /// #     type Input = String;
552    /// #     type Output = Verdict;
553    /// #     fn prompt(&self, input: String) -> String { input }
554    /// # }
555    /// let config = Reviewer.task("diff".to_string()).session_id("sid").into_config();
556    /// assert_eq!(config.session_id.as_deref(), Some("sid"));
557    /// ```
558    pub fn session_id(mut self, session_id: &str) -> Self {
559        self.config = self.config.session_id(session_id);
560        self
561    }
562
563    /// Set the prompt sent when the step is resumed after a lost lease.
564    ///
565    /// # Examples
566    ///
567    /// ```
568    /// # use ironflow_engine::config::{AgentRole, AgentStep};
569    /// # use schemars::JsonSchema;
570    /// # use serde::Deserialize;
571    /// # #[derive(Deserialize, JsonSchema)]
572    /// # struct Verdict { approved: bool }
573    /// # struct Reviewer;
574    /// # impl AgentRole for Reviewer {
575    /// #     type Input = String;
576    /// #     type Output = Verdict;
577    /// #     fn prompt(&self, input: String) -> String { input }
578    /// # }
579    /// let config = Reviewer.task("diff".to_string()).resume_prompt("go on").into_config();
580    /// assert_eq!(config.resume_prompt.as_deref(), Some("go on"));
581    /// ```
582    pub fn resume_prompt(mut self, prompt: &str) -> Self {
583        self.config = self.config.resume_prompt(prompt);
584        self
585    }
586
587    /// Resume the environment handed out by an earlier step.
588    ///
589    /// # Examples
590    ///
591    /// ```
592    /// # use ironflow_engine::config::{AgentRole, AgentStep};
593    /// # use schemars::JsonSchema;
594    /// # use serde::Deserialize;
595    /// # #[derive(Deserialize, JsonSchema)]
596    /// # struct Verdict { approved: bool }
597    /// # struct Reviewer;
598    /// # impl AgentRole for Reviewer {
599    /// #     type Input = String;
600    /// #     type Output = Verdict;
601    /// #     fn prompt(&self, input: String) -> String { input }
602    /// # }
603    /// let config = Reviewer.task("diff".to_string()).resume_environment("env-1").into_config();
604    /// assert_eq!(config.resume_environment_id.as_deref(), Some("env-1"));
605    /// ```
606    pub fn resume_environment(mut self, environment_id: &str) -> Self {
607        self.config = self.config.resume_environment(environment_id);
608        self
609    }
610
611    /// Set the retry policy of the step.
612    ///
613    /// # Examples
614    ///
615    /// ```
616    /// # use ironflow_engine::config::{AgentRole, AgentStep};
617    /// # use ironflow_core::retry::RetryPolicy;
618    /// # use schemars::JsonSchema;
619    /// # use serde::Deserialize;
620    /// # #[derive(Deserialize, JsonSchema)]
621    /// # struct Verdict { approved: bool }
622    /// # struct Reviewer;
623    /// # impl AgentRole for Reviewer {
624    /// #     type Input = String;
625    /// #     type Output = Verdict;
626    /// #     fn prompt(&self, input: String) -> String { input }
627    /// # }
628    /// let config = Reviewer.task("diff".to_string()).retry_policy(RetryPolicy::new(2)).into_config();
629    /// assert_eq!(config.retry.map(|r| r.max_retries()), Some(2));
630    /// ```
631    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
632        self.config = self.config.retry_policy(policy);
633        self
634    }
635
636    /// Let the run go on when this step fails.
637    ///
638    /// # Examples
639    ///
640    /// ```
641    /// # use ironflow_engine::config::{AgentRole, AgentStep};
642    /// # use schemars::JsonSchema;
643    /// # use serde::Deserialize;
644    /// # #[derive(Deserialize, JsonSchema)]
645    /// # struct Verdict { approved: bool }
646    /// # struct Reviewer;
647    /// # impl AgentRole for Reviewer {
648    /// #     type Input = String;
649    /// #     type Output = Verdict;
650    /// #     fn prompt(&self, input: String) -> String { input }
651    /// # }
652    /// let config = Reviewer.task("diff".to_string()).allow_failure().into_config();
653    /// assert!(config.allow_failure);
654    /// ```
655    pub fn allow_failure(mut self) -> Self {
656        self.config = self.config.allow_failure();
657        self
658    }
659
660    /// Record the agent conversation of the call.
661    ///
662    /// # Examples
663    ///
664    /// ```
665    /// # use ironflow_engine::config::{AgentRole, AgentStep};
666    /// # use schemars::JsonSchema;
667    /// # use serde::Deserialize;
668    /// # #[derive(Deserialize, JsonSchema)]
669    /// # struct Verdict { approved: bool }
670    /// # struct Reviewer;
671    /// # impl AgentRole for Reviewer {
672    /// #     type Input = String;
673    /// #     type Output = Verdict;
674    /// #     fn prompt(&self, input: String) -> String { input }
675    /// # }
676    /// let config = Reviewer.task("diff".to_string()).verbose(true).into_config();
677    /// assert!(config.verbose);
678    /// ```
679    pub fn verbose(mut self, enabled: bool) -> Self {
680        self.config = self.config.verbose(enabled);
681        self
682    }
683
684    /// Add a label to the pod running the call.
685    ///
686    /// # Examples
687    ///
688    /// ```
689    /// # use ironflow_engine::config::{AgentRole, AgentStep};
690    /// # use schemars::JsonSchema;
691    /// # use serde::Deserialize;
692    /// # #[derive(Deserialize, JsonSchema)]
693    /// # struct Verdict { approved: bool }
694    /// # struct Reviewer;
695    /// # impl AgentRole for Reviewer {
696    /// #     type Input = String;
697    /// #     type Output = Verdict;
698    /// #     fn prompt(&self, input: String) -> String { input }
699    /// # }
700    /// let config = Reviewer.task("diff".to_string()).pod_label("team", "qa").into_config();
701    /// assert_eq!(config.pod_labels.get("team").map(String::as_str), Some("qa"));
702    /// ```
703    pub fn pod_label(mut self, key: &str, value: &str) -> Self {
704        self.config = self.config.pod_label(key, value);
705        self
706    }
707
708    /// Set the labels of the pod running the call.
709    ///
710    /// # Examples
711    ///
712    /// ```
713    /// # use ironflow_engine::config::{AgentRole, AgentStep};
714    /// # use std::collections::BTreeMap;
715    /// # use schemars::JsonSchema;
716    /// # use serde::Deserialize;
717    /// # #[derive(Deserialize, JsonSchema)]
718    /// # struct Verdict { approved: bool }
719    /// # struct Reviewer;
720    /// # impl AgentRole for Reviewer {
721    /// #     type Input = String;
722    /// #     type Output = Verdict;
723    /// #     fn prompt(&self, input: String) -> String { input }
724    /// # }
725    /// let labels = BTreeMap::from([("team".to_string(), "qa".to_string())]);
726    /// let config = Reviewer.task("diff".to_string()).pod_labels(labels.clone()).into_config();
727    /// assert_eq!(config.pod_labels, labels);
728    /// ```
729    pub fn pod_labels(mut self, labels: BTreeMap<String, String>) -> Self {
730        self.config = self.config.pod_labels(labels);
731        self
732    }
733}
734
735impl<T: DeserializeOwned> AgentStep for AgentTask<T> {
736    type Answer = T;
737
738    fn into_config(self) -> AgentStepConfig {
739        self.config
740    }
741
742    fn answer(output: StepOutput) -> Result<T, EngineError> {
743        output.json()
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::executor::StepArtifacts;
751    use rust_decimal::Decimal;
752    use serde::Deserialize;
753    use serde_json::{Value, json, to_value};
754
755    #[derive(Debug, PartialEq, Deserialize, JsonSchema)]
756    struct Verdict {
757        approved: bool,
758    }
759
760    /// Implements only the required method.
761    struct Bare;
762
763    impl AgentRole for Bare {
764        type Input = String;
765        type Output = Verdict;
766
767        fn prompt(&self, input: String) -> String {
768            input
769        }
770    }
771
772    /// Locks MCP in strict mode.
773    struct Strict;
774
775    impl AgentRole for Strict {
776        type Input = String;
777        type Output = Verdict;
778
779        fn prompt(&self, input: String) -> String {
780            input
781        }
782
783        fn mcp_config(&self) -> Option<&str> {
784            Some("{}")
785        }
786
787        fn strict_mcp_config(&self) -> bool {
788            true
789        }
790    }
791
792    /// Sets every default the other test roles leave out.
793    struct Full;
794
795    impl AgentRole for Full {
796        type Input = String;
797        type Output = Verdict;
798
799        fn prompt(&self, input: String) -> String {
800            input
801        }
802
803        fn system_prompt(&self) -> Option<&str> {
804            Some("base prompt")
805        }
806
807        fn append_system_prompt(&self) -> Option<&str> {
808            Some("persona")
809        }
810
811        fn max_budget_usd(&self) -> Option<f64> {
812            Some(0.5)
813        }
814
815        fn effort(&self) -> Option<Effort> {
816            Some(Effort::Low)
817        }
818    }
819
820    fn step_output(output: Value) -> StepOutput {
821        StepOutput {
822            output,
823            duration_ms: 0,
824            cost_usd: Decimal::ZERO,
825            input_tokens: None,
826            cache_read_input_tokens: None,
827            cache_creation_input_tokens: None,
828            output_tokens: None,
829            model: None,
830            debug_messages: None,
831            artifacts: StepArtifacts::default(),
832            account_id: None,
833            environment_id: None,
834        }
835    }
836
837    #[test]
838    fn a_role_with_only_a_prompt_takes_the_agent_config_new_defaults() {
839        let task = Bare.task("p".to_string());
840        let expected = AgentStepConfig::new("p").output::<Verdict>().into_config();
841        assert_eq!(
842            to_value(task.into_config()).expect("serialize"),
843            to_value(expected).expect("serialize")
844        );
845    }
846
847    #[test]
848    fn a_role_without_tools_has_no_allowed_tools_and_keeps_its_schema() {
849        let config = Bare.task("p".to_string()).into_config();
850        assert!(config.allowed_tools.is_empty());
851        assert!(
852            config
853                .json_schema
854                .as_deref()
855                .is_some_and(|schema| schema.contains("approved"))
856        );
857    }
858
859    #[test]
860    fn strict_mcp_config_of_the_role_is_applied() {
861        let config = Strict.task("p".to_string()).into_config();
862        assert!(config.strict_mcp_config);
863        assert_eq!(config.mcp_config.as_deref(), Some("{}"));
864        assert!(!Bare.task("p".to_string()).into_config().strict_mcp_config);
865    }
866
867    #[test]
868    fn role_defaults_for_prompts_budget_and_effort_are_applied() {
869        let config = Full.task("p".to_string()).into_config();
870        assert_eq!(config.system_prompt.as_deref(), Some("base prompt"));
871        assert_eq!(config.append_system_prompt.as_deref(), Some("persona"));
872        assert_eq!(config.max_budget_usd, Some(0.5));
873        assert_eq!(config.effort, Some(Effort::Low));
874        let overridden = Full
875            .task("p".to_string())
876            .max_budget_usd(1.5)
877            .effort(Effort::High)
878            .into_config();
879        assert_eq!(overridden.max_budget_usd, Some(1.5));
880        assert_eq!(overridden.effort, Some(Effort::High));
881        assert_eq!(overridden.system_prompt.as_deref(), Some("base prompt"));
882    }
883
884    #[test]
885    fn task_answer_reads_the_typed_output() {
886        let answer =
887            <AgentTask<Verdict> as AgentStep>::answer(step_output(json!({"approved": true})))
888                .expect("matches Verdict");
889        assert_eq!(answer, Verdict { approved: true });
890    }
891
892    #[test]
893    fn task_answer_rejects_another_shape() {
894        let err = <AgentTask<Verdict> as AgentStep>::answer(step_output(json!(["approved"])))
895            .expect_err("not a Verdict");
896        assert!(matches!(err, EngineError::Serialization(_)));
897    }
898
899    #[test]
900    fn retry_policy_and_resume_settings_are_applied() {
901        let mut labels = BTreeMap::new();
902        labels.insert("team".to_string(), "qa".to_string());
903        let config = Bare
904            .task("p".to_string())
905            .retry_policy(RetryPolicy::new(2))
906            .resume("sess")
907            .session_id("sid")
908            .resume_prompt("go on")
909            .resume_environment("env-1")
910            .verbose(true)
911            .pod_labels(labels.clone())
912            .into_config();
913        assert_eq!(config.retry.map(|r| r.max_retries()), Some(2));
914        assert_eq!(config.resume_session_id.as_deref(), Some("sess"));
915        assert_eq!(config.session_id.as_deref(), Some("sid"));
916        assert_eq!(config.resume_prompt.as_deref(), Some("go on"));
917        assert_eq!(config.resume_environment_id.as_deref(), Some("env-1"));
918        assert!(config.verbose);
919        assert_eq!(config.pod_labels, labels);
920    }
921
922    #[test]
923    fn clone_keeps_the_config() {
924        let task = Bare
925            .task("p".to_string())
926            .model("haiku")
927            .pod_label("a", "b");
928        let copy = task.clone();
929        assert_eq!(
930            to_value(task.into_config()).expect("serialize"),
931            to_value(copy.into_config()).expect("serialize")
932        );
933    }
934}