agentd-core 1.6.0

Minimal, MCP-native agent runtime as a library: the agentic loop, supervisor, workflows, and code-registered tools (the agentd engine)
Documentation
// SPDX-License-Identifier: AGPL-3.0-only
//! Intelligence wire types — the **provider-neutral** representation the
//! agentic loop reasons over.
//!
//! The loop builds a [`Request`] and consumes a [`Response`] without knowing
//! which provider answered. The `intel/openai.rs` and `intel/anthropic.rs`
//! adapters translate to/from the on-the-wire JSON dialects; a model lacking
//! native tool-calling falls back to the JSON-action shape parsed in
//! `agentloop/action.rs`. Because the neutral model lives here rather than in
//! one provider's struct, supporting another provider costs one adapter and no
//! change to the loop — that is what keeps the adapter count from growing into
//! the rest of the runtime.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// One conversation message. `Assistant` may carry tool calls; `ToolResult`
/// feeds a tool's output back into the conversation as the next observation.
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
    System(String),
    User(String),
    Assistant {
        text: Option<String>,
        tool_calls: Vec<ToolCall>,
    },
    /// A tool/exec result fed back into the loop. `is_error` carries the MCP
    /// `isError: true` signal: the tool ran and reported a domain failure the
    /// model should see and react to. A transport error never reaches here —
    /// it fails the call instead of becoming an observation.
    ToolResult {
        id: String,
        content: String,
        is_error: bool,
    },
}

impl Message {
    pub fn system(s: impl Into<String>) -> Message {
        Message::System(s.into())
    }
    pub fn user(s: impl Into<String>) -> Message {
        Message::User(s.into())
    }
    pub fn tool_result(
        id: impl Into<String>,
        content: impl Into<String>,
        is_error: bool,
    ) -> Message {
        Message::ToolResult {
            id: id.into(),
            content: content.into(),
            is_error,
        }
    }
}

/// A model-requested tool invocation. `arguments` is already-parsed JSON.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    pub id: String,
    pub name: String,
    pub arguments: Value,
}

/// A tool advertised to the model in the request `tools` field. Sourced from
/// the scoped MCP `tools/list` plus agentd's own self-tools.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDef {
    pub name: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub description: String,
    /// JSON Schema of the tool's input (the MCP `inputSchema`).
    pub input_schema: Value,
}

/// A request to the intelligence endpoint.
#[derive(Debug, Clone)]
pub struct Request {
    pub model: String,
    pub messages: Vec<Message>,
    pub tools: Vec<ToolDef>,
    pub max_tokens: u32,
    pub temperature: Option<f32>,
}

/// Why the model stopped — drives the loop's branch (tool-use vs final) and
/// the `exhausted_tokens` terminal status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
    /// The model produced a final answer.
    EndTurn,
    /// The model requested one or more tools.
    ToolUse,
    /// The model hit the response `max_tokens` cap.
    MaxTokens,
    /// Anything else a provider reports (mapped, not dropped).
    Other,
}

/// Token accounting from one model call. The supervisor sums these into the
/// run's budget, and a child's usage also counts against its parent's.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
    #[serde(default)]
    pub input_tokens: u64,
    #[serde(default)]
    pub output_tokens: u64,
}

impl Usage {
    pub fn total(&self) -> u64 {
        self.input_tokens + self.output_tokens
    }
}

/// A response from the intelligence endpoint, normalized across providers.
#[derive(Debug, Clone)]
pub struct Response {
    pub text: Option<String>,
    pub tool_calls: Vec<ToolCall>,
    pub stop_reason: StopReason,
    pub usage: Usage,
}

impl Response {
    /// The model wants tools run before it continues — the loop must execute
    /// them and feed results back rather than treating `text` as final.
    pub fn wants_tools(&self) -> bool {
        !self.tool_calls.is_empty()
    }
}

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

    #[test]
    fn usage_totals() {
        let u = Usage {
            input_tokens: 100,
            output_tokens: 25,
        };
        assert_eq!(u.total(), 125);
    }

    #[test]
    fn tool_call_roundtrips() {
        let tc = ToolCall {
            id: "call_1".into(),
            name: "read_file".into(),
            arguments: serde_json::json!({"path": "/etc/hosts"}),
        };
        let s = serde_json::to_string(&tc).unwrap();
        let back: ToolCall = serde_json::from_str(&s).unwrap();
        assert_eq!(back, tc);
    }

    #[test]
    fn response_branch() {
        let r = Response {
            text: None,
            tool_calls: vec![ToolCall {
                id: "1".into(),
                name: "x".into(),
                arguments: Value::Null,
            }],
            stop_reason: StopReason::ToolUse,
            usage: Usage::default(),
        };
        assert!(r.wants_tools());
    }

    #[test]
    fn stop_reason_snake_case() {
        assert_eq!(
            serde_json::to_string(&StopReason::ToolUse).unwrap(),
            "\"tool_use\""
        );
        assert_eq!(
            serde_json::to_string(&StopReason::EndTurn).unwrap(),
            "\"end_turn\""
        );
    }
}