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}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use serde_json::json;
149
150 // ────────────────────────────────────────────────────────────────
151 // Wire format — verify camelCase on the JSON side. The TS mirror
152 // in `frontend/types/gotypes.d.ts` depends on this; any drift
153 // becomes silent type errors at the IPC seam.
154 // ────────────────────────────────────────────────────────────────
155
156 #[test]
157 fn agent_ref_serializes_camelcase() {
158 let r = AgentRef {
159 identity_id: "id1".into(),
160 memory_id: "mem1".into(),
161 instance_name: "alice".into(),
162 working_directory: "/tmp/x".into(),
163 };
164 let v = serde_json::to_value(&r).unwrap();
165 assert_eq!(
166 v,
167 json!({
168 "identityId": "id1",
169 "memoryId": "mem1",
170 "instanceName": "alice",
171 "workingDirectory": "/tmp/x"
172 })
173 );
174 }
175
176 #[test]
177 fn agent_ref_defaults_round_trip() {
178 // Empty-string sentinels match the wstore convention so the
179 // frontend can omit fields it doesn't set.
180 let r: AgentRef = serde_json::from_value(json!({})).unwrap();
181 assert_eq!(r, AgentRef::default());
182 }
183
184 #[test]
185 fn agent_event_assistant_text_shape() {
186 let ev = AgentEvent::AssistantText {
187 delta: "hi".into(),
188 };
189 let v = serde_json::to_value(&ev).unwrap();
190 assert_eq!(v, json!({ "type": "assistant_text", "delta": "hi" }));
191 }
192
193 #[test]
194 fn agent_event_tool_use_camelcase_id() {
195 let ev = AgentEvent::ToolUse {
196 tool_use_id: "tu_42".into(),
197 tool: "bash".into(),
198 input: json!({ "cmd": "ls" }),
199 };
200 let v = serde_json::to_value(&ev).unwrap();
201 assert_eq!(
202 v,
203 json!({
204 "type": "tool_use",
205 "toolUseId": "tu_42",
206 "tool": "bash",
207 "input": { "cmd": "ls" }
208 })
209 );
210 }
211
212 #[test]
213 fn agent_event_cost_shape() {
214 let ev = AgentEvent::Cost {
215 cost_usd: 0.0123,
216 tokens: TokenCounts {
217 input: 100,
218 output: 50,
219 cache_creation: 0,
220 cache_read: 200,
221 },
222 };
223 let v = serde_json::to_value(&ev).unwrap();
224 assert_eq!(
225 v,
226 json!({
227 "type": "cost",
228 "costUsd": 0.0123,
229 "tokens": {
230 "input": 100,
231 "output": 50,
232 "cacheCreation": 0,
233 "cacheRead": 200
234 }
235 })
236 );
237 }
238
239 #[test]
240 fn agent_event_roundtrips() {
241 let original = AgentEvent::Done {
242 response: "ok".into(),
243 transcript: vec![AgentTurn {
244 role: "assistant".into(),
245 content: json!("hi"),
246 timestamp_ms: 1_700_000_000_000,
247 }],
248 };
249 let s = serde_json::to_string(&original).unwrap();
250 let parsed: AgentEvent = serde_json::from_str(&s).unwrap();
251 // Match the shape, not the exact equality (transcript Vec).
252 match parsed {
253 AgentEvent::Done {
254 response,
255 transcript,
256 } => {
257 assert_eq!(response, "ok");
258 assert_eq!(transcript.len(), 1);
259 assert_eq!(transcript[0].role, "assistant");
260 assert_eq!(transcript[0].timestamp_ms, 1_700_000_000_000);
261 }
262 _ => panic!("expected Done variant"),
263 }
264 }
265
266 #[test]
267 fn agent_run_result_shape() {
268 let r = AgentRunResult {
269 response: "hi".into(),
270 tokens: TokenCounts::default(),
271 cost_usd: 0.0,
272 transcript: vec![],
273 };
274 let v = serde_json::to_value(&r).unwrap();
275 // costUsd at the result level, tokens nested with camelCase.
276 assert_eq!(
277 v,
278 json!({
279 "response": "hi",
280 "tokens": {
281 "input": 0,
282 "output": 0,
283 "cacheCreation": 0,
284 "cacheRead": 0
285 },
286 "costUsd": 0.0,
287 "transcript": []
288 })
289 );
290 }
291}