Skip to main content

haki_core/
agent_loop.rs

1//! Agent loop — the central turn-by-turn execution engine.
2//!
3//! `ToolExecutor` is the extension point: haki-tools implements it for real
4//! tools; tests use `NoopToolExecutor`.
5
6use async_trait::async_trait;
7use anyhow::Context;
8use tracing::{debug, info};
9
10use haki_llm::{CompletionRequest, LlmProvider, Message};
11
12use crate::{message::ToolCall, session::Session};
13
14// ─── ToolExecutor trait ───────────────────────────────────────────────────────
15
16#[async_trait]
17pub trait ToolExecutor: Send + Sync {
18    async fn execute(&self, call: &ToolCall) -> anyhow::Result<String>;
19    fn available_tools(&self) -> Vec<serde_json::Value>;
20}
21
22// ─── No-op stub (used before haki-tools is wired) ────────────────────────────
23
24pub struct NoopToolExecutor;
25
26#[async_trait]
27impl ToolExecutor for NoopToolExecutor {
28    async fn execute(&self, call: &ToolCall) -> anyhow::Result<String> {
29        Ok(format!("[noop] tool '{}' not yet implemented", call.name))
30    }
31
32    fn available_tools(&self) -> Vec<serde_json::Value> {
33        vec![]
34    }
35}
36
37// ─── AgentLoop ────────────────────────────────────────────────────────────────
38
39pub struct AgentLoop {
40    provider: LlmProvider,
41    #[allow(dead_code)]
42    executor: Box<dyn ToolExecutor>,
43    max_tokens: u32,
44}
45
46impl AgentLoop {
47    pub fn new(
48        provider: LlmProvider,
49        executor: Box<dyn ToolExecutor>,
50        max_tokens: u32,
51    ) -> Self {
52        Self { provider, executor, max_tokens }
53    }
54
55    /// Run one user turn. Appends the user message and the assistant reply to
56    /// `session.history`, then returns the assistant's text.
57    pub async fn run_turn(
58        &self,
59        session: &mut Session,
60        user_input: &str,
61    ) -> anyhow::Result<String> {
62        session.push(Message::user(user_input));
63
64        let req = CompletionRequest {
65            model: session.model.clone(),
66            system: session.config.system_prompt.clone(),
67            messages: session.history().to_vec(),
68            max_tokens: self.max_tokens,
69        };
70
71        info!(model = %session.model, "sending completion request");
72        let resp = self.provider.complete(req).await.context("LLM completion failed")?;
73
74        debug!(
75            input_tokens = resp.usage.input_tokens,
76            output_tokens = resp.usage.output_tokens,
77            "token usage"
78        );
79
80        session.push(Message::assistant(&resp.content));
81        Ok(resp.content)
82    }
83}
84
85// ─── Tests ────────────────────────────────────────────────────────────────────
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn noop_executor_tool_list_is_empty() {
93        let exec = NoopToolExecutor;
94        assert!(exec.available_tools().is_empty());
95    }
96
97    #[tokio::test]
98    async fn noop_executor_returns_noop_message() {
99        let call = ToolCall {
100            id: "c1".into(),
101            name: "bash".into(),
102            input: serde_json::json!({}),
103        };
104        let result = NoopToolExecutor.execute(&call).await.unwrap();
105        assert!(result.contains("noop"));
106    }
107}