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}