holon 0.14.1

A headless, event-driven runtime for long-lived agents
Documentation
use anyhow::{anyhow, Result};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{
    runtime::RuntimeHandle,
    tool::{
        spec::{typed_spec, ToolResultStatus},
        ToolResult,
    },
    types::{TaskOutputResult, TaskOutputRetrievalStatus, ToolCapabilityFamily, TrustLevel},
};

use super::{serialize_success, BuiltinToolDefinition};
use crate::tool::helpers::{parse_tool_args, validate_non_empty};

pub(crate) const NAME: &str = "TaskOutput";

#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct TaskOutputArgs {
    pub(crate) task_id: String,
    pub(crate) block: Option<bool>,
    pub(crate) timeout_ms: Option<u64>,
}

pub(crate) fn definition() -> Result<BuiltinToolDefinition> {
    Ok(BuiltinToolDefinition {
        family: ToolCapabilityFamily::CoreAgent,
        spec: typed_spec::<TaskOutputArgs>(
            NAME,
            "Read background task output and optionally wait for task completion.",
        )?,
    })
}

pub(crate) async fn execute(
    runtime: &RuntimeHandle,
    _agent_id: &str,
    _trust: &TrustLevel,
    input: &Value,
) -> Result<ToolResult> {
    let args: TaskOutputArgs = parse_tool_args(NAME, input)?;
    let task_id = validate_non_empty(args.task_id, NAME, "task_id")?;
    let block = args.block.unwrap_or(true);
    let timeout_ms = args.timeout_ms.unwrap_or(30_000);
    let result: TaskOutputResult = runtime
        .managed_tasks()
        .task_output(&task_id, block, timeout_ms)
        .await?;
    serialize_success(NAME, &result)
}

pub(crate) fn render_for_model(result: &ToolResult) -> Result<String> {
    if matches!(result.envelope.status, ToolResultStatus::Error) {
        let error = result
            .tool_error()
            .ok_or_else(|| anyhow!("TaskOutput error result missing tool error"))?;
        return Ok(format!("Task output read failed\n{}\n", error.render()));
    }

    let value = result
        .envelope
        .result
        .clone()
        .ok_or_else(|| anyhow!("TaskOutput result missing payload"))?;
    let result: TaskOutputResult = serde_json::from_value(value)?;
    let status_line = match result.retrieval_status {
        TaskOutputRetrievalStatus::Success => "Task output retrieved".to_string(),
        TaskOutputRetrievalStatus::Timeout => "Task output wait timed out".to_string(),
        TaskOutputRetrievalStatus::NotReady => "Task output is not ready".to_string(),
    };
    let mut lines = vec![
        status_line,
        format!("Task: {}", result.task.task_id),
        format!(
            "Status: {}",
            serde_json::to_string(&result.task.status)?.trim_matches('"')
        ),
    ];
    if let Some(exit_status) = result.task.exit_status {
        lines.push(format!("Exit status: {exit_status}"));
    }
    if let Some(summary) = result.task.summary.as_deref() {
        lines.push(format!("Summary: {summary}"));
    }
    if !result.task.output_preview.trim().is_empty() {
        lines.push(String::new());
        lines.push("Output:".to_string());
        lines.push(result.task.output_preview.clone());
    }
    if !result.task.artifacts.is_empty() {
        lines.push(String::new());
        lines.push("Artifacts:".to_string());
        lines.extend(
            result
                .task
                .artifacts
                .iter()
                .map(|artifact| artifact.path.clone()),
        );
    }
    Ok(lines.join("\n"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        tool::tools::serialize_success,
        types::{TaskOutputSnapshot, TaskStatus},
    };

    #[test]
    fn task_output_renders_text_receipt() {
        let result = serialize_success(
            NAME,
            &TaskOutputResult {
                retrieval_status: TaskOutputRetrievalStatus::Success,
                task: TaskOutputSnapshot {
                    task_id: "task_123".into(),
                    kind: "command_task".into(),
                    status: TaskStatus::Completed,
                    summary: Some("verification finished".into()),
                    output_preview: "ok\nall good".into(),
                    output_truncated: false,
                    artifacts: Vec::new(),
                    output_artifact: None,
                    result_summary: Some("done".into()),
                    exit_status: Some(0),
                    failure_artifact: None,
                    child_supervision: None,
                },
            },
        )
        .unwrap();

        let rendered = render_for_model(&result).unwrap();
        assert!(rendered.contains("Task output retrieved"));
        assert!(rendered.contains("Task: task_123"));
        assert!(rendered.contains("Output:"));
        assert!(rendered.contains("all good"));
    }
}