Unify reasoning storage in Turn::Assistant

Remove the standalone `reasoning: Option<String>` field from
Turn::Assistant. Reasoning/thinking text is now stored exclusively
in `provider_parts` as `ContentPart::Thinking` blocks, eliminating
the dual-storage reconciliation logic in `convert_to_messages`.

Add `Turn::reasoning_text() -> Option<&str>` accessor that extracts
the first non-redacted thinking text from provider_parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-27 23:43:38 -05:00
parent 5c74f88814
commit b80f9cb100
5 changed files with 400 additions and 518 deletions

View file

@ -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 { .. }
));
}
}

View file

@ -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,

View file

@ -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(),

View file

@ -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<String> = 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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<dyn ExecutionEnvironment>,
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<dyn ExecutionEnvironment>,
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(&registered_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<String> = 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();

View file

@ -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<ToolCall>,
reasoning: Option<String>,
/// 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<ContentPart>,
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,