Skip to main content

plato_agent/daemon/
protocol.rs

1use platonic_core::EffectClass;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::fmt;
5
6pub const PROTOCOL_VERSION: u32 = 1;
7
8pub const ERROR_DAEMON_SHUTTING_DOWN: &str = "daemon_shutting_down";
9pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
10pub const ERROR_LAGGED: &str = "lagged";
11pub const ERROR_INTERNAL: &str = "internal_error";
12pub const ERROR_NOT_FOUND: &str = "not_found";
13pub const ERROR_OVERLOAD: &str = "overload";
14pub const ERROR_RUN_FAILED: &str = "run_failed";
15pub const ERROR_SESSIONS_LIST_FAILED: &str = "sessions_list_failed";
16pub const ERROR_UNSUPPORTED_METHOD: &str = "unsupported_method";
17pub const ERROR_UNSUPPORTED_VERSION: &str = "unsupported_version";
18pub const ERROR_WORKSPACE_MISMATCH: &str = "workspace_mismatch";
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RunStateName {
23    Running,
24    Finished,
25    Failed,
26    Canceled,
27    CancelRequested,
28    Interrupted,
29}
30
31impl RunStateName {
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Running => "running",
35            Self::Finished => "finished",
36            Self::Failed => "failed",
37            Self::Canceled => "canceled",
38            Self::CancelRequested => "cancel_requested",
39            Self::Interrupted => "interrupted",
40        }
41    }
42}
43
44impl fmt::Display for RunStateName {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        formatter.pad(self.as_str())
47    }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum EnvelopeKind {
53    Request,
54    Response,
55    Event,
56    Error,
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Envelope {
62    pub v: u32,
63    pub id: Option<String>,
64    pub kind: EnvelopeKind,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub method: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub params: Option<Value>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub result: Option<Value>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub error: Option<ProtocolError>,
73}
74
75impl Envelope {
76    pub fn response(id: Option<String>, method: Option<String>, result: Value) -> Self {
77        Self {
78            v: PROTOCOL_VERSION,
79            id,
80            kind: EnvelopeKind::Response,
81            method,
82            params: None,
83            result: Some(result),
84            error: None,
85        }
86    }
87
88    pub fn response_from<T: Serialize>(
89        id: Option<String>,
90        method: Option<String>,
91        result: T,
92    ) -> Self {
93        Self::response(
94            id,
95            method,
96            serde_json::to_value(result).expect("protocol result serializes"),
97        )
98    }
99
100    pub fn error(
101        id: Option<String>,
102        method: Option<String>,
103        code: impl Into<String>,
104        message: impl Into<String>,
105    ) -> Self {
106        Self {
107            v: PROTOCOL_VERSION,
108            id,
109            kind: EnvelopeKind::Error,
110            method,
111            params: None,
112            result: None,
113            error: Some(ProtocolError {
114                code: code.into(),
115                message: message.into(),
116            }),
117        }
118    }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct ProtocolError {
124    pub code: String,
125    pub message: String,
126}
127
128#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct HelloParams {
131    pub workspace_root: String,
132    pub workspace_id: String,
133}
134
135#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
136pub struct HelloResult {
137    pub daemon_version: String,
138    pub workspace_id: String,
139    pub ledger_path: String,
140    pub capabilities: Vec<String>,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct RunStartParams {
146    pub question: String,
147    #[serde(default)]
148    pub config_path: Option<String>,
149    #[serde(default)]
150    pub wait: Option<bool>,
151}
152
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154pub struct RunStartResult {
155    pub run_id: String,
156    pub session_id: String,
157    pub ledger_path: String,
158    pub status: RunStateName,
159    pub final_answer: Option<String>,
160}
161
162#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct MessageAppendParams {
165    pub message: String,
166    #[serde(default)]
167    pub session_id: Option<String>,
168    #[serde(default)]
169    pub config_path: Option<String>,
170    #[serde(default)]
171    pub wait: Option<bool>,
172}
173
174#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct EventsStreamParams {
177    pub run_id: String,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub from_offset: Option<u64>,
180    #[serde(default)]
181    pub limit: Option<usize>,
182}
183
184#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
185pub struct EventsStreamResult {
186    pub run_id: String,
187    pub from_offset: u64,
188    pub next_offset: u64,
189    pub status: RunStateName,
190    pub events: Vec<Value>,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct ApprovalDecideParams {
196    pub run_id: String,
197    pub tool_call_id: String,
198    pub decision: String,
199    #[serde(default)]
200    pub reason: Option<String>,
201}
202
203#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct RunCancelParams {
206    pub run_id: String,
207}
208
209#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
210pub struct CommandAcceptedResult {
211    pub run_id: String,
212    pub status: RunStateName,
213}
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum ShutdownIfIdleResultName {
218    Shutdown,
219    RefusedActive,
220}
221
222#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
223pub struct ShutdownIfIdleResult {
224    pub result: ShutdownIfIdleResultName,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
228pub struct SessionsListResult {
229    pub sessions: Vec<SessionSummary>,
230}
231
232#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233pub struct SessionSummary {
234    pub session_id: String,
235    pub run_id: String,
236    pub status: RunStateName,
237    pub latest_question: String,
238    pub ledger_path: String,
239}
240
241#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
242#[serde(deny_unknown_fields)]
243pub struct TranscriptReadParams {
244    #[serde(default)]
245    pub run_id: Option<String>,
246    #[serde(default)]
247    pub session_id: Option<String>,
248}
249
250#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
251pub struct TranscriptReadResult {
252    pub run_id: String,
253    pub status: RunStateName,
254    pub final_answer: Option<String>,
255    pub transcript: String,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub typed: Option<TypedTranscript>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub pending_approval: Option<PendingApprovalSnapshot>,
260}
261
262#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
263pub struct PendingApprovalSnapshot {
264    pub run_id: String,
265    pub tool_call_id: String,
266    pub tool_name: String,
267    pub effect: EffectClass,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub reason: Option<String>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub input_preview: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub approval_preview: Option<String>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub diff_preview: Option<String>,
276}
277
278#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
279pub struct TypedTranscript {
280    pub runs: Vec<TypedRun>,
281}
282
283#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
284pub struct TypedRun {
285    pub run_id: String,
286    pub session_index: u64,
287    pub status: RunStateName,
288    pub entries: Vec<TypedTranscriptEntry>,
289}
290
291#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum ApprovalDecisionName {
294    Granted,
295    Denied,
296}
297
298#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case", tag = "kind")]
300pub enum TypedTranscriptEntry {
301    User {
302        text: String,
303    },
304    Assistant {
305        text: String,
306    },
307    ToolCall {
308        call_id: String,
309        tool: String,
310        input: Value,
311    },
312    ToolResult {
313        call_id: String,
314        summary: String,
315    },
316    Approval {
317        call_id: String,
318        decision: ApprovalDecisionName,
319        actor_id: String,
320        #[serde(default, skip_serializing_if = "Option::is_none")]
321        reason: Option<String>,
322    },
323    PolicyDenied {
324        call_id: String,
325        reason: String,
326    },
327    ToolFailed {
328        call_id: String,
329        error: String,
330    },
331}
332
333pub fn decode_request(line: &str) -> Result<Envelope, Box<Envelope>> {
334    let envelope = serde_json::from_str::<Envelope>(line).map_err(|error| {
335        Box::new(Envelope::error(
336            None,
337            None,
338            ERROR_MALFORMED_REQUEST,
339            format!("request is not a valid protocol envelope: {error}"),
340        ))
341    })?;
342
343    if envelope.v != PROTOCOL_VERSION {
344        return Err(Box::new(Envelope::error(
345            envelope.id,
346            envelope.method,
347            ERROR_UNSUPPORTED_VERSION,
348            format!("unsupported protocol version: {}", envelope.v),
349        )));
350    }
351    if envelope.kind != EnvelopeKind::Request {
352        return Err(Box::new(Envelope::error(
353            envelope.id,
354            envelope.method,
355            ERROR_MALFORMED_REQUEST,
356            "envelope kind must be request",
357        )));
358    }
359    if envelope.method.is_none() {
360        return Err(Box::new(Envelope::error(
361            envelope.id,
362            None,
363            ERROR_MALFORMED_REQUEST,
364            "request method is required",
365        )));
366    }
367
368    Ok(envelope)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde_json::json;
375
376    #[test]
377    fn run_state_names_keep_wire_values() {
378        let cases = [
379            (RunStateName::Running, "running"),
380            (RunStateName::Finished, "finished"),
381            (RunStateName::Failed, "failed"),
382            (RunStateName::Canceled, "canceled"),
383            (RunStateName::CancelRequested, "cancel_requested"),
384            (RunStateName::Interrupted, "interrupted"),
385        ];
386
387        for (state, wire_value) in cases {
388            assert_eq!(state.as_str(), wire_value);
389            assert_eq!(serde_json::to_value(state).unwrap(), wire_value);
390            assert_eq!(
391                serde_json::from_value::<RunStateName>(wire_value.into()).unwrap(),
392                state
393            );
394        }
395    }
396
397    #[test]
398    fn decodes_request_envelope() {
399        let envelope = decode_request(
400            r#"{"v":1,"id":"req_1","kind":"request","method":"hello","params":{"workspace_root":"/tmp/work","workspace_id":"work-1234"}}"#,
401        )
402        .unwrap();
403
404        assert_eq!(envelope.id.as_deref(), Some("req_1"));
405        assert_eq!(envelope.method.as_deref(), Some("hello"));
406    }
407
408    #[test]
409    fn rejects_unsupported_version_with_typed_error() {
410        let error =
411            decode_request(r#"{"v":2,"id":"req_1","kind":"request","method":"hello","params":{}}"#)
412                .unwrap_err();
413
414        assert_eq!(error.kind, EnvelopeKind::Error);
415        assert_eq!(
416            error.error.unwrap().code,
417            ERROR_UNSUPPORTED_VERSION.to_string()
418        );
419    }
420
421    #[test]
422    fn response_serializes_without_request_params() {
423        let response = Envelope::response(
424            Some("req_1".into()),
425            Some("hello".into()),
426            serde_json::json!({"workspace_id":"work-1234"}),
427        );
428
429        let raw = serde_json::to_string(&response).unwrap();
430
431        assert!(raw.contains(r#""kind":"response""#));
432        assert!(!raw.contains("params"));
433    }
434
435    #[test]
436    fn typed_transcript_keeps_exact_wire_shape_and_both_compat_directions() {
437        let current = TranscriptReadResult {
438            run_id: "run_1".into(),
439            status: RunStateName::Finished,
440            final_answer: Some("done".into()),
441            transcript: "legacy replay".into(),
442            typed: Some(TypedTranscript {
443                runs: vec![TypedRun {
444                    run_id: "run_1".into(),
445                    session_index: 0,
446                    status: RunStateName::Finished,
447                    entries: vec![
448                        TypedTranscriptEntry::User {
449                            text: "do work".into(),
450                        },
451                        TypedTranscriptEntry::Assistant {
452                            text: "working".into(),
453                        },
454                        TypedTranscriptEntry::ToolCall {
455                            call_id: "call_1".into(),
456                            tool: "file.write".into(),
457                            input: json!({"path": "out.txt", "content": "done"}),
458                        },
459                        TypedTranscriptEntry::ToolResult {
460                            call_id: "call_1".into(),
461                            summary: "wrote out.txt".into(),
462                        },
463                        TypedTranscriptEntry::Approval {
464                            call_id: "call_1".into(),
465                            decision: ApprovalDecisionName::Granted,
466                            actor_id: "human_1".into(),
467                            reason: None,
468                        },
469                        TypedTranscriptEntry::Approval {
470                            call_id: "call_2".into(),
471                            decision: ApprovalDecisionName::Denied,
472                            actor_id: "human_2".into(),
473                            reason: Some("not now".into()),
474                        },
475                        TypedTranscriptEntry::PolicyDenied {
476                            call_id: "call_3".into(),
477                            reason: "secret access denied".into(),
478                        },
479                        TypedTranscriptEntry::ToolFailed {
480                            call_id: "call_4".into(),
481                            error: "tool crashed".into(),
482                        },
483                    ],
484                }],
485            }),
486            pending_approval: None,
487        };
488
489        let wire = serde_json::to_value(&current).unwrap();
490        assert_eq!(
491            wire,
492            json!({
493                "run_id": "run_1",
494                "status": "finished",
495                "final_answer": "done",
496                "transcript": "legacy replay",
497                "typed": {
498                    "runs": [{
499                        "run_id": "run_1",
500                        "session_index": 0,
501                        "status": "finished",
502                        "entries": [
503                            {"kind": "user", "text": "do work"},
504                            {"kind": "assistant", "text": "working"},
505                            {
506                                "kind": "tool_call",
507                                "call_id": "call_1",
508                                "tool": "file.write",
509                                "input": {"path": "out.txt", "content": "done"}
510                            },
511                            {
512                                "kind": "tool_result",
513                                "call_id": "call_1",
514                                "summary": "wrote out.txt"
515                            },
516                            {
517                                "kind": "approval",
518                                "call_id": "call_1",
519                                "decision": "granted",
520                                "actor_id": "human_1"
521                            },
522                            {
523                                "kind": "approval",
524                                "call_id": "call_2",
525                                "decision": "denied",
526                                "actor_id": "human_2",
527                                "reason": "not now"
528                            },
529                            {
530                                "kind": "policy_denied",
531                                "call_id": "call_3",
532                                "reason": "secret access denied"
533                            },
534                            {
535                                "kind": "tool_failed",
536                                "call_id": "call_4",
537                                "error": "tool crashed"
538                            }
539                        ]
540                    }]
541                }
542            })
543        );
544
545        #[derive(Deserialize)]
546        struct LegacyTranscriptReadResult {
547            run_id: String,
548            status: RunStateName,
549            final_answer: Option<String>,
550            transcript: String,
551        }
552
553        let legacy_client: LegacyTranscriptReadResult =
554            serde_json::from_value(wire).expect("legacy clients ignore typed");
555        assert_eq!(legacy_client.run_id, "run_1");
556        assert_eq!(legacy_client.status, RunStateName::Finished);
557        assert_eq!(legacy_client.final_answer.as_deref(), Some("done"));
558        assert_eq!(legacy_client.transcript, "legacy replay");
559
560        let current_client: TranscriptReadResult = serde_json::from_value(json!({
561            "run_id": "run_1",
562            "status": "finished",
563            "final_answer": "done",
564            "transcript": "legacy replay"
565        }))
566        .expect("current clients decode typed-less daemon responses");
567        assert_eq!(current_client.typed, None);
568        assert_eq!(current_client.pending_approval, None);
569    }
570
571    #[test]
572    fn pending_approval_snapshot_keeps_exact_additive_wire_shape() {
573        let current = TranscriptReadResult {
574            run_id: "run_1".into(),
575            status: RunStateName::Running,
576            final_answer: None,
577            transcript: "partial replay".into(),
578            typed: None,
579            pending_approval: Some(PendingApprovalSnapshot {
580                run_id: "run_1".into(),
581                tool_call_id: "call_1".into(),
582                tool_name: "file.write".into(),
583                effect: EffectClass::WorkspaceWrite,
584                reason: Some("file.write requires approval".into()),
585                input_preview: Some(r#"{"path":"out.txt"}"#.into()),
586                approval_preview: Some("write out.txt".into()),
587                diff_preview: Some("--- a/out.txt\n+++ b/out.txt\n".into()),
588            }),
589        };
590
591        let wire = serde_json::to_value(&current).unwrap();
592        assert_eq!(
593            wire,
594            json!({
595                "run_id": "run_1",
596                "status": "running",
597                "final_answer": null,
598                "transcript": "partial replay",
599                "pending_approval": {
600                    "run_id": "run_1",
601                    "tool_call_id": "call_1",
602                    "tool_name": "file.write",
603                    "effect": "workspace_write",
604                    "reason": "file.write requires approval",
605                    "input_preview": "{\"path\":\"out.txt\"}",
606                    "approval_preview": "write out.txt",
607                    "diff_preview": "--- a/out.txt\n+++ b/out.txt\n"
608                }
609            })
610        );
611
612        #[derive(Deserialize)]
613        struct LegacyTranscriptReadResult {
614            run_id: String,
615            status: RunStateName,
616            transcript: String,
617        }
618
619        let decoded: TranscriptReadResult = serde_json::from_value(wire.clone()).unwrap();
620        assert_eq!(decoded, current);
621
622        let legacy: LegacyTranscriptReadResult = serde_json::from_value(wire).unwrap();
623        assert_eq!(legacy.run_id, "run_1");
624        assert_eq!(legacy.status, RunStateName::Running);
625        assert_eq!(legacy.transcript, "partial replay");
626
627        let minimal = serde_json::to_value(PendingApprovalSnapshot {
628            run_id: "run_2".into(),
629            tool_call_id: "call_2".into(),
630            tool_name: "shell.exec".into(),
631            effect: EffectClass::ExternalSideEffect,
632            reason: None,
633            input_preview: None,
634            approval_preview: None,
635            diff_preview: None,
636        })
637        .unwrap();
638        assert_eq!(
639            minimal,
640            json!({
641                "run_id": "run_2",
642                "tool_call_id": "call_2",
643                "tool_name": "shell.exec",
644                "effect": "external_side_effect"
645            })
646        );
647    }
648}