haki-core 0.1.0

Agent loop and session management for haki
Documentation
//! Agent loop — the central turn-by-turn execution engine.
//!
//! `ToolExecutor` is the extension point: haki-tools implements it for real
//! tools; tests use `NoopToolExecutor`.

use async_trait::async_trait;
use anyhow::Context;
use tracing::{debug, info};

use haki_llm::{CompletionRequest, LlmProvider, Message};

use crate::{message::ToolCall, session::Session};

// ─── ToolExecutor trait ───────────────────────────────────────────────────────

#[async_trait]
pub trait ToolExecutor: Send + Sync {
    async fn execute(&self, call: &ToolCall) -> anyhow::Result<String>;
    fn available_tools(&self) -> Vec<serde_json::Value>;
}

// ─── No-op stub (used before haki-tools is wired) ────────────────────────────

pub struct NoopToolExecutor;

#[async_trait]
impl ToolExecutor for NoopToolExecutor {
    async fn execute(&self, call: &ToolCall) -> anyhow::Result<String> {
        Ok(format!("[noop] tool '{}' not yet implemented", call.name))
    }

    fn available_tools(&self) -> Vec<serde_json::Value> {
        vec![]
    }
}

// ─── AgentLoop ────────────────────────────────────────────────────────────────

pub struct AgentLoop {
    provider: LlmProvider,
    #[allow(dead_code)]
    executor: Box<dyn ToolExecutor>,
    max_tokens: u32,
}

impl AgentLoop {
    pub fn new(
        provider: LlmProvider,
        executor: Box<dyn ToolExecutor>,
        max_tokens: u32,
    ) -> Self {
        Self { provider, executor, max_tokens }
    }

    /// Run one user turn. Appends the user message and the assistant reply to
    /// `session.history`, then returns the assistant's text.
    pub async fn run_turn(
        &self,
        session: &mut Session,
        user_input: &str,
    ) -> anyhow::Result<String> {
        session.push(Message::user(user_input));

        let req = CompletionRequest {
            model: session.model.clone(),
            system: session.config.system_prompt.clone(),
            messages: session.history().to_vec(),
            max_tokens: self.max_tokens,
        };

        info!(model = %session.model, "sending completion request");
        let resp = self.provider.complete(req).await.context("LLM completion failed")?;

        debug!(
            input_tokens = resp.usage.input_tokens,
            output_tokens = resp.usage.output_tokens,
            "token usage"
        );

        session.push(Message::assistant(&resp.content));
        Ok(resp.content)
    }
}

// ─── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn noop_executor_tool_list_is_empty() {
        let exec = NoopToolExecutor;
        assert!(exec.available_tools().is_empty());
    }

    #[tokio::test]
    async fn noop_executor_returns_noop_message() {
        let call = ToolCall {
            id: "c1".into(),
            name: "bash".into(),
            input: serde_json::json!({}),
        };
        let result = NoopToolExecutor.execute(&call).await.unwrap();
        assert!(result.contains("noop"));
    }
}