Compare commits

...
2 Commits
Author SHA1 Message Date
Hunter Bown 0ff362aee6 fix(tui): satisfy release clippy on hotfix 2026-05-06 23:54:07 -05:00
Hunter Bown d538582fae fix(tui): stabilize RLM hotfix
Stabilize v0.8.16 RLM and sub-agent UI/terminal behavior.
2026-05-06 23:43:51 -05:00
29 changed files with 939 additions and 165 deletions

No files matched your search

+39
View File
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.16] - 2026-05-07
A focused hotfix for v0.8.15 regressions in RLM, sub-agent visibility, and
terminal ownership. This release keeps the v0.8.15 feature set intact while
making long-running delegated work easier to inspect and safer to run.
### Changed
- **RLM has no fixed 180s wall-clock timeout** (#955) — RLM turns can continue
past the old hard limit when the long-input REPL is still making progress.
- **RLM output is easier to audit** (#955) — final reports now include compact
execution metadata: input size, iteration count, elapsed time, sub-LLM RPC
count, and termination state.
- **RLM chunking guidance is stricter for exact work** (#955) — prompts now
tell the sub-agent to use deterministic Python over the full `context` for
counts/aggregation and to report chunk coverage when splitting a whole input.
- **Tool guidance is less defensive** (#955) — the system prompt now explains
when to use tools instead of discouraging the model from using capabilities
that are actually available.
### Fixed
- **Active RLM work stays visible** (#955) — foreground RLM calls surface in the
active task/right-rail state instead of leaving the Tasks panel saying
`No active tasks`.
- **`/subagents` no longer reports false emptiness** (#955) — the sub-agent
overlay now includes live progress-only agents and transcript fanout workers
when the manager cache has not refreshed yet.
- **Sub-agent cards are quieter and more useful** (#955) — low-signal scheduler
lines such as `step 1/100: requesting model response` are hidden, while
compact tool activity remains visible.
- **Sub-agent completion protocol stays internal** (#955) — completion
sentinels are routed as internal runtime events instead of user messages, so
the parent agent does not explain raw protocol XML back to the user.
- **Sub-agents cannot take over the parent terminal** (#955) — background
agents reject `exec_shell` with `interactive=true`; they can still use
non-interactive shell, background shell, `tty=true`, and task-shell tools.
- **Terminal scrollback ownership is restored** (#955) — the TUI re-enters
alternate-screen mode after foreground/sub-agent work drains, preventing the
host terminal scrollbar from taking over the live interface.
## [0.8.15] - 2026-05-06
An auth, Windows, editor-integration, and setup stabilization release. This
Generated
+14 -14
View File
@@ -1080,7 +1080,7 @@ dependencies = [
[[package]]
name = "deepseek-agent"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"deepseek-config",
"serde",
@@ -1088,7 +1088,7 @@ dependencies = [
[[package]]
name = "deepseek-app-server"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"axum",
@@ -1110,7 +1110,7 @@ dependencies = [
[[package]]
name = "deepseek-config"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"deepseek-secrets",
@@ -1122,7 +1122,7 @@ dependencies = [
[[package]]
name = "deepseek-core"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"chrono",
@@ -1140,7 +1140,7 @@ dependencies = [
[[package]]
name = "deepseek-execpolicy"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"deepseek-protocol",
@@ -1149,7 +1149,7 @@ dependencies = [
[[package]]
name = "deepseek-hooks"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"async-trait",
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "deepseek-mcp"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"serde",
@@ -1172,7 +1172,7 @@ dependencies = [
[[package]]
name = "deepseek-protocol"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"serde",
"serde_json",
@@ -1180,7 +1180,7 @@ dependencies = [
[[package]]
name = "deepseek-secrets"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"dirs",
"keyring",
@@ -1193,7 +1193,7 @@ dependencies = [
[[package]]
name = "deepseek-state"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"chrono",
@@ -1205,7 +1205,7 @@ dependencies = [
[[package]]
name = "deepseek-tools"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"async-trait",
@@ -1218,7 +1218,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"arboard",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-cli"
version = "0.8.15"
version = "0.8.16"
dependencies = [
"anyhow",
"chrono",
@@ -1302,7 +1302,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-core"
version = "0.8.15"
version = "0.8.16"
[[package]]
name = "deranged"
+1 -1
View File
@@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
resolver = "2"
[workspace.package]
version = "0.8.15"
version = "0.8.16"
edition = "2024"
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
# codebase relies on extensively. Cargo enforces this so users on older
+19 -9
View File
@@ -205,18 +205,28 @@ deepseek --provider ollama --model deepseek-coder:1.3b
---
## What's New In v0.8.15
## What's New In v0.8.16
A community-driven stabilization release focused on auth recovery, Windows
terminals, Zed/ACP compatibility, setup friction, and clearer cost display.
A focused hotfix for RLM, sub-agent visibility, and terminal ownership on top
of v0.8.15.
[Full changelog](CHANGELOG.md).
- **Friendlier auth recovery** — runtime API-key failures now explain when the active key came only from `DEEPSEEK_API_KEY` and no saved config key is present
- **Zed / ACP adapter** — `deepseek serve --acp` exposes a local stdio Agent Client Protocol server for Zed and other compatible editors
- **Windows terminal fixes** — UTF-8 console setup, dispatcher resume handling, clipboard fallback, Ctrl+E composer behavior, and safer Windows mouse defaults
- **Yuan cost display** — set `cost_currency = "cny"` (or `yuan` / `rmb`) to show footer, `/cost`, `/tokens`, and notification summaries in CNY
- **Setup and skill polish** — workspace trust persists globally, plain Markdown `SKILL.md` files load correctly, global Agents/Cursor skill paths are discovered, and the TUI shows skills in slash autocomplete
- **Reliability fixes** — workspace-scoped `resume --last`, capped API `max_tokens`, endpoint diagnostics in `deepseek doctor`, npm `--version` fallback, and current-date turn metadata
- **RLM no longer has the old 180s wall-clock timeout** — long-input REPL work
can keep running while it is still making progress.
- **RLM reports what happened** — output now includes input size, iteration
count, elapsed time, sub-LLM RPC count, and termination state.
- **RLM chunking is safer for exact answers** — prompts require deterministic
Python for counts/aggregation and coverage reporting for whole-input chunks.
- **Sub-agent visibility is more truthful** — `/subagents`, the transcript, and
the right rail include live progress and fanout workers instead of showing
false `No agents` or `No active tasks` states.
- **Sub-agent cards are quieter** — internal scheduler lines are hidden while
useful tool activity remains visible.
- **Sub-agent completion events stay internal** — the parent agent integrates
child results without explaining raw sentinel XML back to the user.
- **Terminal ownership is hardened** — background sub-agents cannot take over
the parent terminal, and the TUI restores alternate-screen mode after
delegated work drains.
---
+1 -1
View File
@@ -7,5 +7,5 @@ repository.workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
deepseek-config = { path = "../config", version = "0.8.15" }
deepseek-config = { path = "../config", version = "0.8.16" }
serde.workspace = true
+9 -9
View File
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.15" }
deepseek-config = { path = "../config", version = "0.8.15" }
deepseek-core = { path = "../core", version = "0.8.15" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.15" }
deepseek-hooks = { path = "../hooks", version = "0.8.15" }
deepseek-mcp = { path = "../mcp", version = "0.8.15" }
deepseek-protocol = { path = "../protocol", version = "0.8.15" }
deepseek-state = { path = "../state", version = "0.8.15" }
deepseek-tools = { path = "../tools", version = "0.8.15" }
deepseek-agent = { path = "../agent", version = "0.8.16" }
deepseek-config = { path = "../config", version = "0.8.16" }
deepseek-core = { path = "../core", version = "0.8.16" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.16" }
deepseek-hooks = { path = "../hooks", version = "0.8.16" }
deepseek-mcp = { path = "../mcp", version = "0.8.16" }
deepseek-protocol = { path = "../protocol", version = "0.8.16" }
deepseek-state = { path = "../state", version = "0.8.16" }
deepseek-tools = { path = "../tools", version = "0.8.16" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+7 -7
View File
@@ -14,13 +14,13 @@ path = "src/main.rs"
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.15" }
deepseek-app-server = { path = "../app-server", version = "0.8.15" }
deepseek-config = { path = "../config", version = "0.8.15" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.15" }
deepseek-mcp = { path = "../mcp", version = "0.8.15" }
deepseek-secrets = { path = "../secrets", version = "0.8.15" }
deepseek-state = { path = "../state", version = "0.8.15" }
deepseek-agent = { path = "../agent", version = "0.8.16" }
deepseek-app-server = { path = "../app-server", version = "0.8.16" }
deepseek-config = { path = "../config", version = "0.8.16" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.16" }
deepseek-mcp = { path = "../mcp", version = "0.8.16" }
deepseek-secrets = { path = "../secrets", version = "0.8.16" }
deepseek-state = { path = "../state", version = "0.8.16" }
chrono.workspace = true
dirs.workspace = true
serde.workspace = true
+1 -1
View File
@@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite
[dependencies]
anyhow.workspace = true
deepseek-secrets = { path = "../secrets", version = "0.8.15" }
deepseek-secrets = { path = "../secrets", version = "0.8.16" }
dirs.workspace = true
serde.workspace = true
toml.workspace = true
+8 -8
View File
@@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
deepseek-agent = { path = "../agent", version = "0.8.15" }
deepseek-config = { path = "../config", version = "0.8.15" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.15" }
deepseek-hooks = { path = "../hooks", version = "0.8.15" }
deepseek-mcp = { path = "../mcp", version = "0.8.15" }
deepseek-protocol = { path = "../protocol", version = "0.8.15" }
deepseek-state = { path = "../state", version = "0.8.15" }
deepseek-tools = { path = "../tools", version = "0.8.15" }
deepseek-agent = { path = "../agent", version = "0.8.16" }
deepseek-config = { path = "../config", version = "0.8.16" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.16" }
deepseek-hooks = { path = "../hooks", version = "0.8.16" }
deepseek-mcp = { path = "../mcp", version = "0.8.16" }
deepseek-protocol = { path = "../protocol", version = "0.8.16" }
deepseek-state = { path = "../state", version = "0.8.16" }
deepseek-tools = { path = "../tools", version = "0.8.16" }
serde_json.workspace = true
uuid.workspace = true
+1 -1
View File
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.15" }
deepseek-protocol = { path = "../protocol", version = "0.8.16" }
serde.workspace = true
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.15" }
deepseek-protocol = { path = "../protocol", version = "0.8.16" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.15" }
deepseek-protocol = { path = "../protocol", version = "0.8.16" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+2 -2
View File
@@ -21,8 +21,8 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.8.15" }
deepseek-tools = { path = "../tools", version = "0.8.15" }
deepseek-secrets = { path = "../secrets", version = "0.8.16" }
deepseek-tools = { path = "../tools", version = "0.8.16" }
schemaui = { version = "0.12.0", default-features = false, optional = true }
async-stream = "0.3.6"
async-trait = "0.1"
+25
View File
@@ -538,6 +538,14 @@ fn build_chat_messages_with_reasoning(
pending_tool_calls.clear();
}
out.push(msg);
} else if role == "system" {
let content = text_parts.join("\n");
if !content.trim().is_empty() {
out.push(json!({
"role": "system",
"content": content,
}));
}
} else if role == "user" {
let content = text_parts.join("\n");
if !content.trim().is_empty() {
@@ -1601,4 +1609,21 @@ mod stream_decoder_tests {
.expect("tool-use block present");
assert_eq!(id, "call_xyz");
}
#[test]
fn request_builder_preserves_internal_system_messages() {
let messages = vec![Message {
role: "system".to_string(),
content: vec![ContentBlock::Text {
text: "internal runtime event".to_string(),
cache_control: None,
}],
}];
let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
assert_eq!(built.len(), 1);
assert_eq!(built[0]["role"], "system");
assert_eq!(built[0]["content"], "internal runtime event");
}
}
+3 -3
View File
@@ -5,7 +5,7 @@ use std::fmt::Write;
use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name};
use crate::localization::{MessageId, tr};
use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort};
use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents};
use super::CommandResult;
@@ -140,8 +140,8 @@ pub fn models(_app: &mut App) -> CommandResult {
/// List sub-agent status from the engine
pub fn subagents(app: &mut App) -> CommandResult {
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
app.view_stack
.push(SubAgentsView::new(app.subagent_cache.clone()));
let agents = subagent_view_agents(app, &app.subagent_cache);
app.view_stack.push(SubAgentsView::new(agents));
}
app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string());
CommandResult::action(AppAction::ListSubAgents)
+42 -11
View File
@@ -906,17 +906,8 @@ impl Engine {
if !completions.is_empty() {
let count = completions.len();
for c in completions {
self.session
.working_set
.observe_user_message(&c.payload, &self.session.workspace);
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: c.payload,
cache_control: None,
}],
})
.await;
self.add_session_message(subagent_completion_runtime_message(&c.payload))
.await;
}
let _ = self
.tx_event
@@ -1860,6 +1851,24 @@ impl Engine {
}
}
fn subagent_completion_runtime_message(payload: &str) -> Message {
Message {
role: "system".to_string(),
content: vec![ContentBlock::Text {
text: format!(
"<deepseek:runtime_event kind=\"subagent_completion\" visibility=\"internal\">\n\
This is an internal runtime event, not user input. Use the sub-agent completion \
data below to continue coordinating the current task. Do not tell the user they \
pasted sentinels, do not explain the sentinel protocol, and do not quote the raw \
XML unless the user explicitly asks to debug sub-agent internals.\n\n\
{payload}\n\
</deepseek:runtime_event>"
),
cache_control: None,
}],
}
}
/// Resolve an `"auto"` reasoning-effort tier to a concrete value.
///
/// When the configured effort is `"auto"`, inspects the last user message
@@ -1905,3 +1914,25 @@ fn resolve_auto_effort(reasoning_effort: Option<&str>, messages: &[Message]) ->
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subagent_completion_handoff_is_internal_system_message() {
let message = subagent_completion_runtime_message(
"Build passed\n<deepseek:subagent.done>{\"agent_id\":\"agent_a\"}</deepseek:subagent.done>",
);
assert_eq!(message.role, "system");
let text = match &message.content[0] {
ContentBlock::Text { text, .. } => text,
other => panic!("expected text block, got {other:?}"),
};
assert!(text.contains("internal runtime event, not user input"));
assert!(text.contains("Do not tell the user they pasted sentinels"));
assert!(text.contains("<deepseek:subagent.done>"));
assert!(text.contains("Build passed"));
}
}
+28 -9
View File
@@ -608,6 +608,15 @@ mod tests {
assert!(prompt.contains("Approval Policy: Suggest"));
}
#[test]
fn package_version_is_current_hotfix_release() {
assert_eq!(
env!("CARGO_PKG_VERSION"),
"0.8.16",
"0.8.16 hotfix branch must report the release version before publishing"
);
}
#[test]
fn compose_prompt_deterministic_order() {
let prompt = compose_prompt(AppMode::Yolo, Personality::Calm);
@@ -721,14 +730,18 @@ mod tests {
}
#[test]
fn when_not_to_use_sections_present() {
fn tool_selection_guide_avoids_defensive_tool_suppression() {
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
assert!(prompt.contains("When NOT to use certain tools"));
assert!(prompt.contains("### `apply_patch`"));
assert!(prompt.contains("### `edit_file`"));
assert!(prompt.contains("### `exec_shell`"));
assert!(prompt.contains("### `agent_spawn`"));
assert!(prompt.contains("### `rlm`"));
assert!(prompt.contains("Tool Selection Guide"));
assert!(prompt.contains("Use `agent_result`"));
assert!(
!prompt.contains("When NOT to use certain tools"),
"the system prompt should steer tool choice without training the model to avoid available tools"
);
assert!(
!prompt.contains("Don't reach for"),
"avoid defensive anti-tool wording in the base prompt"
);
}
/// #588: language-mirroring directive must ship in every mode so
@@ -766,7 +779,7 @@ mod tests {
fn rlm_specialty_tool_guidance_present() {
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
// Structural: the RLM heading must exist as a section anchor.
assert!(prompt.contains("RLM — When to Use It"));
assert!(prompt.contains("RLM — How to Use It"));
// Structural: the word "rlm" must appear multiple times (tool
// name, section heading, toolbox reference). Just verify the
// lowercase form — exact wording is NOT a test concern.
@@ -775,14 +788,20 @@ mod tests {
rlm_count >= 5,
"RLM guidance present: expected >= 5 mentions of 'rlm', got {rlm_count}"
);
assert!(
!prompt.contains("When NOT to use RLM"),
"RLM guidance should explain fit and verification without telling the model to avoid the tool"
);
}
#[test]
fn subagent_done_sentinel_section_present() {
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
assert!(prompt.contains("Sub-agent completion sentinel"));
assert!(prompt.contains("Internal Sub-agent Completion Events"));
assert!(prompt.contains("<deepseek:subagent.done>"));
assert!(prompt.contains("not user input"));
assert!(prompt.contains("Integration protocol"));
assert!(prompt.contains("Do not tell the user they pasted sentinels"));
}
#[test]
+11 -30
View File
@@ -85,7 +85,7 @@ Before you fire any tool, scan your checklist: is there another tool you could r
The dispatcher runs parallel tool calls simultaneously. Serializing independent operations wastes the user's time and grows your context faster than necessary.
## RLM — When to Use It
## RLM — How to Use It
RLM loads input into a Python REPL where you write code that calls sub-LLM helpers (`llm_query`, `llm_query_batched`, `rlm_query`). Three patterns, not one — choose based on the shape of the work:
@@ -95,7 +95,7 @@ RLM loads input into a Python REPL where you write code that calls sub-LLM helpe
**RECURSE** — A problem that benefits from decomposition + critique. Use `rlm_query` to have a sub-LLM review your reasoning, identify gaps, or explore alternative approaches. The sub-LLM returns a synthesized answer you verify against live tool output.
**When NOT to use RLM**: a single short file you can read directly; a simple classification on 3 items; interactive iterative exploration (RLM is one-shot batch). For those, `read_file`, `grep_files`, or `agent_spawn` are faster and cheaper.
For exact counts or structured aggregates, compute them directly in Python inside the REPL (`len`, regexes, parsers, counters) and use child LLM calls only for semantic interpretation. When you chunk a whole input, use `chunk_context()` plus `chunk_coverage()` and report coverage explicitly: chunks processed, total chunks, line/char ranges, and any skipped sections. Cross-check surprising aggregate results with deterministic code before presenting them.
The Python helpers visible inside the REPL (`llm_query`, `llm_query_batched`, `rlm_query`, `rlm_query_batched`) are NOT separately-callable tools — they are functions the sub-agent uses inside its Python code. You only call `rlm` itself from the model side.
@@ -147,48 +147,28 @@ When context is deep (past a soft seam): cache reasoning conclusions in concise
Multiple `tool_calls` in one turn run in parallel. `web_search` returns `ref_id`s — cite as `(ref_id)`.
## When NOT to use certain tools
## Tool Selection Guide
### `apply_patch`
Don't reach for `apply_patch` when:
- You're creating a brand-new file — use `write_file`.
- The change is a single search/replace in one location — `edit_file` is simpler and less error-prone.
- You haven't read the target file yet. Patches written blind almost always fail to apply.
- The file is short enough to rewrite whole — `write_file` with full content avoids fuzz matching entirely.
Use `apply_patch` for structural edits, coordinated changes, or cases where line context matters. Use `write_file` for brand-new files or full-file rewrites. Use `edit_file` for a single unambiguous replacement.
### `edit_file`
Don't reach for `edit_file` when:
- You're making coordinated changes across many files — `apply_patch` with a multi-file diff is atomic.
- You need to insert or delete whole blocks of lines — `apply_patch` handles structural edits more cleanly.
- The search string is ambiguous or could match multiple locations — `apply_patch` with line-number context is more precise.
- You're creating a new file — `write_file` is the correct tool.
Use `edit_file` for one clear replacement in one file. Use `apply_patch` when the edit changes whole blocks, touches multiple files, or needs surrounding line context.
### `exec_shell`
Don't reach for `exec_shell` when:
- A structured tool already covers the same operation: `grep_files` for code search, `git_status`/`git_diff` for git inspection, `read_file` for file contents.
- You just need to read or write a file — `read_file` / `write_file` are faster and show up in the tool log.
- The command is a single `cat`, `ls`, or `echo` — use `read_file`, `list_dir`, or just state the result.
- You're tempted to pipe `curl` for a web lookup — `web_search` or `fetch_url` give structured results.
- The command may run for minutes, start a server, run a full test suite, or perform a scientific/release computation — use `task_shell_start` or `exec_shell` with `background: true`, then poll with `task_shell_wait` or `exec_shell_wait`.
Use `exec_shell` for shell-native diagnostics, pipelines, and bounded commands. Use structured tools for structured operations when they map directly (`grep_files`, `git_diff`, `read_file`). For long commands, servers, full test suites, or release computations, start background work with `task_shell_start` or `exec_shell` using `background: true`, then poll with `task_shell_wait` or `exec_shell_wait`.
### `agent_spawn`
Don't reach for `agent_spawn` when:
- The task is a single read or search you can do in one turn — spawning has overhead.
- You need sequential steps where each depends on the prior result — run them yourself, in order.
- The work can be done with a fast `exec_shell` pipeline or a `grep_files` call.
Use `agent_spawn` for independent investigations or implementation slices that can run while you continue coordinating. Use `agent_wait` when you need one or more completions. Use `agent_result` when the sentinel summary is too thin or you need the full structured output. Keep tiny single-read/search tasks local so the transcript stays compact.
### `rlm`
Don't reach for `rlm` (the recursive language model tool) when:
- The input fits comfortably in your context window and the task is straightforward — just read it directly with `read_file`.
- A simple `grep_files` or `exec_shell` pipeline can answer the question.
- You need interactive, iterative exploration of the data — `rlm` is batch-oriented (the sub-LLM writes Python in one shot, then returns).
- The task is a simple classification or extraction on short text — your own reasoning is faster and cheaper.
Use `rlm` for long-context semantic work, bulk classification/extraction, and decomposition where a Python REPL plus child LLM helpers is useful. Use deterministic Python inside RLM for exact counts and structured aggregation; use `grep_files` or `exec_shell` directly when that is the clearest deterministic check.
Inside the `rlm` REPL, the sub-LLM has access to `llm_query()`, `llm_query_batched()`, `rlm_query()`, and `rlm_query_batched()` as Python helpers for further sub-LLM work — those are not standalone tools you call directly.
## Sub-agent completion sentinel
## Internal Sub-agent Completion Events
When you spawn a sub-agent via `agent_spawn`, the child runs independently. You will receive a `<deepseek:subagent.done>` element in the transcript when it finishes. This sentinel carries:
When you spawn a sub-agent via `agent_spawn`, the child runs independently. The runtime may send you an internal `<deepseek:subagent.done>` completion event when it finishes. This event is not user input. It carries:
- `agent_id` — the child's identifier
- `summary` — a human-readable summary of what the child found or did
@@ -201,6 +181,7 @@ When you spawn a sub-agent via `agent_spawn`, the child runs independently. You
3. If the summary is insufficient, call `agent_result` to pull the full structured result.
4. If the child failed (`"failed"`), assess whether the failure blocks your plan or whether you can proceed with a fallback.
5. Update your `checklist_write` items to reflect the child's contribution.
6. Do not tell the user they pasted sentinels or explain this protocol unless they explicitly ask about sub-agent internals.
You may see multiple `<deepseek:subagent.done>` sentinels in a single turn when children were spawned in parallel. Process each one, then synthesize.
+115 -11
View File
@@ -144,6 +144,7 @@ pub struct PythonRuntime {
stdout_limit: usize,
round_count: u64,
started: Instant,
round_timeout: Option<Duration>,
}
impl PythonRuntime {
@@ -151,7 +152,7 @@ impl PythonRuntime {
/// Used by the agent loop for inline `repl` blocks the model emits in
/// regular conversation.
pub async fn new() -> Result<Self, String> {
Self::spawn_inner(None).await
Self::spawn_inner(None, Some(ROUND_TIMEOUT)).await
}
/// Compatibility shim — older RLM code path used to pass a state file.
@@ -172,10 +173,13 @@ impl PythonRuntime {
/// Spawn a REPL with `context` (and `ctx`) preloaded from a file. Used
/// by the RLM turn loop.
pub async fn spawn_with_context(context_path: &Path) -> Result<Self, String> {
Self::spawn_inner(Some(context_path)).await
Self::spawn_inner(Some(context_path), None).await
}
async fn spawn_inner(context_path: Option<&Path>) -> Result<Self, String> {
async fn spawn_inner(
context_path: Option<&Path>,
round_timeout: Option<Duration>,
) -> Result<Self, String> {
let session_id = Uuid::new_v4().simple().to_string();
let bootstrap = render_bootstrap(&session_id);
@@ -215,6 +219,7 @@ impl PythonRuntime {
stdout_limit: DEFAULT_STDOUT_LIMIT,
round_count: 0,
started: Instant::now(),
round_timeout,
};
// Wait for `__RLM_READY_<sid>__` before handing control back. If
@@ -298,6 +303,7 @@ impl PythonRuntime {
let mut final_value: Option<String> = None;
let mut had_error = false;
let mut rpc_count: u32 = 0;
let round_timeout = self.round_timeout;
let read_loop = async {
loop {
@@ -360,15 +366,19 @@ impl PythonRuntime {
Ok::<_, String>(())
};
match tokio::time::timeout(ROUND_TIMEOUT, read_loop).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(format!(
"REPL round timed out after {}s",
ROUND_TIMEOUT.as_secs()
));
if let Some(round_timeout) = round_timeout {
match tokio::time::timeout(round_timeout, read_loop).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(format!(
"REPL round timed out after {}s",
round_timeout.as_secs()
));
}
}
} else {
read_loop.await?;
}
let stderr = self.drain_stderr().await;
@@ -429,6 +439,12 @@ impl PythonRuntime {
self.round_count
}
/// Current per-round timeout policy. RLM context runs intentionally return
/// `None` so long map-reduce jobs are not killed by the old 180s cap.
pub fn round_timeout(&self) -> Option<Duration> {
self.round_timeout
}
/// Wall-clock uptime since spawn.
pub fn uptime(&self) -> Duration {
self.started.elapsed()
@@ -578,6 +594,55 @@ def repl_get(name, default=None):
def repl_set(name, value):
globals()[str(name)] = value
def chunk_context(max_chars=20000, overlap=0):
"""Return full-coverage context chunks with index/start/end/text fields."""
max_chars = int(max_chars)
overlap = max(0, int(overlap))
if max_chars <= 0:
raise ValueError("max_chars must be > 0")
if overlap >= max_chars:
raise ValueError("overlap must be smaller than max_chars")
chunks = []
start = 0
idx = 0
total = len(context)
while start < total:
end = min(total, start + max_chars)
chunks.append({"index": idx, "start": start, "end": end, "text": context[start:end]})
idx += 1
if end >= total:
break
start = end - overlap
return chunks
def chunk_coverage(chunks):
"""Summarize coverage for chunks produced by chunk_context()."""
spans = []
for c in chunks:
try:
spans.append((int(c["start"]), int(c["end"])))
except Exception:
continue
spans.sort()
covered = 0
cursor = 0
gaps = []
for start, end in spans:
if start > cursor:
gaps.append((cursor, start))
if end > cursor:
covered += end - max(start, cursor)
cursor = end
if cursor < len(context):
gaps.append((cursor, len(context)))
return {
"chunks": len(chunks),
"context_chars": len(context),
"covered_chars": covered,
"gaps": gaps,
"complete": covered >= len(context) and not gaps,
}
# Load the long input as `context` (and `ctx`) from a file. This keeps the
# big string out of the process command-line and out of the LLM's window.
_ctx_file = _os.environ.get("RLM_CONTEXT_FILE","")
@@ -595,6 +660,7 @@ _BOOTSTRAP_NAMES = {
"_rpc","_ctx_file","_BOOTSTRAP_NAMES","_main_loop",
"llm_query","llm_query_batched","rlm_query","rlm_query_batched",
"FINAL","FINAL_VAR","SHOW_VARS","repl_get","repl_set",
"chunk_context","chunk_coverage",
"context","ctx",
"_json","_os","_sys","_traceback",
}
@@ -773,6 +839,44 @@ mod tests {
rt.shutdown().await;
}
#[tokio::test]
async fn context_chunk_helpers_report_full_coverage() {
let path = write_temp_context("abcdefghijklmnopqrstuvwxyz");
let mut rt = PythonRuntime::spawn_with_context(&path)
.await
.expect("spawn");
let round = rt
.execute(
"chunks = chunk_context(max_chars=10)\n\
coverage = chunk_coverage(chunks)\n\
print(len(chunks), coverage['covered_chars'], coverage['complete'])",
)
.await
.expect("execute");
assert!(round.stdout.contains("3 26 True"), "{}", round.stdout);
rt.shutdown().await;
}
#[tokio::test]
async fn rlm_context_runtime_has_no_fixed_round_timeout() {
let path = write_temp_context("long input");
let rt = PythonRuntime::spawn_with_context(&path)
.await
.expect("spawn");
assert!(
rt.round_timeout().is_none(),
"RLM context runs must not inherit the old 180s REPL round timeout"
);
rt.shutdown().await;
}
#[tokio::test]
async fn inline_runtime_keeps_bounded_round_timeout() {
let rt = PythonRuntime::new().await.expect("spawn");
assert_eq!(rt.round_timeout(), Some(ROUND_TIMEOUT));
rt.shutdown().await;
}
#[tokio::test]
async fn final_is_captured() {
let mut rt = PythonRuntime::new().await.expect("spawn");
+18 -3
View File
@@ -19,6 +19,8 @@ The REPL exposes:
- `llm_query_batched(prompts, model=None)` — concurrent fan-out. Returns `list[str]` in input order. The `model` argument is accepted for compatibility but ignored.
- `rlm_query(prompt, model=None)` — recursive sub-RLM. Use when a sub-task itself needs decomposition. The `model` argument is accepted for compatibility but ignored.
- `rlm_query_batched(prompts, model=None)` — concurrent recursive sub-RLMs. The `model` argument is accepted for compatibility but ignored.
- `chunk_context(max_chars=20000, overlap=0)` — full-coverage chunks with index/start/end/text fields.
- `chunk_coverage(chunks)` — coverage summary for chunks produced by `chunk_context`.
- `SHOW_VARS()` — list user variables and their types.
- `repl_set(name, value)` / `repl_get(name)` — explicit cross-round storage.
- `print(...)` — diagnostic output. The driver feeds you a truncated preview next round.
@@ -40,11 +42,12 @@ print(context[:500])
2. CHUNK + map-reduce with batched concurrent calls.
```repl
chunk_size = 8000
chunks = [context[i:i+chunk_size] for i in range(0, len(context), chunk_size)]
prompts = [f"Extract any mentions of X from this section:\n\n{c}" for c in chunks]
chunks = chunk_context(max_chars=chunk_size)
coverage = chunk_coverage(chunks)
prompts = [f"Extract any mentions of X from section {c['index']} ({c['start']}:{c['end']}):\n\n{c['text']}" for c in chunks]
partials = llm_query_batched(prompts)
combined = "\n\n".join(partials)
answer = llm_query(f"Synthesize across these section-level extractions:\n\n{combined}")
answer = llm_query(f"Coverage: {coverage}\n\nSynthesize across these section-level extractions:\n\n{combined}")
print(answer[:500])
```
Then on the next turn:
@@ -73,6 +76,8 @@ Rules
- Never `print(context)` or otherwise dump it whole — slice, sample, or chunk.
- You MUST call `llm_query` / `llm_query_batched` / `rlm_query` at least once before `FINAL(...)`. Calling FINAL from a top-level prose answer (without ever running a `repl` block that touched `context` via a sub-LLM) is REJECTED — the driver will discard the FINAL and ask you to actually use the REPL.
- Sub-LLMs are powerful — feed them generous chunks (tens of thousands of chars), not tiny windows.
- For exact counts, package totals, line totals, or other structured aggregates, compute them with Python over `context` directly. Do not ask a child LLM to count.
- For whole-input map-reduce, report coverage in the final answer: chunks processed, total chunks, and whether every line/char range was included. If you only processed a subset, say that explicitly.
- Do NOT pad your output with prose like "Here is what I'll do:" — just emit the next ```repl block.
"#;
@@ -115,6 +120,8 @@ mod tests {
"llm_query_batched",
"rlm_query",
"rlm_query_batched",
"chunk_context",
"chunk_coverage",
"SHOW_VARS",
"FINAL",
"FINAL_VAR",
@@ -133,4 +140,12 @@ mod tests {
"system prompt should reject the prose-shortcut path explicitly"
);
}
#[test]
fn rlm_prompt_requires_deterministic_counts_and_coverage() {
let s = body();
assert!(s.contains("compute them with Python"));
assert!(s.contains("report coverage"));
assert!(s.contains("chunks processed"));
}
}
+27 -7
View File
@@ -34,8 +34,6 @@ const STDOUT_METADATA_PREVIEW_LEN: usize = 800;
const PROMPT_PREVIEW_LEN: usize = 500;
/// Temperature for root LLM calls.
const ROOT_TEMPERATURE: f32 = 0.3;
/// Hard wall-clock cap on a whole RLM turn.
const TURN_TIMEOUT: Duration = Duration::from_secs(180);
/// Bound on conversation history we keep across iterations.
const MAX_HISTORY_MESSAGES: usize = 20;
@@ -156,6 +154,13 @@ pub(crate) fn run_rlm_turn_inner(
))
}
/// RLM turns are long-running background-style work. Do not kill the whole
/// turn with the old fixed 180s wall-clock cap; per-request cancellation still
/// comes from the parent turn token and the user can cancel from the TUI.
fn turn_timeout() -> Option<Duration> {
None
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
@@ -237,15 +242,14 @@ async fn run_rlm_turn_impl(
let result = 'turn: {
for iteration in 0..MAX_RLM_ITERATIONS {
if start.elapsed() > TURN_TIMEOUT {
if let Some(timeout) = turn_timeout()
&& start.elapsed() > timeout
{
break 'turn RlmTurnResult {
answer: String::new(),
iterations: iteration,
duration: start.elapsed(),
error: Some(format!(
"RLM turn timed out after {}s",
TURN_TIMEOUT.as_secs()
)),
error: Some(format!("RLM turn timed out after {}s", timeout.as_secs())),
usage: total_usage,
termination: RlmTermination::Error,
trace: trace.clone(),
@@ -588,6 +592,14 @@ fn build_metadata_message(
parts.push("**REPL helpers** (use inside ```repl blocks)".to_string());
parts.push("- `context` / `ctx` — the full input string".to_string());
parts.push("- `len(context)` / `context[a:b]` / `context.splitlines()` — slice it".to_string());
parts.push(
"- `chunk_context(max_chars=20000, overlap=0)` — full-coverage chunks with index/start/end/text"
.to_string(),
);
parts.push(
"- `chunk_coverage(chunks)` — coverage report for chunk_context output"
.to_string(),
);
parts.push(
"- `llm_query(prompt, model=None)` — one-shot child LLM; `model` is ignored and child calls stay pinned to Flash"
.to_string(),
@@ -971,4 +983,12 @@ mod tests {
assert!(s.contains("line19"));
assert!(s.contains("…"));
}
#[test]
fn rlm_turn_has_no_fixed_wall_clock_timeout() {
assert!(
turn_timeout().is_none(),
"RLM turns should not be killed by the old fixed 180s wall-clock cap"
);
}
}
+46 -13
View File
@@ -60,20 +60,22 @@ impl ToolSpec for RlmTool {
calls in-REPL helpers (`llm_query`, `llm_query_batched`, \
`rlm_query`, `rlm_query_batched`) to process it, then returns a \
synthesized answer. \n\n\
DO NOT use this tool when: the input fits in your context (just \
use `read_file` and reason directly); a `grep_files` / \
`exec_shell` pipeline would answer the question; the task is a \
short classification or extraction; you need interactive \
iterative exploration (rlm is one-shot batch). \n\n\
Use this tool only when the input is genuinely too large to load \
(a whole file > 50K tokens, a long transcript, a multi-document \
corpus). It is slower and more expensive than direct reasoning. \n\n\
Use this tool when the input is genuinely large or when a Python \
map-reduce pass plus child LLM calls is the right shape: whole \
files, long transcripts, multi-document corpora, bulk semantic \
classification, or decomposition/critique work. For exact counts \
or structured aggregates, compute them directly in Python inside \
the REPL and report the deterministic result instead of asking a \
child LLM to guess. For whole-input map-reduce, use the REPL \
helpers `chunk_context()` and `chunk_coverage()` so the result \
states what was covered. \n\n\
Provide `task` (what to do) plus exactly one of `file_path` \
(workspace-relative, preferred — keeps the long input out of \
your context entirely) or `content` (inline, capped at 200k \
chars). The Python helpers (`llm_query`, `rlm_query`, etc.) live \
INSIDE the REPL — they are not separately-callable tools. \n\n\
Returns the final synthesized answer as a string."
Returns the final synthesized answer plus an RLM report showing \
input size, iterations, duration, sub-LLM calls, and trace summary."
}
fn input_schema(&self) -> Value {
@@ -177,6 +179,8 @@ impl ToolSpec for RlmTool {
"rlm: input is empty after loading",
));
}
let input_chars = body.chars().count();
let input_lines = body.lines().count();
// Pin child calls to Flash so model-generated tool args cannot quietly
// turn fanout work into Pro-billed requests. The RLM root still uses
@@ -250,6 +254,14 @@ impl ToolSpec for RlmTool {
RlmTermination::Error => String::new(),
};
let report = format!(
"RLM report:\n- input: {input_lines} line(s), {input_chars} char(s)\n- iterations: {}\n- duration: {}ms\n- sub-LLM RPCs: {}\n- termination: {:?}\n\nAnswer:\n",
result.iterations,
result.duration.as_millis(),
result.total_rpcs,
result.termination,
);
let trace_summary = if result.trace.is_empty() {
String::from("\n\n[trace: no REPL rounds executed]")
} else {
@@ -309,14 +321,17 @@ impl ToolSpec for RlmTool {
"child_model": child_model,
"termination": format!("{:?}", result.termination).to_lowercase(),
"max_depth": max_depth,
"context_chars": input_chars,
"context_lines": input_lines,
"total_rpcs": result.total_rpcs,
"trace": trace_json,
});
Ok(
ToolResult::success(format!("{}{}{}", result.answer, footer, trace_summary))
.with_metadata(metadata),
)
Ok(ToolResult::success(format!(
"{report}{}{}{}",
result.answer, footer, trace_summary
))
.with_metadata(metadata))
}
}
@@ -369,6 +384,24 @@ mod tests {
assert!(tool().supports_parallel());
}
#[test]
fn description_steers_without_suppressing_rlm_use() {
let t = tool();
let description = t.description();
assert!(
description.contains("Use this tool when"),
"description should positively explain the RLM fit"
);
assert!(
!description.contains("DO NOT use"),
"avoid training the model to avoid an available tool"
);
assert!(
!description.contains("slower and more expensive"),
"cost caveats belong in verification guidance, not tool suppression"
);
}
#[tokio::test]
async fn returns_not_available_without_client() {
let t = tool();
+17
View File
@@ -3720,6 +3720,7 @@ impl SubAgentToolRegistry {
if !self.is_tool_allowed(name) {
return Err(anyhow!("Tool {name} not allowed for this sub-agent"));
}
reject_subagent_terminal_takeover(name, &input)?;
self.registry
.execute(name, input)
.await
@@ -3727,6 +3728,22 @@ impl SubAgentToolRegistry {
}
}
fn reject_subagent_terminal_takeover(name: &str, input: &Value) -> Result<()> {
let wants_interactive_shell = name == "exec_shell"
&& input
.get("interactive")
.and_then(Value::as_bool)
.unwrap_or(false);
if wants_interactive_shell {
return Err(anyhow!(
"Sub-agents run in the background and cannot use exec_shell with interactive=true \
because that would take over the parent TUI terminal. Use non-interactive \
exec_shell, background=true, tty=true, or task_shell_start instead."
));
}
Ok(())
}
/// Resolve the effective allowed-tools list for a child.
///
/// **v0.6.6 default: full inheritance.** Returning `Ok(None)` means the
+33
View File
@@ -1003,6 +1003,39 @@ fn mailbox_propagates_through_child_runtime_chain() {
);
}
#[test]
fn subagent_rejects_interactive_shell_terminal_takeover() {
let err = reject_subagent_terminal_takeover(
"exec_shell",
&serde_json::json!({
"command": "python3 -i",
"interactive": true
}),
)
.expect_err("sub-agents must not inherit the parent terminal");
let msg = err.to_string();
assert!(msg.contains("cannot use exec_shell with interactive=true"));
assert!(msg.contains("parent TUI terminal"));
reject_subagent_terminal_takeover(
"exec_shell",
&serde_json::json!({
"command": "cargo check",
"interactive": false
}),
)
.expect("non-interactive shell remains allowed");
reject_subagent_terminal_takeover(
"exec_shell",
&serde_json::json!({
"command": "cargo test",
"background": true
}),
)
.expect("background shell remains allowed");
}
#[tokio::test]
async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() {
use crate::tools::subagent::mailbox::Mailbox;
+137 -4
View File
@@ -82,6 +82,7 @@ use crate::tui::tool_routing::{
};
use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_display_width};
use crate::tui::user_input::UserInputView;
use crate::tui::views::subagent_view_agents;
use super::active_cell::ActiveCell;
use super::app::{
@@ -577,6 +578,8 @@ async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManag
.map(task_summary_to_panel_entry)
.collect();
entries.extend(active_rlm_task_entries(app));
if let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref()
&& let Ok(mut mgr) = shell_mgr.lock()
{
@@ -596,6 +599,39 @@ async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManag
app.task_panel = entries;
}
fn active_rlm_task_entries(app: &App) -> Vec<TaskPanelEntry> {
let Some(active) = app.active_cell.as_ref() else {
return Vec::new();
};
let duration_ms = app
.turn_started_at
.map(|started| u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX));
active
.entries()
.iter()
.enumerate()
.filter_map(|(idx, entry)| {
let HistoryCell::Tool(ToolCell::Generic(generic)) = entry else {
return None;
};
if generic.name != "rlm" || generic.status != ToolStatus::Running {
return None;
}
let summary = generic
.input_summary
.as_deref()
.filter(|summary| !summary.trim().is_empty())
.unwrap_or("running chunked analysis");
Some(TaskPanelEntry {
id: format!("rlm-{}", idx + 1),
status: "running".to_string(),
prompt_summary: format!("RLM: {summary}"),
duration_ms,
})
})
.collect()
}
#[allow(clippy::too_many_lines)]
async fn run_event_loop(
terminal: &mut AppTerminal,
@@ -623,6 +659,7 @@ async fn run_event_loop(
// #376: native-copy escape — hold Shift to bypass alt-screen mouse capture
// for terminal-native text selection.
let mut shift_bypass_active = false;
let mut terminal_paused_at: Option<Instant> = None;
loop {
if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
@@ -1118,6 +1155,7 @@ async fn run_event_loop(
app.use_bracketed_paste,
)?;
event_broker.pause_events();
terminal_paused_at = Some(Instant::now());
}
}
EngineEvent::ResumeEvents => {
@@ -1129,6 +1167,7 @@ async fn run_event_loop(
app.use_bracketed_paste,
)?;
event_broker.resume_events();
terminal_paused_at = None;
}
}
EngineEvent::AgentSpawned { id, prompt } => {
@@ -1157,11 +1196,54 @@ async fn run_event_loop(
app.status_message = Some(format!("Sub-agent {id}: {display}"));
}
EngineEvent::AgentComplete { id, result } => {
let subagent_elapsed = app
.agent_activity_started_at
.or(app.turn_started_at)
.map(|started| started.elapsed())
.unwrap_or_default();
let has_other_running_subagents =
app.agent_progress.keys().any(|agent_id| agent_id != &id)
|| app.subagent_cache.iter().any(|agent| {
agent.agent_id != id
&& matches!(agent.status, SubAgentStatus::Running)
});
app.agent_progress.remove(&id);
app.status_message = Some(format!(
"Sub-agent {id} completed: {}",
summarize_tool_output(&result)
));
let should_recapture_terminal =
!has_other_running_subagents && app.use_alt_screen;
if !has_other_running_subagents
&& let Some((method, threshold, include_summary)) =
notification_settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = subagent_completion_notification_message(
&id,
&result,
include_summary,
subagent_elapsed,
);
crate::tui::notifications::notify_done(
method,
in_tmux,
&msg,
threshold,
subagent_elapsed,
);
}
if should_recapture_terminal {
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
event_broker.resume_events();
terminal_paused_at = None;
app.needs_redraw = true;
}
let _ = engine_handle.send(Op::ListSubAgents).await;
}
EngineEvent::AgentList { agents } => {
@@ -1170,9 +1252,10 @@ async fn run_event_loop(
sorted.retain(|a| !a.from_prior_session);
app.subagent_cache = sorted.clone();
reconcile_subagent_activity_state(app);
if app.view_stack.update_subagents(&sorted) {
let view_agents = subagent_view_agents(app, &sorted);
if app.view_stack.update_subagents(&view_agents) {
app.status_message =
Some(format!("Sub-agents: {} total", sorted.len()));
Some(format!("Sub-agents: {} total", view_agents.len()));
}
// Individual spawn/complete events already log to history;
// full list available via /agents command.
@@ -1401,8 +1484,23 @@ async fn run_event_loop(
}
if event_broker.is_paused() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
let grace_active = terminal_paused_at
.map(|paused_at| paused_at.elapsed() < Duration::from_millis(500))
.unwrap_or(false);
if terminal_pause_has_live_owner(app) || grace_active {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
event_broker.resume_events();
terminal_paused_at = None;
app.status_message = Some("Terminal controls restored".to_string());
app.needs_redraw = true;
}
let now = Instant::now();
@@ -3090,6 +3188,30 @@ fn completed_turn_notification_message(
msg
}
fn subagent_completion_notification_message(
id: &str,
result: &str,
include_summary: bool,
elapsed: Duration,
) -> String {
let result_line = result
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<deepseek:subagent.done>"));
let mut msg = result_line
.and_then(notification_text_summary)
.map(|summary| format!("sub-agent {id}: {summary}"))
.unwrap_or_else(|| format!("deepseek: sub-agent {id} complete"));
if include_summary {
let human = crate::tui::notifications::humanize_duration(elapsed);
msg.push('\n');
msg.push_str(&format!("deepseek: sub-agent complete ({human})"));
}
msg
}
fn latest_assistant_notification_text(messages: &[Message]) -> Option<String> {
messages
.iter()
@@ -6320,6 +6442,17 @@ fn active_foreground_shell_running(app: &App) -> bool {
})
}
fn terminal_pause_has_live_owner(app: &App) -> bool {
app.active_cell.as_ref().is_some_and(|active| {
active.entries().iter().any(|cell| {
matches!(
cell,
HistoryCell::Tool(ToolCell::Exec(exec)) if exec.status == ToolStatus::Running
)
})
})
}
fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) {
let HistoryCell::Tool(tool) = cell else {
return;
+92
View File
@@ -1937,6 +1937,72 @@ fn spillover_pager_section_returns_notice_when_file_missing() {
assert!(section.contains("could not read spillover file"));
}
#[test]
fn terminal_pause_has_live_owner_only_for_running_exec_cells() {
let mut app = create_test_app();
assert!(!terminal_pause_has_live_owner(&app));
let mut active = ActiveCell::new();
active.push_tool(
"tool-1",
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "python3 -i".to_string(),
status: ToolStatus::Running,
output: None,
started_at: Some(Instant::now()),
duration_ms: None,
source: ExecSource::Assistant,
interaction: Some("interactive".to_string()),
})),
);
app.active_cell = Some(active);
assert!(terminal_pause_has_live_owner(&app));
let mut active = ActiveCell::new();
active.push_tool(
"tool-2",
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "rlm".to_string(),
status: ToolStatus::Running,
input_summary: Some("file_path: Cargo.lock".to_string()),
output: None,
prompts: None,
spillover_path: None,
})),
);
app.active_cell = Some(active);
assert!(
!terminal_pause_has_live_owner(&app),
"non-interactive RLM work must not keep the terminal in host-scrollback mode"
);
}
#[test]
fn active_rlm_task_entries_surface_foreground_rlm_work() {
let mut app = create_test_app();
app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));
let mut active = ActiveCell::new();
active.push_tool(
"tool-rlm",
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "rlm".to_string(),
status: ToolStatus::Running,
input_summary: Some("file_path: Cargo.lock".to_string()),
output: None,
prompts: None,
spillover_path: None,
})),
);
app.active_cell = Some(active);
let entries = active_rlm_task_entries(&app);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "rlm-1");
assert_eq!(entries[0].status, "running");
assert_eq!(entries[0].prompt_summary, "RLM: file_path: Cargo.lock");
assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);
}
#[test]
fn details_shortcut_modifiers_accept_plain_shift_and_alt_only() {
assert!(details_shortcut_modifiers(KeyModifiers::NONE));
@@ -3511,3 +3577,29 @@ fn completed_turn_notification_truncates_long_text() {
// 360-char body + 3-char ellipsis
assert_eq!(msg.chars().count(), 363);
}
#[test]
fn subagent_completion_notification_uses_summary_line_not_sentinel() {
let msg = super::subagent_completion_notification_message(
"agent_live",
"Finished the docs audit.\n<deepseek:subagent.done>{}</deepseek:subagent.done>",
false,
Duration::from_secs(42),
);
assert_eq!(msg, "sub-agent agent_live: Finished the docs audit.");
assert!(!msg.contains("deepseek:subagent.done"));
}
#[test]
fn subagent_completion_notification_can_include_elapsed_summary() {
let msg = super::subagent_completion_notification_message(
"agent_live",
"",
true,
Duration::from_secs(65),
);
assert!(msg.contains("deepseek: sub-agent agent_live complete"));
assert!(msg.contains("deepseek: sub-agent complete (1m 5s)"));
}
+174 -2
View File
@@ -7,9 +7,11 @@ use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::settings::Settings;
use crate::tools::UserInputResponse;
use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType};
use crate::tools::subagent::{SubAgentAssignment, SubAgentResult, SubAgentStatus, SubAgentType};
use crate::tui::app::App;
use crate::tui::approval::{ElevationOption, ReviewDecision};
use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
use crate::tui::widgets::agent_card::AgentLifecycle;
pub mod status_picker;
@@ -1384,6 +1386,105 @@ pub struct SubAgentsView {
scroll: usize,
}
/// Build the agent rows shown by `/subagents`.
///
/// The engine manager is the durable source of truth, but live UI cards can
/// briefly be ahead of the manager-list refresh. Include those live rows so
/// the command does not say "no agents" while the footer/sidebar already show
/// active delegated work.
pub(crate) fn subagent_view_agents(
app: &App,
manager_agents: &[SubAgentResult],
) -> Vec<SubAgentResult> {
let mut agents = manager_agents.to_vec();
let mut seen: std::collections::HashSet<String> =
agents.iter().map(|agent| agent.agent_id.clone()).collect();
for (agent_id, progress) in &app.agent_progress {
if seen.insert(agent_id.clone()) {
agents.push(live_subagent_result(
agent_id,
SubAgentType::General,
SubAgentStatus::Running,
progress,
Some("live"),
));
}
}
for cell in &app.history {
match cell {
HistoryCell::SubAgent(SubAgentCell::Delegate(card))
if seen.insert(card.agent_id.clone()) =>
{
let agent_type =
SubAgentType::from_str(&card.agent_type).unwrap_or(SubAgentType::General);
agents.push(live_subagent_result(
&card.agent_id,
agent_type,
lifecycle_to_subagent_status(card.status),
card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
Some("transcript"),
));
}
HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => {
for worker in &card.workers {
if seen.insert(worker.agent_id.clone()) {
let objective = format!(
"{} worker {}",
summarize_tool_output(&card.kind),
summarize_tool_output(&worker.worker_id)
);
agents.push(live_subagent_result(
&worker.agent_id,
SubAgentType::General,
lifecycle_to_subagent_status(worker.status),
&objective,
Some(card.kind.as_str()),
));
}
}
}
_ => {}
}
}
agents
}
fn lifecycle_to_subagent_status(status: AgentLifecycle) -> SubAgentStatus {
match status {
AgentLifecycle::Pending | AgentLifecycle::Running => SubAgentStatus::Running,
AgentLifecycle::Completed => SubAgentStatus::Completed,
AgentLifecycle::Failed => SubAgentStatus::Failed("failed in transcript".to_string()),
AgentLifecycle::Cancelled => SubAgentStatus::Cancelled,
}
}
fn live_subagent_result(
agent_id: &str,
agent_type: SubAgentType,
status: SubAgentStatus,
objective: &str,
role: Option<&str>,
) -> SubAgentResult {
SubAgentResult {
agent_id: agent_id.to_string(),
agent_type,
assignment: SubAgentAssignment {
objective: summarize_tool_output(objective),
role: role.map(str::to_string),
},
model: String::new(),
nickname: None,
status,
result: None,
steps_taken: 0,
duration_ms: 0,
from_prior_session: false,
}
}
impl SubAgentsView {
pub fn new(agents: Vec<SubAgentResult>) -> Self {
Self { agents, scroll: 0 }
@@ -1742,11 +1843,16 @@ fn truncate_view_text(text: &str, max_chars: usize) -> String {
mod tests {
use super::{
ConfigListItem, ConfigSection, ConfigView, ModalKind, ModalView, ShellControlView,
ViewAction, ViewEvent, ViewStack, truncate_view_text,
ViewAction, ViewEvent, ViewStack, subagent_view_agents, truncate_view_text,
};
use crate::config::Config;
use crate::localization::Locale;
use crate::tools::subagent::{
SubAgentAssignment, SubAgentResult, SubAgentStatus, SubAgentType,
};
use crate::tui::app::{App, TuiOptions};
use crate::tui::history::{HistoryCell, SubAgentCell};
use crate::tui::widgets::agent_card::{AgentLifecycle, FanoutCard};
use crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
@@ -1785,6 +1891,72 @@ mod tests {
}
}
fn manager_agent(id: &str, status: SubAgentStatus) -> SubAgentResult {
SubAgentResult {
agent_id: id.to_string(),
agent_type: SubAgentType::Explore,
assignment: SubAgentAssignment {
objective: "read the docs".to_string(),
role: None,
},
model: "deepseek-v4-flash".to_string(),
nickname: None,
status,
result: None,
steps_taken: 1,
duration_ms: 10,
from_prior_session: false,
}
}
#[test]
fn subagent_view_agents_includes_progress_only_running_agent() {
let mut app = create_test_app();
app.agent_progress
.insert("agent_live".to_string(), "reading code".to_string());
let agents = subagent_view_agents(&app, &[]);
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].agent_id, "agent_live");
assert!(matches!(agents[0].status, SubAgentStatus::Running));
assert_eq!(agents[0].assignment.role.as_deref(), Some("live"));
assert!(agents[0].assignment.objective.contains("reading code"));
}
#[test]
fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() {
let mut app = create_test_app();
let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]);
card.upsert_worker("chunk_1", AgentLifecycle::Completed);
card.upsert_worker("chunk_2", AgentLifecycle::Running);
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
app.last_fanout_card_index = Some(app.history.len().saturating_sub(1));
let agents = subagent_view_agents(&app, &[]);
assert_eq!(agents.len(), 2);
assert_eq!(agents[0].agent_id, "chunk_1");
assert!(matches!(agents[0].status, SubAgentStatus::Completed));
assert_eq!(agents[1].agent_id, "chunk_2");
assert!(matches!(agents[1].status, SubAgentStatus::Running));
assert_eq!(agents[1].assignment.role.as_deref(), Some("rlm"));
}
#[test]
fn subagent_view_agents_deduplicates_manager_rows_over_live_rows() {
let mut app = create_test_app();
app.agent_progress
.insert("agent_cached".to_string(), "live duplicate".to_string());
let manager = vec![manager_agent("agent_cached", SubAgentStatus::Running)];
let agents = subagent_view_agents(&app, &manager);
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].agent_type, SubAgentType::Explore);
assert_eq!(agents[0].assignment.objective, "read the docs");
}
fn visible_section_labels(view: &ConfigView) -> Vec<&'static str> {
view.visible_items()
.into_iter()
+65 -15
View File
@@ -389,23 +389,15 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool
}
MailboxMessage::Progress { status, .. } => {
card.status = AgentLifecycle::Running;
card.push_action(status);
if !is_low_signal_progress(status) {
card.push_action(status);
}
}
MailboxMessage::ToolCallStarted {
tool_name, step, ..
} => {
card.push_action(format!("[{step}] {tool_name} started"));
MailboxMessage::ToolCallStarted { tool_name, .. } => {
card.push_action(format!("{tool_name} running"));
}
MailboxMessage::ToolCallCompleted {
tool_name,
step,
ok,
..
} => {
card.push_action(format!(
"[{step}] {tool_name} {}",
if *ok { "ok" } else { "failed" }
));
MailboxMessage::ToolCallCompleted { tool_name, ok, .. } => {
card.push_action(format!("{tool_name} {}", if *ok { "ok" } else { "failed" }));
}
MailboxMessage::Completed { summary, .. } => {
card.status = AgentLifecycle::Completed;
@@ -433,6 +425,13 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool
true
}
fn is_low_signal_progress(status: &str) -> bool {
let status = status.trim().to_ascii_lowercase();
status.contains("requesting model response")
|| status.starts_with("started (")
|| (status.starts_with("step ") && status.contains(": complete"))
}
/// Apply a mailbox envelope to a `FanoutCard`. Updates per-worker state
/// based on which child the envelope is about. Returns `true` on change.
pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool {
@@ -550,6 +549,57 @@ mod tests {
);
}
#[test]
fn delegate_card_ignores_low_signal_scheduler_progress() {
let mut card = DelegateCard::new("agent_003", "general");
let msg = MailboxMessage::progress("agent_003", "step 1/100: requesting model response");
assert!(apply_to_delegate(&mut card, &msg));
assert_eq!(card.status, AgentLifecycle::Running);
assert_eq!(
card.action_count(),
0,
"scheduler progress should not become a stale transcript row"
);
let rendered = render_to_strings(&card.render_lines(80)).join("\n");
assert!(!rendered.contains("step 1/100"), "{rendered}");
assert!(
!rendered.contains("requesting model response"),
"{rendered}"
);
}
#[test]
fn delegate_tool_rows_omit_internal_step_numbers() {
let mut card = DelegateCard::new("agent_004", "general");
assert!(apply_to_delegate(
&mut card,
&MailboxMessage::ToolCallStarted {
agent_id: "agent_004".into(),
tool_name: "read_file".into(),
step: 7,
}
));
assert!(apply_to_delegate(
&mut card,
&MailboxMessage::ToolCallCompleted {
agent_id: "agent_004".into(),
tool_name: "read_file".into(),
step: 7,
ok: true,
}
));
let rendered = render_to_strings(&card.render_lines(80)).join("\n");
assert!(rendered.contains("read_file"), "{rendered}");
assert!(
!rendered.contains("[7]"),
"internal loop step numbers are not useful in the live card: {rendered}"
);
}
#[test]
fn delegate_card_ignores_envelopes_for_other_agents() {
let mut card = DelegateCard::new("agent_a", "general");
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "deepseek-tui",
"version": "0.8.15",
"deepseekBinaryVersion": "0.8.15",
"version": "0.8.16",
"deepseekBinaryVersion": "0.8.16",
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
"author": "Hmbown",
"license": "MIT",