af-agent 0.4.0

Stable Agent model, tool, inbox, and trusted-plugin contracts.
Documentation
//! Unit coverage for ToolRegistry surfaces and the type-erased ChatModel impl.

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::{json, Value};

use af_agent::{
    CancellationToken, ChatModel, RequestContext, Tool, ToolExecutionContext, ToolRegistry,
};
use af_llm::{ChatMessage, Choice, CompletionRequest, CompletionResponse, LlmError};

struct PingTool;

#[async_trait]
impl Tool for PingTool {
    fn name(&self) -> &str {
        "ping"
    }
    fn description(&self) -> &str {
        "returns pong"
    }
    fn parameters(&self) -> Value {
        json!({"type": "object"})
    }
    fn output_schema(&self) -> Value {
        json!({"type":"object","required":["reply"],"properties":{"reply":{"const":"pong"}}})
    }
    async fn call(&self, _args: Value) -> Result<Value, String> {
        Ok(json!({"reply": "pong"}))
    }
}

#[tokio::test]
async fn tool_registry_surfaces() {
    let mut reg = ToolRegistry::new();
    assert!(reg.is_empty());
    assert_eq!(reg.len(), 0);

    reg.register(Arc::new(PingTool)).unwrap();
    assert!(!reg.is_empty());
    assert_eq!(reg.len(), 1);
    assert!(reg.contains("ping"));
    assert!(!reg.contains("nope"));
    assert_eq!(reg.names(), ["ping"]);

    let allowed = reg.filtered(["ping"]);
    assert!(allowed.contains("ping"));
    let denied = reg.filtered(["ghost"]);
    assert!(denied.is_empty());
    assert!(format!("{reg:?}").contains("ping"));

    // specs expose the LLM-facing tool definitions
    let specs = reg.specs();
    assert_eq!(specs.len(), 1);

    // execute known + unknown
    let execution = ToolExecutionContext {
        request: RequestContext {
            tenant_id: "tenant".parse().unwrap(),
            subject_id: "subject".parse().unwrap(),
            roles: Default::default(),
            locale: "en".into(),
            request_id: "request".parse().unwrap(),
            entitlements: Default::default(),
        },
        session_id: "session".parse().unwrap(),
        run_id: "run".parse().unwrap(),
        step: 1,
        call_id: "call".parse().unwrap(),
        source_event_seq: 1,
        interaction_resolution: None,
        cancellation: CancellationToken::default(),
        deadline: std::time::Instant::now() + std::time::Duration::from_secs(1),
    };
    let ok = reg
        .execute_with_context("ping", &execution, json!({}))
        .await
        .unwrap();
    assert_eq!(ok["reply"], "pong");
    let err = reg
        .execute_with_context("ghost", &execution, json!({}))
        .await
        .unwrap_err();
    assert!(err.contains("unknown tool"));
    assert!(err.contains("Available:"));

    assert!(reg
        .execute_with_context("Notebookping", &execution, json!({}))
        .await
        .is_err());
}

struct FixedModel;

#[async_trait]
impl ChatModel for FixedModel {
    async fn complete_streaming(
        &self,
        _req: &CompletionRequest,
        delta_tx: tokio::sync::mpsc::UnboundedSender<(String, bool)>,
    ) -> Result<CompletionResponse, LlmError> {
        let _ = delta_tx.send(("ok".into(), false));
        Ok(CompletionResponse {
            id: "f".into(),
            choices: vec![Choice {
                index: 0,
                message: ChatMessage::assistant("ok"),
                finish_reason: None,
                output_blocks: Vec::new(),
            }],
            usage: None,
        })
    }
}

#[tokio::test]
async fn type_erased_chat_model_delegates() {
    let model: Arc<dyn ChatModel> = Arc::new(FixedModel);
    // exercise the `impl ChatModel for Arc<dyn ChatModel>` delegation
    let req = CompletionRequest::new("m", vec![ChatMessage::user("hi")]);
    let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel();
    let resp = model.complete_streaming(&req, delta_tx).await.unwrap();
    assert_eq!(resp.first_content(), Some("ok"));
    assert_eq!(delta_rx.recv().await.unwrap(), ("ok".into(), false));
}