Skip to main content

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). The design rationale lived in a
6//! Phase-1.5 lineage spec retired in #1928.
7//!
8//! Wire format is camelCase via `serde(rename_all)` so the TS
9//! mirror in `frontend/types/srv-types.d.ts` requires no field
10//! translation.
11
12use serde::{Deserialize, Serialize};
13
14/// Identifies "which agent." Empty-string sentinels match the
15/// existing mstore `AgentInstance` conventions. All fields optional
16/// so callers can construct anything from "blank claude with ambient
17/// creds" (all empty) up to a fully-pinned named-agent continuation.
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase")]
20pub struct AgentRef {
21    /// Legacy Identity-bundle id — `db_identity_bundles` was dropped in
22    /// Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md, so this
23    /// field is now vestigial (never read at spawn; credential resolution
24    /// is `db_agent_identity_links`-only). Empty = none.
25    #[serde(default)]
26    pub identity_id: String,
27    /// FK to `db_bundles.id`. Empty = blank singleton (vanilla CLI,
28    /// no system instructions injected).
29    #[serde(default)]
30    pub memory_id: String,
31    /// User-chosen instance name. Empty for one-shot launches.
32    /// Non-empty triggers the named-agent continuation path: the
33    /// runner looks up an existing `AgentInstance` by name and reuses
34    /// its `working_directory` + `session_id` if present.
35    #[serde(default)]
36    pub instance_name: String,
37    /// Optional explicit working directory override. Empty falls
38    /// back to `allocate_agent_workdir()` at run time.
39    #[serde(default)]
40    pub working_directory: String,
41}
42
43/// What the agent should do, plus the variables for `{{ }}` resolution
44/// inside `prompt`. The agent pane uses `prompt=<user-typed-text>`
45/// with an empty `context`. The drone Agent block uses
46/// `prompt=<block.data.task>` resolved against `scope.outputs +
47/// scope.vars`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct AgentTask {
51    pub prompt: String,
52    /// Variable scope for template resolution inside `prompt`. Keys
53    /// are typically block ids or `var`/`env` namespaces; values are
54    /// JSON. The runner is responsible for resolution before spawn.
55    #[serde(default)]
56    pub context: serde_json::Map<String, serde_json::Value>,
57    /// Hard cap on turns. `None` = use the provider default.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub max_turns: Option<u32>,
60}
61
62/// Discriminated streaming event. Same union for both the agent
63/// pane (renders into the UI) and the drone Agent block
64/// (accumulates until `Done`, returns `AgentRunResult`).
65///
66/// Provider-specific extension goes through a `Custom` variant
67/// reserved here but intentionally not shipped Phase 1.5 — leave
68/// the enum open for it. See spec §8 risks.
69#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
70#[ts(export, export_to = "../../../frontend/types/rpc/")]
71#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
72pub enum AgentEvent {
73    /// Streaming text chunk from the assistant. Agent pane appends
74    /// to the visible transcript; drone Agent block buffers
75    /// until `Done`.
76    AssistantText {
77        delta: String,
78    },
79    /// Tool invocation about to run. `input` is the provider's raw
80    /// tool input JSON; renderers may dispatch on `tool` name.
81    ToolUse {
82        tool_use_id: String,
83        tool: String,
84        #[ts(type = "unknown")]
85        input: serde_json::Value,
86    },
87    /// Tool execution result.
88    ToolResult {
89        tool_use_id: String,
90        #[ts(type = "unknown")]
91        output: serde_json::Value,
92        #[serde(default)]
93        is_error: bool,
94    },
95    /// Final cost + token accounting. Emitted once per run, before
96    /// `Done`.
97    Cost {
98        cost_usd: f64,
99        tokens: TokenCounts,
100    },
101    /// Run completed successfully. `response` is the final assistant
102    /// message text (the drone Agent block's primary output).
103    /// `transcript` is the full ordered turn list for audit / replay.
104    Done {
105        response: String,
106        transcript: Vec<AgentTurn>,
107    },
108    /// Run failed. `message` is the user-facing error.
109    Error {
110        message: String,
111    },
112    /// Context compaction completed. Sourced from Claude Code's
113    /// `type: "system", subtype: "compact_boundary"` stream-json
114    /// frame — exact data, not a heuristic. See
115    /// `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
116    /// §2/§4.1 for the full design rationale and the source frame
117    /// shape this is translated from.
118    CompactionBoundary {
119        trigger: CompactionTrigger,
120        #[ts(type = "number")]
121        pre_tokens: u64,
122        #[ts(type = "number")]
123        post_tokens: u64,
124        #[ts(type = "number")]
125        cumulative_dropped_tokens: u64,
126        #[ts(type = "number")]
127        duration_ms: u64,
128    },
129}
130
131/// How compaction was triggered: the same type as `compaction_started`'s trigger, which
132/// `agentmux-bashwrap` publishes, so it is declared once, in `agentmux-common`.
133pub use agentmux_common::client_events::CompactionTrigger;
134
135// Generated for the session domain (ActivitySummaryResult.tokens). The
136// hand-written global `TokenCounts` in srv-types.d.ts stays for now: four other
137// global types reference it and a global cannot import, so it can only be
138// deleted once those domains migrate too. Both shapes are identical --
139// `rename_all = "camelCase"` gives `cacheCreation`/`cacheRead`, which is what
140// the hand-written copy already says.
141#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, ts_rs::TS)]
142#[ts(export, export_to = "../../../frontend/types/rpc/")]
143#[serde(rename_all = "camelCase")]
144pub struct TokenCounts {
145    #[serde(default)]
146    #[ts(type = "number")]
147    pub input: u64,
148    #[serde(default)]
149    #[ts(type = "number")]
150    pub output: u64,
151    #[serde(default)]
152    #[ts(type = "number")]
153    pub cache_creation: u64,
154    #[serde(default)]
155    #[ts(type = "number")]
156    pub cache_read: u64,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
160#[ts(export, export_to = "../../../frontend/types/rpc/")]
161#[serde(rename_all = "camelCase")]
162pub struct AgentTurn {
163    #[ts(type = r#""user" | "assistant" | "tool_result""#)]
164    pub role: String,
165    #[ts(type = "unknown")]
166    pub content: serde_json::Value,
167    #[ts(type = "number")]
168    pub timestamp_ms: i64,
169}
170
171/// Final structured result of a complete agent run — the value the
172/// drone Agent block returns to downstream blocks. The agent
173/// pane discards this (it has already rendered the stream) but
174/// constructs the same struct for the audit log.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct AgentRunResult {
178    pub response: String,
179    pub tokens: TokenCounts,
180    pub cost_usd: f64,
181    pub transcript: Vec<AgentTurn>,
182    /// Terminal stream-json `result` frame when it reported an error
183    /// (`is_error` / `error_*` subtype). Internal only — `#[serde(skip)]`
184    /// keeps it off the IPC wire. Lets the runner fail a run that claude
185    /// reported as an error on stdout while still exiting 0. (codex P1 #1353.)
186    #[serde(skip)]
187    pub error_frame: Option<serde_json::Value>,
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use serde_json::json;
194
195    // ────────────────────────────────────────────────────────────────
196    // Wire format — verify camelCase on the JSON side. The TS mirror
197    // in `frontend/types/srv-types.d.ts` depends on this; any drift
198    // becomes silent type errors at the IPC seam.
199    // ────────────────────────────────────────────────────────────────
200
201    #[test]
202    fn agent_ref_serializes_camelcase() {
203        let r = AgentRef {
204            identity_id: "id1".into(),
205            memory_id: "mem1".into(),
206            instance_name: "alice".into(),
207            working_directory: "/tmp/x".into(),
208        };
209        let v = serde_json::to_value(&r).unwrap();
210        assert_eq!(
211            v,
212            json!({
213                "identityId": "id1",
214                "memoryId": "mem1",
215                "instanceName": "alice",
216                "workingDirectory": "/tmp/x"
217            })
218        );
219    }
220
221    #[test]
222    fn agent_ref_defaults_round_trip() {
223        // Empty-string sentinels match the mstore convention so the
224        // frontend can omit fields it doesn't set.
225        let r: AgentRef = serde_json::from_value(json!({})).unwrap();
226        assert_eq!(r, AgentRef::default());
227    }
228
229    #[test]
230    fn agent_event_assistant_text_shape() {
231        let ev = AgentEvent::AssistantText {
232            delta: "hi".into(),
233        };
234        let v = serde_json::to_value(&ev).unwrap();
235        assert_eq!(v, json!({ "type": "assistant_text", "delta": "hi" }));
236    }
237
238    #[test]
239    fn agent_event_tool_use_camelcase_id() {
240        let ev = AgentEvent::ToolUse {
241            tool_use_id: "tu_42".into(),
242            tool: "bash".into(),
243            input: json!({ "cmd": "ls" }),
244        };
245        let v = serde_json::to_value(&ev).unwrap();
246        assert_eq!(
247            v,
248            json!({
249                "type": "tool_use",
250                "toolUseId": "tu_42",
251                "tool": "bash",
252                "input": { "cmd": "ls" }
253            })
254        );
255    }
256
257    #[test]
258    fn agent_event_cost_shape() {
259        let ev = AgentEvent::Cost {
260            cost_usd: 0.0123,
261            tokens: TokenCounts {
262                input: 100,
263                output: 50,
264                cache_creation: 0,
265                cache_read: 200,
266            },
267        };
268        let v = serde_json::to_value(&ev).unwrap();
269        assert_eq!(
270            v,
271            json!({
272                "type": "cost",
273                "costUsd": 0.0123,
274                "tokens": {
275                    "input": 100,
276                    "output": 50,
277                    "cacheCreation": 0,
278                    "cacheRead": 200
279                }
280            })
281        );
282    }
283
284    #[test]
285    fn agent_event_roundtrips() {
286        let original = AgentEvent::Done {
287            response: "ok".into(),
288            transcript: vec![AgentTurn {
289                role: "assistant".into(),
290                content: json!("hi"),
291                timestamp_ms: 1_700_000_000_000,
292            }],
293        };
294        let s = serde_json::to_string(&original).unwrap();
295        let parsed: AgentEvent = serde_json::from_str(&s).unwrap();
296        // Match the shape, not the exact equality (transcript Vec).
297        match parsed {
298            AgentEvent::Done {
299                response,
300                transcript,
301            } => {
302                assert_eq!(response, "ok");
303                assert_eq!(transcript.len(), 1);
304                assert_eq!(transcript[0].role, "assistant");
305                assert_eq!(transcript[0].timestamp_ms, 1_700_000_000_000);
306            }
307            _ => panic!("expected Done variant"),
308        }
309    }
310
311    #[test]
312    fn agent_event_compaction_boundary_shape() {
313        let ev = AgentEvent::CompactionBoundary {
314            trigger: CompactionTrigger::Manual,
315            pre_tokens: 783_887,
316            post_tokens: 11_775,
317            cumulative_dropped_tokens: 772_112,
318            duration_ms: 231_606,
319        };
320        let v = serde_json::to_value(&ev).unwrap();
321        assert_eq!(
322            v,
323            json!({
324                "type": "compaction_boundary",
325                "trigger": "manual",
326                "preTokens": 783_887,
327                "postTokens": 11_775,
328                "cumulativeDroppedTokens": 772_112,
329                "durationMs": 231_606
330            })
331        );
332    }
333
334    #[test]
335    fn compaction_trigger_serializes_snake_case() {
336        assert_eq!(
337            serde_json::to_value(CompactionTrigger::Auto).unwrap(),
338            json!("auto")
339        );
340        assert_eq!(
341            serde_json::to_value(CompactionTrigger::Manual).unwrap(),
342            json!("manual")
343        );
344    }
345
346    #[test]
347    fn agent_run_result_shape() {
348        let r = AgentRunResult {
349            response: "hi".into(),
350            tokens: TokenCounts::default(),
351            cost_usd: 0.0,
352            transcript: vec![],
353            error_frame: None,
354        };
355        let v = serde_json::to_value(&r).unwrap();
356        // costUsd at the result level, tokens nested with camelCase.
357        assert_eq!(
358            v,
359            json!({
360                "response": "hi",
361                "tokens": {
362                    "input": 0,
363                    "output": 0,
364                    "cacheCreation": 0,
365                    "cacheRead": 0
366                },
367                "costUsd": 0.0,
368                "transcript": []
369            })
370        );
371    }
372}