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:
xiaoxixi 2026-07-14 11:00:39 +08:00
parent 3d580828b5
commit 63d20d1eb8
27 changed files with 461 additions and 187 deletions

View File

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

View File

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

View File

@ -403,8 +403,8 @@ 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
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();
@ -416,7 +416,6 @@ impl ContextCompressor {
msg.tool_calls = None;
}
}
}
Self::repair_tool_pairs(&mut truncated);
@ -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

View File

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

View File

@ -158,14 +158,12 @@ 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 {
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 {
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,8 +564,9 @@ 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 {
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;
@ -575,7 +574,6 @@ impl SubAgentManager {
}
}
}
}
pub fn active_task_count(&self) -> usize {
self.active_tasks.len()

View File

@ -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")
}
}

View File

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

View File

@ -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,14 +1768,13 @@ 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();
}
}
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]看完继续说"
);
}
}

View File

@ -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)
}

View File

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

View File

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

View File

@ -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,11 +115,11 @@ impl OpenAIProvider {
"role": m.role,
"content": convert_content_blocks(&m.content)
});
if m.role == "assistant" {
if let Some(ref rc) = m.reasoning_content {
if m.role == "assistant"
&& let Some(ref rc) = m.reasoning_content
{
msg["reasoning_content"] = json!(rc);
}
}
msg
}
}).collect::<Vec<_>>(),
@ -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,
},

View File

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

View File

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

View File

@ -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)
}
}

View File

@ -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")?,

View File

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

View File

@ -39,13 +39,13 @@ 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() {
if let Ok(mut driver) = self.driver.lock()
&& let Some(ref mut child) = driver.take()
{
tracing::debug!("Stopping chromedriver process");
let _ = child.start_kill();
}
}
}
}
impl BrowserTool {
@ -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,13 +823,13 @@ 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() {
if let Ok(mut guard) = driver.lock()
&& let Some(ref mut child) = guard.take()
{
tracing::debug!("Stopping chromedriver process");
let _ = child.start_kill();
}
}
}
async fn ensure_session(
&mut self,
@ -957,11 +954,11 @@ 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() {
if let Ok(mut guard) = driver.lock()
&& let Some(ref mut child) = guard.take()
{
let _ = child.start_kill();
}
}
}
async fn wait_for_webdriver_ready(webdriver_url: &str) -> anyhow::Result<()> {

View File

@ -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}"))
}

View File

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

View File

@ -303,12 +303,12 @@ 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 {
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,
output,

277
src/tools/expression.rs Normal file
View 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());
}
}

View File

@ -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,11 +78,11 @@ 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 {
if let Some(cfg) = browser_config
&& cfg.enabled
{
registry.register(BrowserTool::new(cfg));
}
}
if let Some(mgr) = sub_agent_manager {
registry.register(DelegateTool::new(mgr));

View File

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

View File

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

View File

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

View File

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