Skip to main content

agency_proxy_protocol/
lib.rs

1//! Versioned, transport-independent AgencyProxy wire types.
2//!
3//! Local IPC and the later remote connector deliberately share these frames.
4//! Transport access control is outside this crate; v0 local IPC has no
5//! application-layer authentication.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::BTreeMap;
10
11pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 0, minor: 3 };
12pub const MAX_FRAME_BYTES: usize = 1024 * 1024;
13
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct ProtocolVersion {
17    pub major: u16,
18    pub minor: u16,
19}
20
21impl ProtocolVersion {
22    #[must_use]
23    pub const fn negotiate(self, peer: Self) -> Option<Self> {
24        if self.major != peer.major {
25            return None;
26        }
27        Some(Self {
28            major: self.major,
29            minor: if self.minor < peer.minor {
30                self.minor
31            } else {
32                peer.minor
33            },
34        })
35    }
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
39#[serde(transparent)]
40pub struct RunId(pub String);
41
42#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ClientFrame {
45    pub request_id: u64,
46    #[serde(flatten)]
47    pub message: ClientMessage,
48}
49
50#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum ClientMessage {
53    Hello {
54        client_name: String,
55        version: ProtocolVersion,
56    },
57    ListRuns,
58    ProbeProviders,
59    ReadAccountUsage,
60    /// Stop the daemon only when no provider run is still live.
61    ShutdownIfIdle,
62    /// Stop admitting runs, then shut down after the selected policy settles
63    /// every provider process already owned by the daemon.
64    Shutdown {
65        mode: ShutdownMode,
66    },
67    StartRun {
68        run_id: RunId,
69        request: Box<RunRequest>,
70        idempotency_key: String,
71    },
72    AttachRun {
73        run_id: RunId,
74        after_sequence: u64,
75    },
76    DetachRun {
77        run_id: RunId,
78    },
79    InjectMessage {
80        run_id: RunId,
81        body: String,
82        idempotency_key: String,
83    },
84    CancelRun {
85        run_id: RunId,
86        idempotency_key: String,
87    },
88    DecideApproval {
89        run_id: RunId,
90        approval_id: String,
91        decision: ApprovalDecision,
92        idempotency_key: String,
93    },
94    AckEvents {
95        run_id: RunId,
96        through_sequence: u64,
97    },
98}
99
100#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ShutdownMode {
103    /// Let every active provider run finish naturally.
104    Drain,
105    /// Cooperatively terminate active provider runs before shutting down.
106    Terminate,
107}
108
109#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
110#[serde(rename_all = "camelCase")]
111pub struct RunRequest {
112    pub provider: String,
113    pub model: String,
114    pub prompt: String,
115    #[serde(default)]
116    pub is_command: bool,
117    pub system: Option<String>,
118    pub permission: String,
119    pub effort: Option<String>,
120    pub extra_thinking: Option<bool>,
121    pub approvals: bool,
122    pub interactive: bool,
123    pub workspace_roots: Vec<String>,
124    pub resume_session_id: Option<String>,
125    /// Test/development override; production clients normally leave this unset.
126    pub binary: Option<String>,
127    #[serde(default)]
128    pub environment: BTreeMap<String, String>,
129    #[serde(default)]
130    pub unchecked_args: Vec<String>,
131    #[serde(default)]
132    pub metadata: BTreeMap<String, Value>,
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
136#[serde(rename_all = "snake_case")]
137pub enum ApprovalDecision {
138    AllowOnce,
139    AllowSimilar,
140    Deny,
141}
142
143#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
144#[serde(tag = "kind", rename_all = "snake_case")]
145pub enum ServerFrame {
146    Response {
147        request_id: u64,
148        #[serde(flatten)]
149        response: ServerResponse,
150    },
151    Event {
152        run_id: RunId,
153        sequence: u64,
154        event: RunEvent,
155    },
156}
157
158#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
159#[serde(tag = "type", rename_all = "snake_case")]
160pub enum ServerResponse {
161    Hello {
162        server_name: String,
163        version: ProtocolVersion,
164        capabilities: Vec<Capability>,
165    },
166    Runs {
167        runs: Vec<RunSnapshot>,
168    },
169    Providers {
170        providers: Vec<ProviderStatus>,
171    },
172    AccountUsage {
173        providers: Vec<ProviderAccountUsage>,
174    },
175    Run {
176        run: RunSnapshot,
177    },
178    Accepted,
179    Error {
180        code: ErrorCode,
181        message: String,
182    },
183}
184
185#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
186#[serde(rename_all = "snake_case")]
187pub enum Capability {
188    EventReplay,
189    LiveInjection,
190    Approvals,
191    Cancellation,
192    ProviderDetection,
193    LifecycleControl,
194}
195
196#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ProviderStatus {
199    pub provider: String,
200    pub installed: bool,
201    pub version: Option<String>,
202    pub outdated: bool,
203    pub auth_state: String,
204    pub detail: String,
205    pub auth_method: Option<String>,
206    pub account: Option<String>,
207    pub plan: Option<String>,
208    pub login_hint: String,
209}
210
211#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct ProviderAccountUsage {
214    pub provider: String,
215    pub supported: bool,
216    pub usage: Option<Value>,
217    pub error: Option<String>,
218}
219
220#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum ErrorCode {
223    IncompatibleVersion,
224    ProtocolViolation,
225    NotFound,
226    NotImplemented,
227    Conflict,
228    Internal,
229}
230
231#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
232#[serde(rename_all = "snake_case")]
233pub enum RunState {
234    Starting,
235    Running,
236    WaitingApproval,
237    Finishing,
238    Completed,
239    Failed,
240    Canceled,
241    Interrupted,
242}
243
244#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
245#[serde(rename_all = "camelCase")]
246pub struct RunSnapshot {
247    pub run_id: RunId,
248    pub state: RunState,
249    pub provider: String,
250    pub model: String,
251    pub provider_session_id: Option<String>,
252    pub latest_sequence: u64,
253    pub acknowledged_sequence: u64,
254    pub workspace_roots: Vec<String>,
255    #[serde(default)]
256    pub metadata: BTreeMap<String, Value>,
257}
258
259#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
260#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
261pub enum RunEvent {
262    /// The exact terminal outcome after the provider process has been reaped.
263    Finished(Value),
264    /// The provider process could not produce a terminal outcome.
265    Failed(String),
266    StateChanged(RunState),
267    SessionOpened {
268        provider_session_id: String,
269        model: Option<String>,
270    },
271    Reasoning(String),
272    Text(String),
273    MessageBoundary,
274    ToolCall {
275        id: Option<String>,
276        name: String,
277        input: Value,
278    },
279    ToolResult {
280        id: Option<String>,
281        ok: Option<bool>,
282        output: String,
283    },
284    ApprovalRequested {
285        approval_id: String,
286        title: String,
287        detail: Value,
288    },
289    Usage(Value),
290    RateLimit(Value),
291    Compaction(Value),
292    Commands(Value),
293    Error(String),
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn negotiates_only_within_the_same_major_version() {
302        assert_eq!(
303            PROTOCOL_VERSION.negotiate(ProtocolVersion { major: 0, minor: 0 }),
304            Some(ProtocolVersion { major: 0, minor: 0 })
305        );
306        assert_eq!(
307            PROTOCOL_VERSION.negotiate(ProtocolVersion { major: 1, minor: 0 }),
308            None
309        );
310    }
311
312    #[test]
313    fn frames_round_trip_without_transport_specific_state() {
314        let frame = ClientFrame {
315            request_id: 7,
316            message: ClientMessage::AttachRun {
317                run_id: RunId("run-7".into()),
318                after_sequence: 41,
319            },
320        };
321        let encoded = serde_json::to_vec(&frame).expect("frame should encode");
322        let decoded: ClientFrame = serde_json::from_slice(&encoded).expect("frame should decode");
323        assert_eq!(decoded, frame);
324    }
325}