agent-kernel 0.1.0

Minimal Agent orchestration kernel for multi-agent discussion
Documentation
//! Custom runtime example — how to implement `AgentRuntime` with a real HTTP client.
//!
//! This example shows the pattern for wiring `AgentRuntime` to an actual LLM
//! provider (Anthropic, OpenAI, OpenRouter, etc.). The HTTP call itself is
//! left as pseudocode so the example compiles without requiring API keys.
//!
//! Key points:
//! - `AgentRuntime` has exactly one method: `respond()`.
//! - Return type is `BoxFuture<'a, anyhow::Result<String>>` — use `Box::pin(async move { ... })`.
//! - All provider-specific serialization happens *inside* `respond()` (late-binding principle).
//! - The runtime holds shared state (HTTP client, API key) via `Arc` for cheap cloning.
//!
//! Run with:
//! ```bash
//! cargo run --example custom_runtime
//! ```

use std::sync::Arc;

use agent_kernel::{Agent, AgentEvent, AgentRuntime, BoxFuture, Message, Role, discuss};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

// ── HttpRuntime ───────────────────────────────────────────────────────────────

/// A runtime that calls an LLM provider over HTTP.
///
/// In a real implementation you would use `reqwest::Client` here.
/// The `Arc` wrapper lets you clone the runtime cheaply across async tasks.
#[derive(Clone)]
struct HttpRuntime {
    /// Shared HTTP client (e.g. `reqwest::Client`).
    ///
    /// Using `Arc<()>` as a placeholder — replace with your actual client type.
    _client: Arc<()>,
    /// LLM provider base URL (e.g. "https://api.anthropic.com").
    base_url: String,
    /// API key loaded from an environment variable.
    api_key: String,
}

impl HttpRuntime {
    fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
        Self {
            _client: Arc::new(()),
            base_url: base_url.into(),
            api_key: api_key.into(),
        }
    }
}

impl AgentRuntime for HttpRuntime {
    fn respond<'a>(
        &'a self,
        agent: &'a Agent,
        history: &'a [Message],
    ) -> BoxFuture<'a, anyhow::Result<String>> {
        Box::pin(async move {
            // ── Build the request payload ─────────────────────────────────────
            //
            // Convert kernel-internal `Message` types to the provider's JSON
            // format here (late-binding principle: conversion only at the boundary).
            //
            // Example for Anthropic Messages API:
            //
            //   let messages: Vec<serde_json::Value> = history
            //       .iter()
            //       .filter(|m| m.role != Role::System)
            //       .map(|m| serde_json::json!({
            //           "role": match m.role {
            //               Role::User      => "user",
            //               Role::Assistant => "assistant",
            //               Role::System    => unreachable!(),
            //           },
            //           "content": m.content,
            //       }))
            //       .collect();
            //
            //   let body = serde_json::json!({
            //       "model":      &agent.model,
            //       "system":     &agent.soul_md,
            //       "messages":   messages,
            //       "max_tokens": 1024,
            //   });

            // ── Send the request ─────────────────────────────────────────────
            //
            //   let response = client
            //       .post(format!("{}/v1/messages", self.base_url))
            //       .header("x-api-key", &self.api_key)
            //       .header("anthropic-version", "2023-06-01")
            //       .json(&body)
            //       .send()
            //       .await?
            //       .error_for_status()?
            //       .json::<serde_json::Value>()
            //       .await?;
            //
            //   let text = response["content"][0]["text"]
            //       .as_str()
            //       .unwrap_or("")
            //       .to_string();
            //   Ok(text)

            // ── Stub response (remove in real implementation) ─────────────────
            let _ = (&self.base_url, &self.api_key); // suppress unused warnings
            let _ = history.iter().filter(|m| m.role != Role::System).count();
            Ok(format!(
                "[{}] (stub) I would call {} here.",
                agent.name, self.base_url
            ))
        })
    }
}

// ── main ─────────────────────────────────────────────────────────────────────

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Load API key from environment (never hard-code secrets).
    let api_key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_else(|_| "stub-key".to_string());

    let runtime = HttpRuntime::new("https://api.anthropic.com", api_key);

    let agents = vec![
        Agent {
            name: "analyst".to_string(),
            soul_md: "You are a senior software architect. Be precise and concise.".to_string(),
            model: "claude-sonnet-4-6-20250514".to_string(),
        },
        Agent {
            name: "critic".to_string(),
            soul_md: "You are a critical reviewer. Identify risks and edge cases.".to_string(),
            model: "claude-sonnet-4-6-20250514".to_string(),
        },
    ];

    let (tx, mut rx) = mpsc::channel::<AgentEvent>(64);
    let cancel = CancellationToken::new();

    // Collect events in a background task.
    let collector = tokio::spawn(async move {
        let mut events = Vec::new();
        while let Some(event) = rx.recv().await {
            println!("{event:?}");
            events.push(event);
        }
        events
    });

    let summary = discuss(
        &runtime,
        &agents,
        "Should we migrate our REST API to GraphQL?",
        2,
        cancel,
        tx,
    )
    .await?;

    let events = collector.await?;
    println!("\nTotal events: {}", events.len());
    println!("Summary: {summary}");

    Ok(())
}