roder-ext-task-subagent 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use std::sync::Arc;

use anyhow::Context;
use roder_api::subagents::{SubagentDispatcher, SubagentLane, SubagentRequest};
use roder_api::tasks::{
    TaskExecutionContext, TaskExecutionResult, TaskExecutor, TaskOutputStream, TaskSpec,
};
use serde::Deserialize;

pub const SUBAGENT_TASK_EXECUTOR_ID: &str = "subagent";

#[derive(Debug, Clone, Deserialize)]
struct SubagentTaskInput {
    description: String,
    prompt: String,
    #[serde(default)]
    subagent_type: Option<String>,
    #[serde(default)]
    model: Option<String>,
    #[serde(default)]
    tools: Option<Vec<String>>,
    #[serde(default)]
    lane: Option<SubagentLane>,
    #[serde(default)]
    max_concurrent: Option<usize>,
    #[serde(default)]
    allowed_tools: Option<Vec<String>>,
    #[serde(default)]
    parent_deadline_seconds: Option<u64>,
    #[serde(default)]
    inputs: Option<serde_json::Value>,
    #[serde(default)]
    timeout_seconds: Option<u64>,
}

#[derive(Clone)]
pub struct SubagentTaskExecutor {
    dispatcher: Arc<dyn SubagentDispatcher>,
}

impl SubagentTaskExecutor {
    pub fn new(dispatcher: Arc<dyn SubagentDispatcher>) -> Self {
        Self { dispatcher }
    }
}

#[async_trait::async_trait]
impl TaskExecutor for SubagentTaskExecutor {
    fn id(&self) -> String {
        SUBAGENT_TASK_EXECUTOR_ID.to_string()
    }

    fn spec(&self) -> TaskSpec {
        TaskSpec {
            kind: SUBAGENT_TASK_EXECUTOR_ID.to_string(),
            description: "Run a subagent as a background task.".to_string(),
            input_schema: serde_json::json!({
                "type": "object",
                "required": ["description", "prompt"],
                "properties": {
                    "description": { "type": "string" },
                    "prompt": { "type": "string" },
                    "subagent_type": { "type": "string" },
                    "model": { "type": "string" },
                    "tools": { "type": "array", "items": { "type": "string" } },
                    "lane": {
                        "type": "string",
                        "enum": ["scout", "editor", "reviewer", "runner"]
                    },
                    "max_concurrent": { "type": "integer", "minimum": 1 },
                    "allowed_tools": { "type": "array", "items": { "type": "string" } },
                    "parent_deadline_seconds": { "type": "integer", "minimum": 1 },
                    "inputs": {
                        "type": "object",
                        "description": "Optional freeform structured context for the child task."
                    },
                    "timeout_seconds": { "type": "integer", "minimum": 1 }
                },
                "additionalProperties": false
            }),
            default_timeout_seconds: None,
            metadata: serde_json::json!({ "category": "subagent" }),
        }
    }

    async fn execute(
        &self,
        ctx: TaskExecutionContext,
        input: serde_json::Value,
    ) -> anyhow::Result<TaskExecutionResult> {
        let input: SubagentTaskInput =
            serde_json::from_value(input).context("deserialize subagent task input")?;
        let parent_thread_id = ctx.thread_id.unwrap_or_else(|| ctx.task_id.clone());
        let parent_turn_id = ctx.turn_id.unwrap_or_else(|| "background-task".to_string());
        let result = self
            .dispatcher
            .dispatch(
                parent_thread_id,
                parent_turn_id,
                SubagentRequest {
                    description: input.description,
                    prompt: input.prompt,
                    subagent_type: input.subagent_type,
                    model: input.model,
                    tools: input.tools,
                    lane: input.lane,
                    max_concurrent: input.max_concurrent,
                    allowed_tools: input.allowed_tools,
                    parent_deadline_seconds: input.parent_deadline_seconds,
                    inputs: input.inputs,
                    timeout_seconds: input.timeout_seconds,
                },
            )
            .await?;

        if let Some(transcript) = &result.transcript {
            ctx.output
                .write(TaskOutputStream::Log, transcript.to_string())
                .await?;
        }
        ctx.output
            .write(TaskOutputStream::Log, result.final_message.clone())
            .await?;

        Ok(TaskExecutionResult {
            exit_code: None,
            payload: serde_json::to_value(result)?,
        })
    }
}

#[cfg(test)]
mod tests {
    use roder_api::events::{ThreadId, TurnId};
    use roder_api::subagents::{
        SubagentDefinition, SubagentExitReason, SubagentPermissionMode, SubagentResult,
    };

    use super::*;

    struct EmptyDispatcher;

    #[async_trait::async_trait]
    impl SubagentDispatcher for EmptyDispatcher {
        fn id(&self) -> String {
            "empty".to_string()
        }

        fn definitions(&self) -> Vec<SubagentDefinition> {
            vec![SubagentDefinition {
                agent_type: "explore".to_string(),
                description: "Explore the workspace".to_string(),
                tools: vec!["read_file".to_string()],
                model: None,
                system_prompt: None,
                permission_mode: SubagentPermissionMode::ReadOnly,
                max_turns: Some(2),
                max_result_chars: Some(4000),
            }]
        }

        async fn dispatch(
            &self,
            _parent_thread_id: ThreadId,
            _parent_turn_id: TurnId,
            request: SubagentRequest,
        ) -> anyhow::Result<SubagentResult> {
            Ok(SubagentResult {
                thread_id: "child-thread".to_string(),
                turn_id: "child-turn".to_string(),
                agent_type: request
                    .subagent_type
                    .unwrap_or_else(|| "explore".to_string()),
                model: request.model,
                final_message: "done".to_string(),
                usage: None,
                exit_reason: SubagentExitReason::Completed,
                transcript: None,
                metadata: serde_json::json!({ "lane": request.lane }),
            })
        }
    }

    #[test]
    fn speed_schema_snapshot_covers_subagent_task_input() {
        let executor = SubagentTaskExecutor::new(Arc::new(EmptyDispatcher));
        let spec = executor
            .spec()
            .normalized_for_model(roder_api::ToolSchemaPolicy::strict());
        let schema = serde_json::to_string(&spec.input_schema).unwrap();

        assert!(
            schema.starts_with(
                r#"{"type":"object","required":["description","prompt"],"properties":"#
            )
        );
        assert!(schema.contains(
            r#""inputs":{"type":"object","description":"Optional freeform structured context for the child task."}"#
        ));
        assert!(schema.contains(r#""lane":{"type":"string""#));
        assert!(schema.contains(r#""enum":["scout","editor","reviewer","runner"]"#));
        assert!(schema.contains(r#""max_concurrent":{"type":"integer""#));
        assert!(schema.contains(r#""minimum":1"#));
        assert!(schema.contains(r#""additionalProperties":false"#));
    }
}