Skip to main content

agentd/wire/
intel.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Intelligence wire types — the **provider-neutral** representation the
3//! agentic loop reasons over. RFC 0006.
4//!
5//! The loop builds a [`Request`] and consumes a [`Response`] without knowing
6//! which provider answered. The `intel/openai.rs` and `intel/anthropic.rs`
7//! adapters translate to/from the on-the-wire JSON dialects; a model lacking
8//! native tool-calling falls back to the JSON-action shape parsed in
9//! `agentloop/action.rs`. Keeping the neutral model here (not a provider
10//! struct) is what holds the two-adapters-and-no-more line (RFC 0006 §wire).
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// One conversation message. `Assistant` may carry tool calls; `ToolResult`
16/// feeds a tool's output back as the next observation (RFC 0007 §loop).
17#[derive(Debug, Clone, PartialEq)]
18pub enum Message {
19    System(String),
20    User(String),
21    Assistant {
22        text: Option<String>,
23        tool_calls: Vec<ToolCall>,
24    },
25    /// A tool/exec result fed back into the loop. `is_error` carries the MCP
26    /// `isError: true` signal (a tool-domain failure observation, distinct
27    /// from a transport error — RFC 0004 §isError).
28    ToolResult {
29        id: String,
30        content: String,
31        is_error: bool,
32    },
33}
34
35impl Message {
36    pub fn system(s: impl Into<String>) -> Message {
37        Message::System(s.into())
38    }
39    pub fn user(s: impl Into<String>) -> Message {
40        Message::User(s.into())
41    }
42    pub fn tool_result(
43        id: impl Into<String>,
44        content: impl Into<String>,
45        is_error: bool,
46    ) -> Message {
47        Message::ToolResult {
48            id: id.into(),
49            content: content.into(),
50            is_error,
51        }
52    }
53}
54
55/// A model-requested tool invocation. `arguments` is already-parsed JSON.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct ToolCall {
58    pub id: String,
59    pub name: String,
60    pub arguments: Value,
61}
62
63/// A tool advertised to the model in the request `tools` field. Sourced from
64/// the scoped MCP `tools/list` (RFC 0004) plus agentd's self-tools (RFC 0005).
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ToolDef {
67    pub name: String,
68    #[serde(default, skip_serializing_if = "String::is_empty")]
69    pub description: String,
70    /// JSON Schema of the tool's input (the MCP `inputSchema`).
71    pub input_schema: Value,
72}
73
74/// A request to the intelligence endpoint.
75#[derive(Debug, Clone)]
76pub struct Request {
77    pub model: String,
78    pub messages: Vec<Message>,
79    pub tools: Vec<ToolDef>,
80    pub max_tokens: u32,
81    pub temperature: Option<f32>,
82}
83
84/// Why the model stopped — drives the loop's branch (tool-use vs final) and
85/// the `exhausted_tokens` terminal status (RFC 0007 §3.4).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum StopReason {
89    /// The model produced a final answer.
90    EndTurn,
91    /// The model requested one or more tools.
92    ToolUse,
93    /// The model hit the response `max_tokens` cap.
94    MaxTokens,
95    /// Anything else a provider reports (mapped, not dropped).
96    Other,
97}
98
99/// Token accounting from one model call. Summed into the run budget by the
100/// supervisor (RFC 0003 §hierarchical-accounting).
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Usage {
103    #[serde(default)]
104    pub input_tokens: u64,
105    #[serde(default)]
106    pub output_tokens: u64,
107}
108
109impl Usage {
110    pub fn total(&self) -> u64 {
111        self.input_tokens + self.output_tokens
112    }
113}
114
115/// A response from the intelligence endpoint, normalized across providers.
116#[derive(Debug, Clone)]
117pub struct Response {
118    pub text: Option<String>,
119    pub tool_calls: Vec<ToolCall>,
120    pub stop_reason: StopReason,
121    pub usage: Usage,
122}
123
124impl Response {
125    /// The model wants tools run before it continues — the loop must execute
126    /// them and feed results back rather than treating `text` as final.
127    pub fn wants_tools(&self) -> bool {
128        !self.tool_calls.is_empty()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn usage_totals() {
138        let u = Usage {
139            input_tokens: 100,
140            output_tokens: 25,
141        };
142        assert_eq!(u.total(), 125);
143    }
144
145    #[test]
146    fn tool_call_roundtrips() {
147        let tc = ToolCall {
148            id: "call_1".into(),
149            name: "read_file".into(),
150            arguments: serde_json::json!({"path": "/etc/hosts"}),
151        };
152        let s = serde_json::to_string(&tc).unwrap();
153        let back: ToolCall = serde_json::from_str(&s).unwrap();
154        assert_eq!(back, tc);
155    }
156
157    #[test]
158    fn response_branch() {
159        let r = Response {
160            text: None,
161            tool_calls: vec![ToolCall {
162                id: "1".into(),
163                name: "x".into(),
164                arguments: Value::Null,
165            }],
166            stop_reason: StopReason::ToolUse,
167            usage: Usage::default(),
168        };
169        assert!(r.wants_tools());
170    }
171
172    #[test]
173    fn stop_reason_snake_case() {
174        assert_eq!(
175            serde_json::to_string(&StopReason::ToolUse).unwrap(),
176            "\"tool_use\""
177        );
178        assert_eq!(
179            serde_json::to_string(&StopReason::EndTurn).unwrap(),
180            "\"end_turn\""
181        );
182    }
183}