vv-agent 0.8.0

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

use serde_json::json;
use vv_agent::{
    Agent, BeforeLlmEvent, BeforeLlmPatch, FunctionTool, LLMResponse, LlmClient, ModelError,
    ModelProvider, ModelRef, ResolvedModelConfig, RunConfig, Runner, RuntimeHook,
    ScriptedModelProvider, ToolCall, ToolOutput,
};

#[derive(Clone)]
struct AliasResolvingProvider {
    inner: ScriptedModelProvider,
}

impl ModelProvider for AliasResolvingProvider {
    fn resolve(&self, model: &ModelRef) -> Result<ResolvedModelConfig, ModelError> {
        assert_eq!(model, &ModelRef::named("requested-alias"));
        Ok(ResolvedModelConfig::new(
            "scripted",
            "requested-alias",
            "resolved-model",
            "resolved-model",
            Vec::new(),
        )
        .with_token_limits(Some(128_000), Some(16_384))
        .with_capabilities(true, true, false))
    }

    fn client(&self, resolved: &ResolvedModelConfig) -> Result<Arc<dyn LlmClient>, ModelError> {
        self.inner.client(resolved)
    }
}

#[test]
fn agent_rejects_empty_name_and_static_instructions() {
    assert_eq!(
        Agent::builder(" ")
            .instructions("Valid instructions.")
            .build()
            .err()
            .as_deref(),
        Some("agent name cannot be empty")
    );
    assert_eq!(
        Agent::builder("assistant")
            .instructions(" ")
            .build()
            .err()
            .as_deref(),
        Some("agent instructions cannot be empty")
    );
}

#[tokio::test]
async fn dynamic_instructions_receive_the_current_run_context() {
    let observed_context = Arc::new(Mutex::new(None));
    let observed_for_instructions = observed_context.clone();
    let provider = ScriptedModelProvider::from_callback("scripted", "demo-model", |request| {
        assert!(request.messages[0].content.contains("tenant=acme"));
        assert!(request.messages[0].content.contains("agent=assistant"));
        assert!(request.messages[0].content.contains("run=run_"));
        Ok(LLMResponse::with_tool_calls(
            "",
            vec![ToolCall::from_raw_arguments(
                "finish",
                "task_finish",
                json!({"message": "done"}),
            )],
        ))
    });
    let agent = Agent::builder("assistant")
        .dynamic_instructions(move |context, current_agent| {
            *observed_for_instructions.lock().expect("context") = Some(context.run_id.clone());
            assert_eq!(current_agent.name(), "assistant");
            assert_eq!(context.agent_name, current_agent.name());
            assert_eq!(context.model.as_ref(), Some(&ModelRef::named("demo-model")));
            assert_eq!(
                context
                    .app_state::<String>()
                    .map(std::string::String::as_str),
                Some("req-1")
            );
            format!(
                "tenant={} agent={} run={}",
                context.metadata["tenant"].as_str().unwrap_or_default(),
                context.agent_name,
                context.run_id,
            )
        })
        .metadata("tenant", json!("acme"))
        .model(ModelRef::named("demo-model"))
        .build()
        .expect("agent");
    let runner = Runner::builder()
        .model_provider(provider)
        .workspace("./workspace")
        .build()
        .expect("runner");

    let result = runner
        .run_with_config(
            &agent,
            "go",
            RunConfig::builder().app_state("req-1".to_string()).build(),
        )
        .await
        .expect("run");

    assert_eq!(result.final_output(), Some("done"));
    assert!(observed_context
        .lock()
        .expect("context")
        .as_deref()
        .is_some_and(|run_id| run_id.starts_with("run_")));
}

#[tokio::test]
async fn resolved_model_alias_is_used_by_dynamic_instructions_and_tool_enablement() {
    let instructions_model = Arc::new(Mutex::new(None));
    let observed_instructions_model = instructions_model.clone();
    let tool_model = Arc::new(Mutex::new(None));
    let observed_tool_model = tool_model.clone();
    let tool = FunctionTool::builder("resolved_only")
        .enabled_if(move |context| {
            *observed_tool_model.lock().expect("tool model") = context.run.model.clone();
            context.run.model.as_ref() == Some(&ModelRef::named("resolved-model"))
        })
        .handler(|_context, _arguments: serde_json::Value| async { Ok(ToolOutput::text("unused")) })
        .build()
        .expect("tool");
    let provider = AliasResolvingProvider {
        inner: ScriptedModelProvider::from_callback("scripted", "resolved-model", |request| {
            assert_eq!(request.model, "resolved-model");
            assert!(request
                .tools
                .iter()
                .any(|schema| schema["function"]["name"] == "resolved_only"));
            Ok(LLMResponse::with_tool_calls(
                "",
                vec![ToolCall::from_raw_arguments(
                    "finish",
                    "task_finish",
                    json!({"message": "done"}),
                )],
            ))
        }),
    };
    let agent = Agent::builder("assistant")
        .dynamic_instructions(move |context, _agent| {
            *observed_instructions_model
                .lock()
                .expect("instructions model") = context.model.clone();
            "Finish.".to_string()
        })
        .model(ModelRef::named("requested-alias"))
        .tool(tool)
        .build()
        .expect("agent");
    let runner = Runner::builder()
        .model_provider(provider)
        .workspace("./workspace")
        .build()
        .expect("runner");

    let result = runner.run(&agent, "go").await.expect("run");

    assert_eq!(result.final_output(), Some("done"));
    assert_eq!(
        *instructions_model.lock().expect("instructions model"),
        Some(ModelRef::named("resolved-model"))
    );
    assert_eq!(
        *tool_model.lock().expect("tool model"),
        Some(ModelRef::named("resolved-model"))
    );
}

struct OrderedHook {
    name: &'static str,
    order: Arc<Mutex<Vec<&'static str>>>,
}

impl RuntimeHook for OrderedHook {
    fn before_llm(&self, _event: BeforeLlmEvent<'_>) -> Option<BeforeLlmPatch> {
        self.order.lock().expect("hook order").push(self.name);
        None
    }
}

#[tokio::test]
async fn agent_hooks_run_before_per_run_hooks() {
    let provider = ScriptedModelProvider::from_callback("scripted", "demo-model", |_request| {
        Ok(LLMResponse::with_tool_calls(
            "",
            vec![ToolCall::from_raw_arguments(
                "finish",
                "task_finish",
                json!({"message": "done"}),
            )],
        ))
    });
    let order = Arc::new(Mutex::new(Vec::new()));
    let agent = Agent::builder("assistant")
        .instructions("Finish.")
        .model(ModelRef::named("demo-model"))
        .hook(Arc::new(OrderedHook {
            name: "agent",
            order: order.clone(),
        }))
        .build()
        .expect("agent");
    let runner = Runner::builder()
        .model_provider(provider)
        .workspace("./workspace")
        .build()
        .expect("runner");

    let result = runner
        .run_with_config(
            &agent,
            "go",
            RunConfig::builder()
                .hook(Arc::new(OrderedHook {
                    name: "run",
                    order: order.clone(),
                }))
                .build(),
        )
        .await
        .expect("run");

    assert_eq!(result.final_output(), Some("done"));
    assert_eq!(*order.lock().expect("hook order"), vec!["agent", "run"]);
}