#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum AgentStdinFormat {
PlainLine,
ClaudeCodeStreamJson,
}
fn agent_stdin_format(resolved: &ResolvedAgent) -> AgentStdinFormat {
if resolved.agent.id() == "claude-code" && resolved.profile.intervene_stdin {
AgentStdinFormat::ClaudeCodeStreamJson
} else {
AgentStdinFormat::PlainLine
}
}
fn stdin_message_bytes(format: AgentStdinFormat, message: &str) -> Vec<u8> {
match format {
AgentStdinFormat::PlainLine => {
let mut bytes = message.as_bytes().to_vec();
bytes.push(b'\n');
bytes
}
AgentStdinFormat::ClaudeCodeStreamJson => {
let mut bytes = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{ "type": "text", "text": message }]
}
})
.to_string()
.into_bytes();
bytes.push(b'\n');
bytes
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_agent_command(
resolved: &ResolvedAgent,
prompt: &str,
rhei_root: &Path,
checkout_root: &Path,
worktree_root: Option<&Path>,
plan_path: &Path,
state_machine_path: Option<&Path>,
task_id: &str,
state_name: &str,
visit_count: u64,
tooling: &ResolvedTooling,
runtime_dir: &Path,
result_identity: Option<&str>,
) -> std::process::Command {
let profile = &resolved.profile;
let id = resolved.agent.id();
let stdin_format = agent_stdin_format(resolved);
let claude_stream_json = stdin_format == AgentStdinFormat::ClaudeCodeStreamJson;
let (program, base_args) =
profile.command.split_first().expect("registry profile has non-empty command");
let mut cmd = std::process::Command::new(program);
cmd.current_dir(checkout_root);
for arg in base_args {
cmd.arg(arg);
}
if let Some(mode) = resolved.mode.as_deref() {
if let Some(flags) = profile.modes.get(mode) {
for arg in flags {
cmd.arg(arg);
}
}
}
for arg in &resolved.autonomous_args {
cmd.arg(arg);
}
configure_agent_accounting_args(&mut cmd, resolved);
if profile.stdin_prompt || profile.intervene_stdin {
cmd.stdin(std::process::Stdio::piped());
}
if claude_stream_json {
if let Some(flag) = &profile.prompt_flag {
cmd.arg(flag);
}
cmd.arg("--input-format").arg("stream-json");
cmd.arg("--output-format").arg("stream-json");
cmd.arg("--verbose");
} else if let (false, Some(flag)) = (profile.stdin_prompt, &profile.prompt_flag) {
cmd.arg(flag).arg(prompt);
}
let model_flag_value = resolved.model_name.as_deref().or(resolved.model.as_deref());
if let (Some(flag), Some(model)) = (&profile.model_flag, model_flag_value) {
cmd.arg(flag).arg(model);
}
if profile.stdin_prompt {
cmd.arg("--");
}
if let Some(flag) = profile.mcp_flag.as_deref() {
for entry in &tooling.mcp_servers {
if entry.definition.is_some() {
cmd.arg(flag).arg(&entry.id);
}
}
} else if let Some(flag) = profile.mcp_config_flag.as_deref() {
let available: Vec<&ResolvedMcpEntry> =
tooling.mcp_servers.iter().filter(|e| e.definition.is_some()).collect();
if !available.is_empty() {
if let Some(path) =
write_mcp_config_file(runtime_dir, task_id, state_name, id, &available)
{
cmd.arg(flag).arg(path);
}
}
}
if let Some(flag) = profile.skill_flag.as_deref() {
for entry in &tooling.skills {
if entry.definition.is_some() {
cmd.arg(flag).arg(&entry.id);
}
}
}
cmd.env("RHEI_PLAN_PATH", plan_path)
.env("RHEI_ROOT", rhei_root)
.env("RHEI_CHECKOUT_ROOT", checkout_root)
.env("RHEI_TASK_ID", task_id)
.env("RHEI_TASK_ID_LOCAL", rhei_local_id_str(task_id))
.env(
"RHEI_RESULT_PATH",
absolute_invocation_result_file_path(
rhei_root,
task_id,
ResultInvocation { state: state_name, visit_count, identity: result_identity },
),
)
.env("RHEI_STATE", state_name)
.env("RHEI_VISIT_COUNT", visit_count.to_string())
.env("RHEI_AGENT", id);
if let Some(path) = worktree_root {
cmd.env("RHEI_WORKTREE_ROOT", path);
} else {
cmd.env_remove("RHEI_WORKTREE_ROOT");
}
if let Some(path) = state_machine_path {
cmd.env("RHEI_STATE_MACHINE_PATH", path);
}
if let Some(model) = &resolved.model {
cmd.env("RHEI_MODEL", model);
}
if let Some(mode) = &resolved.mode {
cmd.env("RHEI_AGENT_MODE", mode);
}
if let Some(target) = &resolved.target {
cmd.env("RHEI_TARGET", target.selector());
cmd.env("RHEI_TARGET_SLUG", target.slug());
}
if let Some(provider) = resolved.model_provider.as_deref() {
cmd.env("RHEI_MODEL_PROVIDER", provider);
}
if let Some(model_name) = resolved.model_name.as_deref() {
cmd.env("RHEI_MODEL_NAME", model_name);
}
inject_tooling_env(&mut cmd, tooling);
cmd
}
fn write_mcp_config_file(
runtime_dir: &Path,
task_id: &str,
state_name: &str,
agent_id: &str,
entries: &[&ResolvedMcpEntry],
) -> Option<PathBuf> {
let tmp_dir = runtime_dir.join("tmp");
if let Err(err) = fs::create_dir_all(&tmp_dir) {
diag_warn!("warning: failed to create MCP config tmp dir '{}': {err}", tmp_dir.display());
return None;
}
let safe_agent = env_id_segment(agent_id).to_lowercase();
let path = tmp_dir.join(format!("mcp-{task_id}-{state_name}-{safe_agent}.json"));
let mut servers = serde_json::Map::new();
for entry in entries {
let Some(def) = entry.definition.as_ref() else {
continue;
};
let mut obj = serde_json::Map::new();
if let Some(command) = &def.command {
obj.insert(
"command".to_string(),
serde_json::Value::Array(
command.iter().map(|s| serde_json::Value::String(s.clone())).collect(),
),
);
}
if let Some(url) = &def.url {
obj.insert("url".to_string(), serde_json::Value::String(url.clone()));
}
if let Some(transport) = &def.transport {
obj.insert("transport".to_string(), serde_json::Value::String(transport.clone()));
}
if !def.env.is_empty() {
let mut env_map = serde_json::Map::new();
for (k, v) in &def.env {
env_map.insert(k.clone(), serde_json::Value::String(expand_env_vars(v)));
}
obj.insert("env".to_string(), serde_json::Value::Object(env_map));
}
if let Some(wd) = &def.working_directory {
obj.insert("workingDirectory".to_string(), serde_json::Value::String(wd.clone()));
}
servers.insert(entry.id.clone(), serde_json::Value::Object(obj));
}
let envelope = serde_json::json!({ "mcpServers": serde_json::Value::Object(servers) });
match serde_json::to_string_pretty(&envelope) {
Ok(text) => match fs::write(&path, text) {
Ok(()) => Some(path),
Err(err) => {
diag_warn!("warning: failed to write MCP config '{}': {err}", path.display());
None
}
},
Err(err) => {
diag_warn!("warning: failed to serialize MCP config: {err}");
None
}
}
}
fn expand_env_vars(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if let Some(end_rel) = bytes[i + 2..].iter().position(|&b| b == b'}') {
let name_start = i + 2;
let name_end = name_start + end_rel;
let name = &input[name_start..name_end];
out.push_str(&std::env::var(name).unwrap_or_default());
i = name_end + 1;
continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn collect_unsupported_tooling_warnings(
resolved: &ResolvedAgent,
tooling: &ResolvedTooling,
) -> Vec<String> {
let mut warnings = Vec::new();
let agent_id = resolved.agent.id();
if resolved.profile.mcp_flag.is_none()
&& resolved.profile.mcp_config_flag.is_none()
&& tooling.mcp_servers.iter().any(|e| e.definition.is_some())
{
let ids: Vec<&str> = tooling
.mcp_servers
.iter()
.filter(|e| e.definition.is_some())
.map(|e| e.id.as_str())
.collect();
warnings.push(format!(
"warning: agent '{agent_id}' declares no mcp_flag/mcp_config_flag; \
dropping MCP entries: {}",
ids.join(", ")
));
}
if resolved.profile.skill_flag.is_none()
&& tooling.skills.iter().any(|e| e.definition.is_some())
{
let ids: Vec<&str> = tooling
.skills
.iter()
.filter(|e| e.definition.is_some())
.map(|e| e.id.as_str())
.collect();
warnings.push(format!(
"warning: agent '{agent_id}' declares no skill_flag; dropping \
skill entries: {}",
ids.join(", ")
));
}
warnings
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolingKind {
Mcp,
Skill,
}
impl ToolingKind {
fn as_str(self) -> &'static str {
match self {
ToolingKind::Mcp => "mcp",
ToolingKind::Skill => "skill",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolingUnavailable {
kind: ToolingKind,
id: String,
reason: String,
}
#[derive(Debug, Clone, Default)]
struct ToolingGateResult {
tooling: ResolvedTooling,
warnings: Vec<String>,
required: Vec<ToolingUnavailable>,
}
fn gate_tooling_for_agent(
resolved: &ResolvedAgent,
tooling: &ResolvedTooling,
) -> ToolingGateResult {
let mut result = ToolingGateResult::default();
let agent_id = resolved.agent.id();
let mcp_supported =
resolved.profile.mcp_flag.is_some() || resolved.profile.mcp_config_flag.is_some();
for entry in &tooling.mcp_servers {
let reason = if entry.definition.is_none() {
Some("definition is unavailable".to_string())
} else if !mcp_supported {
Some(format!("agent '{agent_id}' declares no mcp_flag/mcp_config_flag"))
} else {
None
};
if let Some(reason) = reason {
if entry.optional {
result.warnings.push(format!(
"warning: optional mcp '{}' unavailable ({}); dropping",
entry.id, reason
));
let mut unavailable = entry.clone();
unavailable.definition = None;
result.tooling.mcp_servers.push(unavailable);
} else {
result.required.push(ToolingUnavailable {
kind: ToolingKind::Mcp,
id: entry.id.clone(),
reason,
});
}
} else {
result.tooling.mcp_servers.push(entry.clone());
}
}
let skill_supported = resolved.profile.skill_flag.is_some();
for entry in &tooling.skills {
let reason = if entry.definition.is_none() {
Some("definition is unavailable".to_string())
} else if !skill_supported {
Some(format!("agent '{agent_id}' declares no skill_flag"))
} else {
None
};
if let Some(reason) = reason {
if entry.optional {
result.warnings.push(format!(
"warning: optional skill '{}' unavailable ({}); dropping",
entry.id, reason
));
let mut unavailable = entry.clone();
unavailable.definition = None;
result.tooling.skills.push(unavailable);
} else {
result.required.push(ToolingUnavailable {
kind: ToolingKind::Skill,
id: entry.id.clone(),
reason,
});
}
} else {
result.tooling.skills.push(entry.clone());
}
}
result
}
fn unavailable_ids(required: &[ToolingUnavailable], kind: ToolingKind) -> Vec<String> {
required.iter().filter(|issue| issue.kind == kind).map(|issue| issue.id.clone()).collect()
}
fn format_required_tooling_error(
task_id: &str,
state_name: &str,
required: &[ToolingUnavailable],
) -> String {
let details = required
.iter()
.map(|issue| format!("{}:{} ({})", issue.kind.as_str(), issue.id, issue.reason))
.collect::<Vec<_>>()
.join(", ");
format!("required tooling unavailable for task {task_id} in state '{state_name}': {details}")
}
fn format_iso8601_utc(t: std::time::SystemTime) -> String {
let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
let days = secs.div_euclid(86_400);
let sec_of_day = secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = sec_of_day / 3_600;
let minute = (sec_of_day % 3_600) / 60;
let second = sec_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn format_duration_human(secs: u64) -> String {
let hours = secs / 3_600;
let minutes = (secs % 3_600) / 60;
let seconds = secs % 60;
let mut out = String::new();
if hours > 0 {
out.push_str(&format!("{hours}h"));
}
if minutes > 0 {
out.push_str(&format!("{minutes}m"));
}
if seconds > 0 || out.is_empty() {
out.push_str(&format!("{seconds}s"));
}
out
}
fn format_tooling_log_line<T, F>(entries: &[T], project: F) -> Option<String>
where
F: Fn(&T) -> (&str, bool, bool),
{
if entries.is_empty() {
return None;
}
let rendered: Vec<String> = entries
.iter()
.map(|entry| {
let (id, optional, available) = project(entry);
if optional && !available {
format!("{id}?")
} else {
id.to_string()
}
})
.collect();
Some(rendered.join(","))
}
fn inject_tooling_env(cmd: &mut std::process::Command, tooling: &ResolvedTooling) {
cmd.env("RHEI_MCP_SERVERS", tooling.mcp_servers_csv());
cmd.env("RHEI_SKILLS", tooling.skills_csv());
for entry in &tooling.mcp_servers {
cmd.env(
format!("RHEI_MCP_{}_AVAILABLE", env_id_segment(&entry.id)),
entry.definition.is_some().to_string(),
);
}
for entry in &tooling.skills {
cmd.env(
format!("RHEI_SKILL_{}_AVAILABLE", env_id_segment(&entry.id)),
entry.definition.is_some().to_string(),
);
}
}
fn agent_log_path(
runtime_dir: &Path,
task_id: &str,
state_name: &str,
suffix: Option<&str>,
) -> PathBuf {
let suffix = suffix
.filter(|value| !value.is_empty())
.map(|value| format!("-{value}"))
.unwrap_or_default();
runtime_dir.join("logs").join(format!("task-{task_id}-{state_name}{suffix}.log"))
}