diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 72feac5..eb3697c 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -2348,6 +2348,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index 7a530c6..3de2773 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -1410,6 +1410,7 @@ mod tests { max_tool_iterations: 100, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 2_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 10, max_image_age_rounds: 50, }; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 853ff20..f0de9d8 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -8,7 +8,7 @@ pub use agent_loop::{ PersistingEmittedMessageHandler, SkillProvider, }; pub use context_compressor::ContextCompressor; -pub use runtime_config::AgentRuntimeConfig; +pub use runtime_config::{AgentRuntimeConfig, DEFAULT_MAX_RUN_SECS}; pub use system_prompt::{ CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider, generate_system_env_prompt, diff --git a/src/agent/runtime_config.rs b/src/agent/runtime_config.rs index 7200056..8c43326 100644 --- a/src/agent/runtime_config.rs +++ b/src/agent/runtime_config.rs @@ -51,7 +51,7 @@ impl From for AgentRuntimeConfig { max_images_in_context: config.max_images_in_context, max_image_age_rounds: config.max_image_age_rounds, max_retries: config.max_retries, - max_run_secs: DEFAULT_MAX_RUN_SECS, + max_run_secs: config.max_run_secs, } } } diff --git a/src/cli/init.rs b/src/cli/init.rs index 7d277ec..8e96f12 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -564,6 +564,7 @@ impl InitWizard { max_tool_iterations: 1000, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 2000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, }; let mut agents = existing.agents.clone(); @@ -629,6 +630,7 @@ impl InitWizard { max_tool_iterations: current_agent.max_tool_iterations, tool_result_max_chars: current_agent.tool_result_max_chars, context_tool_result_trim_chars: current_agent.context_tool_result_trim_chars, + max_run_secs: current_agent.max_run_secs, }; let mut agents = existing.agents.clone(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 38d966f..7a4714b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -571,6 +571,10 @@ pub struct AgentConfig { pub tool_result_max_chars: usize, #[serde(default = "default_context_tool_result_trim_chars")] pub context_tool_result_trim_chars: usize, + /// 单次 agent run 的墙钟预算(秒)。超时后优雅退出并提示用户可继续。 + /// 0 表示不限制。默认见 default_max_run_secs。 + #[serde(default = "default_max_run_secs")] + pub max_run_secs: u64, } fn default_max_tool_iterations() -> usize { @@ -597,6 +601,10 @@ fn default_max_retries() -> u32 { 3 } +fn default_max_run_secs() -> u64 { + crate::agent::DEFAULT_MAX_RUN_SECS +} + fn default_mcp_tool_timeout_secs() -> u64 { 300 } @@ -982,6 +990,8 @@ pub struct LLMProviderConfig { pub max_tool_iterations: usize, pub tool_result_max_chars: usize, pub context_tool_result_trim_chars: usize, + /// 单次 agent run 墙钟预算(秒,透传自 AgentConfig)。0 表示不限制。 + pub max_run_secs: u64, /// 图片上下文限制配置 pub max_images_in_context: usize, pub max_image_age_rounds: usize, @@ -1130,6 +1140,7 @@ impl Config { max_tool_iterations: agent.max_tool_iterations, tool_result_max_chars: agent.tool_result_max_chars, context_tool_result_trim_chars: agent.context_tool_result_trim_chars, + max_run_secs: agent.max_run_secs, max_images_in_context: self.image_context.max_images_in_context, max_image_age_rounds: self.image_context.max_image_age_rounds, }) diff --git a/src/gateway/agent_prompt_provider.rs b/src/gateway/agent_prompt_provider.rs index 2332ef4..aa81321 100644 --- a/src/gateway/agent_prompt_provider.rs +++ b/src/gateway/agent_prompt_provider.rs @@ -129,6 +129,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 20_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } diff --git a/src/gateway/provider_config_service.rs b/src/gateway/provider_config_service.rs index 6aaabb4..023c4bf 100644 --- a/src/gateway/provider_config_service.rs +++ b/src/gateway/provider_config_service.rs @@ -68,6 +68,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 20_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 1177732..cb188ae 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -1372,6 +1372,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, } @@ -1696,6 +1697,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -1754,6 +1756,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -1833,6 +1836,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -1910,6 +1914,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2051,6 +2056,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2123,6 +2129,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2205,6 +2212,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2296,6 +2304,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2386,6 +2395,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2458,6 +2468,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2540,6 +2551,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; @@ -2608,6 +2620,7 @@ mod tests { max_retries: 3, tool_result_max_chars: 100_000, context_tool_result_trim_chars: 100_000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index e666697..0209bce 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -642,10 +642,30 @@ impl SessionStore { } let conn = self.pool.get()?; let mut total = 0; + let mut affected_topics: Vec = Vec::new(); // SQLite 绑定变量上限(SQLITE_MAX_VARIABLE_NUMBER 默认 999), // 分批构造 IN 子句,避免超限。 for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); + + // 先收集将被删除行的 topic_id,用于删除后重算 topics.message_count + let topic_sql = format!( + "SELECT DISTINCT topic_id FROM messages + WHERE session_id = ? AND topic_id IS NOT NULL AND id IN ({placeholders})" + ); + let mut topic_params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1); + topic_params.push(&session_id); + for id in chunk { + topic_params.push(id); + } + let mut stmt = conn.prepare(&topic_sql)?; + let rows = stmt.query_map(topic_params.as_slice(), |row| { + row.get::<_, String>(0) + })?; + for tid in rows { + affected_topics.push(tid?); + } + let sql = format!( "DELETE FROM messages WHERE session_id = ? AND id IN ({placeholders})" ); @@ -656,6 +676,28 @@ impl SessionStore { } total += conn.execute(&sql, params_vec.as_slice())?; } + + // 计数列与消息行的一致性:sessions/topics 的 message_count 是增量维护列 + // (append +1、clear 归零),删除后必须同步。用活 COUNT 重算而非减法, + // 自愈历史漂移且不会减为负数。 + if total > 0 { + conn.execute( + "UPDATE sessions SET message_count = + (SELECT COUNT(*) FROM messages WHERE session_id = ?1) + WHERE id = ?1", + params![session_id], + )?; + affected_topics.sort_unstable(); + affected_topics.dedup(); + for tid in &affected_topics { + conn.execute( + "UPDATE topics SET message_count = + (SELECT COUNT(*) FROM messages WHERE topic_id = ?1) + WHERE id = ?1", + params![tid], + )?; + } + } Ok(total) } diff --git a/src/storage/tests.rs b/src/storage/tests.rs index 2d86038..a504847 100644 --- a/src/storage/tests.rs +++ b/src/storage/tests.rs @@ -981,6 +981,8 @@ fn test_delete_messages_by_ids_removes_only_target_rows() { .unwrap(), 0 ); - // 消息计数同步修正 + // 消息计数同步修正(活查询 + 计数列均一致) assert_eq!(store.get_topic_message_count(&topic.id).unwrap(), 3); + let session_after = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(session_after.message_count, 3); } diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index 94e7570..ceae373 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -134,6 +134,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 1000, context_tool_result_trim_chars: 1000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }, @@ -168,6 +169,7 @@ mod tests { max_tool_iterations: 1, tool_result_max_chars: 1000, context_tool_result_trim_chars: 1000, + max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }, diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 7c13f5d..05e654f 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1039,7 +1039,18 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { let topic_id = session.parent_topic_id.clone().unwrap_or_default(); let task_id = session.id.clone(); - // 8a. INSERT pending_subagents 记录 + // 8a. 注册 cancel_registry(必须先于 DB 写入 running 记录)。 + // reap_orphan_subagents 以"registry 中存在条目"判定执行任务存活, + // 因此不变量为:DB 出现 running 记录时 registry 必然已有条目。 + // 若顺序颠倒,spawn 间隙(DB 已写、registry 未写)的并发 reap + // 会把刚启动的子代理误标为 interrupted。 + let cancel_registry = self.cancel_registry.clone(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + cancel_registry + .lock() + .insert(task_id.clone(), cancel_token.clone()); + + // 8b. INSERT pending_subagents 记录 let pending_record = PendingSubagentRecord { task_id: task_id.clone(), parent_session_id: session.parent_session_id.clone(), @@ -1058,7 +1069,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { ); } - // 8b. tokio::spawn 后台执行子代理 + // 8c. tokio::spawn 后台执行子代理 let store = self.store.clone(); let task_repository = self.task_repository.clone(); let bus = self.bus.clone(); @@ -1070,16 +1081,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { let max_secs = self.effective_max_execution_secs(&def); let task_id_for_spawn = task_id.clone(); let semaphore = self.semaphore.clone(); - let cancel_registry = self.cancel_registry.clone(); - - // 创建 CancellationToken 并注册到 registry(供 /stop 取消传播) - let cancel_token = tokio_util::sync::CancellationToken::new(); - cancel_registry - .lock() - .insert(task_id_for_spawn.clone(), cancel_token.clone()); // RAII guard:spawn 任务退出时(正常/early return/panic)确定性清理 registry // 不变量 3:清理与作用域绑定,避免末行清理被 panic 绕过 + // (cancel_token 已在 8a 注册,此处仅绑定生命周期清理) let registry_guard = CancelRegistryGuard::new(task_id_for_spawn.clone(), cancel_registry.clone()); diff --git a/tests/test_integration.rs b/tests/test_integration.rs index 3ab6cf7..560fc67 100644 --- a/tests/test_integration.rs +++ b/tests/test_integration.rs @@ -45,6 +45,7 @@ fn load_config() -> Option { max_tool_iterations: 20, tool_result_max_chars: 20_000, context_tool_result_trim_chars: 20_000, + max_run_secs: picobot::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, }) diff --git a/tests/test_tool_calling.rs b/tests/test_tool_calling.rs index 5eea5a5..07b1adc 100644 --- a/tests/test_tool_calling.rs +++ b/tests/test_tool_calling.rs @@ -47,6 +47,7 @@ fn load_openai_config() -> Option { max_tool_iterations: 20, tool_result_max_chars: 20_000, context_tool_result_trim_chars: 20_000, + max_run_secs: picobot::agent::DEFAULT_MAX_RUN_SECS, max_images_in_context: 1, max_image_age_rounds: 10, })