use std::any::type_name;
use std::collections::BTreeMap;
use std::fmt;
use std::marker::PhantomData;
use ironflow_core::agent_role::{AgentRoleBinding, MAX_ROLE_NAME_LEN, RoleCostCap};
use ironflow_core::operations::agent::{Effort, PermissionMode};
use ironflow_core::retry::RetryPolicy;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use super::agent::{AgentStep, AgentStepConfig, Tool};
use crate::error::EngineError;
use crate::executor::StepOutput;
pub trait AgentRole {
type Input;
type Output: DeserializeOwned + JsonSchema;
fn name(&self) -> &str {
short_type_name(type_name::<Self>())
}
fn prompt(&self, input: Self::Input) -> String;
fn system_prompt(&self) -> Option<&str> {
None
}
fn append_system_prompt(&self) -> Option<&str> {
None
}
fn tools(&self) -> Vec<Tool> {
Vec::new()
}
fn permission_mode(&self) -> Option<PermissionMode> {
None
}
fn mcp_config(&self) -> Option<&str> {
None
}
fn strict_mcp_config(&self) -> bool {
false
}
fn model(&self) -> Option<&str> {
None
}
fn max_budget_usd(&self) -> Option<f64> {
None
}
fn max_turns(&self) -> Option<u32> {
None
}
fn effort(&self) -> Option<Effort> {
None
}
fn cost_cap(&self) -> Option<RoleCostCap> {
None
}
fn task(&self, input: Self::Input) -> AgentTask<Self::Output> {
AgentTask::from_role(self, input)
}
}
fn short_type_name(full: &'static str) -> &'static str {
let without_generics = full.split('<').next().unwrap_or(full);
without_generics
.rsplit("::")
.next()
.unwrap_or(without_generics)
}
pub struct AgentTask<T> {
config: AgentStepConfig,
_output: PhantomData<fn() -> T>,
}
impl<T> fmt::Debug for AgentTask<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentTask")
.field("config", &self.config)
.finish()
}
}
impl<T> Clone for AgentTask<T> {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
_output: PhantomData,
}
}
}
impl<T: JsonSchema> AgentTask<T> {
pub(crate) fn from_role<R: AgentRole<Output = T> + ?Sized>(role: &R, input: R::Input) -> Self {
let name = role.name();
assert!(!name.trim().is_empty(), "AgentRole::name must not be empty");
assert!(
name.trim() == name,
"AgentRole::name must not have leading or trailing whitespace"
);
assert!(
name.len() <= MAX_ROLE_NAME_LEN,
"AgentRole::name must be at most {MAX_ROLE_NAME_LEN} bytes"
);
let mut config = AgentStepConfig::new(&role.prompt(input));
if let Some(prompt) = role.system_prompt() {
config = config.system_prompt(prompt);
}
if let Some(prompt) = role.append_system_prompt() {
config = config.append_system_prompt(prompt);
}
if let Some(mcp) = role.mcp_config() {
config = config.mcp_config(mcp);
}
if role.strict_mcp_config() {
config = config.strict_mcp_config(true);
}
if let Some(model) = role.model() {
config = config.model(model);
}
if let Some(budget) = role.max_budget_usd() {
config = config.max_budget_usd(budget);
}
if let Some(turns) = role.max_turns() {
config = config.max_turns(turns);
}
if let Some(effort) = role.effort() {
config = config.effort(effort);
}
if let Some(mode) = role.permission_mode() {
config = config.permission_mode(mode);
}
for tool in role.tools() {
config = config.allow_tool(tool).into();
}
config = config.agent_role(AgentRoleBinding::new(name, role.cost_cap()));
config = config.output::<T>().into();
config.role = Some(name.to_string());
Self {
config,
_output: PhantomData,
}
}
}
impl<T> AgentTask<T> {
pub fn model(mut self, model: &str) -> Self {
self.config = self.config.model(model);
self
}
pub fn max_budget_usd(mut self, budget: f64) -> Self {
self.config = self.config.max_budget_usd(budget);
self
}
pub fn max_turns(mut self, turns: u32) -> Self {
self.config = self.config.max_turns(turns);
self
}
pub fn effort(mut self, effort: Effort) -> Self {
self.config = self.config.effort(effort);
self
}
pub fn working_dir(mut self, dir: &str) -> Self {
self.config = self.config.working_dir(dir);
self
}
pub fn resume(mut self, session_id: &str) -> Self {
self.config = self.config.resume(session_id);
self
}
pub fn session_id(mut self, session_id: &str) -> Self {
self.config = self.config.session_id(session_id);
self
}
pub fn resume_prompt(mut self, prompt: &str) -> Self {
self.config = self.config.resume_prompt(prompt);
self
}
pub fn resume_environment(mut self, environment_id: &str) -> Self {
self.config = self.config.resume_environment(environment_id);
self
}
pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
self.config = self.config.retry_policy(policy);
self
}
pub fn allow_failure(mut self) -> Self {
self.config = self.config.allow_failure();
self
}
pub fn verbose(mut self, enabled: bool) -> Self {
self.config = self.config.verbose(enabled);
self
}
pub fn pod_label(mut self, key: &str, value: &str) -> Self {
self.config = self.config.pod_label(key, value);
self
}
pub fn pod_labels(mut self, labels: BTreeMap<String, String>) -> Self {
self.config = self.config.pod_labels(labels);
self
}
}
impl<T: DeserializeOwned> AgentStep for AgentTask<T> {
type Answer = T;
fn into_config(self) -> AgentStepConfig {
self.config
}
fn answer(output: StepOutput) -> Result<T, EngineError> {
output.json()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::executor::StepArtifacts;
use ironflow_core::agent_role::CostPeriod;
use rust_decimal::Decimal;
use serde::Deserialize;
use serde_json::{Value, json, to_value};
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct Verdict {
approved: bool,
}
struct Bare;
impl AgentRole for Bare {
type Input = String;
type Output = Verdict;
fn prompt(&self, input: String) -> String {
input
}
}
struct Strict;
impl AgentRole for Strict {
type Input = String;
type Output = Verdict;
fn prompt(&self, input: String) -> String {
input
}
fn mcp_config(&self) -> Option<&str> {
Some("{}")
}
fn strict_mcp_config(&self) -> bool {
true
}
}
struct Full;
impl AgentRole for Full {
type Input = String;
type Output = Verdict;
fn prompt(&self, input: String) -> String {
input
}
fn system_prompt(&self) -> Option<&str> {
Some("base prompt")
}
fn append_system_prompt(&self) -> Option<&str> {
Some("persona")
}
fn max_budget_usd(&self) -> Option<f64> {
Some(0.5)
}
fn effort(&self) -> Option<Effort> {
Some(Effort::Low)
}
}
struct Capped;
impl AgentRole for Capped {
type Input = String;
type Output = Verdict;
fn prompt(&self, input: String) -> String {
input
}
fn name(&self) -> &str {
"reviewer"
}
fn cost_cap(&self) -> Option<RoleCostCap> {
Some(RoleCostCap::per_day(Decimal::TWO))
}
}
#[test]
fn a_capped_role_binds_its_tasks_with_its_cap_and_period() {
let config = Capped.task("p".to_string()).into_config();
assert_eq!(
config.agent_role,
Some(AgentRoleBinding {
name: "reviewer".to_string(),
cost_cap_usd: Some(Decimal::TWO),
period: CostPeriod::Day,
})
);
}
#[test]
fn a_role_without_cap_counts_under_its_default_name_over_a_month() {
let binding = Bare
.task("p".to_string())
.into_config()
.agent_role
.expect("every role is bound");
assert_eq!(binding.name, "Bare");
assert_eq!(binding.cost_cap_usd, None);
assert_eq!(binding.period, CostPeriod::Month);
}
#[test]
fn the_binding_and_the_recorded_role_share_the_name() {
let config = Capped.task("p".to_string()).into_config();
assert_eq!(config.role.as_deref(), Some("reviewer"));
assert_eq!(
config.agent_role.map(|b| b.name).as_deref(),
Some("reviewer")
);
}
#[test]
fn call_site_overrides_keep_the_binding() {
let config = Capped
.task("p".to_string())
.max_budget_usd(5.0)
.model("haiku")
.allow_failure()
.into_config();
assert_eq!(config.max_budget_usd, Some(5.0));
assert_eq!(
config.agent_role.map(|b| b.name),
Some("reviewer".to_string())
);
}
fn step_output(output: Value) -> StepOutput {
StepOutput {
output,
duration_ms: 0,
cost_usd: Decimal::ZERO,
input_tokens: None,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
output_tokens: None,
model: None,
debug_messages: None,
artifacts: StepArtifacts::default(),
account_id: None,
environment_id: None,
}
}
#[test]
fn a_role_with_only_a_prompt_takes_the_agent_config_new_defaults() {
let task = Bare.task("p".to_string());
let mut expected = AgentStepConfig::new("p").output::<Verdict>().into_config();
expected.role = Some("Bare".to_string());
assert_eq!(
to_value(task.into_config()).expect("serialize"),
to_value(expected).expect("serialize")
);
}
#[test]
fn a_role_without_tools_has_no_allowed_tools_and_keeps_its_schema() {
let config = Bare.task("p".to_string()).into_config();
assert!(config.allowed_tools.is_empty());
assert!(
config
.json_schema
.as_deref()
.is_some_and(|schema| schema.contains("approved"))
);
}
#[test]
fn strict_mcp_config_of_the_role_is_applied() {
let config = Strict.task("p".to_string()).into_config();
assert!(config.strict_mcp_config);
assert_eq!(config.mcp_config.as_deref(), Some("{}"));
assert!(!Bare.task("p".to_string()).into_config().strict_mcp_config);
}
#[test]
fn role_defaults_for_prompts_budget_and_effort_are_applied() {
let config = Full.task("p".to_string()).into_config();
assert_eq!(config.system_prompt.as_deref(), Some("base prompt"));
assert_eq!(config.append_system_prompt.as_deref(), Some("persona"));
assert_eq!(config.max_budget_usd, Some(0.5));
assert_eq!(config.effort, Some(Effort::Low));
let overridden = Full
.task("p".to_string())
.max_budget_usd(1.5)
.effort(Effort::High)
.into_config();
assert_eq!(overridden.max_budget_usd, Some(1.5));
assert_eq!(overridden.effort, Some(Effort::High));
assert_eq!(overridden.system_prompt.as_deref(), Some("base prompt"));
}
#[test]
fn task_answer_reads_the_typed_output() {
let answer =
<AgentTask<Verdict> as AgentStep>::answer(step_output(json!({"approved": true})))
.expect("matches Verdict");
assert_eq!(answer, Verdict { approved: true });
}
#[test]
fn task_answer_rejects_another_shape() {
let err = <AgentTask<Verdict> as AgentStep>::answer(step_output(json!(["approved"])))
.expect_err("not a Verdict");
assert!(matches!(err, EngineError::Serialization(_)));
}
#[test]
fn retry_policy_and_resume_settings_are_applied() {
let mut labels = BTreeMap::new();
labels.insert("team".to_string(), "qa".to_string());
let config = Bare
.task("p".to_string())
.retry_policy(RetryPolicy::new(2))
.resume("sess")
.session_id("sid")
.resume_prompt("go on")
.resume_environment("env-1")
.verbose(true)
.pod_labels(labels.clone())
.into_config();
assert_eq!(config.retry.map(|r| r.max_retries()), Some(2));
assert_eq!(config.resume_session_id.as_deref(), Some("sess"));
assert_eq!(config.session_id.as_deref(), Some("sid"));
assert_eq!(config.resume_prompt.as_deref(), Some("go on"));
assert_eq!(config.resume_environment_id.as_deref(), Some("env-1"));
assert!(config.verbose);
assert_eq!(config.pod_labels, labels);
}
#[test]
fn clone_keeps_the_config() {
let task = Bare
.task("p".to_string())
.model("haiku")
.pod_label("a", "b");
let copy = task.clone();
assert_eq!(
to_value(task.into_config()).expect("serialize"),
to_value(copy.into_config()).expect("serialize")
);
}
struct Named;
impl AgentRole for Named {
type Input = String;
type Output = Verdict;
fn name(&self) -> &str {
"code-reviewer"
}
fn prompt(&self, input: String) -> String {
input
}
}
struct Blank;
impl AgentRole for Blank {
type Input = String;
type Output = Verdict;
fn name(&self) -> &str {
" "
}
fn prompt(&self, input: String) -> String {
input
}
}
struct TooLong(String);
impl AgentRole for TooLong {
type Input = String;
type Output = Verdict;
fn name(&self) -> &str {
&self.0
}
fn prompt(&self, input: String) -> String {
input
}
}
#[test]
fn the_default_name_is_the_short_type_name() {
assert_eq!(Bare.name(), "Bare");
let config = Bare.task("p".to_string()).into_config();
assert_eq!(config.role.as_deref(), Some("Bare"));
}
#[test]
fn an_overridden_name_is_recorded() {
assert_eq!(Named.name(), "code-reviewer");
let config = Named.task("p".to_string()).into_config();
assert_eq!(config.role.as_deref(), Some("code-reviewer"));
}
#[test]
fn short_type_name_strips_path_and_generics() {
assert_eq!(short_type_name("a::b::Wrapper<c::Inner>"), "Wrapper");
assert_eq!(short_type_name("Plain"), "Plain");
}
#[test]
fn call_site_overrides_keep_the_role_name() {
let config = Bare
.task("p".to_string())
.model("haiku")
.allow_failure()
.into_config();
assert_eq!(config.model, "haiku");
assert!(config.allow_failure);
assert_eq!(config.role.as_deref(), Some("Bare"));
}
#[test]
#[should_panic(expected = "must not be empty")]
fn an_empty_role_name_panics() {
let _ = Blank.task("p".to_string());
}
#[test]
#[should_panic(expected = "must be at most")]
fn a_too_long_role_name_panics() {
let _ = TooLong("r".repeat(MAX_ROLE_NAME_LEN + 1)).task("p".to_string());
}
#[test]
#[should_panic(expected = "must not have leading or trailing whitespace")]
fn a_role_name_with_surrounding_whitespace_panics() {
let _ = TooLong(" Reviewer".to_string()).task("p".to_string());
}
#[test]
fn a_role_name_at_the_limit_is_accepted() {
let name = "r".repeat(MAX_ROLE_NAME_LEN);
let config = TooLong(name.clone()).task("p".to_string()).into_config();
assert_eq!(config.role, Some(name));
}
}