Remove RLM/Duo modes and restore footer scroll

This commit is contained in:
Hunter Bown
2026-02-03 18:29:36 -06:00
parent 02ecc51e9c
commit e0bccecd5c
39 changed files with 126 additions and 5350 deletions
+17
View File
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.9] - 2026-02-04
### Removed
- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design
- Duo mode tools and prompts pending a citable research spec
### Fixed
- Footer context usage bar remains visible while status toasts are shown
### Changed
- Updated prompts and docs to reflect the simplified mode/tool surface
## [0.3.8] - 2026-02-03
### Fixed
- Resolve clippy warnings (CI `-D warnings`) in new tool implementations
## [0.3.7] - 2026-02-03
### Added
Generated
+1 -1
View File
@@ -674,7 +674,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.7"
version = "0.3.9"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deepseek-tui"
version = "0.3.7"
version = "0.3.9"
edition = "2024"
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
license = "MIT"
+2 -30
View File
@@ -11,7 +11,7 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.
## ✨ Features
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO)
- **Comprehensive tool access** File operations, shell execution, task management, and sub-agent systems
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
- **Shell execution**: Run commands with timeout support, background execution with task management
@@ -100,7 +100,7 @@ See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
## 🎮 Modes
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → Normal**.
| Mode | Description | Approval Behavior |
|------|-------------|-------------------|
@@ -108,9 +108,6 @@ In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO →
| **Plan** | Designfirst prompting; same approvals as Normal | Manual approval for writes & shell |
| **Agent** | Multistep tool use; asks before shell | Manual approval for shell, autoapprove file writes |
| **YOLO** | Enables shell + trust + autoapproves all tools (dangerous) | Autoapprove all tools |
| **RLM** | Externalized context + REPL helpers; autoapproves tools (best for large files) | Autoapprove tools |
| **Duo** | Playercoach autocoding with iterative validation (based on g3 paper) | Depends on phase |
Approval behavior is modedependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
## 🛠️ Tools
@@ -163,30 +160,6 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
- **Skills**: Reusable workflows stored as `SKILL.md` directories. The resolved skills dir prefers workspace-local `.agents/skills`, then `./skills`, then `~/.deepseek/skills`. Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
- **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`.
## 🧠 RLM (Reasoning & Largescale Memory)
RLM mode is designed for "too big for context" tasks: large files, wholedoc sweeps, and big pasted blocks.
- Autoswitch triggers: "largest file", explicit "RLM", large file requests, and large pastes.
- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly.
- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON).
- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`).
- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`.
`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of subqueries.
## 👥 Duo Mode
> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution.
Duo mode implements the playercoach autocoding paradigm for iterative development with builtin validation:
- **Player**: implements requirements (builder role)
- **Coach**: validates implementation against requirements (critic role)
- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status`
Workflow: `init → player → coach → advance → (repeat until approved)`
## 📚 Examples
### Interactive chat
@@ -278,7 +251,6 @@ Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
- `docs/CONFIGURATION.md` Complete configuration reference
- `docs/MCP.md` Model Context Protocol guide
- `docs/ARCHITECTURE.md` Project architecture
- `docs/RLM.md` RLM mode deepdive
- `docs/MODES.md` Mode comparison and usage
- `CONTRIBUTING.md` How to contribute to the project
-12
View File
@@ -54,8 +54,6 @@ subagents = true
web_search = true # enables web.run and web_search
apply_patch = true
mcp = true
rlm = true
duo = true
exec_policy = true
# ─────────────────────────────────────────────────────────────────────────────────
@@ -78,16 +76,6 @@ exponential_base = 2.0
# model = "deepseek-chat" # Model to use for summarization
# cache_summary = true # Cache the summary block
# ─────────────────────────────────────────────────────────────────────────────────
# RLM Sandbox Configuration (PLANNED - not yet implemented)
# ─────────────────────────────────────────────────────────────────────────────────
# [rlm]
# max_context_chars = 10000000 # Max characters for context (10MB)
# max_search_results = 100 # Max search results
# default_chunk_size = 2000 # Default chunk size
# default_overlap = 200 # Default chunk overlap
# session_dir = "~/.deepseek/rlm" # Directory for RLM sessions
# ─────────────────────────────────────────────────────────────────────────────────
# Profile Example (for multiple environments)
# ─────────────────────────────────────────────────────────────────────────────────
-1
View File
@@ -121,7 +121,6 @@ Responses API (with automatic fallback if needed).
- **`utils.rs`** - Common utilities
- **`logging.rs`** - Logging infrastructure
- **`compaction.rs`** - Context compaction for long conversations
- **`rlm.rs`** - Reflection/reasoning utilities
- **`pricing.rs`** - Cost estimation
- **`prompts.rs`** - System prompt templates
- **`project_doc.rs`** - Project documentation handling
+1 -3
View File
@@ -67,7 +67,7 @@ Common settings keys:
- `auto_compact` (on/off)
- `show_thinking` (on/off)
- `show_tool_details` (on/off)
- `default_mode` (normal, agent, plan, yolo, rlm, duo)
- `default_mode` (normal, agent, plan, yolo)
- `max_history` (number of input history entries)
- `default_model` (model name override)
@@ -113,8 +113,6 @@ subagents = true
web_search = true # enables web.run and web_search
apply_patch = true
mcp = true
rlm = true
duo = true
exec_policy = true
```
+3 -4
View File
@@ -2,18 +2,17 @@
DeepSeek CLI has two related concepts:
- **TUI mode**: what kind of interaction youre in (Normal/Plan/Agent/YOLO/RLM).
- **TUI mode**: what kind of interaction youre in (Normal/Plan/Agent/YOLO).
- **Approval mode**: how aggressively the UI asks before executing tools.
## TUI Modes
Press `Tab` to cycle: **Normal → Plan → Agent → YOLO → RLM → Normal**.
Press `Tab` to cycle: **Normal → Plan → Agent → YOLO → Normal**.
- **Normal**: chat-first. Approvals for file writes, shell, and paid tools.
- **Plan**: design-first prompting. Approvals match Normal.
- **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt).
- **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos.
- **RLM**: externalized context store + REPL helpers. Tools are auto-approved (best for large files and long-context work).
## Approval Mode
@@ -26,7 +25,7 @@ You can override approval behavior at runtime:
```
- `suggest` (default): uses the per-mode rules above.
- `auto`: auto-approves all tools (similar to YOLO/RLM approval behavior, but without forcing YOLO mode).
- `auto`: auto-approves all tools (similar to YOLO approval behavior, but without forcing YOLO mode).
- `never`: blocks any tool that isnt considered safe/read-only.
## Workspace Boundary and Trust Mode
-54
View File
@@ -1,54 +0,0 @@
# RLM Mode
RLM mode (“Recursive Language Model” mode) is DeepSeek CLIs long-context workflow: it stores large context externally (Aleph-style external memory) and provides REPL-like tools to explore and query it without stuffing everything into the models context window.
If youre curious about the research inspiration and implementation notes, see:
- `docs/rlm-paper.txt`
- `docs/rlm_gap_analysis.md`
## When To Use It
RLM mode is best for:
- “Analyze this large file / doc”
- “Summarize the whole repository”
- “Search for every occurrence of X and explain it”
- Big pasted blocks of text
The UI may auto-switch to RLM for large file requests, “largest file”, explicit “RLM” requests, and large pastes.
## How To Use It
### Switch modes
- Press `Tab` until you reach **RLM**
- Or type `/rlm` (or `/aleph`) to jump directly into RLM mode
### Load context
In RLM mode, `/load` loads external context (in other modes, `/load` loads a saved chat JSON):
```text
/load @path/to/file.rs
```
`@path` is workspace-relative.
### Inspect and query
- `/status` shows which contexts are loaded and basic usage totals.
- `/repl` toggles expression input mode.
Typical REPL helpers include:
- `lines(1, 80)` (show a slice of the context)
- `search("pattern")`
- `chunk(2000)` (create fixed-size chunks for later querying)
Under the hood, the model uses tools like `rlm_load`, `rlm_exec`, `rlm_status`, and `rlm_query`.
## Cost and Safety Notes
- `rlm_query` can be expensive because it triggers additional model calls. Prefer batching related questions.
- RLM mode auto-approves tools; keep `--workspace` scoped to the repo you want it to access.
-5
View File
@@ -125,9 +125,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
compaction.model = app.model.clone();
action = Some(AppAction::UpdateCompaction(compaction));
}
"auto_rlm" => {
app.auto_rlm = settings.auto_rlm;
}
"show_thinking" | "thinking" => {
app.show_thinking = settings.show_thinking;
app.mark_history_updated();
@@ -141,8 +138,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
"agent" | "normal" => AppMode::Agent,
"plan" => AppMode::Plan,
"yolo" => AppMode::Yolo,
"rlm" => AppMode::Rlm,
"duo" => AppMode::Duo,
_ => AppMode::Agent,
};
app.set_mode(mode);
-8
View File
@@ -160,14 +160,6 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
let _ = writeln!(stats, "Plan mode - Design before implementing");
let _ = writeln!(stats, " Use /plan to create structured checklists");
}
AppMode::Rlm => {
let _ = writeln!(stats, "RLM mode - Recursive language model sandbox");
let _ = writeln!(stats, " Use /repl to toggle REPL input");
}
AppMode::Duo => {
let _ = writeln!(stats, "Duo mode - Dialectical autocoding");
let _ = writeln!(stats, " Player-coach loop for complex tasks");
}
}
CommandResult::message(stats)
+4 -93
View File
@@ -10,11 +10,10 @@ mod init;
mod note;
mod queue;
mod review;
pub mod rlm;
mod session;
mod skills;
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::app::{App, AppAction};
/// Result of executing a command
#[derive(Debug, Clone)]
@@ -150,39 +149,9 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "load",
aliases: &[],
description: "Load session from file (or RLM context in RLM mode)",
description: "Load session from file",
usage: "/load [path]",
},
CommandInfo {
name: "rlm",
aliases: &[],
description: "Enter RLM (Aleph) mode and show quickstart",
usage: "/rlm",
},
CommandInfo {
name: "aleph",
aliases: &[],
description: "Alias for /rlm (external memory quickstart)",
usage: "/aleph",
},
CommandInfo {
name: "save-session",
aliases: &["save_session"],
description: "Save RLM session to file",
usage: "/save-session [path]",
},
CommandInfo {
name: "status",
aliases: &[],
description: "Show RLM context status",
usage: "/status",
},
CommandInfo {
name: "repl",
aliases: &[],
description: "Toggle RLM REPL mode",
usage: "/repl",
},
CommandInfo {
name: "compact",
aliases: &[],
@@ -320,18 +289,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
// Session commands
"save" => session::save(app, arg),
"sessions" | "resume" => session::sessions(app),
"load" => {
let force_rlm = arg.is_some_and(|raw| raw.trim_start().starts_with('@'));
if app.mode == AppMode::Rlm || force_rlm {
rlm::load(app, arg)
} else {
session::load(app, arg)
}
}
"rlm" | "aleph" => rlm::enter(app),
"save-session" | "save_session" => rlm::save_session(app, arg),
"status" => rlm::status(app),
"repl" => rlm::repl(app),
"load" => session::load(app, arg),
"compact" => session::compact(app),
"export" => session::export(app, arg),
@@ -387,52 +345,5 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
#[cfg(test)]
mod tests {
use super::execute;
use crate::config::Config;
use crate::tui::app::{App, AppMode, TuiOptions};
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
fn make_test_app(workspace: PathBuf) -> App {
let skills_dir = workspace.join("skills");
let _ = fs::create_dir_all(&skills_dir);
let options = TuiOptions {
model: "test-model".to_string(),
workspace,
allow_shell: false,
use_alt_screen: false,
max_subagents: 1,
skills_dir,
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn load_at_path_uses_rlm_outside_rlm_mode() {
let tmp = tempdir().expect("tempdir");
let file = tmp.path().join("example.txt");
fs::write(&file, "hello").expect("write");
let mut app = make_test_app(tmp.path().to_path_buf());
app.mode = AppMode::Normal;
let result = execute("/load @example.txt", &mut app);
let message = result.message.unwrap_or_default();
assert!(
message.starts_with("Loaded "),
"expected RLM load message, got: {message}"
);
let session = app.rlm_session.lock().expect("lock session");
assert!(!session.contexts.is_empty());
}
// No unit tests currently required for command routing.
}
-254
View File
@@ -1,254 +0,0 @@
//! RLM commands for the TUI (load/status/repl/save-session).
use std::fs;
use std::path::{Path, PathBuf};
use crate::rlm::{context_id_from_path, unique_context_id};
use crate::tui::app::{App, AppMode};
use super::CommandResult;
const DEFAULT_CHUNK_SIZE: usize = 2000;
const DEFAULT_CHUNK_OVERLAP: usize = 200;
pub fn welcome_message() -> String {
[
"DeepSeek RLM / Aleph Sandbox",
"Commands: /rlm, /aleph, /load <file>, /repl, /status, /save-session",
"Press Tab to exit RLM mode",
"Use /repl to toggle expression mode (chat is the default)",
"Tip: /load @path forces workspace-relative paths (e.g. @docs/rlm-paper.txt)",
"",
"Expressions:",
" len(ctx)",
" search(\"pattern\")",
" lines(1, 20)",
" chunk(2000, 200)",
" chunk_sections(20000)",
" chunk_auto(20000)",
" vars(), get(\"name\"), set(\"name\", \"value\")",
"",
"Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.",
"Tip: /save-session <path> persists the current RLM session.",
]
.join("\n")
}
pub fn overview_message() -> String {
[
"RLM / Aleph Quickstart",
"Use /rlm or /aleph to enter external-memory mode.",
"Use /load @path to load a file into the RLM context store.",
"Use /status to list contexts and usage totals.",
"Use /repl to toggle expression mode (chat is default).",
"Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.",
]
.join("\n")
}
pub fn enter(app: &mut App) -> CommandResult {
if app.mode != AppMode::Rlm {
app.set_mode(AppMode::Rlm);
}
app.rlm_repl_active = false;
CommandResult::message(overview_message())
}
pub fn repl(app: &mut App) -> CommandResult {
if app.mode != AppMode::Rlm {
app.set_mode(AppMode::Rlm);
}
if app.rlm_repl_active {
app.rlm_repl_active = false;
return CommandResult::message("Exited RLM REPL mode. Chat is active.");
}
app.rlm_repl_active = true;
CommandResult::message(welcome_message())
}
pub fn status(app: &mut App) -> CommandResult {
let session = match app.rlm_session.lock() {
Ok(session) => session,
Err(_) => return CommandResult::error("Failed to access RLM session"),
};
if session.contexts.is_empty() {
return CommandResult::message("No RLM contexts loaded. Use /load <path>.");
}
let mut lines = Vec::new();
lines.push("RLM Session".to_string());
lines.push(format!("Active context: {}", session.active_context));
lines.push(format!("Loaded contexts: {}", session.contexts.len()));
lines.push(format!(
"Queries: {} | Input tokens: {} | Output tokens: {}",
session.usage.queries, session.usage.input_tokens, session.usage.output_tokens
));
let mut ids: Vec<_> = session.contexts.keys().collect();
ids.sort();
for id in ids {
if let Some(ctx) = session.contexts.get(id) {
let source = ctx
.source_path
.as_ref()
.map(|s| format!(" (source: {s})"))
.unwrap_or_default();
let chunk_count = ctx.chunk(DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP).len();
let section_count = ctx.chunk_sections(20_000).len();
lines.push(format!(
"- {id}: {} lines, {} chars, {} chunks, {} sections{source}",
ctx.line_count, ctx.char_count, chunk_count, section_count
));
if !ctx.variables.is_empty() {
lines.push(format!(" variables: {}", ctx.variables.len()));
}
}
}
CommandResult::message(lines.join("\n"))
}
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
let Some(raw) = path else {
return CommandResult::error("Usage: /load <path>");
};
let resolved = match resolve_path(app, raw) {
Ok(path) => path,
Err(err) => return CommandResult::error(err),
};
let mut session = match app.rlm_session.lock() {
Ok(session) => session,
Err(_) => return CommandResult::error("Failed to access RLM session"),
};
let base_id = context_id_from_path(&resolved);
let id = unique_context_id(&session, &base_id);
let (line_count, char_count) = match session.load_file(&id, &resolved) {
Ok(stats) => stats,
Err(err) => {
return CommandResult::error(format!("Failed to load {}: {err}", resolved.display()));
}
};
CommandResult::message(format!(
"Loaded {} ({} lines, {} chars)",
resolved.display(),
line_count,
char_count
))
}
pub fn save_session(app: &mut App, path: Option<&str>) -> CommandResult {
let save_path = if let Some(p) = path {
PathBuf::from(p)
} else {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
PathBuf::from(format!("rlm_session_{timestamp}.json"))
};
let parent_dir = save_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(std::path::Path::to_path_buf);
if let Some(dir) = parent_dir
&& let Err(err) = fs::create_dir_all(&dir)
{
return CommandResult::error(format!(
"Failed to create directory {}: {err}",
dir.display()
));
}
let session = match app.rlm_session.lock() {
Ok(session) => session,
Err(_) => return CommandResult::error("Failed to access RLM session"),
};
let json = match serde_json::to_string_pretty(&*session) {
Ok(json) => json,
Err(err) => return CommandResult::error(format!("Failed to serialize session: {err}")),
};
match fs::write(&save_path, json) {
Ok(()) => CommandResult::message(format!("RLM session saved to {}", save_path.display())),
Err(err) => CommandResult::error(format!("Failed to save session: {err}")),
}
}
fn resolve_path(app: &App, raw: &str) -> Result<PathBuf, String> {
let raw = raw.trim();
let (raw, force_workspace) = if let Some(stripped) = raw.strip_prefix('@') {
(stripped.trim(), true)
} else {
(raw, false)
};
if raw.is_empty() {
return Err("Usage: /load <path> (use @ for workspace-relative paths)".to_string());
}
let candidate = if force_workspace {
app.workspace.join(raw.trim_start_matches(['/', '\\']))
} else if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
app.workspace.join(raw)
};
let canonical = candidate.canonicalize().map_err(|err| {
let mut message = format!("Failed to resolve path {}: {err}", candidate.display());
if !force_workspace {
message.push_str("\nTip: use /load @path to resolve relative to the workspace.");
}
message
})?;
let workspace_root = app
.workspace
.canonicalize()
.unwrap_or_else(|_| app.workspace.clone());
if !app.trust_mode && !canonical.starts_with(&workspace_root) {
return Err("Path is outside workspace. Use /trust to allow access.".to_string());
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use std::fs;
fn make_app(workspace: PathBuf) -> App {
let options = TuiOptions {
model: "test-model".to_string(),
workspace,
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: false,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn resolve_path_with_at_prefix_uses_workspace_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let docs_dir = tmp.path().join("docs");
fs::create_dir_all(&docs_dir).expect("create docs dir");
let file_path = docs_dir.join("rlm-paper.txt");
fs::write(&file_path, "hello").expect("write file");
let app = make_app(tmp.path().to_path_buf());
let resolved = resolve_path(&app, "@/docs/rlm-paper.txt").expect("resolve path with @");
assert_eq!(resolved, file_path.canonicalize().expect("canonicalize"));
}
}
-2
View File
@@ -157,8 +157,6 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
let (role, body) = match cell {
HistoryCell::User { content } => ("**You:**", content.clone()),
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
HistoryCell::Player { content, .. } => ("**Player:**", content.clone()),
HistoryCell::Coach { content, .. } => ("**Coach:**", content.clone()),
HistoryCell::System { content } => ("*System:*", content.clone()),
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
+4 -167
View File
@@ -7,10 +7,9 @@
//! - Proper cancellation support
//! - Tool execution orchestration
use std::fmt::Write;
use std::path::PathBuf;
use std::pin::pin;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
@@ -26,7 +25,6 @@ use crate::compaction::{
};
use crate::config::Config;
use crate::config::DEFAULT_MAX_SUBAGENTS;
use crate::duo::{DuoSession, SharedDuoSession, session_summary as duo_session_summary};
use crate::features::{Feature, Features};
use crate::llm_client::LlmClient;
use crate::mcp::McpPool;
@@ -34,7 +32,6 @@ use crate::models::{
ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, StreamEvent, Tool, Usage,
};
use crate::prompts;
use crate::rlm::{RlmSession, SharedRlmSession, session_summary as rlm_session_summary};
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult};
@@ -75,10 +72,6 @@ pub struct EngineConfig {
pub max_subagents: usize,
/// Feature flags controlling tool availability.
pub features: Features,
/// Shared RLM session state.
pub rlm_session: SharedRlmSession,
/// Shared Duo session state.
pub duo_session: SharedDuoSession,
/// Auto-compaction settings for long conversations.
pub compaction: CompactionConfig,
/// Shared Todo list state.
@@ -99,8 +92,6 @@ impl Default for EngineConfig {
max_steps: 100,
max_subagents: DEFAULT_MAX_SUBAGENTS,
features: Features::with_defaults(),
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
duo_session: Arc::new(Mutex::new(DuoSession::new())),
compaction: CompactionConfig::default(),
todos: new_shared_todo_list(),
plan_state: new_shared_plan_state(),
@@ -538,8 +529,6 @@ impl Engine {
AppMode::Agent,
&config.workspace,
working_set_summary.as_deref(),
None,
None,
);
session.system_prompt = Some(system_prompt);
@@ -778,24 +767,6 @@ impl Engine {
self.config.trust_mode = trust_mode;
// Update system prompt to match the current mode
let rlm_summary = if mode == AppMode::Rlm {
self.config
.rlm_session
.lock()
.ok()
.map(|session| rlm_session_summary(&session))
} else {
None
};
let duo_summary = if mode == AppMode::Duo {
self.config
.duo_session
.lock()
.ok()
.map(|s| duo_session_summary(&s))
} else {
None
};
let working_set_summary = self
.session
.working_set
@@ -804,8 +775,6 @@ impl Engine {
mode,
&self.config.workspace,
working_set_summary.as_deref(),
rlm_summary.as_deref(),
duo_summary.as_deref(),
));
// Build tool registry and tool list for the current mode
@@ -822,19 +791,8 @@ impl Engine {
.with_todo_tool(todo_list.clone())
.with_plan_tool(plan_state.clone())
} else {
let rlm_opt = if self.config.features.enabled(Feature::Rlm) {
Some(self.config.rlm_session.clone())
} else {
None
};
ToolRegistryBuilder::new()
.with_agent_tools(
self.session.allow_shell,
rlm_opt,
self.deepseek_client.clone(),
self.session.model.clone(),
)
.with_agent_tools(self.session.allow_shell)
.with_todo_tool(todo_list.clone())
.with_plan_tool(plan_state.clone())
};
@@ -857,33 +815,9 @@ impl Engine {
{
builder = builder.with_shell_tools();
}
if mode == AppMode::Rlm {
if self.config.features.enabled(Feature::Rlm) {
builder = builder.with_rlm_tools(
self.config.rlm_session.clone(),
self.deepseek_client.clone(),
self.session.model.clone(),
);
} else {
let _ = self
.tx_event
.send(Event::status("RLM tools are disabled by feature flags"))
.await;
}
}
if mode == AppMode::Duo {
if self.config.features.enabled(Feature::Duo) {
builder = builder.with_duo_tools(self.config.duo_session.clone());
} else {
let _ = self
.tx_event
.send(Event::status("Duo tools are disabled by feature flags"))
.await;
}
}
let tool_registry = match mode {
AppMode::Agent | AppMode::Yolo | AppMode::Rlm | AppMode::Duo => {
AppMode::Agent | AppMode::Yolo => {
if self.config.features.enabled(Feature::Subagents) {
let runtime = if let Some(client) = self.deepseek_client.clone() {
Some(SubAgentRuntime::new(
@@ -947,42 +881,6 @@ impl Engine {
.with_shell_manager(self.shell_manager.clone())
}
/// Automatically offload large tool results to RLM memory if enabled.
/// Returns either the original content or a pointer to the RLM context.
fn offload_to_rlm_if_needed(&self, tool_name: &str, content: String) -> String {
const OFFLOAD_THRESHOLD: usize = 15_000;
if !self.config.features.enabled(Feature::Rlm) || content.len() < OFFLOAD_THRESHOLD {
return content;
}
let mut session = match self.config.rlm_session.lock() {
Ok(s) => s,
Err(_) => return content,
};
let context_id = format!(
"auto_{}_{}",
tool_name,
&uuid::Uuid::new_v4().to_string()[..8]
);
let char_count = content.len();
let line_count = content.lines().count();
session.load_context(&context_id, content, None);
format!(
"[AUTOMATIC RLM OFFLOAD]\n\
The output of '{tool_name}' was too large ({char_count} chars, {line_count} lines) \
and has been moved to RLM memory to preserve your context window.\n\n\
Context ID: {context_id}\n\n\
You can explore this data using RLM tools:\n\
- `rlm_exec(code=\"lines(1, 100)\", context_id=\"{context_id}\")` to see the start\n\
- `rlm_exec(code=\"search(\\\"pattern\\\")\", context_id=\"{context_id}\")` to search\n\
- `rlm_query(query=\"...\", context_id=\"{context_id}\")` for deep analysis"
)
}
async fn ensure_mcp_pool(&mut self) -> Result<Arc<AsyncMutex<McpPool>>, ToolError> {
if let Some(pool) = self.mcp_pool.as_ref() {
return Ok(Arc::clone(pool));
@@ -1322,49 +1220,6 @@ impl Engine {
Ok(result) => {
// Only update if we got valid messages (never corrupt state)
if !result.messages.is_empty() || self.session.messages.is_empty() {
// Offload removed messages to RLM history if enabled
if self.config.features.enabled(Feature::Rlm)
&& !result.removed_messages.is_empty()
{
if let Ok(mut rlm) = self.config.rlm_session.lock() {
let mut history_text = String::new();
for msg in &result.removed_messages {
let role = if msg.role == "user" {
"User"
} else {
"Assistant"
};
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
let _ =
writeln!(history_text, "{role}: {text}\n");
}
ContentBlock::ToolUse { name, input, .. } => {
let _ = writeln!(
history_text,
"{role}: [Used tool: {name}] Input: {input}\n"
);
}
ContentBlock::ToolResult { content, .. } => {
let _ = writeln!(
history_text,
"Tool result: {content}\n"
);
}
ContentBlock::Thinking { thinking } => {
let _ = writeln!(
history_text,
"{role} (thinking): {thinking}\n"
);
}
}
}
}
rlm.append_var("history", history_text);
}
}
self.session.messages = result.messages;
self.session.system_prompt = merge_system_prompts(
self.session.system_prompt.as_ref(),
@@ -1970,9 +1825,7 @@ impl Engine {
match outcome.result {
Ok(output) => {
let original_content = output.content;
let output_content =
self.offload_to_rlm_if_needed(&outcome.name, original_content);
let output_content = output.content;
tool_call.set_result(output_content.clone(), duration);
self.session.working_set.observe_tool_call(
@@ -2044,20 +1897,6 @@ impl Engine {
/// Refresh the system prompt based on current mode and context.
fn refresh_system_prompt(&mut self, mode: AppMode) {
let rlm_summary = self
.config
.rlm_session
.lock()
.ok()
.map(|session| rlm_session_summary(&session));
let duo_summary = self
.config
.duo_session
.lock()
.ok()
.map(|s| duo_session_summary(&s));
let working_set_summary = self
.session
.working_set
@@ -2067,8 +1906,6 @@ impl Engine {
mode,
&self.config.workspace,
working_set_summary.as_deref(),
rlm_summary.as_deref(),
duo_summary.as_deref(),
));
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ pub enum Event {
/// The turn is complete (no more tool calls)
TurnComplete { usage: Usage },
// === Sub-Agent Events (for RLM mode) ===
// === Sub-Agent Events ===
/// A sub-agent has been spawned
AgentSpawned { id: String, prompt: String },
+1 -1
View File
@@ -29,7 +29,7 @@ pub enum Op {
/// Deny a tool call that requires permission
DenyToolCall { id: String },
/// Spawn a sub-agent (for RLM mode)
/// Spawn a sub-agent
SpawnSubAgent { prompt: String },
/// List current sub-agents and their status
-802
View File
@@ -1,802 +0,0 @@
//! Duo mode state machine for hegelion's autocoding (player-coach adversarial cooperation).
//!
//! Implements the g3 paper's coach-player paradigm where:
//! - Player: implements requirements (builder role)
//! - Coach: validates implementation against requirements (critic role)
//!
//! The loop continues until the coach approves or max turns are reached.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// === Phase & Status Enums ===
/// The current phase in the autocoding loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DuoPhase {
/// Session initialized, ready to start player phase
Init,
/// Player is implementing requirements
Player,
/// Coach is validating the implementation
Coach,
/// Coach approved the implementation
Approved,
/// Maximum turns reached without approval
Timeout,
}
impl std::fmt::Display for DuoPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DuoPhase::Init => write!(f, "init"),
DuoPhase::Player => write!(f, "player"),
DuoPhase::Coach => write!(f, "coach"),
DuoPhase::Approved => write!(f, "approved"),
DuoPhase::Timeout => write!(f, "timeout"),
}
}
}
/// The overall status of the autocoding session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DuoStatus {
/// Session is actively running
Active,
/// Coach has approved the implementation
Approved,
/// Coach has rejected (used for explicit rejection, not just iteration)
Rejected,
/// Maximum turns exhausted without approval
Timeout,
}
impl std::fmt::Display for DuoStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DuoStatus::Active => write!(f, "active"),
DuoStatus::Approved => write!(f, "approved"),
DuoStatus::Rejected => write!(f, "rejected"),
DuoStatus::Timeout => write!(f, "timeout"),
}
}
}
// === Turn History ===
/// Record of a single turn in the autocoding loop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnRecord {
/// Turn number (1-indexed)
pub turn: u32,
/// The phase this record is for
pub phase: DuoPhase,
/// Summary of what happened (player implementation or coach feedback)
pub summary: String,
/// Quality score from coach (0.0 to 1.0), if applicable
pub quality_score: Option<f64>,
/// Timestamp when this turn was recorded
#[serde(default = "chrono::Utc::now")]
pub timestamp: chrono::DateTime<chrono::Utc>,
}
impl TurnRecord {
/// Create a new turn record.
#[must_use]
pub fn new(turn: u32, phase: DuoPhase, summary: String, quality_score: Option<f64>) -> Self {
Self {
turn,
phase,
summary,
quality_score,
timestamp: chrono::Utc::now(),
}
}
}
// === Main State ===
/// The complete state of a Duo autocoding session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DuoState {
/// Unique session identifier
pub session_id: String,
/// Optional human-readable session name
pub session_name: Option<String>,
/// The requirements document (source of truth for validation)
pub requirements: String,
/// Current turn number (1-indexed)
pub current_turn: u32,
/// Maximum allowed turns before timeout
pub max_turns: u32,
/// Current phase in the autocoding loop
pub phase: DuoPhase,
/// Overall session status
pub status: DuoStatus,
/// History of all turns
pub turn_history: Vec<TurnRecord>,
/// Last feedback from the coach (used in next player prompt)
pub last_coach_feedback: Option<String>,
/// Quality scores from each coach review
pub quality_scores: Vec<f64>,
/// Threshold score needed for approval (0.0 to 1.0)
pub approval_threshold: f64,
/// Timestamp when session was created
#[serde(default = "chrono::Utc::now")]
pub created_at: chrono::DateTime<chrono::Utc>,
/// Timestamp of last update
#[serde(default = "chrono::Utc::now")]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl DuoState {
/// Create a new Duo session with the given requirements.
///
/// # Arguments
/// * `requirements` - The requirements document (source of truth)
/// * `session_name` - Optional human-readable name
/// * `max_turns` - Maximum turns before timeout (default: 10)
/// * `approval_threshold` - Score needed for approval (default: 0.9)
#[must_use]
pub fn create(
requirements: String,
session_name: Option<String>,
max_turns: Option<u32>,
approval_threshold: Option<f64>,
) -> Self {
let now = chrono::Utc::now();
Self {
session_id: Uuid::new_v4().to_string(),
session_name,
requirements,
current_turn: 1,
max_turns: max_turns.unwrap_or(10),
phase: DuoPhase::Init,
status: DuoStatus::Active,
turn_history: Vec::new(),
last_coach_feedback: None,
quality_scores: Vec::new(),
approval_threshold: approval_threshold.unwrap_or(0.9),
created_at: now,
updated_at: now,
}
}
/// Transition from Init or Player phase to Coach phase.
///
/// Records the player's implementation summary in turn history.
///
/// # Arguments
/// * `player_summary` - Summary of what the player implemented
///
/// # Returns
/// `Ok(())` on success, `Err` if not in a valid phase for this transition
pub fn advance_to_coach(&mut self, player_summary: String) -> Result<(), DuoError> {
match self.phase {
DuoPhase::Init | DuoPhase::Player => {
// Record player turn
let record =
TurnRecord::new(self.current_turn, DuoPhase::Player, player_summary, None);
self.turn_history.push(record);
self.phase = DuoPhase::Coach;
self.updated_at = chrono::Utc::now();
Ok(())
}
_ => Err(DuoError::InvalidPhaseTransition {
from: self.phase,
to: DuoPhase::Coach,
}),
}
}
/// Process coach feedback and determine the next phase.
///
/// # Arguments
/// * `coach_feedback` - The coach's feedback text
/// * `approved` - Whether the coach approved the implementation
/// * `compliance_score` - Optional compliance score (0.0 to 1.0)
///
/// # Returns
/// `Ok(())` on success, `Err` if not in coach phase
pub fn advance_turn(
&mut self,
coach_feedback: String,
approved: bool,
compliance_score: Option<f64>,
) -> Result<(), DuoError> {
if self.phase != DuoPhase::Coach {
return Err(DuoError::InvalidPhaseTransition {
from: self.phase,
to: DuoPhase::Player,
});
}
// Record coach turn
let record = TurnRecord::new(
self.current_turn,
DuoPhase::Coach,
coach_feedback.clone(),
compliance_score,
);
self.turn_history.push(record);
// Track quality score if provided
if let Some(score) = compliance_score {
self.quality_scores.push(score);
}
self.last_coach_feedback = Some(coach_feedback);
self.updated_at = chrono::Utc::now();
if approved {
// Coach approved - session complete
self.phase = DuoPhase::Approved;
self.status = DuoStatus::Approved;
} else if self.current_turn >= self.max_turns {
// Max turns reached - timeout
self.phase = DuoPhase::Timeout;
self.status = DuoStatus::Timeout;
} else {
// Continue to next turn
self.current_turn += 1;
self.phase = DuoPhase::Player;
}
Ok(())
}
/// Check if the session is complete (approved or timed out).
#[must_use]
pub fn is_complete(&self) -> bool {
matches!(
self.status,
DuoStatus::Approved | DuoStatus::Rejected | DuoStatus::Timeout
)
}
/// Get the number of turns remaining before timeout.
#[must_use]
pub fn turns_remaining(&self) -> u32 {
self.max_turns.saturating_sub(self.current_turn)
}
/// Get the average quality score across all coach reviews.
#[must_use]
pub fn average_quality_score(&self) -> Option<f64> {
if self.quality_scores.is_empty() {
None
} else {
let sum: f64 = self.quality_scores.iter().sum();
Some(sum / self.quality_scores.len() as f64)
}
}
/// Generate a human-readable summary of the session state.
#[must_use]
pub fn summary(&self) -> String {
let name = self
.session_name
.as_deref()
.unwrap_or(&self.session_id[..8]);
let avg_score = self
.average_quality_score()
.map(|s| format!("{:.1}%", s * 100.0))
.unwrap_or_else(|| "N/A".to_string());
let status_icon = match self.status {
DuoStatus::Active => "🔄",
DuoStatus::Approved => "",
DuoStatus::Rejected => "",
DuoStatus::Timeout => "",
};
format!(
"{status_icon} Duo Session: {name}\n\
Phase: {} | Turn: {}/{} | Status: {}\n\
Avg Quality: {} | Threshold: {:.0}%\n\
History: {} records",
self.phase,
self.current_turn,
self.max_turns,
self.status,
avg_score,
self.approval_threshold * 100.0,
self.turn_history.len()
)
}
}
// === Error Types ===
/// Errors that can occur during Duo session operations.
#[derive(Debug, Clone)]
pub enum DuoError {
/// Invalid phase transition attempted
InvalidPhaseTransition { from: DuoPhase, to: DuoPhase },
/// Session not found (reserved for future multi-session management)
#[allow(dead_code)]
SessionNotFound { session_id: String },
/// Session already complete (reserved for future session validation)
#[allow(dead_code)]
SessionAlreadyComplete { session_id: String },
}
impl std::fmt::Display for DuoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DuoError::InvalidPhaseTransition { from, to } => {
write!(f, "Invalid phase transition from {} to {}", from, to)
}
DuoError::SessionNotFound { session_id } => {
write!(f, "Session not found: {}", session_id)
}
DuoError::SessionAlreadyComplete { session_id } => {
write!(f, "Session already complete: {}", session_id)
}
}
}
}
impl std::error::Error for DuoError {}
// === Session Container ===
/// Container for managing multiple Duo sessions.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DuoSession {
/// The currently active session state
pub active_state: Option<DuoState>,
/// Saved/completed session states indexed by session_id
pub saved_states: HashMap<String, DuoState>,
}
impl DuoSession {
/// Create a new empty session container.
#[must_use]
pub fn new() -> Self {
Self {
active_state: None,
saved_states: HashMap::new(),
}
}
/// Start a new Duo session.
pub fn start_session(
&mut self,
requirements: String,
session_name: Option<String>,
max_turns: Option<u32>,
approval_threshold: Option<f64>,
) -> &DuoState {
// Save any existing active session
if let Some(state) = self.active_state.take() {
self.saved_states.insert(state.session_id.clone(), state);
}
// Create new session
let state = DuoState::create(requirements, session_name, max_turns, approval_threshold);
self.active_state = Some(state);
self.active_state.as_ref().expect("just set active_state")
}
/// Get the active session state.
#[must_use]
pub fn get_active(&self) -> Option<&DuoState> {
self.active_state.as_ref()
}
/// Get a mutable reference to the active session state.
pub fn get_active_mut(&mut self) -> Option<&mut DuoState> {
self.active_state.as_mut()
}
/// Get a saved session by ID (reserved for future multi-session management).
#[must_use]
#[allow(dead_code)]
pub fn get_saved(&self, session_id: &str) -> Option<&DuoState> {
self.saved_states.get(session_id)
}
/// Save the current active session and clear it (reserved for future session management).
#[allow(dead_code)]
pub fn save_active(&mut self) -> Option<String> {
if let Some(state) = self.active_state.take() {
let id = state.session_id.clone();
self.saved_states.insert(id.clone(), state);
Some(id)
} else {
None
}
}
/// Restore a saved session as the active session (reserved for future session management).
#[allow(dead_code)]
pub fn restore_session(&mut self, session_id: &str) -> Result<(), DuoError> {
let state =
self.saved_states
.remove(session_id)
.ok_or_else(|| DuoError::SessionNotFound {
session_id: session_id.to_string(),
})?;
// Save current active if any
if let Some(current) = self.active_state.take() {
self.saved_states
.insert(current.session_id.clone(), current);
}
self.active_state = Some(state);
Ok(())
}
/// List all session IDs (active and saved, reserved for future session management).
#[must_use]
#[allow(dead_code)]
pub fn list_sessions(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self.saved_states.keys().map(String::as_str).collect();
if let Some(ref active) = self.active_state {
ids.push(&active.session_id);
}
ids.sort();
ids
}
}
/// Thread-safe shared Duo session.
pub type SharedDuoSession = Arc<Mutex<DuoSession>>;
/// Create a new shared Duo session.
#[must_use]
pub fn new_shared_duo_session() -> SharedDuoSession {
Arc::new(Mutex::new(DuoSession::new()))
}
// === Prompt Generation ===
/// Generate the player (implementation) prompt for the current state.
///
/// The player focuses on implementing requirements and should NOT declare success.
#[must_use]
pub fn generate_player_prompt(state: &DuoState) -> String {
let mut prompt = String::new();
prompt.push_str("# Player Phase - Implementation\n\n");
prompt.push_str("You are the PLAYER in an autocoding session. Your role is to IMPLEMENT the requirements.\n\n");
prompt.push_str("## Requirements (Source of Truth)\n\n");
prompt.push_str(&state.requirements);
prompt.push_str("\n\n");
prompt.push_str(&format!(
"## Session Info\n\n\
- Turn: {}/{}\n\
- Approval Threshold: {:.0}%\n",
state.current_turn,
state.max_turns,
state.approval_threshold * 100.0
));
if let Some(ref feedback) = state.last_coach_feedback {
prompt.push_str("\n## Previous Coach Feedback\n\n");
prompt.push_str("Address these issues from the last review:\n\n");
prompt.push_str(feedback);
prompt.push('\n');
}
prompt.push_str("\n## Instructions\n\n");
prompt.push_str(
"1. Implement the requirements above using available tools\n\
2. Focus on making incremental progress\n\
3. DO NOT declare success or claim completion\n\
4. DO NOT evaluate your own work\n\
5. The Coach will verify your implementation\n\n\
Begin implementation now.\n",
);
prompt
}
/// Generate the coach (validation) prompt for the current state.
///
/// The coach verifies the implementation against requirements and ignores player self-assessment.
#[must_use]
pub fn generate_coach_prompt(state: &DuoState) -> String {
let mut prompt = String::new();
prompt.push_str("# Coach Phase - Validation\n\n");
prompt.push_str("You are the COACH in an autocoding session. Your role is to VERIFY the implementation.\n\n");
prompt.push_str("## Requirements (Source of Truth)\n\n");
prompt.push_str(&state.requirements);
prompt.push_str("\n\n");
prompt.push_str(&format!(
"## Session Info\n\n\
- Turn: {}/{}\n\
- Approval Threshold: {:.0}%\n\
- Turns Remaining: {}\n",
state.current_turn,
state.max_turns,
state.approval_threshold * 100.0,
state.turns_remaining()
));
if !state.quality_scores.is_empty() {
let avg = state.average_quality_score().unwrap_or(0.0);
prompt.push_str(&format!("- Average Quality: {:.1}%\n", avg * 100.0));
}
prompt.push_str("\n## Instructions\n\n");
prompt.push_str(
"1. Review the current implementation against the requirements\n\
2. Create a COMPLIANCE CHECKLIST:\n\
- [ ] or [x] for each requirement item\n\
- Note any missing or incorrect implementations\n\
3. Calculate a COMPLIANCE SCORE (0.0 to 1.0)\n\
4. IGNORE any player self-assessment or claims of completion\n\
5. If score >= threshold AND all critical items pass:\n\
- Output: COACH APPROVED\n\
6. Otherwise, provide specific feedback:\n\
- What is missing\n\
- What needs to be fixed\n\
- Actionable next steps\n\n\
Begin validation now.\n",
);
prompt
}
/// Generate a summary of the session for system prompt injection.
#[must_use]
pub fn session_summary(session: &DuoSession) -> String {
let mut lines = Vec::new();
if let Some(ref state) = session.active_state {
lines.push(format!("Active Duo Session: {}", state.summary()));
} else {
lines.push("No active Duo session.".to_string());
}
if !session.saved_states.is_empty() {
lines.push(format!("Saved sessions: {}", session.saved_states.len()));
for (id, state) in &session.saved_states {
let name = state
.session_name
.as_deref()
.unwrap_or(&id[..8.min(id.len())]);
lines.push(format!(" - {}: {} ({})", name, state.status, state.phase));
}
}
lines.join("\n")
}
// === Tests ===
#[cfg(test)]
mod tests {
use super::*;
fn sample_requirements() -> String {
"## Requirements\n\
- [ ] Create a function `add(a, b)` that returns the sum\n\
- [ ] Add unit tests for the function\n\
- [ ] Document the function with comments"
.to_string()
}
#[test]
fn test_create_session() {
let state = DuoState::create(
sample_requirements(),
Some("test-session".to_string()),
None,
None,
);
assert_eq!(state.session_name, Some("test-session".to_string()));
assert_eq!(state.current_turn, 1);
assert_eq!(state.max_turns, 10);
assert_eq!(state.phase, DuoPhase::Init);
assert_eq!(state.status, DuoStatus::Active);
assert!(state.turn_history.is_empty());
assert!(state.last_coach_feedback.is_none());
assert_eq!(state.approval_threshold, 0.9);
}
#[test]
fn test_advance_to_coach() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
assert!(
state
.advance_to_coach("Implemented add function".to_string())
.is_ok()
);
assert_eq!(state.phase, DuoPhase::Coach);
assert_eq!(state.turn_history.len(), 1);
assert_eq!(state.turn_history[0].phase, DuoPhase::Player);
}
#[test]
fn test_advance_turn_approved() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
state
.advance_to_coach("Implemented everything".to_string())
.unwrap();
assert!(
state
.advance_turn(
"COACH APPROVED - All requirements met".to_string(),
true,
Some(0.95)
)
.is_ok()
);
assert_eq!(state.phase, DuoPhase::Approved);
assert_eq!(state.status, DuoStatus::Approved);
assert!(state.is_complete());
assert_eq!(state.quality_scores, vec![0.95]);
}
#[test]
fn test_advance_turn_continue() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
state
.advance_to_coach("Partial implementation".to_string())
.unwrap();
assert!(
state
.advance_turn("Missing tests".to_string(), false, Some(0.5))
.is_ok()
);
assert_eq!(state.phase, DuoPhase::Player);
assert_eq!(state.status, DuoStatus::Active);
assert_eq!(state.current_turn, 2);
assert!(!state.is_complete());
assert_eq!(state.last_coach_feedback, Some("Missing tests".to_string()));
}
#[test]
fn test_timeout() {
let mut state = DuoState::create(sample_requirements(), None, Some(2), None);
// Turn 1
state.advance_to_coach("Attempt 1".to_string()).unwrap();
state
.advance_turn("Not good enough".to_string(), false, Some(0.3))
.unwrap();
// Turn 2 (max)
state.advance_to_coach("Attempt 2".to_string()).unwrap();
state
.advance_turn("Still not good enough".to_string(), false, Some(0.4))
.unwrap();
assert_eq!(state.phase, DuoPhase::Timeout);
assert_eq!(state.status, DuoStatus::Timeout);
assert!(state.is_complete());
}
#[test]
fn test_invalid_phase_transition() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
state.phase = DuoPhase::Approved;
let result = state.advance_to_coach("Should fail".to_string());
assert!(result.is_err());
}
#[test]
fn test_turns_remaining() {
let state = DuoState::create(sample_requirements(), None, Some(10), None);
assert_eq!(state.turns_remaining(), 9);
}
#[test]
fn test_average_quality_score() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
assert!(state.average_quality_score().is_none());
state.quality_scores = vec![0.5, 0.7, 0.9];
let avg = state.average_quality_score().unwrap();
assert!((avg - 0.7).abs() < 0.001);
}
#[test]
fn test_session_container() {
let mut session = DuoSession::new();
// Start first session
session.start_session(
sample_requirements(),
Some("session-1".to_string()),
None,
None,
);
assert!(session.get_active().is_some());
// Start second session (first gets saved)
session.start_session(
"Other requirements".to_string(),
Some("session-2".to_string()),
None,
None,
);
assert_eq!(session.saved_states.len(), 1);
// Get active
let active = session.get_active().unwrap();
assert_eq!(active.session_name, Some("session-2".to_string()));
}
#[test]
fn test_generate_player_prompt() {
let state = DuoState::create(sample_requirements(), None, None, None);
let prompt = generate_player_prompt(&state);
assert!(prompt.contains("Player Phase"));
assert!(prompt.contains("Requirements (Source of Truth)"));
assert!(prompt.contains("Turn: 1/10"));
assert!(prompt.contains("DO NOT declare success"));
}
#[test]
fn test_generate_coach_prompt() {
let state = DuoState::create(sample_requirements(), None, None, None);
let prompt = generate_coach_prompt(&state);
assert!(prompt.contains("Coach Phase"));
assert!(prompt.contains("COMPLIANCE CHECKLIST"));
assert!(prompt.contains("COACH APPROVED"));
assert!(prompt.contains("IGNORE any player self-assessment"));
}
#[test]
fn test_shared_session() {
let shared = new_shared_duo_session();
{
let mut session = shared.lock().unwrap();
session.start_session(sample_requirements(), None, None, None);
}
{
let session = shared.lock().unwrap();
assert!(session.get_active().is_some());
}
}
#[test]
fn test_summary() {
let state = DuoState::create(sample_requirements(), Some("test".to_string()), None, None);
let summary = state.summary();
assert!(summary.contains("Duo Session: test"));
assert!(summary.contains("Phase: init"));
assert!(summary.contains("Turn: 1/10"));
}
#[test]
fn test_session_summary() {
let mut session = DuoSession::new();
session.start_session(
sample_requirements(),
Some("active-session".to_string()),
None,
None,
);
let summary = session_summary(&session);
assert!(summary.contains("Active Duo Session"));
assert!(summary.contains("active-session"));
}
}
-16
View File
@@ -28,10 +28,6 @@ pub enum Feature {
ApplyPatch,
/// Enable MCP tools.
Mcp,
/// Enable RLM tools.
Rlm,
/// Enable Duo tools.
Duo,
/// Enable execpolicy integration/tooling.
ExecPolicy,
}
@@ -171,18 +167,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::Rlm,
key: "rlm",
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::Duo,
key: "duo",
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
+1 -1
View File
@@ -77,7 +77,7 @@ pub enum HookCondition {
},
/// Only run in specific modes
Mode {
/// Mode: "plan", "agent", "yolo", "rlm", "duo"
/// Mode: "plan", "agent", "yolo"
mode: String,
},
/// Only run when exit code matches (for `ToolCallAfter`)
-8
View File
@@ -32,7 +32,6 @@ mod commands;
mod compaction;
mod config;
mod core;
mod duo;
mod eval;
mod execpolicy;
mod features;
@@ -48,7 +47,6 @@ mod project_context;
mod project_doc;
mod prompts;
mod responses_api_proxy;
mod rlm;
mod sandbox;
mod session_manager;
mod settings;
@@ -1823,14 +1821,10 @@ async fn run_exec_agent(
auto_approve: bool,
trust_mode: bool,
) -> Result<()> {
use std::sync::{Arc, Mutex};
use crate::compaction::CompactionConfig;
use crate::core::engine::{EngineConfig, spawn_engine};
use crate::core::events::Event;
use crate::core::ops::Op;
use crate::duo::DuoSession;
use crate::rlm::RlmSession;
use crate::tools::plan::new_shared_plan_state;
use crate::tools::todo::new_shared_todo_list;
use crate::tui::app::AppMode;
@@ -1845,8 +1839,6 @@ async fn run_exec_agent(
max_steps: 100,
max_subagents,
features: config.features(),
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
duo_session: Arc::new(Mutex::new(DuoSession::new())),
compaction: CompactionConfig::default(),
todos: new_shared_todo_list(),
plan_state: new_shared_plan_state(),
-2
View File
@@ -56,8 +56,6 @@ pub const MODE_NORMAL: Color = Color::Gray;
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
pub const MODE_RLM: Color = Color::Rgb(180, 100, 255); // Purple (was INK!)
pub const MODE_DUO: Color = Color::Rgb(100, 220, 180); // Teal
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
-18
View File
@@ -13,8 +13,6 @@ 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 PLAN_PROMPT: &str = include_str!("prompts/plan.txt");
pub const RLM_PROMPT: &str = include_str!("prompts/rlm.txt");
pub const DUO_PROMPT: &str = include_str!("prompts/duo.txt");
/// Get the system prompt for a specific mode
pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
@@ -22,8 +20,6 @@ pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
AppMode::Plan => PLAN_PROMPT,
AppMode::Rlm => RLM_PROMPT,
AppMode::Duo => DUO_PROMPT,
};
SystemPrompt::Text(text.trim().to_string())
}
@@ -33,15 +29,11 @@ pub fn system_prompt_for_mode_with_context(
mode: AppMode,
workspace: &Path,
working_set_summary: Option<&str>,
rlm_summary: Option<&str>,
duo_summary: Option<&str>,
) -> SystemPrompt {
let base_prompt = match mode {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
AppMode::Plan => PLAN_PROMPT,
AppMode::Rlm => RLM_PROMPT,
AppMode::Duo => DUO_PROMPT,
};
// Load project context from workspace
@@ -68,16 +60,6 @@ pub fn system_prompt_for_mode_with_context(
full_prompt = format!("{full_prompt}\n\n{summary}");
}
if mode == AppMode::Rlm {
let summary = rlm_summary.unwrap_or("No RLM contexts loaded.");
full_prompt = format!("{full_prompt}\n\nRLM Context Summary:\n{summary}");
}
if mode == AppMode::Duo {
let summary = duo_summary.unwrap_or("No Duo contexts loaded.");
full_prompt = format!("{full_prompt}\n\nDuo Context Summary:\n{summary}");
}
SystemPrompt::Text(full_prompt)
}
+3 -5
View File
@@ -40,8 +40,6 @@ Approval etiquette:
Tone: competent, warm, and concise. Use light humor sparingly when it fits; a rare example is "You're absolutely right! ... maybe."
Context Management & RLM:
- You have a finite context window. Large tool results may be automatically moved to RLM memory.
- If you see "[AUTOMATIC RLM OFFLOAD]", the content was stored in an RLM context ID. Use `rlm_exec` or `rlm_query` to access it.
- Proactively use `rlm_load` for files larger than 100KB to keep your main window lean.
- If the conversation is long, earlier messages may be summarized. You can search the full history using `rlm_exec(code="get(\"history\")")` if available.
Context Management:
- You have a finite context window. Keep responses concise and prefer targeted file reads or searches.
- Long conversations may be compacted into summaries; ask for clarification if critical details are missing.
-18
View File
@@ -1,18 +0,0 @@
You are in Duo mode for requirements-driven development.
Workflow:
- Start with duo_init using a clear requirements checklist and acceptance criteria.
- Alternate duo_player (implement) and duo_coach (verify) until approved.
- In duo_player phases, work tool-first: search, read, make targeted edits, then verify.
- In duo_coach phases, prioritize objective verification and requirement coverage.
Tool selection and verification:
- Use search/read tools to ground work in the current codebase before editing.
- Prefer targeted diffs over broad rewrites when possible.
- After any change, run the most relevant tests/checks before handing off to duo_coach.
- If verification fails, report it concisely, fix it, and re-run.
Budgeting and hygiene:
- Budget attempts. If 2-3 attempts fail to make progress, reassess and state the blocker.
- Check git status early and again before finishing; do not revert unrelated changes.
- Provide brief progress updates at phase boundaries and major checkpoints.
-13
View File
@@ -1,13 +0,0 @@
You are in RLM mode for working with large files that exceed context limits.
Work tool-first and chunk-aware:
- Use rlm_* tools to load files, explore content, and run focused queries over chunks.
- Prefer search-then-read: locate relevant sections before loading large spans.
- Summarize the relevant chunks in your own words before editing.
- Make targeted edits; avoid full-file rewrites unless necessary.
Verification, budgeting, and hygiene:
- After any change, run the most relevant tests/checks before declaring success.
- Check git status early and again before finishing; do not revert unrelated changes.
- Budget attempts. If 2-3 chunking/search attempts fail to surface the needed context, pause and state the blocker or request clarification.
- Provide concise progress updates at key checkpoints (what chunk/area you inspected, what changed, what verified).
-1317
View File
File diff suppressed because it is too large Load Diff
+4 -15
View File
@@ -15,13 +15,11 @@ pub struct Settings {
pub theme: String,
/// Auto-compact conversations when they get long
pub auto_compact: bool,
/// Auto-switch to RLM mode when large inputs are detected
pub auto_rlm: bool,
/// Show thinking blocks from the model
pub show_thinking: bool,
/// Show detailed tool output
pub show_tool_details: bool,
/// Default mode: "agent", "plan", "yolo", "rlm", "duo"
/// Default mode: "agent", "plan", "yolo"
pub default_mode: String,
/// Sidebar width as percentage of terminal width
pub sidebar_width_percent: u16,
@@ -36,7 +34,6 @@ impl Default for Settings {
Self {
theme: "whale".to_string(),
auto_compact: true,
auto_rlm: true,
show_thinking: true,
show_tool_details: true,
default_mode: "agent".to_string(),
@@ -102,9 +99,6 @@ impl Settings {
"auto_compact" | "compact" => {
self.auto_compact = parse_bool(value)?;
}
"auto_rlm" => {
self.auto_rlm = parse_bool(value)?;
}
"show_thinking" | "thinking" => {
self.show_thinking = parse_bool(value)?;
}
@@ -113,9 +107,9 @@ impl Settings {
}
"default_mode" | "mode" => {
let normalized = normalize_mode(value);
if !["agent", "plan", "yolo", "rlm", "duo"].contains(&normalized) {
if !["agent", "plan", "yolo"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo, rlm, duo."
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
);
}
self.default_mode = normalized.to_string();
@@ -160,7 +154,6 @@ impl Settings {
lines.push("─────────────────────────────".to_string());
lines.push(format!(" theme: {}", self.theme));
lines.push(format!(" auto_compact: {}", self.auto_compact));
lines.push(format!(" auto_rlm: {}", self.auto_rlm));
lines.push(format!(" show_thinking: {}", self.show_thinking));
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
lines.push(format!(" default_mode: {}", self.default_mode));
@@ -186,13 +179,9 @@ impl Settings {
vec![
("theme", "Color theme: default, dark, light"),
("auto_compact", "Auto-compact conversations: on/off"),
(
"auto_rlm",
"Auto-switch to RLM mode for large inputs: on/off",
),
("show_thinking", "Show model thinking: on/off"),
("show_tool_details", "Show detailed tool output: on/off"),
("default_mode", "Default mode: agent, plan, yolo, rlm, duo"),
("default_mode", "Default mode: agent, plan, yolo"),
("sidebar_width", "Sidebar width percentage: 10-50"),
("max_history", "Max input history entries"),
("default_model", "Default model name"),
-468
View File
@@ -1,468 +0,0 @@
//! Tools for Duo mode: Player-Coach autocoding workflow.
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::duo::{
DuoPhase, SharedDuoSession, generate_coach_prompt, generate_player_prompt, session_summary,
};
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, required_str,
};
/// Initialize an autocoding session with requirements.
pub struct DuoInitTool {
session: SharedDuoSession,
}
impl DuoInitTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoInitTool {
fn name(&self) -> &'static str {
"duo_init"
}
fn description(&self) -> &'static str {
"Initialize a Duo autocoding session with requirements. Returns session summary."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"requirements": {
"type": "string",
"description": "The requirements document (source of truth). Should be structured as a checklist."
},
"max_turns": {
"type": "integer",
"description": "Maximum turns before timeout (default: 10)"
},
"session_name": {
"type": "string",
"description": "Optional human-readable session name (e.g., 'auth-feature')"
},
"approval_threshold": {
"type": "number",
"description": "Minimum compliance score for approval (0-1, default: 0.9)"
}
},
"required": ["requirements"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let requirements = required_str(&input, "requirements")?;
let max_turns = input
.get("max_turns")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let session_name = optional_str(&input, "session_name").map(str::to_string);
let approval_threshold = input.get("approval_threshold").and_then(|v| v.as_f64());
let mut session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session.start_session(
requirements.to_string(),
session_name,
max_turns,
approval_threshold,
);
let summary = state.summary();
Ok(ToolResult::success(format!(
"Duo session initialized. Ready for player phase.\n\n{}",
summary
)))
}
}
/// Generate the player prompt for implementation.
pub struct DuoPlayerTool {
session: SharedDuoSession,
}
impl DuoPlayerTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoPlayerTool {
fn name(&self) -> &'static str {
"duo_player"
}
fn description(&self) -> &'static str {
"Generate the player prompt for implementation. Must be in Init or Player phase. Call after implementing to advance to Coach phase."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"implementation_summary": {
"type": "string",
"description": "Optional summary of implementation work done (recorded in history)"
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let implementation_summary = optional_str(&input, "implementation_summary")
.map(str::to_string)
.unwrap_or_else(|| "Implementation in progress".to_string());
let mut session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session
.get_active_mut()
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
// Check we're in a valid phase for player
match state.phase {
DuoPhase::Init | DuoPhase::Player => {
// Generate prompt first
let prompt = generate_player_prompt(state);
// Advance to Coach phase
state
.advance_to_coach(implementation_summary)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
Ok(ToolResult::success(format!(
"=== PLAYER PROMPT ===\n\n{}\n\n---\nAdvanced to Coach phase. Use duo_coach for verification.",
prompt
)))
}
DuoPhase::Coach => Err(ToolError::invalid_input(
"Already in Coach phase. Use duo_coach to get verification prompt.",
)),
DuoPhase::Approved => Err(ToolError::invalid_input(
"Session already approved. Start a new session with duo_init.",
)),
DuoPhase::Timeout => Err(ToolError::invalid_input(
"Session timed out. Start a new session with duo_init.",
)),
}
}
}
/// Generate the coach prompt for validation.
pub struct DuoCoachTool {
session: SharedDuoSession,
}
impl DuoCoachTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoCoachTool {
fn name(&self) -> &'static str {
"duo_coach"
}
fn description(&self) -> &'static str {
"Generate the coach prompt for validation. Must be in Coach phase. Does NOT advance state."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session
.get_active()
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
if state.phase != DuoPhase::Coach {
return Err(ToolError::invalid_input(format!(
"Expected Coach phase, but current phase is {}. Use duo_player first.",
state.phase
)));
}
let prompt = generate_coach_prompt(state);
Ok(ToolResult::success(format!(
"=== COACH PROMPT ===\n\n{}\n\n---\nAfter verification, use duo_advance with feedback and approval status.",
prompt
)))
}
}
/// Advance the session after coach review.
pub struct DuoAdvanceTool {
session: SharedDuoSession,
}
impl DuoAdvanceTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoAdvanceTool {
fn name(&self) -> &'static str {
"duo_advance"
}
fn description(&self) -> &'static str {
"Advance the session after coach review. Updates turn count and records feedback. Returns new status."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"feedback": {
"type": "string",
"description": "The coach's feedback text (compliance checklist and actions needed)"
},
"approved": {
"type": "boolean",
"description": "Whether the coach approved the implementation (look for 'COACH APPROVED')"
},
"compliance_score": {
"type": "number",
"description": "Optional compliance score (0-1) based on checklist items satisfied"
}
},
"required": ["feedback", "approved"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let feedback = required_str(&input, "feedback")?;
let approved = input
.get("approved")
.and_then(|v| v.as_bool())
.ok_or_else(|| ToolError::missing_field("approved"))?;
let compliance_score = input.get("compliance_score").and_then(|v| v.as_f64());
let mut session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session
.get_active_mut()
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
if state.phase != DuoPhase::Coach {
return Err(ToolError::invalid_input(format!(
"Expected Coach phase, but current phase is {}",
state.phase
)));
}
// Advance the turn
state
.advance_turn(feedback.to_string(), approved, compliance_score)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
// Determine status message based on new phase
let status_msg = match state.phase {
DuoPhase::Approved => "🎉 APPROVED! All requirements verified.",
DuoPhase::Timeout => "⏰ TIMEOUT. Max turns reached without approval.",
DuoPhase::Player => "🔄 Continuing to next player turn...",
_ => "Session updated.",
};
let summary = state.summary();
let mut result = ToolResult::success(format!("{}\n\n{}", status_msg, summary));
result.metadata = Some(json!({
"phase": state.phase.to_string(),
"status": state.status.to_string(),
"turn": state.current_turn,
"max_turns": state.max_turns,
"approved": approved,
"compliance_score": compliance_score,
"is_complete": state.is_complete(),
}));
Ok(result)
}
}
/// Show the current session status.
pub struct DuoStatusTool {
session: SharedDuoSession,
}
impl DuoStatusTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoStatusTool {
fn name(&self) -> &'static str {
"duo_status"
}
fn description(&self) -> &'static str {
"Show the current Duo session status including phase, turn count, and requirements."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
Ok(ToolResult::success(session_summary(&session)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::duo::new_shared_duo_session;
#[test]
fn test_duo_init_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoInitTool::new(session);
assert_eq!(tool.name(), "duo_init");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
let schema = tool.input_schema();
assert!(schema.get("properties").is_some());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&json!("requirements"))
);
}
#[test]
fn test_duo_player_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoPlayerTool::new(session);
assert_eq!(tool.name(), "duo_player");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
}
#[test]
fn test_duo_coach_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoCoachTool::new(session);
assert_eq!(tool.name(), "duo_coach");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
}
#[test]
fn test_duo_advance_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoAdvanceTool::new(session);
assert_eq!(tool.name(), "duo_advance");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
let schema = tool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("feedback")));
assert!(required.contains(&json!("approved")));
}
#[test]
fn test_duo_status_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoStatusTool::new(session);
assert_eq!(tool.name(), "duo_status");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
}
}
-5
View File
@@ -7,7 +7,6 @@
pub mod apply_patch;
pub mod calculator;
pub mod diagnostics;
pub mod duo;
pub mod file;
pub mod file_search;
pub mod finance;
@@ -17,7 +16,6 @@ pub mod plan;
pub mod project;
pub mod registry;
pub mod review;
pub mod rlm;
pub mod search;
pub mod shell;
pub mod spec;
@@ -90,6 +88,3 @@ pub use parallel::MultiToolUseParallelTool;
// Re-export user input tool/types
pub use user_input::{RequestUserInputTool, UserInputAnswer, UserInputRequest, UserInputResponse};
// Re-export RLM tools
pub use rlm::{RlmExecTool, RlmLoadTool, RlmQueryTool, RlmStatusTool};
+3 -45
View File
@@ -12,9 +12,7 @@ use std::sync::Arc;
use serde_json::Value;
use crate::client::DeepSeekClient;
use crate::duo::SharedDuoSession;
use crate::models::Tool;
use crate::rlm::SharedRlmSession;
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
@@ -383,14 +381,8 @@ impl ToolRegistryBuilder {
/// Include all agent tools (file tools + shell + note + search + patch).
#[must_use]
pub fn with_agent_tools(
self,
allow_shell: bool,
rlm_session: Option<SharedRlmSession>,
client: Option<DeepSeekClient>,
model: String,
) -> Self {
let mut builder = self
pub fn with_agent_tools(self, allow_shell: bool) -> Self {
let builder = self
.with_file_tools()
.with_note_tool()
.with_search_tools()
@@ -404,10 +396,6 @@ impl ToolRegistryBuilder {
.with_project_tools()
.with_test_runner_tool();
if let Some(session) = rlm_session {
builder = builder.with_rlm_tools(session, client, model);
}
if allow_shell {
builder.with_shell_tools()
} else {
@@ -439,42 +427,12 @@ impl ToolRegistryBuilder {
allow_shell: bool,
todo_list: super::todo::SharedTodoList,
plan_state: super::plan::SharedPlanState,
rlm_session: Option<SharedRlmSession>,
client: Option<DeepSeekClient>,
model: String,
) -> Self {
self.with_agent_tools(allow_shell, rlm_session, client, model)
self.with_agent_tools(allow_shell)
.with_todo_tool(todo_list)
.with_plan_tool(plan_state)
}
/// Include RLM tools for context execution and sub-queries.
#[must_use]
pub fn with_rlm_tools(
self,
session: SharedRlmSession,
client: Option<DeepSeekClient>,
model: String,
) -> Self {
self.with_tool(Arc::new(super::rlm::RlmExecTool::new(session.clone())))
.with_tool(Arc::new(super::rlm::RlmLoadTool::new(session.clone())))
.with_tool(Arc::new(super::rlm::RlmStatusTool::new(session.clone())))
.with_tool(Arc::new(super::rlm::RlmQueryTool::new(
session, client, model,
)))
}
/// Include Duo tools for dialectical autocoding.
#[must_use]
pub fn with_duo_tools(self, session: SharedDuoSession) -> Self {
use super::duo::{DuoAdvanceTool, DuoCoachTool, DuoInitTool, DuoPlayerTool, DuoStatusTool};
self.with_tool(Arc::new(DuoInitTool::new(session.clone())))
.with_tool(Arc::new(DuoPlayerTool::new(session.clone())))
.with_tool(Arc::new(DuoCoachTool::new(session.clone())))
.with_tool(Arc::new(DuoAdvanceTool::new(session.clone())))
.with_tool(Arc::new(DuoStatusTool::new(session)))
}
/// Include sub-agent management tools.
#[must_use]
pub fn with_subagent_tools(
-1047
View File
File diff suppressed because it is too large Load Diff
+2 -36
View File
@@ -10,11 +10,9 @@ use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{Config, has_api_key, save_api_key};
use crate::duo::{SharedDuoSession, new_shared_duo_session};
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
use crate::models::{Message, SystemPrompt};
use crate::palette::{self, UiTheme};
use crate::rlm::{RlmSession, SharedRlmSession};
use crate::settings::Settings;
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
use crate::tools::subagent::SubAgentResult;
@@ -27,7 +25,6 @@ use crate::tui::scrolling::{MouseScrollState, TranscriptScroll};
use crate::tui::selection::TranscriptSelection;
use crate::tui::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
/// Format a nice welcome banner similar to Kimi CLI.
@@ -70,8 +67,6 @@ pub enum AppMode {
Agent,
Yolo,
Plan,
Rlm,
Duo,
}
fn char_count(text: &str) -> usize {
@@ -119,8 +114,6 @@ impl AppMode {
AppMode::Agent => "AGENT",
AppMode::Yolo => "YOLO",
AppMode::Plan => "PLAN",
AppMode::Rlm => "RLM",
AppMode::Duo => "DUO",
}
}
@@ -132,8 +125,6 @@ impl AppMode {
AppMode::Agent => "Agent mode - autonomous task execution with tools",
AppMode::Yolo => "YOLO mode - full tool access without approvals",
AppMode::Plan => "Plan mode - design before implementing",
AppMode::Rlm => "RLM mode - recursive language model sandbox",
AppMode::Duo => "Duo mode - dialectical autocoding with player-coach loop",
}
}
}
@@ -200,7 +191,6 @@ pub struct App {
pub input_history: Vec<String>,
pub history_index: Option<usize>,
pub auto_compact: bool,
pub auto_rlm: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
#[allow(dead_code)]
@@ -242,12 +232,6 @@ pub struct App {
pub plan_prompt_pending: bool,
/// Whether update_plan was called during the current turn
pub plan_tool_used_in_turn: bool,
/// RLM sandbox session state
pub rlm_session: SharedRlmSession,
/// Duo mode session state (player-coach autocoding loop)
pub duo_session: SharedDuoSession,
/// Whether RLM REPL input mode is active.
pub rlm_repl_active: bool,
/// Todo list for `TodoWriteTool`
#[allow(dead_code)] // For future engine integration
pub todos: SharedTodoList,
@@ -314,14 +298,6 @@ impl QueuedMessage {
self.display.clone()
}
}
pub fn content_with_query(&self, query: &str) -> String {
if let Some(skill_instruction) = self.skill_instruction.as_ref() {
format!("{skill_instruction}\n\n---\n\nUser request: {query}")
} else {
query.to_string()
}
}
}
// === Errors ===
@@ -364,7 +340,6 @@ impl App {
let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key);
let settings = Settings::load().unwrap_or_else(|_| Settings::default());
let auto_compact = settings.auto_compact;
let auto_rlm = settings.auto_rlm;
let show_thinking = settings.show_thinking;
let show_tool_details = settings.show_tool_details;
let max_input_history = settings.max_input_history;
@@ -376,8 +351,6 @@ impl App {
"plan" => AppMode::Plan,
"agent" | "normal" => AppMode::Agent,
"yolo" => AppMode::Yolo,
"rlm" => AppMode::Rlm,
"duo" => AppMode::Duo,
_ => AppMode::Agent,
};
let initial_mode = if yolo {
@@ -445,7 +418,6 @@ impl App {
input_history: Vec::new(),
history_index: None,
auto_compact,
auto_rlm,
show_thinking,
show_tool_details,
compact_threshold: 50000,
@@ -484,9 +456,6 @@ impl App {
plan_state,
plan_prompt_pending: false,
plan_tool_used_in_turn: false,
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
duo_session: new_shared_duo_session(),
rlm_repl_active: false,
todos: new_shared_todo_list(),
tool_log: Vec::new(),
session_cost: 0.0,
@@ -550,7 +519,6 @@ impl App {
} else {
ApprovalMode::Suggest
};
self.rlm_repl_active = false;
if mode != AppMode::Plan {
self.plan_prompt_pending = false;
self.plan_tool_used_in_turn = false;
@@ -565,14 +533,12 @@ impl App {
let _ = self.hooks.execute(HookEvent::ModeChange, &context);
}
/// Cycle through modes: Plan → Agent → YOLO → RLM → Duo → Plan
/// Cycle through modes: Plan → Agent → YOLO → Plan
pub fn cycle_mode(&mut self) {
let next = match self.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Rlm,
AppMode::Rlm => AppMode::Duo,
AppMode::Duo | AppMode::Normal => AppMode::Plan,
AppMode::Yolo | AppMode::Normal => AppMode::Plan,
};
self.set_mode(next);
}
+4 -62
View File
@@ -27,8 +27,6 @@ const TOOL_TEXT_LIMIT: usize = 240;
pub enum HistoryCell {
User { content: String },
Assistant { content: String, streaming: bool },
Player { content: String, streaming: bool },
Coach { content: String, streaming: bool },
System { content: String },
Thinking { content: String, streaming: bool },
Tool(ToolCell),
@@ -67,30 +65,6 @@ impl HistoryCell {
}
lines
}
HistoryCell::Player { content, streaming } => {
let mut lines = render_message("Player", content, player_style(), width);
if *streaming {
if let Some(last) = lines.last_mut() {
last.spans.push(Span::styled(
"",
Style::default().fg(palette::DEEPSEEK_SKY),
));
}
}
lines
}
HistoryCell::Coach { content, streaming } => {
let mut lines = render_message("Coach", content, coach_style(), width);
if *streaming {
if let Some(last) = lines.last_mut() {
last.spans.push(Span::styled(
"",
Style::default().fg(palette::DEEPSEEK_SKY),
));
}
}
lines
}
HistoryCell::System { content } => {
render_message("System", content, system_style(), width)
}
@@ -148,15 +122,6 @@ impl HistoryCell {
/// Convert a message into history cells for rendering.
#[must_use]
pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
history_cells_from_message_with_mode(msg, None)
}
/// Convert a message into history cells with optional Duo phase context.
#[must_use]
pub fn history_cells_from_message_with_mode(
msg: &Message,
duo_phase: Option<crate::duo::DuoPhase>,
) -> Vec<HistoryCell> {
let mut cells = Vec::new();
let mut text_blocks = Vec::new();
let mut thinking_blocks = Vec::new();
@@ -174,23 +139,10 @@ pub fn history_cells_from_message_with_mode(
match msg.role.as_str() {
"user" => cells.push(HistoryCell::User { content }),
"assistant" => {
let cell = match duo_phase {
Some(crate::duo::DuoPhase::Player) | Some(crate::duo::DuoPhase::Init) => {
HistoryCell::Player {
content,
streaming: false,
}
}
Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach {
content,
streaming: false,
},
_ => HistoryCell::Assistant {
content,
streaming: false,
},
};
cells.push(cell);
cells.push(HistoryCell::Assistant {
content,
streaming: false,
});
}
"system" => cells.push(HistoryCell::System { content }),
_ => {}
@@ -1321,16 +1273,6 @@ fn assistant_style() -> Style {
Style::default().fg(palette::DEEPSEEK_SKY)
}
fn player_style() -> Style {
Style::default().fg(palette::MODE_DUO)
}
fn coach_style() -> Style {
Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::BOLD)
}
fn system_style() -> Style {
Style::default().fg(palette::TEXT_MUTED).italic()
}
+1 -3
View File
@@ -55,9 +55,7 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::raw(
" - Tab cycles modes (Plan → Agent → YOLO → RLM → Duo)",
)),
Line::from(Span::raw(" - Tab cycles modes (Plan → Agent → YOLO)")),
Line::from(Span::raw(" - Ctrl+R opens the session picker")),
Line::from(Span::raw(" - l opens the pager for the last message")),
Line::from(Span::raw(" - Ctrl+C cancels or exits")),
+70 -807
View File
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -284,7 +284,7 @@ impl ModalView for HelpView {
"Modes:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]),
Line::from(" Tab cycles modes: Plan → Agent → YOLO → RLM → Duo"),
Line::from(" Tab cycles modes: Plan → Agent → YOLO"),
Line::from(""),
Line::from(vec![Span::styled(
"Commands:",
@@ -299,18 +299,6 @@ impl ModalView for HelpView {
)));
}
help_lines.push(Line::from(""));
help_lines.push(Line::from(vec![Span::styled(
"RLM / Aleph:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]));
help_lines.push(Line::from(" /rlm or /aleph - Enter external memory mode"));
help_lines.push(Line::from(
" /load @path - Load a file into RLM context",
));
help_lines.push(Line::from(" /repl - Toggle expression mode"));
help_lines.push(Line::from(" /status - Show contexts and usage"));
help_lines.push(Line::from(""));
help_lines.push(Line::from(vec![Span::styled(
"Tools:",
-2
View File
@@ -60,8 +60,6 @@ impl<'a> HeaderWidget<'a> {
AppMode::Agent => palette::MODE_AGENT,
AppMode::Yolo => palette::MODE_YOLO,
AppMode::Plan => palette::MODE_PLAN,
AppMode::Rlm => palette::MODE_RLM,
AppMode::Duo => palette::MODE_DUO,
}
}
+2 -10
View File
@@ -5,7 +5,7 @@ pub use header::{HeaderData, HeaderWidget};
pub use renderable::Renderable;
use crate::palette;
use crate::tui::app::{App, AppMode};
use crate::tui::app::App;
use crate::tui::approval::{ApprovalRequest, ElevationOption, ElevationRequest, ToolCategory};
use crate::tui::scrolling::TranscriptScroll;
use ratatui::{
@@ -128,15 +128,7 @@ impl Renderable for ComposerWidget<'_> {
let mut lines = Vec::new();
if self.app.input.is_empty() {
let placeholder = if self.app.mode == AppMode::Rlm {
if self.app.rlm_repl_active {
"Type an RLM expression or /repl to exit..."
} else {
"Ask a question or /repl to enter expression mode..."
}
} else {
"Type a message or /help for commands..."
};
let placeholder = "Type a message or /help for commands...";
lines.push(Line::from(vec![
Span::styled(
self.prompt,