agentmux_srv\agents/
types.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified agent types shared between the agent pane (interactive)
5//! and the drone Agent block (headless). See
6//! `docs/specs/SPEC_UNIFIED_AGENT_TYPES_2026_05_13.md` §3 for the
7//! full design rationale.
8//!
9//! Wire format is camelCase via `serde(rename_all)` so the TS
10//! mirror in `frontend/types/gotypes.d.ts` requires no field
11//! translation.
12
13use serde::{Deserialize, Serialize};
14
15/// Identifies "which agent." Empty-string sentinels match the
16/// existing wstore `AgentInstance` conventions. All fields optional
17/// so callers can construct anything from "blank claude with ambient
18/// creds" (all empty) up to a fully-pinned named-agent continuation.
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "camelCase")]
21pub struct AgentRef {
22    /// FK to `db_identity_bundles.id`. Empty = blank singleton (ambient
23    /// creds, no env-var injection at spawn).
24    #[serde(default)]
25    pub identity_id: String,
26    /// FK to `db_memory_bundles.id`. Empty = blank singleton (vanilla CLI,
27    /// no system instructions injected).
28    #[serde(default)]
29    pub memory_id: String,
30    /// User-chosen instance name. Empty for one-shot launches.
31    /// Non-empty triggers the named-agent continuation path: the
32    /// runner looks up an existing `AgentInstance` by name and reuses
33    /// its `working_directory` + `session_id` if present.
34    #[serde(default)]
35    pub instance_name: String,
36    /// Optional explicit working directory override. Empty falls
37    /// back to `allocate_agent_workdir()` at run time.
38    #[serde(default)]
39    pub working_directory: String,
40}
41
42/// What the agent should do, plus the variables for `{{ }}` resolution
43/// inside `prompt`. The agent pane uses `prompt=<user-typed-text>`
44/// with an empty `context`. The drone Agent block uses
45/// `prompt=<block.data.task>` resolved against `scope.outputs +
46/// scope.vars`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct AgentTask {
50    pub prompt: String,
51    /// Variable scope for template resolution inside `prompt`. Keys
52    /// are typically block ids or `var`/`env` namespaces; values are
53    /// JSON. The runner is responsible for resolution before spawn.
54    #[serde(default)]
55    pub context: serde_json::Map<String, serde_json::Value>,
56    /// Hard cap on turns. `None` = use the provider default.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub max_turns: Option<u32>,
59}
60
61/// Discriminated streaming event. Same union for both the agent
62/// pane (renders into the UI) and the drone Agent block
63/// (accumulates until `Done`, returns `AgentRunResult`).
64///
65/// Provider-specific extension goes through a `Custom` variant
66/// reserved here but intentionally not shipped Phase 1.5 — leave
67/// the enum open for it. See spec §8 risks.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
70pub enum AgentEvent {
71    /// Streaming text chunk from the assistant. Agent pane appends
72    /// to the visible transcript; drone Agent block buffers
73    /// until `Done`.
74    AssistantText {
75        delta: String,
76    },
77    /// Tool invocation about to run. `input` is the provider's raw
78    /// tool input JSON; renderers may dispatch on `tool` name.
79    ToolUse {
80        tool_use_id: String,
81        tool: String,
82        input: serde_json::Value,
83    },
84    /// Tool execution result.
85    ToolResult {
86        tool_use_id: String,
87        output: serde_json::Value,
88        #[serde(default)]
89        is_error: bool,
90    },
91    /// Final cost + token accounting. Emitted once per run, before
92    /// `Done`.
93    Cost {
94        cost_usd: f64,
95        tokens: TokenCounts,
96    },
97    /// Run completed successfully. `response` is the final assistant
98    /// message text (the drone Agent block's primary output).
99    /// `transcript` is the full ordered turn list for audit / replay.
100    Done {
101        response: String,
102        transcript: Vec<AgentTurn>,
103    },
104    /// Run failed. `message` is the user-facing error.
105    Error {
106        message: String,
107    },
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
111#[serde(rename_all = "camelCase")]
112pub struct TokenCounts {
113    #[serde(default)]
114    pub input: u64,
115    #[serde(default)]
116    pub output: u64,
117    #[serde(default)]
118    pub cache_creation: u64,
119    #[serde(default)]
120    pub cache_read: u64,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct AgentTurn {
126    /// `"user"` | `"assistant"` | `"tool_result"`.
127    pub role: String,
128    pub content: serde_json::Value,
129    pub timestamp_ms: i64,
130}
131
132/// Final structured result of a complete agent run — the value the
133/// drone Agent block returns to downstream blocks. The agent
134/// pane discards this (it has already rendered the stream) but
135/// constructs the same struct for the audit log.
136#[derive(Debug, Clone, Default, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct AgentRunResult {
139    pub response: String,
140    pub tokens: TokenCounts,
141    pub cost_usd: f64,
142    pub transcript: Vec<AgentTurn>,
143    /// Terminal stream-json `result` frame when it reported an error
144    /// (`is_error` / `error_*` subtype). Internal only — `#[serde(skip)]`
145    /// keeps it off the IPC wire. Lets the runner fail a run that claude
146    /// reported as an error on stdout while still exiting 0. (codex P1 #1353.)
147    #[serde(skip)]
148    pub error_frame: Option<serde_json::Value>,
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use serde_json::json;
155
156    // ────────────────────────────────────────────────────────────────
157    // Wire format — verify camelCase on the JSON side. The TS mirror
158    // in `frontend/types/gotypes.d.ts` depends on this; any drift
159    // becomes silent type errors at the IPC seam.
160    // ────────────────────────────────────────────────────────────────
161
162    #[test]
163    fn agent_ref_serializes_camelcase() {
164        let r = AgentRef {
165            identity_id: "id1".into(),
166            memory_id: "mem1".into(),
167            instance_name: "alice".into(),
168            working_directory: "/tmp/x".into(),
169        };
170        let v = serde_json::to_value(&r).unwrap();
171        assert_eq!(
172            v,
173            json!({
174                "identityId": "id1",
175                "memoryId": "mem1",
176                "instanceName": "alice",
177                "workingDirectory": "/tmp/x"
178            })
179        );
180    }
181
182    #[test]
183    fn agent_ref_defaults_round_trip() {
184        // Empty-string sentinels match the wstore convention so the
185        // frontend can omit fields it doesn't set.
186        let r: AgentRef = serde_json::from_value(json!({})).unwrap();
187        assert_eq!(r, AgentRef::default());
188    }
189
190    #[test]
191    fn agent_event_assistant_text_shape() {
192        let ev = AgentEvent::AssistantText {
193            delta: "hi".into(),
194        };
195        let v = serde_json::to_value(&ev).unwrap();
196        assert_eq!(v, json!({ "type": "assistant_text", "delta": "hi" }));
197    }
198
199    #[test]
200    fn agent_event_tool_use_camelcase_id() {
201        let ev = AgentEvent::ToolUse {
202            tool_use_id: "tu_42".into(),
203            tool: "bash".into(),
204            input: json!({ "cmd": "ls" }),
205        };
206        let v = serde_json::to_value(&ev).unwrap();
207        assert_eq!(
208            v,
209            json!({
210                "type": "tool_use",
211                "toolUseId": "tu_42",
212                "tool": "bash",
213                "input": { "cmd": "ls" }
214            })
215        );
216    }
217
218    #[test]
219    fn agent_event_cost_shape() {
220        let ev = AgentEvent::Cost {
221            cost_usd: 0.0123,
222            tokens: TokenCounts {
223                input: 100,
224                output: 50,
225                cache_creation: 0,
226                cache_read: 200,
227            },
228        };
229        let v = serde_json::to_value(&ev).unwrap();
230        assert_eq!(
231            v,
232            json!({
233                "type": "cost",
234                "costUsd": 0.0123,
235                "tokens": {
236                    "input": 100,
237                    "output": 50,
238                    "cacheCreation": 0,
239                    "cacheRead": 200
240                }
241            })
242        );
243    }
244
245    #[test]
246    fn agent_event_roundtrips() {
247        let original = AgentEvent::Done {
248            response: "ok".into(),
249            transcript: vec![AgentTurn {
250                role: "assistant".into(),
251                content: json!("hi"),
252                timestamp_ms: 1_700_000_000_000,
253            }],
254        };
255        let s = serde_json::to_string(&original).unwrap();
256        let parsed: AgentEvent = serde_json::from_str(&s).unwrap();
257        // Match the shape, not the exact equality (transcript Vec).
258        match parsed {
259            AgentEvent::Done {
260                response,
261                transcript,
262            } => {
263                assert_eq!(response, "ok");
264                assert_eq!(transcript.len(), 1);
265                assert_eq!(transcript[0].role, "assistant");
266                assert_eq!(transcript[0].timestamp_ms, 1_700_000_000_000);
267            }
268            _ => panic!("expected Done variant"),
269        }
270    }
271
272    #[test]
273    fn agent_run_result_shape() {
274        let r = AgentRunResult {
275            response: "hi".into(),
276            tokens: TokenCounts::default(),
277            cost_usd: 0.0,
278            transcript: vec![],
279            error_frame: None,
280        };
281        let v = serde_json::to_value(&r).unwrap();
282        // costUsd at the result level, tokens nested with camelCase.
283        assert_eq!(
284            v,
285            json!({
286                "response": "hi",
287                "tokens": {
288                    "input": 0,
289                    "output": 0,
290                    "cacheCreation": 0,
291                    "cacheRead": 0
292                },
293                "costUsd": 0.0,
294                "transcript": []
295            })
296        );
297    }
298}