feat(v0.8.45): land control-plane workstream slices (#2035)
Includes cancellable file_search/list_dir behavior, deterministic sub-agent whale-species nicknames, and an honest /balance command scaffold for the provider-billing work. Reviewed the overlapping file-search cancellation contribution in #2044 from @h3c-hexin; that PR's Windows failure was unrelated to the cancellation code, and the contributor context is preserved here.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
//! Balance: query the active provider's account balance or credit status.
|
||||
//!
|
||||
//! Provider-specific network dispatch is still pending. Until that lands, keep
|
||||
//! this command explicit about being a scaffold so users do not mistake it for
|
||||
//! a live balance lookup.
|
||||
|
||||
use crate::config::ApiProvider;
|
||||
use crate::tui::app::App;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Query provider account balance / credits.
|
||||
pub fn balance(app: &mut App) -> CommandResult {
|
||||
let provider = app.api_provider;
|
||||
match provider {
|
||||
ApiProvider::Deepseek
|
||||
| ApiProvider::DeepseekCN
|
||||
| ApiProvider::Openrouter
|
||||
| ApiProvider::Novita => CommandResult::message(format!(
|
||||
"Balance check for {} is planned, but provider balance network dispatch is not wired in this build yet.",
|
||||
provider.display_name()
|
||||
)),
|
||||
_ => CommandResult::message(format!(
|
||||
"Balance check is not supported for {} yet. Check the provider dashboard for account balance details.",
|
||||
provider.display_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod anchor;
|
||||
mod attachment;
|
||||
mod balance;
|
||||
mod change;
|
||||
mod config;
|
||||
mod core;
|
||||
@@ -518,6 +519,13 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
usage: "/cost",
|
||||
description_id: MessageId::CmdCostDescription,
|
||||
},
|
||||
// Balance query (#2019)
|
||||
CommandInfo {
|
||||
name: "balance",
|
||||
aliases: &[],
|
||||
usage: "/balance",
|
||||
description_id: MessageId::CmdBalanceDescription,
|
||||
},
|
||||
// Profile switching (#390)
|
||||
CommandInfo {
|
||||
name: "profile",
|
||||
@@ -603,6 +611,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"translate" | "translation" | "transale" => core::translate(app),
|
||||
"tokens" => debug::tokens(app),
|
||||
"cost" => debug::cost(app),
|
||||
"balance" => balance::balance(app),
|
||||
"cache" => debug::cache(app, arg),
|
||||
|
||||
// ChangeLog command
|
||||
@@ -1063,7 +1072,7 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
|
||||
use crate::tools::todo::TodoStatus;
|
||||
use crate::tui::app::{App, AppAction, TuiOptions};
|
||||
@@ -1485,6 +1494,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_command_has_own_help_text() {
|
||||
let info = get_command_info("balance").expect("balance command should be registered");
|
||||
assert_eq!(info.description_id, MessageId::CmdBalanceDescription);
|
||||
assert!(
|
||||
info.description_for(Locale::En)
|
||||
.contains("provider account balance")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_command_reports_scaffold_without_claiming_dispatch() {
|
||||
let mut app = create_test_app();
|
||||
app.api_provider = ApiProvider::Deepseek;
|
||||
|
||||
let result = execute("/balance", &mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("balance scaffold should explain current state");
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(msg.contains("DeepSeek"));
|
||||
assert!(msg.contains("not wired"));
|
||||
assert!(!msg.contains("sent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_command_reports_unsupported_provider_clearly() {
|
||||
let mut app = create_test_app();
|
||||
app.api_provider = ApiProvider::Ollama;
|
||||
|
||||
let result = execute("/balance", &mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("unsupported providers should return a clear message");
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(msg.contains("Ollama"));
|
||||
assert!(msg.contains("not supported"));
|
||||
assert!(msg.contains("dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_suggests_nearest_match() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -252,6 +252,7 @@ pub enum MessageId {
|
||||
CmdChangeTranslationQueued,
|
||||
CmdChangeTranslationUnavailable,
|
||||
CmdChangePreviousVersion,
|
||||
CmdBalanceDescription,
|
||||
CmdClearDescription,
|
||||
CmdCompactDescription,
|
||||
CmdConfigDescription,
|
||||
@@ -486,6 +487,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::HelpFooterClose,
|
||||
MessageId::CmdAnchorDescription,
|
||||
MessageId::CmdAttachDescription,
|
||||
MessageId::CmdBalanceDescription,
|
||||
MessageId::CmdCacheDescription,
|
||||
MessageId::CmdClearDescription,
|
||||
MessageId::CmdCompactDescription,
|
||||
@@ -915,6 +917,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
MessageId::CmdChangePreviousVersion => {
|
||||
"Previous version: {version} — run `/change {version}` to view it"
|
||||
}
|
||||
MessageId::CmdBalanceDescription => "Check the active provider account balance",
|
||||
MessageId::CmdClearDescription => "Clear conversation history",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)"
|
||||
@@ -1294,6 +1297,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdChangePreviousVersion => {
|
||||
"前のバージョン: {version} — `/change {version}` で表示"
|
||||
}
|
||||
MessageId::CmdBalanceDescription => "アクティブなプロバイダーのアカウント残高を確認",
|
||||
MessageId::CmdClearDescription => "会話履歴をクリア",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
|
||||
@@ -1648,6 +1652,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdChangePreviousVersion => {
|
||||
"上一个版本: {version} —— 输入 `/change {version}` 查看"
|
||||
}
|
||||
MessageId::CmdBalanceDescription => "查看当前提供商账户余额",
|
||||
MessageId::CmdClearDescription => "清除对话历史",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
|
||||
@@ -1962,6 +1967,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdChangePreviousVersion => {
|
||||
"Versão anterior: {version} — execute `/change {version}` para visualizar"
|
||||
}
|
||||
MessageId::CmdBalanceDescription => "Verificar o saldo da conta do provedor ativo",
|
||||
MessageId::CmdClearDescription => "Limpar o histórico da conversa",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"
|
||||
@@ -2348,6 +2354,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdChangePreviousVersion => {
|
||||
"Versión anterior: {version} — ejecuta `/change {version}` para verla"
|
||||
}
|
||||
MessageId::CmdBalanceDescription => "Consultar el saldo de la cuenta del proveedor activo",
|
||||
MessageId::CmdClearDescription => "Limpiar el historial de la conversación",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Compactar el contexto para liberar espacio (heredado; v0.6.6 prefiere reinicio de ciclo)"
|
||||
|
||||
+136
-20
@@ -11,8 +11,10 @@ use super::spec::{
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// === ReadFileTool ===
|
||||
|
||||
@@ -761,6 +763,8 @@ fn punctuation_normalized_matches(contents: &str, search: &str) -> Vec<(usize, u
|
||||
/// Tool for listing directory contents.
|
||||
pub struct ListDirTool;
|
||||
|
||||
const LIST_DIR_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for ListDirTool {
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -796,30 +800,107 @@ impl ToolSpec for ListDirTool {
|
||||
let path_str = optional_str(&input, "path").unwrap_or(".");
|
||||
let dir_path = context.resolve_path(path_str)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&dir_path).map_err(|e| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read directory {}: {}",
|
||||
dir_path.display(),
|
||||
e
|
||||
))
|
||||
})? {
|
||||
let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
|
||||
entries.push(json!({
|
||||
"name": entry.file_name().to_string_lossy().to_string(),
|
||||
"is_dir": file_type.is_dir(),
|
||||
}));
|
||||
}
|
||||
let entries =
|
||||
list_dir_entries_async(dir_path, context.cancel_token.clone(), LIST_DIR_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_dir_entries_async(
|
||||
dir_path: PathBuf,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<Value>, ToolError> {
|
||||
let worker_cancel_token = cancel_token.clone();
|
||||
run_blocking_list_dir(timeout, cancel_token, move || {
|
||||
list_dir_entries(&dir_path, worker_cancel_token.as_ref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_blocking_list_dir<F>(
|
||||
timeout: Duration,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
list_dir: F,
|
||||
) -> Result<Vec<Value>, ToolError>
|
||||
where
|
||||
F: FnOnce() -> Result<Vec<Value>, ToolError> + Send + 'static,
|
||||
{
|
||||
if cancel_token
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled)
|
||||
{
|
||||
return Err(list_dir_cancelled());
|
||||
}
|
||||
|
||||
let task = tokio::task::spawn_blocking(list_dir);
|
||||
let result = match cancel_token {
|
||||
Some(token) => {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = token.cancelled() => return Err(list_dir_cancelled()),
|
||||
result = tokio::time::timeout(timeout, task) => result,
|
||||
}
|
||||
}
|
||||
None => tokio::time::timeout(timeout, task).await,
|
||||
};
|
||||
|
||||
let joined = result.map_err(|_| list_dir_timeout(timeout))?;
|
||||
joined.map_err(|err| {
|
||||
ToolError::execution_failed(format!("list_dir worker failed before completion: {err}"))
|
||||
})?
|
||||
}
|
||||
|
||||
fn list_dir_entries(
|
||||
dir_path: &Path,
|
||||
cancel_token: Option<&CancellationToken>,
|
||||
) -> Result<Vec<Value>, ToolError> {
|
||||
check_list_dir_cancelled(cancel_token)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(dir_path).map_err(|e| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read directory {}: {}",
|
||||
dir_path.display(),
|
||||
e
|
||||
))
|
||||
})? {
|
||||
check_list_dir_cancelled(cancel_token)?;
|
||||
|
||||
let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
|
||||
entries.push(json!({
|
||||
"name": entry.file_name().to_string_lossy().to_string(),
|
||||
"is_dir": file_type.is_dir(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn check_list_dir_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
|
||||
if cancel_token.is_some_and(CancellationToken::is_cancelled) {
|
||||
return Err(list_dir_cancelled());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_dir_cancelled() -> ToolError {
|
||||
ToolError::execution_failed("list_dir cancelled before completion")
|
||||
}
|
||||
|
||||
fn list_dir_timeout(timeout: Duration) -> ToolError {
|
||||
ToolError::Timeout {
|
||||
seconds: timeout.as_secs().max(1),
|
||||
}
|
||||
}
|
||||
|
||||
// === Unit Tests ===
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1647,6 +1728,41 @@ mod tests {
|
||||
assert!(result.content.contains("nested.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_dir_respects_cancel_token() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("file.txt"), "").expect("write");
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
|
||||
|
||||
let tool = ListDirTool;
|
||||
let err = tool
|
||||
.execute(json!({}), &ctx)
|
||||
.await
|
||||
.expect_err("cancelled list_dir should return an error");
|
||||
|
||||
assert!(
|
||||
format!("{err:?}").contains("cancelled"),
|
||||
"unexpected error: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_dir_blocking_wrapper_reports_timeout() {
|
||||
let err = run_blocking_list_dir(Duration::from_millis(1), None, || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
Ok(Vec::new())
|
||||
})
|
||||
.await
|
||||
.expect_err("slow list_dir worker should time out");
|
||||
|
||||
assert!(
|
||||
matches!(err, ToolError::Timeout { seconds: 1 }),
|
||||
"unexpected error: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_file_tool_properties() {
|
||||
let tool = ReadFileTool;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! File search tool with fuzzy matching and scoring.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use ignore::WalkBuilder;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::tools::search::matches_glob;
|
||||
|
||||
@@ -15,6 +17,8 @@ use super::spec::{
|
||||
optional_str, optional_u64, required_str,
|
||||
};
|
||||
|
||||
const FILE_SEARCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FileSearchMatch {
|
||||
path: String,
|
||||
@@ -87,11 +91,88 @@ impl ToolSpec for FileSearchTool {
|
||||
|
||||
let extensions = parse_extensions(&input);
|
||||
let exclude_patterns = parse_exclude_patterns(&input);
|
||||
let matches = search_files(query, &base_path, extensions, exclude_patterns, limit)?;
|
||||
let matches = search_files_async(
|
||||
query.to_string(),
|
||||
base_path,
|
||||
extensions,
|
||||
exclude_patterns,
|
||||
limit,
|
||||
context.cancel_token.clone(),
|
||||
FILE_SEARCH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_files_async(
|
||||
query: String,
|
||||
base_path: PathBuf,
|
||||
extensions: Vec<String>,
|
||||
exclude_patterns: Vec<String>,
|
||||
limit: usize,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<FileSearchMatch>, ToolError> {
|
||||
let worker_cancel_token = cancel_token.clone();
|
||||
run_blocking_file_search(timeout, cancel_token, move || {
|
||||
search_files(
|
||||
&query,
|
||||
&base_path,
|
||||
extensions,
|
||||
exclude_patterns,
|
||||
limit,
|
||||
worker_cancel_token.as_ref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_blocking_file_search<F>(
|
||||
timeout: Duration,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
search: F,
|
||||
) -> Result<Vec<FileSearchMatch>, ToolError>
|
||||
where
|
||||
F: FnOnce() -> Result<Vec<FileSearchMatch>, ToolError> + Send + 'static,
|
||||
{
|
||||
if cancel_token
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled)
|
||||
{
|
||||
return Err(file_search_cancelled());
|
||||
}
|
||||
|
||||
let task = tokio::task::spawn_blocking(search);
|
||||
let result = match cancel_token {
|
||||
Some(token) => {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = token.cancelled() => return Err(file_search_cancelled()),
|
||||
result = tokio::time::timeout(timeout, task) => result,
|
||||
}
|
||||
}
|
||||
None => tokio::time::timeout(timeout, task).await,
|
||||
};
|
||||
|
||||
let joined = result.map_err(|_| file_search_timeout(timeout))?;
|
||||
joined.map_err(|err| {
|
||||
ToolError::execution_failed(format!(
|
||||
"file_search worker failed before completion: {err}"
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
fn file_search_cancelled() -> ToolError {
|
||||
ToolError::execution_failed("file_search cancelled before completion")
|
||||
}
|
||||
|
||||
fn file_search_timeout(timeout: Duration) -> ToolError {
|
||||
ToolError::Timeout {
|
||||
seconds: timeout.as_secs().max(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_extensions(input: &Value) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) {
|
||||
@@ -147,7 +228,10 @@ fn search_files(
|
||||
extensions: Vec<String>,
|
||||
exclude_patterns: Vec<String>,
|
||||
limit: usize,
|
||||
cancel_token: Option<&CancellationToken>,
|
||||
) -> Result<Vec<FileSearchMatch>, ToolError> {
|
||||
check_cancelled(cancel_token)?;
|
||||
|
||||
if !base_path.exists() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Base path does not exist: {}",
|
||||
@@ -163,6 +247,8 @@ fn search_files(
|
||||
let walker = builder.build();
|
||||
|
||||
for entry in walker {
|
||||
check_cancelled(cancel_token)?;
|
||||
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
@@ -206,6 +292,13 @@ fn search_files(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
|
||||
if cancel_token.is_some_and(CancellationToken::is_cancelled) {
|
||||
return Err(file_search_cancelled());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool {
|
||||
exclude_patterns
|
||||
.iter()
|
||||
@@ -408,6 +501,42 @@ mod tests {
|
||||
assert!(!result.content.contains("target/needle.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_search_respects_cancel_token() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
let ctx = ToolContext::new(root.to_path_buf()).with_cancel_token(cancel_token);
|
||||
|
||||
let tool = FileSearchTool;
|
||||
let err = tool
|
||||
.execute(json!({"query": "needle"}), &ctx)
|
||||
.await
|
||||
.expect_err("cancelled file_search should return an error");
|
||||
|
||||
assert!(
|
||||
format!("{err:?}").contains("cancelled"),
|
||||
"unexpected error: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_search_blocking_wrapper_reports_timeout() {
|
||||
let err = run_blocking_file_search(Duration::from_millis(1), None, || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
Ok(Vec::new())
|
||||
})
|
||||
.await
|
||||
.expect_err("slow file_search worker should time out");
|
||||
|
||||
assert!(
|
||||
matches!(err, ToolError::Timeout { seconds: 1 }),
|
||||
"unexpected error: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn test_file_search_does_not_follow_symlinked_files() {
|
||||
|
||||
@@ -86,10 +86,17 @@ const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart";
|
||||
|
||||
const VALID_SUBAGENT_TYPES: &str = "general, explore, plan, review, implementer, verifier, tool_agent, custom, \
|
||||
worker, explorer, awaiter, default, implement, builder, verify, validator, tester, tool-agent, executor, fin";
|
||||
/// Whale species names rotated through `whale_nickname_for_index` to label
|
||||
/// sub-agents in the UI. English and Simplified-Chinese names are interleaved
|
||||
/// so any newly spawned agent has a roughly even chance of either — the goal
|
||||
/// is friendly variety, not a strict locale match.
|
||||
/// Whale species used as friendly names for sub-agents in the UI. The full
|
||||
/// Cetacea infraorder — baleen whales (Mysticeti), toothed whales
|
||||
/// (Odontoceti), plus select dolphin species (family Delphinidae) that
|
||||
/// don't conflate with existing agent type labels. Porpoises (Phocoenidae)
|
||||
/// are excluded because their name doesn't carry well as a friendly label.
|
||||
///
|
||||
/// English and Simplified-Chinese names are interleaved so any newly spawned
|
||||
/// agent has a roughly even chance of either — the goal is friendly variety,
|
||||
/// not a strict locale match.
|
||||
///
|
||||
/// Taxonomy source: Society for Marine Mammalogy (2025).
|
||||
pub const WHALE_NICKNAMES: &[&str] = &[
|
||||
"Blue",
|
||||
"蓝鲸",
|
||||
@@ -107,6 +114,14 @@ pub const WHALE_NICKNAMES: &[&str] = &[
|
||||
"小须鲸",
|
||||
"Antarctic Minke",
|
||||
"南极小须鲸",
|
||||
"Pygmy Right",
|
||||
"小露脊鲸",
|
||||
"Omura's",
|
||||
"大村鲸",
|
||||
"Eden's",
|
||||
"艾氏鲸",
|
||||
"Rice's",
|
||||
"赖斯鲸",
|
||||
"Gray",
|
||||
"灰鲸",
|
||||
"Bowhead",
|
||||
@@ -139,8 +154,99 @@ pub const WHALE_NICKNAMES: &[&str] = &[
|
||||
"贝氏喙鲸",
|
||||
"Blainville's Beaked",
|
||||
"柏氏喙鲸",
|
||||
"Ginkgo-toothed Beaked",
|
||||
"银杏齿喙鲸",
|
||||
"Strap-toothed",
|
||||
"带齿喙鲸",
|
||||
"Stejneger's Beaked",
|
||||
"斯氏喙鲸",
|
||||
"Dwarf Sperm",
|
||||
"小抹香鲸",
|
||||
"Pygmy Sperm",
|
||||
"侏儒抹香鲸",
|
||||
"Rough-toothed",
|
||||
"糙齿海豚",
|
||||
"Atlantic Spotted",
|
||||
"大西洋斑海豚",
|
||||
"Pantropical Spotted",
|
||||
"热带斑海豚",
|
||||
"Spinner",
|
||||
"长吻飞旋海豚",
|
||||
"Clymene",
|
||||
"短吻飞旋海豚",
|
||||
"Striped",
|
||||
"条纹海豚",
|
||||
"Common Bottlenose",
|
||||
"宽吻海豚",
|
||||
"Indo-Pacific Bottlenose",
|
||||
"印太瓶鼻海豚",
|
||||
"Risso's",
|
||||
"灰海豚",
|
||||
"Commerson's",
|
||||
"花斑海豚",
|
||||
"Chilean",
|
||||
"智利海豚",
|
||||
"Heaviside's",
|
||||
"海氏矮海豚",
|
||||
"Hector's",
|
||||
"赫氏矮海豚",
|
||||
"Amazon River",
|
||||
"亚马逊河豚",
|
||||
"Ganges River",
|
||||
"恒河豚",
|
||||
"Indus River",
|
||||
"印度河豚",
|
||||
"La Plata",
|
||||
"拉普拉塔河豚",
|
||||
"Franciscana",
|
||||
"拉河豚",
|
||||
];
|
||||
|
||||
/// Return a deterministic whale name for a given agent ID using a hash of
|
||||
/// the ID string. The same ID always gets the same name — stable across
|
||||
/// session restarts for persisted agents.
|
||||
#[must_use]
|
||||
pub fn whale_name_for_id(id: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut hasher);
|
||||
let idx = (hasher.finish() as usize) % WHALE_NICKNAMES.len();
|
||||
WHALE_NICKNAMES[idx].to_string()
|
||||
}
|
||||
|
||||
/// Assign a unique whale name for an agent ID, avoiding collisions with
|
||||
/// names already in `active_names`. If the deterministic name is taken,
|
||||
/// appends a numeric suffix (e.g. "Orca (2)").
|
||||
#[must_use]
|
||||
pub fn assign_unique_whale_name(
|
||||
id: &str,
|
||||
active_names: &std::collections::HashSet<String>,
|
||||
) -> String {
|
||||
let base = whale_name_for_id(id);
|
||||
if !active_names.contains(&base) {
|
||||
return base;
|
||||
}
|
||||
// Deterministic suffix from the same hash to keep it stable
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut hasher);
|
||||
let suffix_seed = hasher.finish();
|
||||
for i in 2.. {
|
||||
let candidate = format!("{base} ({i})");
|
||||
if !active_names.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
// Vary the probe using the seed
|
||||
let probe = (suffix_seed.wrapping_add(i as u64)) % 100;
|
||||
let candidate2 = format!("{base} ({probe})");
|
||||
if !active_names.contains(&candidate2) {
|
||||
return candidate2;
|
||||
}
|
||||
}
|
||||
// Fallback (should never reach here)
|
||||
format!("{base} ({})", id.get(..4).unwrap_or("?"))
|
||||
}
|
||||
|
||||
/// Removal version for deprecated tool aliases.
|
||||
const DEPRECATION_REMOVAL_VERSION: &str = "0.8.0";
|
||||
|
||||
@@ -886,9 +992,11 @@ pub struct SubAgent {
|
||||
}
|
||||
|
||||
impl SubAgent {
|
||||
/// Create a new sub-agent.
|
||||
/// Create a new sub-agent. The `id` is generated by the caller so that
|
||||
/// deterministic whale-naming can hash the ID before construction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
id: String,
|
||||
agent_type: SubAgentType,
|
||||
prompt: String,
|
||||
assignment: SubAgentAssignment,
|
||||
@@ -898,7 +1006,6 @@ impl SubAgent {
|
||||
input_tx: mpsc::UnboundedSender<SubAgentInput>,
|
||||
session_boot_id: String,
|
||||
) -> Self {
|
||||
let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let session_name = id.clone();
|
||||
|
||||
Self {
|
||||
@@ -1199,12 +1306,19 @@ impl SubAgentManager {
|
||||
runtime.model = model.to_string();
|
||||
}
|
||||
let effective_model = runtime.model.clone();
|
||||
let agent_id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let active_names: std::collections::HashSet<String> = self
|
||||
.agents
|
||||
.values()
|
||||
.filter_map(|a| a.nickname.clone())
|
||||
.collect();
|
||||
let nickname = options
|
||||
.nickname
|
||||
.or_else(|| Some(whale_nickname_for_index(self.agents.len())));
|
||||
.or_else(|| Some(assign_unique_whale_name(&agent_id, &active_names)));
|
||||
let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?;
|
||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
agent_id.clone(),
|
||||
agent_type.clone(),
|
||||
prompt.clone(),
|
||||
assignment.clone(),
|
||||
|
||||
@@ -803,6 +803,7 @@ async fn test_wait_for_result_reports_timeout_when_still_running() {
|
||||
let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2)));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let agent = SubAgent::new(
|
||||
"test_agent_1".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
@@ -834,6 +835,7 @@ async fn agent_eval_on_completed_session_returns_full_projection_not_running_err
|
||||
let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1)));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_2".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"analyze 14 issues".to_string(),
|
||||
make_assignment(),
|
||||
@@ -887,6 +889,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_3".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
@@ -918,6 +921,7 @@ fn test_running_count_ignores_running_status_without_task_handle() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_4".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
@@ -938,6 +942,7 @@ async fn test_running_count_ignores_finished_task_handles() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_5".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
@@ -966,6 +971,7 @@ fn test_assign_updates_running_agent_and_sends_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 2);
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
|
||||
let agent = SubAgent::new(
|
||||
"test_agent_6".to_string(),
|
||||
SubAgentType::General,
|
||||
"work".to_string(),
|
||||
make_assignment(),
|
||||
@@ -1003,6 +1009,7 @@ fn test_assign_rejects_message_for_non_running_agent() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_7".to_string(),
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
@@ -1027,6 +1034,7 @@ fn test_assign_updates_non_running_metadata_without_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
"test_agent_8".to_string(),
|
||||
SubAgentType::Plan,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
@@ -1062,6 +1070,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() {
|
||||
let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let running = SubAgent::new(
|
||||
"test_agent_9_running".to_string(),
|
||||
SubAgentType::General,
|
||||
"work".to_string(),
|
||||
make_assignment(),
|
||||
@@ -1760,6 +1769,7 @@ fn insert_prior_session_agent(
|
||||
) {
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
id.to_string(),
|
||||
SubAgentType::General,
|
||||
"old prompt".to_string(),
|
||||
make_assignment(),
|
||||
|
||||
@@ -1548,6 +1548,7 @@ pub(crate) fn subagent_view_agents(
|
||||
SubAgentStatus::Running,
|
||||
progress,
|
||||
Some("live"),
|
||||
None, // live rows compute nickname from agent manager on render
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1565,6 +1566,7 @@ pub(crate) fn subagent_view_agents(
|
||||
lifecycle_to_subagent_status(card.status),
|
||||
card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
|
||||
Some("transcript"),
|
||||
None, // transcript-derived rows get nickname from manager on render
|
||||
));
|
||||
}
|
||||
HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => {
|
||||
@@ -1581,6 +1583,7 @@ pub(crate) fn subagent_view_agents(
|
||||
lifecycle_to_subagent_status(worker.status),
|
||||
&objective,
|
||||
Some(card.kind.as_str()),
|
||||
None, // fanout worker rows get nickname from manager on render
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1607,6 +1610,7 @@ fn live_subagent_result(
|
||||
status: SubAgentStatus,
|
||||
objective: &str,
|
||||
role: Option<&str>,
|
||||
nickname: Option<String>,
|
||||
) -> SubAgentResult {
|
||||
SubAgentResult {
|
||||
name: agent_id.to_string(),
|
||||
@@ -1619,7 +1623,7 @@ fn live_subagent_result(
|
||||
role: role.map(str::to_string),
|
||||
},
|
||||
model: String::new(),
|
||||
nickname: None,
|
||||
nickname,
|
||||
status,
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
@@ -1861,15 +1865,19 @@ fn append_subagent_group(
|
||||
|
||||
for agent in agents {
|
||||
let id = truncate_view_text(&agent.agent_id, 11);
|
||||
let display_name = agent
|
||||
.nickname
|
||||
.as_deref()
|
||||
.map(|nick| format!("{nick:<12}"))
|
||||
.unwrap_or_else(|| format!("{id:<12}"));
|
||||
let kind = format_agent_type(&agent.agent_type);
|
||||
let (status, status_style, status_detail) = format_agent_status(&agent.status);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{id:<12}"),
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(display_name, Style::default().fg(palette::TEXT_PRIMARY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)),
|
||||
Span::styled(
|
||||
format!("{kind:<9}"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
|
||||
Reference in New Issue
Block a user