vv-agent 0.6.2

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::collections::BTreeMap;
use std::sync::Arc;

use serde_json::json;
use vv_agent::{
    BeforeToolCallEvent, BeforeToolCallPatch, RuntimeHook, RuntimeHookManager, ToolCall,
    ToolCallRunner, ToolContext, ToolDirective, ToolExecutionResult, ToolRegistry,
    ToolResultStatus, ToolRunRequest, ToolSpec,
};

#[test]
fn tool_call_runner_skips_remaining_calls_after_finish() {
    let workspace = tempfile::tempdir().expect("workspace");
    let mut registry = ToolRegistry::new();
    registry
        .register(ToolSpec::new(
            "_finish",
            "finish",
            Arc::new(|_context, _arguments| {
                let mut result = ToolExecutionResult::success("", json!({"ok": true}).to_string());
                result.directive = ToolDirective::Finish;
                result
                    .metadata
                    .insert("final_message".to_string(), json!("finished"));
                result
            }),
        ))
        .expect("finish tool");
    registry
        .register(ToolSpec::new(
            "_never",
            "must not run",
            Arc::new(|_context, _arguments| ToolExecutionResult::error("", "should not run")),
        ))
        .expect("never tool");
    let runner = ToolCallRunner::new(registry);
    let task = vv_agent::AgentTask::new("tool_runner", "demo", "system", "prompt");
    let mut context = ToolContext::new(workspace.path());
    let tool_calls = vec![
        ToolCall::new("finish_call", "_finish", BTreeMap::new()),
        ToolCall::new("skipped_call", "_never", BTreeMap::new()),
    ];
    let mut messages = Vec::new();
    let mut cycle = vv_agent::CycleRecord {
        index: 1,
        assistant_message: String::new(),
        tool_calls: tool_calls.clone(),
        tool_results: Vec::new(),
        memory_compacted: false,
        token_usage: vv_agent::TokenUsage::default(),
    };

    let outcome = runner
        .run(ToolRunRequest::new(
            &task,
            tool_calls,
            &mut context,
            &mut messages,
            &mut cycle,
        ))
        .expect("tool run");

    assert_eq!(
        outcome.directive_result.expect("directive").directive,
        ToolDirective::Finish
    );
    assert_eq!(cycle.tool_results.len(), 2);
    assert_eq!(cycle.tool_results[0].tool_call_id, "finish_call");
    assert_eq!(cycle.tool_results[1].tool_call_id, "skipped_call");
    assert_eq!(
        cycle.tool_results[1].error_code.as_deref(),
        Some("skipped_due_to_finish")
    );
    assert_eq!(cycle.tool_results[1].status, ToolResultStatus::Error);
    let skipped_payload: serde_json::Value =
        serde_json::from_str(&cycle.tool_results[1].content).expect("skipped payload json");
    assert_eq!(
        skipped_payload,
        json!({
            "ok": false,
            "error": "Tool skipped because a previous tool finished the task.",
            "error_code": "skipped_due_to_finish",
        })
    );
    let skipped_message_payload: serde_json::Value =
        serde_json::from_str(&messages[1].content).expect("skipped message payload json");
    assert_eq!(skipped_message_payload, skipped_payload);
    assert_eq!(messages.len(), 2);
}

#[test]
fn tool_call_runner_short_circuit_result_keeps_original_tool_call_id_after_call_patch() {
    let workspace = tempfile::tempdir().expect("workspace");
    let registry = ToolRegistry::new();
    let runner =
        ToolCallRunner::new(registry).with_hook_manager(RuntimeHookManager::new(vec![Arc::new(
            PatchedCallAndBlankResultHook,
        )]));
    let task = vv_agent::AgentTask::new("tool_runner_hook", "demo", "system", "prompt");
    let mut context = ToolContext::new(workspace.path());
    let tool_calls = vec![ToolCall::new(
        "original_call",
        "_patched_by_hook",
        BTreeMap::new(),
    )];
    let mut messages = Vec::new();
    let mut cycle = vv_agent::CycleRecord {
        index: 1,
        assistant_message: String::new(),
        tool_calls: tool_calls.clone(),
        tool_results: Vec::new(),
        memory_compacted: false,
        token_usage: vv_agent::TokenUsage::default(),
    };

    runner
        .run(ToolRunRequest::new(
            &task,
            tool_calls,
            &mut context,
            &mut messages,
            &mut cycle,
        ))
        .expect("tool run");

    assert_eq!(cycle.tool_results.len(), 1);
    assert_eq!(cycle.tool_results[0].tool_call_id, "original_call");
    assert_eq!(
        messages[0].tool_call_id.as_deref(),
        Some("original_call"),
        "tool messages must answer the model's original tool call id even when a hook patches the call"
    );
}

struct PatchedCallAndBlankResultHook;

impl RuntimeHook for PatchedCallAndBlankResultHook {
    fn before_tool_call(&self, event: BeforeToolCallEvent<'_>) -> Option<BeforeToolCallPatch> {
        let mut patched = event.call.clone();
        patched.id = "patched_call".to_string();
        let result = ToolExecutionResult::success("", json!({"ok": true}).to_string());
        Some(BeforeToolCallPatch {
            call: Some(patched),
            result: Some(result),
        })
    }
}