From 9dedd2b19b008350ff68c43469e31891b289e3f6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:46:02 -0700 Subject: [PATCH] feat(agent): persist session transcript provenance (#4134) --- src/openhuman/agent/dispatcher.rs | 25 +- src/openhuman/agent/dispatcher_tests.rs | 3 + src/openhuman/agent/harness/engine/core.rs | 79 +++++- src/openhuman/agent/harness/engine/state.rs | 2 +- .../agent/harness/session/runtime_tests.rs | 2 + .../agent/harness/session/transcript.rs | 259 ++++++++++++++++-- .../agent/harness/session/transcript_tests.rs | 83 ++++++ .../agent/harness/session/turn/session_io.rs | 9 + .../harness/session/turn_engine_adapter.rs | 33 ++- .../agent/harness/session/turn_tests.rs | 1 + .../harness/subagent_runner/extract_tool.rs | 10 + .../harness/subagent_runner/ops/loop_.rs | 1 + .../harness/subagent_runner/ops/observer.rs | 44 ++- src/openhuman/agent/harness/token_budget.rs | 4 + src/openhuman/agent/tests.rs | 6 + src/openhuman/context/manager_tests.rs | 1 + src/openhuman/context/microcompact.rs | 1 + src/openhuman/context/pipeline.rs | 1 + src/openhuman/context/summarizer_tests.rs | 2 + src/openhuman/inference/provider/traits.rs | 2 + .../learning/transcript_ingest/tests.rs | 5 + src/openhuman/migrations/mod_tests.rs | 5 + .../migrations/phase_out_profile_md_tests.rs | 5 + tests/agent_harness_raw_coverage_e2e.rs | 13 + tests/inference_agent_raw_coverage_e2e.rs | 2 + 25 files changed, 557 insertions(+), 41 deletions(-) diff --git a/src/openhuman/agent/dispatcher.rs b/src/openhuman/agent/dispatcher.rs index 7c635d17b..547386ff3 100644 --- a/src/openhuman/agent/dispatcher.rs +++ b/src/openhuman/agent/dispatcher.rs @@ -135,8 +135,14 @@ impl ToolDispatcher for XmlToolDispatcher { .iter() .flat_map(|msg| match msg { ConversationMessage::Chat(chat) => vec![chat.clone()], - ConversationMessage::AssistantToolCalls { text, .. } => { - vec![ChatMessage::assistant(text.clone().unwrap_or_default())] + ConversationMessage::AssistantToolCalls { + text, + extra_metadata, + .. + } => { + let mut msg = ChatMessage::assistant(text.clone().unwrap_or_default()); + msg.extra_metadata = extra_metadata.clone(); + vec![msg] } ConversationMessage::ToolResults(results) => { let mut content = String::new(); @@ -358,8 +364,14 @@ impl ToolDispatcher for PFormatToolDispatcher { .iter() .flat_map(|msg| match msg { ConversationMessage::Chat(chat) => vec![chat.clone()], - ConversationMessage::AssistantToolCalls { text, .. } => { - vec![ChatMessage::assistant(text.clone().unwrap_or_default())] + ConversationMessage::AssistantToolCalls { + text, + extra_metadata, + .. + } => { + let mut msg = ChatMessage::assistant(text.clone().unwrap_or_default()); + msg.extra_metadata = extra_metadata.clone(); + vec![msg] } ConversationMessage::ToolResults(results) => { let mut content = String::new(); @@ -554,6 +566,7 @@ impl ToolDispatcher for NativeToolDispatcher { text, tool_calls, reasoning_content, + extra_metadata, } => { let mut payload = serde_json::json!({ "content": text, @@ -562,7 +575,9 @@ impl ToolDispatcher for NativeToolDispatcher { if let Some(rc) = reasoning_content { payload["reasoning_content"] = serde_json::Value::String(rc.clone()); } - vec![ChatMessage::assistant(payload.to_string())] + let mut msg = ChatMessage::assistant(payload.to_string()); + msg.extra_metadata = extra_metadata.clone(); + vec![msg] } ConversationMessage::ToolResults(results) => results .iter() diff --git a/src/openhuman/agent/dispatcher_tests.rs b/src/openhuman/agent/dispatcher_tests.rs index d95997bd9..00943dce9 100644 --- a/src/openhuman/agent/dispatcher_tests.rs +++ b/src/openhuman/agent/dispatcher_tests.rs @@ -272,6 +272,7 @@ fn assistant_tool_calls(id: &str) -> ConversationMessage { extra_content: None, }], reasoning_content: None, + extra_metadata: None, } } @@ -403,6 +404,7 @@ fn assistant_tool_calls_multi(ids: &[&str]) -> ConversationMessage { }) .collect(), reasoning_content: None, + extra_metadata: None, } } @@ -419,6 +421,7 @@ fn native_dispatcher_serializes_reasoning_content_for_tool_call_turns() { extra_content: None, }], reasoning_content: Some("chain-of-thought replay blob".into()), + extra_metadata: None, }, tool_results("tc-1"), ]; diff --git a/src/openhuman/agent/harness/engine/core.rs b/src/openhuman/agent/harness/engine/core.rs index 4ad43b628..2e06d1530 100644 --- a/src/openhuman/agent/harness/engine/core.rs +++ b/src/openhuman/agent/harness/engine/core.rs @@ -27,11 +27,13 @@ use crate::openhuman::agent::stop_hooks::{current_stop_hooks, StopDecision, Turn use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard}; use crate::openhuman::context::{summarize_chat_history, EngineAutocompact}; use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, Provider, ProviderCapabilityError, AGENT_TURN_MAX_OUTPUT_TOKENS, + ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall, UsageInfo, + AGENT_TURN_MAX_OUTPUT_TOKENS, }; use super::super::parse::build_native_assistant_history; use super::super::run_queue::RunQueue; +use super::super::session::transcript::{self, MessageUsage, TurnUsage}; use super::super::token_budget::trim_chat_messages_to_budget; use super::super::tool_loop::{RepeatFailureGuard, RepeatOutputGuard, STREAM_CHUNK_MIN_CHARS}; use super::checkpoint::CheckpointStrategy; @@ -40,6 +42,35 @@ use super::progress::ProgressReporter; use super::state::TurnObserver; use super::tool_source::ToolSource; +fn transcript_turn_usage( + provider: &str, + model: &str, + usage: Option<&UsageInfo>, + reasoning_content: Option<&str>, + tool_calls: &[ToolCall], + iteration: usize, +) -> Option { + let usage = usage?; + Some(TurnUsage { + provider: provider.to_string(), + model: model.to_string(), + usage: MessageUsage { + input: usage.input_tokens, + output: usage.output_tokens, + cached_input: usage.cached_input_tokens, + context_window: usage.context_window, + cost_usd: usage.charged_amount_usd, + }, + ts: chrono::Utc::now().to_rfc3339(), + reasoning_content: reasoning_content + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string), + tool_calls: tool_calls.to_vec(), + iteration: (iteration + 1) as u32, + }) +} + /// What a completed turn yields. `text` is the final assistant text (or the /// circuit-breaker / checkpoint summary); `iterations` and `cost` let stateful /// callers attribute the run. @@ -550,13 +581,14 @@ pub(crate) async fn run_turn_engine( tool_calls, assistant_history_content, native_tool_calls, + response_usage, ) = match chat_result { Ok(resp) => { // Update context guard + cost with token usage from this response. if let Some(ref usage) = resp.usage { context_guard.update_usage(usage); turn_cost.add_call(model, usage); - observer.record_usage(model, usage); + observer.record_usage(provider_name, model, usage); tracing::debug!( iteration, input_tokens = usage.input_tokens, @@ -597,6 +629,7 @@ pub(crate) async fn run_turn_engine( let reasoning_content = resp.reasoning_content; let native_calls = resp.tool_calls; + let response_usage = resp.usage; ( response_text, display_text, @@ -604,6 +637,7 @@ pub(crate) async fn run_turn_engine( calls, assistant_history_content, native_calls, + response_usage, ) } Err(e) => { @@ -686,7 +720,18 @@ pub(crate) async fn run_turn_engine( let _ = tx.send(chunk).await; } } - history.push(ChatMessage::assistant(response_text.clone())); + let mut assistant_msg = ChatMessage::assistant(response_text.clone()); + if let Some(turn_usage) = transcript_turn_usage( + provider_name, + model, + response_usage.as_ref(), + reasoning_content.as_deref(), + &[], + iteration, + ) { + transcript::attach_turn_usage_metadata(&mut assistant_msg, &turn_usage); + } + history.push(assistant_msg); observer .on_assistant( &final_out, @@ -736,7 +781,18 @@ pub(crate) async fn run_turn_engine( iteration, "[agent_loop] repeat-output circuit breaker tripped — identical response+tool-call repeated; halting with no-progress summary" ); - history.push(ChatMessage::assistant(assistant_history_content.clone())); + let mut assistant_msg = ChatMessage::assistant(assistant_history_content.clone()); + if let Some(turn_usage) = transcript_turn_usage( + provider_name, + model, + response_usage.as_ref(), + reasoning_content.as_deref(), + &native_tool_calls, + iteration, + ) { + transcript::attach_turn_usage_metadata(&mut assistant_msg, &turn_usage); + } + history.push(assistant_msg); // Mirror the assistant turn to the observer like every other // assistant-append path, so transcript/mirroring isn't skipped // for the final repeated iteration on this early exit. @@ -907,7 +963,18 @@ pub(crate) async fn run_turn_engine( // Native mode: JSON-structured messages so convert_messages() can // reconstruct OpenAI-format tool_calls + tool result messages. Prompt // mode: XML-based text format. - history.push(ChatMessage::assistant(assistant_history_content)); + let mut assistant_msg = ChatMessage::assistant(assistant_history_content); + if let Some(turn_usage) = transcript_turn_usage( + provider_name, + model, + response_usage.as_ref(), + reasoning_content.as_deref(), + executed_native_calls, + iteration, + ) { + transcript::attach_turn_usage_metadata(&mut assistant_msg, &turn_usage); + } + history.push(assistant_msg); observer .on_assistant( &display_text, @@ -990,7 +1057,7 @@ pub(crate) async fn run_turn_engine( // accounting stays complete. if let Some(ref u) = co.usage { turn_cost.add_call(model, u); - observer.record_usage(model, u); + observer.record_usage(provider_name, model, u); } // Emit the terminal lifecycle event on this successful (checkpoint) exit // too, so consumers aren't left waiting — matching the final-response and diff --git a/src/openhuman/agent/harness/engine/state.rs b/src/openhuman/agent/harness/engine/state.rs index 7acb09111..1c4839df8 100644 --- a/src/openhuman/agent/harness/engine/state.rs +++ b/src/openhuman/agent/harness/engine/state.rs @@ -38,7 +38,7 @@ pub(crate) trait TurnObserver: Send { /// Called once per provider response that carried a usage block, so the /// caller can accumulate its own token tally / transcript usage snapshot. - fn record_usage(&mut self, _model: &str, _usage: &UsageInfo) {} + fn record_usage(&mut self, _provider: &str, _model: &str, _usage: &UsageInfo) {} /// Called after the assistant message for this iteration is committed to /// the engine's working buffer. `response_text` is the raw provider text diff --git a/src/openhuman/agent/harness/session/runtime_tests.rs b/src/openhuman/agent/harness/session/runtime_tests.rs index b85139ef0..b00ec6ea2 100644 --- a/src/openhuman/agent/harness/session/runtime_tests.rs +++ b/src/openhuman/agent/harness/session/runtime_tests.rs @@ -138,11 +138,13 @@ fn sanitizers_and_tool_call_helpers_cover_fallback_paths() { text: None, tool_calls: vec![], reasoning_content: None, + extra_metadata: None, }, ConversationMessage::AssistantToolCalls { text: None, tool_calls: vec![], reasoning_content: None, + extra_metadata: None, }, ]; assert_eq!(Agent::count_iterations(&history), 3); diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 3ee740fc5..9612344d8 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -51,6 +51,7 @@ //! message log without losing message-level addressing. use crate::openhuman::inference::provider::ChatMessage; +use crate::openhuman::inference::provider::ToolCall; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -66,24 +67,97 @@ pub struct MessageUsage { pub input: u64, pub output: u64, pub cached_input: u64, + #[serde(default)] + pub context_window: u64, pub cost_usd: f64, } /// Usage + provenance for one provider response, attached to the last /// assistant message in a turn. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TurnUsage { + #[serde(default)] + pub provider: String, + #[serde(default)] pub model: String, pub usage: MessageUsage, /// RFC-3339 timestamp of the response. + #[serde(default)] pub ts: String, + /// Raw reasoning/thinking content returned by thinking models. This is + /// persisted as metadata so the later transcript view can show the model's + /// thoughts without depending on the live stream still being open. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, + /// Native tool calls emitted in this provider response, if any. Text-mode + /// calls remain present in `content` as the raw markup the model emitted. + #[serde(default)] + pub tool_calls: Vec, + /// One-based engine iteration for this provider response. + #[serde(default)] + pub iteration: u32, +} + +const TURN_USAGE_METADATA_KEY: &str = "openhuman_turn_usage"; + +pub(crate) fn attach_turn_usage_metadata(message: &mut ChatMessage, turn_usage: &TurnUsage) { + let Ok(payload) = serde_json::to_value(turn_usage) else { + log::warn!("[transcript] failed to serialize turn usage metadata"); + return; + }; + + match message.extra_metadata.take() { + Some(serde_json::Value::Object(mut map)) => { + map.insert(TURN_USAGE_METADATA_KEY.to_string(), payload); + message.extra_metadata = Some(serde_json::Value::Object(map)); + } + Some(existing) => { + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), existing); + map.insert(TURN_USAGE_METADATA_KEY.to_string(), payload); + message.extra_metadata = Some(serde_json::Value::Object(map)); + } + None => { + let mut map = serde_json::Map::new(); + map.insert(TURN_USAGE_METADATA_KEY.to_string(), payload); + message.extra_metadata = Some(serde_json::Value::Object(map)); + } + } +} + +pub(crate) fn turn_usage_extra_metadata(turn_usage: &TurnUsage) -> Option { + let mut message = ChatMessage::assistant(""); + attach_turn_usage_metadata(&mut message, turn_usage); + message.extra_metadata +} + +fn turn_usage_from_metadata(message: &ChatMessage) -> Option { + let payload = message + .extra_metadata + .as_ref()? + .get(TURN_USAGE_METADATA_KEY)?; + serde_json::from_value(payload.clone()).ok() } /// Metadata header for a session transcript file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscriptMeta { pub agent_name: String, + /// Canonical registry id for the agent that produced this transcript. + /// `agent_name` may be per-thread renamed for file names; this remains the + /// stable archetype id when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + /// Coarse runtime kind (`root`, `subagent`, `extractor`, ...). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub dispatcher: String, + /// Provider label used for the most recent recorded response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Model id used for the most recent recorded response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, pub created: String, pub updated: String, pub turn_count: usize, @@ -102,6 +176,9 @@ pub struct TranscriptMeta { /// originate from a thread-scoped channel (e.g. CLI-only sessions). #[serde(default, skip_serializing_if = "Option::is_none")] pub thread_id: Option, + /// Sub-agent task id, when this transcript belongs to a spawned worker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, } /// A parsed session transcript: metadata + exact message array. @@ -123,7 +200,15 @@ struct MetaLine { #[derive(Serialize, Deserialize)] struct MetaPayload { agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_type: Option, dispatcher: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, created: String, updated: String, turn_count: usize, @@ -133,6 +218,8 @@ struct MetaPayload { charged_amount_usd: f64, #[serde(default, skip_serializing_if = "Option::is_none")] thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + task_id: Option, } /// One message line in the JSONL — only `role` and `content` are required. @@ -147,10 +234,18 @@ struct MessageLine { #[serde(default, skip_serializing_if = "Option::is_none")] extra_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] usage: Option, #[serde(skip_serializing_if = "Option::is_none")] + reasoning_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + iteration: Option, + #[serde(skip_serializing_if = "Option::is_none")] ts: Option, /// Absorb any unknown fields so forward-compat reads don't error. #[serde(flatten)] @@ -183,7 +278,11 @@ pub fn write_transcript( let meta_line = MetaLine { meta: MetaPayload { agent: meta.agent_name.clone(), + agent_id: meta.agent_id.clone(), + agent_type: meta.agent_type.clone(), dispatcher: meta.dispatcher.clone(), + provider: meta.provider.clone(), + model: meta.model.clone(), created: meta.created.clone(), updated: meta.updated.clone(), turn_count: meta.turn_count, @@ -192,6 +291,7 @@ pub fn write_transcript( cached_input_tokens: meta.cached_input_tokens, charged_amount_usd: meta.charged_amount_usd, thread_id: meta.thread_id.clone(), + task_id: meta.task_id.clone(), }, }; let meta_json = @@ -199,35 +299,70 @@ pub fn write_transcript( jsonl_buf.push_str(&meta_json); jsonl_buf.push('\n'); - // Identify the index of the last assistant message so we can attach - // per-turn usage to it. + // Identify the index of the last assistant message so older call sites can + // still attach per-turn usage without embedding it on the message. let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant"); for (i, msg) in messages.iter().enumerate() { - // Only the last assistant message carries usage/model/ts; every - // other line has those fields omitted. Pattern-match both - // options together so there's no separate unwrap. - let line = match (last_assistant_idx, last_assistant_turn_usage) { - (Some(idx), Some(tu)) if idx == i => MessageLine { - id: msg.id.clone(), - role: msg.role.clone(), - content: msg.content.clone(), - extra_metadata: msg.extra_metadata.clone(), - model: Some(tu.model.clone()), - usage: Some(tu.usage.clone()), - ts: Some(tu.ts.clone()), - _extra: HashMap::new(), - }, - _ => MessageLine { + let turn_usage = if Some(i) == last_assistant_idx { + last_assistant_turn_usage + .cloned() + .or_else(|| turn_usage_from_metadata(msg)) + } else { + turn_usage_from_metadata(msg) + }; + + let line = if msg.role == "assistant" { + if let Some(tu) = turn_usage.as_ref() { + MessageLine { + id: msg.id.clone(), + role: msg.role.clone(), + content: msg.content.clone(), + extra_metadata: msg.extra_metadata.clone(), + provider: Some(tu.provider.clone()), + model: Some(tu.model.clone()), + usage: Some(tu.usage.clone()), + reasoning_content: tu.reasoning_content.clone(), + tool_calls: if tu.tool_calls.is_empty() { + None + } else { + Some(tu.tool_calls.clone()) + }, + iteration: Some(tu.iteration), + ts: Some(tu.ts.clone()), + _extra: HashMap::new(), + } + } else { + MessageLine { + id: msg.id.clone(), + role: msg.role.clone(), + content: msg.content.clone(), + extra_metadata: msg.extra_metadata.clone(), + provider: None, + model: None, + usage: None, + reasoning_content: None, + tool_calls: None, + iteration: None, + ts: None, + _extra: HashMap::new(), + } + } + } else { + MessageLine { id: msg.id.clone(), role: msg.role.clone(), content: msg.content.clone(), extra_metadata: msg.extra_metadata.clone(), + provider: None, model: None, usage: None, + reasoning_content: None, + tool_calls: None, + iteration: None, ts: None, _extra: HashMap::new(), - }, + } }; let line_json = @@ -246,11 +381,25 @@ pub fn write_transcript( ); // ── Companion .md ──────────────────────────────────────────────── - // Build per-message usage index for the renderer (only last assistant). - let mut per_msg_usage: HashMap = HashMap::new(); - if let (Some(idx), Some(tu)) = (last_assistant_idx, last_assistant_turn_usage) { - per_msg_usage.insert(idx, tu); + // Build per-message usage index for the renderer. Embedded metadata keeps + // older assistant iterations visible when the full JSONL is rewritten. + let mut owned_usage: Vec<(usize, TurnUsage)> = Vec::new(); + for (idx, msg) in messages.iter().enumerate() { + let usage = if Some(idx) == last_assistant_idx { + last_assistant_turn_usage + .cloned() + .or_else(|| turn_usage_from_metadata(msg)) + } else { + turn_usage_from_metadata(msg) + }; + if let Some(usage) = usage { + owned_usage.push((idx, usage)); + } } + let per_msg_usage: HashMap = owned_usage + .iter() + .map(|(idx, usage)| (*idx, usage)) + .collect(); // The .md companion is a *derived* view — the JSONL above is the // source of truth. Failures here must not propagate: a readable-log @@ -350,7 +499,11 @@ fn read_transcript_jsonl(path: &Path) -> Result { let mp = ml.meta; meta = Some(TranscriptMeta { agent_name: mp.agent, + agent_id: mp.agent_id, + agent_type: mp.agent_type, dispatcher: mp.dispatcher, + provider: mp.provider, + model: mp.model, created: mp.created, updated: mp.updated, turn_count: mp.turn_count, @@ -359,6 +512,7 @@ fn read_transcript_jsonl(path: &Path) -> Result { cached_input_tokens: mp.cached_input_tokens, charged_amount_usd: mp.charged_amount_usd, thread_id: mp.thread_id, + task_id: mp.task_id, }); continue; } @@ -366,12 +520,37 @@ fn read_transcript_jsonl(path: &Path) -> Result { // Message line. match serde_json::from_str::(line) { Ok(ml) => { - messages.push(ChatMessage { + let turn_usage = match ( + ml.provider.clone(), + ml.model.clone(), + ml.usage.clone(), + ml.ts.clone(), + ) { + (Some(provider), Some(model), Some(usage), Some(ts)) + if ml.role == "assistant" => + { + Some(TurnUsage { + provider, + model, + usage, + ts, + reasoning_content: ml.reasoning_content.clone(), + tool_calls: ml.tool_calls.clone().unwrap_or_default(), + iteration: ml.iteration.unwrap_or_default(), + }) + } + _ => None, + }; + let mut message = ChatMessage { id: ml.id, role: ml.role, content: ml.content, extra_metadata: ml.extra_metadata, - }); + }; + if let Some(turn_usage) = turn_usage.as_ref() { + attach_turn_usage_metadata(&mut message, turn_usage); + } + messages.push(message); } Err(err) => { log::warn!( @@ -567,6 +746,21 @@ fn render_markdown( let _ = writeln!(buf, "# Session transcript — {}", meta.agent_name); buf.push('\n'); let _ = writeln!(buf, "- Dispatcher: {}", meta.dispatcher); + if let Some(agent_id) = meta.agent_id.as_deref() { + let _ = writeln!(buf, "- Agent ID: `{agent_id}`"); + } + if let Some(agent_type) = meta.agent_type.as_deref() { + let _ = writeln!(buf, "- Agent type: `{agent_type}`"); + } + if let Some(provider) = meta.provider.as_deref() { + let _ = writeln!(buf, "- Provider: `{provider}`"); + } + if let Some(model) = meta.model.as_deref() { + let _ = writeln!(buf, "- Model: `{model}`"); + } + if let Some(task_id) = meta.task_id.as_deref() { + let _ = writeln!(buf, "- Task: `{task_id}`"); + } if let Some(tid) = meta.thread_id.as_deref() { let _ = writeln!(buf, "- Thread: `{tid}`"); } @@ -602,6 +796,16 @@ fn render_markdown( tu.usage.cached_input, tu.usage.cost_usd ); + if !tu.provider.is_empty() || tu.usage.context_window > 0 { + let _ = writeln!( + buf, + "_provider: `{}` · iteration: {} · context window: {}_", + tu.provider, tu.iteration, tu.usage.context_window + ); + } + if let Some(reasoning) = tu.reasoning_content.as_deref().filter(|s| !s.is_empty()) { + let _ = writeln!(buf, "\n### Thoughts\n\n{reasoning}\n"); + } } else { let _ = writeln!(buf, "## [{}]", msg.role); } @@ -668,6 +872,10 @@ fn parse_legacy_meta(raw: &str) -> Result { Ok(TranscriptMeta { agent_name: get("agent").unwrap_or_else(|| "unknown".into()), dispatcher: get("dispatcher").unwrap_or_else(|| "native".into()), + agent_id: None, + agent_type: None, + provider: None, + model: None, created: get("created").unwrap_or_default(), updated: get("updated").unwrap_or_default(), turn_count: get("turn_count").and_then(|s| s.parse().ok()).unwrap_or(0), @@ -684,6 +892,7 @@ fn parse_legacy_meta(raw: &str) -> Result { .and_then(|s| s.trim_start_matches('$').parse().ok()) .unwrap_or(0.0), thread_id: get("thread_id").filter(|s| !s.is_empty()), + task_id: None, }) } diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index 11a28bae3..f01d1cb6e 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::openhuman::inference::provider::ToolCall; use tempfile::TempDir; fn sample_messages() -> Vec { @@ -16,7 +17,11 @@ fn sample_messages() -> Vec { fn sample_meta() -> TranscriptMeta { TranscriptMeta { agent_name: "code_executor".into(), + agent_id: Some("code_executor".into()), + agent_type: Some("subagent".into()), dispatcher: "native".into(), + provider: Some("openhuman-backend".into()), + model: Some("claude-sonnet-4-6".into()), created: "2026-04-11T14:30:00Z".into(), updated: "2026-04-11T14:35:22Z".into(), turn_count: 3, @@ -25,19 +30,30 @@ fn sample_meta() -> TranscriptMeta { cached_input_tokens: 3500, charged_amount_usd: 0.0045, thread_id: None, + task_id: Some("task-123".into()), } } fn sample_turn_usage() -> TurnUsage { TurnUsage { + provider: "openhuman-backend".into(), model: "claude-sonnet-4-6".into(), usage: MessageUsage { input: 1234, output: 567, cached_input: 1000, + context_window: 200_000, cost_usd: 0.0012, }, ts: "2026-04-17T10:00:00Z".into(), + reasoning_content: Some("private reasoning trace".into()), + tool_calls: vec![ToolCall { + id: "call-1".into(), + name: "shell".into(), + arguments: "{\"cmd\":\"ls\"}".into(), + extra_content: None, + }], + iteration: 1, } } @@ -425,6 +441,22 @@ fn usage_round_trips_on_last_assistant_message() { last_assistant_line.contains("claude-sonnet-4-6"), "model missing from last assistant line" ); + assert!( + last_assistant_line.contains("openhuman-backend"), + "provider missing from last assistant line" + ); + assert!( + last_assistant_line.contains("\"context_window\":200000"), + "context window missing from usage" + ); + assert!( + last_assistant_line.contains("private reasoning trace"), + "reasoning content missing from assistant metadata" + ); + assert!( + last_assistant_line.contains("\"tool_calls\""), + "native tool calls missing from assistant metadata" + ); assert!( last_assistant_line.contains("\"cost_usd\""), "cost_usd missing" @@ -439,6 +471,52 @@ fn usage_round_trips_on_last_assistant_message() { } } +#[test] +fn embedded_usage_preserves_earlier_assistant_messages_on_rewrite() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("multi_usage.jsonl"); + let mut messages = vec![ + ChatMessage::user("start"), + ChatMessage::assistant("first"), + ChatMessage::user("continue"), + ChatMessage::assistant("second"), + ]; + let first_usage = TurnUsage { + provider: "provider-a".into(), + model: "model-a".into(), + iteration: 1, + ..sample_turn_usage() + }; + let second_usage = TurnUsage { + provider: "provider-b".into(), + model: "model-b".into(), + iteration: 2, + ..sample_turn_usage() + }; + attach_turn_usage_metadata(&mut messages[1], &first_usage); + + write_transcript(&path, &messages, &sample_meta(), Some(&second_usage)).unwrap(); + + let raw = fs::read_to_string(&path).unwrap(); + let assistant_lines: Vec<&str> = raw + .lines() + .filter(|line| line.contains("\"role\":\"assistant\"")) + .collect(); + assert_eq!(assistant_lines.len(), 2); + assert!(assistant_lines[0].contains("provider-a")); + assert!(assistant_lines[0].contains("model-a")); + assert!(assistant_lines[1].contains("provider-b")); + assert!(assistant_lines[1].contains("model-b")); + + let loaded = read_transcript(&path).unwrap(); + write_transcript(&path, &loaded.messages, &loaded.meta, None).unwrap(); + let rewritten = fs::read_to_string(&path).unwrap(); + assert!(rewritten.contains("provider-a")); + assert!(rewritten.contains("model-a")); + assert!(rewritten.contains("provider-b")); + assert!(rewritten.contains("model-b")); +} + #[test] fn md_companion_file_is_written() { let dir = TempDir::new().unwrap(); @@ -453,10 +531,15 @@ fn md_companion_file_is_written() { assert!(md_path.exists(), ".md companion should be written"); let md = fs::read_to_string(&md_path).unwrap(); assert!(md.contains("# Session transcript — code_executor")); + assert!(md.contains("Agent ID: `code_executor`")); + assert!(md.contains("Agent type: `subagent`")); + assert!(md.contains("Provider: `openhuman-backend`")); + assert!(md.contains("Task: `task-123`")); assert!( md.contains("claude-sonnet-4-6"), "model should appear in md" ); + assert!(md.contains("private reasoning trace")); assert!(md.contains("## [system]"), "system section missing"); assert!(md.contains("## [user]"), "user section missing"); } diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 69078ae25..f8c607541 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -212,11 +212,19 @@ impl Agent { let meta = transcript::TranscriptMeta { agent_name: self.agent_definition_name.clone(), + agent_id: Some(self.agent_definition_id.clone()), + agent_type: Some(if self.session_parent_prefix.is_some() { + "subagent".to_string() + } else { + "root".to_string() + }), dispatcher: if self.tool_dispatcher.should_send_tool_specs() { "native".into() } else { "xml".into() }, + provider: turn_usage.map(|usage| usage.provider.clone()), + model: turn_usage.map(|usage| usage.model.clone()), created: now.clone(), updated: now, turn_count: self.context.stats().session_memory_current_turn as usize, @@ -225,6 +233,7 @@ impl Agent { cached_input_tokens, charged_amount_usd, thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(), + task_id: None, }; if let Err(err) = transcript::write_transcript(path, messages, &meta, turn_usage) { diff --git a/src/openhuman/agent/harness/session/turn_engine_adapter.rs b/src/openhuman/agent/harness/session/turn_engine_adapter.rs index 75fdb7f85..5ecf274cf 100644 --- a/src/openhuman/agent/harness/session/turn_engine_adapter.rs +++ b/src/openhuman/agent/harness/session/turn_engine_adapter.rs @@ -306,7 +306,7 @@ impl TurnObserver for AgentObserver<'_> { false } - fn record_usage(&mut self, model: &str, usage: &UsageInfo) { + fn record_usage(&mut self, provider: &str, model: &str, usage: &UsageInfo) { self.agent.context.record_usage(usage); crate::openhuman::cost::record_provider_usage(model, usage); self.cumulative_input += usage.input_tokens; @@ -314,14 +314,19 @@ impl TurnObserver for AgentObserver<'_> { self.cumulative_cached += usage.cached_input_tokens; self.cumulative_charged += usage.charged_amount_usd; self.last_turn_usage = Some(transcript::TurnUsage { + provider: provider.to_string(), model: model.to_string(), usage: transcript::MessageUsage { input: usage.input_tokens, output: usage.output_tokens, cached_input: usage.cached_input_tokens, + context_window: usage.context_window, cost_usd: usage.charged_amount_usd, }, ts: chrono::Utc::now().to_rfc3339(), + reasoning_content: None, + tool_calls: Vec::new(), + iteration: 0, }); } @@ -340,6 +345,19 @@ impl TurnObserver for AgentObserver<'_> { if let Some(rc) = reasoning_content { assistant_msg.extra_metadata = Some(serde_json::json!({ "reasoning_content": rc })); } + let mut turn_usage = None; + if let Some(ref mut usage) = self.last_turn_usage { + usage.reasoning_content = reasoning_content + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + usage.tool_calls = native_tool_calls.to_vec(); + usage.iteration = (iteration + 1) as u32; + turn_usage = Some(usage.clone()); + } + if let Some(turn_usage) = turn_usage.as_ref() { + transcript::attach_turn_usage_metadata(&mut assistant_msg, turn_usage); + } self.agent .history .push(ConversationMessage::Chat(assistant_msg)); @@ -365,6 +383,18 @@ impl TurnObserver for AgentObserver<'_> { &self.pending_results, iteration, ); + if let Some(ref mut usage) = self.last_turn_usage { + usage.reasoning_content = reasoning_content + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + usage.tool_calls = tool_calls.clone(); + usage.iteration = (iteration + 1) as u32; + } + let extra_metadata = self + .last_turn_usage + .as_ref() + .and_then(transcript::turn_usage_extra_metadata); self.agent .history .push(ConversationMessage::AssistantToolCalls { @@ -378,6 +408,7 @@ impl TurnObserver for AgentObserver<'_> { .map(str::trim) .filter(|s| !s.is_empty()) .map(ToString::to_string), + extra_metadata, }); let mut results = std::mem::take(&mut self.pending_results); spill_aggregate_tool_results( diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 5bcda3bba..42efc0ce8 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -417,6 +417,7 @@ fn trim_history_snaps_past_orphaned_tool_results() { extra_content: None, }], reasoning_content: None, + extra_metadata: None, }, // ...orphaning this result at the head of the kept window. ConversationMessage::ToolResults(vec![ToolResultMessage { diff --git a/src/openhuman/agent/harness/subagent_runner/extract_tool.rs b/src/openhuman/agent/harness/subagent_runner/extract_tool.rs index e7f799895..2967d6fbf 100644 --- a/src/openhuman/agent/harness/subagent_runner/extract_tool.rs +++ b/src/openhuman/agent/harness/subagent_runner/extract_tool.rs @@ -520,19 +520,28 @@ fn write_extract_transcript( // the blanks when we wire richer accounting later. let ts_rfc3339 = chrono::Utc::now().to_rfc3339(); let turn_usage = TurnUsage { + provider: "extract_from_result".to_string(), model: model.to_string(), usage: MessageUsage { input: 0, output: 0, cached_input: 0, + context_window: 0, cost_usd: 0.0, }, ts: ts_rfc3339.clone(), + reasoning_content: None, + tool_calls: Vec::new(), + iteration: 1, }; let meta = TranscriptMeta { agent_name: format!("{owner_agent_id}::extract_from_result"), + agent_id: Some(owner_agent_id.to_string()), + agent_type: Some("extractor".to_string()), dispatcher: "native".into(), + provider: Some(turn_usage.provider.clone()), + model: Some(turn_usage.model.clone()), created: ts_rfc3339.clone(), updated: ts_rfc3339, turn_count: 1, @@ -541,6 +550,7 @@ fn write_extract_transcript( cached_input_tokens: 0, charged_amount_usd: 0.0, thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(), + task_id: None, }; if let Err(e) = write_transcript(&path, &messages, &meta, Some(&turn_usage)) { diff --git a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs index 07408bda3..769fd12a5 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs @@ -155,6 +155,7 @@ pub(super) async fn run_inner_loop( task_id: task_id.to_string(), force_text_mode, usage: AggregatedUsage::default(), + last_turn_usage: None, }; let checkpoint = SubagentCheckpoint { provider, diff --git a/src/openhuman/agent/harness/subagent_runner/ops/observer.rs b/src/openhuman/agent/harness/subagent_runner/ops/observer.rs index 432adf0df..f8cf5b016 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/observer.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/observer.rs @@ -18,6 +18,7 @@ pub(super) struct SubagentObserver { pub(super) task_id: String, pub(super) force_text_mode: bool, pub(super) usage: AggregatedUsage, + pub(super) last_turn_usage: Option, } impl SubagentObserver { @@ -70,7 +71,17 @@ impl SubagentObserver { let now = chrono::Utc::now().to_rfc3339(); let meta = transcript::TranscriptMeta { agent_name: self.agent_id.clone(), + agent_id: Some(self.agent_id.clone()), + agent_type: Some("subagent".to_string()), dispatcher: "native".into(), + provider: self + .last_turn_usage + .as_ref() + .map(|usage| usage.provider.clone()), + model: self + .last_turn_usage + .as_ref() + .map(|usage| usage.model.clone()), created: now.clone(), updated: now, turn_count: 1, @@ -79,8 +90,11 @@ impl SubagentObserver { cached_input_tokens: self.usage.cached_input_tokens, charged_amount_usd: self.usage.charged_amount_usd, thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(), + task_id: Some(self.task_id.clone()), }; - if let Err(err) = transcript::write_transcript(&path, history, &meta, None) { + if let Err(err) = + transcript::write_transcript(&path, history, &meta, self.last_turn_usage.as_ref()) + { tracing::debug!( agent_id = %self.agent_id, error = %err, @@ -94,6 +108,7 @@ impl SubagentObserver { impl super::super::super::engine::TurnObserver for SubagentObserver { fn record_usage( &mut self, + provider: &str, _model: &str, usage: &crate::openhuman::inference::provider::UsageInfo, ) { @@ -101,19 +116,42 @@ impl super::super::super::engine::TurnObserver for SubagentObserver { self.usage.output_tokens += usage.output_tokens; self.usage.cached_input_tokens += usage.cached_input_tokens; self.usage.charged_amount_usd += usage.charged_amount_usd; + self.last_turn_usage = Some(transcript::TurnUsage { + provider: provider.to_string(), + model: _model.to_string(), + usage: transcript::MessageUsage { + input: usage.input_tokens, + output: usage.output_tokens, + cached_input: usage.cached_input_tokens, + context_window: usage.context_window, + cost_usd: usage.charged_amount_usd, + }, + ts: chrono::Utc::now().to_rfc3339(), + reasoning_content: None, + tool_calls: Vec::new(), + iteration: 0, + }); } async fn on_assistant( &mut self, _display_text: &str, response_text: &str, - _reasoning_content: Option<&str>, - _native_tool_calls: &[crate::openhuman::inference::provider::ToolCall], + reasoning_content: Option<&str>, + native_tool_calls: &[crate::openhuman::inference::provider::ToolCall], parsed_calls: &[super::super::super::parse::ParsedToolCall], iteration: usize, is_final: bool, ) { let tool_calls = parsed_calls.len(); + if let Some(ref mut usage) = self.last_turn_usage { + usage.reasoning_content = reasoning_content + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + usage.tool_calls = native_tool_calls.to_vec(); + usage.iteration = (iteration + 1) as u32; + } let extra = if is_final { serde_json::json!({ "scope": "worker_thread", diff --git a/src/openhuman/agent/harness/token_budget.rs b/src/openhuman/agent/harness/token_budget.rs index 5c2b9f010..6e671f3fd 100644 --- a/src/openhuman/agent/harness/token_budget.rs +++ b/src/openhuman/agent/harness/token_budget.rs @@ -89,6 +89,7 @@ pub fn estimate_conversation_message_tokens(msg: &ConversationMessage) -> usize text, tool_calls, reasoning_content, + .. } => { let body = text.as_deref().unwrap_or_default(); let mut total = estimate_tokens(body); @@ -579,6 +580,7 @@ mod tests { extra_content: None, }], reasoning_content: None, + extra_metadata: None, }; assert!(estimate_conversation_message_tokens(&msg) > 0); } @@ -639,6 +641,7 @@ mod tests { text: Some("x".repeat(400_000)), // oldest non-system → evicted tool_calls: vec![tool_call("X"), tool_call("Y")], reasoning_content: None, + extra_metadata: None, }, tool_results(&["X"]), tool_results(&["Y"]), @@ -669,6 +672,7 @@ mod tests { text: None, tool_calls: vec![tool_call("A")], reasoning_content: None, + extra_metadata: None, }, tool_results(&["A"]), ConversationMessage::Chat(user_msg("keep")), diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index b10fcfdee..e062eed9c 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -1204,6 +1204,7 @@ fn conversation_message_serialization_roundtrip() { extra_content: None, }], reasoning_content: Some("thinking".into()), + extra_metadata: Some(serde_json::json!({ "source": "unit-test" })), }, ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "tc1".into(), @@ -1227,16 +1228,19 @@ fn conversation_message_serialization_roundtrip() { text: a_text, tool_calls: a_calls, reasoning_content: a_reasoning, + extra_metadata: a_extra, }, ConversationMessage::AssistantToolCalls { text: b_text, tool_calls: b_calls, reasoning_content: b_reasoning, + extra_metadata: b_extra, }, ) => { assert_eq!(a_text, b_text); assert_eq!(a_calls.len(), b_calls.len()); assert_eq!(a_reasoning, b_reasoning); + assert_eq!(a_extra, b_extra); } (ConversationMessage::ToolResults(a), ConversationMessage::ToolResults(b)) => { assert_eq!(a.len(), b.len()); @@ -1331,6 +1335,7 @@ fn xml_dispatcher_converts_history_to_provider_messages() { extra_content: None, }], reasoning_content: None, + extra_metadata: None, }, ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "tc1".into(), @@ -1374,6 +1379,7 @@ fn native_dispatcher_converts_tool_results_to_tool_messages() { }, ], reasoning_content: None, + extra_metadata: None, }, ConversationMessage::ToolResults(vec![ ToolResultMessage { diff --git a/src/openhuman/context/manager_tests.rs b/src/openhuman/context/manager_tests.rs index c8d6aa747..0f802afa4 100644 --- a/src/openhuman/context/manager_tests.rs +++ b/src/openhuman/context/manager_tests.rs @@ -17,6 +17,7 @@ fn call(id: &str) -> ConversationMessage { extra_content: None, }], reasoning_content: None, + extra_metadata: None, } } diff --git a/src/openhuman/context/microcompact.rs b/src/openhuman/context/microcompact.rs index dad448718..553ab6297 100644 --- a/src/openhuman/context/microcompact.rs +++ b/src/openhuman/context/microcompact.rs @@ -118,6 +118,7 @@ mod tests { extra_content: None, }], reasoning_content: None, + extra_metadata: None, } } diff --git a/src/openhuman/context/pipeline.rs b/src/openhuman/context/pipeline.rs index d83b5c758..bcf17c677 100644 --- a/src/openhuman/context/pipeline.rs +++ b/src/openhuman/context/pipeline.rs @@ -272,6 +272,7 @@ mod tests { extra_content: None, }], reasoning_content: None, + extra_metadata: None, } } diff --git a/src/openhuman/context/summarizer_tests.rs b/src/openhuman/context/summarizer_tests.rs index 8b3ddc625..a8facfa60 100644 --- a/src/openhuman/context/summarizer_tests.rs +++ b/src/openhuman/context/summarizer_tests.rs @@ -21,6 +21,7 @@ fn call(id: &str) -> ConversationMessage { extra_content: None, }], reasoning_content: None, + extra_metadata: None, } } @@ -222,6 +223,7 @@ fn transcript_renders_all_message_variants() { extra_content: None, }], reasoning_content: None, + extra_metadata: None, }, result("1", "file.txt"), ]; diff --git a/src/openhuman/inference/provider/traits.rs b/src/openhuman/inference/provider/traits.rs index 1bbc61bef..33c87c7f1 100644 --- a/src/openhuman/inference/provider/traits.rs +++ b/src/openhuman/inference/provider/traits.rs @@ -212,6 +212,8 @@ pub enum ConversationMessage { tool_calls: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] reasoning_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + extra_metadata: Option, }, /// Results of tool executions, fed back to the LLM. ToolResults(Vec), diff --git a/src/openhuman/learning/transcript_ingest/tests.rs b/src/openhuman/learning/transcript_ingest/tests.rs index 836771fca..8099e6b12 100644 --- a/src/openhuman/learning/transcript_ingest/tests.rs +++ b/src/openhuman/learning/transcript_ingest/tests.rs @@ -170,7 +170,11 @@ impl Memory for InMemory { fn fake_meta(thread_id: Option<&str>) -> TranscriptMeta { TranscriptMeta { agent_name: "main".into(), + agent_id: None, + agent_type: None, dispatcher: "native".into(), + provider: None, + model: None, created: "2026-05-09T11:00:00Z".into(), updated: "2026-05-09T12:00:00Z".into(), turn_count: 4, @@ -179,6 +183,7 @@ fn fake_meta(thread_id: Option<&str>) -> TranscriptMeta { cached_input_tokens: 0, charged_amount_usd: 0.0, thread_id: thread_id.map(|s| s.into()), + task_id: None, } } diff --git a/src/openhuman/migrations/mod_tests.rs b/src/openhuman/migrations/mod_tests.rs index 0f7e5a937..f439ab7f4 100644 --- a/src/openhuman/migrations/mod_tests.rs +++ b/src/openhuman/migrations/mod_tests.rs @@ -46,7 +46,11 @@ fn tainted_prompt() -> String { fn meta() -> TranscriptMeta { TranscriptMeta { agent_name: "main".into(), + agent_id: None, + agent_type: None, dispatcher: "native".into(), + provider: None, + model: None, created: "2026-05-01T00:00:00Z".into(), updated: "2026-05-01T00:00:00Z".into(), turn_count: 1, @@ -55,6 +59,7 @@ fn meta() -> TranscriptMeta { cached_input_tokens: 0, charged_amount_usd: 0.0, thread_id: None, + task_id: None, } } diff --git a/src/openhuman/migrations/phase_out_profile_md_tests.rs b/src/openhuman/migrations/phase_out_profile_md_tests.rs index a084cee52..4cbd673b9 100644 --- a/src/openhuman/migrations/phase_out_profile_md_tests.rs +++ b/src/openhuman/migrations/phase_out_profile_md_tests.rs @@ -9,7 +9,11 @@ use tempfile::TempDir; fn meta() -> TranscriptMeta { TranscriptMeta { agent_name: "main".into(), + agent_id: None, + agent_type: None, dispatcher: "native".into(), + provider: None, + model: None, created: "2026-05-01T00:00:00Z".into(), updated: "2026-05-01T00:00:00Z".into(), turn_count: 1, @@ -18,6 +22,7 @@ fn meta() -> TranscriptMeta { cached_input_tokens: 0, charged_amount_usd: 0.0, thread_id: None, + task_id: None, } } diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index d022813da..008bf22ae 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -363,6 +363,10 @@ async fn agent_turn_executes_tools_persists_and_resumes_raw_transcript() -> Resu assert_eq!(files.len(), 1, "expected one root transcript: {files:?}"); let transcript = std::fs::read_to_string(&files[0])?; assert!(transcript.contains("\"agent\":\"coverage_main\"")); + assert!(transcript.contains("\"agent_id\":\"coverage_main\"")); + assert!(transcript.contains("\"agent_type\":\"root\"")); + assert!(transcript.contains("\"provider\":\"coverage-channel\"")); + assert!(transcript.contains("\"model\":\"coverage-model\"")); assert!(transcript.contains("final after echo")); assert!(transcript.contains("\"input_tokens\":252")); assert!(workspace.path().join("sessions").exists()); @@ -523,6 +527,12 @@ async fn repeated_subagent_spawns_keep_cacheable_prefix_and_record_provider_cach "provider-reported cached input tokens from the second child run should be preserved \ in subagent transcript accounting:\n{joined}" ); + assert!( + joined.contains("\"agent_type\":\"subagent\"") + && joined.contains("\"provider\":\"subagent\"") + && joined.contains("\"model\":\"coverage-model\""), + "subagent transcript metadata should retain agent type, provider, and model:\n{joined}" + ); Ok(()) } @@ -674,6 +684,9 @@ inline = "Answer the delegated cache probe directly." .join("\n"); assert!( joined.contains("\"agent\":\"cache_probe_child\"") + && joined.contains("\"agent_id\":\"cache_probe_child\"") + && joined.contains("\"agent_type\":\"subagent\"") + && joined.contains("\"task_id\":\"sub-") && joined.contains(child_answer) && joined.contains("\"cached_input_tokens\":64"), "child transcript should be persisted alongside the parent turn:\n{joined}" diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index c0bee3f7f..ac667dbe4 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -3871,6 +3871,7 @@ fn agent_dispatchers_and_host_runtime_cover_public_edge_paths() { extra_content: None, }], reasoning_content: Some("thinking".into()), + extra_metadata: None, }, ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "call-1".into(), @@ -3885,6 +3886,7 @@ fn agent_dispatchers_and_host_runtime_cover_public_edge_paths() { extra_content: None, }], reasoning_content: None, + extra_metadata: None, }, ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "orphan".into(),