roder-core 0.3.2

Agentic software development tools and SDKs for Roder.
Documentation
use roder_api::events::ThreadId;
use roder_api::teams::{TeamId, TeamMemberDescriptor};

use crate::runtime::Runtime;
use crate::teams::TeamState;

#[derive(Debug, Clone)]
pub(super) struct AgentTarget {
    pub(super) team_id: TeamId,
    pub(super) member: TeamMemberDescriptor,
}

impl Runtime {
    /// Whether a mailbox message is waiting for this thread's own team member.
    ///
    /// `has_pending_turn_steers` only sees messages that delivery has already
    /// converted into turn steers. A message queued while the recipient's turn
    /// is not yet registered stays in the mailbox undelivered, so a waiter that
    /// consulted the steer queue alone could sleep through it.
    pub(super) async fn has_pending_mailbox_activity(&self, thread_id: &ThreadId) -> bool {
        for team in self.caller_agents(thread_id).await {
            let Some(member) = team
                .members
                .iter()
                .find(|member| member.thread_id == *thread_id)
            else {
                continue;
            };
            if self
                .teams
                .has_pending_mailbox_messages(&team.id, &member.id)
                .await
            {
                return true;
            }
        }
        false
    }

    pub(super) async fn caller_agents(&self, parent_thread_id: &ThreadId) -> Vec<TeamState> {
        self.list_teams()
            .await
            .into_iter()
            .filter(|team| {
                team.lead_thread_id == *parent_thread_id
                    || team
                        .members
                        .iter()
                        .any(|member| member.thread_id == *parent_thread_id)
            })
            .collect()
    }

    pub(super) async fn resolve_agent_target(
        &self,
        parent_thread_id: &ThreadId,
        target: &str,
    ) -> anyhow::Result<AgentTarget> {
        let target = target.trim();
        anyhow::ensure!(!target.is_empty(), "agent target must not be empty");
        let teams = self.caller_agents(parent_thread_id).await;
        anyhow::ensure!(!teams.is_empty(), "caller is not part of an agent team");
        let caller_path = teams
            .iter()
            .flat_map(|team| team.members.iter())
            .find(|member| member.thread_id == *parent_thread_id)
            .and_then(member_agent_path)
            .unwrap_or_else(|| "/root".to_string());
        let relative_path = canonical_agent_path(&caller_path, target);
        let all_targets = teams
            .into_iter()
            .flat_map(|team| {
                let team_id = team.id;
                team.members.into_iter().map(move |member| AgentTarget {
                    team_id: team_id.clone(),
                    member,
                })
            })
            .collect::<Vec<_>>();

        let by_stable_id = all_targets
            .iter()
            .filter(|candidate| {
                candidate.member.id == target || candidate.member.thread_id == target
            })
            .cloned()
            .collect::<Vec<_>>();
        if let Some(target) = unique_target(target, by_stable_id)? {
            return Ok(target);
        }

        let by_path = all_targets
            .iter()
            .filter(|candidate| {
                member_agent_path(&candidate.member).as_deref() == Some(relative_path.as_str())
            })
            .cloned()
            .collect::<Vec<_>>();
        if let Some(target) = unique_target(target, by_path)? {
            return Ok(target);
        }

        if !target.contains('/') {
            let by_task_name = all_targets
                .into_iter()
                .filter(|candidate| {
                    candidate.member.task_name.as_deref() == Some(target)
                        || candidate.member.name == target
                        || candidate.member.name.split(':').next() == Some(target)
                })
                .collect::<Vec<_>>();
            if let Some(target) = unique_target(target, by_task_name)? {
                return Ok(target);
            }
        }

        anyhow::bail!("unknown agent target {target:?}")
    }
}

pub(super) fn member_agent_path(member: &TeamMemberDescriptor) -> Option<String> {
    member.agent_path.clone().or_else(|| {
        (member.role == roder_api::teams::TeamMemberRole::Lead).then(|| "/root".to_string())
    })
}

pub(super) fn member_identity(member: &TeamMemberDescriptor) -> &str {
    member
        .agent_path
        .as_deref()
        .or(member.task_name.as_deref())
        .unwrap_or(&member.name)
}

pub(super) fn canonical_agent_path(caller_path: &str, target: &str) -> String {
    let target = target.trim();
    if target.starts_with('/') {
        let normalized = target.trim_end_matches('/');
        return if normalized.is_empty() {
            "/".to_string()
        } else {
            normalized.to_string()
        };
    }
    format!(
        "{}/{}",
        caller_path.trim_end_matches('/'),
        target.trim_matches('/')
    )
}

pub(super) fn agent_path_matches_prefix(agent_path: &str, prefix: &str) -> bool {
    agent_path == prefix
        || agent_path
            .strip_prefix(prefix)
            .is_some_and(|suffix| suffix.starts_with('/'))
}

fn unique_target(target: &str, matches: Vec<AgentTarget>) -> anyhow::Result<Option<AgentTarget>> {
    match matches.as_slice() {
        [] => Ok(None),
        [matched] => Ok(Some(matched.clone())),
        _ => anyhow::bail!(
            "ambiguous agent target {target:?}; use the canonical path from list_agents"
        ),
    }
}

pub(super) fn reject_root_or_self(
    caller_thread_id: &ThreadId,
    target: &TeamMemberDescriptor,
    action: &str,
) -> Result<(), String> {
    if target.role == roder_api::teams::TeamMemberRole::Lead
        || target.agent_path.as_deref() == Some("/root")
    {
        return Err("root is not a spawned agent".to_string());
    }
    if target.thread_id == *caller_thread_id {
        return Err(format!("an agent cannot {action} itself"));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn relative_paths_and_prefixes_preserve_task_tree_boundaries() {
        assert_eq!(
            canonical_agent_path("/root/task1", "child"),
            "/root/task1/child"
        );
        assert_eq!(
            canonical_agent_path("/root/task1", "/root/task2/child"),
            "/root/task2/child"
        );
        assert!(agent_path_matches_prefix(
            "/root/task1/child",
            "/root/task1"
        ));
        assert!(!agent_path_matches_prefix("/root/task10", "/root/task1"));
    }
}