Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bt-daemon/src/sink/braintrust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,9 @@ fn destination_components(destination: &TraceDestination, project: &str) -> Dest
fn map_span_type(t: SpanType) -> SdkSpanType {
match t {
SpanType::Task => SdkSpanType::Task,
// Braintrust's span type enum has no dedicated wait value. Keep waits
// as task spans so their elapsed time cannot be mistaken for tool time.
SpanType::Wait => SdkSpanType::Task,
SpanType::Llm => SdkSpanType::Llm,
SpanType::Tool => SdkSpanType::Tool,
}
Expand Down
24 changes: 20 additions & 4 deletions bt-daemon/src/translate/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,13 +716,21 @@ impl ClaudeTranslator {
// PreToolUse only proves that Claude proposed a call. Approval is
// recorded once a terminal hook or transcript result proves whether
// the call executed or was denied.
let metadata = tool_metadata(event, &tool_name, &call_id, None, &input);
let mut metadata = tool_metadata(event, &tool_name, &call_id, None, &input);
let span_type = if tool_name == "AskUserQuestion" {
if let Some(fields) = metadata.as_object_mut() {
fields.insert("wait_kind".into(), json!("user_question"));
}
SpanType::Wait
} else {
SpanType::Tool
};
Comment thread
Qard marked this conversation as resolved.
ops.push(SpanOp::Insert(SpanRow {
span_id: span_id.clone(),
root_span_id: self.root_span_id.clone(),
parent_span_ids: vec![parent_id.clone()],
name: tool_span_name(&tool_name, &input),
span_type: SpanType::Tool,
span_type,
start_ms: Some(event.ts_ms),
input: Some(input.clone()),
metadata: Some(metadata.clone()),
Expand Down Expand Up @@ -756,7 +764,7 @@ impl ClaudeTranslator {
let error = (approval != ToolApproval::Denied)
.then(|| forced_error.or_else(|| tool_error(&event.payload)))
.flatten();
let metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input);
let mut metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input);
if let Some(pending) = self.pending_tools.remove(&call_id) {
// A PreToolUse hook may rewrite the call via `updatedInput`, so the
// terminal hook's `tool_input` is what the call ran (or was denied)
Expand All @@ -782,6 +790,14 @@ impl ClaudeTranslator {
let Some(parent_id) = self.parent_for(event, ops) else {
return;
};
let span_type = if tool_name == "AskUserQuestion" {
if let Some(fields) = metadata.as_object_mut() {
fields.insert("wait_kind".into(), json!("user_question"));
}
SpanType::Wait
} else {
SpanType::Tool
};
let duration = event
.payload
.get("duration_ms")
Expand All @@ -793,7 +809,7 @@ impl ClaudeTranslator {
turn_span_id: self.owner_of(&parent_id),
parent_span_ids: vec![parent_id],
name: tool_span_name(&tool_name, &input),
span_type: SpanType::Tool,
span_type,
start_ms: Some(event.ts_ms.saturating_sub(duration)),
end_ms: Some(event.ts_ms),
input: Some(input),
Expand Down
48 changes: 35 additions & 13 deletions bt-daemon/src/translate/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use std::sync::{Arc, OnceLock};

const SPAWN_AGENT_TOOL: &str = "spawn_agent";
const MISSING_TOOL_OUTPUT_ERROR: &str = "Tool output missing before turn ended";
const MAX_OPEN_TOOL_SPAN_MS: i64 = 24 * 60 * 60 * 1000;
/// A hook can arrive after a daemon restart or against an existing rollout.
/// Keep a single translator batch small even when the unread suffix is large.
const CATCH_UP_BYTE_BUDGET: usize = 64 * 1024;
Expand Down Expand Up @@ -117,6 +118,8 @@ impl TranslatorFactory for crate::agents::Codex {
effective_root_span_id: root_span_id,
external_parent_span_id: None,
root_opened: false,
main_root_start_ms: None,
first_observed_ms: None,
session_source: None,
permission_mode: None,
root_cwd: None,
Expand Down Expand Up @@ -179,7 +182,7 @@ struct Scope {
open_turns: Vec<OpenTurn>,
message_history: Vec<Value>,
open_llm: Option<OpenLlm>,
open_tools: HashMap<String, (String, String)>, // call_id -> (tool span_id, turn_id)
open_tools: HashMap<String, (String, String, i64)>, // call_id -> (tool span_id, turn_id, start_ms)
last_turn_end_ms: Option<i64>,
last_turn_output: Option<Value>,
turn_seq: u32,
Expand Down Expand Up @@ -242,6 +245,8 @@ struct CodexTranslator {
effective_root_span_id: String,
external_parent_span_id: Option<String>,
root_opened: bool,
main_root_start_ms: Option<i64>,
first_observed_ms: Option<i64>,
session_source: Option<String>,
permission_mode: Option<String>,
root_cwd: Option<String>,
Expand All @@ -262,6 +267,7 @@ impl AgentTranslator for CodexTranslator {
self.pending.is_none(),
"Codex translator has pending catch-up work; drain it before handling another event"
);
self.first_observed_ms.get_or_insert(event.ts_ms);
let payload = &event.payload;
let mut ops = Vec::new();

Expand Down Expand Up @@ -759,6 +765,10 @@ impl CodexTranslator {
};
let cwd = str_field(payload, "cwd");
self.root_cwd = cwd.clone();
let root_start_ms = self
.first_observed_ms
.map_or(ts, |first_observed| ts.max(first_observed));
self.main_root_start_ms = Some(root_start_ms);
let mut md = Map::new();
for k in ["id", "cwd", "cli_version"] {
if let Some(v) = str_field(payload, k) {
Expand Down Expand Up @@ -797,7 +807,7 @@ impl CodexTranslator {
parent_span_ids: self.scope_root_parent_span_ids(scope),
name,
span_type: SpanType::Task,
start_ms: Some(ts),
start_ms: Some(root_start_ms),
input: Some(json!({
"model": scope.model,
"cwd": cwd,
Expand Down Expand Up @@ -1180,7 +1190,7 @@ impl CodexTranslator {
tags: if tags.is_empty() { None } else { Some(tags) },
..Default::default()
}));
scope.open_tools.insert(call_id, (span_id, turn_id));
scope.open_tools.insert(call_id, (span_id, turn_id, ts));
}

fn on_tool_output(
Expand All @@ -1195,7 +1205,7 @@ impl CodexTranslator {
return;
};
push_tool_result(scope, Some(&call_id), payload);
let Some((span_id, turn_id)) = scope.open_tools.remove(&call_id) else {
let Some((span_id, turn_id, _start_ms)) = scope.open_tools.remove(&call_id) else {
return;
};
if let Some(turn) = scope
Expand Down Expand Up @@ -1347,16 +1357,19 @@ impl CodexTranslator {
let call_ids: Vec<String> = scope
.open_tools
.iter()
.filter(|(_, (_, owner))| owner == turn_id)
.filter(|(_, (_, owner, _))| owner == turn_id)
.map(|(call_id, _)| call_id.clone())
.collect();
for call_id in call_ids {
if let Some((span_id, _)) = scope.open_tools.remove(&call_id) {
if let Some((span_id, _, start_ms)) = scope.open_tools.remove(&call_id) {
ops.push(SpanOp::Merge(SpanRow {
span_id,
root_span_id: self.effective_root_span_id.clone(),
parent_span_ids: parent_span_ids.clone(),
end_ms,
end_ms: Some(end_ms.map_or_else(
|| start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS),
|end| end.min(start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS)),
)),
metadata: Some(tool_approval_metadata(Some(ToolApproval::Approved))),
error: Some(MISSING_TOOL_OUTPUT_ERROR.to_string()),
..Default::default()
Expand Down Expand Up @@ -1455,9 +1468,10 @@ impl CodexTranslator {
.main_path
.as_ref()
.and_then(|path| self.scopes.get(path));
let end_ms = main_scope
let observed_end_ms = main_scope
.and_then(|scope| scope.last_turn_end_ms)
.unwrap_or(fallback_ts);
let end_ms = observed_end_ms.max(self.main_root_start_ms.unwrap_or(observed_end_ms));
let output = main_scope.and_then(|scope| scope.last_turn_output.clone());
ops.push(SpanOp::Merge(SpanRow {
span_id: self.root_span_id.clone(),
Expand Down Expand Up @@ -1525,19 +1539,27 @@ impl CodexTranslator {
..Default::default()
}));
}
let tools: Vec<(String, String, String)> = scope
let tools: Vec<(String, String, i64, String)> = scope
.open_tools
.drain()
.map(|(_, (span_id, turn_id))| {
(span_id, turn_id, MISSING_TOOL_OUTPUT_ERROR.to_string())
.map(|(_, (span_id, turn_id, start_ms))| {
(
span_id,
turn_id,
start_ms,
MISSING_TOOL_OUTPUT_ERROR.to_string(),
)
})
.collect();
for (sid, turn_id, error) in tools {
for (sid, turn_id, start_ms, error) in tools {
ops.push(SpanOp::Merge(SpanRow {
span_id: sid,
root_span_id: self.effective_root_span_id.clone(),
parent_span_ids: self.turn_parent_span_ids(&turn_id),
end_ms,
end_ms: Some(end_ms.map_or_else(
|| start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS),
|end| end.min(start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS)),
)),
metadata: Some(tool_approval_metadata(Some(ToolApproval::Approved))),
error: Some(error),
..Default::default()
Expand Down
2 changes: 2 additions & 0 deletions bt-daemon/src/translate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub enum SpanType {
Task,
Llm,
Tool,
/// A human response or approval interval, separate from agent execution.
Wait,
}

/// Immutable versions captured when a coding-agent span is first created.
Expand Down
Loading