mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
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:
parent
5c74f88814
commit
b80f9cb100
5 changed files with 400 additions and 518 deletions
336
crates/agent/src/compaction.rs
Normal file
336
crates/agent/src/compaction.rs
Normal 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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(®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<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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue