1use serde::{Deserialize, Serialize};
2
3use crate::events::{ThreadId, TurnId};
4use crate::inference::TokenUsage;
5use crate::subagents::{SubagentExitReason, SubagentLane};
6
7pub type SubagentTraceId = String;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "camelCase")]
11pub struct ParentTurnRef {
12 pub thread_id: ThreadId,
13 pub turn_id: TurnId,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "snake_case")]
18pub enum SubagentTraceStatus {
19 Queued,
20 Running,
21 WaitingForApproval,
22 Completed,
23 Failed,
24 Cancelled,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase")]
29pub struct SubagentDestination {
30 pub kind: SubagentDestinationKind,
31 pub label: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub path: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub provider_id: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub destination_id: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum SubagentDestinationKind {
43 InProcess,
44 LocalWorktree,
45 RemoteRunner,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "camelCase")]
50pub struct SubagentTraceSummary {
51 pub trace_id: SubagentTraceId,
52 pub parent: ParentTurnRef,
53 pub child_thread_id: ThreadId,
54 pub child_turn_id: TurnId,
55 pub title: String,
56 pub role: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub model: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub lane: Option<SubagentLane>,
61 pub status: SubagentTraceStatus,
62 pub elapsed_ms: u64,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub usage: Option<TokenUsage>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub destination: Option<SubagentDestination>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub latest_activity: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub error_summary: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub exit_reason: Option<SubagentExitReason>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76#[serde(rename_all = "camelCase")]
77pub struct SubagentTraceDelta {
78 pub trace_id: SubagentTraceId,
79 pub parent: ParentTurnRef,
80 pub item: SubagentTraceItem,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84#[serde(tag = "type", rename_all = "camelCase")]
85pub enum SubagentTraceItem {
86 Message {
87 role: String,
88 content: PagedTraceText,
89 },
90 Reasoning {
91 content: PagedTraceText,
92 },
93 ToolCall {
94 tool_id: String,
95 tool_name: String,
96 #[serde(default)]
97 input: serde_json::Value,
98 },
99 ToolResult {
100 tool_id: String,
101 is_error: bool,
102 output: PagedTraceText,
103 },
104 Status {
105 status: SubagentTraceStatus,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 detail: Option<String>,
108 },
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "camelCase")]
113pub struct PagedTraceText {
114 pub text: String,
115 #[serde(default)]
116 pub truncated: bool,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub next_offset: Option<usize>,
119}
120
121impl PagedTraceText {
122 pub fn capped(text: impl Into<String>, max_chars: usize) -> Self {
123 let text = text.into();
124 let char_count = text.chars().count();
125 if char_count <= max_chars {
126 return Self {
127 text,
128 truncated: false,
129 next_offset: None,
130 };
131 }
132 Self {
133 text: text.chars().take(max_chars).collect(),
134 truncated: true,
135 next_offset: Some(max_chars),
136 }
137 }
138}
139
140#[async_trait::async_trait]
141pub trait SubagentTraceSink: Send + Sync + 'static {
142 async fn trace_created(&self, summary: SubagentTraceSummary);
143
144 async fn trace_delta(&self, delta: SubagentTraceDelta);
145
146 async fn trace_status_changed(
147 &self,
148 trace_id: SubagentTraceId,
149 parent: ParentTurnRef,
150 status: SubagentTraceStatus,
151 detail: Option<String>,
152 );
153
154 async fn trace_completed(&self, summary: SubagentTraceSummary);
155
156 async fn trace_failed(&self, summary: SubagentTraceSummary, error: String);
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn subagent_trace_summary_round_trips_camel_case_fields() {
165 let summary = SubagentTraceSummary {
166 trace_id: "trace-1".to_string(),
167 parent: ParentTurnRef {
168 thread_id: "parent-thread".to_string(),
169 turn_id: "parent-turn".to_string(),
170 },
171 child_thread_id: "child-thread".to_string(),
172 child_turn_id: "child-turn".to_string(),
173 title: "Inspect files".to_string(),
174 role: "explorer".to_string(),
175 model: Some("gpt-test".to_string()),
176 lane: Some(SubagentLane::Scout),
177 status: SubagentTraceStatus::Running,
178 elapsed_ms: 1200,
179 usage: None,
180 destination: Some(SubagentDestination {
181 kind: SubagentDestinationKind::InProcess,
182 label: "workspace".to_string(),
183 path: None,
184 provider_id: None,
185 destination_id: None,
186 }),
187 latest_activity: Some("reading README".to_string()),
188 error_summary: None,
189 exit_reason: None,
190 };
191
192 let value = serde_json::to_value(&summary).unwrap();
193 assert_eq!(value["traceId"], "trace-1");
194 assert_eq!(value["childThreadId"], "child-thread");
195 assert_eq!(value["lane"], "scout");
196 assert_eq!(value["status"], "running");
197 assert_eq!(value["destination"]["kind"], "in_process");
198
199 let round_trip: SubagentTraceSummary = serde_json::from_value(value).unwrap();
200 assert_eq!(round_trip, summary);
201 }
202
203 #[test]
204 fn subagent_trace_delta_caps_tool_output() {
205 let output = PagedTraceText::capped("abcdef", 3);
206
207 assert_eq!(output.text, "abc");
208 assert!(output.truncated);
209 assert_eq!(output.next_offset, Some(3));
210 }
211}