diff --git a/crates/agent/src/compaction.rs b/crates/agent/src/compaction.rs new file mode 100644 index 000000000..40267de86 --- /dev/null +++ b/crates/agent/src/compaction.rs @@ -0,0 +1,336 @@ +use crate::error::AgentError; +use crate::event::EventEmitter; +use crate::file_tracker::FileTracker; +use crate::history::History; +use crate::provider_profile::ProviderProfile; +use crate::types::{AgentEvent, Turn}; +use llm::client::Client; +use llm::types::{Message, Request}; + +/// Check whether the context window usage exceeds the configured threshold. +/// Emits a `ContextWindowWarning` event when over the threshold. +/// Returns `true` if the threshold is exceeded. +pub fn check_context_usage( + system_prompt: &str, + history: &History, + provider_profile: &dyn ProviderProfile, + threshold_percent: usize, + emitter: &EventEmitter, + session_id: &str, +) -> bool { + let estimated_tokens = estimate_token_count(system_prompt, history); + let context_window = provider_profile.context_window_size(); + let threshold = context_window * threshold_percent / 100; + + if estimated_tokens > threshold { + emitter.emit( + session_id.to_owned(), + AgentEvent::ContextWindowWarning { + estimated_tokens, + context_window_size: context_window, + usage_percent: estimated_tokens * 100 / context_window, + }, + ); + true + } else { + false + } +} + +/// Compact the conversation history by summarizing older turns via a non-streaming LLM call. +pub async fn compact_context( + history: &mut History, + llm_client: &Client, + provider_profile: &dyn ProviderProfile, + system_prompt: &str, + file_tracker: &FileTracker, + preserve_count: usize, + emitter: &EventEmitter, + session_id: &str, +) -> Result<(), AgentError> { + let estimated_tokens = estimate_token_count(system_prompt, history); + let context_window = provider_profile.context_window_size(); + let original_turn_count = history.turns().len(); + + emitter.emit( + session_id.to_owned(), + AgentEvent::CompactionStarted { + estimated_tokens, + context_window_size: context_window, + }, + ); + + // Determine turns to summarize + if original_turn_count <= preserve_count { + return Ok(()); + } + let turns_to_summarize = &history.turns()[..original_turn_count - preserve_count]; + let rendered = render_turns_for_summary(turns_to_summarize); + + // Build structured summarization prompt + let file_ops_section = if file_tracker.is_empty() { + String::new() + } else { + format!( + "\n## File Operations\nCOPY THIS SECTION VERBATIM into your summary.\n\n{}", + file_tracker.render() + ) + }; + + let summarization_prompt = format!( + "You are summarizing a coding assistant conversation to provide continuity. A new context \ +window will continue this work with only your summary and the most recent messages.\n\n\ +Write a summary using EXACTLY these sections:\n\n\ +## Goal\nWhat the user asked for and any constraints or preferences stated.\n\n\ +## Progress\nWhat was accomplished, with file paths and key decisions.\n\n\ +## Key Decisions\nImportant choices made and their rationale.\n\n\ +## Failed Approaches\nWhat was tried and didn't work, and why.\n\n\ +## Open Issues\nBugs, edge cases, or TODOs that remain.\n\n\ +## Next Steps\nWhat should happen next to make progress.\n\n\ +Be specific — include file paths, function names, and error messages. Omit pleasantries \ +and conversational filler.{file_ops_section}" + ); + + let summary_request = Request { + model: provider_profile.model().to_string(), + messages: vec![ + Message::system(summarization_prompt), + Message::user(format!( + "Here is the conversation to summarize:\n\n{rendered}" + )), + ], + provider: Some(provider_profile.id().to_string()), + tools: None, + tool_choice: None, + response_format: None, + temperature: Some(0.0), + top_p: None, + max_tokens: Some(4096), + stop_sequences: None, + reasoning_effort: None, + metadata: None, + provider_options: None, + }; + + let response = llm_client + .complete(&summary_request) + .await + .map_err(AgentError::Llm)?; + + let summary_text = response.text(); + let summary_content = format!("[Context Summary]\n{summary_text}"); + let summary_token_estimate = summary_content.len() / 4; + + history.compact(preserve_count, summary_content); + + emitter.emit( + session_id.to_owned(), + AgentEvent::CompactionCompleted { + original_turn_count, + preserved_turn_count: preserve_count, + summary_token_estimate, + tracked_file_count: file_tracker.file_count(), + }, + ); + + Ok(()) +} + +/// Estimate the total token count of the system prompt and conversation history. +/// Uses a rough heuristic of ~4 characters per token. +pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize { + let mut total_chars = system_prompt.len(); + + for turn in history.turns() { + match turn { + Turn::User { content, .. } => total_chars += content.len(), + Turn::Assistant { + content, + tool_calls, + .. + } => { + total_chars += content.len(); + if let Some(r) = turn.reasoning_text() { + total_chars += r.len(); + } + for tc in tool_calls { + total_chars += tc.name.len(); + total_chars += tc.arguments.to_string().len(); + } + } + Turn::ToolResults { results, .. } => { + for r in results { + total_chars += r.content.to_string().len(); + } + } + Turn::System { content, .. } | Turn::Steering { content, .. } => { + total_chars += content.len(); + } + } + } + + total_chars / 4 // rough estimate: ~4 chars per token +} + +/// Render conversation turns into a human-readable summary format for the compaction LLM call. +pub fn render_turns_for_summary(turns: &[Turn]) -> String { + let mut out = String::new(); + for turn in turns { + match turn { + Turn::User { content, .. } => { + out.push_str(&format!("User: {content}\n")); + } + Turn::Assistant { + content, + tool_calls, + .. + } => { + if !content.is_empty() { + out.push_str(&format!("Assistant: {content}\n")); + } + for tc in tool_calls { + let args_str = tc.arguments.to_string(); + let truncated = if args_str.len() > 500 { + format!("{}...", &args_str[..500]) + } else { + args_str + }; + out.push_str(&format!("[Tool call: {}] {truncated}\n", tc.name)); + } + } + Turn::ToolResults { results, .. } => { + for r in results { + let content_str = r.content.to_string(); + let truncated = if content_str.len() > 500 { + format!("{}...", &content_str[..500]) + } else { + content_str + }; + out.push_str(&format!("[Tool result: {}] {truncated}\n", r.tool_call_id)); + } + } + Turn::System { content, .. } => { + out.push_str(&format!("System: {content}\n")); + } + Turn::Steering { content, .. } => { + out.push_str(&format!("Steering: {content}\n")); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::EventEmitter; + use crate::history::History; + use crate::types::Turn; + use llm::types::{ToolCall, ToolResult, Usage}; + use std::time::SystemTime; + + #[test] + fn render_turns_produces_labeled_text() { + let turns = vec![ + Turn::User { + content: "Hello".into(), + timestamp: SystemTime::now(), + }, + Turn::Assistant { + content: "Let me check".into(), + tool_calls: vec![ToolCall::new( + "c1", + "read_file", + serde_json::json!({"path": "foo.rs"}), + )], + provider_parts: vec![], + usage: Usage::default(), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }, + Turn::ToolResults { + results: vec![ToolResult { + tool_call_id: "c1".into(), + content: serde_json::json!("file contents here"), + is_error: false, + image_data: None, + image_media_type: None, + }], + timestamp: SystemTime::now(), + }, + ]; + let rendered = render_turns_for_summary(&turns); + assert!(rendered.contains("User:")); + assert!(rendered.contains("Hello")); + assert!(rendered.contains("Assistant:")); + assert!(rendered.contains("Let me check")); + assert!(rendered.contains("[Tool call: read_file]")); + assert!(rendered.contains("[Tool result: c1]")); + } + + #[test] + fn render_turns_truncates_long_tool_output() { + let long_output = "x".repeat(1000); + let turns = vec![Turn::ToolResults { + results: vec![ToolResult { + tool_call_id: "c1".into(), + content: serde_json::json!(long_output), + is_error: false, + image_data: None, + image_media_type: None, + }], + timestamp: SystemTime::now(), + }]; + let rendered = render_turns_for_summary(&turns); + // Should be truncated to 500 chars + "..." + assert!(rendered.len() < 1000); + assert!(rendered.contains("...")); + } + + #[test] + fn estimate_token_count_basic() { + let mut history = History::default(); + history.push(Turn::User { + content: "Hello world".into(), // 11 chars + timestamp: SystemTime::now(), + }); + // system_prompt = "test" (4 chars) + 11 chars = 15 chars / 4 = 3 tokens + assert_eq!(estimate_token_count("test", &history), 3); + } + + #[test] + fn check_context_usage_below_threshold() { + let history = History::default(); + let emitter = EventEmitter::new(); + let profile = crate::test_support::TestProfile::new(); + // Empty history, huge context window => well below threshold + let over = check_context_usage("short", &history, &profile, 80, &emitter, "sess"); + assert!(!over); + } + + #[test] + fn check_context_usage_above_threshold() { + let mut history = History::default(); + // Push enough content to exceed a tiny context window + history.push(Turn::User { + content: "x".repeat(1000), + timestamp: SystemTime::now(), + }); + let emitter = EventEmitter::new(); + let mut rx = emitter.subscribe(); + // TestProfile has context_window=200_000 by default; use a small one + let profile = crate::test_support::TestProfile::parallel_with_context_window( + crate::tool_registry::ToolRegistry::new(), + 100, + ); + let over = check_context_usage("prompt", &history, &profile, 80, &emitter, "sess"); + assert!(over); + + // Should have emitted a ContextWindowWarning + let event = rx.try_recv().unwrap(); + assert!(matches!( + event.event, + AgentEvent::ContextWindowWarning { .. } + )); + } +} diff --git a/crates/agent/src/history.rs b/crates/agent/src/history.rs index 36e197024..b5af57e6e 100644 --- a/crates/agent/src/history.rs +++ b/crates/agent/src/history.rs @@ -38,7 +38,6 @@ impl History { Turn::Assistant { content, tool_calls, - reasoning, provider_parts, .. } => { @@ -47,22 +46,6 @@ impl History { // Anthropic thinking blocks with signatures) must precede // function calls for correct round-tripping. parts.extend(provider_parts.iter().cloned()); - // Only reconstruct thinking from plain text if provider_parts - // doesn't already contain thinking blocks (which preserve signatures). - let has_thinking_parts = provider_parts - .iter() - .any(|p| matches!(p, ContentPart::Thinking(_))); - if !has_thinking_parts { - if let Some(reasoning_text) = reasoning { - parts.push(ContentPart::Thinking( - llm::types::ThinkingData { - text: reasoning_text.clone(), - signature: None, - redacted: false, - }, - )); - } - } if !content.is_empty() { parts.push(ContentPart::text(content)); } @@ -193,7 +176,6 @@ mod tests { history.push(Turn::Assistant { content: "Hi there".into(), tool_calls: vec![], - reasoning: None, provider_parts: vec![], usage: Usage::default(), response_id: "resp_1".into(), @@ -212,7 +194,6 @@ mod tests { history.push(Turn::Assistant { content: "Let me read that".into(), tool_calls: vec![tc], - reasoning: None, provider_parts: vec![], usage: Usage::default(), response_id: "resp_2".into(), @@ -229,13 +210,17 @@ mod tests { } #[test] - fn assistant_turn_with_reasoning() { + fn assistant_turn_with_reasoning_in_provider_parts() { let mut history = History::default(); + let thinking = ContentPart::Thinking(llm::types::ThinkingData { + text: "Let me think about this...".into(), + signature: None, + redacted: false, + }); history.push(Turn::Assistant { content: "The answer is 42".into(), tool_calls: vec![], - reasoning: Some("Let me think about this...".into()), - provider_parts: vec![], + provider_parts: vec![thinking], usage: Usage::default(), response_id: "resp_3".into(), timestamp: SystemTime::now(), @@ -260,7 +245,6 @@ mod tests { history.push(Turn::Assistant { content: "The answer".into(), tool_calls: vec![], - reasoning: Some("Let me think...".into()), provider_parts: vec![thinking], usage: Usage::default(), response_id: "resp_4".into(), @@ -292,7 +276,6 @@ mod tests { history.push(Turn::Assistant { content: String::new(), tool_calls: vec![tc], - reasoning: None, provider_parts: vec![reasoning_item], usage: Usage::default(), response_id: "resp_1".into(), @@ -357,7 +340,6 @@ mod tests { history.push(Turn::Assistant { content: "Second".into(), tool_calls: vec![], - reasoning: None, provider_parts: vec![], usage: Usage::default(), response_id: "resp_1".into(), @@ -380,8 +362,11 @@ mod tests { "shell", serde_json::json!({"cmd": "ls"}), )], - reasoning: Some("thinking...".into()), - provider_parts: vec![], + provider_parts: vec![ContentPart::Thinking(llm::types::ThinkingData { + text: "thinking...".into(), + signature: None, + redacted: false, + })], usage: Usage { input_tokens: 10, output_tokens: 5, diff --git a/crates/agent/src/loop_detection.rs b/crates/agent/src/loop_detection.rs index 7aba47efe..63eb66fd4 100644 --- a/crates/agent/src/loop_detection.rs +++ b/crates/agent/src/loop_detection.rs @@ -101,7 +101,6 @@ mod tests { Turn::Assistant { content: String::new(), tool_calls: vec![ToolCall::new("call_1", name, args)], - reasoning: None, provider_parts: vec![], usage: Usage::default(), response_id: "resp".into(), diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index 9e775be82..db83c1f89 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -1,4 +1,4 @@ -use crate::config::{SessionConfig, ToolApprovalFn}; +use crate::config::SessionConfig; use crate::error::AgentError; use crate::event::EventEmitter; use crate::execution_env::ExecutionEnvironment; @@ -463,7 +463,6 @@ impl Session { // Record assistant turn let text = response.text(); let tool_calls = response.tool_calls(); - let reasoning = response.reasoning(); let provider_parts: Vec<_> = response .message .content @@ -482,7 +481,6 @@ impl Session { self.history.push(Turn::Assistant { content: text.clone(), tool_calls: tool_calls.clone(), - reasoning, provider_parts, usage, response_id: response.id.clone(), @@ -501,9 +499,25 @@ impl Session { ); // Check context window usage and compact if needed - let over_threshold = self.check_context_usage(); + let over_threshold = crate::compaction::check_context_usage( + &self.system_prompt, + &self.history, + self.provider_profile.as_ref(), + self.config.compaction_threshold_percent, + &self.event_emitter, + &self.id, + ); if over_threshold && self.config.enable_context_compaction { - if let Err(e) = self.compact_context().await { + if let Err(e) = crate::compaction::compact_context( + &mut self.history, + &self.llm_client, + self.provider_profile.as_ref(), + &self.system_prompt, + &self.file_tracker, + self.config.compaction_preserve_turns, + &self.event_emitter, + &self.id, + ).await { self.event_emitter.emit( self.id.clone(), AgentEvent::Error { @@ -521,7 +535,18 @@ impl Session { round_count += 1; // Execute tool calls (parallel or sequential based on provider) - let results = self.execute_tool_calls(&tool_calls).await; + let results = crate::tool_execution::execute_tool_calls( + &tool_calls, + self.provider_profile.supports_parallel_tool_calls(), + self.provider_profile.tool_registry(), + self.execution_env.clone(), + self.config.tool_approval.as_ref(), + &self.cancel_token, + &self.config, + &self.event_emitter, + &self.id, + ) + .await; // Track file operations from tool calls self.file_tracker.record_from_tool_calls(&tool_calls, &results); @@ -561,90 +586,6 @@ impl Session { Ok(()) } - async fn compact_context(&mut self) -> Result<(), AgentError> { - let estimated_tokens = self.estimate_token_count(); - let context_window = self.provider_profile.context_window_size(); - let original_turn_count = self.history.turns().len(); - - self.event_emitter.emit( - self.id.clone(), - AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size: context_window, - }, - ); - - let preserve_count = self.config.compaction_preserve_turns; - - // Determine turns to summarize - if original_turn_count <= preserve_count { - return Ok(()); - } - let turns_to_summarize = &self.history.turns()[..original_turn_count - preserve_count]; - let rendered = render_turns_for_summary(turns_to_summarize); - - // Build structured summarization prompt - let file_ops_section = if self.file_tracker.is_empty() { - String::new() - } else { - format!("\n## File Operations\nCOPY THIS SECTION VERBATIM into your summary.\n\n{}", self.file_tracker.render()) - }; - - let system_prompt = format!( - "You are summarizing a coding assistant conversation to provide continuity. A new context \ -window will continue this work with only your summary and the most recent messages.\n\n\ -Write a summary using EXACTLY these sections:\n\n\ -## Goal\nWhat the user asked for and any constraints or preferences stated.\n\n\ -## Progress\nWhat was accomplished, with file paths and key decisions.\n\n\ -## Key Decisions\nImportant choices made and their rationale.\n\n\ -## Failed Approaches\nWhat was tried and didn't work, and why.\n\n\ -## Open Issues\nBugs, edge cases, or TODOs that remain.\n\n\ -## Next Steps\nWhat should happen next to make progress.\n\n\ -Be specific — include file paths, function names, and error messages. Omit pleasantries \ -and conversational filler.{file_ops_section}" - ); - - let summary_request = Request { - model: self.provider_profile.model().to_string(), - messages: vec![ - Message::system(system_prompt), - Message::user(format!("Here is the conversation to summarize:\n\n{rendered}")), - ], - provider: Some(self.provider_profile.id().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(4096), - stop_sequences: None, - reasoning_effort: None, - metadata: None, - provider_options: None, - }; - - let response = self.llm_client.complete(&summary_request).await - .map_err(AgentError::Llm)?; - - let summary_text = response.text(); - let summary_content = format!("[Context Summary]\n{summary_text}"); - let summary_token_estimate = summary_content.len() / 4; - - self.history.compact(preserve_count, summary_content); - - self.event_emitter.emit( - self.id.clone(), - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count: preserve_count, - summary_token_estimate, - tracked_file_count: self.file_tracker.file_count(), - }, - ); - - Ok(()) - } - fn drain_steering(&mut self) { let messages: Vec = self .steering_queue @@ -692,246 +633,6 @@ and conversational filler.{file_ops_section}" } } - async fn execute_tool_calls( - &mut self, - tool_calls: &[llm::types::ToolCall], - ) -> Vec { - if self.provider_profile.supports_parallel_tool_calls() && tool_calls.len() > 1 { - self.execute_tool_calls_parallel(tool_calls).await - } else { - self.execute_tool_calls_sequential(tool_calls).await - } - } - - async fn execute_tool_calls_sequential( - &self, - tool_calls: &[llm::types::ToolCall], - ) -> Vec { - let mut results = Vec::new(); - for tc in tool_calls { - if self.cancel_token.is_cancelled() { - results.push(ToolResult::error(tc.id.clone(), "Cancelled")); - continue; - } - - let result = execute_and_emit_one_tool( - tc, - self.provider_profile.tool_registry(), - self.execution_env.clone(), - self.config.tool_approval.as_ref(), - self.cancel_token.child_token(), - &self.config, - &self.event_emitter, - &self.id, - ) - .await; - results.push(result); - } - results - } - - async fn execute_tool_calls_parallel( - &self, - tool_calls: &[llm::types::ToolCall], - ) -> Vec { - let emitter = self.event_emitter.clone(); - let env = self.execution_env.clone(); - let profile = self.provider_profile.clone(); - let session_id = self.id.clone(); - let config = self.config.clone(); - let cancel_token = self.cancel_token.clone(); - - let futures: Vec<_> = tool_calls - .iter() - .map(|tc| { - let emitter = emitter.clone(); - let env = env.clone(); - let profile = profile.clone(); - let session_id = session_id.clone(); - let config = config.clone(); - let cancel_token = cancel_token.clone(); - let tc = tc.clone(); - async move { - execute_and_emit_one_tool( - &tc, - profile.tool_registry(), - env, - config.tool_approval.as_ref(), - cancel_token.child_token(), - &config, - &emitter, - &session_id, - ) - .await - } - }) - .collect(); - - futures::future::join_all(futures).await - } - - fn estimate_token_count(&self) -> usize { - let mut total_chars = self.system_prompt.len(); - - for turn in self.history.turns() { - match turn { - Turn::User { content, .. } => total_chars += content.len(), - Turn::Assistant { - content, - tool_calls, - reasoning, - .. - } => { - total_chars += content.len(); - if let Some(r) = reasoning { - total_chars += r.len(); - } - for tc in tool_calls { - total_chars += tc.name.len(); - total_chars += tc.arguments.to_string().len(); - } - } - Turn::ToolResults { results, .. } => { - for r in results { - total_chars += r.content.to_string().len(); - } - } - Turn::System { content, .. } | Turn::Steering { content, .. } => { - total_chars += content.len(); - } - } - } - - total_chars / 4 // rough estimate: ~4 chars per token - } - - fn check_context_usage(&self) -> bool { - let estimated_tokens = self.estimate_token_count(); - let context_window = self.provider_profile.context_window_size(); - let threshold = context_window * self.config.compaction_threshold_percent / 100; - - if estimated_tokens > threshold { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::ContextWindowWarning { - estimated_tokens, - context_window_size: context_window, - usage_percent: estimated_tokens * 100 / context_window, - }, - ); - true - } else { - false - } - } -} - -/// Execute a single tool call with event emission and output truncation. -/// Shared by both sequential and parallel execution paths. -#[allow(clippy::too_many_arguments)] -async fn execute_and_emit_one_tool( - tc: &llm::types::ToolCall, - registry: &ToolRegistry, - env: Arc, - tool_approval: Option<&ToolApprovalFn>, - cancel_token: CancellationToken, - config: &SessionConfig, - emitter: &EventEmitter, - session_id: &str, -) -> ToolResult { - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallStarted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - arguments: tc.arguments.clone(), - }, - ); - - let result = execute_one_tool( - &tc.id, - &tc.name, - &tc.arguments, - registry, - env, - tool_approval, - cancel_token, - ) - .await; - - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallOutputDelta { - delta: result.content.to_string(), - }, - ); - - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallCompleted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - output: result.content.clone(), - is_error: result.is_error, - }, - ); - - truncate_tool_result(&result, &tc.name, config) -} - -/// Execute a single tool call: registry lookup, argument validation, and execution. -async fn execute_one_tool( - tool_call_id: &str, - tool_name: &str, - arguments: &serde_json::Value, - registry: &ToolRegistry, - env: Arc, - tool_approval: Option<&ToolApprovalFn>, - cancel_token: CancellationToken, -) -> ToolResult { - if let Some(approval_fn) = tool_approval { - if let Err(denial_message) = approval_fn(tool_name, arguments) { - return ToolResult::error(tool_call_id, denial_message); - } - } - - match registry.get(tool_name) { - Some(registered_tool) => { - if let Err(validation_error) = - validate_tool_args(®istered_tool.definition.parameters, arguments) - { - return ToolResult::error(tool_call_id, validation_error); - } - - match (registered_tool.executor)(arguments.clone(), env, cancel_token).await { - Ok(output) => ToolResult::success(tool_call_id, serde_json::json!(output)), - Err(err) => ToolResult::error(tool_call_id, err), - } - } - None => ToolResult::error(tool_call_id, format!("Unknown tool: {tool_name}")), - } -} - -/// Truncate tool output for history storage while preserving identity fields. -fn truncate_tool_result( - result: &ToolResult, - tool_name: &str, - config: &SessionConfig, -) -> ToolResult { - let truncated_content = match &result.content { - serde_json::Value::String(s) => { - serde_json::json!(truncate_tool_output(s, tool_name, config)) - } - other => other.clone(), - }; - - ToolResult { - tool_call_id: result.tool_call_id.clone(), - content: truncated_content, - is_error: result.is_error, - image_data: result.image_data.clone(), - image_media_type: result.image_media_type.clone(), - } } const fn is_auth_error(err: &SdkError) -> bool { @@ -941,79 +642,6 @@ const fn is_auth_error(err: &SdkError) -> bool { ) } -fn render_turns_for_summary(turns: &[Turn]) -> String { - let mut out = String::new(); - for turn in turns { - match turn { - Turn::User { content, .. } => { - out.push_str(&format!("User: {content}\n")); - } - Turn::Assistant { - content, - tool_calls, - .. - } => { - if !content.is_empty() { - out.push_str(&format!("Assistant: {content}\n")); - } - for tc in tool_calls { - let args_str = tc.arguments.to_string(); - let truncated = if args_str.len() > 500 { - format!("{}...", &args_str[..500]) - } else { - args_str - }; - out.push_str(&format!("[Tool call: {}] {truncated}\n", tc.name)); - } - } - Turn::ToolResults { results, .. } => { - for r in results { - let content_str = r.content.to_string(); - let truncated = if content_str.len() > 500 { - format!("{}...", &content_str[..500]) - } else { - content_str - }; - out.push_str(&format!("[Tool result: {}] {truncated}\n", r.tool_call_id)); - } - } - Turn::System { content, .. } => { - out.push_str(&format!("System: {content}\n")); - } - Turn::Steering { content, .. } => { - out.push_str(&format!("Steering: {content}\n")); - } - } - } - out -} - -fn validate_tool_args(schema: &serde_json::Value, args: &serde_json::Value) -> Result<(), String> { - // Skip validation for empty/trivial schemas - if schema.is_null() { - return Ok(()); - } - if let Some(obj) = schema.as_object() { - if obj.is_empty() { - return Ok(()); - } - } - - let validator = jsonschema::validator_for(schema) - .map_err(|e| format!("Invalid tool schema: {e}"))?; - - let errors: Vec = validator.iter_errors(args).map(|e| e.to_string()).collect(); - - if errors.is_empty() { - Ok(()) - } else { - Err(format!( - "Tool argument validation failed: {}", - errors.join("; ") - )) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1604,89 +1232,6 @@ mod tests { assert!(!found_warning); } - #[test] - fn render_turns_produces_labeled_text() { - use llm::types::{ToolCall, ToolResult, Usage}; - - let turns = vec![ - Turn::User { - content: "Hello".into(), - timestamp: SystemTime::now(), - }, - Turn::Assistant { - content: "Let me check".into(), - tool_calls: vec![ToolCall::new("c1", "read_file", serde_json::json!({"path": "foo.rs"}))], - reasoning: None, - provider_parts: vec![], - usage: Usage::default(), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), - }, - Turn::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!("file contents here"), - is_error: false, - image_data: None, - image_media_type: None, - }], - timestamp: SystemTime::now(), - }, - ]; - let rendered = render_turns_for_summary(&turns); - assert!(rendered.contains("User:")); - assert!(rendered.contains("Hello")); - assert!(rendered.contains("Assistant:")); - assert!(rendered.contains("Let me check")); - assert!(rendered.contains("[Tool call: read_file]")); - assert!(rendered.contains("[Tool result: c1]")); - } - - #[test] - fn render_turns_truncates_long_tool_output() { - use llm::types::ToolResult; - - let long_output = "x".repeat(1000); - let turns = vec![Turn::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!(long_output), - is_error: false, - image_data: None, - image_media_type: None, - }], - timestamp: SystemTime::now(), - }]; - let rendered = render_turns_for_summary(&turns); - // Should be truncated to 500 chars + "..." - assert!(rendered.len() < 1000); - assert!(rendered.contains("...")); - } - - #[tokio::test] - async fn check_context_usage_returns_true_over_threshold() { - let large_input = "x".repeat(400); - let responses = vec![text_response("OK")]; - - let provider = Arc::new(MockLlmProvider::new(responses)); - let client = make_client(provider).await; - let registry = ToolRegistry::new(); - let profile = Arc::new(TestProfile::parallel_with_context_window(registry, 100)); - let env = Arc::new(MockExecutionEnvironment::default()); - let config = SessionConfig { - enable_context_compaction: false, // disable compaction to isolate check - ..Default::default() - }; - let mut session = Session::new(client, profile, env, config); - - session.system_prompt = "You are a test assistant.".to_string(); - - // Push a user turn to populate history - session.process_input(&large_input).await.unwrap(); - - assert!(session.check_context_usage()); - } - #[tokio::test] async fn invalid_tool_args_returns_validation_error() { let mut registry = ToolRegistry::new(); diff --git a/crates/agent/src/types.rs b/crates/agent/src/types.rs index df7cd76a4..fed41482a 100644 --- a/crates/agent/src/types.rs +++ b/crates/agent/src/types.rs @@ -1,5 +1,5 @@ use std::time::SystemTime; -use llm::types::{ContentPart, ToolCall, ToolResult, Usage}; +use llm::types::{ContentPart, ThinkingData, ToolCall, ToolResult, Usage}; use serde::{Deserialize, Serialize}; mod system_time_iso8601 { @@ -34,9 +34,9 @@ pub enum Turn { Assistant { content: String, tool_calls: Vec, - reasoning: Option, - /// Opaque provider-specific content parts (e.g. `OpenAI` reasoning items) - /// that must be preserved for round-tripping but don't map to standard fields. + /// Provider-specific content parts (e.g. `OpenAI` reasoning items, + /// `Anthropic` thinking blocks with signatures) preserved for round-tripping. + /// Reasoning/thinking text is stored here as `ContentPart::Thinking`. provider_parts: Vec, usage: Usage, response_id: String, @@ -59,6 +59,23 @@ pub enum Turn { }, } +impl Turn { + /// Extract the first non-redacted thinking/reasoning text from an `Assistant` turn's + /// `provider_parts`, if any. + #[must_use] + pub fn reasoning_text(&self) -> Option<&str> { + let Turn::Assistant { provider_parts, .. } = self else { + return None; + }; + provider_parts.iter().find_map(|p| match p { + ContentPart::Thinking(ThinkingData { + text, redacted: false, .. + }) => Some(text.as_str()), + _ => None, + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionState { Idle,