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::any::type_name;
10use std::collections::BTreeMap;
11use std::fmt;
12use std::marker::PhantomData;
13
14use ironflow_core::agent_role::{AgentRoleBinding, MAX_ROLE_NAME_LEN, RoleCostCap};
15use ironflow_core::operations::agent::{Effort, PermissionMode};
16use ironflow_core::retry::RetryPolicy;
17use schemars::JsonSchema;
18use serde::de::DeserializeOwned;
19
20use super::agent::{AgentStep, AgentStepConfig, Tool};
21use crate::error::EngineError;
22use crate::executor::StepOutput;
23
24/// A reusable agent with a fixed role: persona, tools, rights and output type.
25///
26/// Only [`prompt`](Self::prompt) is required. The persona, tools, permission
27/// mode, MCP config and output type are locked by the role: the caller of
28/// [`task`](Self::task) cannot change them (a role with more rights is another
29/// role). The model, budget, turn limit, effort and per-call settings are
30/// defaults the caller may override on the [`AgentTask`].
31///
32/// # Examples
33///
34/// ```
35/// use ironflow_engine::config::{AgentRole, AgentStep, Tool};
36/// use schemars::JsonSchema;
37/// use serde::Deserialize;
38///
39/// #[derive(Deserialize, JsonSchema)]
40/// struct Verdict {
41///     approved: bool,
42/// }
43///
44/// struct Reviewer;
45///
46/// impl AgentRole for Reviewer {
47///     type Input = String;
48///     type Output = Verdict;
49///
50///     fn prompt(&self, input: String) -> String {
51///         format!("Review: {input}")
52///     }
53///
54///     fn append_system_prompt(&self) -> Option<&str> {
55///         Some("You are a strict reviewer.")
56///     }
57///
58///     fn tools(&self) -> Vec<Tool> {
59///         vec![Tool::Read]
60///     }
61///
62///     fn max_turns(&self) -> Option<u32> {
63///         Some(4)
64///     }
65/// }
66///
67/// let config = Reviewer.task("diff".to_string()).max_budget_usd(0.10).into_config();
68/// assert_eq!(config.append_system_prompt.as_deref(), Some("You are a strict reviewer."));
69/// assert_eq!(config.allowed_tools, vec!["Read"]);
70/// assert!(config.json_schema.is_some());
71/// assert_eq!(config.max_budget_usd, Some(0.10));
72/// ```
73///
74/// Calling it from a workflow:
75///
76/// ```no_run
77/// # use ironflow_engine::config::AgentRole;
78/// # use ironflow_engine::context::WorkflowContext;
79/// # use ironflow_engine::error::EngineError;
80/// # use schemars::JsonSchema;
81/// # use serde::Deserialize;
82/// # #[derive(Deserialize, JsonSchema)]
83/// # struct Verdict { approved: bool }
84/// # struct Reviewer;
85/// # impl AgentRole for Reviewer {
86/// #     type Input = String;
87/// #     type Output = Verdict;
88/// #     fn prompt(&self, input: String) -> String { input }
89/// # }
90/// # async fn example(ctx: &mut WorkflowContext, diff: String) -> Result<(), EngineError> {
91/// let verdict: Verdict = ctx.agent("review", Reviewer.task(diff)).await?;
92/// # Ok(())
93/// # }
94/// ```
95pub trait AgentRole {
96    /// What the caller passes to [`task`](Self::task) to build the prompt.
97    type Input;
98
99    /// What the role answers; `ctx.agent` returns it.
100    type Output: DeserializeOwned + JsonSchema;
101
102    /// Name of the role, recorded on every step it runs and shown by the API,
103    /// CLI and dashboard. Defaults to the type name without its module path
104    /// (`Reviewer`). Override it to keep the name stable across a type rename.
105    ///
106    /// The name also keys the cost counter of the role, shared by every step
107    /// of every role with this name and capped by
108    /// [`cost_cap`](Self::cost_cap). Renaming the type changes the default
109    /// name: the steps, costs and counter of the role are then split between
110    /// the old and the new name. The caller of [`task`](Self::task) cannot
111    /// override it.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// # use ironflow_engine::config::{AgentRole, AgentStep};
117    /// # use schemars::JsonSchema;
118    /// # use serde::Deserialize;
119    /// # #[derive(Deserialize, JsonSchema)]
120    /// # struct Verdict { approved: bool }
121    /// struct Reviewer;
122    ///
123    /// impl AgentRole for Reviewer {
124    ///     type Input = String;
125    ///     type Output = Verdict;
126    ///
127    ///     fn prompt(&self, input: String) -> String {
128    ///         input
129    ///     }
130    /// }
131    ///
132    /// struct Named;
133    ///
134    /// impl AgentRole for Named {
135    ///     type Input = String;
136    ///     type Output = Verdict;
137    ///
138    ///     fn name(&self) -> &str {
139    ///         "code-reviewer"
140    ///     }
141    ///
142    ///     fn prompt(&self, input: String) -> String {
143    ///         input
144    ///     }
145    /// }
146    ///
147    /// assert_eq!(Reviewer.name(), "Reviewer");
148    /// assert_eq!(Named.name(), "code-reviewer");
149    /// let config = Named.task("diff".to_string()).into_config();
150    /// assert_eq!(config.role.as_deref(), Some("code-reviewer"));
151    /// ```
152    fn name(&self) -> &str {
153        short_type_name(type_name::<Self>())
154    }
155
156    /// The prompt of one task. Required.
157    fn prompt(&self, input: Self::Input) -> String;
158
159    /// Replaces the provider system prompt. Claude Code loses its own (tools,
160    /// skills) when it is replaced. `None` by default.
161    fn system_prompt(&self) -> Option<&str> {
162        None
163    }
164
165    /// Persona appended to the provider system prompt. Prefer it to
166    /// [`system_prompt`](Self::system_prompt) for a role with tools. `None` by
167    /// default.
168    fn append_system_prompt(&self) -> Option<&str> {
169        None
170    }
171
172    /// Tools the role may use. None by default.
173    fn tools(&self) -> Vec<Tool> {
174        Vec::new()
175    }
176
177    /// Permission mode of the role; `None` keeps the
178    /// `AgentConfig::new` default.
179    fn permission_mode(&self) -> Option<PermissionMode> {
180        None
181    }
182
183    /// MCP configuration, as JSON or a path. `None` by default.
184    fn mcp_config(&self) -> Option<&str> {
185        None
186    }
187
188    /// Restrict the MCP servers to [`mcp_config`](Self::mcp_config). `false` by
189    /// default.
190    fn strict_mcp_config(&self) -> bool {
191        false
192    }
193
194    /// Default model; `None` keeps the `AgentConfig::new` default. The
195    /// caller may override it.
196    fn model(&self) -> Option<&str> {
197        None
198    }
199
200    /// Default budget in USD. The caller may override it.
201    fn max_budget_usd(&self) -> Option<f64> {
202        None
203    }
204
205    /// Default turn limit. The caller may override it.
206    ///
207    /// A structured output needs at least 2 turns; like [`AgentStepConfig`],
208    /// the role does not validate it.
209    fn max_turns(&self) -> Option<u32> {
210        None
211    }
212
213    /// Default effort. The caller may override it.
214    fn effort(&self) -> Option<Effort> {
215        None
216    }
217
218    /// Cost cap of the role over a calendar period (UTC), across every run of
219    /// every workflow, counted under its [`name`](Self::name). `None` by
220    /// default: the cost is counted over a month, uncapped until an operator
221    /// sets a cap.
222    ///
223    /// Checked by the engine before each agent step of the role: once the cap
224    /// is reached, the step fails with `AgentError::RoleCostCapReached`
225    /// without calling the provider. A step already running may overshoot it
226    /// by at most its own `max_budget_usd`; a step without one holds all the
227    /// remaining room while it runs. An operator may lower the cap from the
228    /// API, never raise it.
229    fn cost_cap(&self) -> Option<RoleCostCap> {
230        None
231    }
232
233    /// Build one task of this role.
234    ///
235    /// # Panics
236    ///
237    /// Panics if [`name`](Self::name) is empty or blank, has leading or
238    /// trailing whitespace, or is longer than
239    /// [`MAX_ROLE_NAME_LEN`] bytes.
240    ///
241    /// # Examples
242    ///
243    /// ```
244    /// # use ironflow_engine::config::{AgentRole, AgentStep};
245    /// # use schemars::JsonSchema;
246    /// # use serde::Deserialize;
247    /// # #[derive(Deserialize, JsonSchema)]
248    /// # struct Verdict { approved: bool }
249    /// # struct Reviewer;
250    /// # impl AgentRole for Reviewer {
251    /// #     type Input = String;
252    /// #     type Output = Verdict;
253    /// #     fn prompt(&self, input: String) -> String { format!("Review: {input}") }
254    /// # }
255    /// let config = Reviewer.task("diff".to_string()).into_config();
256    /// assert_eq!(config.prompt, "Review: diff");
257    /// ```
258    fn task(&self, input: Self::Input) -> AgentTask<Self::Output> {
259        AgentTask::from_role(self, input)
260    }
261}
262
263/// Type name without its module path and generic arguments:
264/// `a::b::Wrapper<c::Inner>` gives `Wrapper`.
265fn short_type_name(full: &'static str) -> &'static str {
266    let without_generics = full.split('<').next().unwrap_or(full);
267    without_generics
268        .rsplit("::")
269        .next()
270        .unwrap_or(without_generics)
271}
272
273/// One call of an [`AgentRole`], answering with the `T` of the role.
274///
275/// The role locks its persona, tools, permission mode, MCP config and output
276/// type: none of them has a setter here, and each of these calls does not
277/// compile.
278///
279/// The name and cost cap of the role are locked too: no setter of the task
280/// changes or removes them, so every task of the role is charged to its counter.
281///
282/// The system prompt is locked:
283///
284/// ```compile_fail,E0599
285/// # use ironflow_engine::config::AgentRole;
286/// # use schemars::JsonSchema;
287/// # use serde::Deserialize;
288/// # #[derive(Deserialize, JsonSchema)]
289/// # struct Verdict { approved: bool }
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()).system_prompt("x");
297/// ```
298///
299/// The persona is locked:
300///
301/// ```compile_fail,E0599
302/// # use ironflow_engine::config::AgentRole;
303/// # use schemars::JsonSchema;
304/// # use serde::Deserialize;
305/// # #[derive(Deserialize, JsonSchema)]
306/// # struct Verdict { approved: bool }
307/// # struct Reviewer;
308/// # impl AgentRole for Reviewer {
309/// #     type Input = String;
310/// #     type Output = Verdict;
311/// #     fn prompt(&self, input: String) -> String { input }
312/// # }
313/// let _ = Reviewer.task("diff".to_string()).append_system_prompt("x");
314/// ```
315///
316/// The tools are locked:
317///
318/// ```compile_fail,E0599
319/// # use ironflow_engine::config::{AgentRole, Tool};
320/// # use schemars::JsonSchema;
321/// # use serde::Deserialize;
322/// # #[derive(Deserialize, JsonSchema)]
323/// # struct Verdict { approved: bool }
324/// # struct Reviewer;
325/// # impl AgentRole for Reviewer {
326/// #     type Input = String;
327/// #     type Output = Verdict;
328/// #     fn prompt(&self, input: String) -> String { input }
329/// # }
330/// let _ = Reviewer.task("diff".to_string()).allow_tool(Tool::Bash);
331/// ```
332///
333/// The permission mode is locked:
334///
335/// ```compile_fail,E0599
336/// # use ironflow_core::operations::agent::PermissionMode;
337/// # use ironflow_engine::config::AgentRole;
338/// # use schemars::JsonSchema;
339/// # use serde::Deserialize;
340/// # #[derive(Deserialize, JsonSchema)]
341/// # struct Verdict { approved: bool }
342/// # struct Reviewer;
343/// # impl AgentRole for Reviewer {
344/// #     type Input = String;
345/// #     type Output = Verdict;
346/// #     fn prompt(&self, input: String) -> String { input }
347/// # }
348/// let _ = Reviewer
349///     .task("diff".to_string())
350///     .permission_mode(PermissionMode::BypassPermissions);
351/// ```
352///
353/// The MCP config is locked:
354///
355/// ```compile_fail,E0599
356/// # use ironflow_engine::config::AgentRole;
357/// # use schemars::JsonSchema;
358/// # use serde::Deserialize;
359/// # #[derive(Deserialize, JsonSchema)]
360/// # struct Verdict { approved: bool }
361/// # struct Reviewer;
362/// # impl AgentRole for Reviewer {
363/// #     type Input = String;
364/// #     type Output = Verdict;
365/// #     fn prompt(&self, input: String) -> String { input }
366/// # }
367/// let _ = Reviewer.task("diff".to_string()).mcp_config("{}");
368/// ```
369///
370/// The output type is locked:
371///
372/// ```compile_fail,E0599
373/// # use ironflow_engine::config::AgentRole;
374/// # use schemars::JsonSchema;
375/// # use serde::Deserialize;
376/// # #[derive(Deserialize, JsonSchema)]
377/// # struct Verdict { approved: bool }
378/// # #[derive(Deserialize, JsonSchema)]
379/// # struct Other { n: u8 }
380/// # struct Reviewer;
381/// # impl AgentRole for Reviewer {
382/// #     type Input = String;
383/// #     type Output = Verdict;
384/// #     fn prompt(&self, input: String) -> String { input }
385/// # }
386/// let _ = Reviewer.task("diff".to_string()).output::<Other>();
387/// ```
388///
389/// The role name is locked:
390///
391/// ```compile_fail,E0599
392/// # use ironflow_engine::config::AgentRole;
393/// # use schemars::JsonSchema;
394/// # use serde::Deserialize;
395/// # #[derive(Deserialize, JsonSchema)]
396/// # struct Verdict { approved: bool }
397/// # struct Reviewer;
398/// # impl AgentRole for Reviewer {
399/// #     type Input = String;
400/// #     type Output = Verdict;
401/// #     fn prompt(&self, input: String) -> String { input }
402/// # }
403/// let _ = Reviewer.task("diff".to_string()).role("x");
404/// ```
405///
406/// Taking the raw config with [`AgentStep::into_config`] and editing it loses
407/// the typed answer (`ctx.agent` then returns a [`StepOutput`]), so there is no
408/// silent override.
409///
410/// A call-site override changes the step config, so it changes the hash of the
411/// `RecordReplayProvider` fixture: a fixture recorded for the role defaults
412/// does not match an overridden task.
413///
414/// # Examples
415///
416/// ```
417/// # use ironflow_engine::config::{AgentRole, AgentStep};
418/// # use schemars::JsonSchema;
419/// # use serde::Deserialize;
420/// # #[derive(Deserialize, JsonSchema)]
421/// # struct Verdict { approved: bool }
422/// # struct Reviewer;
423/// # impl AgentRole for Reviewer {
424/// #     type Input = String;
425/// #     type Output = Verdict;
426/// #     fn prompt(&self, input: String) -> String { input }
427/// # }
428/// let task = Reviewer.task("diff".to_string()).model("haiku").allow_failure();
429/// let config = task.into_config();
430/// assert_eq!(config.model, "haiku");
431/// assert!(config.allow_failure);
432/// ```
433pub struct AgentTask<T> {
434    config: AgentStepConfig,
435    _output: PhantomData<fn() -> T>,
436}
437
438// Manual impls: a derive would require `T: Debug` / `T: Clone`, though `T` is
439// only a marker (same reasoning as `WithSchema` in `ironflow-core`).
440impl<T> fmt::Debug for AgentTask<T> {
441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442        f.debug_struct("AgentTask")
443            .field("config", &self.config)
444            .finish()
445    }
446}
447
448impl<T> Clone for AgentTask<T> {
449    fn clone(&self) -> Self {
450        Self {
451            config: self.config.clone(),
452            _output: PhantomData,
453        }
454    }
455}
456
457impl<T: JsonSchema> AgentTask<T> {
458    /// Builds the task from the role defaults. `pub(crate)`: no other crate can
459    /// wrap an arbitrary config in an [`AgentTask`].
460    ///
461    /// # Panics
462    ///
463    /// Panics if [`AgentRole::name`] is empty or blank, has leading or
464    /// trailing whitespace, or is longer than
465    /// [`MAX_ROLE_NAME_LEN`] bytes.
466    pub(crate) fn from_role<R: AgentRole<Output = T> + ?Sized>(role: &R, input: R::Input) -> Self {
467        let name = role.name();
468        assert!(!name.trim().is_empty(), "AgentRole::name must not be empty");
469        assert!(
470            name.trim() == name,
471            "AgentRole::name must not have leading or trailing whitespace"
472        );
473        assert!(
474            name.len() <= MAX_ROLE_NAME_LEN,
475            "AgentRole::name must be at most {MAX_ROLE_NAME_LEN} bytes"
476        );
477        let mut config = AgentStepConfig::new(&role.prompt(input));
478        if let Some(prompt) = role.system_prompt() {
479            config = config.system_prompt(prompt);
480        }
481        if let Some(prompt) = role.append_system_prompt() {
482            config = config.append_system_prompt(prompt);
483        }
484        if let Some(mcp) = role.mcp_config() {
485            config = config.mcp_config(mcp);
486        }
487        if role.strict_mcp_config() {
488            config = config.strict_mcp_config(true);
489        }
490        if let Some(model) = role.model() {
491            config = config.model(model);
492        }
493        if let Some(budget) = role.max_budget_usd() {
494            config = config.max_budget_usd(budget);
495        }
496        if let Some(turns) = role.max_turns() {
497            config = config.max_turns(turns);
498        }
499        if let Some(effort) = role.effort() {
500            config = config.effort(effort);
501        }
502        if let Some(mode) = role.permission_mode() {
503            config = config.permission_mode(mode);
504        }
505        for tool in role.tools() {
506            config = config.allow_tool(tool).into();
507        }
508        config = config.agent_role(AgentRoleBinding::new(name, role.cost_cap()));
509        config = config.output::<T>().into();
510        config.role = Some(name.to_string());
511        Self {
512            config,
513            _output: PhantomData,
514        }
515    }
516}
517
518impl<T> AgentTask<T> {
519    /// Override the model of the role for this call.
520    ///
521    /// # Examples
522    ///
523    /// ```
524    /// # use ironflow_engine::config::{AgentRole, AgentStep};
525    /// # use schemars::JsonSchema;
526    /// # use serde::Deserialize;
527    /// # #[derive(Deserialize, JsonSchema)]
528    /// # struct Verdict { approved: bool }
529    /// # struct Reviewer;
530    /// # impl AgentRole for Reviewer {
531    /// #     type Input = String;
532    /// #     type Output = Verdict;
533    /// #     fn prompt(&self, input: String) -> String { input }
534    /// # }
535    /// let config = Reviewer.task("diff".to_string()).model("haiku").into_config();
536    /// assert_eq!(config.model, "haiku");
537    /// ```
538    pub fn model(mut self, model: &str) -> Self {
539        self.config = self.config.model(model);
540        self
541    }
542
543    /// Override the budget of the role for this call.
544    ///
545    /// # Examples
546    ///
547    /// ```
548    /// # use ironflow_engine::config::{AgentRole, AgentStep};
549    /// # use schemars::JsonSchema;
550    /// # use serde::Deserialize;
551    /// # #[derive(Deserialize, JsonSchema)]
552    /// # struct Verdict { approved: bool }
553    /// # struct Reviewer;
554    /// # impl AgentRole for Reviewer {
555    /// #     type Input = String;
556    /// #     type Output = Verdict;
557    /// #     fn prompt(&self, input: String) -> String { input }
558    /// # }
559    /// let config = Reviewer.task("diff".to_string()).max_budget_usd(0.10).into_config();
560    /// assert_eq!(config.max_budget_usd, Some(0.10));
561    /// ```
562    pub fn max_budget_usd(mut self, budget: f64) -> Self {
563        self.config = self.config.max_budget_usd(budget);
564        self
565    }
566
567    /// Override the turn limit of the role for this call.
568    ///
569    /// # Examples
570    ///
571    /// ```
572    /// # use ironflow_engine::config::{AgentRole, AgentStep};
573    /// # use schemars::JsonSchema;
574    /// # use serde::Deserialize;
575    /// # #[derive(Deserialize, JsonSchema)]
576    /// # struct Verdict { approved: bool }
577    /// # struct Reviewer;
578    /// # impl AgentRole for Reviewer {
579    /// #     type Input = String;
580    /// #     type Output = Verdict;
581    /// #     fn prompt(&self, input: String) -> String { input }
582    /// # }
583    /// let config = Reviewer.task("diff".to_string()).max_turns(6).into_config();
584    /// assert_eq!(config.max_turns, Some(6));
585    /// ```
586    pub fn max_turns(mut self, turns: u32) -> Self {
587        self.config = self.config.max_turns(turns);
588        self
589    }
590
591    /// Override the effort of the role for this call.
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// # use ironflow_engine::config::{AgentRole, AgentStep};
597    /// # use ironflow_core::operations::agent::Effort;
598    /// # use schemars::JsonSchema;
599    /// # use serde::Deserialize;
600    /// # #[derive(Deserialize, JsonSchema)]
601    /// # struct Verdict { approved: bool }
602    /// # struct Reviewer;
603    /// # impl AgentRole for Reviewer {
604    /// #     type Input = String;
605    /// #     type Output = Verdict;
606    /// #     fn prompt(&self, input: String) -> String { input }
607    /// # }
608    /// let config = Reviewer.task("diff".to_string()).effort(Effort::High).into_config();
609    /// assert!(matches!(config.effort, Some(Effort::High)));
610    /// ```
611    pub fn effort(mut self, effort: Effort) -> Self {
612        self.config = self.config.effort(effort);
613        self
614    }
615
616    /// Set the working directory of the call.
617    ///
618    /// # Examples
619    ///
620    /// ```
621    /// # use ironflow_engine::config::{AgentRole, AgentStep};
622    /// # use schemars::JsonSchema;
623    /// # use serde::Deserialize;
624    /// # #[derive(Deserialize, JsonSchema)]
625    /// # struct Verdict { approved: bool }
626    /// # struct Reviewer;
627    /// # impl AgentRole for Reviewer {
628    /// #     type Input = String;
629    /// #     type Output = Verdict;
630    /// #     fn prompt(&self, input: String) -> String { input }
631    /// # }
632    /// let config = Reviewer.task("diff".to_string()).working_dir("/repo").into_config();
633    /// assert_eq!(config.working_dir.as_deref(), Some("/repo"));
634    /// ```
635    pub fn working_dir(mut self, dir: &str) -> Self {
636        self.config = self.config.working_dir(dir);
637        self
638    }
639
640    /// Resume a Claude Code session.
641    ///
642    /// # Examples
643    ///
644    /// ```
645    /// # use ironflow_engine::config::{AgentRole, AgentStep};
646    /// # use schemars::JsonSchema;
647    /// # use serde::Deserialize;
648    /// # #[derive(Deserialize, JsonSchema)]
649    /// # struct Verdict { approved: bool }
650    /// # struct Reviewer;
651    /// # impl AgentRole for Reviewer {
652    /// #     type Input = String;
653    /// #     type Output = Verdict;
654    /// #     fn prompt(&self, input: String) -> String { input }
655    /// # }
656    /// let config = Reviewer.task("diff".to_string()).resume("sess").into_config();
657    /// assert_eq!(config.resume_session_id.as_deref(), Some("sess"));
658    /// ```
659    pub fn resume(mut self, session_id: &str) -> Self {
660        self.config = self.config.resume(session_id);
661        self
662    }
663
664    /// Pin the session id of the call.
665    ///
666    /// # Examples
667    ///
668    /// ```
669    /// # use ironflow_engine::config::{AgentRole, AgentStep};
670    /// # use schemars::JsonSchema;
671    /// # use serde::Deserialize;
672    /// # #[derive(Deserialize, JsonSchema)]
673    /// # struct Verdict { approved: bool }
674    /// # struct Reviewer;
675    /// # impl AgentRole for Reviewer {
676    /// #     type Input = String;
677    /// #     type Output = Verdict;
678    /// #     fn prompt(&self, input: String) -> String { input }
679    /// # }
680    /// let config = Reviewer.task("diff".to_string()).session_id("sid").into_config();
681    /// assert_eq!(config.session_id.as_deref(), Some("sid"));
682    /// ```
683    pub fn session_id(mut self, session_id: &str) -> Self {
684        self.config = self.config.session_id(session_id);
685        self
686    }
687
688    /// Set the prompt sent when the step is resumed after a lost lease.
689    ///
690    /// # Examples
691    ///
692    /// ```
693    /// # use ironflow_engine::config::{AgentRole, AgentStep};
694    /// # use schemars::JsonSchema;
695    /// # use serde::Deserialize;
696    /// # #[derive(Deserialize, JsonSchema)]
697    /// # struct Verdict { approved: bool }
698    /// # struct Reviewer;
699    /// # impl AgentRole for Reviewer {
700    /// #     type Input = String;
701    /// #     type Output = Verdict;
702    /// #     fn prompt(&self, input: String) -> String { input }
703    /// # }
704    /// let config = Reviewer.task("diff".to_string()).resume_prompt("go on").into_config();
705    /// assert_eq!(config.resume_prompt.as_deref(), Some("go on"));
706    /// ```
707    pub fn resume_prompt(mut self, prompt: &str) -> Self {
708        self.config = self.config.resume_prompt(prompt);
709        self
710    }
711
712    /// Resume the environment handed out by an earlier step.
713    ///
714    /// # Examples
715    ///
716    /// ```
717    /// # use ironflow_engine::config::{AgentRole, AgentStep};
718    /// # use schemars::JsonSchema;
719    /// # use serde::Deserialize;
720    /// # #[derive(Deserialize, JsonSchema)]
721    /// # struct Verdict { approved: bool }
722    /// # struct Reviewer;
723    /// # impl AgentRole for Reviewer {
724    /// #     type Input = String;
725    /// #     type Output = Verdict;
726    /// #     fn prompt(&self, input: String) -> String { input }
727    /// # }
728    /// let config = Reviewer.task("diff".to_string()).resume_environment("env-1").into_config();
729    /// assert_eq!(config.resume_environment_id.as_deref(), Some("env-1"));
730    /// ```
731    pub fn resume_environment(mut self, environment_id: &str) -> Self {
732        self.config = self.config.resume_environment(environment_id);
733        self
734    }
735
736    /// Set the retry policy of the step.
737    ///
738    /// # Examples
739    ///
740    /// ```
741    /// # use ironflow_engine::config::{AgentRole, AgentStep};
742    /// # use ironflow_core::retry::RetryPolicy;
743    /// # use schemars::JsonSchema;
744    /// # use serde::Deserialize;
745    /// # #[derive(Deserialize, JsonSchema)]
746    /// # struct Verdict { approved: bool }
747    /// # struct Reviewer;
748    /// # impl AgentRole for Reviewer {
749    /// #     type Input = String;
750    /// #     type Output = Verdict;
751    /// #     fn prompt(&self, input: String) -> String { input }
752    /// # }
753    /// let config = Reviewer.task("diff".to_string()).retry_policy(RetryPolicy::new(2)).into_config();
754    /// assert_eq!(config.retry.map(|r| r.max_retries()), Some(2));
755    /// ```
756    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
757        self.config = self.config.retry_policy(policy);
758        self
759    }
760
761    /// Let the run go on when this step fails.
762    ///
763    /// # Examples
764    ///
765    /// ```
766    /// # use ironflow_engine::config::{AgentRole, AgentStep};
767    /// # use schemars::JsonSchema;
768    /// # use serde::Deserialize;
769    /// # #[derive(Deserialize, JsonSchema)]
770    /// # struct Verdict { approved: bool }
771    /// # struct Reviewer;
772    /// # impl AgentRole for Reviewer {
773    /// #     type Input = String;
774    /// #     type Output = Verdict;
775    /// #     fn prompt(&self, input: String) -> String { input }
776    /// # }
777    /// let config = Reviewer.task("diff".to_string()).allow_failure().into_config();
778    /// assert!(config.allow_failure);
779    /// ```
780    pub fn allow_failure(mut self) -> Self {
781        self.config = self.config.allow_failure();
782        self
783    }
784
785    /// Record the agent conversation of the call.
786    ///
787    /// # Examples
788    ///
789    /// ```
790    /// # use ironflow_engine::config::{AgentRole, AgentStep};
791    /// # use schemars::JsonSchema;
792    /// # use serde::Deserialize;
793    /// # #[derive(Deserialize, JsonSchema)]
794    /// # struct Verdict { approved: bool }
795    /// # struct Reviewer;
796    /// # impl AgentRole for Reviewer {
797    /// #     type Input = String;
798    /// #     type Output = Verdict;
799    /// #     fn prompt(&self, input: String) -> String { input }
800    /// # }
801    /// let config = Reviewer.task("diff".to_string()).verbose(true).into_config();
802    /// assert!(config.verbose);
803    /// ```
804    pub fn verbose(mut self, enabled: bool) -> Self {
805        self.config = self.config.verbose(enabled);
806        self
807    }
808
809    /// Add a label to the pod running the call.
810    ///
811    /// # Examples
812    ///
813    /// ```
814    /// # use ironflow_engine::config::{AgentRole, AgentStep};
815    /// # use schemars::JsonSchema;
816    /// # use serde::Deserialize;
817    /// # #[derive(Deserialize, JsonSchema)]
818    /// # struct Verdict { approved: bool }
819    /// # struct Reviewer;
820    /// # impl AgentRole for Reviewer {
821    /// #     type Input = String;
822    /// #     type Output = Verdict;
823    /// #     fn prompt(&self, input: String) -> String { input }
824    /// # }
825    /// let config = Reviewer.task("diff".to_string()).pod_label("team", "qa").into_config();
826    /// assert_eq!(config.pod_labels.get("team").map(String::as_str), Some("qa"));
827    /// ```
828    pub fn pod_label(mut self, key: &str, value: &str) -> Self {
829        self.config = self.config.pod_label(key, value);
830        self
831    }
832
833    /// Set the labels of the pod running the call.
834    ///
835    /// # Examples
836    ///
837    /// ```
838    /// # use ironflow_engine::config::{AgentRole, AgentStep};
839    /// # use std::collections::BTreeMap;
840    /// # use schemars::JsonSchema;
841    /// # use serde::Deserialize;
842    /// # #[derive(Deserialize, JsonSchema)]
843    /// # struct Verdict { approved: bool }
844    /// # struct Reviewer;
845    /// # impl AgentRole for Reviewer {
846    /// #     type Input = String;
847    /// #     type Output = Verdict;
848    /// #     fn prompt(&self, input: String) -> String { input }
849    /// # }
850    /// let labels = BTreeMap::from([("team".to_string(), "qa".to_string())]);
851    /// let config = Reviewer.task("diff".to_string()).pod_labels(labels.clone()).into_config();
852    /// assert_eq!(config.pod_labels, labels);
853    /// ```
854    pub fn pod_labels(mut self, labels: BTreeMap<String, String>) -> Self {
855        self.config = self.config.pod_labels(labels);
856        self
857    }
858}
859
860impl<T: DeserializeOwned> AgentStep for AgentTask<T> {
861    type Answer = T;
862
863    fn into_config(self) -> AgentStepConfig {
864        self.config
865    }
866
867    fn answer(output: StepOutput) -> Result<T, EngineError> {
868        output.json()
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use crate::executor::StepArtifacts;
876    use ironflow_core::agent_role::CostPeriod;
877    use rust_decimal::Decimal;
878    use serde::Deserialize;
879    use serde_json::{Value, json, to_value};
880
881    #[derive(Debug, PartialEq, Deserialize, JsonSchema)]
882    struct Verdict {
883        approved: bool,
884    }
885
886    /// Implements only the required method.
887    struct Bare;
888
889    impl AgentRole for Bare {
890        type Input = String;
891        type Output = Verdict;
892
893        fn prompt(&self, input: String) -> String {
894            input
895        }
896    }
897
898    /// Locks MCP in strict mode.
899    struct Strict;
900
901    impl AgentRole for Strict {
902        type Input = String;
903        type Output = Verdict;
904
905        fn prompt(&self, input: String) -> String {
906            input
907        }
908
909        fn mcp_config(&self) -> Option<&str> {
910            Some("{}")
911        }
912
913        fn strict_mcp_config(&self) -> bool {
914            true
915        }
916    }
917
918    /// Sets every default the other test roles leave out.
919    struct Full;
920
921    impl AgentRole for Full {
922        type Input = String;
923        type Output = Verdict;
924
925        fn prompt(&self, input: String) -> String {
926            input
927        }
928
929        fn system_prompt(&self) -> Option<&str> {
930            Some("base prompt")
931        }
932
933        fn append_system_prompt(&self) -> Option<&str> {
934            Some("persona")
935        }
936
937        fn max_budget_usd(&self) -> Option<f64> {
938            Some(0.5)
939        }
940
941        fn effort(&self) -> Option<Effort> {
942            Some(Effort::Low)
943        }
944    }
945
946    /// Named and capped at 2 USD a day.
947    struct Capped;
948
949    impl AgentRole for Capped {
950        type Input = String;
951        type Output = Verdict;
952
953        fn prompt(&self, input: String) -> String {
954            input
955        }
956
957        fn name(&self) -> &str {
958            "reviewer"
959        }
960
961        fn cost_cap(&self) -> Option<RoleCostCap> {
962            Some(RoleCostCap::per_day(Decimal::TWO))
963        }
964    }
965
966    #[test]
967    fn a_capped_role_binds_its_tasks_with_its_cap_and_period() {
968        let config = Capped.task("p".to_string()).into_config();
969        assert_eq!(
970            config.agent_role,
971            Some(AgentRoleBinding {
972                name: "reviewer".to_string(),
973                cost_cap_usd: Some(Decimal::TWO),
974                period: CostPeriod::Day,
975            })
976        );
977    }
978
979    #[test]
980    fn a_role_without_cap_counts_under_its_default_name_over_a_month() {
981        let binding = Bare
982            .task("p".to_string())
983            .into_config()
984            .agent_role
985            .expect("every role is bound");
986        assert_eq!(binding.name, "Bare");
987        assert_eq!(binding.cost_cap_usd, None);
988        assert_eq!(binding.period, CostPeriod::Month);
989    }
990
991    #[test]
992    fn the_binding_and_the_recorded_role_share_the_name() {
993        let config = Capped.task("p".to_string()).into_config();
994        assert_eq!(config.role.as_deref(), Some("reviewer"));
995        assert_eq!(
996            config.agent_role.map(|b| b.name).as_deref(),
997            Some("reviewer")
998        );
999    }
1000
1001    #[test]
1002    fn call_site_overrides_keep_the_binding() {
1003        let config = Capped
1004            .task("p".to_string())
1005            .max_budget_usd(5.0)
1006            .model("haiku")
1007            .allow_failure()
1008            .into_config();
1009        assert_eq!(config.max_budget_usd, Some(5.0));
1010        assert_eq!(
1011            config.agent_role.map(|b| b.name),
1012            Some("reviewer".to_string())
1013        );
1014    }
1015
1016    fn step_output(output: Value) -> StepOutput {
1017        StepOutput {
1018            output,
1019            duration_ms: 0,
1020            cost_usd: Decimal::ZERO,
1021            input_tokens: None,
1022            cache_read_input_tokens: None,
1023            cache_creation_input_tokens: None,
1024            output_tokens: None,
1025            model: None,
1026            debug_messages: None,
1027            artifacts: StepArtifacts::default(),
1028            account_id: None,
1029            environment_id: None,
1030        }
1031    }
1032
1033    #[test]
1034    fn a_role_with_only_a_prompt_takes_the_agent_config_new_defaults() {
1035        let task = Bare.task("p".to_string());
1036        let mut expected = AgentStepConfig::new("p").output::<Verdict>().into_config();
1037        expected.role = Some("Bare".to_string());
1038        assert_eq!(
1039            to_value(task.into_config()).expect("serialize"),
1040            to_value(expected).expect("serialize")
1041        );
1042    }
1043
1044    #[test]
1045    fn a_role_without_tools_has_no_allowed_tools_and_keeps_its_schema() {
1046        let config = Bare.task("p".to_string()).into_config();
1047        assert!(config.allowed_tools.is_empty());
1048        assert!(
1049            config
1050                .json_schema
1051                .as_deref()
1052                .is_some_and(|schema| schema.contains("approved"))
1053        );
1054    }
1055
1056    #[test]
1057    fn strict_mcp_config_of_the_role_is_applied() {
1058        let config = Strict.task("p".to_string()).into_config();
1059        assert!(config.strict_mcp_config);
1060        assert_eq!(config.mcp_config.as_deref(), Some("{}"));
1061        assert!(!Bare.task("p".to_string()).into_config().strict_mcp_config);
1062    }
1063
1064    #[test]
1065    fn role_defaults_for_prompts_budget_and_effort_are_applied() {
1066        let config = Full.task("p".to_string()).into_config();
1067        assert_eq!(config.system_prompt.as_deref(), Some("base prompt"));
1068        assert_eq!(config.append_system_prompt.as_deref(), Some("persona"));
1069        assert_eq!(config.max_budget_usd, Some(0.5));
1070        assert_eq!(config.effort, Some(Effort::Low));
1071        let overridden = Full
1072            .task("p".to_string())
1073            .max_budget_usd(1.5)
1074            .effort(Effort::High)
1075            .into_config();
1076        assert_eq!(overridden.max_budget_usd, Some(1.5));
1077        assert_eq!(overridden.effort, Some(Effort::High));
1078        assert_eq!(overridden.system_prompt.as_deref(), Some("base prompt"));
1079    }
1080
1081    #[test]
1082    fn task_answer_reads_the_typed_output() {
1083        let answer =
1084            <AgentTask<Verdict> as AgentStep>::answer(step_output(json!({"approved": true})))
1085                .expect("matches Verdict");
1086        assert_eq!(answer, Verdict { approved: true });
1087    }
1088
1089    #[test]
1090    fn task_answer_rejects_another_shape() {
1091        let err = <AgentTask<Verdict> as AgentStep>::answer(step_output(json!(["approved"])))
1092            .expect_err("not a Verdict");
1093        assert!(matches!(err, EngineError::Serialization(_)));
1094    }
1095
1096    #[test]
1097    fn retry_policy_and_resume_settings_are_applied() {
1098        let mut labels = BTreeMap::new();
1099        labels.insert("team".to_string(), "qa".to_string());
1100        let config = Bare
1101            .task("p".to_string())
1102            .retry_policy(RetryPolicy::new(2))
1103            .resume("sess")
1104            .session_id("sid")
1105            .resume_prompt("go on")
1106            .resume_environment("env-1")
1107            .verbose(true)
1108            .pod_labels(labels.clone())
1109            .into_config();
1110        assert_eq!(config.retry.map(|r| r.max_retries()), Some(2));
1111        assert_eq!(config.resume_session_id.as_deref(), Some("sess"));
1112        assert_eq!(config.session_id.as_deref(), Some("sid"));
1113        assert_eq!(config.resume_prompt.as_deref(), Some("go on"));
1114        assert_eq!(config.resume_environment_id.as_deref(), Some("env-1"));
1115        assert!(config.verbose);
1116        assert_eq!(config.pod_labels, labels);
1117    }
1118
1119    #[test]
1120    fn clone_keeps_the_config() {
1121        let task = Bare
1122            .task("p".to_string())
1123            .model("haiku")
1124            .pod_label("a", "b");
1125        let copy = task.clone();
1126        assert_eq!(
1127            to_value(task.into_config()).expect("serialize"),
1128            to_value(copy.into_config()).expect("serialize")
1129        );
1130    }
1131
1132    /// Overrides the default name.
1133    struct Named;
1134
1135    impl AgentRole for Named {
1136        type Input = String;
1137        type Output = Verdict;
1138
1139        fn name(&self) -> &str {
1140            "code-reviewer"
1141        }
1142
1143        fn prompt(&self, input: String) -> String {
1144            input
1145        }
1146    }
1147
1148    /// Returns a blank name.
1149    struct Blank;
1150
1151    impl AgentRole for Blank {
1152        type Input = String;
1153        type Output = Verdict;
1154
1155        fn name(&self) -> &str {
1156            "  "
1157        }
1158
1159        fn prompt(&self, input: String) -> String {
1160            input
1161        }
1162    }
1163
1164    /// Returns a name one byte over the limit.
1165    struct TooLong(String);
1166
1167    impl AgentRole for TooLong {
1168        type Input = String;
1169        type Output = Verdict;
1170
1171        fn name(&self) -> &str {
1172            &self.0
1173        }
1174
1175        fn prompt(&self, input: String) -> String {
1176            input
1177        }
1178    }
1179
1180    #[test]
1181    fn the_default_name_is_the_short_type_name() {
1182        assert_eq!(Bare.name(), "Bare");
1183        let config = Bare.task("p".to_string()).into_config();
1184        assert_eq!(config.role.as_deref(), Some("Bare"));
1185    }
1186
1187    #[test]
1188    fn an_overridden_name_is_recorded() {
1189        assert_eq!(Named.name(), "code-reviewer");
1190        let config = Named.task("p".to_string()).into_config();
1191        assert_eq!(config.role.as_deref(), Some("code-reviewer"));
1192    }
1193
1194    #[test]
1195    fn short_type_name_strips_path_and_generics() {
1196        assert_eq!(short_type_name("a::b::Wrapper<c::Inner>"), "Wrapper");
1197        assert_eq!(short_type_name("Plain"), "Plain");
1198    }
1199
1200    #[test]
1201    fn call_site_overrides_keep_the_role_name() {
1202        let config = Bare
1203            .task("p".to_string())
1204            .model("haiku")
1205            .allow_failure()
1206            .into_config();
1207        assert_eq!(config.model, "haiku");
1208        assert!(config.allow_failure);
1209        assert_eq!(config.role.as_deref(), Some("Bare"));
1210    }
1211
1212    #[test]
1213    #[should_panic(expected = "must not be empty")]
1214    fn an_empty_role_name_panics() {
1215        let _ = Blank.task("p".to_string());
1216    }
1217
1218    #[test]
1219    #[should_panic(expected = "must be at most")]
1220    fn a_too_long_role_name_panics() {
1221        let _ = TooLong("r".repeat(MAX_ROLE_NAME_LEN + 1)).task("p".to_string());
1222    }
1223
1224    #[test]
1225    #[should_panic(expected = "must not have leading or trailing whitespace")]
1226    fn a_role_name_with_surrounding_whitespace_panics() {
1227        let _ = TooLong(" Reviewer".to_string()).task("p".to_string());
1228    }
1229
1230    #[test]
1231    fn a_role_name_at_the_limit_is_accepted() {
1232        let name = "r".repeat(MAX_ROLE_NAME_LEN);
1233        let config = TooLong(name.clone()).task("p".to_string()).into_config();
1234        assert_eq!(config.role, Some(name));
1235    }
1236}