diff --git a/crates/tinyagents-session/src/lib.rs b/crates/tinyagents-session/src/lib.rs index dbb77368..4a3b6e8f 100644 --- a/crates/tinyagents-session/src/lib.rs +++ b/crates/tinyagents-session/src/lib.rs @@ -79,8 +79,9 @@ pub use tinyagents_harness::error::{Result, TinyAgentsError}; pub use ops::{ DEFAULT_FTS_SNIPPET_BYTES, fts_snippet_bytes, get_session, list_children, list_messages, - list_sessions, list_tool_calls, mark_interrupted, record_message, record_session_end, - record_session_start, record_tool_call, search_sessions, set_fts_snippet_bytes, + list_sessions, list_tool_calls, mark_interrupted, record_message, + record_message_with_reasoning, record_session_end, record_session_start, record_tool_call, + search_sessions, set_fts_snippet_bytes, }; pub use retention::{ RetentionReport, apply_retention, prune_run_events_before, prune_run_telemetry_before, diff --git a/crates/tinyagents-session/src/migrations.rs b/crates/tinyagents-session/src/migrations.rs index a5d5a928..adb93a82 100644 --- a/crates/tinyagents-session/src/migrations.rs +++ b/crates/tinyagents-session/src/migrations.rs @@ -241,6 +241,8 @@ pub(super) const MIGRATIONS: &[&str] = &[ CREATE INDEX IF NOT EXISTS idx_agent_teams_updated ON agent_teams(updated_at); CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_order ON agent_team_tasks(team_id, order_index, created_at);", + // ---- 4: retain hidden assistant reasoning --------------------------- + "ALTER TABLE session_messages ADD COLUMN reasoning_content TEXT;", ]; /// Applies every migration newer than the database's recorded schema version. @@ -325,7 +327,7 @@ mod test { fn migration_list_is_append_only() { assert_eq!( MIGRATIONS.len(), - 4, + 5, "MIGRATIONS is append-only — adding one is fine, reordering or \ deleting one silently re-numbers every later migration" ); diff --git a/crates/tinyagents-session/src/ops.rs b/crates/tinyagents-session/src/ops.rs index a7832ae5..10e894fb 100644 --- a/crates/tinyagents-session/src/ops.rs +++ b/crates/tinyagents-session/src/ops.rs @@ -133,6 +133,36 @@ pub fn record_message( input_tokens: Option, output_tokens: Option, cost_usd: Option, +) -> Result { + record_message_with_reasoning( + workspace_dir, + session_id, + role, + content, + None, + model, + input_tokens, + output_tokens, + cost_usd, + ) +} + +/// Record a session message together with provider-exposed hidden reasoning. +/// +/// The historical [`record_message`] API deliberately remains visible-text +/// only. Hosts that retain a structured assistant message should use this +/// variant so a tool-call turn does not discard its thinking trace. +#[allow(clippy::too_many_arguments)] +pub fn record_message_with_reasoning( + workspace_dir: &Path, + session_id: &str, + role: &str, + content: &str, + reasoning_content: Option<&str>, + model: Option<&str>, + input_tokens: Option, + output_tokens: Option, + cost_usd: Option, ) -> Result { let now = Utc::now(); tinyagents_tracing::trace!( @@ -149,13 +179,14 @@ pub fn record_message( with_transaction(workspace_dir, |conn| { conn.execute( "INSERT INTO session_messages ( - session_id, role, content, model, + session_id, role, content, reasoning_content, model, input_tokens, output_tokens, cost_usd, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ session_id, role, content, + reasoning_content.filter(|value| !value.trim().is_empty()), model, input_tokens.map(|v| v as i64), output_tokens.map(|v| v as i64), @@ -456,7 +487,7 @@ pub fn list_messages( with_connection(workspace_dir, |conn| { let lim = limit.unwrap_or(200).min(1000) as i64; let mut stmt = conn.prepare( - "SELECT id, session_id, role, content, model, + "SELECT id, session_id, role, content, reasoning_content, model, input_tokens, output_tokens, cost_usd, created_at FROM session_messages WHERE session_id = ?1 @@ -470,11 +501,12 @@ pub fn list_messages( session_id: row.get(1)?, role: row.get(2)?, content: row.get(3)?, - model: row.get(4)?, - input_tokens: row.get::<_, Option>(5)?.map(|v| v as u64), - output_tokens: row.get::<_, Option>(6)?.map(|v| v as u64), - cost_usd: row.get(7)?, - created_at: parse_rfc3339(&row.get::<_, String>(8)?) + reasoning_content: row.get(4)?, + model: row.get(5)?, + input_tokens: row.get::<_, Option>(6)?.map(|v| v as u64), + output_tokens: row.get::<_, Option>(7)?.map(|v| v as u64), + cost_usd: row.get(8)?, + created_at: parse_rfc3339(&row.get::<_, String>(9)?) .map_err(sql_conversion_error)?, }) })?; diff --git a/crates/tinyagents-session/src/test.rs b/crates/tinyagents-session/src/test.rs index d0a262b3..f4f984f3 100644 --- a/crates/tinyagents-session/src/test.rs +++ b/crates/tinyagents-session/src/test.rs @@ -554,6 +554,46 @@ fn public_session_operations_round_trip_every_record_kind() { assert!(ended.ended_at.is_some()); } +#[test] +fn assistant_reasoning_round_trips_separately_from_visible_content() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + record_session_start( + workspace, + "reasoning-session", + "agent", + "Agent", + "reasoning-session", + None, + None, + None, + Some("reasoning-model"), + None, + ) + .unwrap(); + + record_message_with_reasoning( + workspace, + "reasoning-session", + "assistant", + "I will inspect the repository.", + Some("First identify the relevant files."), + Some("reasoning-model"), + None, + None, + None, + ) + .unwrap(); + + let messages = list_messages(workspace, "reasoning-session", None).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "I will inspect the repository."); + assert_eq!( + messages[0].reasoning_content.as_deref(), + Some("First identify the relevant files.") + ); +} + #[test] fn public_listing_filters_and_caps_results() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tinyagents-session/src/types.rs b/crates/tinyagents-session/src/types.rs index d1fa9600..900fb8cd 100644 --- a/crates/tinyagents-session/src/types.rs +++ b/crates/tinyagents-session/src/types.rs @@ -57,6 +57,10 @@ pub struct SessionMessage { pub session_id: String, pub role: String, pub content: String, + /// Hidden model reasoning, when the provider exposed it. Kept separate + /// from visible `content` so search/UI consumers never mistake it for an + /// assistant reply. + pub reasoning_content: Option, pub model: Option, pub input_tokens: Option, pub output_tokens: Option,