holon 0.14.1

A headless, event-driven runtime for long-lived agents
Documentation
use std::sync::Arc;

use anyhow::Result;
use holon::{
    config::{AppConfig, ControlAuthMode, ModelRef},
    host::RuntimeHost,
    provider::{
        test_support::{ScriptedAgentProvider, ScriptedProviderStep},
        ConversationMessage,
    },
    types::{MessageBody, MessageEnvelope, MessageKind, MessageOrigin, Priority, TrustLevel},
};
use serde_json::json;
use tempfile::tempdir;
use tokio::time::{Duration, Instant};

fn test_config() -> AppConfig {
    let home_dir = tempdir().unwrap().keep();
    AppConfig {
        default_agent_id: "default".into(),
        http_addr: "127.0.0.1:0".into(),
        callback_base_url: "http://127.0.0.1:0".into(),
        home_dir: home_dir.clone(),
        data_dir: home_dir.clone(),
        socket_path: home_dir.join("run").join("holon.sock"),
        workspace_dir: tempdir().unwrap().keep(),
        context_window_messages: 8,
        context_window_briefs: 8,
        compaction_trigger_messages: 10,
        compaction_keep_recent_messages: 4,
        compaction_trigger_estimated_tokens: 2048,
        compaction_keep_recent_estimated_tokens: 768,
        prompt_budget_estimated_tokens: 4096,
        recent_episode_candidates: 12,
        max_relevant_episodes: 3,
        control_token: Some("secret".into()),
        control_auth_mode: ControlAuthMode::Auto,
        config_file_path: home_dir.join("config.json"),
        stored_config: Default::default(),
        default_model: ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
        fallback_models: Vec::new(),
        runtime_max_output_tokens: 8192,
        default_tool_output_tokens: 8_000,
        max_tool_output_tokens: 64_000,
        disable_provider_fallback: false,
        tui_alternate_screen: holon::config::AltScreenMode::Auto,
        validated_model_overrides: std::collections::HashMap::new(),
        validated_unknown_model_fallback: None,
        providers: holon::config::provider_registry_for_tests(
            None,
            Some("dummy"),
            home_dir.join(".codex"),
        ),
        web_config: holon::web::WebConfig::default(),
    }
}

async fn wait_until(predicate: impl Fn() -> Result<bool>) -> Result<()> {
    let deadline = Instant::now() + Duration::from_secs(3);
    loop {
        if predicate()? {
            return Ok(());
        }
        if Instant::now() >= deadline {
            break;
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    Err(anyhow::anyhow!("timed out waiting for condition"))
}

async fn wait_until_async<F, Fut>(predicate: F) -> Result<()>
where
    F: Fn() -> Fut,
    Fut: std::future::Future<Output = Result<bool>>,
{
    let deadline = Instant::now() + Duration::from_secs(3);
    loop {
        if predicate().await? {
            return Ok(());
        }
        if Instant::now() >= deadline {
            break;
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    Err(anyhow::anyhow!("timed out waiting for condition"))
}

#[tokio::test]
async fn scripted_agent_provider_drives_tool_loop_and_captures_requests() -> Result<()> {
    let provider = ScriptedAgentProvider::new([
        ScriptedProviderStep::tool_use("agent-get-1", "AgentGet", json!({}))
            .with_token_usage(10, 5),
        ScriptedProviderStep::text("finished after scripted tool result").with_token_usage(7, 3),
    ]);
    let captured_provider = provider.clone();
    let host = RuntimeHost::new_with_provider(test_config(), Arc::new(provider))?;
    let runtime = host.default_runtime().await?;

    runtime
        .enqueue(MessageEnvelope::new(
            "default",
            MessageKind::OperatorPrompt,
            MessageOrigin::Operator { actor_id: None },
            TrustLevel::TrustedOperator,
            Priority::Normal,
            MessageBody::Text {
                text: "inspect agent state".into(),
            },
        ))
        .await?;

    wait_until(|| Ok(captured_provider.request_count() >= 2)).await?;
    wait_until_async(|| async {
        Ok(runtime
            .recent_briefs(10)
            .await?
            .iter()
            .any(|brief| brief.text.contains("finished after scripted tool result")))
    })
    .await?;

    let requests = captured_provider.requests();
    assert_eq!(requests.len(), 2);

    let first = &requests[0];
    let first_tool_names = first
        .tools
        .iter()
        .map(|tool| tool.name.as_str())
        .collect::<Vec<_>>();
    assert!(first_tool_names.contains(&"AgentGet"));
    assert!(
        first.prompt_frame.system_prompt.contains("Use AgentGet"),
        "prompt should include AgentGet guidance when AgentGet is exposed"
    );

    let second = &requests[1];
    let tool_results = second
        .conversation
        .iter()
        .find_map(|message| match message {
            ConversationMessage::UserToolResults(results) => Some(results),
            _ => None,
        });
    let tool_results = tool_results.expect("second request should include tool results");
    let agent_get_result = tool_results
        .iter()
        .find(|result| result.tool_use_id == "agent-get-1")
        .expect("AgentGet result should be returned to the provider");
    assert!(!agent_get_result.is_error);
    assert!(
        agent_get_result.content.contains("\"agent\""),
        "AgentGet tool result should preserve the structured result envelope"
    );

    let state = runtime.agent_state().await?;
    assert_eq!(state.total_model_rounds, 2);
    assert_eq!(state.total_input_tokens, 17);
    assert_eq!(state.total_output_tokens, 8);

    Ok(())
}