Update README with latest features (sub‑agent orchestration, parallel tool execution, runtime API, task queue, etc.)
This commit is contained in:
Generated
+22
@@ -651,6 +651,27 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||
dependencies = [
|
||||
"csv-core",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv-core"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.1.26"
|
||||
@@ -740,6 +761,7 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"colored",
|
||||
"crossterm",
|
||||
"csv",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
|
||||
@@ -24,6 +24,7 @@ clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
csv = "1.4"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
|
||||
@@ -19,9 +19,9 @@ Three modes:
|
||||
|
||||
- **Plan** — design-first, proposes before acting
|
||||
- **Agent** — multi-step autonomous tool use
|
||||
- **YOLO** — full auto-approve, no guardrails
|
||||
- **YOLO** — full auto-approve, no guardrails (preloads tools by default)
|
||||
|
||||
Sub-agent orchestration is in there too (background workers, parallel tool calls). Still shaking out the rough edges.
|
||||
**Recent highlights**: sub‑agent orchestration (background workers, parallel tool calls, dependency‑aware swarms), parallel tool execution (`multi_tool_use.parallel`), runtime HTTP/SSE API (`deepseek serve --http`), background task queue (`/task`), interactive configuration (`/config`), model discovery (`/models`), command palette (`Ctrl+K`), expandable tool payloads (`v`), persistent sidebar for live plan/todo/sub‑agent state, and model context‑window suffix hints (`-32k`, `-256k`).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -57,13 +57,17 @@ deepseek # interactive TUI
|
||||
deepseek -p "explain this in 2 sentences" # one-shot prompt
|
||||
deepseek --yolo # agent mode, all tools auto-approved
|
||||
deepseek doctor # check your setup
|
||||
deepseek models # list available models
|
||||
deepseek serve --http # start HTTP/SSE API server
|
||||
```
|
||||
|
||||
Within the TUI, use `/config`, `/models`, `/task`, and `Ctrl+K` command palette.
|
||||
|
||||
## Model IDs
|
||||
|
||||
Common model IDs: `deepseek-chat`, `deepseek-reasoner`.
|
||||
|
||||
Any valid `deepseek-*` model ID is accepted (including future releases). To see live IDs from your configured endpoint:
|
||||
Any valid `deepseek-*` model ID is accepted (including future releases). Model IDs can include context‑window suffix hints (`-32k`, `-256k`). To see live IDs from your configured endpoint:
|
||||
|
||||
```bash
|
||||
deepseek models
|
||||
@@ -83,4 +87,4 @@ Detailed docs are in the [docs/](docs/) folder — architecture, modes, MCP inte
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT
|
||||
+2
-1
@@ -1288,7 +1288,8 @@ impl Engine {
|
||||
}
|
||||
Op::ListSubAgents => {
|
||||
let agents = {
|
||||
let manager = self.subagent_manager.lock().await;
|
||||
let mut manager = self.subagent_manager.lock().await;
|
||||
manager.cleanup(Duration::from_secs(60 * 60));
|
||||
manager.list()
|
||||
};
|
||||
let _ = self.tx_event.send(Event::AgentList { agents }).await;
|
||||
|
||||
+25
-18
@@ -12,16 +12,25 @@ use std::path::Path;
|
||||
pub const BASE_PROMPT: &str = include_str!("prompts/base.txt");
|
||||
pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt");
|
||||
pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt");
|
||||
pub const YOLO_PROMPT: &str = include_str!("prompts/yolo.txt");
|
||||
pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt");
|
||||
|
||||
fn mode_prompt(mode: AppMode) -> &'static str {
|
||||
match mode {
|
||||
AppMode::Normal => NORMAL_PROMPT,
|
||||
AppMode::Agent => AGENT_PROMPT,
|
||||
AppMode::Yolo => YOLO_PROMPT,
|
||||
AppMode::Plan => PLAN_PROMPT,
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_mode_prompt(mode: AppMode) -> String {
|
||||
format!("{}\n\n{}", BASE_PROMPT.trim(), mode_prompt(mode).trim())
|
||||
}
|
||||
|
||||
/// Get the system prompt for a specific mode
|
||||
pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
|
||||
let text = match mode {
|
||||
AppMode::Normal => NORMAL_PROMPT,
|
||||
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
|
||||
AppMode::Plan => PLAN_PROMPT,
|
||||
};
|
||||
SystemPrompt::Text(text.trim().to_string())
|
||||
SystemPrompt::Text(compose_mode_prompt(mode))
|
||||
}
|
||||
|
||||
/// Get the system prompt for a specific mode with project context
|
||||
@@ -30,27 +39,21 @@ pub fn system_prompt_for_mode_with_context(
|
||||
workspace: &Path,
|
||||
working_set_summary: Option<&str>,
|
||||
) -> SystemPrompt {
|
||||
let base_prompt = match mode {
|
||||
AppMode::Normal => NORMAL_PROMPT,
|
||||
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
|
||||
AppMode::Plan => PLAN_PROMPT,
|
||||
};
|
||||
let mode_prompt = compose_mode_prompt(mode);
|
||||
|
||||
// Load project context from workspace
|
||||
let project_context = load_project_context_with_parents(workspace);
|
||||
|
||||
// Combine base prompt with project context
|
||||
let mut full_prompt = if let Some(project_block) = project_context.as_system_block() {
|
||||
format!("{}\n\n{}", base_prompt.trim(), project_block)
|
||||
format!("{}\n\n{}", mode_prompt, project_block)
|
||||
} else {
|
||||
// Fallback: Generate an automatic project map summary
|
||||
let summary = crate::utils::summarize_project(workspace);
|
||||
let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt
|
||||
format!(
|
||||
"{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```",
|
||||
base_prompt.trim(),
|
||||
summary,
|
||||
tree
|
||||
mode_prompt, summary, tree
|
||||
)
|
||||
};
|
||||
|
||||
@@ -91,13 +94,17 @@ pub fn base_system_prompt() -> SystemPrompt {
|
||||
}
|
||||
|
||||
pub fn normal_system_prompt() -> SystemPrompt {
|
||||
SystemPrompt::Text(NORMAL_PROMPT.trim().to_string())
|
||||
system_prompt_for_mode(AppMode::Normal)
|
||||
}
|
||||
|
||||
pub fn agent_system_prompt() -> SystemPrompt {
|
||||
SystemPrompt::Text(AGENT_PROMPT.trim().to_string())
|
||||
system_prompt_for_mode(AppMode::Agent)
|
||||
}
|
||||
|
||||
pub fn yolo_system_prompt() -> SystemPrompt {
|
||||
system_prompt_for_mode(AppMode::Yolo)
|
||||
}
|
||||
|
||||
pub fn plan_system_prompt() -> SystemPrompt {
|
||||
SystemPrompt::Text(PLAN_PROMPT.trim().to_string())
|
||||
system_prompt_for_mode(AppMode::Plan)
|
||||
}
|
||||
|
||||
+26
-4
@@ -78,21 +78,43 @@ TASK MANAGEMENT:
|
||||
- note: Record important information
|
||||
|
||||
SUB-AGENTS:
|
||||
- spawn_agent: Spawn a background sub-agent (agent_type, message/items)
|
||||
- agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools)
|
||||
- spawn_agents_on_csv: Batch-process CSV rows with one worker sub-agent per row
|
||||
- report_agent_job_result: Worker-only job row report tool for spawn_agents_on_csv
|
||||
- agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context)
|
||||
- swarm_status: Check status for a previously started swarm (swarm_id)
|
||||
- swarm_result: Get full results for a previously started swarm (swarm_id, optional block/timeout)
|
||||
- agent_result: Get result from a sub-agent (agent_id, block, timeout_ms)
|
||||
- send_input: Send input to a running sub-agent (agent_id, message, interrupt)
|
||||
- wait: Wait for one or more sub-agents to complete (ids, timeout_ms)
|
||||
- send_input: Send input to a running sub-agent (agent_id, message/items, interrupt)
|
||||
- agent_assign / assign_agent: Update assignment objective/role and optionally push immediate guidance
|
||||
- wait: Wait for one or more sub-agents to complete (ids optional, wait_mode:any|all, timeout_ms)
|
||||
- agent_cancel: Cancel a running sub-agent (agent_id)
|
||||
- close_agent: Close a running sub-agent (alias for cancel)
|
||||
- resume_agent: Resume a previously closed/completed sub-agent
|
||||
- agent_list: List all sub-agents and their status
|
||||
If you spawn a sub-agent, always follow up with agent_result (block: true) and incorporate its result before responding to the user.
|
||||
If you use agent_swarm, incorporate its aggregated results (or follow up with agent_result for any running agents) before responding.
|
||||
Delegation protocol:
|
||||
- Delegate only bounded, parallelizable work with a clear input, expected output, and tool limits.
|
||||
- Prefer multiple sub-agents for independent steps to maximize parallelism.
|
||||
- When spawning/delegating, include explicit assignment metadata: objective + role (worker/explorer/awaiter/default) or agent_type.
|
||||
- Use agent_assign to retask active sub-agents instead of respawning when objective/role changes.
|
||||
- After spawning, immediately track completion with wait (for groups), swarm_result (for non-blocking swarms), or agent_result (block: true) per agent.
|
||||
- For full barriers, use wait with wait_mode="all" and a generous timeout (prefer >= 60000ms). Omit ids to wait on all currently running agents.
|
||||
- For spawn_agents_on_csv workers: call report_agent_job_result exactly once per row item; missing reports are treated as failures.
|
||||
- Workers may set stop=true in report_agent_job_result to cancel remaining unstarted CSV rows.
|
||||
- If sub-agents are still running, wait for their outputs before presenting final conclusions unless the user asked a direct question that needs an immediate reply.
|
||||
- Do not present final conclusions until required sub-agent results are collected and integrated.
|
||||
- If an agent stalls or fails, retry once with a tighter prompt; otherwise cancel it and continue with an explicit fallback.
|
||||
- Close idle agents with close_agent to free capacity; use resume_agent to continue paused/completed assignments when needed.
|
||||
- Verify critical sub-agent claims with primary tool output before applying changes.
|
||||
|
||||
Planning and progress:
|
||||
- For complex or multi-file work, call update_plan to publish a checklist.
|
||||
- Keep exactly one plan step in_progress at a time.
|
||||
- Use todo tools for granular progress when helpful.
|
||||
- Prefer short progress notes over long narration.
|
||||
- For long-running tasks, emit checkpoint updates every few actions with: done, next, and blockers.
|
||||
- Re-baseline plan/todos at each checkpoint when scope shifts.
|
||||
|
||||
Git hygiene:
|
||||
- Run git status early (to see the workspace state) and again before finishing.
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::models::{
|
||||
compaction_threshold_for_model,
|
||||
};
|
||||
use crate::tools::plan::new_shared_plan_state;
|
||||
use crate::tools::subagent::SubAgentStatus;
|
||||
use crate::tools::todo::new_shared_todo_list;
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
@@ -1746,6 +1747,123 @@ impl RuntimeThreadManager {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::AgentSpawned { id, prompt } => {
|
||||
let message = format!(
|
||||
"Sub-agent {id} spawned: {}",
|
||||
summarize_text(&prompt, SUMMARY_LIMIT)
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: summarize_text(&message, SUMMARY_LIMIT),
|
||||
detail: Some(message),
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(Utc::now()),
|
||||
ended_at: Some(Utc::now()),
|
||||
};
|
||||
self.store.save_item(&item)?;
|
||||
self.attach_item_to_turn(&turn_id, &item.id)?;
|
||||
self.emit_event(
|
||||
&thread_id,
|
||||
Some(&turn_id),
|
||||
Some(&item.id),
|
||||
"agent.spawned",
|
||||
json!({ "item": item, "agent_id": id }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::AgentProgress { id, status } => {
|
||||
let message = format!("Sub-agent {id}: {status}");
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: summarize_text(&message, SUMMARY_LIMIT),
|
||||
detail: Some(message),
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(Utc::now()),
|
||||
ended_at: Some(Utc::now()),
|
||||
};
|
||||
self.store.save_item(&item)?;
|
||||
self.attach_item_to_turn(&turn_id, &item.id)?;
|
||||
self.emit_event(
|
||||
&thread_id,
|
||||
Some(&turn_id),
|
||||
Some(&item.id),
|
||||
"agent.progress",
|
||||
json!({ "item": item, "agent_id": id }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::AgentComplete { id, result } => {
|
||||
let message = format!(
|
||||
"Sub-agent {id} completed: {}",
|
||||
summarize_text(&result, SUMMARY_LIMIT)
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: summarize_text(&message, SUMMARY_LIMIT),
|
||||
detail: Some(message),
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(Utc::now()),
|
||||
ended_at: Some(Utc::now()),
|
||||
};
|
||||
self.store.save_item(&item)?;
|
||||
self.attach_item_to_turn(&turn_id, &item.id)?;
|
||||
self.emit_event(
|
||||
&thread_id,
|
||||
Some(&turn_id),
|
||||
Some(&item.id),
|
||||
"agent.completed",
|
||||
json!({ "item": item, "agent_id": id }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::AgentList { agents } => {
|
||||
let running = agents
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
.count();
|
||||
let completed = agents
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Completed))
|
||||
.count();
|
||||
let message = format!(
|
||||
"Sub-agent list refreshed: {} total ({running} running, {completed} completed)",
|
||||
agents.len()
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: summarize_text(&message, SUMMARY_LIMIT),
|
||||
detail: Some(message),
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(Utc::now()),
|
||||
ended_at: Some(Utc::now()),
|
||||
};
|
||||
self.store.save_item(&item)?;
|
||||
self.attach_item_to_turn(&turn_id, &item.id)?;
|
||||
self.emit_event(
|
||||
&thread_id,
|
||||
Some(&turn_id),
|
||||
Some(&item.id),
|
||||
"agent.list",
|
||||
json!({ "item": item, "agents": agents }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::ApprovalRequired {
|
||||
id,
|
||||
tool_name,
|
||||
|
||||
+37
-4
@@ -446,31 +446,64 @@ impl ToolRegistryBuilder {
|
||||
runtime: super::subagent::SubAgentRuntime,
|
||||
) -> Self {
|
||||
use super::subagent::{
|
||||
AgentCancelTool, AgentListTool, AgentResultTool, AgentSendInputTool, AgentSpawnTool,
|
||||
AgentWaitTool, DelegateToAgentTool,
|
||||
AgentAssignTool, AgentCancelTool, AgentCloseTool, AgentListTool, AgentResultTool,
|
||||
AgentResumeTool, AgentSendInputTool, AgentSpawnTool, AgentWaitTool,
|
||||
DelegateToAgentTool, ReportAgentJobResultTool, SpawnAgentsOnCsvTool,
|
||||
};
|
||||
use super::swarm::AgentSwarmTool;
|
||||
use super::swarm::{AgentSwarmTool, SwarmResultTool, SwarmStatusTool};
|
||||
|
||||
self.with_tool(Arc::new(AgentSpawnTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(AgentSpawnTool::with_name(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
"spawn_agent",
|
||||
)))
|
||||
.with_tool(Arc::new(DelegateToAgentTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime)))
|
||||
.with_tool(Arc::new(AgentSwarmTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(SpawnAgentsOnCsvTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(ReportAgentJobResultTool))
|
||||
.with_tool(Arc::new(SwarmStatusTool::new(
|
||||
runtime.context.workspace.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(SwarmResultTool::new(
|
||||
runtime.context.workspace.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(AgentResultTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentSendInputTool::new(
|
||||
manager.clone(),
|
||||
"send_input",
|
||||
)))
|
||||
.with_tool(Arc::new(AgentAssignTool::new(
|
||||
manager.clone(),
|
||||
"agent_assign",
|
||||
)))
|
||||
.with_tool(Arc::new(AgentAssignTool::new(
|
||||
manager.clone(),
|
||||
"assign_agent",
|
||||
)))
|
||||
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "wait")))
|
||||
.with_tool(Arc::new(AgentSendInputTool::new(
|
||||
manager.clone(),
|
||||
"agent_send_input",
|
||||
)))
|
||||
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "agent_wait")))
|
||||
.with_tool(Arc::new(AgentResumeTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(AgentCloseTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentCancelTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentListTool::new(manager)))
|
||||
}
|
||||
|
||||
+2928
-152
File diff suppressed because it is too large
Load Diff
+887
-41
File diff suppressed because it is too large
Load Diff
@@ -296,6 +296,10 @@ pub struct App {
|
||||
pub max_subagents: usize,
|
||||
/// Cached sub-agent snapshots for UI views.
|
||||
pub subagent_cache: Vec<SubAgentResult>,
|
||||
/// Last known per-agent progress text for running sub-agents.
|
||||
pub agent_progress: HashMap<String, String>,
|
||||
/// Animation anchor for status-strip active sub-agent spinner.
|
||||
pub agent_activity_started_at: Option<Instant>,
|
||||
pub ui_theme: UiTheme,
|
||||
// Onboarding
|
||||
pub onboarding: OnboardingState,
|
||||
@@ -574,6 +578,8 @@ impl App {
|
||||
allow_shell,
|
||||
max_subagents,
|
||||
subagent_cache: Vec::new(),
|
||||
agent_progress: HashMap::new(),
|
||||
agent_activity_started_at: None,
|
||||
ui_theme,
|
||||
onboarding: if needs_onboarding {
|
||||
if was_onboarded && needs_api_key {
|
||||
|
||||
+210
-32
@@ -48,7 +48,7 @@ use crate::task_manager::{
|
||||
use crate::tools::ReviewOutput;
|
||||
use crate::tools::plan::StepStatus;
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType};
|
||||
use crate::tools::subagent::{SubAgentResult, SubAgentStatus};
|
||||
use crate::tools::todo::TodoStatus;
|
||||
use crate::tui::command_palette::{
|
||||
CommandPaletteView, build_entries as build_command_palette_entries,
|
||||
@@ -95,6 +95,7 @@ const UI_ACTIVE_POLL_MS: u64 = 16;
|
||||
const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 120;
|
||||
const UI_TYPING_INDICATOR_MS: u64 = 120;
|
||||
const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS;
|
||||
const MAX_ACTIVE_AGENT_STATUS_ROWS: usize = 2;
|
||||
const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -620,38 +621,46 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
EngineEvent::AgentSpawned { id, prompt } => {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!(
|
||||
"Sub-agent {id} spawned: {}",
|
||||
summarize_tool_output(&prompt)
|
||||
),
|
||||
});
|
||||
if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
let prompt_summary = summarize_tool_output(&prompt);
|
||||
app.agent_progress
|
||||
.insert(id.clone(), format!("starting: {prompt_summary}"));
|
||||
if app.agent_activity_started_at.is_none() {
|
||||
app.agent_activity_started_at = Some(Instant::now());
|
||||
}
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Sub-agent {id} spawned: {}", prompt_summary),
|
||||
});
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
}
|
||||
EngineEvent::AgentProgress { id, status } => {
|
||||
app.agent_progress
|
||||
.insert(id.clone(), summarize_tool_output(&status));
|
||||
if app.agent_activity_started_at.is_none() {
|
||||
app.agent_activity_started_at = Some(Instant::now());
|
||||
}
|
||||
app.status_message = Some(format!("Sub-agent {id}: {status}"));
|
||||
}
|
||||
EngineEvent::AgentComplete { id, result } => {
|
||||
app.agent_progress.remove(&id);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!(
|
||||
"Sub-agent {id} completed: {}",
|
||||
summarize_tool_output(&result)
|
||||
),
|
||||
});
|
||||
if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
}
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
}
|
||||
EngineEvent::AgentList { agents } => {
|
||||
app.subagent_cache = agents.clone();
|
||||
if app.view_stack.update_subagents(&agents) {
|
||||
let mut sorted = agents.clone();
|
||||
sort_subagents_in_place(&mut sorted);
|
||||
app.subagent_cache = sorted.clone();
|
||||
reconcile_subagent_activity_state(app);
|
||||
if app.view_stack.update_subagents(&sorted) {
|
||||
app.status_message =
|
||||
Some(format!("Sub-agents: {} total", agents.len()));
|
||||
Some(format!("Sub-agents: {} total", sorted.len()));
|
||||
} else {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format_subagent_list(&agents),
|
||||
content: format_subagent_list(&sorted),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -815,7 +824,8 @@ async fn run_event_loop(
|
||||
handle_view_events(app, &engine_handle, events).await;
|
||||
}
|
||||
|
||||
if app.is_loading
|
||||
let has_running_agents = running_agent_count(app) > 0;
|
||||
if (app.is_loading || has_running_agents)
|
||||
&& last_status_frame.elapsed() >= Duration::from_millis(UI_STATUS_ANIMATION_MS)
|
||||
{
|
||||
app.needs_redraw = true;
|
||||
@@ -837,7 +847,7 @@ async fn run_event_loop(
|
||||
app.needs_redraw = false;
|
||||
}
|
||||
|
||||
let mut poll_timeout = if app.is_loading {
|
||||
let mut poll_timeout = if app.is_loading || has_running_agents {
|
||||
Duration::from_millis(UI_ACTIVE_POLL_MS)
|
||||
} else {
|
||||
Duration::from_millis(UI_IDLE_POLL_MS)
|
||||
@@ -2140,6 +2150,84 @@ fn compact_queued_preview(app: &App, preview_rows_budget: usize) -> (Vec<String>
|
||||
(previews, queue_count > shown_count)
|
||||
}
|
||||
|
||||
fn running_agent_count(app: &App) -> usize {
|
||||
let mut ids: std::collections::HashSet<&str> =
|
||||
app.agent_progress.keys().map(String::as_str).collect();
|
||||
for agent in app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
{
|
||||
ids.insert(agent.agent_id.as_str());
|
||||
}
|
||||
ids.len()
|
||||
}
|
||||
|
||||
fn active_agent_rows(app: &App, limit: usize) -> Vec<(String, String)> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for agent in app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
{
|
||||
let detail = app
|
||||
.agent_progress
|
||||
.get(&agent.agent_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| summarize_tool_output(&agent.assignment.objective));
|
||||
rows.push((agent.agent_id.clone(), summarize_tool_output(&detail)));
|
||||
seen.insert(agent.agent_id.clone());
|
||||
if rows.len() >= limit {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
let mut extras: Vec<(String, String)> = app
|
||||
.agent_progress
|
||||
.iter()
|
||||
.filter(|(id, _)| !seen.contains(id.as_str()))
|
||||
.map(|(id, status)| (id.clone(), summarize_tool_output(status)))
|
||||
.collect();
|
||||
extras.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
rows.extend(extras.into_iter().take(limit.saturating_sub(rows.len())));
|
||||
rows
|
||||
}
|
||||
|
||||
fn reconcile_subagent_activity_state(app: &mut App) {
|
||||
let running_agents: Vec<(String, String)> = app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
.map(|agent| {
|
||||
(
|
||||
agent.agent_id.clone(),
|
||||
summarize_tool_output(&agent.assignment.objective),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let running_ids: std::collections::HashSet<String> =
|
||||
running_agents.iter().map(|(id, _)| id.clone()).collect();
|
||||
app.agent_progress
|
||||
.retain(|id, _| running_ids.contains(id.as_str()));
|
||||
for (id, objective) in running_agents {
|
||||
app.agent_progress.entry(id).or_insert(objective);
|
||||
}
|
||||
|
||||
if running_ids.is_empty() {
|
||||
app.agent_activity_started_at = None;
|
||||
} else if app.agent_activity_started_at.is_none() {
|
||||
app.agent_activity_started_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_status_layout(
|
||||
app: &App,
|
||||
terminal_height: u16,
|
||||
@@ -2154,7 +2242,10 @@ fn compute_status_layout(
|
||||
};
|
||||
}
|
||||
|
||||
let fixed_rows = usize::from(app.is_loading) + usize::from(app.queued_draft.is_some());
|
||||
let active_agents = running_agent_count(app);
|
||||
let fixed_rows = usize::from(app.is_loading)
|
||||
+ usize::from(app.queued_draft.is_some())
|
||||
+ usize::from(active_agents > 0);
|
||||
let queue_rows_budget = usize::from(status_budget).saturating_sub(fixed_rows);
|
||||
|
||||
let (queued_preview, preview_compacted) = if queue_rows_budget > 0 {
|
||||
@@ -2168,7 +2259,12 @@ fn compute_status_layout(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let requested_rows = fixed_rows + queue_rows;
|
||||
let mut requested_rows = fixed_rows + queue_rows;
|
||||
if active_agents > 0 {
|
||||
let detail_rows_budget = usize::from(status_budget).saturating_sub(requested_rows);
|
||||
let detail_rows = detail_rows_budget.min(active_agents.min(MAX_ACTIVE_AGENT_STATUS_ROWS));
|
||||
requested_rows += detail_rows;
|
||||
}
|
||||
let status_height =
|
||||
u16::try_from(requested_rows.min(usize::from(status_budget))).unwrap_or(status_budget);
|
||||
let queued_compacted = preview_compacted || (app.queued_message_count() > 0 && queue_rows == 0);
|
||||
@@ -2605,15 +2701,10 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
|
||||
SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR),
|
||||
SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED),
|
||||
};
|
||||
let agent_type = match agent.agent_type {
|
||||
SubAgentType::General => "general",
|
||||
SubAgentType::Explore => "explore",
|
||||
SubAgentType::Plan => "plan",
|
||||
SubAgentType::Review => "review",
|
||||
SubAgentType::Custom => "custom",
|
||||
};
|
||||
let agent_type = agent.agent_type.as_str();
|
||||
let role = agent.assignment.role.as_deref().unwrap_or("default");
|
||||
let summary = format!(
|
||||
"{} {agent_type} {status_label} ({} steps)",
|
||||
"{} {agent_type}/{role} {status_label} ({} steps)",
|
||||
truncate_line_to_width(&agent.agent_id, 10),
|
||||
agent.steps_taken
|
||||
);
|
||||
@@ -2621,6 +2712,16 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
|
||||
truncate_line_to_width(&summary, content_width.max(1)),
|
||||
Style::default().fg(status_color),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" {}",
|
||||
truncate_line_to_width(
|
||||
&agent.assignment.objective,
|
||||
content_width.saturating_sub(2).max(1)
|
||||
)
|
||||
),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
|
||||
let remaining = app.subagent_cache.len().saturating_sub(max_agents);
|
||||
@@ -3030,7 +3131,13 @@ fn render_status_indicator(
|
||||
queued: &[String],
|
||||
queued_compacted: bool,
|
||||
) {
|
||||
let mut lines = Vec::with_capacity(1 + queued.len() + usize::from(app.queued_draft.is_some()));
|
||||
let max_rows = usize::from(area.height);
|
||||
if max_rows == 0 {
|
||||
return;
|
||||
}
|
||||
let mut lines = Vec::with_capacity(
|
||||
2 + queued.len() + usize::from(app.queued_draft.is_some()) + MAX_ACTIVE_AGENT_STATUS_ROWS,
|
||||
);
|
||||
|
||||
if app.is_loading {
|
||||
let header = if app.show_thinking {
|
||||
@@ -3083,6 +3190,49 @@ fn render_status_indicator(
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
let active_agent_total = running_agent_count(app);
|
||||
if active_agent_total > 0 && lines.len() < max_rows {
|
||||
let available = area.width as usize;
|
||||
let spinner_start = app.agent_activity_started_at.or(app.turn_started_at);
|
||||
let spinner = deepseek_squiggle(spinner_start);
|
||||
let header = format!("AGENTS {active_agent_total} running | /agents");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
truncate_line_to_width(&header, available.saturating_sub(2).max(1)),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
),
|
||||
]));
|
||||
|
||||
let preview_limit = max_rows
|
||||
.saturating_sub(lines.len())
|
||||
.min(MAX_ACTIVE_AGENT_STATUS_ROWS);
|
||||
let active_rows = active_agent_rows(app, preview_limit);
|
||||
for (id, status) in &active_rows {
|
||||
if lines.len() >= max_rows {
|
||||
break;
|
||||
}
|
||||
let id_short = truncate_line_to_width(id, 12);
|
||||
let status_single_line = status.lines().next().unwrap_or(status.as_str());
|
||||
let prefix = format!(" {id_short}: ");
|
||||
let detail_width = available.saturating_sub(prefix.width()).max(1);
|
||||
let detail = truncate_line_to_width(status_single_line, detail_width);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(detail, Style::default().fg(palette::TEXT_DIM)),
|
||||
]));
|
||||
}
|
||||
|
||||
let hidden = active_agent_total.saturating_sub(active_rows.len());
|
||||
if hidden > 0 && lines.len() < max_rows {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" +{hidden} more running"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(draft) = app.queued_draft.as_ref() {
|
||||
let available = area.width as usize;
|
||||
let prefix = "Editing queued:";
|
||||
@@ -3828,20 +3978,48 @@ fn extract_reasoning_header(text: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
|
||||
match status {
|
||||
SubAgentStatus::Running => 0,
|
||||
SubAgentStatus::Failed(_) => 1,
|
||||
SubAgentStatus::Completed => 2,
|
||||
SubAgentStatus::Cancelled => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
|
||||
agents.sort_by(|a, b| {
|
||||
subagent_status_rank(&a.status)
|
||||
.cmp(&subagent_status_rank(&b.status))
|
||||
.then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
|
||||
.then_with(|| a.agent_id.cmp(&b.agent_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn format_subagent_list(agents: &[SubAgentResult]) -> String {
|
||||
if agents.is_empty() {
|
||||
return "No sub-agents running.".to_string();
|
||||
}
|
||||
|
||||
let mut sorted = agents.to_vec();
|
||||
sort_subagents_in_place(&mut sorted);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push("Sub-agents:".to_string());
|
||||
lines.push("----------------------------------------".to_string());
|
||||
|
||||
for agent in agents {
|
||||
for agent in &sorted {
|
||||
let status = format_subagent_status(&agent.status);
|
||||
let role = agent.assignment.role.as_deref().unwrap_or("default");
|
||||
let mut line = format!(
|
||||
" {} ({:?}) - {} | steps: {} | {}ms",
|
||||
agent.agent_id, agent.agent_type, status, agent.steps_taken, agent.duration_ms
|
||||
" {} ({}/{}) - {} | steps: {} | {}ms\n objective: {}",
|
||||
agent.agent_id,
|
||||
agent.agent_type.as_str(),
|
||||
role,
|
||||
status,
|
||||
agent.steps_taken,
|
||||
agent.duration_ms,
|
||||
summarize_tool_output(&agent.assignment.objective)
|
||||
);
|
||||
if matches!(agent.status, SubAgentStatus::Completed)
|
||||
&& let Some(result) = agent.result.as_ref()
|
||||
|
||||
@@ -116,6 +116,111 @@ fn create_test_app() -> App {
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn make_subagent(
|
||||
id: &str,
|
||||
status: crate::tools::subagent::SubAgentStatus,
|
||||
) -> crate::tools::subagent::SubAgentResult {
|
||||
crate::tools::subagent::SubAgentResult {
|
||||
agent_id: id.to_string(),
|
||||
agent_type: crate::tools::subagent::SubAgentType::General,
|
||||
assignment: crate::tools::subagent::SubAgentAssignment {
|
||||
objective: format!("objective-{id}"),
|
||||
role: Some("worker".to_string()),
|
||||
},
|
||||
status,
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_subagents_orders_running_before_terminal_statuses() {
|
||||
let mut agents = vec![
|
||||
make_subagent("agent_c", crate::tools::subagent::SubAgentStatus::Completed),
|
||||
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
|
||||
make_subagent(
|
||||
"agent_b",
|
||||
crate::tools::subagent::SubAgentStatus::Failed("boom".to_string()),
|
||||
),
|
||||
];
|
||||
|
||||
sort_subagents_in_place(&mut agents);
|
||||
|
||||
assert_eq!(agents[0].agent_id, "agent_a");
|
||||
assert_eq!(agents[1].agent_id, "agent_b");
|
||||
assert_eq!(agents[2].agent_id, "agent_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_agent_count_unions_cache_and_progress() {
|
||||
let mut app = create_test_app();
|
||||
app.subagent_cache = vec![
|
||||
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
|
||||
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed),
|
||||
];
|
||||
app.agent_progress
|
||||
.insert("agent_c".to_string(), "planning".to_string());
|
||||
|
||||
assert_eq!(running_agent_count(&app), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_status_layout_reserves_rows_for_active_agents() {
|
||||
let app = create_test_app();
|
||||
let baseline = compute_status_layout(&app, 30, 3);
|
||||
assert_eq!(baseline.status_height, 0);
|
||||
|
||||
let mut with_agents = create_test_app();
|
||||
with_agents
|
||||
.agent_progress
|
||||
.insert("agent_a".to_string(), "running".to_string());
|
||||
let active = compute_status_layout(&with_agents, 30, 3);
|
||||
assert!(active.status_height >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_agent_rows_prefers_cache_order_and_progress_text() {
|
||||
let mut app = create_test_app();
|
||||
app.subagent_cache = vec![
|
||||
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
|
||||
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running),
|
||||
];
|
||||
app.agent_progress
|
||||
.insert("agent_b".to_string(), "step 2".to_string());
|
||||
app.agent_progress
|
||||
.insert("agent_c".to_string(), "queued".to_string());
|
||||
|
||||
let rows = active_agent_rows(&app, 3);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].0, "agent_a");
|
||||
assert!(rows[0].1.contains("objective-agent_a"));
|
||||
assert_eq!(rows[1].0, "agent_b");
|
||||
assert_eq!(rows[1].1, "step 2");
|
||||
assert_eq!(rows[2].0, "agent_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
|
||||
let mut app = create_test_app();
|
||||
app.subagent_cache = vec![
|
||||
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
|
||||
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed),
|
||||
];
|
||||
app.agent_progress
|
||||
.insert("agent_stale".to_string(), "old".to_string());
|
||||
|
||||
reconcile_subagent_activity_state(&mut app);
|
||||
assert!(app.agent_progress.contains_key("agent_a"));
|
||||
assert!(!app.agent_progress.contains_key("agent_stale"));
|
||||
assert!(app.agent_activity_started_at.is_some());
|
||||
|
||||
app.subagent_cache.clear();
|
||||
reconcile_subagent_activity_state(&mut app);
|
||||
assert!(app.agent_progress.is_empty());
|
||||
assert!(app.agent_activity_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_token_count_compact_formats_units() {
|
||||
assert_eq!(format_token_count_compact(999), "999");
|
||||
|
||||
@@ -1360,6 +1360,22 @@ fn append_subagent_group(
|
||||
]));
|
||||
}
|
||||
|
||||
if let Some(role) = agent.assignment.role.as_deref() {
|
||||
let max_len = content_width.saturating_sub(14);
|
||||
let role = truncate_view_text(role, max_len);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" role: ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(role, Style::default().fg(palette::DEEPSEEK_SKY)),
|
||||
]));
|
||||
}
|
||||
|
||||
let max_len = content_width.saturating_sub(18);
|
||||
let objective = truncate_view_text(&agent.assignment.objective, max_len);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" objective: ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(objective, Style::default().fg(palette::TEXT_DIM)),
|
||||
]));
|
||||
|
||||
if let Some(result) = agent.result.as_ref() {
|
||||
let max_len = content_width.saturating_sub(16);
|
||||
let preview = truncate_view_text(result, max_len);
|
||||
|
||||
Reference in New Issue
Block a user