refactor: eliminate build warnings and legacy evaluator
Resolve strict Clippy findings across all targets, preserve public API compatibility with scoped lint exceptions, and fix sourced messages retaining media references. Replace meval and its future-incompatible nom dependency with a bounded internal expression parser and regression tests.
This commit is contained in:
parent
3d580828b5
commit
63d20d1eb8
@ -30,7 +30,6 @@ base64 = "0.22"
|
||||
tempfile = "3"
|
||||
cron = "0.16"
|
||||
chrono-tz = "0.10"
|
||||
meval = "0.2"
|
||||
ratatui = "0.30"
|
||||
crossterm = { version = "0.29", features = ["event-stream"] }
|
||||
termimad = "0.34"
|
||||
|
||||
@ -362,16 +362,16 @@ impl AgentLoop {
|
||||
let end = messages.len().saturating_sub(keep_recent);
|
||||
let start = 1; // protect system message at [0] if present
|
||||
let mut modified = 0;
|
||||
for i in start..end {
|
||||
if messages[i].role != "tool" {
|
||||
for message in messages.iter_mut().take(end).skip(start) {
|
||||
if message.role != "tool" {
|
||||
continue;
|
||||
}
|
||||
if messages[i].content.len() <= max_chars {
|
||||
if message.content.len() <= max_chars {
|
||||
continue;
|
||||
}
|
||||
let tool_name = messages[i].tool_name.as_deref().unwrap_or("unknown");
|
||||
let chars = messages[i].content.len();
|
||||
messages[i].content = format!(
|
||||
let tool_name = message.tool_name.as_deref().unwrap_or("unknown");
|
||||
let chars = message.content.len();
|
||||
message.content = format!(
|
||||
"[Tool output ({}) — {} chars, omitted from context]",
|
||||
tool_name, chars
|
||||
);
|
||||
@ -810,14 +810,14 @@ mod tests {
|
||||
fn test_should_execute_in_parallel_single_tool() {
|
||||
// Would need a proper setup with AgentLoop to test fully
|
||||
// For now, just verify the logic: single tool should return false
|
||||
let calls = vec![ToolCall {
|
||||
let calls = [ToolCall {
|
||||
id: "1".to_string(),
|
||||
name: "test".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
// If there's only 1 tool, should return false regardless
|
||||
assert_eq!(calls.len() <= 1, true);
|
||||
assert!(calls.len() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -403,18 +403,17 @@ impl ContextCompressor {
|
||||
// Strip tool_calls from any assistant in the head whose results
|
||||
// were dropped (previously in the middle section).
|
||||
for msg in &mut truncated[..self.config.protect_first_n] {
|
||||
if msg.role == "assistant" {
|
||||
if let Some(ref tcs) = msg.tool_calls
|
||||
&& !tcs.is_empty()
|
||||
{
|
||||
let names: Vec<&str> = tcs.iter().map(|tc| tc.name.as_str()).collect();
|
||||
msg.content = format!(
|
||||
"{}\n\n[Tool calls ({}) — results dropped during truncation]",
|
||||
msg.content,
|
||||
names.join(", ")
|
||||
);
|
||||
msg.tool_calls = None;
|
||||
}
|
||||
if msg.role == "assistant"
|
||||
&& let Some(ref tcs) = msg.tool_calls
|
||||
&& !tcs.is_empty()
|
||||
{
|
||||
let names: Vec<&str> = tcs.iter().map(|tc| tc.name.as_str()).collect();
|
||||
msg.content = format!(
|
||||
"{}\n\n[Tool calls ({}) — results dropped during truncation]",
|
||||
msg.content,
|
||||
names.join(", ")
|
||||
);
|
||||
msg.tool_calls = None;
|
||||
}
|
||||
}
|
||||
|
||||
@ -564,9 +563,7 @@ impl ContextCompressor {
|
||||
|
||||
// Add last user and everything after (protected)
|
||||
let last_user_idx = user_indices[user_indices.len() - 1];
|
||||
for i in last_user_idx..history.len() {
|
||||
new_messages.push(history[i].clone());
|
||||
}
|
||||
new_messages.extend_from_slice(&history[last_user_idx..]);
|
||||
|
||||
// Remove orphan tool results whose declaring tool_calls were compressed away
|
||||
Self::repair_tool_pairs(&mut new_messages);
|
||||
@ -786,7 +783,7 @@ mod tests {
|
||||
|
||||
let mut messages = vec![
|
||||
ChatMessage::user("Hello"),
|
||||
ChatMessage::tool("call1", "bash", &"x".repeat(200)),
|
||||
ChatMessage::tool("call1", "bash", "x".repeat(200)),
|
||||
];
|
||||
|
||||
let modified = compressor.fast_trim_tool_results(&mut messages, 2);
|
||||
@ -820,7 +817,7 @@ mod tests {
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::user("Hi"),
|
||||
ChatMessage::tool("call1", "bash", &"x".repeat(3000)),
|
||||
ChatMessage::tool("call1", "bash", "x".repeat(3000)),
|
||||
];
|
||||
|
||||
let result = compressor
|
||||
|
||||
@ -67,6 +67,12 @@ pub struct MediaHandlerRegistry {
|
||||
handlers: HashMap<String, Box<dyn MediaHandler>>,
|
||||
}
|
||||
|
||||
impl Default for MediaHandlerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaHandlerRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
||||
@ -158,12 +158,10 @@ impl SubAgentManager {
|
||||
|
||||
fn get_skills_prompt(&self, tools: &ToolRegistry) -> Option<String> {
|
||||
let has_get_skill = tools.iter().iter().any(|(name, _)| name == "get_skill");
|
||||
if has_get_skill {
|
||||
if let Some(ref loader) = self.skills_loader {
|
||||
let prompt = loader.build_skills_prompt();
|
||||
if !prompt.is_empty() {
|
||||
return Some(prompt);
|
||||
}
|
||||
if has_get_skill && let Some(ref loader) = self.skills_loader {
|
||||
let prompt = loader.build_skills_prompt();
|
||||
if !prompt.is_empty() {
|
||||
return Some(prompt);
|
||||
}
|
||||
}
|
||||
None
|
||||
@ -300,7 +298,7 @@ impl SubAgentManager {
|
||||
.collect();
|
||||
|
||||
let results = futures_util::future::join_all(futures).await;
|
||||
Ok(results.into_iter().collect::<Result<Vec<_>, _>>()?)
|
||||
results.into_iter().collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
|
||||
pub async fn run_background(
|
||||
@ -395,12 +393,12 @@ impl SubAgentManager {
|
||||
}
|
||||
|
||||
let mut provider = create_provider(provider_config.clone()).ok();
|
||||
if let Some(ref mut p) = provider {
|
||||
if let Some(ref s) = storage {
|
||||
p.set_storage(s.clone());
|
||||
}
|
||||
if let Some(ref mut p) = provider
|
||||
&& let Some(ref s) = storage
|
||||
{
|
||||
p.set_storage(s.clone());
|
||||
}
|
||||
let provider_result: Option<Arc<dyn LLMProvider>> = provider.map(|p| Arc::from(p));
|
||||
let provider_result: Option<Arc<dyn LLMProvider>> = provider.map(Arc::from);
|
||||
|
||||
let result = match provider_result {
|
||||
Some(provider) => {
|
||||
@ -566,12 +564,12 @@ impl SubAgentManager {
|
||||
|
||||
pub async fn cancel_by_session(&self, session_id: &str) {
|
||||
// Cancel all running tasks for a session by checking DB
|
||||
if let Some(ref s) = self.storage {
|
||||
if let Ok(tasks) = s.list_background_tasks(session_id).await {
|
||||
for task in &tasks {
|
||||
if task.status == "pending" || task.status == "running" {
|
||||
let _ = self.cancel_task(&task.id).await;
|
||||
}
|
||||
if let Some(ref s) = self.storage
|
||||
&& let Ok(tasks) = s.list_background_tasks(session_id).await
|
||||
{
|
||||
for task in &tasks {
|
||||
if task.status == "pending" || task.status == "running" {
|
||||
let _ = self.cancel_task(&task.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -286,7 +286,7 @@ pub struct OutboundMessage {
|
||||
|
||||
impl OutboundMessage {
|
||||
pub fn is_stream_delta(&self) -> bool {
|
||||
self.metadata.get("_stream_delta").is_some()
|
||||
self.metadata.contains_key("_stream_delta")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -73,7 +73,7 @@ impl CliChatChannel {
|
||||
Ok((id, _title)) => id,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to create initial session");
|
||||
UnifiedSessionId::new("cli_chat", &chat_id, &crate::util::short_id()).to_string()
|
||||
UnifiedSessionId::new("cli_chat", &chat_id, crate::util::short_id()).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1357,52 +1357,6 @@ impl FeishuChannel {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collect_post_image_keys_finds_nested_images() {
|
||||
let content = serde_json::json!({
|
||||
"zh_cn": {
|
||||
"title": "",
|
||||
"content": [[
|
||||
{"tag": "img", "image_key": "img_v3_001"},
|
||||
{"tag": "text", "text": "这是哪里?"},
|
||||
{"tag": "img", "image_key": "img_v3_002"},
|
||||
{"tag": "img", "image_key": "img_v3_001"}
|
||||
]]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
collect_post_image_keys(&content),
|
||||
vec!["img_v3_001".to_string(), "img_v3_002".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_preserves_image_positions() {
|
||||
let content = serde_json::json!({
|
||||
"zh_cn": {
|
||||
"title": "",
|
||||
"content": [[
|
||||
{"tag": "text", "text": "这是一张图:"},
|
||||
{"tag": "img", "image_key": "img_v3_001"},
|
||||
{"tag": "text", "text": "看完继续说"}
|
||||
]]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
parse_post_content(&content),
|
||||
"这是一张图:[image]看完继续说"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_post_content(content: &str) -> String {
|
||||
/// Extract text from a single post element (text, link, at-mention).
|
||||
fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) {
|
||||
@ -1732,13 +1686,8 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
|
||||
collect_list_items(children, lines, depth + 1);
|
||||
}
|
||||
} else if let Some(children_arr) = item.as_array().and_then(|arr| {
|
||||
arr.iter().find_map(|child| {
|
||||
if child.as_object().and_then(|o| o.get("children")).is_some() {
|
||||
Some(child)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
arr.iter()
|
||||
.find(|child| child.as_object().and_then(|o| o.get("children")).is_some())
|
||||
}) && let Some(children) = children_arr
|
||||
.as_object()
|
||||
.and_then(|o| o.get("children"))
|
||||
@ -1819,13 +1768,12 @@ fn resolve_image_ext(content_type: &str) -> &str {
|
||||
}
|
||||
|
||||
fn resolve_file_ext(content_json: &serde_json::Value) -> String {
|
||||
if let Some(name) = content_json.get("file_name").and_then(|v| v.as_str()) {
|
||||
if let Some(ext) = std::path::Path::new(name)
|
||||
if let Some(name) = content_json.get("file_name").and_then(|v| v.as_str())
|
||||
&& let Some(ext) = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
{
|
||||
return ext.to_string();
|
||||
}
|
||||
{
|
||||
return ext.to_string();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
@ -2271,3 +2219,49 @@ impl Channel for FeishuChannel {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collect_post_image_keys_finds_nested_images() {
|
||||
let content = serde_json::json!({
|
||||
"zh_cn": {
|
||||
"title": "",
|
||||
"content": [[
|
||||
{"tag": "img", "image_key": "img_v3_001"},
|
||||
{"tag": "text", "text": "这是哪里?"},
|
||||
{"tag": "img", "image_key": "img_v3_002"},
|
||||
{"tag": "img", "image_key": "img_v3_001"}
|
||||
]]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
collect_post_image_keys(&content),
|
||||
vec!["img_v3_001".to_string(), "img_v3_002".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_preserves_image_positions() {
|
||||
let content = serde_json::json!({
|
||||
"zh_cn": {
|
||||
"title": "",
|
||||
"content": [[
|
||||
{"tag": "text", "text": "这是一张图:"},
|
||||
{"tag": "img", "image_key": "img_v3_001"},
|
||||
{"tag": "text", "text": "看完继续说"}
|
||||
]]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
parse_post_content(&content),
|
||||
"这是一张图:[image]看完继续说"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,10 +19,10 @@ pub fn get_default_workspace_dir() -> PathBuf {
|
||||
|
||||
/// Expand ~ in path to user home directory
|
||||
pub fn expand_path(path: &str) -> PathBuf {
|
||||
if path.starts_with("~/") {
|
||||
if let Some(path) = path.strip_prefix("~/") {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(&path[2..])
|
||||
.join(path)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ impl MemoryCategory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"knowledge" => Some(Self::Knowledge),
|
||||
"timeline" => Some(Self::Timeline),
|
||||
@ -78,13 +78,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_memory_category_from_str() {
|
||||
assert_eq!(
|
||||
MemoryCategory::from_str("knowledge"),
|
||||
MemoryCategory::parse("knowledge"),
|
||||
Some(MemoryCategory::Knowledge)
|
||||
);
|
||||
assert_eq!(
|
||||
MemoryCategory::from_str("timeline"),
|
||||
MemoryCategory::parse("timeline"),
|
||||
Some(MemoryCategory::Timeline)
|
||||
);
|
||||
assert_eq!(MemoryCategory::from_str("invalid"), None);
|
||||
assert_eq!(MemoryCategory::parse("invalid"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,8 @@ pub struct AnthropicProvider {
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
// Keep this constructor aligned with OpenAIProvider and LLMProviderConfig.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
name: String,
|
||||
api_key: String,
|
||||
|
||||
@ -46,6 +46,9 @@ pub struct OpenAIProvider {
|
||||
}
|
||||
|
||||
impl OpenAIProvider {
|
||||
// Provider construction mirrors the independently configurable fields in
|
||||
// LLMProviderConfig; grouping them again would only duplicate that API.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
name: String,
|
||||
api_key: String,
|
||||
@ -112,10 +115,10 @@ impl OpenAIProvider {
|
||||
"role": m.role,
|
||||
"content": convert_content_blocks(&m.content)
|
||||
});
|
||||
if m.role == "assistant" {
|
||||
if let Some(ref rc) = m.reasoning_content {
|
||||
msg["reasoning_content"] = json!(rc);
|
||||
}
|
||||
if m.role == "assistant"
|
||||
&& let Some(ref rc) = m.reasoning_content
|
||||
{
|
||||
msg["reasoning_content"] = json!(rc);
|
||||
}
|
||||
msg
|
||||
}
|
||||
@ -358,7 +361,7 @@ impl LLMProvider for OpenAIProvider {
|
||||
prompt_tokens: usage.prompt_tokens,
|
||||
completion_tokens: usage.completion_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
cached_tokens: cached_tokens,
|
||||
cached_tokens,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
},
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
// The public `session::session` path is retained for API compatibility.
|
||||
#[allow(clippy::module_inception)]
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
|
||||
|
||||
@ -531,11 +531,9 @@ impl Session {
|
||||
media_refs: Vec<MediaRef>,
|
||||
source: MessageSource,
|
||||
) -> ChatMessage {
|
||||
if media_refs.is_empty() {
|
||||
ChatMessage::user_with_source(content, source)
|
||||
} else {
|
||||
ChatMessage::user_with_source(content, source)
|
||||
}
|
||||
let mut message = ChatMessage::user_with_source(content, source);
|
||||
message.media_refs = media_refs;
|
||||
message
|
||||
}
|
||||
|
||||
/// 将 session 元数据写回 Storage
|
||||
@ -861,7 +859,7 @@ impl Session {
|
||||
/// Repair damaged tool call chains after restoring from storage.
|
||||
/// Handles cases where the gateway crashed mid-loop, leaving assistant
|
||||
/// tool_calls without corresponding tool result messages.
|
||||
fn repair_tool_call_chains(messages: &mut Vec<ChatMessage>) {
|
||||
fn repair_tool_call_chains(messages: &mut [ChatMessage]) {
|
||||
let mut i = 0;
|
||||
while i < messages.len() {
|
||||
let calls = match &messages[i].tool_calls {
|
||||
@ -2665,6 +2663,24 @@ impl OutboundMessenger for SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_task_notification(
|
||||
task_id: &str,
|
||||
status: &crate::agent::TaskStatus,
|
||||
summary: &str,
|
||||
) -> String {
|
||||
match status {
|
||||
crate::agent::TaskStatus::Completed => format!(
|
||||
"📋 后台任务完成\n\n任务 ID: {}\n\n结果:\n{}",
|
||||
task_id, summary
|
||||
),
|
||||
crate::agent::TaskStatus::Failed(err) => {
|
||||
format!("📋 后台任务失败\n\n任务 ID: {}\n错误: {}", task_id, err)
|
||||
}
|
||||
crate::agent::TaskStatus::Cancelled => format!("📋 后台任务已取消\n\n任务 ID: {}", task_id),
|
||||
crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -2689,21 +2705,3 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_task_notification(
|
||||
task_id: &str,
|
||||
status: &crate::agent::TaskStatus,
|
||||
summary: &str,
|
||||
) -> String {
|
||||
match status {
|
||||
crate::agent::TaskStatus::Completed => format!(
|
||||
"📋 后台任务完成\n\n任务 ID: {}\n\n结果:\n{}",
|
||||
task_id, summary
|
||||
),
|
||||
crate::agent::TaskStatus::Failed(err) => {
|
||||
format!("📋 后台任务失败\n\n任务 ID: {}\n错误: {}", task_id, err)
|
||||
}
|
||||
crate::agent::TaskStatus::Cancelled => format!("📋 后台任务已取消\n\n任务 ID: {}", task_id),
|
||||
crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id),
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,11 +55,6 @@ impl UnifiedSessionId {
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert to string format "channel:chat_id:dialog_id"
|
||||
pub fn to_string(&self) -> String {
|
||||
format!("{}:{}:{}", self.channel, self.chat_id, self.dialog_id)
|
||||
}
|
||||
|
||||
/// Get the session key without dialog_id (channel:chat_id)
|
||||
/// This is used to group all dialogs within a chat
|
||||
pub fn chat_scope(&self) -> String {
|
||||
@ -69,7 +64,7 @@ impl UnifiedSessionId {
|
||||
|
||||
impl std::fmt::Display for UnifiedSessionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.to_string())
|
||||
write!(f, "{}:{}:{}", self.channel, self.chat_id, self.dialog_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -282,7 +282,7 @@ fn parse_memory_rows(rows: &[sqlx::sqlite::SqliteRow]) -> Result<Vec<MemoryEntry
|
||||
id: row.try_get("id")?,
|
||||
key: row.try_get("key")?,
|
||||
content: row.try_get("content")?,
|
||||
category: MemoryCategory::from_str(&row.try_get::<String, _>("category")?)
|
||||
category: MemoryCategory::parse(&row.try_get::<String, _>("category")?)
|
||||
.unwrap_or(MemoryCategory::Knowledge),
|
||||
importance: row.try_get::<f64, _>("importance")?,
|
||||
session_id: row.try_get::<Option<String>, _>("session_id")?,
|
||||
|
||||
@ -1390,8 +1390,8 @@ mod tests {
|
||||
chat_id: "sid123".to_string(),
|
||||
dialog_id: format!("dialog{}", i),
|
||||
title: format!("会话{}", i),
|
||||
created_at: i as i64 * 1000,
|
||||
last_active_at: i as i64 * 1000,
|
||||
created_at: i * 1000,
|
||||
last_active_at: i * 1000,
|
||||
message_count: i,
|
||||
routing_info: None,
|
||||
archived_at: None,
|
||||
|
||||
@ -39,11 +39,11 @@ struct BrowserState {
|
||||
|
||||
impl Drop for BrowserTool {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut driver) = self.driver.lock() {
|
||||
if let Some(ref mut child) = driver.take() {
|
||||
tracing::debug!("Stopping chromedriver process");
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Ok(mut driver) = self.driver.lock()
|
||||
&& let Some(ref mut child) = driver.take()
|
||||
{
|
||||
tracing::debug!("Stopping chromedriver process");
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -454,10 +454,7 @@ impl BrowserState {
|
||||
} => {
|
||||
let client = self.active_client()?;
|
||||
let result: Value = client
|
||||
.execute(
|
||||
&snapshot_script(interactive_only, compact, depth.map(i64::from)),
|
||||
vec![],
|
||||
)
|
||||
.execute(&snapshot_script(interactive_only, compact, depth), vec![])
|
||||
.await?;
|
||||
let output = serde_json::to_string_pretty(&result)?;
|
||||
Ok(ToolResult {
|
||||
@ -826,11 +823,11 @@ impl BrowserState {
|
||||
if let Some(client) = self.client.take() {
|
||||
let _ = client.close().await;
|
||||
}
|
||||
if let Ok(mut guard) = driver.lock() {
|
||||
if let Some(ref mut child) = guard.take() {
|
||||
tracing::debug!("Stopping chromedriver process");
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Ok(mut guard) = driver.lock()
|
||||
&& let Some(ref mut child) = guard.take()
|
||||
{
|
||||
tracing::debug!("Stopping chromedriver process");
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
@ -957,10 +954,10 @@ fn launch_chromedriver(
|
||||
}
|
||||
|
||||
fn kill_driver_guard(driver: &std::sync::Mutex<Option<tokio::process::Child>>) {
|
||||
if let Ok(mut guard) = driver.lock() {
|
||||
if let Some(ref mut child) = guard.take() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Ok(mut guard) = driver.lock()
|
||||
&& let Some(ref mut child) = guard.take()
|
||||
{
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -380,7 +380,7 @@ fn calc_evaluate(args: &serde_json::Value) -> Result<String, String> {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing required parameter: expression".to_string())?;
|
||||
|
||||
meval::eval_str(expression)
|
||||
super::expression::evaluate(expression)
|
||||
.map(format_num)
|
||||
.map_err(|e| format!("Expression evaluation error: {e}"))
|
||||
}
|
||||
|
||||
@ -299,8 +299,8 @@ mod tests {
|
||||
chat_id: format!("sid{}", i),
|
||||
dialog_id: format!("dialog{}", i),
|
||||
title: format!("会话{}", i),
|
||||
created_at: now - i * 3600_000,
|
||||
last_active_at: now - i * 3600_000,
|
||||
created_at: now - i * 3_600_000,
|
||||
last_active_at: now - i * 3_600_000,
|
||||
message_count: i * 5,
|
||||
routing_info: None,
|
||||
archived_at: None,
|
||||
@ -350,7 +350,7 @@ mod tests {
|
||||
let msg = crate::storage::message::MessageMeta {
|
||||
id: format!("msg{}", i),
|
||||
session_id: session_id.to_string(),
|
||||
seq: i as i64 + 1,
|
||||
seq: i + 1,
|
||||
role: if i == 0 {
|
||||
"user".to_string()
|
||||
} else {
|
||||
@ -412,7 +412,7 @@ mod tests {
|
||||
let msg = crate::storage::message::MessageMeta {
|
||||
id: format!("msg{}", i),
|
||||
session_id: session_id.to_string(),
|
||||
seq: i as i64 + 1,
|
||||
seq: i + 1,
|
||||
role: if i % 2 == 0 {
|
||||
"user".to_string()
|
||||
} else {
|
||||
@ -472,7 +472,7 @@ mod tests {
|
||||
let msg = crate::storage::message::MessageMeta {
|
||||
id: format!("msg{}", i),
|
||||
session_id: session_id.to_string(),
|
||||
seq: i as i64 + 1,
|
||||
seq: i + 1,
|
||||
role: "user".to_string(),
|
||||
content: format!("消息内容 {}", i),
|
||||
reasoning_content: None,
|
||||
|
||||
@ -303,11 +303,11 @@ impl DelegateTool {
|
||||
if let Some(ref error) = task.error {
|
||||
output.push_str(&format!("\n错误: {}", error));
|
||||
}
|
||||
if let Some(started) = task.started_at {
|
||||
if let Some(finished) = task.finished_at {
|
||||
let duration = (finished - started) as f64 / 1000.0;
|
||||
output.push_str(&format!("\n耗时: {:.1}s", duration));
|
||||
}
|
||||
if let Some(started) = task.started_at
|
||||
&& let Some(finished) = task.finished_at
|
||||
{
|
||||
let duration = (finished - started) as f64 / 1000.0;
|
||||
output.push_str(&format!("\n耗时: {:.1}s", duration));
|
||||
}
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
|
||||
277
src/tools/expression.rs
Normal file
277
src/tools/expression.rs
Normal file
@ -0,0 +1,277 @@
|
||||
/// Evaluate a self-contained mathematical expression without executing code or
|
||||
/// resolving external variables.
|
||||
pub(super) fn evaluate(input: &str) -> Result<f64, String> {
|
||||
const MAX_EXPRESSION_BYTES: usize = 4096;
|
||||
if input.len() > MAX_EXPRESSION_BYTES {
|
||||
return Err(format!(
|
||||
"expression exceeds the {MAX_EXPRESSION_BYTES}-byte limit"
|
||||
));
|
||||
}
|
||||
let mut parser = Parser {
|
||||
input,
|
||||
position: 0,
|
||||
depth: 0,
|
||||
};
|
||||
let value = parser.parse_expression()?;
|
||||
parser.skip_whitespace();
|
||||
if parser.position != input.len() {
|
||||
return Err(parser.error("unexpected trailing input"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
input: &'a str,
|
||||
position: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn parse_expression(&mut self) -> Result<f64, String> {
|
||||
let mut value = self.parse_term()?;
|
||||
loop {
|
||||
if self.consume(b'+') {
|
||||
value += self.parse_term()?;
|
||||
} else if self.consume(b'-') {
|
||||
value -= self.parse_term()?;
|
||||
} else {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_term(&mut self) -> Result<f64, String> {
|
||||
let mut value = self.parse_unary()?;
|
||||
loop {
|
||||
if self.consume(b'*') {
|
||||
value *= self.parse_unary()?;
|
||||
} else if self.consume(b'/') {
|
||||
value /= self.parse_unary()?;
|
||||
} else if self.consume(b'%') {
|
||||
value %= self.parse_unary()?;
|
||||
} else {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_unary(&mut self) -> Result<f64, String> {
|
||||
if self.consume(b'+') {
|
||||
self.nested(Self::parse_unary)
|
||||
} else if self.consume(b'-') {
|
||||
Ok(-self.nested(Self::parse_unary)?)
|
||||
} else {
|
||||
self.parse_power()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_power(&mut self) -> Result<f64, String> {
|
||||
let base = self.parse_primary()?;
|
||||
if self.consume(b'^') {
|
||||
Ok(base.powf(self.nested(Self::parse_unary)?))
|
||||
} else {
|
||||
Ok(base)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Result<f64, String> {
|
||||
self.skip_whitespace();
|
||||
match self.peek() {
|
||||
Some(b'(') => {
|
||||
self.position += 1;
|
||||
let value = self.nested(Self::parse_expression)?;
|
||||
if !self.consume(b')') {
|
||||
return Err(self.error("expected ')'"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Some(byte) if byte.is_ascii_digit() || byte == b'.' => self.parse_number(),
|
||||
Some(byte) if byte.is_ascii_alphabetic() || byte == b'_' => self.parse_identifier(),
|
||||
Some(_) => Err(self.error("expected a number, constant, function, or '('")),
|
||||
None => Err(self.error("unexpected end of expression")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number(&mut self) -> Result<f64, String> {
|
||||
self.skip_whitespace();
|
||||
let start = self.position;
|
||||
let mut digits = 0;
|
||||
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
|
||||
self.position += 1;
|
||||
digits += 1;
|
||||
}
|
||||
if self.peek() == Some(b'.') {
|
||||
self.position += 1;
|
||||
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
|
||||
self.position += 1;
|
||||
digits += 1;
|
||||
}
|
||||
}
|
||||
if digits == 0 {
|
||||
return Err(self.error("invalid number"));
|
||||
}
|
||||
if matches!(self.peek(), Some(b'e' | b'E')) {
|
||||
self.position += 1;
|
||||
if matches!(self.peek(), Some(b'+' | b'-')) {
|
||||
self.position += 1;
|
||||
}
|
||||
let exponent_start = self.position;
|
||||
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
|
||||
self.position += 1;
|
||||
}
|
||||
if self.position == exponent_start {
|
||||
return Err(self.error("invalid numeric exponent"));
|
||||
}
|
||||
}
|
||||
|
||||
self.input[start..self.position]
|
||||
.parse::<f64>()
|
||||
.map_err(|_| self.error("invalid number"))
|
||||
}
|
||||
|
||||
fn parse_identifier(&mut self) -> Result<f64, String> {
|
||||
self.skip_whitespace();
|
||||
let start = self.position;
|
||||
while self
|
||||
.peek()
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
self.position += 1;
|
||||
}
|
||||
let name = self.input[start..self.position].to_ascii_lowercase();
|
||||
self.skip_whitespace();
|
||||
if self.peek() != Some(b'(') {
|
||||
return match name.as_str() {
|
||||
"pi" => Ok(std::f64::consts::PI),
|
||||
"e" => Ok(std::f64::consts::E),
|
||||
_ => Err(self.error(&format!("unknown constant or variable '{name}'"))),
|
||||
};
|
||||
}
|
||||
|
||||
self.position += 1;
|
||||
let mut arguments = Vec::new();
|
||||
self.skip_whitespace();
|
||||
if self.peek() != Some(b')') {
|
||||
loop {
|
||||
arguments.push(self.nested(Self::parse_expression)?);
|
||||
if self.consume(b',') {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !self.consume(b')') {
|
||||
return Err(self.error("expected ')' after function arguments"));
|
||||
}
|
||||
apply_function(&name, &arguments).map_err(|message| self.error(&message))
|
||||
}
|
||||
|
||||
fn consume(&mut self, expected: u8) -> bool {
|
||||
self.skip_whitespace();
|
||||
if self.peek() == Some(expected) {
|
||||
self.position += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_whitespace(&mut self) {
|
||||
while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
|
||||
self.position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.input.as_bytes().get(self.position).copied()
|
||||
}
|
||||
|
||||
fn nested<T>(&mut self, parse: fn(&mut Self) -> Result<T, String>) -> Result<T, String> {
|
||||
const MAX_PARSE_DEPTH: usize = 128;
|
||||
if self.depth >= MAX_PARSE_DEPTH {
|
||||
return Err(self.error("expression nesting limit exceeded"));
|
||||
}
|
||||
self.depth += 1;
|
||||
let result = parse(self);
|
||||
self.depth -= 1;
|
||||
result
|
||||
}
|
||||
|
||||
fn error(&self, message: &str) -> String {
|
||||
format!("{message} at byte {}", self.position)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_function(name: &str, arguments: &[f64]) -> Result<f64, String> {
|
||||
let unary = |function: fn(f64) -> f64| match arguments {
|
||||
[value] => Ok(function(*value)),
|
||||
_ => Err(format!("function '{name}' expects one argument")),
|
||||
};
|
||||
match name {
|
||||
"sqrt" => unary(f64::sqrt),
|
||||
"abs" => unary(f64::abs),
|
||||
"exp" => unary(f64::exp),
|
||||
"ln" => unary(f64::ln),
|
||||
"log2" => unary(f64::log2),
|
||||
"log10" => unary(f64::log10),
|
||||
"sin" => unary(f64::sin),
|
||||
"cos" => unary(f64::cos),
|
||||
"tan" => unary(f64::tan),
|
||||
"asin" => unary(f64::asin),
|
||||
"acos" => unary(f64::acos),
|
||||
"atan" => unary(f64::atan),
|
||||
"sinh" => unary(f64::sinh),
|
||||
"cosh" => unary(f64::cosh),
|
||||
"tanh" => unary(f64::tanh),
|
||||
"asinh" => unary(f64::asinh),
|
||||
"acosh" => unary(f64::acosh),
|
||||
"atanh" => unary(f64::atanh),
|
||||
"floor" => unary(f64::floor),
|
||||
"ceil" => unary(f64::ceil),
|
||||
"round" => unary(f64::round),
|
||||
"signum" => unary(f64::signum),
|
||||
"atan2" => match arguments {
|
||||
[y, x] => Ok(y.atan2(*x)),
|
||||
_ => Err("function 'atan2' expects two arguments".to_string()),
|
||||
},
|
||||
"min" => arguments
|
||||
.iter()
|
||||
.copied()
|
||||
.reduce(f64::min)
|
||||
.ok_or_else(|| "function 'min' expects at least one argument".to_string()),
|
||||
"max" => arguments
|
||||
.iter()
|
||||
.copied()
|
||||
.reduce(f64::max)
|
||||
.ok_or_else(|| "function 'max' expects at least one argument".to_string()),
|
||||
_ => Err(format!("unknown function '{name}'")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn respects_precedence_and_right_associative_power() {
|
||||
assert_eq!(evaluate("15*3+5^(2+1)").unwrap(), 170.0);
|
||||
assert_eq!(evaluate("2^3^2").unwrap(), 512.0);
|
||||
assert_eq!(evaluate("-2^2").unwrap(), -4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_constants_functions_and_scientific_notation() {
|
||||
assert_eq!(evaluate("sqrt(1.44e2)").unwrap(), 12.0);
|
||||
assert_eq!(evaluate("max(1, 2, 3) + min(4, 5)").unwrap(), 7.0);
|
||||
assert!((evaluate("sin(pi / 2)").unwrap() - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_names_and_trailing_input() {
|
||||
assert!(evaluate("unknown").is_err());
|
||||
assert!(evaluate("1 + 2 garbage").is_err());
|
||||
assert!(evaluate("sqrt() ").is_err());
|
||||
assert!(evaluate(&"(".repeat(129)).is_err());
|
||||
assert!(evaluate(&"1+".repeat(3000)).is_err());
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ pub mod chat_manager;
|
||||
pub mod content_search;
|
||||
pub mod cron;
|
||||
pub mod delegate;
|
||||
mod expression;
|
||||
pub mod file_edit;
|
||||
pub mod file_read;
|
||||
pub mod file_search;
|
||||
@ -77,10 +78,10 @@ pub fn create_default_tools(
|
||||
registry.register(TimelineRecallTool::new(memory.clone()));
|
||||
registry.register(MemoryForgetTool::new(memory.clone()));
|
||||
|
||||
if let Some(cfg) = browser_config {
|
||||
if cfg.enabled {
|
||||
registry.register(BrowserTool::new(cfg));
|
||||
}
|
||||
if let Some(cfg) = browser_config
|
||||
&& cfg.enabled
|
||||
{
|
||||
registry.register(BrowserTool::new(cfg));
|
||||
}
|
||||
|
||||
if let Some(mgr) = sub_agent_manager {
|
||||
|
||||
@ -146,6 +146,12 @@ pub struct PtyManager {
|
||||
sessions: Mutex<HashMap<String, Arc<Mutex<PtySession>>>>,
|
||||
}
|
||||
|
||||
impl Default for PtyManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PtyManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@ -199,7 +205,7 @@ impl PtyManager {
|
||||
.map_err(|e| format!("Failed to open PTY: {}", e))?;
|
||||
|
||||
let mut cmd = portable_pty::CommandBuilder::new("bash");
|
||||
cmd.args(&["-c", command]);
|
||||
cmd.args(["-c", command]);
|
||||
cmd.cwd(cwd);
|
||||
|
||||
let child = pty_pair
|
||||
|
||||
@ -169,7 +169,7 @@ fn parse_files_arg(args: &serde_json::Value) -> Vec<MediaItem> {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|path| path_to_media_item(path))
|
||||
.map(path_to_media_item)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ fn test_message_special_characters() {
|
||||
/// Test that multi-line system prompt is preserved
|
||||
#[test]
|
||||
fn test_multiline_system_prompt() {
|
||||
let messages = vec![
|
||||
let messages = [
|
||||
Message::system(
|
||||
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
|
||||
),
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
/// Integration tests for the scheduled tasks (cron) system.
|
||||
/// Run with: cargo test --test test_scheduler
|
||||
use serde_json::json;
|
||||
//! Integration tests for the scheduled tasks (cron) system.
|
||||
//! Run with: `cargo test --test test_scheduler`.
|
||||
|
||||
/// Verify that Schedule types (de)serialize correctly.
|
||||
#[tokio::test]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user