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}