feat: 添加字符计数和文本截断功能,增强文本处理能力

This commit is contained in:
ooodc 2026-04-24 14:34:06 +08:00
parent e6f23858b8
commit 4b74fabb98
12 changed files with 321 additions and 43 deletions

View File

@ -10,15 +10,13 @@ use crate::providers::{create_provider, LLMProvider, ChatCompletionRequest, Mess
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
use crate::text::{char_count, take_prefix_chars, take_suffix_chars};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::io::Read; use std::io::Read;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
/// Maximum characters in a tool result before truncation.
/// Prevents context overflow from large tool outputs.
const MAX_TOOL_RESULT_CHARS: usize = 16_000;
/// Minimum characters to keep when truncating /// Minimum characters to keep when truncating
const TRUNCATION_SUFFIX_LEN: usize = 200; const TRUNCATION_SUFFIX_LEN: usize = 200;
const MEMORY_TOOL_USAGE_SYSTEM_PROMPT: &str = const MEMORY_TOOL_USAGE_SYSTEM_PROMPT: &str =
@ -94,27 +92,27 @@ fn encode_image_to_base64(path: &str) -> Result<(String, String), std::io::Error
Ok((mime, encoded)) Ok((mime, encoded))
} }
/// Truncate tool result if it exceeds MAX_TOOL_RESULT_CHARS. /// Truncate tool result if it exceeds the configured limit.
/// Preserves the end of the output as it often contains the conclusion/useful result. /// Preserves the end of the output as it often contains the conclusion/useful result.
fn truncate_tool_result(output: &str) -> String { fn truncate_tool_result(output: &str, max_tool_result_chars: usize) -> String {
let char_count = output.chars().count(); let total_chars = char_count(output);
if char_count <= MAX_TOOL_RESULT_CHARS { if total_chars <= max_tool_result_chars {
return output.to_string(); return output.to_string();
} }
let truncated_start_len = char_count.saturating_sub(TRUNCATION_SUFFIX_LEN); let truncated_start_len = total_chars.saturating_sub(TRUNCATION_SUFFIX_LEN);
if truncated_start_len > MAX_TOOL_RESULT_CHARS { if truncated_start_len > max_tool_result_chars {
// Even after removing suffix, still too long - take from beginning // Even after removing suffix, still too long - take from beginning
let head_len = MAX_TOOL_RESULT_CHARS - 100; let head_len = max_tool_result_chars.saturating_sub(100);
let head: String = output.chars().take(head_len).collect(); let head = take_prefix_chars(output, head_len);
format!( format!(
"{}...\n\n[Output truncated - {} characters removed]", "{}...\n\n[Output truncated - {} characters removed]",
head, head,
char_count - MAX_TOOL_RESULT_CHARS + 100 total_chars - max_tool_result_chars + 100
) )
} else { } else {
// Keep most of the end which usually contains the useful result // Keep most of the end which usually contains the useful result
let tail: String = output.chars().skip(truncated_start_len).collect(); let tail = take_suffix_chars(output, total_chars.saturating_sub(truncated_start_len));
format!( format!(
"...\n\n[Output truncated - {} characters removed]\n\n{}", "...\n\n[Output truncated - {} characters removed]\n\n{}",
truncated_start_len, truncated_start_len,
@ -486,7 +484,10 @@ impl AgentLoop {
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool"); tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
// Truncate tool result if too large // Truncate tool result if too large
let truncated_output = truncate_tool_result(&result.output); let truncated_output = truncate_tool_result(
&result.output,
self.provider_config.tool_result_max_chars,
);
// Record tool call and check for loops // Record tool call and check for loops
let loop_result = loop_detector.record(&tool_call.name, &tool_call.arguments); let loop_result = loop_detector.record(&tool_call.name, &tool_call.arguments);
@ -894,9 +895,9 @@ mod tests {
#[test] #[test]
fn test_truncate_tool_result_handles_utf8_char_boundaries() { fn test_truncate_tool_result_handles_utf8_char_boundaries() {
let input = "".repeat(MAX_TOOL_RESULT_CHARS + 500); let input = "".repeat(20_500);
let output = truncate_tool_result(&input); let output = truncate_tool_result(&input, 20_000);
assert!(output.contains("Output truncated")); assert!(output.contains("Output truncated"));
assert!(output.is_char_boundary(output.len())); assert!(output.is_char_boundary(output.len()));

View File

@ -1,6 +1,7 @@
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::providers::{create_provider, ChatCompletionRequest, Message}; use crate::providers::{create_provider, ChatCompletionRequest, Message};
use crate::text::{char_count, take_prefix_chars};
use crate::agent::AgentError; use crate::agent::AgentError;
@ -34,8 +35,8 @@ impl Default for ContextCompressionConfig {
protect_first_n: 1, protect_first_n: 1,
protect_last_n: 4, protect_last_n: 4,
max_passes: 3, max_passes: 3,
summary_max_chars: 4000, summary_max_chars: 20_000,
tool_result_trim_chars: 2000, tool_result_trim_chars: 20_000,
} }
} }
} }
@ -59,6 +60,17 @@ impl ContextCompressor {
} }
} }
pub fn from_provider_config(provider_config: &LLMProviderConfig) -> Self {
Self::with_config(
provider_config.token_limit,
ContextCompressionConfig {
summary_max_chars: provider_config.context_summary_max_chars,
tool_result_trim_chars: provider_config.context_tool_result_trim_chars,
..ContextCompressionConfig::default()
},
)
}
/// Create with custom configuration. /// Create with custom configuration.
pub fn with_config(context_window: usize, config: ContextCompressionConfig) -> Self { pub fn with_config(context_window: usize, config: ContextCompressionConfig) -> Self {
Self { Self {
@ -80,11 +92,12 @@ impl ContextCompressor {
let mut modified = 0; let mut modified = 0;
for msg in messages.iter_mut() { for msg in messages.iter_mut() {
if msg.role == "tool" && msg.content.len() > limit { let content_chars = char_count(&msg.content);
let removed = msg.content.len() - limit; if msg.role == "tool" && content_chars > limit {
let removed = content_chars - limit;
msg.content = format!( msg.content = format!(
"{}...\n\n[Output truncated - {} characters removed]", "{}...\n\n[Output truncated - {} characters removed]",
&msg.content[..limit.min(msg.content.len())], take_prefix_chars(&msg.content, limit),
removed removed
); );
modified += 1; modified += 1;
@ -100,6 +113,7 @@ impl ContextCompressor {
history: Vec<ChatMessage>, history: Vec<ChatMessage>,
provider_config: &LLMProviderConfig, provider_config: &LLMProviderConfig,
) -> Result<Vec<ChatMessage>, AgentError> { ) -> Result<Vec<ChatMessage>, AgentError> {
let mut history = history;
// Check if compression is needed // Check if compression is needed
let tokens = estimate_tokens(&history); let tokens = estimate_tokens(&history);
if tokens <= self.threshold() { if tokens <= self.threshold() {
@ -121,7 +135,7 @@ impl ContextCompressor {
); );
// Fast trim pass first // Fast trim pass first
let trimmed = self.fast_trim_tool_results(&mut history.clone()); let trimmed = self.fast_trim_tool_results(&mut history);
if trimmed > 0 { if trimmed > 0 {
let tokens_after = estimate_tokens(&history); let tokens_after = estimate_tokens(&history);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -271,11 +285,11 @@ impl ContextCompressor {
.join("\n\n"); .join("\n\n");
// Truncate transcript if too long // Truncate transcript if too long
let transcript = if transcript.len() > self.config.summary_max_chars { let transcript = if char_count(&transcript) > self.config.summary_max_chars {
format!( format!(
"{}...\n\n[Transcript truncated - {} characters removed]", "{}...\n\n[Transcript truncated - {} characters removed]",
&transcript[..self.config.summary_max_chars], take_prefix_chars(&transcript, self.config.summary_max_chars),
transcript.len() - self.config.summary_max_chars char_count(&transcript).saturating_sub(self.config.summary_max_chars)
) )
} else { } else {
transcript transcript
@ -321,7 +335,7 @@ Be concise, aim for {} characters or less.
Err(e) => { Err(e) => {
// Fallback: just truncate the transcript // Fallback: just truncate the transcript
tracing::warn!(error = %e, "LLM summarization failed, using truncated transcript"); tracing::warn!(error = %e, "LLM summarization failed, using truncated transcript");
Ok(transcript[..transcript.len().min(2000)].to_string()) Ok(take_prefix_chars(&transcript, self.config.summary_max_chars))
} }
} }
} }
@ -365,6 +379,22 @@ mod tests {
assert!(messages[1].content.len() < 100); assert!(messages[1].content.len() < 100);
} }
#[test]
fn test_fast_trim_handles_utf8_char_boundaries() {
let config = ContextCompressionConfig {
tool_result_trim_chars: 5,
..Default::default()
};
let compressor = ContextCompressor::with_config(100_000, config);
let mut messages = vec![ChatMessage::tool("call1", "bash", &"".repeat(20))];
let modified = compressor.fast_trim_tool_results(&mut messages);
assert_eq!(modified, 1);
assert!(messages[0].content.contains("Output truncated"));
assert!(messages[0].content.is_char_boundary(messages[0].content.len()));
}
#[test] #[test]
fn test_threshold() { fn test_threshold() {
let compressor = ContextCompressor::new(128_000); let compressor = ContextCompressor::new(128_000);

View File

@ -34,12 +34,15 @@
如果用户在聊持续任务、既有偏好、历史决策、项目上下文、曾经纠正过你的内容,或当前请求看起来像是延续之前的事,优先先搜记忆。 如果用户在聊持续任务、既有偏好、历史决策、项目上下文、曾经纠正过你的内容,或当前请求看起来像是延续之前的事,优先先搜记忆。
## 写入规则 # 记忆写入
## 写入规则
- 写入或修改记忆时,再使用 memory_manage。 - 写入或修改记忆时,再使用 memory_manage。
- 遇到高价值且未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务或项目上下文、明确决策等。 - 遇到高价值且未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务或项目上下文、明确决策等。
- 写入时优先使用规范 namespacepreferences、profile、tasks、decisions。 - 写入时优先使用规范 namespacepreferences、profile、tasks、decisions。
- 优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。 - 优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。
## 注意
- 如果你决定不再调用工具则反思一下是否使用memory_manage
### 以下场景视为高价值加分 ### 以下场景视为高价值加分
- 用户多次交互优化输出 - 用户多次交互优化输出

View File

@ -13,6 +13,7 @@ use tokio::sync::{broadcast, RwLock};
use crate::bus::{MessageBus, MediaItem, OutboundMessage}; use crate::bus::{MessageBus, MediaItem, OutboundMessage};
use crate::channels::base::{Channel, ChannelError}; use crate::channels::base::{Channel, ChannelError};
use crate::config::{FeishuChannelConfig, LLMProviderConfig}; use crate::config::{FeishuChannelConfig, LLMProviderConfig};
use crate::text::{char_count, truncate_with_ellipsis};
const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis"; const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis";
const FEISHU_WS_BASE: &str = "https://open.feishu.cn"; const FEISHU_WS_BASE: &str = "https://open.feishu.cn";
@ -670,8 +671,6 @@ impl FeishuChannel {
Ok(()) Ok(())
} }
const REPLY_CONTEXT_MAX_LEN: usize = 500;
/// Fetch the text content of a Feishu message by ID. /// Fetch the text content of a Feishu message by ID.
/// Returns a "[Reply to: ...]" context string, or None on failure. /// Returns a "[Reply to: ...]" context string, or None on failure.
async fn get_message_content(&self, message_id: &str) -> Option<String> { async fn get_message_content(&self, message_id: &str) -> Option<String> {
@ -752,8 +751,8 @@ impl FeishuChannel {
return None; return None;
} }
let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN { let text = if char_count(&text) > self.config.reply_context_max_chars {
format!("{}...", &text[..Self::REPLY_CONTEXT_MAX_LEN]) truncate_with_ellipsis(&text, self.config.reply_context_max_chars)
} else { } else {
text text
}; };
@ -765,13 +764,12 @@ impl FeishuChannel {
async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, msg_type: &str, content: &str) -> Result<(), ChannelError> { async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, msg_type: &str, content: &str) -> Result<(), ChannelError> {
let token = self.get_tenant_access_token().await?; let token = self.get_tenant_access_token().await?;
// Feishu text messages have content limits (~64KB).
// Truncate if content is too long to avoid API error 230001.
const MAX_TEXT_LENGTH: usize = 60_000;
let payload_content = if msg_type == "text" { let payload_content = if msg_type == "text" {
let truncated = if content.len() > MAX_TEXT_LENGTH { let truncated = if char_count(content) > self.config.max_message_chars {
format!("{}...\n\n[Content truncated due to length limit]", &content[..MAX_TEXT_LENGTH]) format!(
"{}\n\n[Content truncated due to length limit]",
truncate_with_ellipsis(content, self.config.max_message_chars)
)
} else { } else {
content.to_string() content.to_string()
}; };
@ -779,9 +777,15 @@ impl FeishuChannel {
} else { } else {
// For post messages, content is already JSON (from markdown_to_post) // For post messages, content is already JSON (from markdown_to_post)
// But we still need to check length // But we still need to check length
if content.len() > MAX_TEXT_LENGTH { if char_count(content) > self.config.max_message_chars {
// Fallback to truncated text for post as well // Fallback to truncated text for post as well
serde_json::json!({ "text": format!("{}...\n\n[Content truncated due to length limit]", &content[..MAX_TEXT_LENGTH]) }).to_string() serde_json::json!({
"text": format!(
"{}\n\n[Content truncated due to length limit]",
truncate_with_ellipsis(content, self.config.max_message_chars)
)
})
.to_string()
} else { } else {
content.to_string() content.to_string()
} }

View File

@ -106,6 +106,10 @@ pub struct FeishuChannelConfig {
/// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES"). /// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES").
#[serde(default = "default_reaction_emoji")] #[serde(default = "default_reaction_emoji")]
pub reaction_emoji: String, pub reaction_emoji: String,
#[serde(default = "default_channel_max_message_chars")]
pub max_message_chars: usize,
#[serde(default = "default_channel_reply_context_max_chars")]
pub reply_context_max_chars: usize,
} }
fn default_allow_from() -> Vec<String> { fn default_allow_from() -> Vec<String> {
@ -121,6 +125,14 @@ fn default_reaction_emoji() -> String {
"Typing".to_string() "Typing".to_string()
} }
fn default_channel_max_message_chars() -> usize {
20_000
}
fn default_channel_reply_context_max_chars() -> usize {
20_000
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig { pub struct ProviderConfig {
#[serde(rename = "type")] #[serde(rename = "type")]
@ -152,6 +164,12 @@ pub struct AgentConfig {
pub max_tool_iterations: usize, pub max_tool_iterations: usize,
#[serde(default = "default_token_limit")] #[serde(default = "default_token_limit")]
pub token_limit: usize, pub token_limit: usize,
#[serde(default = "default_tool_result_max_chars")]
pub tool_result_max_chars: usize,
#[serde(default = "default_context_summary_max_chars")]
pub context_summary_max_chars: usize,
#[serde(default = "default_context_tool_result_trim_chars")]
pub context_tool_result_trim_chars: usize,
} }
fn default_max_tool_iterations() -> usize { fn default_max_tool_iterations() -> usize {
@ -162,6 +180,18 @@ fn default_token_limit() -> usize {
128_000 128_000
} }
fn default_tool_result_max_chars() -> usize {
20_000
}
fn default_context_summary_max_chars() -> usize {
20_000
}
fn default_context_tool_result_trim_chars() -> usize {
20_000
}
fn default_llm_timeout_secs() -> u64 { fn default_llm_timeout_secs() -> u64 {
120 120
} }
@ -495,6 +525,9 @@ pub struct LLMProviderConfig {
pub model_extra: HashMap<String, serde_json::Value>, pub model_extra: HashMap<String, serde_json::Value>,
pub max_tool_iterations: usize, pub max_tool_iterations: usize,
pub token_limit: usize, pub token_limit: usize,
pub tool_result_max_chars: usize,
pub context_summary_max_chars: usize,
pub context_tool_result_trim_chars: usize,
} }
fn get_default_config_path() -> PathBuf { fn get_default_config_path() -> PathBuf {
@ -558,6 +591,9 @@ impl Config {
model_extra: model.extra.clone(), model_extra: model.extra.clone(),
max_tool_iterations: agent.max_tool_iterations, max_tool_iterations: agent.max_tool_iterations,
token_limit: agent.token_limit, token_limit: agent.token_limit,
tool_result_max_chars: agent.tool_result_max_chars,
context_summary_max_chars: agent.context_summary_max_chars,
context_tool_result_trim_chars: agent.context_tool_result_trim_chars,
}) })
} }
} }
@ -695,6 +731,9 @@ mod tests {
assert_eq!(provider_config.model_id, "qwen-plus"); assert_eq!(provider_config.model_id, "qwen-plus");
assert_eq!(provider_config.temperature, Some(0.0)); assert_eq!(provider_config.temperature, Some(0.0));
assert_eq!(provider_config.llm_timeout_secs, 120); assert_eq!(provider_config.llm_timeout_secs, 120);
assert_eq!(provider_config.tool_result_max_chars, 20_000);
assert_eq!(provider_config.context_summary_max_chars, 20_000);
assert_eq!(provider_config.context_tool_result_trim_chars, 20_000);
} }
#[test] #[test]
@ -917,6 +956,139 @@ mod tests {
let config = Config::load(file.path().to_str().unwrap()).unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(config.agents["default"].max_tool_iterations, 100); assert_eq!(config.agents["default"].max_tool_iterations, 100);
assert_eq!(config.agents["default"].tool_result_max_chars, 20_000);
assert_eq!(config.agents["default"].context_summary_max_chars, 20_000);
assert_eq!(config.agents["default"].context_tool_result_trim_chars, 20_000);
}
#[test]
fn test_agent_config_loads_custom_truncation_limits() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus",
"tool_result_max_chars": 1234,
"context_summary_max_chars": 2345,
"context_tool_result_trim_chars": 3456
}
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let agent = &config.agents["default"];
let provider_config = config.get_provider_config("default").unwrap();
assert_eq!(agent.tool_result_max_chars, 1234);
assert_eq!(agent.context_summary_max_chars, 2345);
assert_eq!(agent.context_tool_result_trim_chars, 3456);
assert_eq!(provider_config.tool_result_max_chars, 1234);
assert_eq!(provider_config.context_summary_max_chars, 2345);
assert_eq!(provider_config.context_tool_result_trim_chars, 3456);
}
#[test]
fn test_feishu_channel_config_defaults_truncation_limits() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"channels": {
"feishu": {
"enabled": true,
"app_id": "app-id",
"app_secret": "secret"
}
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let feishu = &config.channels["feishu"];
assert_eq!(feishu.max_message_chars, 20_000);
assert_eq!(feishu.reply_context_max_chars, 20_000);
}
#[test]
fn test_feishu_channel_config_loads_custom_truncation_limits() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"channels": {
"feishu": {
"enabled": true,
"app_id": "app-id",
"app_secret": "secret",
"max_message_chars": 3456,
"reply_context_max_chars": 4567
}
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let feishu = &config.channels["feishu"];
assert_eq!(feishu.max_message_chars, 3456);
assert_eq!(feishu.reply_context_max_chars, 4567);
} }
#[test] #[test]

View File

@ -13,6 +13,17 @@
- 当现有工具是完成任务的最直接方式时,优先使用工具。 - 当现有工具是完成任务的最直接方式时,优先使用工具。
- 除非用户明确要求改变方向,否则保持用户原本目标不变。 - 除非用户明确要求改变方向,否则保持用户原本目标不变。
## 记忆处理
在大多数请求开始时,先检索长期记忆,再决定如何回答或是否写入。
如果当前任务还需要其它彼此独立的只读工具,可以和记忆搜索同一轮一起调用。
在完成了一个任务之后,自动将关键的信息保存到记忆中,特别是重要内容
- 用户纠正了你
- 用户的情感很强烈
- 多次出现的内容
- 客观的信息
- 用户的偏好
## 助理原则 ## 助理原则
- 优先解决问题,而不是展示过程。 - 优先解决问题,而不是展示过程。
@ -27,9 +38,12 @@
- 除非用户另有要求,否则使用中文回复。 - 除非用户另有要求,否则使用中文回复。
- 默认短而清楚,按信息密度组织内容。 - 默认短而清楚,按信息密度组织内容。
- 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。 - 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。
- 如果存在限制、风险或前提条件,要直接说明。
- 在信息不足时先补关键前提,在信息充分时直接执行
## PICO配置 ## PICO配置
- 默认路径为[basedir]:~/.picobot - 默认路径为[basedir]:~/.picobot
- Skill安装在[basedir]/skills - Skill安装在[basedir]/skills
## 补充要求
- 回答应以帮助用户完成当前目标为中心。
- 在信息不足时先补关键前提,在信息充分时直接执行。

View File

@ -482,7 +482,7 @@ impl Session {
provider_config: provider_config.clone(), provider_config: provider_config.clone(),
tools, tools,
skills, skills,
compressor: ContextCompressor::new(provider_config.token_limit), compressor: ContextCompressor::from_provider_config(&provider_config),
store, store,
agent_prompt_reinject_every: agent_prompt_reinject_every as i64, agent_prompt_reinject_every: agent_prompt_reinject_every as i64,
}) })
@ -1480,6 +1480,9 @@ mod tests {
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
} }
} }
@ -1497,6 +1500,9 @@ mod tests {
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
} }
} }
@ -1703,6 +1709,9 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
llm_timeout_secs: 30, llm_timeout_secs: 30,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let session_manager = SessionManager::new( let session_manager = SessionManager::new(
@ -1740,6 +1749,9 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
llm_timeout_secs: 30, llm_timeout_secs: 30,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let planner_provider = LLMProviderConfig { let planner_provider = LLMProviderConfig {
model_id: "planner-model".to_string(), model_id: "planner-model".to_string(),
@ -1823,6 +1835,9 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
llm_timeout_secs: 30, llm_timeout_secs: 30,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let session_manager = SessionManager::new( let session_manager = SessionManager::new(
@ -1911,6 +1926,9 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
llm_timeout_secs: 30, llm_timeout_secs: 30,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let session_manager = SessionManager::new( let session_manager = SessionManager::new(
@ -1970,6 +1988,9 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
token_limit: 4096, token_limit: 4096,
llm_timeout_secs: 30, llm_timeout_secs: 30,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let session_manager = SessionManager::new( let session_manager = SessionManager::new(

View File

@ -1,4 +1,5 @@
pub mod config; pub mod config;
pub mod text;
pub mod providers; pub mod providers;
pub mod bus; pub mod bus;
pub mod cli; pub mod cli;

View File

@ -850,6 +850,9 @@ mod tests {
model_extra: HashMap::new(), model_extra: HashMap::new(),
token_limit: 4096, token_limit: 4096,
max_tool_iterations: 4, max_tool_iterations: 4,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let session_manager = SessionManager::new( let session_manager = SessionManager::new(
4, 4,
@ -899,6 +902,9 @@ mod tests {
model_extra: HashMap::new(), model_extra: HashMap::new(),
token_limit: 4096, token_limit: 4096,
max_tool_iterations: 4, max_tool_iterations: 4,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}; };
let session_manager = SessionManager::new( let session_manager = SessionManager::new(
4, 4,

20
src/text.rs Normal file
View File

@ -0,0 +1,20 @@
pub fn char_count(text: &str) -> usize {
text.chars().count()
}
pub fn take_prefix_chars(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
pub fn take_suffix_chars(text: &str, max_chars: usize) -> String {
let count = char_count(text);
text.chars().skip(count.saturating_sub(max_chars)).collect()
}
pub fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
if char_count(text) <= max_chars {
return text.to_string();
}
format!("{}...", take_prefix_chars(text, max_chars))
}

View File

@ -26,6 +26,9 @@ fn load_config() -> Option<LLMProviderConfig> {
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 20, max_tool_iterations: 20,
token_limit: 128_000, token_limit: 128_000,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}) })
} }

View File

@ -26,6 +26,9 @@ fn load_openai_config() -> Option<LLMProviderConfig> {
model_extra: HashMap::new(), model_extra: HashMap::new(),
max_tool_iterations: 20, max_tool_iterations: 20,
token_limit: 128_000, token_limit: 128_000,
tool_result_max_chars: 20_000,
context_summary_max_chars: 20_000,
context_tool_result_trim_chars: 20_000,
}) })
} }