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}