Skip to main content

tau_agent_base/
plugin_protocol.rs

1//! Plugin wire protocol types.
2//!
3//! These are the JSON-lines messages exchanged between the server and plugin
4//! processes over stdin/stdout. Pure serde data — no async, no I/O.
5//!
6//! **Status:** scheduled for replacement by [`crate::plugin_service`] (the
7//! myelin-based duplex transport with CBOR codec). Until that migration
8//! lands one plugin at a time, this module is the live wire protocol
9//! for both `tau-agent-plugin-worker` and `tau-agent-plugin-tasks`.
10//! See task #759 for the rollout plan.
11
12use serde::{Deserialize, Serialize};
13
14use crate::protocol::{Request, Response};
15use crate::types::{Tool, ToolResultContent};
16
17// ---------------------------------------------------------------------------
18// Protocol messages: tau → plugin
19// ---------------------------------------------------------------------------
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23#[allow(clippy::large_enum_variant)]
24pub enum PluginRequest {
25    /// Initialize the plugin with session context.
26    Init {
27        cwd: String,
28        session_id: String,
29        /// Project name for this session.
30        #[serde(skip_serializing_if = "Option::is_none")]
31        project_name: Option<String>,
32    },
33    /// Call a hook.
34    Hook {
35        name: String,
36        data: serde_json::Value,
37    },
38    /// Execute a tool call.
39    ToolCall {
40        tool_call_id: String,
41        name: String,
42        arguments: serde_json::Value,
43        /// Working directory for tool execution.
44        #[serde(skip_serializing_if = "Option::is_none")]
45        cwd: Option<String>,
46        /// Session this tool call belongs to.
47        #[serde(skip_serializing_if = "Option::is_none")]
48        session_id: Option<String>,
49        /// Project name for this session.
50        #[serde(skip_serializing_if = "Option::is_none")]
51        project_name: Option<String>,
52    },
53    /// Cancel an in-flight tool call. The plugin should abort the tool by
54    /// its `tool_call_id` (e.g. kill the bash subprocess) and return a
55    /// normal `ToolResult` indicating cancellation. If the tool has already
56    /// completed, this is a no-op.
57    CancelToolCall { tool_call_id: String },
58    /// Notify session start.
59    SessionStart {
60        cwd: String,
61        session_id: String,
62        /// Project name for this session.
63        #[serde(skip_serializing_if = "Option::is_none")]
64        project_name: Option<String>,
65    },
66    /// Notify the plugin it has been idle. Plugin may exit in response.
67    Idle,
68    /// Server response (server -> plugin tunnel).
69    /// Response to a PluginMessage::ServerRequest.
70    ServerResponse {
71        request_id: String,
72        response: Response,
73    },
74}
75
76// ---------------------------------------------------------------------------
77// Protocol messages: plugin → tau
78// ---------------------------------------------------------------------------
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "type", rename_all = "snake_case")]
82pub enum PluginMessage {
83    /// Plugin registration (sent once on startup).
84    Register(PluginRegistration),
85    /// Hook result.
86    HookResult(HookResult),
87    /// Tool execution result (final).
88    ToolResult(PluginToolResult),
89    /// Tool output delta (streaming).
90    OutputDelta { tool_call_id: String, text: String },
91    /// Server request (plugin → server tunnel).
92    /// Plugin sends a client protocol Request; server processes it and
93    /// responds with ServerResponse.
94    ServerRequest {
95        request_id: String,
96        request: Request,
97    },
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PluginRegistration {
102    /// Plugin name.
103    pub name: String,
104    /// Tools provided by this plugin.
105    #[serde(default)]
106    pub tools: Vec<PluginToolDef>,
107    /// Hooks this plugin wants to receive.
108    #[serde(default)]
109    pub hooks: Vec<String>,
110    /// Slash commands provided by this plugin.
111    #[serde(default)]
112    pub commands: Vec<PluginCommand>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct PluginToolDef {
117    /// Tool name.
118    pub name: String,
119    /// Tool description (for LLM).
120    pub description: String,
121    /// JSON Schema for parameters.
122    pub parameters: serde_json::Value,
123    /// One-line snippet for system prompt "Available tools:" list.
124    #[serde(default)]
125    pub prompt_snippet: Option<String>,
126    /// Extra guideline bullets for system prompt.
127    #[serde(default)]
128    pub prompt_guidelines: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PluginCommand {
133    /// Command name (without /).
134    pub name: String,
135    /// Description shown in /help.
136    pub description: String,
137}
138
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct HookResult {
141    /// Optional message to inject before the LLM turn.
142    #[serde(default)]
143    pub message: Option<HookMessage>,
144    /// Optional replacement system prompt.
145    #[serde(default)]
146    pub system_prompt: Option<String>,
147    /// Optional text to append to a tool result (for after_tool_result hook).
148    #[serde(default)]
149    pub tool_result_append: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct HookMessage {
154    /// Content of the injected message.
155    pub content: String,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PluginToolResult {
160    pub tool_call_id: String,
161    pub content: Vec<ToolResultContent>,
162    pub is_error: bool,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub summary: Option<String>,
165    /// Tier-2 actions to run after this tool result is persisted to the
166    /// caller's session history. Drained by the agent loop once the tool
167    /// result reaches the caller's history.
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub post_persist_actions: Vec<crate::types::PostPersistAction>,
170}
171
172/// Convert a `PluginToolDef` to a `Tool` (for LLM context).
173impl From<&PluginToolDef> for Tool {
174    fn from(def: &PluginToolDef) -> Self {
175        Tool {
176            name: def.name.clone(),
177            description: def.description.clone(),
178            parameters: def.parameters.clone(),
179        }
180    }
181}
182
183/// Convert a `PluginToolDef` into a `ToolPrompt`.
184impl From<&PluginToolDef> for crate::tool_prompt::ToolPrompt {
185    fn from(def: &PluginToolDef) -> Self {
186        crate::tool_prompt::ToolPrompt {
187            name: def.name.clone(),
188            snippet: def.prompt_snippet.clone().unwrap_or_default(),
189            guidelines: def.prompt_guidelines.clone(),
190        }
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Tests
196// ---------------------------------------------------------------------------
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn cancel_tool_call_roundtrip() {
204        // Wire shape for the cancellation RPC — snake_case tag + field.
205        let req = PluginRequest::CancelToolCall {
206            tool_call_id: "tc-42".into(),
207        };
208        let json = serde_json::to_string(&req).expect("serialize");
209        assert_eq!(
210            json,
211            r#"{"type":"cancel_tool_call","tool_call_id":"tc-42"}"#
212        );
213
214        let parsed: PluginRequest = serde_json::from_str(&json).expect("deserialize");
215        match parsed {
216            PluginRequest::CancelToolCall { tool_call_id } => {
217                assert_eq!(tool_call_id, "tc-42");
218            }
219            other => panic!("expected CancelToolCall, got {other:?}"),
220        }
221    }
222
223    #[test]
224    fn tool_call_roundtrip_unchanged_by_cancel_variant() {
225        // Back-compat sanity: adding CancelToolCall must not alter the wire
226        // encoding of pre-existing variants.
227        let req = PluginRequest::ToolCall {
228            tool_call_id: "tc-1".into(),
229            name: "bash".into(),
230            arguments: serde_json::json!({"command": "echo hi"}),
231            cwd: Some("/tmp".into()),
232            session_id: None,
233            project_name: None,
234        };
235        let json = serde_json::to_string(&req).expect("serialize");
236        assert!(json.contains(r#""type":"tool_call""#));
237        assert!(json.contains(r#""tool_call_id":"tc-1""#));
238        assert!(json.contains(r#""name":"bash""#));
239        assert!(json.contains(r#""cwd":"/tmp""#));
240        // Absent optional fields should not serialise.
241        assert!(!json.contains("session_id"));
242        assert!(!json.contains("project_name"));
243    }
244}