vv-agent 0.4.8

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::sync::{Arc, Mutex};

use serde_json::json;
use vv_agent::runtime::is_prompt_too_long_error;
use vv_agent::{
    build_default_registry, CycleRunRequest, CycleRunner, LLMResponse, LlmError, MemoryManager,
    MemoryManagerConfig, Message, ScriptStep, ScriptedLlmClient, ToolCall,
    MAX_PROMPT_TOO_LONG_RETRIES,
};

#[test]
fn cycle_runner_public_api_builds_assistant_message() {
    let mut response = LLMResponse::new("cycle done");
    response
        .raw
        .insert("reasoning_content".to_string(), json!("cycle reasoning"));
    let runner = CycleRunner::new(
        ScriptedLlmClient::new(vec![response]),
        build_default_registry(),
    );
    let task = vv_agent::AgentTask::new("cycle_api", "demo", "system", "prompt");
    let mut memory_manager = MemoryManager::new(MemoryManagerConfig::default());

    let (messages, cycle) = runner
        .run_cycle(CycleRunRequest::new(
            &task,
            vec![Message::system("system"), Message::user("prompt")],
            1,
            &mut memory_manager,
        ))
        .expect("cycle");

    assert_eq!(cycle.index, 1);
    assert_eq!(cycle.assistant_message, "cycle done");
    assert_eq!(messages.last().expect("assistant").content, "cycle done");
    assert_eq!(
        messages
            .last()
            .expect("assistant")
            .reasoning_content
            .as_deref(),
        Some("cycle reasoning")
    );
}

#[test]
fn cycle_runner_microcompacts_before_full_compaction_when_previous_prompt_tokens_are_high() {
    let captured_requests = Arc::new(Mutex::new(Vec::<Vec<Message>>::new()));
    let captured_for_step = Arc::clone(&captured_requests);
    let runner = CycleRunner::new(
        ScriptedLlmClient::from_steps(vec![ScriptStep::callback(move |request| {
            captured_for_step
                .lock()
                .expect("capture")
                .push(request.messages.clone());
            Ok(LLMResponse::new("done"))
        })]),
        build_default_registry(),
    );
    let task = vv_agent::AgentTask::new("cycle_microcompact", "demo", "system", "prompt");
    let mut memory_manager = MemoryManager::new(MemoryManagerConfig {
        model: "demo".to_string(),
        model_context_window: 800,
        reserved_output_tokens: 50,
        autocompact_buffer_tokens: 50,
        summary_callback: Some(Arc::new(|_, _, _| {
            Some(
                json!({
                    "summary_version": 1,
                    "progress": ["summarized"],
                    "key_facts": [],
                    "open_issues": [],
                    "next_steps": []
                })
                .to_string(),
            )
        })),
        tool_result_compact_threshold: 10_000,
        microcompact_trigger_ratio: 0.2,
        microcompact_keep_recent_cycles: 0,
        microcompact_min_result_length: 200,
        ..MemoryManagerConfig::default()
    });

    let mut assistant = Message::assistant("old tool call");
    assistant
        .tool_calls
        .push(ToolCall::new("call_old", "read_file", Default::default()));
    let messages = vec![
        Message::system("system"),
        Message::user("original request"),
        assistant,
        Message::tool("x".repeat(2_000), "call_old"),
        Message::user("latest request"),
    ];

    let (_messages, cycle) = runner
        .run_cycle(
            CycleRunRequest::new(&task, messages, 3, &mut memory_manager)
                .with_previous_prompt_tokens(Some(5_000)),
        )
        .expect("cycle");

    assert!(cycle.memory_compacted);
    let captured = captured_requests.lock().expect("captured");
    let request_messages = captured.first().expect("llm request");
    assert!(
        request_messages.iter().any(|message| {
            message.content == vv_agent::memory::CLEARED_MARKER
                && message
                    .metadata
                    .get("microcompacted")
                    .is_some_and(|value| value == true)
        }),
        "previous prompt token pressure should first clear old compactable tool output: {request_messages:#?}"
    );
    assert!(
        request_messages
            .iter()
            .all(|message| !message.content.contains("<Compressed Agent Memory>")),
        "microcompact should avoid full summary when the reduced request fits: {request_messages:#?}"
    );
}

#[test]
fn cycle_runner_retries_prompt_too_long_with_forced_compaction() {
    let captured_requests = Arc::new(Mutex::new(Vec::<Vec<Message>>::new()));
    let captured_for_step = Arc::clone(&captured_requests);
    let runner = CycleRunner::new(
        ScriptedLlmClient::from_steps(vec![
            prompt_too_long_step("Prompt is too long for this model"),
            ScriptStep::callback(move |request| {
                captured_for_step
                    .lock()
                    .expect("capture")
                    .push(request.messages.clone());
                Ok(LLMResponse::new("done"))
            }),
        ]),
        build_default_registry(),
    );
    let task = vv_agent::AgentTask::new("cycle_prompt_retry", "demo", "system", "prompt");
    let mut memory_manager = prompt_retry_memory_manager();

    let (messages, cycle) = runner
        .run_cycle(CycleRunRequest::new(
            &task,
            retry_fixture_messages(),
            1,
            &mut memory_manager,
        ))
        .expect("cycle");

    assert!(cycle.memory_compacted);
    assert_eq!(messages.last().expect("assistant").content, "done");
    let captured = captured_requests.lock().expect("captured");
    let retry_request = captured.first().expect("retry request");
    assert!(
        retry_request
            .iter()
            .any(|message| message.content.contains("<Compressed Agent Memory>")),
        "retry request should include compressed memory: {retry_request:#?}"
    );
}

#[test]
fn cycle_runner_retries_prompt_too_long_then_accepts_second_retry() {
    let captured_requests = Arc::new(Mutex::new(Vec::<Vec<Message>>::new()));
    let captured_for_step = Arc::clone(&captured_requests);
    let runner = CycleRunner::new(
        ScriptedLlmClient::from_steps(vec![
            prompt_too_long_step("Prompt is too long for this model"),
            prompt_too_long_step("context_length_exceeded"),
            ScriptStep::callback(move |request| {
                captured_for_step
                    .lock()
                    .expect("capture")
                    .push(request.messages.clone());
                Ok(LLMResponse::new("done after retry"))
            }),
        ]),
        build_default_registry(),
    );
    let task = vv_agent::AgentTask::new("cycle_prompt_second_retry", "demo", "system", "prompt");
    let mut memory_manager = prompt_retry_memory_manager();

    let (messages, cycle) = runner
        .run_cycle(CycleRunRequest::new(
            &task,
            retry_fixture_messages(),
            1,
            &mut memory_manager,
        ))
        .expect("cycle");

    assert!(cycle.memory_compacted);
    assert_eq!(
        messages.last().expect("assistant").content,
        "done after retry"
    );
    let captured = captured_requests.lock().expect("captured");
    let final_request = captured.first().expect("final request");
    assert!(
        final_request.len() <= 2,
        "second retry should use compacted messages: {final_request:#?}"
    );
}

#[test]
fn cycle_runner_returns_compaction_exhausted_after_prompt_too_long_retries() {
    let steps = (0..=MAX_PROMPT_TOO_LONG_RETRIES)
        .map(|_| prompt_too_long_step("request too large"))
        .collect::<Vec<_>>();
    let runner = CycleRunner::new(
        ScriptedLlmClient::from_steps(steps),
        build_default_registry(),
    );
    let task = vv_agent::AgentTask::new("cycle_prompt_exhausted", "demo", "system", "prompt");
    let mut memory_manager = prompt_retry_memory_manager();

    let error = runner
        .run_cycle(CycleRunRequest::new(
            &task,
            retry_fixture_messages(),
            1,
            &mut memory_manager,
        ))
        .expect_err("compaction exhausted error");

    match error {
        LlmError::CompactionExhausted(error) => {
            assert_eq!(error.attempts, MAX_PROMPT_TOO_LONG_RETRIES + 1);
            assert!(error
                .last_error
                .as_deref()
                .is_some_and(|message| message.contains("request too large")));
        }
        other => panic!("expected compaction exhausted error, got {other:?}"),
    }
}

#[test]
fn cycle_runner_propagates_non_prompt_too_long_errors() {
    let runner = CycleRunner::new(
        ScriptedLlmClient::from_steps(vec![ScriptStep::callback(|_| {
            Err(LlmError::Request("network down".to_string()))
        })]),
        build_default_registry(),
    );
    let task = vv_agent::AgentTask::new("cycle_prompt_other_error", "demo", "system", "prompt");
    let mut memory_manager = prompt_retry_memory_manager();

    let error = runner
        .run_cycle(CycleRunRequest::new(
            &task,
            vec![Message::system("system"), Message::user("hello")],
            1,
            &mut memory_manager,
        ))
        .expect_err("network error");

    assert!(error.to_string().contains("network down"));
}

#[test]
fn cycle_runner_recognizes_prompt_too_long_error_patterns() {
    assert!(is_prompt_too_long_error(&LlmError::Request(
        "maximum context length exceeded".to_string()
    )));
    assert!(is_prompt_too_long_error(&LlmError::Request(
        "request too large".to_string()
    )));
    assert!(!is_prompt_too_long_error(&LlmError::Request(
        "network down".to_string()
    )));
}

fn prompt_too_long_step(message: &'static str) -> ScriptStep {
    ScriptStep::callback(move |_| Err(LlmError::Request(message.to_string())))
}

fn prompt_retry_memory_manager() -> MemoryManager {
    MemoryManager::new(MemoryManagerConfig {
        model: "demo".to_string(),
        model_context_window: 60,
        reserved_output_tokens: 10,
        autocompact_buffer_tokens: 10,
        summary_callback: Some(Arc::new(|_, _, _| {
            Some(
                json!({
                    "summary_version": 1,
                    "progress": ["done"],
                    "key_facts": [],
                    "open_issues": [],
                    "next_steps": []
                })
                .to_string(),
            )
        })),
        keep_recent_messages: 1,
        ..MemoryManagerConfig::default()
    })
}

fn retry_fixture_messages() -> Vec<Message> {
    vec![
        Message::system("system"),
        Message::user("u".repeat(40)),
        Message::assistant("a".repeat(40)),
        Message::user("c".repeat(40)),
    ]
}