use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
agent::{AgentOrigin, PromptPolicy, ToolCapability, BUILTIN_TOOL_CAPABILITIES},
app::agent_binding::{AgentBinder, AgentInvocation, AgentRole, BoundRuntime},
app::sdk_config,
workflow::{
freeze_directory_identity, freeze_executable_identity, ExecutableIdentity, NodeExecution,
ResolvedAgent, ResolvedCommand, ResolvedNode,
},
};
pub(crate) trait PlanHost: Send + Sync {
fn workspace(&self) -> &Path;
fn config(&self) -> &crate::config::Config;
fn catalog(&self) -> &crate::agent::AgentCatalog;
fn available_tools(&self) -> &crate::agent::AgentCapabilities;
fn resolve_executable(&self, executable: &str)
-> anyhow::Result<(PathBuf, ExecutableIdentity)>;
}
pub(crate) struct DiscoveringPlanHost<'a> {
workspace: &'a Path,
config: &'a crate::config::Config,
catalog: crate::agent::AgentCatalog,
available_tools: &'a crate::agent::AgentCapabilities,
}
impl<'a> DiscoveringPlanHost<'a> {
pub(crate) fn new(
workspace: &'a Path,
config: &'a crate::config::Config,
available_tools: &'a crate::agent::AgentCapabilities,
workflow_entry: &Path,
) -> anyhow::Result<Self> {
let home = crate::paths::home_dir();
let trust = if std::env::var_os("RHO_TRUST_PROJECT_AGENTS").as_deref()
== Some(std::ffi::OsStr::new("1"))
{
crate::agent::ProjectTrust::Trusted
} else {
crate::agent::ProjectTrust::Untrusted
};
Ok(Self {
workspace,
config,
catalog: crate::agent::AgentCatalog::discover_for_workflow_entry(
workspace,
workflow_entry,
home.as_deref(),
trust,
)?,
available_tools,
})
}
}
impl PlanHost for DiscoveringPlanHost<'_> {
fn workspace(&self) -> &Path {
self.workspace
}
fn config(&self) -> &crate::config::Config {
self.config
}
fn catalog(&self) -> &crate::agent::AgentCatalog {
&self.catalog
}
fn available_tools(&self) -> &crate::agent::AgentCapabilities {
self.available_tools
}
fn resolve_executable(
&self,
executable: &str,
) -> anyhow::Result<(PathBuf, ExecutableIdentity)> {
let path = resolve_executable_path(executable, self.workspace)?;
let identity = freeze_executable_identity(&path)?;
Ok((path, identity))
}
}
pub(crate) struct AuthorizedPlanHost<'a> {
workspace: &'a Path,
config: &'a crate::config::Config,
catalog: &'a crate::agent::AgentCatalog,
available_tools: &'a crate::agent::AgentCapabilities,
executables: &'a BTreeMap<String, ExecutableIdentity>,
}
impl<'a> AuthorizedPlanHost<'a> {
pub(crate) fn new(
workspace: &'a Path,
config: &'a crate::config::Config,
catalog: &'a crate::agent::AgentCatalog,
available_tools: &'a crate::agent::AgentCapabilities,
executables: &'a BTreeMap<String, ExecutableIdentity>,
) -> Self {
Self {
workspace,
config,
catalog,
available_tools,
executables,
}
}
}
impl PlanHost for AuthorizedPlanHost<'_> {
fn workspace(&self) -> &Path {
self.workspace
}
fn config(&self) -> &crate::config::Config {
self.config
}
fn catalog(&self) -> &crate::agent::AgentCatalog {
self.catalog
}
fn available_tools(&self) -> &crate::agent::AgentCapabilities {
self.available_tools
}
fn resolve_executable(
&self,
executable: &str,
) -> anyhow::Result<(PathBuf, ExecutableIdentity)> {
let identity = self.executables.get(executable).ok_or_else(|| {
anyhow::anyhow!("authorized executable identity is missing for '{executable}'")
})?;
Ok((
PathBuf::from(&identity.file.canonical_path),
identity.clone(),
))
}
}
pub(crate) fn resolve_nodes_with_host(
graph: &crate::workflow::WorkflowGraph,
host: &dyn PlanHost,
) -> anyhow::Result<BTreeMap<crate::workflow::NodeId, ResolvedNode>> {
graph
.nodes
.iter()
.map(|(id, node)| {
let resolved = match &node.execution {
NodeExecution::Agent(agent) => {
let entry = host.catalog().find(&agent.agent)?;
let bound = AgentBinder::bind(
Arc::new(entry.definition.clone()),
AgentInvocation {
role: AgentRole::Workflow,
available_tools: host.available_tools().clone(),
},
host.config(),
)?;
ResolvedNode::Agent(Box::new(resolve_agent(entry, bound, host)?))
}
NodeExecution::Command(command) => {
let (executable, cwd) = match command {
crate::workflow::CommandNode::Direct {
executable, cwd, ..
}
| crate::workflow::CommandNode::Shell {
executable, cwd, ..
} => (executable, cwd),
};
let workspace = host.workspace().canonicalize()?;
let cwd_path = workspace.join(cwd).canonicalize()?;
if !cwd_path.starts_with(&workspace) {
anyhow::bail!("command node '{id}' cwd is outside the workspace");
}
let (executable_path, executable_identity) =
host.resolve_executable(executable)?;
ResolvedNode::Command(Box::new(ResolvedCommand {
executable_identity,
executable: crate::paths::display(&executable_path),
exact_path: true,
cwd: crate::paths::display(&cwd_path),
cwd_identity: freeze_directory_identity(&cwd_path)?,
environment_policy: "inherit-current-process".to_owned(),
}))
}
};
Ok((id.clone(), resolved))
})
.collect()
}
fn resolve_agent(
entry: &crate::agent::AgentCatalogEntry,
bound: crate::app::agent_binding::BoundAgent,
host: &dyn PlanHost,
) -> anyhow::Result<ResolvedAgent> {
let source_origin = match entry.metadata.origin {
AgentOrigin::Internal => "internal",
AgentOrigin::BuiltIn => "built_in",
AgentOrigin::AgentsHome => "agents_home",
AgentOrigin::RhoHome => "rho_home",
AgentOrigin::Project => "project",
AgentOrigin::Workflow => "workflow",
};
let source_origin = match &entry.metadata.path {
Some(path) => format!("{source_origin}:{}", crate::paths::display(path)),
None => source_origin.to_owned(),
};
let prompt_policy = match &entry.definition.prompt {
PromptPolicy::Extend(text) => format!("extend:{text}"),
PromptPolicy::Replace(text) => format!("replace:{text}"),
};
let permission_ceiling = match bound.runtime() {
BoundRuntime::Rho { config, .. } => config.permission_mode.to_string(),
BoundRuntime::ClaudeCli {
permission_mode, ..
} => permission_mode.to_string(),
};
let common = ResolvedAgent {
agent_id: entry.definition.id.to_string(),
fingerprint: entry.fingerprint.to_string(),
runtime: match bound.runtime() {
BoundRuntime::Rho { .. } => crate::workflow::AgentRuntime::Rho,
BoundRuntime::ClaudeCli { .. } => crate::workflow::AgentRuntime::ClaudeCli,
},
source_origin,
trust_required: entry.metadata.origin == AgentOrigin::Project,
prompt_policy,
provider: None,
model: None,
reasoning: None,
step_limit: sdk_config::run_step_limit().get() as u64,
capabilities: BTreeSet::new(),
permission_ceiling,
auth_profile: None,
executable: None,
executable_identity: None,
arguments: Vec::new(),
};
Ok(match bound.runtime() {
BoundRuntime::Rho {
config,
capabilities,
} => ResolvedAgent {
provider: Some(config.provider.clone()),
model: Some(config.model.clone()),
reasoning: Some(config.reasoning.to_string()),
capabilities: frozen_capabilities(capabilities),
auth_profile: Some(config.auth.clone()),
..common
},
BoundRuntime::ClaudeCli {
model,
tools,
inherit_claude_config,
permission_mode,
max_turns,
effort,
} => {
let (executable, executable_identity) = host.resolve_executable("claude")?;
let plan = crate::claude_runtime::spawn::build_spawn_plan(
&crate::claude_runtime::spawn::ClaudeSpawnRequest {
system_prompt: entry.definition.prompt.clone(),
model: model.clone(),
tools: tools.clone(),
inherit_claude_config: *inherit_claude_config,
permission_mode: *permission_mode,
cwd: host.workspace().to_path_buf(),
max_turns: *max_turns,
effort: *effort,
},
)?;
ResolvedAgent {
model: model.clone(),
reasoning: effort.map(str::to_owned),
step_limit: *max_turns,
capabilities: tools.iter().cloned().collect(),
executable: Some(crate::paths::display(&executable)),
executable_identity: Some(executable_identity),
arguments: plan.args,
..common
}
}
})
}
fn frozen_capabilities(capabilities: &crate::agent::AgentCapabilities) -> BTreeSet<String> {
BUILTIN_TOOL_CAPABILITIES
.iter()
.filter(|capability| capabilities.contains(capability))
.filter(|capability| {
!matches!(
capability,
ToolCapability::Agent
| ToolCapability::Agents
| ToolCapability::Questionnaire
| ToolCapability::Rho
| ToolCapability::Workflow
)
})
.map(|capability| capability.as_str().to_owned())
.collect()
}
fn resolve_executable_path(executable: &str, workspace: &Path) -> anyhow::Result<PathBuf> {
let path = Path::new(executable);
let resolved = if path.components().count() == 1 {
crate::executable::find_on_path(executable)
.ok_or_else(|| anyhow::anyhow!("executable '{executable}' was not found on PATH"))?
} else if path.is_absolute() {
path.canonicalize()?
} else {
workspace.join(path).canonicalize()?
};
Ok(resolved)
}