Skip to main content

agentd/wire/
intel.rs

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