Skip to main content

roder_api/
trace.rs

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}