From 8b9efa1f1f8a4f1ecef45bea88ab09c93b7c4520 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Tue, 7 Jul 2026 11:46:35 +0800 Subject: [PATCH 01/69] docs: add interactive command terminal & markdown fixes design spec --- .../2026-07-07-streaming-cmd-ui-design.md | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md diff --git a/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md b/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md new file mode 100644 index 0000000..e7ed8e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md @@ -0,0 +1,314 @@ +# Interactive Command Terminal & Markdown Fixes + +**Date:** 2026-07-07 +**Branch:** `feat/streaming-cmd-ui` +**Status:** Design — awaiting review + +## Overview + +Three interconnected improvements to the Telegram bot's tool-calling UX: +1. Show the actual shell command in the verbose tool notification +2. Interactive per-command Telegram message with live output streaming and a cancel button +3. Fix raw-markdown display during streaming and add message splitting for all command responses + +--- + +## Feature 1: Show Command in Verbose Mode + +### Problem + +`format_args_preview()` in `src/platform/tool_notifier.rs` treats `"command"` as a sensitive key and redacts it. Users see `"šŸ’» Running a command"` with no detail about what is being run, even when `/verbose` mode is enabled. + +### Solution + +Stop treating `"command"` as a sensitive key in `is_sensitive_key()`. The `format_args_preview()` function already truncates values to 60 chars and omits nested/array arguments — it is safe for command display. + +**Changes:** + +| File | Change | +|------|--------| +| `src/platform/tool_notifier.rs` | Remove `"command"` from the `is_sensitive_key()` check list | +| `src/platform/tool_notifier.rs` | The preview value from `format_args_preview()` already goes through `truncate_chars()` so long commands are capped | + +**Result:** +- Verbose card shows: `šŸ’» Running a command: cargo build --release... -- running` +- Interactive command message also shows the full command (Feature 2) + +--- + +## Feature 2: Interactive Command Terminal with Cancel + +### Problem + +`execute_command` in `src/tools.rs` uses `tokio::process::Command::output()` which blocks until the child exits. A long-lived process (e.g. `pnpm dev`, `cargo watch`) hangs the entire agent loop with no way to abort. + +### Solution + +Replace the blocking `.output()` call with an interactive flow that: +1. Sends a dedicated Telegram message showing the command and live output +2. Includes an inline `[Cancel]` button +3. Streams stdout/stderr to the message in near-real-time +4. Kills the child process when Cancel is pressed +5. Returns `"āš ļø User cancelled"` to the LLM + +### Architecture + +#### New shared state + +```rust +// Added to Agent struct (src/agent.rs) +pub running_commands: Arc>>, + +pub struct RunningCommand { + pub cancel_tx: oneshot::Sender<()>, +} +``` + +The `cancel_tx` channel is the bridge between the Telegram callback handler and the waiting agent loop. + +#### Flow (in `Agent::execute_tool`) + +``` +execute_tool("execute_command", {command: "pnpm dev"}, user_id, chat_id) + │ + ā”œā”€ 1. Generate cmd_id = format!("cmd_{}", uuid::Uuid::new_v4()) + │ + ā”œā”€ 2. bot.send_message(chat_id, + │ "šŸ’» Running: `pnpm dev`\n\n```\nā³ Starting...\n```", + │ reply_markup = [[Cancel]] // InlineKeyboardButton::callback("Cancel", "cancel_cmd:{cmd_id}") + │ ) + │ + ā”œā”€ 3. Spawn process: + │ tokio::process::Command::new("sh") + │ .arg("-c").arg(command) + │ .stdout(Stdio::piped()) + │ .stderr(Stdio::piped()) + │ .current_dir(sandbox_dir) + │ .spawn() + │ + ā”œā”€ 4. Register: running_commands.insert(cmd_id, RunningCommand { cancel_tx }) + │ + ā”œā”€ 5. Spawn background reader task: + │ Reads lines from stdout/stderr via BufReader + │ Sends lines through mpsc::channel + │ + ā”œā”€ 6. Main loop (tokio::select!): + │ + │ loop { + │ tokio::select! { + │ Some(line) = output_rx.recv() => { + │ append to buffer + │ if last_edit > 500ms ago: update_message(buffer) + │ } + │ status = child.wait() => { + │ // Process completed naturally + │ update_message(final_output, remove keyboard) + │ return output_string + │ } + │ _ = cancel_rx => { + │ // User pressed Cancel + │ child.kill().await.ok(); + │ update_message("āŒ Cancelled: ...", remove keyboard) + │ return "āš ļø User cancelled the command" + │ } + │ } + │ } + │ + └─ 7. Remove from registry (always, in a finally block) +``` + +#### Message update function + +```rust +fn format_command_message(command: &str, output: &str, status: CmdStatus) -> String { + let icon = match status { + Running => "šŸ’»", + Completed => "āœ…", + Cancelled => "āŒ", + }; + let header = format!("{} Running: `{}`\n\n", icon, escape_text(command)); + let body = if output.is_empty() { + "ā³ Starting...".to_string() + } else { + // Cap at ~3500 chars, show tail with truncation marker + let capped = truncate_tail(output, 3500); + format!("```\n{}\n```", capped) + }; + format!("{}{}", header, body) +} +``` + +`truncate_tail(s, limit)`: If `s` exceeds `limit` chars, keep the last `limit` chars and prepend `"...(truncated)\n"`. + +#### Callback handler (src/platform/telegram.rs) + +Extend `handle_model_callback` to handle `cancel_cmd:*`: + +```rust +if let Some(cmd_id) = data.strip_prefix("cancel_cmd:") { + let mut map = agent.running_commands.lock().await; + if let Some(cmd) = map.remove(&cmd_id.to_string()) { + let _ = cmd.cancel_tx.send(()); + bot.answer_callback_query(callback_id) + .text("ā›” Command cancelled") + .await?; + } else { + bot.answer_callback_query(callback_id) + .text("Command already finished") + .await?; + } + return Ok(()); +} +``` + +#### Agent.rs changes + +Add an explicit arm in `execute_tool` for `"execute_command"` (currently it falls through to the catch-all `tools::execute_builtin_tool`): + +```rust +"execute_command" => { + // Interactive flow — needs bot, chat_id, running_commands + self.execute_command_interactive(arguments, user_id, chat_id).await +} +``` + +#### New method: `Agent::execute_command_interactive` + +This is where the interactive flow lives. It does NOT delegate to `tools::execute_builtin_tool`. + +#### Edge cases + +| Case | Handling | +|------|----------| +| Command exits quickly (< 500ms) | Single message edit: Running → Completed. No intermediate state visible | +| Command produces no output | Show "Command completed with no output" | +| Output exceeds 3500 chars | Keep tail; prepend "...(truncated)\n" | +| Cancel button clicked but process already exited | Registry entry cleaned up; answer callback "Already finished" | +| Multiple commands running | Each has its own message + cmd_id. Independently cancellable | +| Agent loop times out (max_iterations) | The tool is still awaited, so timeout won't fire until command completes. Improve: use tokio::time::timeout on the entire select loop | + +--- + +## Feature 3: Markdown Fixes & Message Splitting + +### Problem + +1. **Split streaming messages show raw markdown**: When the buffer exceeds 3800 chars during streaming, the split message is sent as plain text with visible `**markdown**` syntax. Only the final flush message is properly entity-formatted. + +2. **Command responses (`/tools`, `/skills`, `/start`) can exceed 4096 chars**: These use `escape_text() + ParseMode::MarkdownV2` and don't split. Telegram silently rejects or truncates them. + +3. **No formatting in command responses**: Using `escape_text()` strips all formatting capability. + +### Solution + +#### 3a. Retroactive entity-formatting of split messages + +During streaming, track every `msg_id` we send (including split messages). On final flush, re-edit ALL tracked messages with entity-based formatting, not just the last one. + +```rust +// New state in stream_handle task +let mut sent_msg_ids: Vec<(ChatId, MessageId)> = Vec::new(); + +// When sending/finalizing a split message: +if let Ok(sent) = bot.send_message(chat_id, &buffer).await { + sent_msg_ids.push((chat_id, sent.id)); +} + +// On final flush: +let (plain_text, entities) = markdown_to_entities(&full_buffer); +let chunks = split_entities(&plain_text, &entities, 4090); + +for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { + if i < sent_msg_ids.len() { + // Re-edit existing message with proper entities + let (cid, mid) = sent_msg_ids[i]; + bot.edit_message_text(cid, mid, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } else { + // Overflow chunks: send as new messages + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } +} +``` + +This ensures EVERY message the user sees is properly formatted, not just the last one. + +#### 3b. Entity-based command responses + +Replace all `escape_text() + ParseMode::MarkdownV2` command response paths with the entity approach: + +| Command | Current | New | +|---------|---------|-----| +| `/start` | `escape_text(help)` + MarkdownV2 | `markdown_to_entities(help)` + `.entities()` | +| `/tools` | `escape_text(tool_list)` + MarkdownV2 | Entity-based + `split_entities()` + grouping | +| `/skills` | `escape_text(skill_list)` + MarkdownV2 | Entity-based + `split_entities()` | +| `/clear` | `escape_text(msg)` + MarkdownV2 | Entity-based | +| `/verbose` response | `escape_text(msg)` + MarkdownV2 | Entity-based | +| `/queryrewrite` | `escape_text(msg)` + MarkdownV2 | Entity-based | +| Error messages | `escape_text(err)` + MarkdownV2 | Entity-based | + +#### 3c. Tool grouping for `/tools` + +Group tools by origin to make the list scannable: + +``` +šŸ“¦ Built-in tools (12) + - read_file: Read a file... + - write_file: Write a file... + - execute_command: Execute a shell command... + ... + +šŸ” MCP: brave-search (3) + - mcp_brave-search_search_web: Search... + ... + +šŸ“§ MCP: google-workspace (5) + - mcp_google-workspace_query_gmail_emails: Query... + ... +``` + +If the full list exceeds ~4000 UTF-16 units, truncate and append: +``` +... and 8 more tools. Use /tools to see specific tools. +``` + +#### 3d. Extend markdown_to_entities + +Add support in `src/utils/markdown_entities.rs` for: + +- **Blockquotes** (`Tag::BlockQuote`): Prefix contained text with `> ` in plain text +- **Tables** (`Tag::Table`, `Tag::TableHead`, `Tag::TableRow`, `Tag::TableCell`): Render as plain text with aligned spacing + +These are handled by adding `Event::Start` / `Event::End` arms in the pulldown-cmark parser loop. + +--- + +## Files Changed + +| File | Feature | Changes | +|------|---------|---------| +| `src/agent.rs` | F2, F3 | Add `running_commands` field to `Agent`; add `execute_command_interactive()`; add explicit `"execute_command"` arm in `execute_tool()` | +| `src/tools.rs` | F2 | No change — `execute_builtin_tool` stays for other tools; interactive command is handled in `agent.rs` | +| `src/platform/tool_notifier.rs` | F1 | Remove `"command"` from `is_sensitive_key()` | +| `src/platform/telegram.rs` | F2, F3 | Add `cancel_cmd:*` handler to `handle_model_callback`; entity-based command responses; retroactive split-msg formatting; track `sent_msg_ids` in stream_handle | +| `src/utils/markdown_entities.rs` | F3 | Add blockquote and table support | +| `src/utils/telegram_markdown.rs` | F3 | No change needed (entity approach replaces most callers) | + +## Verification + +- `cargo clippy -- -D warnings` — no new warnings +- `cargo test` — all existing tests pass; add tests for: + - `truncate_tail()` utility function + - `format_command_message()` formatting + - Retroactive split-msg entity formatting (integration test) + - Blockquote rendering in `markdown_to_entities` + - Table rendering in `markdown_to_entities` +- Manual verify on Telegram: + - `/verbose` → execute a long command → cancel button works + - `/tools` with 50+ tools → properly split and grouped + - Streaming a long markdown response → all split messages formatted From 19362b3d2d87daa57428cf3d6c882ed01d6de101 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Tue, 7 Jul 2026 11:49:57 +0800 Subject: [PATCH 02/69] docs: address spec-reviewer recommendations --- .../2026-07-07-streaming-cmd-ui-design.md | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md b/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md index e7ed8e5..9695121 100644 --- a/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md +++ b/docs/superpowers/specs/2026-07-07-streaming-cmd-ui-design.md @@ -57,7 +57,7 @@ Replace the blocking `.output()` call with an interactive flow that: ```rust // Added to Agent struct (src/agent.rs) -pub running_commands: Arc>>, +pub running_commands: Arc>>, pub struct RunningCommand { pub cancel_tx: oneshot::Sender<()>, @@ -138,7 +138,7 @@ fn format_command_message(command: &str, output: &str, status: CmdStatus) -> Str } ``` -`truncate_tail(s, limit)`: If `s` exceeds `limit` chars, keep the last `limit` chars and prepend `"...(truncated)\n"`. +`truncate_tail(s, limit)`: If `s` exceeds `limit` chars, keep the last `limit` chars and prepend `"...(truncated)\n"`. Lives in `src/utils/strings.rs` alongside existing `truncate_chars`. #### Callback handler (src/platform/telegram.rs) @@ -146,7 +146,7 @@ Extend `handle_model_callback` to handle `cancel_cmd:*`: ```rust if let Some(cmd_id) = data.strip_prefix("cancel_cmd:") { - let mut map = agent.running_commands.lock().await; + let mut map = agent.running_commands.lock().await; // tokio::sync::Mutex if let Some(cmd) = map.remove(&cmd_id.to_string()) { let _ = cmd.cancel_tx.send(()); bot.answer_callback_query(callback_id) @@ -174,7 +174,19 @@ Add an explicit arm in `execute_tool` for `"execute_command"` (currently it fall #### New method: `Agent::execute_command_interactive` -This is where the interactive flow lives. It does NOT delegate to `tools::execute_builtin_tool`. +```rust +async fn execute_command_interactive( + &self, + arguments: &Value, + user_id: &str, + chat_id: ChatId, +) -> String { + // Uses self.bot (Arc), self.config.sandbox.allowed_directory, + // and self.running_commands (Arc>>) +} +``` + +This does NOT delegate to `tools::execute_builtin_tool`. The tool definition entry for `execute_command` in `tools.rs` (line 169) stays — it's needed for the LLM to see the tool. Execution is intercepted in `agent.rs`. #### Edge cases @@ -185,7 +197,7 @@ This is where the interactive flow lives. It does NOT delegate to `tools::execut | Output exceeds 3500 chars | Keep tail; prepend "...(truncated)\n" | | Cancel button clicked but process already exited | Registry entry cleaned up; answer callback "Already finished" | | Multiple commands running | Each has its own message + cmd_id. Independently cancellable | -| Agent loop times out (max_iterations) | The tool is still awaited, so timeout won't fire until command completes. Improve: use tokio::time::timeout on the entire select loop | +| Agent loop times out (max_iterations) | The tool is still awaited, so timeout won't fire until command completes. Wrap the entire select loop in `tokio::time::timeout(300s, ...)`. On timeout: kill child, return `"āš ļø Command timed out (300s)"` to LLM | --- @@ -281,8 +293,8 @@ If the full list exceeds ~4000 UTF-16 units, truncate and append: Add support in `src/utils/markdown_entities.rs` for: -- **Blockquotes** (`Tag::BlockQuote`): Prefix contained text with `> ` in plain text -- **Tables** (`Tag::Table`, `Tag::TableHead`, `Tag::TableRow`, `Tag::TableCell`): Render as plain text with aligned spacing +- **Blockquotes** (`Tag::BlockQuote`): Prefix contained text with `> ` in plain text (cosmetic only — Telegram has no native blockquote entity for the entity-based send path) +- **Tables** (`Tag::Table`, `Tag::TableHead`, `Tag::TableRow`, `Tag::TableCell`): Render as plain text with columns padded to max width per column These are handled by adding `Event::Start` / `Event::End` arms in the pulldown-cmark parser loop. @@ -297,7 +309,8 @@ These are handled by adding `Event::Start` / `Event::End` arms in the pulldown-c | `src/platform/tool_notifier.rs` | F1 | Remove `"command"` from `is_sensitive_key()` | | `src/platform/telegram.rs` | F2, F3 | Add `cancel_cmd:*` handler to `handle_model_callback`; entity-based command responses; retroactive split-msg formatting; track `sent_msg_ids` in stream_handle | | `src/utils/markdown_entities.rs` | F3 | Add blockquote and table support | -| `src/utils/telegram_markdown.rs` | F3 | No change needed (entity approach replaces most callers) | +| `src/utils/telegram_markdown.rs` | F3 | Minimal or no change (entity approach replaces most callers) | +| `src/platform/telegram.rs` (test) | F3 | Update `test_command_responses_use_escape_text` — after migration `markdown_to_entities` replaces `escape_text` in command responses | ## Verification From 6f342f9836d2cdb8d576c7988bd0f9bb863a508b Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Tue, 7 Jul 2026 12:08:58 +0800 Subject: [PATCH 03/69] feat: interactive command terminal, verbose mode, entity-based formatting - Show command text in verbose tool notification (remove 'command' from sensitive keys, add to SAFE_KEYS) - Add truncate_tail utility for command output display - Add RunningCommand registry and interactive command execution with cancel button via inline keyboard callback - Wire cancel_cmd:* callback handler in Telegram - Retroactively format split streaming messages with entities - Convert all command responses to entity-based send_markdown_message - Add blockquote and table support to markdown_to_entities --- .../plans/2026-07-07-streaming-cmd-ui.md | 1405 +++++++++++++++++ src/agent.rs | 187 ++- src/platform/telegram.rs | 217 ++- src/platform/tool_notifier.rs | 16 +- src/utils/markdown_entities.rs | 83 +- src/utils/strings.rs | 47 + 6 files changed, 1865 insertions(+), 90 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-07-streaming-cmd-ui.md diff --git a/docs/superpowers/plans/2026-07-07-streaming-cmd-ui.md b/docs/superpowers/plans/2026-07-07-streaming-cmd-ui.md new file mode 100644 index 0000000..df1b64d --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-streaming-cmd-ui.md @@ -0,0 +1,1405 @@ +# Interactive Command Terminal & Markdown Fixes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add interactive command execution with cancel button, show commands in verbose mode, fix raw markdown display during streaming, and add message splitting for command responses. + +**Architecture:** Three independent-but-compatible features layered on the existing agent loop. Feature 1 is a one-line change. Feature 2 adds interactive command execution with `tokio::select!` + oneshot cancellation (new `RunningCommand` registry in `Agent`). Feature 3 retroactively formats split streaming messages with entities, converts all command responses from `escape_text` to entity-based formatting, and extends the markdown parser for blockquotes/tables. + +**Tech Stack:** Rust, tokio, teloxide 0.17, pulldown-cmark 0.12 + +--- + +### Task 1: Show command in verbose mode (Feature 1) + +**Files:** +- Modify: `src/platform/tool_notifier.rs:379-398` + +- [ ] **Step 1: Remove `"command"` from the sensitive-key list** + +```rust +// Before (line 379-398): +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + [ + "token", + "secret", + "password", + "bearer", + "authorization", + "api_key", + "apikey", + "private_key", + "cookie", + "content", + "command", // <-- remove this + "prompt", + "message", + "text", + ] + .iter() + .any(|sensitive| lower.contains(sensitive)) +} + +// After: +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + [ + "token", + "secret", + "password", + "bearer", + "authorization", + "api_key", + "apikey", + "private_key", + "cookie", + "content", + "prompt", + "message", + "text", + ] + .iter() + .any(|sensitive| lower.contains(sensitive)) +} +``` + +Note: The `SAFE_KEYS` list (line 409) also contains `"name"` but not `"command"`. The `format_args_preview` function iterates `SAFE_KEYS` to build the display string, but for single-arg calls where the key is `"command"`, the path is: + +1. `obj.len() == 1` → enters single-arg branch +2. `is_sensitive_key("command")` → previously returned `true`, now returns `false` +3. `key_matches_any("command", &SAFE_KEYS)` → returns `false` (not in list) +4. Falls to the `return String::new()` at line 447 + +So removing "command" from `is_sensitive_key` is not enough. We also need to add `"command"` to `SAFE_KEYS` so the single-arg path renders it: + +```rust +const SAFE_KEYS: [&str; 14] = [ + "query", + "path", + "url", + "title", + "description", + "step_id", + "status", + "skill_name", + "agent", + "model", + "language", + "technology", + "name", + "command", // <-- add this +]; +``` + +- [ ] **Step 2: Run tests to verify no regressions** + +Run: `cargo test -p rustfox --lib platform::tool_notifier::tests 2>&1` +Expected: All existing tests pass. The test `test_format_args_preview_redacts_sensitive_single_arg` will now FAIL because it expects `{"command": "..."}` to be redacted, but it's no longer sensitive. We need to update that test. + +- [ ] **Step 3: Fix the failing test** + +In `src/platform/tool_notifier.rs`, find the test `test_format_args_preview_redacts_sensitive_single_arg` (line 607). It uses `api_key` as the test case. That should still be redacted. The test should still pass since `api_key` is still sensitive. + +Check if any other test uses `"command"` as a sensitive key. If `test_format_args_preview_suppresses_secret_key_variants` includes a `command` variant, update it to use a different key. + +The test `test_format_args_preview_multi_arg_keeps_multiple_safe_keys` (line 1041) should still pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/tool_notifier.rs +git commit -m "feat: show command text in verbose tool notification" +``` + +--- + +### Task 2: Add `truncate_tail` utility (Feature 2 prerequisite) + +**Files:** +- Create: `src/utils/strings.rs` (modify existing) + +- [ ] **Step 1: Write the failing test** + +In `src/utils/strings.rs`, add to the `mod tests` block: + +```rust +#[test] +fn test_truncate_tail_short_text() { + let input = "hello world"; + let result = super::truncate_tail(input, 100); + assert_eq!(result, "hello world"); +} + +#[test] +fn test_truncate_tail_exact() { + let input = "hello"; + let result = super::truncate_tail(input, 5); + assert_eq!(result, "hello"); +} + +#[test] +fn test_truncate_tail_truncated() { + let input = "abcdefghijklmnopqrstuvwxyz"; + let result = super::truncate_tail(input, 10); + assert!(result.starts_with("...(truncated)\n")); + assert_eq!(result.len(), "abcdefghij".len() + "...(truncated)\n".len()); + assert!(result.ends_with("abcdefghij")); +} + +#[test] +fn test_truncate_tail_chinese() { + let input = "ęÆę—„äøŠåˆ10點 arXiv AI č«–ę–‡ę‘˜č¦ļ¼ˆé¦™ęøÆę™‚é–“ļ¼‰é€™ę˜Æäø€ę®µå¾ˆé•·ēš„äø­ę–‡ę–‡å­—"; + let result = super::truncate_tail(input, 10); + assert!(result.starts_with("...(truncated)\n")); + let char_count = result.chars().count(); + // 10 tail chars + 16 prefix chars + assert!(char_count <= 27, "too long: {} chars", char_count); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustfox -- utils::strings::tests --test test_truncate_tail 2>&1` +Expected: FAIL — `truncate_tail` not defined + +- [ ] **Step 3: Write the minimal implementation** + +After the existing `truncate_chars` function in `src/utils/strings.rs`: + +```rust +/// Keep the last `max_chars` characters of `s`. If `s` exceeds `max_chars`, +/// prepend `"...(truncated)\n"` to the tail. +/// Safe for any UTF-8 input. +pub fn truncate_tail(s: &str, max_chars: usize) -> String { + let char_count = s.chars().count(); + if char_count <= max_chars { + return s.to_string(); + } + let prefix = "...(truncated)\n"; + let tail: String = s.chars().skip(char_count.saturating_sub(max_chars)).collect(); + format!("{}{}", prefix, tail) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p rustfox -- utils::strings::tests --test test_truncate_tail 2>&1` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/strings.rs +git commit -m "feat: add truncate_tail utility for command output display" +``` + +--- + +### Task 3: Add RunningCommand registry and interactive command execution (Feature 2 core) + +**Files:** +- Modify: `src/agent.rs` — add `RunningCommand` struct, `running_commands` field to `Agent`, `execute_command_interactive` method, `"execute_command"` arm in `execute_tool` +- Modify: `src/agent.rs` — add imports for `tokio::sync::oneshot`, `tokio::process`, `tokio::io::{AsyncBufReadExt, BufReader}`, `std::process::Stdio` + +- [ ] **Step 1: Add the RunningCommand struct and imports** + +At the top of `src/agent.rs`, add to the existing imports: + +```rust +use std::io::BufReader as StdBufReader; // for sync BufReader +use tokio::io::AsyncBufReadExt; +use tokio::process::{Child, Command as TokioCommand}; +use tokio::sync::oneshot; +``` + +After the `ScheduledJobRequest` struct (around line 41), add: + +```rust +/// A running shell command that can be cancelled by the user. +pub struct RunningCommand { + pub cancel_tx: oneshot::Sender<()>, +} +``` + +- [ ] **Step 2: Add `running_commands` to `Agent` struct** + +In the `Agent` struct (line 46), add a new field: + +```rust +pub running_commands: Arc>>, +``` + +The `Agent` struct is created in `Agent::new()` (find it by searching). Add initialization there: + +```rust +running_commands: Arc::new(tokio::sync::Mutex::new(HashMap::new())), +``` + +Also add `use std::collections::HashMap;` to the imports. + +- [ ] **Step 3: Add `execute_command_interactive` method to `Agent`** + +Find the `execute_tool` method (line 2476). Just before it, add: + +```rust +async fn execute_command_interactive( + &self, + arguments: &serde_json::Value, + _user_id: &str, + chat_id: ChatId, +) -> String { + use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + use tokio::io::AsyncReadExt; + use std::time::Instant; + + let command = match arguments["command"].as_str() { + Some(c) => c, + None => return "Error: Missing 'command' argument".to_string(), + }; + + let cmd_id = format!("cmd_{}", uuid::Uuid::new_v4()); + let sandbox_dir = &self.config.sandbox.allowed_directory; + + // Spawn process + let mut child = match TokioCommand::new("sh") + .arg("-c") + .arg(command) + .current_dir(sandbox_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => return format!("Error: Failed to spawn command: {}", e), + }; + + // Send initial message with cancel button + let initial_text = format!("šŸ’» Running: `{}`\n\n```\nā³ Starting...\n```", crate::utils::telegram_markdown::escape_text(command)); + let keyboard = InlineKeyboardMarkup::new([[ + InlineKeyboardButton::callback("Cancel", format!("cancel_cmd:{}", cmd_id)), + ]]); + + let msg = match self.bot.send_message(chat_id, &initial_text) + .reply_markup(keyboard) + .await + { + Ok(m) => m, + Err(e) => { + let _ = child.kill().await; + return format!("Error: Failed to send command message: {}", e); + } + }; + + // Set up cancel channel + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + // Register in running_commands + { + let mut map = self.running_commands.lock().await; + map.insert(cmd_id.clone(), RunningCommand { cancel_tx }); + } + + // Set up output streaming + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::(256); + let mut child_stdout = child.stdout.take(); + let mut child_stderr = child.stderr.take(); + + // Spawn stdout reader + tokio::spawn(async move { + if let Some(mut stdout) = child_stdout { + let mut buf = vec![0u8; 4096]; + while let Ok(n) = stdout.read(&mut buf).await { + if n == 0 { break; } + let chunk = String::from_utf8_lossy(&buf[..n]).to_string(); + if output_tx.send(chunk).await.is_err() { break; } + } + } + }); + + // Spawn stderr reader + tokio::spawn(async move { + if let Some(mut stderr) = child_stderr { + let mut buf = vec![0u8; 4096]; + while let Ok(n) = stderr.read(&mut buf).await { + if n == 0 { break; } + let chunk = String::from_utf8_lossy(&buf[..n]).to_string(); + if output_tx.send(chunk).await.is_err() { break; } + } + } + }); + + // Main loop: wait for output, completion, or cancel + let mut output_buffer = String::new(); + let mut last_edit = Instant::now(); + let mut final_result: Option = None; + + // Remove from registry when done + let cmd_id_for_cleanup = cmd_id.clone(); + let running_commands = self.running_commands.clone(); + + let cleanup = |running_commands: Arc>>, cmd_id: &str| { + let id = cmd_id.to_string(); + async move { + let mut map = running_commands.lock().await; + map.remove(&id); + } + }; + + loop { + tokio::select! { + Some(line) = output_rx.recv() => { + output_buffer.push_str(&line); + + // Update message every 500ms + if last_edit.elapsed() >= std::time::Duration::from_millis(500) { + let body = if output_buffer.is_empty() { + "ā³ Starting...".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = format!("šŸ’» Running: `{}`\n\n{}", crate::utils::telegram_markdown::escape_text(command), body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + last_edit = Instant::now(); + } + } + status = child.wait() => { + // Process completed + let exit_code = status.code().unwrap_or(-1); + let body = if output_buffer.is_empty() { + "Command completed with no output.".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let header = if exit_code == 0 { + format!("āœ… Completed: `{}`\n\n", crate::utils::telegram_markdown::escape_text(command)) + } else { + format!("āŒ Failed (exit code {}): `{}`\n\n", exit_code, crate::utils::telegram_markdown::escape_text(command)) + }; + let text = format!("{}{}", header, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + + // Build result string for LLM + let mut result = String::new(); + if !output_buffer.is_empty() { + result.push_str(&output_buffer); + result.push('\n'); + } + result.push_str(&format!("Exit code: {}", exit_code)); + + // Remove trailing newline for cleaner tool result + let result = result.trim_end().to_string(); + final_result = Some(result); + break; + } + _ = cancel_rx => { + // User cancelled — kill process + child.kill().await.ok(); + child.wait().await.ok(); // reap zombie + + cancel_running_commands(&running_commands, &cmd_id_for_cleanup).await; + + let body = if output_buffer.is_empty() { + String::new() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = format!("āŒ Cancelled: `{}`\n\n{}", crate::utils::telegram_markdown::escape_text(command), body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + + final_result = Some("āš ļø User cancelled the command".to_string()); + break; + } + } + } + + // Cleanup registry + cleanup(running_commands, &cmd_id_for_cleanup).await; + + final_result.unwrap_or_else(|| "Error: command execution failed".to_string()) +} +``` + +Wait — there's a subtlety with the cancel branch. `cancel_rx` is consumed by the `select!` but `running_commands` is also moved into the cleanup. Let me fix the cancel branch to not use `cancel_running_commands` (which doesn't exist). Instead, the cleanup after the loop does it. + +Actually, let me simplify. The `cleanup` closure is defined but not used in the cancel branch correctly. Let me restructure: + +```rust +async fn execute_command_interactive( + &self, + arguments: &serde_json::Value, + _user_id: &str, + chat_id: ChatId, +) -> String { + use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + use tokio::io::AsyncReadExt; + use std::time::Instant; + + let command = match arguments["command"].as_str() { + Some(c) => c, + None => return "Error: Missing 'command' argument".to_string(), + }; + + let cmd_id = format!("cmd_{}", uuid::Uuid::new_v4()); + let sandbox_dir = &self.config.sandbox.allowed_directory; + + // Spawn process + let mut child = match TokioCommand::new("sh") + .arg("-c") + .arg(command) + .current_dir(sandbox_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => return format!("Error: Failed to spawn command: {}", e), + }; + + let escaped_cmd = crate::utils::telegram_markdown::escape_text(command); + + // Send initial message with cancel button + let keyboard = InlineKeyboardMarkup::new([[ + InlineKeyboardButton::callback("Cancel", format!("cancel_cmd:{}", cmd_id)), + ]]); + + let msg = match self.bot.send_message(chat_id, + &format!("šŸ’» Running: `{}`\n\n```\nā³ Starting...\n```", escaped_cmd)) + .reply_markup(keyboard) + .await + { + Ok(m) => m, + Err(e) => { + let _ = child.kill().await; + return format!("Error: Failed to send command message: {}", e); + } + }; + + // Set up cancel channel + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + // Register in running_commands + { + let mut map = self.running_commands.lock().await; + map.insert(cmd_id.clone(), RunningCommand { cancel_tx }); + } + + // Capture Arc for cleanup + let running_commands = self.running_commands.clone(); + let cmd_id_clone = cmd_id.clone(); + + // Output streaming + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::(256); + let mut child_stdout = child.stdout.take(); + let mut child_stderr = child.stderr.take(); + + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + while let Some(mut stream) = child_stdout.as_mut() { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if output_tx.send(String::from_utf8_lossy(&buf[..n]).to_string()).await.is_err() { break; } + } + } + } + }); + + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + while let Some(mut stream) = child_stderr.as_mut() { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if output_tx.send(String::from_utf8_lossy(&buf[..n]).to_string()).await.is_err() { break; } + } + } + } + }); + + // Helper to update the Telegram message + let update_msg = |bot: &Bot, chat_id: ChatId, msg_id: teloxide::types::MessageId, icon: &str, label: &str, body: &str| { + let text = if body.is_empty() { + format!("{} {}: `{}`", icon, label, escaped_cmd) + } else { + format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body) + }; + async move { + bot.edit_message_text(chat_id, msg_id, &text).await.ok(); + } + }; + + // Main select loop + let mut output_buffer = String::new(); + let mut last_edit = Instant::now(); + + let result = loop { + tokio::select! { + Some(chunk) = output_rx.recv() => { + output_buffer.push_str(&chunk); + if last_edit.elapsed() >= std::time::Duration::from_millis(500) { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + update_msg(&self.bot, chat_id, msg.id, "šŸ’»", "Running", &format!("```\n{}\n```", capped)).await; + last_edit = Instant::now(); + } + } + status = child.wait() => { + let exit_code = status.code().unwrap_or(-1); + let (icon, label) = if exit_code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + let body = if output_buffer.is_empty() { + "Command completed with no output.".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + update_msg(&self.bot, chat_id, msg.id, icon, label, &body).await; + + let mut result = String::new(); + if !output_buffer.is_empty() { + result.push_str(output_buffer.trim_end()); + result.push('\n'); + } + result.push_str(&format!("Exit code: {}", exit_code)); + break result; + } + _ = &mut cancel_rx => { + let _ = child.kill().await; + let _ = child.wait().await; + let body = if output_buffer.is_empty() { + String::new() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + update_msg(&self.bot, chat_id, msg.id, "āŒ", "Cancelled", &body).await; + break "āš ļø User cancelled the command".to_string(); + } + } + }; + + // Cleanup registry + let mut map = running_commands.lock().await; + map.remove(&cmd_id_clone); + + result +} +``` + +- [ ] **Step 2: Add `"execute_command"` arm in `execute_tool`** + +Find the `execute_tool` method (line 2476). Before the MCP catch-all (`_ if self.mcp.is_mcp_tool(name)`), add: + +```rust +"execute_command" => { + self.execute_command_interactive(arguments, user_id, chat_id).await +} +``` + +- [ ] **Step 3: Add missing imports** + +Ensure these imports are present at the top of `agent.rs`: + +```rust +use std::collections::HashMap; +use tokio::sync::oneshot; +use tokio::process::Command as TokioCommand; +``` + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check 2>&1` +Expected: No errors + +- [ ] **Step 5: Run existing tests** + +Run: `cargo test 2>&1` +Expected: All existing tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/agent.rs +git commit -m "feat: add interactive command execution with cancel button" +``` + +--- + +### Task 4: Wire cancel callback in Telegram handler (Feature 2) + +**Files:** +- Modify: `src/platform/telegram.rs` — add `cancel_cmd:*` handler to `handle_model_callback` + +- [ ] **Step 1: Add cancel command handler to `handle_model_callback`** + +In `handle_model_callback` (line 1141), after the `model_select:cancel` branch (around line 1199), add: + +```rust +// Handle command cancellation +if let Some(cmd_id) = data.strip_prefix("cancel_cmd:") { + let mut map = agent.running_commands.lock().await; + if let Some(cmd) = map.remove(cmd_id) { + let _ = cmd.cancel_tx.send(()); + bot.answer_callback_query(callback_id) + .text("ā›” Command cancelled") + .await?; + } else { + bot.answer_callback_query(callback_id) + .text("Command already finished") + .await?; + } + return Ok(()); +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cargo check 2>&1` +Expected: No errors + +- [ ] **Step 3: Run tests** + +Run: `cargo test 2>&1` +Expected: All existing tests pass + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat: wire cancel command callback in Telegram handler" +``` + +--- + +### Task 5: Retroactive entity formatting for split streaming messages (Feature 3a) + +**Files:** +- Modify: `src/platform/telegram.rs` — track `sent_msg_ids` in `stream_handle`, re-edit on final flush + +- [ ] **Step 1: Track sent message IDs during streaming** + +In `stream_handle` (line 958), add a new tracking vec: + +```rust +// After the existing let statements (line 964-966): +let mut sent_msg_ids: Vec<(teloxide::types::ChatId, teloxide::types::MessageId)> = Vec::new(); +``` + +At the split point (line 981-995), when a split message is sent, track it: + +```rust +// Replace the split-send block (lines 981-995): +if buffer.len() > TELEGRAM_STREAM_SPLIT { + if let Some(msg_id) = current_msg_id { + // Edit existing message (this is fine — we already track it) + if let Err(e) = stream_bot + .edit_message_text(stream_chat_id, msg_id, &buffer) + .await + { + tracing::warn!(error = %e, "stream_handle: edit failed at split"); + } + } else { + // Send as new message and track it + if let Ok(sent) = stream_bot.send_message(stream_chat_id, &buffer).await { + sent_msg_ids.push((stream_chat_id, sent.id)); + } else { + tracing::warn!("stream_handle: send failed at split"); + } + } + buffer.clear(); + current_msg_id = None; + last_action = Instant::now(); + continue; +} +``` + +Also track the initial message when it's first sent (line 1005-1009): + +```rust +// Replace the first-send block (lines 1005-1010): +} else { + match stream_bot.send_message(stream_chat_id, &buffer).await { + Ok(sent) => { + current_msg_id = Some(sent.id); + // Also track in sent_msg_ids so it gets retroactive formatting + sent_msg_ids.push((stream_chat_id, sent.id)); + } + Err(e) => tracing::warn!(error = %e, "stream_handle: initial send failed"), + } +} +``` + +- [ ] **Step 2: Update final flush to re-edit all tracked messages** + +Replace the final flush block (lines 1019-1049) with: + +```rust +// Final: flush whatever is left in the buffer. +if !buffer.is_empty() { + const MAX_UTF16: usize = 4090; + let (plain_text, entities) = markdown_to_entities(&buffer); + let chunks = split_entities(&plain_text, &entities, MAX_UTF16); + + // Track the span that the current in-progress message covers + // (it was edited during streaming with plain text) + if let Some(msg_id) = current_msg_id { + // This message was already sent — ensure it's in sent_msg_ids + if !sent_msg_ids.iter().any(|(cid, mid)| *cid == stream_chat_id && *mid == msg_id) { + sent_msg_ids.push((stream_chat_id, msg_id)); + } + } + + for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { + if i < sent_msg_ids.len() { + // Re-edit existing split message with proper entities + let (cid, mid) = sent_msg_ids[i]; + stream_bot + .edit_message_text(cid, mid, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } else { + // Overflow chunks beyond what we tracked: send as new messages + stream_bot + .send_message(stream_chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } + } +} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check 2>&1` +Expected: No errors + +- [ ] **Step 4: Run tests** + +Run: `cargo test 2>&1` +Expected: All existing tests pass. Note: `test_final_flush_uses_entity_based_conversion` and `test_stream_handle_does_not_require_placeholder_send` are source-inspection tests that should still pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat: retroactively format split streaming messages with entities" +``` + +--- + +### Task 6: Entity-based command responses with splitting (Feature 3b) + +**Files:** +- Modify: `src/platform/telegram.rs` — convert `/start`, `/clear`, `/verbose`, `/queryrewrite`, `/skills`, error messages to entity-based formatting with splitting +- Modify: `src/platform/telegram.rs` — update `/tools` to use entity-based + grouping + +- [ ] **Step 1: Create helper function for entity-based message sending** + +Before `is_verbose_enabled` (around line 219), add a helper: + +```rust +/// Send a markdown string as entity-formatted message(s), splitting if needed. +/// Returns Ok if at least one message was sent successfully. +async fn send_markdown_message( + bot: &Bot, + chat_id: ChatId, + markdown: &str, +) -> ResponseResult<()> { + const MAX_UTF16: usize = 4090; + let (plain_text, entities) = markdown_to_entities(markdown); + let chunks = split_entities(&plain_text, &entities, MAX_UTF16); + + if chunks.is_empty() { + // Empty output — send something so the user knows the command ran + bot.send_message(chat_id, "Done.").await?; + return Ok(()); + } + + for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { + if i == 0 { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await?; + } else { + // Best-effort for overflow chunks — ignore send failures + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } + } + Ok(()) +} +``` + +- [ ] **Step 2: Convert `/start` command** + +Replace the `/start` handler (lines 592-609): + +```rust +if text == "/start" { + let help = "Hello! I'm your AI assistant. Send me a message and I'll help you.\n\n\ + Commands:\n\ + **/clear** - Clear conversation history\n\ + **/tools** - List available tools\n\ + **/skills** - List loaded skills\n\ + **/update\\-skills** - Re-sync bundled skills/agents (backs up local edits)\n\ + **/verbose** - Toggle tool call progress display\n\ + **/queryrewrite** - Toggle query rewriting for memory search\n\ + **/selfupgrade** - Upgrade the bot (source or release binary)\n\ + **/models** - Browse and change the model"; + return send_markdown_message(&bot, msg.chat.id, help).await; +} +``` + +- [ ] **Step 3: Convert `/clear` command** + +Replace line 583-589: + +```rust +if text == "/clear" { + if let Err(e) = agent + .clear_conversation("telegram", &user_id.to_string()) + .await + { + error!("Failed to clear conversation: {}", e); + } + return send_markdown_message(&bot, msg.chat.id, "Conversation archived. Past messages remain searchable.").await; +} +``` + +- [ ] **Step 4: Convert `/verbose` response** + +Replace the response at lines 690-698: + +```rust +let reply = if new_value == "true" { + "šŸ”§ **Tool call UI enabled.** I'll show you what I'm working on." +} else { + "šŸ”‡ **Tool call UI disabled.** I'll respond silently." +}; +return send_markdown_message(&bot, msg.chat.id, reply).await; +``` + +- [ ] **Step 5: Convert `/queryrewrite` response** + +Replace the response at lines 727-735: + +```rust +let reply = if new_value == "true" { + "šŸ” **Query rewriting enabled.** Follow\\-up questions will be rewritten before memory search." +} else { + "šŸ” **Query rewriting disabled.** Messages will be searched as\\-is." +}; +return send_markdown_message(&bot, msg.chat.id, reply).await; +``` + +- [ ] **Step 6: Convert `/skills` command** + +Replace lines 626-643: + +```rust +if text == "/skills" { + let skills_guard = agent.skills.read().await; + let skills = skills_guard.list(); + if skills.is_empty() { + return send_markdown_message(&bot, msg.chat.id, "No skills loaded.").await; + } + let mut skill_list = String::from("**Loaded skills:**\n\n"); + for skill in &skills { + skill_list.push_str(&format!("- **{}**: {}\n", skill.name, skill.description)); + } + return send_markdown_message(&bot, msg.chat.id, &skill_list).await; +} +``` + +- [ ] **Step 7: Convert `/tools` with grouping** + +Replace lines 611-623: + +```rust +if text == "/tools" { + let all_tools = agent.all_tool_definitions(); + let mut builtin = Vec::new(); + let mut mcp_servers: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + + for tool in &all_tools { + if let Some(rest) = tool.function.name.strip_prefix("mcp_") { + if let Some(sep) = rest.find('_') { + let server = rest[..sep].to_string(); + mcp_servers.entry(server).or_default().push(tool); + } else { + // Unknown MCP format — treat as builtin-like + builtin.push(tool); + } + } else { + builtin.push(tool); + } + } + + let mut tool_list = format!("šŸ“¦ **Built-in tools ({})**\n", builtin.len()); + for tool in &builtin { + tool_list.push_str(&format!(" - `{}`: {}\n", tool.function.name, tool.function.description)); + } + tool_list.push('\n'); + + for (server, tools) in &mcp_servers { + tool_list.push_str(&format!("šŸ”§ **MCP: {} ({})**\n", server, tools.len())); + for tool in tools { + tool_list.push_str(&format!(" - `{}`: {}\n", tool.function.name, tool.function.description)); + } + tool_list.push('\n'); + } + + return send_markdown_message(&bot, msg.chat.id, &tool_list).await; +} +``` + +Add `use std::collections::BTreeMap;` at the top of the file. + +- [ ] **Step 8: Convert error messages** + +Replace lines 1113-1117: + +```rust +if let Err(e) = process_result { + warn!(error = %e, "Agent processing failed"); + return send_markdown_message(&bot, msg.chat.id, &format!("**Error:** {}", e)).await; +} +``` + +- [ ] **Step 9: Verify compilation** + +Run: `cargo check 2>&1` +Expected: No errors + +- [ ] **Step 10: Run tests** + +Run: `cargo test 2>&1` +Expected: Tests pass. The test `test_command_responses_use_escape_text` (line 1396) will FAIL because it asserts `escape_text` appears in the source, but we're replacing it with `send_markdown_message`. Update this test. + +- [ ] **Step 11: Fix the test** + +Replace the `test_command_responses_use_escape_text` test (line 1396) with: + +```rust +#[test] +fn test_command_responses_use_entity_formatting() { + // Command responses now use send_markdown_message (entity-based) instead of + // escape_text + ParseMode::MarkdownV2. + let source = include_str!("telegram.rs"); + assert!( + source.contains("send_markdown_message"), + "Command responses must use send_markdown_message for entity-based formatting" + ); +} +``` + +- [ ] **Step 12: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat: convert command responses to entity-based formatting with splitting" +``` + +--- + +### Task 7: Extend markdown_to_entities with blockquote and table support (Feature 3c) + +**Files:** +- Modify: `src/utils/markdown_entities.rs` — add `Tag::BlockQuote`, `Tag::Table`, `Tag::TableHead`, `Tag::TableRow`, `Tag::TableCell` handling + +- [ ] **Step 1: Write failing tests** + +In `src/utils/markdown_entities.rs`, add to the `mod tests` block: + +```rust +#[test] +fn test_blockquote_prefixes_with_gt() { + let (text, _) = markdown_to_entities("> This is a quote"); + assert!( + text.contains("> "), + "blockquote must be prefixed with '> ': {text}" + ); + assert!( + text.contains("This is a quote"), + "blockquote text must be present: {text}" + ); +} + +#[test] +fn test_table_renders_columns() { + let input = "| A | B |\n|---|---|\n| 1 | 2 |"; + let (text, _) = markdown_to_entities(input); + assert!(text.contains('A'), "column A must be in output: {text}"); + assert!(text.contains('B'), "column B must be in output: {text}"); + assert!(text.contains('1'), "row 1 col 1 must be in output: {text}"); + assert!(text.contains('2'), "row 1 col 2 must be in output: {text}"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustfox -- utils::markdown_entities::tests --test test_blockquote 2>&1` +Expected: FAIL — blockquote/table not handled + +- [ ] **Step 3: Add `StackTag` variants** + +In the `StackTag` enum (line 317), add: + +```rust +enum StackTag { + Bold, + Italic, + Strikethrough, + Link(String), + Heading, + CodeBlock(Option), + BlockQuote, // <-- add + TableRow, // <-- add +} +``` + +- [ ] **Step 4: Handle `Tag::BlockQuote` in `Event::Start`** + +In the `Event::Start(tag)` match (around line 84), add: + +```rust +Tag::BlockQuote => { + stack.push((StackTag::BlockQuote, plain_utf16_len)); +} +Tag::Table(_) => { + // Start of a table — no entity needed, just track state + // We don't push to stack; TableRow handles individual rows +} +Tag::TableHead => { + stack.push((StackTag::TableRow, plain_utf16_len)); +} +Tag::TableRow => { + stack.push((StackTag::TableRow, plain_utf16_len)); +} +Tag::TableCell => { + // Push a marker so we can add spacing between cells + // We need to track cell boundaries for padding + stack.push((StackTag::Bold, plain_utf16_len)); // table cells render as bold +} +``` + +Wait, the table approach is more complex. Let me keep it simpler: just render table cells with text content, separated by spaces. No need for complex alignment. + +Actually, a simpler approach for tables: don't add entities, just extract the plain text from cells with separators. + +```rust +Tag::BlockQuote => { + // Blockquote: prefix with "> " — handled on End + stack.push((StackTag::BlockQuote, plain_utf16_len)); +} +Tag::TableHead | Tag::TableRow => { + // Each row starts — push a marker + stack.push((StackTag::TableRow, plain_utf16_len)); +} +Tag::TableCell => { + // Record where this cell starts, so we can add a separator on End + stack.push((StackTag::Bold, plain_utf16_len)); // just a position marker +} +``` + +- [ ] **Step 5: Handle `TagEnd` in `Event::End`** + +In the `Event::End(tag_end)` match (around line 119), add: + +```rust +TagEnd::BlockQuote => { + if let Some((StackTag::BlockQuote, start)) = stack.pop() { + // Prefix all lines in the range with "> " + // We need to modify the plain text retroactively + // Simple approach: add "> " at the start and after each newline in range + // But modifying existing plain text would mess up offsets... + // Better: append "> " to the start of the quote text + // Since pulldown-cmark gives us the text content between Start/End, + // we can just prepend "> " to each line we've accumulated. + } +} +TagEnd::TableHead | TagEnd::TableRow => { + stack.pop(); // discard TableRow marker + plain.push('\n'); + plain_utf16_len += 1; +} +TagEnd::TableCell => { + // Pop the Bold marker we pushed as a position tracker + stack.pop(); + // Add a spacer between cells (but not after the last cell) + plain.push_str(" "); + plain_utf16_len += 2; +} +``` + +Actually, this blockquote approach is wrong. We can't retroactively modify the plain text because entity offsets would be wrong. + +Let me take a different approach for blockquotes — instead of trying to modify the text retrospectively, I'll handle it at the text level: + +For blockquotes, we need to inject "> " before each text event inside the blockquote. The simplest way is to add a flag: + +```rust +// Before the parser loop: +let mut in_blockquote = false; + +// In Event::Start: +Tag::BlockQuote => { + in_blockquote = true; +} + +// In Event::End: +TagEnd::BlockQuote => { + in_blockquote = false; +} + +// In Event::Text: +Event::Text(text) => { + if in_blockquote { + // Prefix each line with "> " + let quoted: String = text.lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n"); + plain.push_str("ed); + plain_utf16_len += quoted.encode_utf16().count(); + } else { + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } +} +``` + +And for tables, similarly handle `in_table` / `in_table_row` / `in_table_cell` flags: + +```rust +let mut in_blockquote = false; +let mut in_table_cell = false; +let mut cell_texts: Vec = Vec::new(); +let mut is_table_head = false; + +// In Event::Start: +Tag::BlockQuote => { in_blockquote = true; } +Tag::Table(_) => { cell_texts.clear(); } +Tag::TableHead => { is_table_head = true; } +Tag::TableRow => { cell_texts.clear(); } +Tag::TableCell => { in_table_cell = true; } + +// In Event::End: +TagEnd::BlockQuote => { in_blockquote = false; } +TagEnd::TableHead | TagEnd::TableRow => { + let row_text = cell_texts.join(" "); + if is_table_head && matches!(tag_end, TagEnd::TableHead) { + // Add a separator line under the header + plain.push_str(&row_text); + plain.push_str("\n"); + plain.push_str(&"-".repeat(row_text.encode_utf16().count())); + is_table_head = false; + } else { + plain.push_str(&row_text); + } + plain.push('\n'); + plain_utf16_len += (row_text.encode_utf16().count() + 1) as usize; + // ... but this is getting complex because we're in the Event::Text handler... + +``` + +This is getting really complex for tables. Let me take a simpler approach: + +**Blockquotes**: Use a simple flag approach as shown above. Prefix each line with "> ". + +**Tables**: Since proper table rendering with alignment is complex and prone to bugs, and Telegram doesn't support tables natively, let's just render each cell on a new line with a bold prefix-like marker. Actually, the simplest approach: collect the text from all cells sequentially, separated by spaces, terminated by newline per row. + +Hmm, this is getting complex in the spec. Let me simplify for the plan — just handle blockquotes, and for tables, add a basic fallback that joins cell content with spaces. The implementation details will be worked out during coding. + +Let me write a simpler version: + +- [ ] **Step 3: Add blockquote and table state tracking** + +Add these variables before the parser loop (line 56): + +```rust +let mut in_blockquote = false; +``` + +- [ ] **Step 4: Handle blockquotes** + +In `Event::Start`, add: + +```rust +Tag::BlockQuote => { + in_blockquote = true; +} +``` + +In `Event::End`, add: + +```rust +TagEnd::BlockQuote => { + in_blockquote = false; + // Ensure blockquote ends with a newline + if !plain.ends_with('\n') { + plain.push('\n'); + plain_utf16_len += 1; + } +} +``` + +In `Event::Text`, modify to: + +```rust +Event::Text(text) => { + if in_blockquote { + let quoted: String = text.lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n"); + plain.push_str("ed); + plain_utf16_len += quoted.encode_utf16().count(); + } else { + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } +} +``` + +- [ ] **Step 5: Handle tables** + +For tables, add state tracking: + +```rust +let mut in_table_cell = false; +let mut table_cell_texts: Vec = Vec::new(); +``` + +In `Event::Start`, add: + +```rust +Tag::Table(_) => { + table_cell_texts.clear(); +} +Tag::TableHead => {} +Tag::TableRow => { + table_cell_texts.clear(); +} +Tag::TableCell => { + in_table_cell = true; +} +``` + +In `Event::End`, add: + +```rust +TagEnd::TableHead => { + let row = table_cell_texts.join(" │ "); + plain.push_str(&row); + plain_utf16_len += row.encode_utf16().count(); + // Add separator row + let sep = "─".repeat(5); + let separators: Vec<&str> = (0..table_cell_texts.len()).map(|_| &sep[..]).collect(); + let sep_line = separators.join("─┼─"); + plain.push('\n'); + plain.push_str(&sep_line); + plain.push('\n'); + plain_utf16_len += sep_line.encode_utf16().count() + 2; + table_cell_texts.clear(); +} +TagEnd::TableRow => { + let row = table_cell_texts.join(" │ "); + plain.push_str(&row); + plain.push('\n'); + plain_utf16_len += row.encode_utf16().count() + 1; + table_cell_texts.clear(); +} +TagEnd::TableCell => { + in_table_cell = false; +} +``` + +And in `Event::Text`, accumulate cell text: + +```rust +Event::Text(text) => { + if in_blockquote { + // ... blockquote handling + } else if in_table_cell { + table_cell_texts.push(text.to_string()); + // Don't add to plain yet — we'll join on row end + } else { + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } +} +``` + +Wait, this has a problem — table cells need their text to go through to plain text, not just be accumulated. Otherwise entity offsets break. Let me simplify: just add cell text directly to plain text, with a " | " separator. Don't try to retroactively create an aligned table. + +Simplest approach for tables — append cell content directly to plain text with separators: + +For `Event::Start`: +```rust +Tag::Table(_) | Tag::TableHead | Tag::TableRow => { + // No special handling needed +} +Tag::TableCell => { + in_table_cell = true; +} +``` + +For `Event::Text`: +```rust +Event::Text(text) => { + if in_table_cell { + // Directly append cell text; separator added on End + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } else if in_blockquote { + // ... blockquote + } else { + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } +} +``` + +For `Event::End`: +```rust +TagEnd::TableCell => { + in_table_cell = false; + plain.push_str(" | "); + plain_utf16_len += 3; +} +TagEnd::TableRow | TagEnd::TableHead => { + // Remove trailing " | " and add newline + if plain.ends_with(" | ") { + plain.truncate(plain.len() - 3); + plain_utf16_len = plain_utf16_len.saturating_sub(3); + } + plain.push('\n'); + plain_utf16_len += 1; +} +TagEnd::TableBody => { + // No-op +} +``` + +This is simple but produces readable output like: +``` +Col1 | Col2 +A | B +``` + +OK, this is good enough. Let me write it as a cleaner step in the plan. + +Actually, I realize this approach is getting very detailed and complex for a plan document. Let me simplify the plan step to just say "Add blockquote and table handling" with the key changes shown, rather than trying to write every single line. + +Let me rewrite this task more concisely:<|end▁of▁thinking|> + +<||DSML||tool_calls> +<||DSML||invoke name="edit"> +<||DSML||parameter name="filePath" string="true">/home/kan/workspaces/playground/myplay/RustBot/docs/superpowers/plans/2026-07-07-streaming-cmd-ui.md \ No newline at end of file diff --git a/src/agent.rs b/src/agent.rs index df3e6f1..05940b8 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; use tracing::{debug, error, info, warn}; -use teloxide::payloads::SendDocumentSetters; +use teloxide::payloads::{SendDocumentSetters, SendMessageSetters}; use teloxide::prelude::Requester; use teloxide::types::{ChatId, InputFile}; use teloxide::Bot; @@ -27,6 +27,9 @@ use crate::scheduler::reminders::ScheduledTaskStore; use crate::scheduler::Scheduler; use crate::skills::{format_listed_section, SkillRegistry}; use crate::tools; +use std::collections::HashMap; +use tokio::process::Command as TokioCommand; +use tokio::sync::oneshot; /// Number of context snippets to retrieve from conversation history for /// compaction summarization. @@ -41,6 +44,11 @@ pub struct ScheduledJobRequest { pub task_store: ScheduledTaskStore, } +/// A running shell command that can be cancelled by the user via a callback button. +pub struct RunningCommand { + pub cancel_tx: oneshot::Sender<()>, +} + /// The core agent that processes messages through LLM + tools. /// Platform-agnostic — receives IncomingMessage, returns response text. pub struct Agent { @@ -64,6 +72,7 @@ pub struct Agent { pub soul_updated: AtomicBool, pub current_model: tokio::sync::RwLock, pub config_path: PathBuf, + pub running_commands: Arc>>, } /// A task parsed from the spawn_agents tool arguments, after validation. @@ -140,6 +149,7 @@ impl Agent { soul_updated: AtomicBool::new(false), current_model: tokio::sync::RwLock::new(initial_model), config_path, + running_commands: Arc::new(tokio::sync::Mutex::new(HashMap::new())), } } @@ -2472,6 +2482,178 @@ impl Agent { tools::validate_home_path(home, &path.to_string_lossy()) } + async fn execute_command_interactive( + &self, + arguments: &serde_json::Value, + _user_id: &str, + chat_id: ChatId, + ) -> String { + use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + use std::time::Instant; + use tokio::io::AsyncReadExt; + + let command = match arguments["command"].as_str() { + Some(c) => c, + None => return "Error: Missing 'command' argument".to_string(), + }; + + let cmd_id = format!("cmd_{}", uuid::Uuid::new_v4()); + let sandbox_dir = &self.config.sandbox.allowed_directory; + + let mut child = match TokioCommand::new("sh") + .arg("-c") + .arg(command) + .current_dir(sandbox_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => return format!("Error: Failed to spawn command: {}", e), + }; + + let escaped_cmd = crate::utils::telegram_markdown::escape_text(command); + + // Send initial message with cancel button + let keyboard = InlineKeyboardMarkup::new([[ + InlineKeyboardButton::callback("Cancel", format!("cancel_cmd:{}", cmd_id)), + ]]); + + let msg = match self + .bot + .send_message( + chat_id, + format!("šŸ’» Running: `{}`\n\n```\nā³ Starting...\n```", escaped_cmd), + ) + .reply_markup(keyboard) + .await + { + Ok(m) => m, + Err(e) => { + let _ = child.kill().await; + return format!("Error: Failed to send command message: {}", e); + } + }; + + // Set up cancel channel + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + // Register in running_commands + { + let mut map = self.running_commands.lock().await; + map.insert(cmd_id.clone(), RunningCommand { cancel_tx }); + } + + // Capture Arc for cleanup + let running_commands = self.running_commands.clone(); + let cmd_id_clone = cmd_id.clone(); + + // Output streaming + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::(256); + let output_tx2 = output_tx.clone(); + let mut child_stdout = child.stdout.take(); + let mut child_stderr = child.stderr.take(); + + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + while let Some(stream) = child_stdout.as_mut() { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if output_tx + .send(String::from_utf8_lossy(&buf[..n]).to_string()) + .await + .is_err() + { + break; + } + } + } + } + }); + + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + while let Some(stream) = child_stderr.as_mut() { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if output_tx2 + .send(String::from_utf8_lossy(&buf[..n]).to_string()) + .await + .is_err() + { + break; + } + } + } + } + }); + + // Main select loop + let mut output_buffer = String::new(); + let mut last_edit = Instant::now(); + tokio::pin!(cancel_rx); + + let result = loop { + tokio::select! { + Some(chunk) = output_rx.recv() => { + output_buffer.push_str(&chunk); + if last_edit.elapsed() >= std::time::Duration::from_millis(500) { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + let body = format!("```\n{}\n```", capped); + let text = format!("šŸ’» Running: `{}`\n\n{}", escaped_cmd, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + last_edit = Instant::now(); + } + } + status = child.wait() => { + let exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); + let (icon, label) = if exit_code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + let body = if output_buffer.is_empty() { + "Command completed with no output.".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + + let mut result = String::new(); + if !output_buffer.is_empty() { + result.push_str(output_buffer.trim_end()); + result.push('\n'); + } + result.push_str(&format!("Exit code: {}", exit_code)); + break result; + } + _ = &mut cancel_rx => { + let _ = child.kill().await; + let _ = child.wait().await; + let body = if output_buffer.is_empty() { + String::new() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = if body.is_empty() { + format!("āŒ Cancelled: `{}`", escaped_cmd) + } else { + format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) + }; + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + break "āš ļø User cancelled the command".to_string(); + } + } + }; + + // Cleanup registry + let mut map = running_commands.lock().await; + map.remove(&cmd_id_clone); + + result + } + /// Execute a tool call by routing to the right handler async fn execute_tool( &self, @@ -3437,6 +3619,9 @@ impl Agent { Err(e) => format!("Failed to restore backup: {}", e), } } + "execute_command" => { + self.execute_command_interactive(arguments, user_id, chat_id).await + } _ if self.mcp.is_mcp_tool(name) => match self.mcp.call_tool(name, arguments).await { Ok(result) => result, Err(e) => format!("MCP tool error: {}", e), diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index cb998ee..1292da6 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -216,6 +217,37 @@ pub async fn run( Ok(()) } +/// Send a markdown string as entity-formatted message(s), splitting if needed. +/// Returns Ok if at least one message was sent successfully. +async fn send_markdown_message( + bot: &Bot, + chat_id: ChatId, + markdown: &str, +) -> ResponseResult<()> { + const MAX_UTF16: usize = 4090; + let (plain_text, entities) = markdown_to_entities(markdown); + let chunks = split_entities(&plain_text, &entities, MAX_UTF16); + + if chunks.is_empty() { + bot.send_message(chat_id, "Done.").await?; + return Ok(()); + } + + for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { + if i == 0 { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await?; + } else { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } + } + Ok(()) +} + fn is_verbose_enabled(value: Option<&str>) -> bool { value.map(|v| v == "true").unwrap_or(false) } @@ -580,66 +612,74 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe { error!("Failed to clear conversation: {}", e); } - bot.send_message( + return send_markdown_message( + &bot, msg.chat.id, - escape_text("Conversation archived. Past messages remain searchable."), + "Conversation archived. Past messages remain searchable.", ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - return Ok(()); + .await; } if text == "/start" { - let help = escape_text( - "Hello! I'm your AI assistant. Send me a message and I'll help you.\n\n\ + let help = "Hello! I'm your AI assistant. Send me a message and I'll help you.\n\n\ Commands:\n\ - /clear - Clear conversation history\n\ - /tools - List available tools\n\ - /skills - List loaded skills\n\ - /update-skills - Re-sync bundled skills/agents (backs up local edits)\n\ - /verbose - Toggle tool call progress display\n\ - /queryrewrite - Toggle query rewriting for memory search\n\ - /selfupgrade - Upgrade the bot (source or release binary)\n\ - /models - Browse and change the OpenRouter model", - ); - bot.send_message(msg.chat.id, help) - .parse_mode(ParseMode::MarkdownV2) - .await?; - return Ok(()); + **/clear** — Clear conversation history\n\ + **/tools** — List available tools\n\ + **/skills** — List loaded skills\n\ + **/update-skills** — Re-sync bundled skills (backs up local edits)\n\ + **/verbose** — Toggle tool call progress display\n\ + **/queryrewrite** — Toggle query rewriting for memory search\n\ + **/selfupgrade** — Upgrade the bot (source or release binary)\n\ + **/models** — Browse and change the model"; + return send_markdown_message(&bot, msg.chat.id, help).await; } if text == "/tools" { let all_tools = agent.all_tool_definitions(); - let mut tool_list = String::from("Available tools:\n\n"); + let mut builtin = Vec::new(); + let mut mcp_servers: BTreeMap> = BTreeMap::new(); + for tool in &all_tools { - tool_list.push_str(&format!( - " - {}: {}\n", - tool.function.name, tool.function.description - )); + if let Some(rest) = tool.function.name.strip_prefix("mcp_") { + if let Some(sep) = rest.find('_') { + let server = rest[..sep].to_string(); + mcp_servers.entry(server).or_default().push(tool); + } else { + builtin.push(tool); + } + } else { + builtin.push(tool); + } } - bot.send_message(msg.chat.id, escape_text(&tool_list)) - .parse_mode(ParseMode::MarkdownV2) - .await?; - return Ok(()); + + let mut tool_list = format!("**Built-in tools** ({}):\n", builtin.len()); + for tool in &builtin { + tool_list.push_str(&format!(" - `{}`: {}\n", tool.function.name, tool.function.description)); + } + tool_list.push('\n'); + + for (server, tools) in &mcp_servers { + tool_list.push_str(&format!("**MCP: {}** ({}):\n", server, tools.len())); + for tool in tools { + tool_list.push_str(&format!(" - `{}`: {}\n", tool.function.name, tool.function.description)); + } + tool_list.push('\n'); + } + + return send_markdown_message(&bot, msg.chat.id, &tool_list).await; } if text == "/skills" { let skills_guard = agent.skills.read().await; let skills = skills_guard.list(); if skills.is_empty() { - bot.send_message(msg.chat.id, escape_text("No skills loaded.")) - .parse_mode(ParseMode::MarkdownV2) - .await?; - } else { - let mut skill_list = String::from("Loaded skills:\n\n"); - for skill in &skills { - skill_list.push_str(&format!(" - {}: {}\n", skill.name, skill.description)); - } - bot.send_message(msg.chat.id, escape_text(&skill_list)) - .parse_mode(ParseMode::MarkdownV2) - .await?; + return send_markdown_message(&bot, msg.chat.id, "No skills loaded.").await; } - return Ok(()); + let mut skill_list = String::from("**Loaded skills:**\n\n"); + for skill in &skills { + skill_list.push_str(&format!("- **{}**: {}\n", skill.name, skill.description)); + } + return send_markdown_message(&bot, msg.chat.id, &skill_list).await; } if text == "/updateskills" || text == "/update-skills" { @@ -663,10 +703,7 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe let (s, a) = agent.reload_skills_and_agents().await; lines.push(format!("Reloaded: {s} skill(s), {a} agent(s) active.")); - bot.send_message(msg.chat.id, escape_text(&lines.join("\n"))) - .parse_mode(ParseMode::MarkdownV2) - .await?; - return Ok(()); + return send_markdown_message(&bot, msg.chat.id, &lines.join("\n")).await; } if text == "/verbose" { @@ -688,14 +725,11 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe .await .ok(); let reply = if new_value == "true" { - "šŸ”§ Tool call UI enabled. I'll show you what I'm working on." + "šŸ”§ **Tool call UI enabled.** I'll show you what I'm working on." } else { - "šŸ”‡ Tool call UI disabled. I'll respond silently." + "šŸ”‡ **Tool call UI disabled.** I'll respond silently." }; - bot.send_message(msg.chat.id, escape_text(reply)) - .parse_mode(ParseMode::MarkdownV2) - .await?; - return Ok(()); + return send_markdown_message(&bot, msg.chat.id, reply).await; } // Accept both the canonical `/queryrewrite` (registered with Telegram — @@ -725,14 +759,11 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe .await .ok(); let reply = if new_value == "true" { - "šŸ” Query rewriting enabled. Follow-up questions will be rewritten before memory search." + "šŸ” **Query rewriting enabled.** Follow-up questions will be rewritten before memory search." } else { - "šŸ” Query rewriting disabled. Messages will be searched as-is." + "šŸ” **Query rewriting disabled.** Messages will be searched as-is." }; - bot.send_message(msg.chat.id, escape_text(reply)) - .parse_mode(ParseMode::MarkdownV2) - .await?; - return Ok(()); + return send_markdown_message(&bot, msg.chat.id, reply).await; } // Combined parse_command dispatch for /self-upgrade and /models. @@ -962,6 +993,8 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe // The first token always starts a fresh message — the placeholder // (if any) is owned and deleted by `handle_message` after streaming. let mut current_msg_id: Option = None; + // Track ALL split messages so they can be retroactively formatted with entities + let mut split_contents: Vec = Vec::new(); let mut last_action = Instant::now(); let mut rx = stream_token_rx; @@ -970,25 +1003,19 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe // When buffer exceeds split threshold, finalize the current message // and reset so subsequent tokens start a new message. - // - // Previous logic sent the full buffer as a NEW message, then cleared - // the buffer. This caused the new message to visually shrink on the - // next edit (which only contained the small post-split tokens). - // - // Fix: edit/send the current message with its accumulated content - // (finalizing it), then clear the buffer AND current_msg_id so the - // next batch of tokens creates a fresh message. if buffer.len() > TELEGRAM_STREAM_SPLIT { + let snapshot = buffer.clone(); if let Some(msg_id) = current_msg_id { if let Err(e) = stream_bot - .edit_message_text(stream_chat_id, msg_id, &buffer) + .edit_message_text(stream_chat_id, msg_id, &snapshot) .await { tracing::warn!(error = %e, "stream_handle: edit failed at split"); } - } else if let Err(e) = stream_bot.send_message(stream_chat_id, &buffer).await { + } else if let Err(e) = stream_bot.send_message(stream_chat_id, &snapshot).await { tracing::warn!(error = %e, "stream_handle: send failed at split"); } + split_contents.push(snapshot); buffer.clear(); current_msg_id = None; last_action = Instant::now(); @@ -1012,18 +1039,27 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe } } - // Final: flush whatever is left in the buffer. - // Use the entity-based approach: convert completed Markdown to (plain_text, entities). - // This is robust for LLM output — no escaping needed, no risk of Telegram 400 errors. - // Intermediate streaming edits remain plain text (partial markdown is fragile). + // Final: flush whatever is left in the buffer with retroactive entity formatting. + // During streaming, intermediate edits use plain text (partial markdown is fragile). + // On final flush, convert the complete markdown to entities and re-edit all + // tracked messages so they render with proper formatting. if !buffer.is_empty() { + // Also add the final buffer content as the last segment + split_contents.push(buffer); + } + + if !split_contents.is_empty() { + // First, rebuild the full text for proper markdown parsing + let full_text: String = split_contents.join(""); const MAX_UTF16: usize = 4090; - let (plain_text, entities) = markdown_to_entities(&buffer); + let (plain_text, entities) = markdown_to_entities(&full_text); let chunks = split_entities(&plain_text, &entities, MAX_UTF16); + // The first msg_id in the current message (if any) corresponds to the + // first chunk. Tracked split IDs from sent messages correspond to their + // own chunks. for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { if i == 0 { - // First chunk: edit or replace the existing in-progress message if let Some(msg_id) = current_msg_id { stream_bot .edit_message_text(stream_chat_id, msg_id, chunk_text) @@ -1038,7 +1074,6 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe .ok(); } } else { - // Subsequent chunks: send as new messages stream_bot .send_message(stream_chat_id, chunk_text) .entities(chunk_entities.clone()) @@ -1111,9 +1146,7 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe if let Err(e) = process_result { warn!(error = %e, "Agent processing failed"); - bot.send_message(msg.chat.id, escape_text(&format!("Error: {:#}", e))) - .parse_mode(ParseMode::MarkdownV2) - .await?; + return send_markdown_message(&bot, msg.chat.id, &format!("**Error:** {}", e)).await; } // Success: response already delivered via streaming @@ -1150,7 +1183,7 @@ async fn handle_model_callback( }; let msg = q.regular_message().cloned(); - bot.answer_callback_query(callback_id).await?; + bot.answer_callback_query(callback_id.clone()).await?; if let Some(provider_name) = data.strip_prefix("provider_select:") { if let Some(provider) = agent.registry.get_provider(provider_name) { @@ -1198,6 +1231,24 @@ async fn handle_model_callback( return Ok(()); } + // Handle command cancellation + if let Some(cmd_id) = data.strip_prefix("cancel_cmd:") { + let mut map = agent.running_commands.lock().await; + if let Some(cmd) = map.remove(cmd_id) { + let _ = cmd.cancel_tx.send(()); + bot.answer_callback_query(callback_id) + .text("ā›” Command cancelled") + .await + .ok(); + } else { + bot.answer_callback_query(callback_id) + .text("Command already finished") + .await + .ok(); + } + return Ok(()); + } + if let Some(model_id) = data.strip_prefix("model_select:") { match agent.set_model(model_id).await { Ok(()) => { @@ -1393,13 +1444,13 @@ mod tests { } #[test] - fn test_command_responses_use_escape_text() { - // All non-streaming command responses must escape plain text and use MarkdownV2 - // so that special chars like `.`, `-`, `!`, `_`, `(`, `)` don't break the parser. + fn test_command_responses_use_entity_formatting() { + // Command responses now use send_markdown_message (entity-based) instead of + // escape_text + ParseMode::MarkdownV2. let source = include_str!("telegram.rs"); assert!( - source.contains("escape_text"), - "Command responses must call escape_text() before sending with MarkdownV2" + source.contains("send_markdown_message"), + "Command responses must use send_markdown_message for entity-based formatting" ); } diff --git a/src/platform/tool_notifier.rs b/src/platform/tool_notifier.rs index c1e24fb..3827706 100644 --- a/src/platform/tool_notifier.rs +++ b/src/platform/tool_notifier.rs @@ -389,7 +389,6 @@ fn is_sensitive_key(key: &str) -> bool { "private_key", "cookie", "content", - "command", "prompt", "message", "text", @@ -406,7 +405,7 @@ pub fn format_args_preview(args_json: &str) -> String { // - Only render a compact allowlist of safe keys when present // - Never render nested objects/arrays in full - const SAFE_KEYS: [&str; 13] = [ + const SAFE_KEYS: [&str; 14] = [ "query", "path", "url", @@ -420,6 +419,7 @@ pub fn format_args_preview(args_json: &str) -> String { "language", "technology", "name", + "command", ]; let Ok(val) = serde_json::from_str::(args_json) else { @@ -612,8 +612,8 @@ mod tests { } #[test] - fn test_format_args_preview_suppresses_command_content_and_prompt() { - let j = r#"{"command":"rm -rf /", "prompt":"write me a secret"}"#; + fn test_format_args_preview_suppresses_content_and_prompt() { + let j = r#"{"sensitive":"data", "prompt":"write me a secret"}"#; // multi-key but all sensitive — should suppress to empty let out = format_args_preview(j); assert!( @@ -623,6 +623,14 @@ mod tests { ); } + #[test] + fn test_format_args_preview_shows_command_single_arg() { + let j = r#"{"command":"rm -rf /"}"#; + let out = format_args_preview(j); + assert!(!out.is_empty(), "command should be visible: {out}"); + assert!(out.contains("rm -rf /"), "command content must be in output: {out}"); + } + #[test] fn test_format_args_preview_suppresses_unknown_single_scalar_key() { let preview = format_args_preview(r#"{"message":"private freeform text"}"#); diff --git a/src/utils/markdown_entities.rs b/src/utils/markdown_entities.rs index cade990..23857fc 100644 --- a/src/utils/markdown_entities.rs +++ b/src/utils/markdown_entities.rs @@ -53,12 +53,31 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { // Track UTF-16 length incrementally to avoid O(n²) rescanning let mut plain_utf16_len = 0usize; + // State for blockquote and table rendering + let mut in_blockquote = false; + let mut in_table_cell = false; + let mut table_cell_texts: Vec = Vec::new(); + for event in parser { match event { // --- Text content --- Event::Text(text) => { - plain.push_str(&text); - plain_utf16_len += text.encode_utf16().count(); + if in_blockquote { + let quoted: String = text + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n"); + plain.push_str("ed); + plain_utf16_len += quoted.encode_utf16().count(); + } else if in_table_cell { + table_cell_texts.push(text.to_string()); + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } else { + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); + } } Event::Code(text) => { // Inline code: emit as a Code entity @@ -111,6 +130,18 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { }; stack.push((StackTag::CodeBlock(lang), plain_utf16_len)); } + Tag::BlockQuote(_) => { + in_blockquote = true; + } + Tag::Table(_) => { + table_cell_texts.clear(); + } + Tag::TableHead | Tag::TableRow => { + table_cell_texts.clear(); + } + Tag::TableCell => { + in_table_cell = true; + } // Paragraph, list, etc. — no entity emitted on start. _ => {} }, @@ -193,6 +224,27 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { plain.push('\n'); plain_utf16_len += 1; } + TagEnd::BlockQuote(_) => { + in_blockquote = false; + if !plain.ends_with('\n') { + plain.push('\n'); + plain_utf16_len += 1; + } + } + TagEnd::TableCell => { + in_table_cell = false; + plain.push_str(" | "); + plain_utf16_len += 3; + } + TagEnd::TableHead | TagEnd::TableRow => { + // Remove trailing " | " and add newline + if plain.ends_with(" | ") { + plain.truncate(plain.len() - 3); + plain_utf16_len = plain_utf16_len.saturating_sub(3); + } + plain.push('\n'); + plain_utf16_len += 1; + } _ => {} } } @@ -595,4 +647,31 @@ mod tests { "first chunk bold must start at offset 0" ); } + + // --- Blockquotes --- + + #[test] + fn test_blockquote_prefixes_with_gt() { + let (text, _) = markdown_to_entities("> This is a quote"); + assert!( + text.contains("> "), + "blockquote must be prefixed with '> ': {text}" + ); + assert!( + text.contains("This is a quote"), + "blockquote text must be present: {text}" + ); + } + + // --- Tables --- + + #[test] + fn test_table_renders_columns() { + let input = "| A | B |\n|---|---|\n| 1 | 2 |"; + let (text, _) = markdown_to_entities(input); + assert!(text.contains('A'), "column A must be in output: {text}"); + assert!(text.contains('B'), "column B must be in output: {text}"); + assert!(text.contains('1'), "row 1 col 1 must be in output: {text}"); + assert!(text.contains('2'), "row 1 col 2 must be in output: {text}"); + } } diff --git a/src/utils/strings.rs b/src/utils/strings.rs index 408eca6..da6aaf5 100644 --- a/src/utils/strings.rs +++ b/src/utils/strings.rs @@ -1,3 +1,16 @@ +/// Keep the last `max_chars` characters of `s`. If `s` exceeds `max_chars`, +/// prepend `"...(truncated)\n"` to the tail. +/// Safe for any UTF-8 input. +pub fn truncate_tail(s: &str, max_chars: usize) -> String { + let char_count = s.chars().count(); + if char_count <= max_chars { + return s.to_string(); + } + let prefix = "...(truncated)\n"; + let tail: String = s.chars().skip(char_count.saturating_sub(max_chars)).collect(); + format!("{}{}", prefix, tail) +} + /// Truncates `s` to at most `max_chars` Unicode scalar values. /// Appends "..." if truncation occurred. /// Safe for any UTF-8 input including Chinese, Japanese, emoji, etc. @@ -16,6 +29,40 @@ pub fn truncate_chars(s: &str, max_chars: usize) -> String { mod tests { use super::*; + #[test] + fn test_truncate_tail_short_text() { + let input = "hello world"; + let result = truncate_tail(input, 100); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_truncate_tail_exact() { + let input = "hello"; + let result = truncate_tail(input, 5); + assert_eq!(result, "hello"); + } + + #[test] + fn test_truncate_tail_truncated() { + let input = "abcdefghijklmnopqrstuvwxyz"; + let result = truncate_tail(input, 10); + let prefix = "...(truncated)\n"; + assert!(result.starts_with(prefix)); + assert!(result.ends_with("qrstuvwxyz")); + assert_eq!(result.len(), prefix.len() + "qrstuvwxyz".len()); + } + + #[test] + fn test_truncate_tail_chinese() { + let input = "ęÆę—„äøŠåˆ10點 arXiv AI č«–ę–‡ę‘˜č¦ļ¼ˆé¦™ęøÆę™‚é–“ļ¼‰é€™ę˜Æäø€ę®µå¾ˆé•·ēš„äø­ę–‡ę–‡å­—"; + let result = truncate_tail(input, 10); + assert!(result.starts_with("...(truncated)\n")); + let char_count = result.chars().count(); + // 10 tail chars + 16 prefix chars + assert!(char_count <= 27, "too long: {} chars", char_count); + } + #[test] fn test_truncate_chars_ascii_short() { assert_eq!(truncate_chars("hello", 10), "hello"); From c0e20d263b0bf555c1497e2c06476f2d6d6c42d5 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 10:46:55 +0800 Subject: [PATCH 04/69] fix: drain pipe reader JoinHandles after child.wait() to capture all command output The select! loop in execute_command_interactive races child.wait() against the spawned pipe reader tasks. When the child exits quickly, child.wait() resolves before the readers have flushed pipe data through the mpsc channel, producing a result of just "Exit code: 0" with no output. Fix: capture JoinHandle from each spawned reader, then after the select loop exits, await both handles and drain remaining channel data with try_recv() before building the result. This guarantees the output buffer is complete, with no borrow conflicts or cancel-safety issues. Also: fix pre-existing clippy issue in markdown_entities.rs where in_table_cell flag had identical handling to the else branch. --- .../2026-07-08-command-output-drain-fix.md | 208 ++++++++++++++++++ .../2026-07-08-command-output-drain-fix.md | 193 ++++++++++++++++ src/agent.rs | 98 +++++++-- src/platform/telegram.rs | 61 +++-- src/platform/tool_notifier.rs | 5 +- src/tools.rs | 34 +-- src/utils/markdown_entities.rs | 19 +- src/utils/strings.rs | 5 +- 8 files changed, 541 insertions(+), 82 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-command-output-drain-fix.md create mode 100644 docs/superpowers/specs/2026-07-08-command-output-drain-fix.md diff --git a/docs/superpowers/plans/2026-07-08-command-output-drain-fix.md b/docs/superpowers/plans/2026-07-08-command-output-drain-fix.md new file mode 100644 index 0000000..953c759 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-command-output-drain-fix.md @@ -0,0 +1,208 @@ +# Command Output Drain Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the race condition in `execute_command_interactive` where `child.wait()` resolves before pipe readers have delivered all output, causing the LLM to see only `"Exit code: 0"` with no command output. + +**Architecture:** Capture `JoinHandle`s from the spawned stdout/stderr reader tasks. After the `tokio::select!` loop exits (either via `child.wait()` or cancel), await both handles in sequential code, then drain the remaining mpsc channel data with `try_recv()`. This guarantees the output buffer is complete before the result string is built, with no borrow conflicts or cancel-safety issues. + +**Tech Stack:** Rust, Tokio (`JoinHandle`, `mpsc::Receiver::try_recv`, `tokio::join!`) + +--- + +### Task 1: Capture JoinHandles and restructure the select loop + +**Files:** +- Modify: `src/agent.rs:2558-2654` + +The spawned reader tasks return `JoinHandle`s. We store them, then restructure the select loop to **only determine the exit reason** — the final result is built after the loop, after awaiting handles. + +- [ ] **Step 1: Capture JoinHandle from stdout reader** + +Current (lines 2558-2574): +```rust +tokio::spawn(async move { +``` + +Replace with: +```rust +let stdout_handle = tokio::spawn(async move { +``` + +- [ ] **Step 2: Capture JoinHandle from stderr reader** + +Current (lines 2576-2592): +```rust +tokio::spawn(async move { +``` + +Replace with: +```rust +let stderr_handle = tokio::spawn(async move { +``` + +- [ ] **Step 3: Replace the select loop with a post-loop drain** + +The select loop should no longer build and return the result inline. Instead, it breaks out of the loop with just the exit status info. Replace lines 2594-2654. + +Remove: +```rust + let result = loop { +``` + +Replace entire block from line 2594 (`// Main select loop`) through line 2654 (`};`) with: + +```rust + // Cap accumulated output to prevent unbounded memory growth + const MAX_BUFFER_CHARS: usize = 100_000; + + // Main select loop — only determines exit reason + let mut exit_code: Option = None; + let mut cancelled = false; + tokio::pin!(cancel_rx); + + loop { + tokio::select! { + Some(chunk) = output_rx.recv() => { + output_buffer.push_str(&chunk); + if output_buffer.chars().count() > MAX_BUFFER_CHARS { + output_buffer = crate::utils::strings::truncate_tail(&output_buffer, MAX_BUFFER_CHARS); + } + if last_edit.elapsed() >= std::time::Duration::from_millis(500) { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + let body = format!("```\n{}\n```", capped); + let text = format!("šŸ’» Running: `{}`\n\n{}", escaped_cmd, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + last_edit = Instant::now(); + } + } + status = child.wait() => { + exit_code = Some(status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)); + let (icon, label) = if exit_code == Some(0) { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + let body = if output_buffer.is_empty() { + "Command completed with no output.".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + break; + } + _ = &mut cancel_rx => { + cancelled = true; + let _ = child.kill().await; + let _ = child.wait().await; + let body = if output_buffer.is_empty() { + String::new() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = if body.is_empty() { + format!("āŒ Cancelled: `{}`", escaped_cmd) + } else { + format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) + }; + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + break; + } + } + } + + // Post-loop: wait for readers to finish, drain remaining output + // Timeout is a safety net — readers finish promptly after pipe EOF. + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + async { let _ = tokio::join!(stdout_handle, stderr_handle); }, + ).await; + while let Ok(chunk) = output_rx.try_recv() { + output_buffer.push_str(&chunk); + } + // Re-cap buffer after drain (defensive — drain may push past limit) + if output_buffer.chars().count() > MAX_BUFFER_CHARS { + output_buffer = crate::utils::strings::truncate_tail(&output_buffer, MAX_BUFFER_CHARS); + } + + // Build the final result with complete output + let result = if cancelled { + // Update display with final (complete) output + let body = if output_buffer.is_empty() { + String::new() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = if body.is_empty() { + format!("āŒ Cancelled: `{}`", escaped_cmd) + } else { + format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) + }; + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + "āš ļø User cancelled the command".to_string() + } else if let Some(code) = exit_code { + // Update display with final (complete) output + let (icon, label) = if code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + let body = if output_buffer.is_empty() { + "Command completed with no output.".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + + let mut result = String::new(); + if !output_buffer.is_empty() { + result.push_str(output_buffer.trim_end()); + result.push('\n'); + } + result.push_str(&format!("Exit code: {}", code)); + result + } else { + "Error: command exited with unknown state".to_string() + }; +``` + +- [ ] **Step 4: Verify the code compiles** + +Run: `cargo check 2>&1` + +Expected: No errors. If borrow-checker errors occur around `child.wait()` in select vs `child.wait()` in cancel, verify the `&mut` borrows are non-overlapping (they should be — select branches are mutually exclusive). + +--- + +### Task 2: Run clippy and tests + +- [ ] **Step 1: Run clippy** + +Run: `cargo clippy -- -D warnings 2>&1` + +Expected: No new warnings. If any warnings about unused variables or dead code, fix them. + +- [ ] **Step 2: Run existing tests** + +Run: `cargo test 2>&1` + +Expected: All tests pass. + +--- + +### Task 3: Commit + +- [ ] **Step 1: Stage and commit** + +```bash +git add src/agent.rs +git commit -m "fix: drain pipe reader JoinHandles after child.wait() to capture all command output + +The select! loop in execute_command_interactive races child.wait() against +the spawned pipe reader tasks. When the child exits quickly, child.wait() +resolves before the readers have flushed pipe data through the mpsc +channel, producing a result of just \"Exit code: 0\" with no output. + +Fix: capture JoinHandle from each spawned reader, then after the select +loop exits, await both handles and drain remaining channel data with +try_recv() before building the result. This guarantees the output buffer +is complete, with no borrow conflicts or cancel-safety issues." +``` diff --git a/docs/superpowers/specs/2026-07-08-command-output-drain-fix.md b/docs/superpowers/specs/2026-07-08-command-output-drain-fix.md new file mode 100644 index 0000000..07614b7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-command-output-drain-fix.md @@ -0,0 +1,193 @@ +# Interactive Command Terminal: Fix Missing Output + +**Date:** 2026-07-08 +**Branch:** `feat/streaming-cmd-ui` +**Status:** Design — awaiting review + +## Problem + +When executing a command via the interactive terminal UI, the tool result returned to the LLM contains only `"Exit code: 0"` — the actual stdout/stderr output is missing. The LLM cannot see command results (file listings, build output, git status, etc.), making the `execute_command` tool effectively useless for context-dependent decisions. + +**Observed log:** + +```json +{ + "inputs": { "arguments": { "command": "ls -la /home/kan/" } }, + "outputs": { "result": "Exit code: 0" }, + "metadata": { "ls_run_depth": 1 } +} +``` + +## Root Cause + +`tokio::select!` races `child.wait()` against `output_rx.recv()` on every loop iteration (`src/agent.rs:2599-2654`). When a command finishes quickly: + +1. The child process writes output to pipes and exits +2. `child.wait()` resolves immediately — kernel reports the child exited +3. **BUT:** the spawned stdout/stderr reader tasks may not have been polled yet, or may still have data in-flight in the mpsc channel +4. The `break result` fires before `output_buffer` contains any data +5. The result is just `"Exit code: 0"` + +The spawned reader tasks eventually read the pipe data and send it to the channel, but nobody is receiving anymore — the loop already broke. + +## Solution: Join Reader Tasks Before Building Result + +Replace the race-condition-prone `tokio::select!` with a **guarded exit**: only build the result after both `child.wait()` AND all pipe reader `JoinHandle`s have completed. + +### Mechanism: `JoinHandle` await + +`tokio::spawn` returns a `JoinHandle` that resolves when the spawned task finishes. The reader tasks complete naturally when their pipe reaches EOF (child exits, write end closes). By `await`ing the handles after `child.wait()` resolves, we guarantee all pipe data has been consumed and delivered to the mpsc channel before building the result. + +``` + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ Child Process │ + │ (writes output) │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ stdout │ stderr │ + ā–¼ ā–¼ │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ + │ Reader 1 │ │ Reader 2 │ │ + │ (spawn) │ │ (spawn) │ │ + │ returns │ │ returns │ │ + │ JoinHandle│ │ JoinHandle│ │ + ā””ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”˜ │ + │ │ │ + │ child.wait() │ + │ resolves │ + ā–¼ ā–¼ │ + join!(handle1, handle2) ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ Output is │ + │ 100% complete│ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +**Flow:** + +1. Spawn two reader tasks (stdout/stderr), capturing their `JoinHandle`s +2. Readers read from pipes in a loop, sending chunks to a shared `mpsc::channel` +3. On EOF (`read()` returns `Ok(0)`), the reader task loop exits naturally +4. Main select loop races: `mpsc::Receiver::recv()` vs `child.wait()` vs cancel +5. When `child.wait()` fires, the main task enters a **drain phase**: + - Awaits both `JoinHandle`s via `tokio::join!` (readers already finished or finishing due to pipe EOF) + - Drains remaining channel data with `try_recv()` in a loop + - Builds the final result from `output_buffer + exit code` +6. If cancel fires: kill child, await JoinHandles (250ms timeout), drain channel, return cancellation message + +### Code: Before vs After + +**Before** (`src/agent.rs:2558-2654`): +```rust +tokio::spawn(async move { /* read stdout, send to channel */ }); +tokio::spawn(async move { /* read stderr, send to channel */ }); + +loop { + tokio::select! { + Some(chunk) = output_rx.recv() => { /* buffer + display */ } + status = child.wait() => { + // āš ļø RACE: child exited but readers may not have flushed + let exit_code = ...; + break result; // output_buffer may be empty + } + _ = &mut cancel_rx => { /* kill + break */ } + } +} +``` + +**After**: +```rust +// Capture JoinHandles from spawned readers +let stdout_handle = tokio::spawn(async move { + // ... read stdout, send to mpsc ... +}); + +let stderr_handle = tokio::spawn(async move { + // ... read stderr, send to mpsc ... +}); + +loop { + tokio::select! { + Some(chunk) = output_rx.recv() => { /* buffer + display */ } + status = child.wait() => { + let exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); + + // DRAIN PHASE: wait for readers to finish, then drain channel + let _ = tokio::join!(stdout_handle, stderr_handle); + while let Ok(chunk) = output_rx.try_recv() { + output_buffer.push_str(&chunk); + } + + // Build result from complete output + let mut result = String::new(); + if !output_buffer.is_empty() { + result.push_str(output_buffer.trim_end()); + result.push('\n'); + } + result.push_str(&format!("Exit code: {}", exit_code)); + break result; + } + _ = &mut cancel_rx => { + let _ = child.kill().await; + // Wait for readers to drain pipe data (with timeout) + tokio::select! { + _ = async { let _ = tokio::join!(stdout_handle, stderr_handle); } => {} + _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => {} + } + while let Ok(chunk) = output_rx.try_recv() { + output_buffer.push_str(&chunk); + } + let _ = child.wait().await; // reap zombie + break "āš ļø User cancelled the command".to_string(); + } + } +} +``` + +### Why JoinHandle and not alternatives + +| Approach | Problem | +|----------|---------| +| `try_recv` drain only (no join) | Data might still be in pipe buffer, not yet read by spawned task | +| `tokio::sync::Barrier` | `Barrier::wait()` is **not cancel-safe** — cancelling the future mid-wait (e.g. via `tokio::select!` timeout) corrupts internal state and may hang the reader tasks. JoinHandle has no such issue. | +| `oneshot::channel` per reader | Works, but JoinHandle is the idiomatic tokio primitive for this — no extra channels or synchronization needed | +| Single-threaded `.output()` | Loses streaming UX entirely | + +### Why JoinHandle works cleanly with borrows + +`JoinHandle::await` takes `&mut Self` (via `Future::poll`). It does NOT borrow `child`, `output_rx`, or any shared state — just the handle itself. This means: +- No borrow conflict with `child.wait()` (branch 2 future) — `JoinHandle` await is in the branch **body**, after the future is dropped +- No borrow conflict with cancel branch (branch 3) — `JoinHandle` values are independent of `child` +- No borrow conflict with `output_rx.try_recv()` in the same body — sequential code, borrows are non-overlapping + +### Edge cases + +| Case | Handling | +|------|----------| +| Command exits before readers process output | `tokio::join!` blocks until readers finish draining pipe data (which happens promptly after EOF) | +| Readers finish before child exits | `stdout_handle` / `stderr_handle` already resolved; `join!` returns immediately | +| Cancel pressed | Kill child, await handles with 250ms timeout, then drain channel | +| Reader task panics | `JoinHandle::await` returns `Err(JoinError)` — `let _ =` discards it; `output_buffer` contains whatever was captured before the panic | +| Command with no output | `output_buffer` empty, result is `"Exit code: 0"` (same as before) | +| Very large output (100K+ chars) | Already capped by `MAX_BUFFER_CHARS`; `join!` waits for all pipes to drain, so final buffer is complete | +| Outer 300s timeout | Unchanged — the parent agent loop timeout still wraps `execute_tool` | +| stderr-only output | Stderr reader hits EOF, exits; joined alongside stdout reader; main task proceeds with all stderr content in buffer | + +## File Changes + +| File | Change | +|------|--------| +| `src/agent.rs` | Capture `JoinHandle`s from `tokio::spawn`; add `tokio::join!` in drain phase after `child.wait()`; add join+drain in cancel branch | +| `src/agent.rs` imports | Add `tokio::time::sleep`, no new struct imports needed (`JoinHandle` is already the return type of `tokio::spawn`) | + +No new dependencies. No new struct imports. + +## Verification + +1. **Manual test on Telegram**: Run `ls -la /` (short command, large output) — verify LLM sees file listing + exit code. Run `cargo build` (long command) — verify streaming display + final output. +2. **Race condition torture**: Run 100 iterations of a fast command (`echo hello`) in quick succession via the Telegram `/supervise` command. Verify LLM sees `"hello"` alongside `"Exit code: 0"` every time. +3. **`cargo clippy -- -D warnings`** — no new warnings. +4. **`cargo test`** — all existing tests pass. diff --git a/src/agent.rs b/src/agent.rs index 05940b8..d9afe97 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2488,8 +2488,8 @@ impl Agent { _user_id: &str, chat_id: ChatId, ) -> String { - use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; use std::time::Instant; + use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; use tokio::io::AsyncReadExt; let command = match arguments["command"].as_str() { @@ -2515,9 +2515,10 @@ impl Agent { let escaped_cmd = crate::utils::telegram_markdown::escape_text(command); // Send initial message with cancel button - let keyboard = InlineKeyboardMarkup::new([[ - InlineKeyboardButton::callback("Cancel", format!("cancel_cmd:{}", cmd_id)), - ]]); + let keyboard = InlineKeyboardMarkup::new([[InlineKeyboardButton::callback( + "Cancel", + format!("cancel_cmd:{}", cmd_id), + )]]); let msg = match self .bot @@ -2554,7 +2555,7 @@ impl Agent { let mut child_stdout = child.stdout.take(); let mut child_stderr = child.stderr.take(); - tokio::spawn(async move { + let stdout_handle = tokio::spawn(async move { let mut buf = vec![0u8; 4096]; while let Some(stream) = child_stdout.as_mut() { match stream.read(&mut buf).await { @@ -2572,7 +2573,7 @@ impl Agent { } }); - tokio::spawn(async move { + let stderr_handle = tokio::spawn(async move { let mut buf = vec![0u8; 4096]; while let Some(stream) = child_stderr.as_mut() { match stream.read(&mut buf).await { @@ -2590,15 +2591,24 @@ impl Agent { } }); - // Main select loop + // Cap accumulated output to prevent unbounded memory growth + const MAX_BUFFER_CHARS: usize = 100_000; + let mut output_buffer = String::new(); let mut last_edit = Instant::now(); + + // Main select loop — only determines exit reason + let mut exit_code: Option = None; + let mut cancelled = false; tokio::pin!(cancel_rx); - let result = loop { + loop { tokio::select! { Some(chunk) = output_rx.recv() => { output_buffer.push_str(&chunk); + if output_buffer.chars().count() > MAX_BUFFER_CHARS { + output_buffer = crate::utils::strings::truncate_tail(&output_buffer, MAX_BUFFER_CHARS); + } if last_edit.elapsed() >= std::time::Duration::from_millis(500) { let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); let body = format!("```\n{}\n```", capped); @@ -2608,8 +2618,8 @@ impl Agent { } } status = child.wait() => { - let exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); - let (icon, label) = if exit_code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + exit_code = Some(status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)); + let (icon, label) = if exit_code == Some(0) { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; let body = if output_buffer.is_empty() { "Command completed with no output.".to_string() } else { @@ -2618,16 +2628,10 @@ impl Agent { }; let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); - - let mut result = String::new(); - if !output_buffer.is_empty() { - result.push_str(output_buffer.trim_end()); - result.push('\n'); - } - result.push_str(&format!("Exit code: {}", exit_code)); - break result; + break; } _ = &mut cancel_rx => { + cancelled = true; let _ = child.kill().await; let _ = child.wait().await; let body = if output_buffer.is_empty() { @@ -2642,9 +2646,62 @@ impl Agent { format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) }; self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); - break "āš ļø User cancelled the command".to_string(); + break; } } + } + + // Post-loop: wait for readers to finish, drain remaining output + // Timeout is a safety net — readers finish promptly after pipe EOF. + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + async { let _ = tokio::join!(stdout_handle, stderr_handle); }, + ).await; + while let Ok(chunk) = output_rx.try_recv() { + output_buffer.push_str(&chunk); + } + // Re-cap buffer after drain (defensive — drain may push past limit) + if output_buffer.chars().count() > MAX_BUFFER_CHARS { + output_buffer = crate::utils::strings::truncate_tail(&output_buffer, MAX_BUFFER_CHARS); + } + + // Build the final result with complete output + let result = if cancelled { + // Update display with final (complete) output + let body = if output_buffer.is_empty() { + String::new() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = if body.is_empty() { + format!("āŒ Cancelled: `{}`", escaped_cmd) + } else { + format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) + }; + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + "āš ļø User cancelled the command".to_string() + } else if let Some(code) = exit_code { + // Update display with final (complete) output + let (icon, label) = if code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + let body = if output_buffer.is_empty() { + "Command completed with no output.".to_string() + } else { + let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); + format!("```\n{}\n```", capped) + }; + let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); + self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + + let mut result = String::new(); + if !output_buffer.is_empty() { + result.push_str(output_buffer.trim_end()); + result.push('\n'); + } + result.push_str(&format!("Exit code: {}", code)); + result + } else { + "Error: command exited with unknown state".to_string() }; // Cleanup registry @@ -3620,7 +3677,8 @@ impl Agent { } } "execute_command" => { - self.execute_command_interactive(arguments, user_id, chat_id).await + self.execute_command_interactive(arguments, user_id, chat_id) + .await } _ if self.mcp.is_mcp_tool(name) => match self.mcp.call_tool(name, arguments).await { Ok(result) => result, diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 1292da6..9dc6a8c 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -219,11 +219,7 @@ pub async fn run( /// Send a markdown string as entity-formatted message(s), splitting if needed. /// Returns Ok if at least one message was sent successfully. -async fn send_markdown_message( - bot: &Bot, - chat_id: ChatId, - markdown: &str, -) -> ResponseResult<()> { +async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { const MAX_UTF16: usize = 4090; let (plain_text, entities) = markdown_to_entities(markdown); let chunks = split_entities(&plain_text, &entities, MAX_UTF16); @@ -639,13 +635,38 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe let mut builtin = Vec::new(); let mut mcp_servers: BTreeMap> = BTreeMap::new(); + // Known MCP server names (same list as friendly_tool_name in tool_notifier.rs) + // Sorted by length descending to match longest first (handles server names with underscores) + const KNOWN_MCP_SERVERS: [&str; 14] = [ + "google-workspace", + "google_workspace", + "brave-search", + "brave_search", + "filesystem", + "puppeteer", + "github", + "sqlite", + "threads", + "notion", + "fetch", + "git", + "context7", + "qdrant", + ]; + for tool in &all_tools { if let Some(rest) = tool.function.name.strip_prefix("mcp_") { - if let Some(sep) = rest.find('_') { - let server = rest[..sep].to_string(); - mcp_servers.entry(server).or_default().push(tool); - } else { - builtin.push(tool); + let server = KNOWN_MCP_SERVERS + .iter() + .find(|server| rest.starts_with(&format!("{}_", server))) + .map(|s| s.to_string()) + .or_else(|| { + // Unknown server: split on first underscore + rest.find('_').map(|sep| rest[..sep].to_string()) + }); + match server { + Some(s) => mcp_servers.entry(s).or_default().push(tool), + None => builtin.push(tool), } } else { builtin.push(tool); @@ -654,14 +675,20 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe let mut tool_list = format!("**Built-in tools** ({}):\n", builtin.len()); for tool in &builtin { - tool_list.push_str(&format!(" - `{}`: {}\n", tool.function.name, tool.function.description)); + tool_list.push_str(&format!( + " - `{}`: {}\n", + tool.function.name, tool.function.description + )); } tool_list.push('\n'); for (server, tools) in &mcp_servers { tool_list.push_str(&format!("**MCP: {}** ({}):\n", server, tools.len())); for tool in tools { - tool_list.push_str(&format!(" - `{}`: {}\n", tool.function.name, tool.function.description)); + tool_list.push_str(&format!( + " - `{}`: {}\n", + tool.function.name, tool.function.description + )); } tool_list.push('\n'); } @@ -979,7 +1006,10 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe }; // Streaming: set up token channel for progressive message display - const TELEGRAM_STREAM_SPLIT: usize = 3800; + // Split threshold: use UTF-16 code units (Telegram's limit is 4096). + // Streaming uses a conservative 3500 to leave room for mid-split growth. + // The final flush uses markdown_to_entities + split_entities with MAX_UTF16=4090. + const TELEGRAM_STREAM_SPLIT_UTF16: usize = 3500; let (stream_token_tx, stream_token_rx) = tokio::sync::mpsc::channel::(128); @@ -997,13 +1027,15 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe let mut split_contents: Vec = Vec::new(); let mut last_action = Instant::now(); let mut rx = stream_token_rx; + let mut buffer_utf16_len: usize = 0; while let Some(token) = rx.recv().await { buffer.push_str(&token); + buffer_utf16_len += token.encode_utf16().count(); // When buffer exceeds split threshold, finalize the current message // and reset so subsequent tokens start a new message. - if buffer.len() > TELEGRAM_STREAM_SPLIT { + if buffer_utf16_len > TELEGRAM_STREAM_SPLIT_UTF16 { let snapshot = buffer.clone(); if let Some(msg_id) = current_msg_id { if let Err(e) = stream_bot @@ -1017,6 +1049,7 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe } split_contents.push(snapshot); buffer.clear(); + buffer_utf16_len = 0; current_msg_id = None; last_action = Instant::now(); continue; diff --git a/src/platform/tool_notifier.rs b/src/platform/tool_notifier.rs index 3827706..b83233c 100644 --- a/src/platform/tool_notifier.rs +++ b/src/platform/tool_notifier.rs @@ -628,7 +628,10 @@ mod tests { let j = r#"{"command":"rm -rf /"}"#; let out = format_args_preview(j); assert!(!out.is_empty(), "command should be visible: {out}"); - assert!(out.contains("rm -rf /"), "command content must be in output: {out}"); + assert!( + out.contains("rm -rf /"), + "command content must be in output: {out}" + ); } #[test] diff --git a/src/tools.rs b/src/tools.rs index 9e021b7..9c6ceca 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -457,37 +457,9 @@ pub async fn execute_builtin_tool( Ok(entries.join("\n")) } } - "execute_command" => { - let command = arguments["command"] - .as_str() - .context("Missing 'command' argument")?; - - info!("Executing command in sandbox: {}", command); - - let output = tokio::process::Command::new("sh") - .arg("-c") - .arg(command) - .current_dir(sandbox_dir) - .output() - .await - .with_context(|| format!("Failed to execute command: {}", command))?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - let mut result = String::new(); - if !stdout.is_empty() { - result.push_str(&format!("STDOUT:\n{}\n", stdout)); - } - if !stderr.is_empty() { - result.push_str(&format!("STDERR:\n{}\n", stderr)); - } - result.push_str(&format!( - "Exit code: {}", - output.status.code().unwrap_or(-1) - )); - Ok(result) - } + // NOTE: execute_command is handled by Agent::execute_command_interactive + // (src/agent.rs) before reaching this fallback. The tool definition lives + // here so the LLM knows the tool exists. "plan_create" => { let title = arguments["title"] .as_str() diff --git a/src/utils/markdown_entities.rs b/src/utils/markdown_entities.rs index 23857fc..e914cf4 100644 --- a/src/utils/markdown_entities.rs +++ b/src/utils/markdown_entities.rs @@ -53,10 +53,8 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { // Track UTF-16 length incrementally to avoid O(n²) rescanning let mut plain_utf16_len = 0usize; - // State for blockquote and table rendering + // State for blockquote rendering let mut in_blockquote = false; - let mut in_table_cell = false; - let mut table_cell_texts: Vec = Vec::new(); for event in parser { match event { @@ -70,10 +68,6 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { .join("\n"); plain.push_str("ed); plain_utf16_len += quoted.encode_utf16().count(); - } else if in_table_cell { - table_cell_texts.push(text.to_string()); - plain.push_str(&text); - plain_utf16_len += text.encode_utf16().count(); } else { plain.push_str(&text); plain_utf16_len += text.encode_utf16().count(); @@ -134,14 +128,10 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { in_blockquote = true; } Tag::Table(_) => { - table_cell_texts.clear(); - } - Tag::TableHead | Tag::TableRow => { - table_cell_texts.clear(); - } - Tag::TableCell => { - in_table_cell = true; + // Table alignment metadata is discarded — rendered as plain text } + Tag::TableHead | Tag::TableRow => {} + Tag::TableCell => {} // Paragraph, list, etc. — no entity emitted on start. _ => {} }, @@ -232,7 +222,6 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { } } TagEnd::TableCell => { - in_table_cell = false; plain.push_str(" | "); plain_utf16_len += 3; } diff --git a/src/utils/strings.rs b/src/utils/strings.rs index da6aaf5..09dd185 100644 --- a/src/utils/strings.rs +++ b/src/utils/strings.rs @@ -7,7 +7,10 @@ pub fn truncate_tail(s: &str, max_chars: usize) -> String { return s.to_string(); } let prefix = "...(truncated)\n"; - let tail: String = s.chars().skip(char_count.saturating_sub(max_chars)).collect(); + let tail: String = s + .chars() + .skip(char_count.saturating_sub(max_chars)) + .collect(); format!("{}{}", prefix, tail) } From 8ddec81cffcc045d5904cd48c0cafb76a97f2730 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 11:10:34 +0800 Subject: [PATCH 05/69] =?UTF-8?q?refactor:=20address=20code=20quality=20re?= =?UTF-8?q?view=20=E2=80=94=20remove=20drain=20timeout,=20deduplicate=20bo?= =?UTF-8?q?dy=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes per code-quality-reviewer PASS_WITH_CONCERNS: - Remove 250ms timeout around post-loop tokio::join!(handles) — any timeout reintroduces a race window where try_recv could miss late chunks. Readers finish within microseconds after pipe EOF, so no timeout is needed. - Remove pre-drain edit_message_text calls in select branches — only update display once after drain with complete output (avoids flicker and duplicate API calls). - Extract format_body() helper to deduplicate the output-formatting pattern that appeared 4 times. - Replace unreachable else with unreachable!(). --- src/agent.rs | 68 ++++++++++++++++------------------------------------ 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index d9afe97..179e2b9 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2619,44 +2619,22 @@ impl Agent { } status = child.wait() => { exit_code = Some(status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)); - let (icon, label) = if exit_code == Some(0) { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; - let body = if output_buffer.is_empty() { - "Command completed with no output.".to_string() - } else { - let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); - format!("```\n{}\n```", capped) - }; - let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); - self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); break; } _ = &mut cancel_rx => { cancelled = true; let _ = child.kill().await; let _ = child.wait().await; - let body = if output_buffer.is_empty() { - String::new() - } else { - let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); - format!("```\n{}\n```", capped) - }; - let text = if body.is_empty() { - format!("āŒ Cancelled: `{}`", escaped_cmd) - } else { - format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) - }; - self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); break; } } } - // Post-loop: wait for readers to finish, drain remaining output - // Timeout is a safety net — readers finish promptly after pipe EOF. - let _ = tokio::time::timeout( - std::time::Duration::from_millis(250), - async { let _ = tokio::join!(stdout_handle, stderr_handle); }, - ).await; + // Post-loop: wait for readers to finish, drain remaining output. + // Readers finish promptly after pipe EOF (child has exited), so this + // join resolves within microseconds. No timeout needed — it would + // reintroduce a race window where try_recv could miss late chunks. + let _ = tokio::join!(stdout_handle, stderr_handle); while let Ok(chunk) = output_rx.try_recv() { output_buffer.push_str(&chunk); } @@ -2665,32 +2643,28 @@ impl Agent { output_buffer = crate::utils::strings::truncate_tail(&output_buffer, MAX_BUFFER_CHARS); } + fn format_body(buf: &str, no_output_msg: &str) -> Option { + if buf.is_empty() { + if no_output_msg.is_empty() { None } else { Some(no_output_msg.to_owned()) } + } else { + let capped = crate::utils::strings::truncate_tail(buf, 3500); + Some(format!("```\n{}\n```", capped)) + } + } + // Build the final result with complete output let result = if cancelled { - // Update display with final (complete) output - let body = if output_buffer.is_empty() { - String::new() - } else { - let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); - format!("```\n{}\n```", capped) - }; - let text = if body.is_empty() { - format!("āŒ Cancelled: `{}`", escaped_cmd) - } else { - format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, body) + let body = format_body(&output_buffer, ""); + let text = match body { + None => format!("āŒ Cancelled: `{}`", escaped_cmd), + Some(b) => format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, b), }; self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); "āš ļø User cancelled the command".to_string() } else if let Some(code) = exit_code { - // Update display with final (complete) output let (icon, label) = if code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; - let body = if output_buffer.is_empty() { - "Command completed with no output.".to_string() - } else { - let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); - format!("```\n{}\n```", capped) - }; - let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body); + let body = format_body(&output_buffer, "Command completed with no output."); + let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body.unwrap_or_default()); self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); let mut result = String::new(); @@ -2701,7 +2675,7 @@ impl Agent { result.push_str(&format!("Exit code: {}", code)); result } else { - "Error: command exited with unknown state".to_string() + unreachable!("select loop always sets either cancelled or exit_code") }; // Cleanup registry From 21ef6b3d7ef400b0c84870a5424d50fba749981f Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 11:57:03 +0800 Subject: [PATCH 06/69] fix: cancel button now reliably kills command and shows toast notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes: - Double answer_callback_query: first answer (no text, unconditional) swallowed the 'ā›” Command cancelled' toast. Telegram only honors the first answer per callback_id. If the first answer failed (used ?), the cancel handler never ran. - child.kill() only killed sh, not descendants of sh -c. - edit_message_text failures were silently swallowed by .ok(). Fixes: 1. telegram.rs: Remove unconditional answer at fn entry. Each branch answers exactly once with appropriate text. Cancel branch now shows the toast. Non-cancel branches use silent answers. 2. agent.rs: Add .process_group(0) to spawn so sh -c gets its own process group. On cancel, call nix::sys::signal::killpg() to send SIGKILL to the entire process group, killing both sh and all descendant processes. 3. agent.rs: Replace .ok() with if-let Err + warn! on all three edit_message_text calls so API failures are logged. 4. tests/command_cancel.rs: Add integration tests for process-group killpg and oneshot-cancel-in-select-loop patterns. --- Cargo.lock | 1 + Cargo.toml | 3 ++ src/agent.rs | 20 +++++++-- src/agent_prompt.rs | 1 + src/platform/telegram.rs | 9 +++- tests/command_cancel.rs | 93 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/command_cancel.rs diff --git a/Cargo.lock b/Cargo.lock index 689eb11..9a23ec2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,6 +2674,7 @@ dependencies = [ "image", "include_dir", "infer", + "nix", "ocrs", "pdf-extract", "pulldown-cmark", diff --git a/Cargo.toml b/Cargo.toml index 36750dc..3f2bb4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ regex = "1" # OS home-directory resolution for the persistent home dir (~/.rustfox) dirs = "5" +# Unix process group management (kill child process trees) +nix = { version = "0.31", features = ["signal"] } + [lib] name = "rustfox" path = "src/lib.rs" diff --git a/src/agent.rs b/src/agent.rs index 179e2b9..996906a 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2506,6 +2506,7 @@ impl Agent { .current_dir(sandbox_dir) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) + .process_group(0) .spawn() { Ok(c) => c, @@ -2613,7 +2614,9 @@ impl Agent { let capped = crate::utils::strings::truncate_tail(&output_buffer, 3500); let body = format!("```\n{}\n```", capped); let text = format!("šŸ’» Running: `{}`\n\n{}", escaped_cmd, body); - self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + if let Err(e) = self.bot.edit_message_text(chat_id, msg.id, &text).await { + warn!("Failed to update running message: {e}"); + } last_edit = Instant::now(); } } @@ -2623,6 +2626,13 @@ impl Agent { } _ = &mut cancel_rx => { cancelled = true; + // Kill child + its process group so sh -c grandchildren are stopped + if let Some(pid) = child.id() { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ); + } let _ = child.kill().await; let _ = child.wait().await; break; @@ -2659,13 +2669,17 @@ impl Agent { None => format!("āŒ Cancelled: `{}`", escaped_cmd), Some(b) => format!("āŒ Cancelled: `{}`\n\n{}", escaped_cmd, b), }; - self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + if let Err(e) = self.bot.edit_message_text(chat_id, msg.id, &text).await { + warn!("Failed to update cancelled message: {e}"); + } "āš ļø User cancelled the command".to_string() } else if let Some(code) = exit_code { let (icon, label) = if code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; let body = format_body(&output_buffer, "Command completed with no output."); let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body.unwrap_or_default()); - self.bot.edit_message_text(chat_id, msg.id, &text).await.ok(); + if let Err(e) = self.bot.edit_message_text(chat_id, msg.id, &text).await { + warn!("Failed to update completed message: {e}"); + } let mut result = String::new(); if !output_buffer.is_empty() { diff --git a/src/agent_prompt.rs b/src/agent_prompt.rs index 1bc6209..dab4f58 100644 --- a/src/agent_prompt.rs +++ b/src/agent_prompt.rs @@ -808,6 +808,7 @@ mod tests { } #[test] + #[allow(clippy::vec_init_then_push)] fn find_tool_groups_detects_consecutive_tool_calls() { let mut messages = Vec::new(); messages.push(ChatMessage { diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 9dc6a8c..b225772 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -1216,9 +1216,13 @@ async fn handle_model_callback( }; let msg = q.regular_message().cloned(); - bot.answer_callback_query(callback_id.clone()).await?; + // Remove the old unconditional answer_callback_query that had no text. + // Each branch below now answers with the appropriate text (or silently) + // exactly once. A second answer for the same callback_id is ignored by + // Telegram, which previously swallowed the "ā›” Command cancelled" toast. if let Some(provider_name) = data.strip_prefix("provider_select:") { + bot.answer_callback_query(callback_id.clone()).await.ok(); if let Some(provider) = agent.registry.get_provider(provider_name) { if let Some(ref m) = msg { let user_id = q.from.id.0.to_string(); @@ -1237,6 +1241,7 @@ async fn handle_model_callback( } if data == "model_search_prompt" { + bot.answer_callback_query(callback_id.clone()).await.ok(); if let Some(m) = msg { let prompt = "Send me a model name or ID to search for. Examples: claude, kimi, gpt, or a full model ID like openrouter/o3-mini."; bot.edit_message_text(m.chat.id, m.id, prompt).await?; @@ -1257,6 +1262,7 @@ async fn handle_model_callback( } if data == "model_select:cancel" { + bot.answer_callback_query(callback_id.clone()).await.ok(); if let Some(m) = msg { bot.edit_message_text(m.chat.id, m.id, "āŒ Model selection cancelled.") .await?; @@ -1299,6 +1305,7 @@ async fn handle_model_callback( } } + bot.answer_callback_query(callback_id).await.ok(); Ok(()) } diff --git a/tests/command_cancel.rs b/tests/command_cancel.rs new file mode 100644 index 0000000..e3313cb --- /dev/null +++ b/tests/command_cancel.rs @@ -0,0 +1,93 @@ +use std::time::Duration; +use tokio::time::sleep; + +/// Verify that `process_group(0)` isolates a spawned `sh -c` into its +/// own process group AND that `killpg` can kill the entire group. +#[tokio::test] +async fn test_process_group_killpg_terminates_tree() { + let mut child = tokio::process::Command::new("sh") + .arg("-c") + .arg("sleep 120") + .process_group(0) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn sh -c sleep 120"); + + let pid = child.id().expect("child should have a PID"); + + // The child should have its own PGID equal to its PID + // (process_group(0) calls setpgid(0, 0) in the child) + let child_pgid = + nix::unistd::getpgid(Some(nix::unistd::Pid::from_raw(pid as i32))) + .expect("child should have a process group"); + assert_eq!( + child_pgid, + nix::unistd::Pid::from_raw(pid as i32), + "PGID should match PID (new process group)" + ); + + // Kill the entire process group + nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ) + .expect("killpg should succeed"); + + // Reap the child — wait() should return quickly with a signal status + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("child.wait() should complete within 5s") + .expect("child.wait() should succeed"); + + assert!( + !status.success(), + "child should have been killed by signal (status={:?})", + status.code() + ); +} + +/// Verify that the oneshot-channel cancel pattern used by the bot actually +/// breaks out of a select! loop even when the output channel is active. +#[tokio::test] +async fn test_oneshot_cancel_breaks_select_loop() { + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::(16); + + let select_task = tokio::spawn(async move { + tokio::pin!(cancel_rx); + + let feeder = tokio::spawn(async move { + for i in 0..10 { + let _ = output_tx.send(format!("chunk_{i}")).await; + sleep(Duration::from_millis(1)).await; + } + }); + + let result = loop { + tokio::select! { + Some(_chunk) = output_rx.recv() => { + sleep(Duration::from_millis(5)).await; + } + _ = &mut cancel_rx => { + break true; + } + } + }; + + feeder.await.ok(); + result + }); + + // Let the loop start before sending the signal + sleep(Duration::from_millis(30)).await; + + cancel_tx.send(()).expect("cancel_tx.send should succeed"); + + let result: bool = tokio::time::timeout(Duration::from_secs(10), select_task) + .await + .expect("select loop should exit within 10 seconds") + .expect("select task should not panic"); + + assert!(result, "cancelled should be true after cancel signal"); +} From e77d9064037e54c4f9a646af504a13a00ec546c1 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 15:48:58 +0800 Subject: [PATCH 07/69] chore: add tokio-util dependency for CancellationToken --- Cargo.lock | 1 + Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 9a23ec2..2bf5ab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2693,6 +2693,7 @@ dependencies = [ "tempfile", "tokio", "tokio-cron-scheduler", + "tokio-util", "toml", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 3f2bb4e..6a03306 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] # Async runtime tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7" } # Telegram bot framework teloxide = { version = "0.17", features = ["macros"] } From 9c393fb60d7b7015462615586b2458842563bb27 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 15:49:53 +0800 Subject: [PATCH 08/69] feat(agent): add cancel_token_registry and pending_injections fields --- src/agent.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index 996906a..70c02ef 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use teloxide::payloads::{SendDocumentSetters, SendMessageSetters}; @@ -73,6 +74,12 @@ pub struct Agent { pub current_model: tokio::sync::RwLock, pub config_path: PathBuf, pub running_commands: Arc>>, + /// Per-user CancellationTokens for /stop — created at process_message entry, + /// removed on exit. Checked at each iteration boundary. + pub cancel_token_registry: Arc>>, + /// Per-user pending injection messages (Steer/Inject), max 10 per user. + /// When a non-command message arrives while processing is active, it's queued here. + pub pending_injections: Arc>>>, } /// A task parsed from the spawn_agents tool arguments, after validation. @@ -150,6 +157,8 @@ impl Agent { current_model: tokio::sync::RwLock::new(initial_model), config_path, running_commands: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + cancel_token_registry: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + pending_injections: Arc::new(tokio::sync::Mutex::new(HashMap::new())), } } From 9a5588028d509bba2bb960f0fbafb2268b931645 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 15:50:15 +0800 Subject: [PATCH 09/69] feat(agent): add cancel/inject queue public methods --- src/agent.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index 70c02ef..3b2855c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -408,6 +408,63 @@ impl Agent { Ok(()) } + /// Register a CancellationToken for the given user_id before processing starts. + /// Called at the start of process_message. Returns the token for cancellation checks. + pub async fn register_cancel_token(&self, user_id: &str) -> CancellationToken { + let token = CancellationToken::new(); + self.cancel_token_registry + .lock() + .await + .insert(user_id.to_string(), token.clone()); + token + } + + /// Cancel processing for a user. Returns true if there was an active token. + pub async fn cancel_processing(&self, user_id: &str) -> bool { + let mut map = self.cancel_token_registry.lock().await; + if let Some(token) = map.remove(user_id) { + token.cancel(); + true + } else { + false + } + } + + /// Check if a user has active processing. + pub async fn is_processing(&self, user_id: &str) -> bool { + self.cancel_token_registry + .lock() + .await + .contains_key(user_id) + } + + /// Queue an injection message for a user. Returns false if queue is full (max 10). + pub async fn queue_injection(&self, user_id: &str, text: &str) -> bool { + const MAX_INJECTIONS: usize = 10; + let mut map = self.pending_injections.lock().await; + let queue = map.entry(user_id.to_string()).or_default(); + if queue.len() >= MAX_INJECTIONS { + false + } else { + queue.push(text.to_string()); + true + } + } + + /// Drain all pending injection messages for a user. + pub async fn drain_injections(&self, user_id: &str) -> Vec { + let mut map = self.pending_injections.lock().await; + map.remove(user_id).unwrap_or_default() + } + + /// Remove cancel token for a user (called on process_message exit). + pub async fn clear_cancel_token(&self, user_id: &str) { + self.cancel_token_registry + .lock() + .await + .remove(user_id); + } + /// Fetch the context window size for the current model from the /// provider API and cache it. Non-fatal — uses static fallback on /// failure. From e83e11e02f587cf10b790ba80efd95b398df360b Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 15:58:32 +0800 Subject: [PATCH 10/69] feat(agent): add cancellation checks, injection drain, cancel token to subagent loops, ask_parallel --- src/agent.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 3b2855c..e6e9a6e 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -729,19 +729,52 @@ impl Agent { self.soul_updated .store(false, std::sync::atomic::Ordering::Relaxed); + // Register cancel token for /stop support + let cancel_token = self.register_cancel_token(user_id).await; + // Resolve context_window once before the loop (can't .await inside the loop) let context_window = { let model = self.current_model.read().await; self.registry.effective_context_window(&model) }; - for iteration in 0..max_iterations { + 'outer: for iteration in 0..max_iterations { debug!( "Trying iteration {}: messages length: {}", iteration, messages.len() ); + // CHECK: cancelled by /stop? + if cancel_token.is_cancelled() { + info!( + user_id = %user_id, + iteration, + "Processing cancelled by user via /stop" + ); + break; + } + + // CHECK: pending injections from user? + let injections = self.drain_injections(user_id).await; + for text in &injections { + let inject_msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!( + "**[User injected mid-processing]:** {}", + text + ))), + tool_calls: None, + tool_call_id: None, + }; + // Save to persistent memory + self.memory + .save_message(&conversation_id, &inject_msg) + .await + .ok(); + messages.push(inject_msg); + } + // --- Empty response recovery: retry loop --- let mut retry_count = 0u32; let response: ChatMessage; @@ -804,6 +837,12 @@ impl Agent { let base_prompt = prepare_messages_for_llm(&messages, context_window); loop { + // CHECK: cancelled while retrying? + if cancel_token.is_cancelled() { + info!("Cancelled during retry loop — breaking"); + break 'outer; + } + // Clone the base prompt for this retry attempt let mut prompt = base_prompt.clone(); @@ -970,6 +1009,7 @@ impl Agent { error: Some(err_str), end_time: Self::now_iso8601_static(), }); + self.clear_cancel_token(user_id).await; return Err(e); } // recovered_from_413 is true but the compiler can't see this; @@ -1043,6 +1083,7 @@ impl Agent { end_time: Self::now_iso8601_static(), }); + self.clear_cancel_token(user_id).await; return Err(anyhow::anyhow!( "Unable to get a valid response from the AI model after {} attempts. \ Your conversation history has been saved. Please try rephrasing your \ @@ -1406,6 +1447,7 @@ impl Agent { } } + self.clear_cancel_token(user_id).await; return Ok(final_content); } @@ -1425,6 +1467,7 @@ impl Agent { end_time: Self::now_iso8601_static(), }); + self.clear_cancel_token(user_id).await; Ok("I've reached the maximum number of tool call iterations. Please try rephrasing your request.".to_string()) } @@ -2181,7 +2224,7 @@ impl Agent { /// directly with a default sandbox tool whitelist. The system_prompt is augmented /// with ambient system context (timestamp, user model, location) via /// `build_subagent_system_prompt`. - async fn run_subagent( + pub(crate) async fn run_subagent( &self, skill_name: Option<&str>, system_prompt: &str, @@ -2250,6 +2293,7 @@ impl Agent { &model, max_iter, "_ad_hoc_", + None, ) .await; } @@ -2397,12 +2441,14 @@ impl Agent { &resolved_model, max_iter, skill_name, + None, ) .await } /// Shared mini-agentic loop used by both ad-hoc and predefined subagents. /// Runs LLM calls, executes whitelisted tools, and returns the final text response. + #[allow(clippy::too_many_arguments)] async fn run_subagent_loop( &self, messages: &mut Vec, @@ -2411,10 +2457,17 @@ impl Agent { model: &str, max_iter: u32, label: &str, + cancel_token: Option, ) -> String { let empty_response_retry_limit = self.config.empty_response_retry_limit(); for _iteration in 0..max_iter { + // CHECK: cancelled by /stop? + if let Some(ref token) = cancel_token { + if token.is_cancelled() { + return format!("Subagent '{}' cancelled by user.", label); + } + } // --- Empty response recovery: retry loop --- let mut retry_count = 0u32; let response: ChatMessage; @@ -2524,6 +2577,34 @@ impl Agent { ) } + /// Ask a parallel question while the main agent is processing. + /// Spawns an isolated ad-hoc subagent with timestamp/location context. + /// Returns the subagent's answer or an error message. + pub async fn ask_parallel(&self, question: &str) -> Result { + let answer = self + .run_subagent( + None, + "Answer the user's follow-up question concisely and accurately using your knowledge.", + question, + None, + None, + ) + .await; + // Detect error patterns from run_subagent/run_subagent_loop: + // - "Subagent '...' error: ..." (API error) + // - "Subagent '...' reached the maximum number of iterations" (max iterations) + // - "Subagent '...' returned an empty response after ... attempts" (empty response) + if answer.starts_with("Subagent '") + && (answer.contains("error") + || answer.contains("reached the maximum") + || answer.contains("empty response")) + { + Err(anyhow::anyhow!("{}", answer)) + } else { + Ok(answer) + } + } + /// Get the path for a soul file by name. fn soul_file_path(&self, file_name: &str) -> anyhow::Result { let home = self From 54794ed3936a04cf949aaf762ff82ee077977c68 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 16:07:36 +0800 Subject: [PATCH 11/69] fix: distinguish user-cancelled from max-iterations in process_message exit message --- src/agent.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index e6e9a6e..c01e697 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -731,6 +731,7 @@ impl Agent { // Register cancel token for /stop support let cancel_token = self.register_cancel_token(user_id).await; + let mut was_cancelled = false; // Resolve context_window once before the loop (can't .await inside the loop) let context_window = { @@ -747,6 +748,7 @@ impl Agent { // CHECK: cancelled by /stop? if cancel_token.is_cancelled() { + was_cancelled = true; info!( user_id = %user_id, iteration, @@ -839,7 +841,8 @@ impl Agent { loop { // CHECK: cancelled while retrying? if cancel_token.is_cancelled() { - info!("Cancelled during retry loop — breaking"); + was_cancelled = true; + info!("Cancelled during retry loop — breaking out of outer iteration loop"); break 'outer; } @@ -1451,6 +1454,25 @@ impl Agent { return Ok(final_content); } + // Clear cancel token before handling termination + self.clear_cancel_token(user_id).await; + + if was_cancelled { + info!( + user_id = %user_id, + iteration_count, + "Processing cancelled by user — returning partial result" + ); + // --- LangSmith: end chain run (cancelled) --- + self.langsmith.end_run(crate::langsmith::EndRunParams { + id: chain_run_id, + outputs: None, + error: Some("Cancelled by user".to_string()), + end_time: Self::now_iso8601_static(), + }); + return Ok("Processing was cancelled.".to_string()); + } + // Reached max iterations warn!( user_id = %user_id, @@ -1467,7 +1489,6 @@ impl Agent { end_time: Self::now_iso8601_static(), }); - self.clear_cancel_token(user_id).await; Ok("I've reached the maximum number of tool call iterations. Please try rephrasing your request.".to_string()) } From aa3dae2022e9ce0a758cc767507338f274538bdc Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 16:08:57 +0800 Subject: [PATCH 12/69] feat(telegram): add /stop, /btw commands and injection queue for busy users --- src/platform/telegram.rs | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index b225772..b732577 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -92,6 +92,8 @@ pub(crate) fn supported_commands() -> Vec { "Upgrade the bot to the latest version (source or release binary)", ), BotCommand::new("models", "Browse and change the OpenRouter model"), + BotCommand::new("stop", "Cancel the current processing gracefully"), + BotCommand::new("btw", "Ask a parallel question while the bot is busy"), ] } @@ -793,6 +795,45 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe return send_markdown_message(&bot, msg.chat.id, reply).await; } + // Handle /btw for parallel question via isolated subagent + if text == "/btw" || text.starts_with("/btw ") { + let btw_text = text + .strip_prefix("/btw") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("What are you doing?") + .to_string(); + + // Reply immediately, then answer in background + let _ = send_markdown_message( + &bot, + msg.chat.id, + "ā³ **BTW question sent to subagent...**", + ) + .await; + + let agent_clone = agent.clone(); + let bot_clone = bot.clone(); + let chat_id = msg.chat.id; + tokio::spawn(async move { + match agent_clone.ask_parallel(&btw_text).await { + Ok(answer) => { + let _ = send_markdown_message(&bot_clone, chat_id, &answer).await; + } + Err(e) => { + let _ = send_markdown_message( + &bot_clone, + chat_id, + &format!("**BTW error:** {}", e), + ) + .await; + } + } + }); + + return Ok(()); + } + // Combined parse_command dispatch for /self-upgrade and /models. if let Some((cmd, arg)) = parse_command(&text) { match cmd.as_str() { @@ -935,6 +976,51 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe } } + // Handle /stop command + if text == "/stop" { + if agent + .cancel_processing(&user_id.to_string()) + .await + { + return send_markdown_message( + &bot, + msg.chat.id, + "ā¹ **Processing cancelled.** Accumulated state has been saved.", + ) + .await; + } else { + return send_markdown_message( + &bot, + msg.chat.id, + "Nothing is currently processing.", + ) + .await; + } + } + + // CHECK: if user is currently being processed, queue non-command messages as injection + if !text.starts_with('/') && agent.is_processing(&user_id.to_string()).await { + if agent + .queue_injection(&user_id.to_string(), &text) + .await + { + info!("Queued '{}' as injection for user {}", text, user_id); + return send_markdown_message( + &bot, + msg.chat.id, + "šŸ“Ø **Message queued** — will inject into current processing at the next step.", + ) + .await; + } else { + return send_markdown_message( + &bot, + msg.chat.id, + "āš ļø **Injection queue full** (max 10). Please wait for current processing to finish.", + ) + .await; + } + } + // Send "typing" indicator bot.send_chat_action(msg.chat.id, teloxide::types::ChatAction::Typing) .await From 02ab5aa702d71b370f3f02dae6d44372d59b9ede Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 16:15:08 +0800 Subject: [PATCH 13/69] fix: add /stop and /btw to /start help text, cargo fmt --- src/platform/telegram.rs | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index b732577..4ffcd94 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -628,7 +628,9 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe **/verbose** — Toggle tool call progress display\n\ **/queryrewrite** — Toggle query rewriting for memory search\n\ **/selfupgrade** — Upgrade the bot (source or release binary)\n\ - **/models** — Browse and change the model"; + **/models** — Browse and change the model\n\ + **/stop** — Cancel the current processing gracefully\n\ + **/btw** — Ask a parallel question while the bot is busy"; return send_markdown_message(&bot, msg.chat.id, help).await; } @@ -805,12 +807,8 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe .to_string(); // Reply immediately, then answer in background - let _ = send_markdown_message( - &bot, - msg.chat.id, - "ā³ **BTW question sent to subagent...**", - ) - .await; + let _ = send_markdown_message(&bot, msg.chat.id, "ā³ **BTW question sent to subagent...**") + .await; let agent_clone = agent.clone(); let bot_clone = bot.clone(); @@ -978,10 +976,7 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe // Handle /stop command if text == "/stop" { - if agent - .cancel_processing(&user_id.to_string()) - .await - { + if agent.cancel_processing(&user_id.to_string()).await { return send_markdown_message( &bot, msg.chat.id, @@ -989,21 +984,14 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe ) .await; } else { - return send_markdown_message( - &bot, - msg.chat.id, - "Nothing is currently processing.", - ) - .await; + return send_markdown_message(&bot, msg.chat.id, "Nothing is currently processing.") + .await; } } // CHECK: if user is currently being processed, queue non-command messages as injection if !text.starts_with('/') && agent.is_processing(&user_id.to_string()).await { - if agent - .queue_injection(&user_id.to_string(), &text) - .await - { + if agent.queue_injection(&user_id.to_string(), &text).await { info!("Queued '{}' as injection for user {}", text, user_id); return send_markdown_message( &bot, From 2d043c44063c0213b443ea7a396a94c780ab6a89 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Wed, 8 Jul 2026 17:07:08 +0800 Subject: [PATCH 14/69] =?UTF-8?q?chore:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20warn=20on=20injection=20save=20failure,=20cargo=20f?= =?UTF-8?q?mt,=20add=20design=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-08-stop-btw-steer-plan.md | 633 ++++++++++++++++++ .../specs/2026-07-08-stop-btw-steer-design.md | 183 +++++ src/agent.rs | 32 +- tests/command_cancel.rs | 5 +- 4 files changed, 841 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-stop-btw-steer-plan.md create mode 100644 docs/superpowers/specs/2026-07-08-stop-btw-steer-design.md diff --git a/docs/superpowers/plans/2026-07-08-stop-btw-steer-plan.md b/docs/superpowers/plans/2026-07-08-stop-btw-steer-plan.md new file mode 100644 index 0000000..bdfad50 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-stop-btw-steer-plan.md @@ -0,0 +1,633 @@ +# Stop, BTW, and Steer/Inject Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add /stop (cooperative cancel), /btw (parallel subagent question), and steer/inject (user messages injected mid-processing) to RustFox. + +**Architecture:** Three features sharing per-user CancellationToken registry + pending injection queue on Agent. Cancellation checks at iteration boundaries in the agentic loop. Injection drains between tool execution and next LLM call. BTW spawns isolated ad-hoc subagent via `run_subagent`. + +**Tech Stack:** Rust, tokio, tokio-util (CancellationToken), teloxide + +--- + +### Task 1: Add tokio-util dependency + +**Files:** +- Modify: `Cargo.toml:9` + +- [ ] **Step 1: Add tokio-util to dependencies** + +Edit `Cargo.toml`, add right after the `tokio` line (line 8): + +```toml +# Async runtime +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7" } +``` + +- [ ] **Step 2: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 3: Commit** + +``` +git add Cargo.toml Cargo.lock +git commit -m "chore: add tokio-util dependency for CancellationToken" +``` + +--- + +### Task 2: Add cancel token registry + pending injection queue to Agent + +**Files:** +- Modify: `src/agent.rs:3-5` (imports) +- Modify: `src/agent.rs:54-76` (Agent struct) +- Modify: `src/agent.rs:115-154` (Agent::new) + +- [ ] **Step 1: Add import** + +Add `use tokio_util::sync::CancellationToken;` to the imports at the top of `agent.rs` (insert after `use std::sync::{Arc, Weak};`): + +```rust +use std::sync::{Arc, Weak}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; +``` + +- [ ] **Step 2: Add two new fields to `Agent` struct** + +After `running_commands` (search for `pub running_commands:`), add: + +```rust + pub running_commands: Arc>>, + /// Per-user CancellationTokens for /stop — created at process_message entry, + /// removed on exit. Checked at each iteration boundary. + pub cancel_token_registry: Arc>>, + /// Per-user pending injection messages (Steer/Inject), max 10 per user. + /// When a non-command message arrives while processing is active, it's queued here. + pub pending_injections: Arc>>>, +``` + +- [ ] **Step 3: Initialize new fields in `Agent::new`** + +In the `Self { ... }` block (after `running_commands` at line 152), add: + +```rust + running_commands: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + cancel_token_registry: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + pending_injections: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + } + } +``` + +- [ ] **Step 4: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 5: Commit** + +``` +git add src/agent.rs +git commit -m "feat(agent): add cancel_token_registry and pending_injections fields" +``` + +--- + +### Task 3: Add public methods for cancel/inject on Agent + +**Files:** +- Modify: `src/agent.rs` (add methods after `set_model` ends ~line 399) + +- [ ] **Step 1: Add six new methods after set_model** + +After the closing `}` of `set_model` (search for `pub async fn refresh_context_window_cache`), add: + +```rust + /// Register a CancellationToken for the given user_id before processing starts. + /// Called at the start of process_message. Returns the token for cancellation checks. + pub async fn register_cancel_token(&self, user_id: &str) -> CancellationToken { + let token = CancellationToken::new(); + self.cancel_token_registry + .lock() + .await + .insert(user_id.to_string(), token.clone()); + token + } + + /// Cancel processing for a user. Returns true if there was an active token. + pub async fn cancel_processing(&self, user_id: &str) -> bool { + let mut map = self.cancel_token_registry.lock().await; + if let Some(token) = map.remove(user_id) { + token.cancel(); + true + } else { + false + } + } + + /// Check if a user has active processing. + pub async fn is_processing(&self, user_id: &str) -> bool { + self.cancel_token_registry + .lock() + .await + .contains_key(user_id) + } + + /// Queue an injection message for a user. Returns false if queue is full (max 10). + pub async fn queue_injection(&self, user_id: &str, text: &str) -> bool { + const MAX_INJECTIONS: usize = 10; + let mut map = self.pending_injections.lock().await; + let queue = map.entry(user_id.to_string()).or_default(); + if queue.len() >= MAX_INJECTIONS { + false + } else { + queue.push(text.to_string()); + true + } + } + + /// Drain all pending injection messages for a user. + pub async fn drain_injections(&self, user_id: &str) -> Vec { + let mut map = self.pending_injections.lock().await; + map.remove(user_id).unwrap_or_default() + } + + /// Remove cancel token for a user (called on process_message exit). + pub async fn clear_cancel_token(&self, user_id: &str) { + self.cancel_token_registry + .lock() + .await + .remove(user_id); + } +``` + +- [ ] **Step 2: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 3: Commit** + +``` +git add src/agent.rs +git commit -m "feat(agent): add cancel/inject queue public methods" +``` + +--- + +### Task 4: Add cancellation check + injection drain in process_message + +**Files:** +- Modify: `src/agent.rs:660-690` (start of agentic loop, register token) +- Modify: `src/agent.rs:672-680` (top of for-iteration loop) +- Modify: `src/agent.rs:907` (early return — 413 recovery) +- Modify: `src/agent.rs:980` (early return — empty response retry) +- Modify: `src/agent.rs:1343` (success return) +- Modify: `src/agent.rs:1362` (max iterations return) + +- [ ] **Step 1: Register cancel token before the agentic loop** + +After the `soul_updated` reset (line 663), add: + +```rust + // Reset soul-update flag for this session + self.soul_updated + .store(false, std::sync::atomic::Ordering::Relaxed); + + // Register cancel token for /stop support + let cancel_token = self.register_cancel_token(user_id).await; +``` + +- [ ] **Step 2: Add cancellation check + injection drain at the top of the outer loop** + +At the start of `for iteration in 0..max_iterations` (right after line 677), add: + +```rust + for iteration in 0..max_iterations { + debug!( + "Trying iteration {}: messages length: {}", + iteration, + messages.len() + ); + + // CHECK: cancelled by /stop? + if cancel_token.is_cancelled() { + info!( + user_id = %user_id, + iteration, + "Processing cancelled by user via /stop" + ); + break; + } + + // CHECK: pending injections from user? + let injections = self.drain_injections(user_id).await; + for text in &injections { + let inject_msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!( + "**[User injected mid-processing]:** {}", + text + ))), + tool_calls: None, + tool_call_id: None, + }; + // Save to persistent memory + self.memory + .save_message(&conversation_id, &inject_msg) + .await + .ok(); + messages.push(inject_msg); + } +``` + +- [ ] **Step 3: Add cancellation check inside the retry loop** + +The inner retry loop (search for `loop {` after `// Tiers 1-2: sync compaction`) runs multiple LLM call attempts per iteration. Add a check before each LLM call: + +```rust + loop { + // CHECK: cancelled while retrying? + if cancel_token.is_cancelled() { + info!("Cancelled during retry loop — breaking"); + break; + } + + // Clone the base prompt for this retry attempt + let mut prompt = base_prompt.clone(); +``` + +The `break` exits the retry loop, returning to the outer `for iteration` loop which checks `is_cancelled()` again and `break`s out. + +- [ ] **Step 4: Clear cancel token before every return path** + +There are 4 return/error paths in `process_message`. Add `self.clear_cancel_token(user_id).await;` before each: + +**Path 1 — 413 recovery failed (around line 907):** +```rust + self.langsmith.end_run(crate::langsmith::EndRunParams { + id: chain_run_id, + outputs: None, + error: Some(err_str), + end_time: Self::now_iso8601_static(), + }); + self.clear_cancel_token(user_id).await; + return Err(e); +``` + +**Path 2 — empty response retry exhausted (around line 980):** +```rust + self.langsmith.end_run(crate::langsmith::EndRunParams { + id: chain_run_id, + outputs: None, + error: Some(format!( + "Unable to get valid response after {} attempts", + retry_count + 1 + )), + end_time: Self::now_iso8601_static(), + }); + self.clear_cancel_token(user_id).await; + return Err(anyhow::anyhow!(...)); +``` + +**Path 3 — success return (around line 1343):** +```rust + self.clear_cancel_token(user_id).await; + + return Ok(final_content); +``` + +**Path 4 — max iterations (around line 1362):** +```rust + self.clear_cancel_token(user_id).await; + + Ok("I've reached the maximum...") +``` + +- [ ] **Step 5: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 6: Commit** + +``` +git add src/agent.rs +git commit -m "feat(agent): add cancellation checks and injection drain in process_message loop" +``` + +--- + +### Task 5: Add ask_parallel method + make run_subagent pub(crate) + +**Files:** +- Modify: `src/agent.rs:2118` (change run_subagent visibility) +- Modify: `src/agent.rs:2460` (add ask_parallel after run_subagent_loop) + +- [ ] **Step 1: Make run_subagent pub(crate)** + +Change line 2118 from: + +```rust + async fn run_subagent( +``` + +to: + +```rust + pub(crate) async fn run_subagent( +``` + +- [ ] **Step 2: Add CancellationToken parameter to run_subagent_loop** + +Modify `run_subagent_loop` signature (search for `async fn run_subagent_loop`) to accept an optional `CancellationToken`: + +```rust + async fn run_subagent_loop( + &self, + messages: &mut Vec, + subagent_tools: &[ToolDefinition], + allowed_tools: &[String], + model: &str, + max_iter: u32, + label: &str, + cancel_token: Option, + ) -> String { +``` + +Then add a cancellation check at the start of the subagent loop (search for `for _iteration in 0..max_iter`): + +```rust + for _iteration in 0..max_iter { + // CHECK: cancelled by /stop? + if let Some(ref token) = cancel_token { + if token.is_cancelled() { + return format!("Subagent '{}' cancelled by user.", label); + } + } +``` + +Also update the two existing callers of `run_subagent_loop` to pass `None`: +- In `run_subagent` (the ad-hoc path), add `None` as the last argument to `run_subagent_loop`. +- In `run_subagent` (the predefined agent path), also add `None`. + +- [ ] **Step 3: Add ask_parallel public method** + +After the `run_subagent_loop` method (which ends ~line 2459), add: + +```rust + /// Ask a parallel question while the main agent is processing. + /// Spawns an isolated ad-hoc subagent with timestamp/location context. + /// Returns the subagent's answer or an error message. + pub async fn ask_parallel(&self, question: &str) -> Result { + let answer = self + .run_subagent( + None, + "Answer the user's follow-up question concisely and accurately using your knowledge.", + question, + None, + None, + ) + .await; + // Detect error patterns from run_subagent/run_subagent_loop: + // - "Subagent '...' error: ..." (API error) + // - "Subagent '...' reached the maximum number of iterations" (max iterations) + // - "Subagent '...' returned an empty response after ... attempts" (empty response) + if answer.starts_with("Subagent '") && (answer.contains("error") || answer.contains("reached the maximum") || answer.contains("empty response")) + { + Err(anyhow::anyhow!("{}", answer)) + } else { + Ok(answer) + } + } +``` + +- [ ] **Step 4: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 5: Commit** + +``` +git add src/agent.rs +git commit -m "feat(agent): add cancel token to subagent loops, ask_parallel for /btw" +``` + +--- + +### Task 6: Add /stop command handler in Telegram handler + +**Files:** +- Modify: `src/platform/telegram.rs` (supported_commands, handle_message) + +- [ ] **Step 1: Register /stop and /btw in supported_commands** + +Search for `pub(crate) fn supported_commands`, then add after the `BotCommand::new("models", ...)` line: + +```rust + BotCommand::new("models", "Browse and change the OpenRouter model"), + BotCommand::new("stop", "Cancel the current processing gracefully"), + BotCommand::new("btw", "Ask a parallel question while the bot is busy"), + ] +} +``` + +- [ ] **Step 2: Add /stop command handler** + +In `handle_message`, after the parse_command dispatch block and before the line that says `bot.send_chat_action` (search for `ChatAction::Typing`), add: + +```rust + // Handle /stop command + if text == "/stop" { + if agent + .cancel_processing(&user_id.to_string()) + .await + { + return send_markdown_message( + &bot, + msg.chat.id, + "ā¹ **Processing cancelled.** Accumulated state has been saved.", + ) + .await; + } else { + return send_markdown_message( + &bot, + msg.chat.id, + "Nothing is currently processing.", + ) + .await; + } + } +``` + +- [ ] **Step 3: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 4: Commit** + +``` +git add src/platform/telegram.rs +git commit -m "feat(telegram): add /stop command handler" +``` + +--- + +### Task 7: Add user-busy detection + inject queue for non-command messages + +**Files:** +- Modify: `src/platform/telegram.rs` (handle_message) + +- [ ] **Step 1: Add user-busy check before process_message** + +In `handle_message`, after the /stop handler and before the line with `bot.send_chat_action(msg.chat.id, ...)`, add: + +```rust + // CHECK: if user is currently being processed, queue non-command messages as injection + if !text.starts_with('/') && agent.is_processing(&user_id.to_string()).await { + if agent + .queue_injection(&user_id.to_string(), &text) + .await + { + info!("Queued '{}' as injection for user {}", text, user_id); + return send_markdown_message( + &bot, + msg.chat.id, + "šŸ“Ø **Message queued** — will inject into current processing at the next step.", + ) + .await; + } else { + return send_markdown_message( + &bot, + msg.chat.id, + "āš ļø **Injection queue full** (max 10). Please wait for current processing to finish.", + ) + .await; + } + } +``` + +- [ ] **Step 2: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 3: Commit** + +``` +git add src/platform/telegram.rs +git commit -m "feat(telegram): queue non-command messages as injections when user is busy" +``` + +--- + +### Task 8: Add /btw command handler + +**Files:** +- Modify: `src/platform/telegram.rs` (handle_message) + +- [ ] **Step 1: Add /btw command handler before the parse_command block** + +Add before the line with `if let Some((cmd, arg)) = parse_command(&text)` (search for `parse_command`), after the `/query-rewrite` handler: + +```rust + // Handle /btw for parallel question via isolated subagent + if text == "/btw" || text.starts_with("/btw ") { + let btw_text = text + .strip_prefix("/btw") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("What are you doing?") + .to_string(); + + // Reply immediately, then answer in background + let _ = send_markdown_message( + &bot, + msg.chat.id, + "ā³ **BTW question sent to subagent...**", + ) + .await; + + let agent_clone = agent.clone(); + let bot_clone = bot.clone(); + let chat_id = msg.chat.id; + tokio::spawn(async move { + match agent_clone.ask_parallel(&btw_text).await { + Ok(answer) => { + let _ = send_markdown_message(&bot_clone, chat_id, &answer).await; + } + Err(e) => { + let _ = send_markdown_message( + &bot_clone, + chat_id, + &format!("**BTW error:** {}", e), + ) + .await; + } + } + }); + + return Ok(()); + } +``` + +- [ ] **Step 2: Verify cargo check passes** + +Run: `cargo check` +Expected: Success + +- [ ] **Step 3: Commit** + +``` +git add src/platform/telegram.rs +git commit -m "feat(telegram): add /btw command for parallel subagent questions" +``` + +--- + +### Design Note: Injection Queue Overflow Behavior + +The spec initially described FIFO drop (oldest message silently discarded when +cap reached). During review, this was changed to explicit rejection (user told +"queue full"). Reason: silent drop is confusing — user thinks their message was +accepted but it was dropped. The `queue_injection` method returns `false` when +full, and the Telegram handler sends a warning message. + +--- + +### Task 9: Build, test, and verify + +**Files:** +- Test: all modified files + +- [ ] **Step 1: Full cargo check** + +Run: `cargo check` +Expected: Clean build with no warnings + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -- -D warnings` +Expected: Clean + +- [ ] **Step 3: Run tests** + +Run: `cargo test` +Expected: All tests pass (including existing agent, telegram, config tests) + +- [ ] **Step 4: Format** + +Run: `cargo fmt` +Expected: Clean + +- [ ] **Step 5: Final commit** + +``` +git add src/agent.rs src/platform/telegram.rs Cargo.toml +git commit -m "feat: add /stop, /btw, and steer/inject for mid-processing user interaction" +``` diff --git a/docs/superpowers/specs/2026-07-08-stop-btw-steer-design.md b/docs/superpowers/specs/2026-07-08-stop-btw-steer-design.md new file mode 100644 index 0000000..6e0ee21 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-stop-btw-steer-design.md @@ -0,0 +1,183 @@ +# Stop, BTW, and Steer/Inject — Mid-Processing User Interaction + +Date: 2026-07-08 + +## Problem + +Once the agent begins processing a message (`process_message` runs the agentic +loop), the user has no way to interact mid-flight: + +- No way to stop processing gracefully (only kill the bot process) +- No way to redirect the agent mid-task ("use JWT not sessions") +- No way to ask a separate question while the agent is busy +- The Telegram handler is blocked for potentially minutes + +## Research + +**Hermes Agent**: Uses `/stop` to cancel, "send a new message" to interrupt. + +**OpenCode PR #32425 (`subagent-interrupt`)**: Three interrupt modes — +`task_steer` (inject guidance frame, continue), `task_cancel` (grace window +then break), `task_abort` (immediate hard stop). Messages are consumed at the +next turn boundary in the run loop. + +**OpenCode Issue #21388 (mid-turn messaging)**: Proposes three modes — +Queue+Inject at tool boundary, Preempt (pause stream + inject + resume), Hard +interrupt with context carry-forward. + +## Design + +Three interconnected features sharing a `per-user processing state tracked via +a CancellationToken registry`: + +### /stop — Cooperative Cancellation + +When `/stop` is received during active processing, signal a `CancellationToken` +at the next iteration boundary. The loop breaks gracefully preserving +accumulated conversation state. + +**Token lifecycle:** +- Created at `process_message` entry, keyed by `user_id` +- Checked at each iteration boundary (before auto-compact, before the LLM retry loop) +- Removed when `process_message` exits (any path) + +**Where checks go:** +- Start of `for iteration in 0..max_iterations` (the outer agentic loop) +- Before auto-compact (Tier 3) — skip if cancelled +- Before 413 recovery (Tier 4) — skip if cancelled +- Start of the retry inner loop (before each LLM call attempt) + +**In-flight LLM request** is allowed to finish (cooperative, not aborted). + +### Steer/Inject — User Messages Injected Mid-Processing + +When a non-command Telegram message arrives and the user is currently being +processed (token exists in registry), the message is **queued as a pending +injection** rather than starting a new `process_message`. + +**At the next iteration boundary** (between tool execution and LLM call), the +agent drains pending injections and inserts them as `user`-role ChatMessages. +The agent sees the guidance alongside tool results and adapts. Injected +messages are saved to persistent memory via `self.memory.save_message()` so +they survive restarts and conversation reloads. + +**Injection format:** +``` +**[User injected mid-processing]:** +``` + +**Queue storage:** `pending_injections: Arc>>>` + +### /btw — Parallel Question + +When `/btw ` is received (regardless of whether user is processing), a +background `tokio::spawn` task calls `run_subagent(None, ..., text, ...)` — +an isolated ad-hoc subagent with timestamp/location context. The answer is +sent as a new Telegram message. + +**No interaction with main processing** — fully isolated conversation state. + +## Architecture + +### Agent state additions + +**Dependency:** `tokio-util` — provides `CancellationToken` (used instead of +`AtomicBool` because it supports `.cancelled()` async waiter and can be cloned +across tasks). + +```rust +use tokio_util::sync::CancellationToken; + +pub struct Agent { + // ... + /// Per-user CancellationTokens for /stop + pub cancel_token_registry: Arc>>, + /// Per-user pending injection messages (Steer/Inject), max 10 per user. + pub pending_injections: Arc>>>, +} +``` + +### process_message loop changes + +``` +for iteration in 0..max_iterations { + // CHECK: cancelled? + if token_registry.is_cancelled(user_id) { + break; // return partial response + } + + // CHECK: pending injections? + if let Some(msgs) = drain_injections(user_id) { + for msg in msgs { + messages.push(ChatMessage { role: "user", content: msg }); + } + } + + // ...existing LLM call + tool execution... +} +``` + +### src/platform/telegram.rs handle_message changes + +``` +"/stop" => { + if agent.cancel_processing(user_id) { + send "ā¹ Processing cancelled" + } else { + send "Nothing is currently processing." + } +} + +if agent.is_processing(user_id) && !text.starts_with('/') { + if agent.queue_injection(user_id, text) { + send "šŸ“Ø Message queued — will inject into current processing" + } else { + send "āš ļø Injection queue full (max 10). Please wait for current processing to finish." + } + return; +} + +if let Some((cmd, arg)) = parse_command("/btw ...") { + let answer_bot = bot.clone(); + let answer_chat_id = chat_id; + tokio::spawn(async move { + match agent.ask_parallel(&arg).await { + Ok(answer) => { send_message(answer_bot, answer_chat_id, &answer).await; } + Err(e) => { send_message(answer_bot, answer_chat_id, &format!("BTW error: {}", e)).await; } + } + }); + send "ā³ BTW question sent to subagent..." + return; +} + +**Injection queue:** Per-user cap of 10 messages. Oldest message is dropped +when the cap is reached (FIFO). +``` + +### Commands registered + +``` +/stop — Cancel the current processing gracefully +/btw — Ask a parallel question while the bot is busy +``` + +- `cancel_processing(user_id) -> bool` — returns false if no processing was + active (so caller can give different feedback) +- `ask_parallel(question) -> Result` — returns error instead of + silently failing; spawned task sends error message to user on failure +- Injected messages are saved to persistent memory (`save_message`) so they + survive restarts + +## Edge Cases + +- **Double /stop**: Second cancel finds empty registry → reply "No active + processing" +- **Inject while idle**: Non-command message without active processing → + process as normal (existing behavior) +- **Inject + /stop simultaneously**: Cancel wins (loop breaks before next + injection drain) +- **/btw while idle**: Same behavior — subagent answers immediately +- **Partial state on cancel**: Messages accumulated so far are saved to DB, + user can continue from there +- **Subagent loops**: Same CancellationToken checked at `run_subagent_loop` + iteration boundaries diff --git a/src/agent.rs b/src/agent.rs index c01e697..df784a3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -459,10 +459,7 @@ impl Agent { /// Remove cancel token for a user (called on process_message exit). pub async fn clear_cancel_token(&self, user_id: &str) { - self.cancel_token_registry - .lock() - .await - .remove(user_id); + self.cancel_token_registry.lock().await.remove(user_id); } /// Fetch the context window size for the current model from the @@ -770,10 +767,13 @@ impl Agent { tool_call_id: None, }; // Save to persistent memory - self.memory + if let Err(e) = self + .memory .save_message(&conversation_id, &inject_msg) .await - .ok(); + { + warn!("Failed to persist injected message: {}", e); + } messages.push(inject_msg); } @@ -2823,7 +2823,11 @@ impl Agent { fn format_body(buf: &str, no_output_msg: &str) -> Option { if buf.is_empty() { - if no_output_msg.is_empty() { None } else { Some(no_output_msg.to_owned()) } + if no_output_msg.is_empty() { + None + } else { + Some(no_output_msg.to_owned()) + } } else { let capped = crate::utils::strings::truncate_tail(buf, 3500); Some(format!("```\n{}\n```", capped)) @@ -2842,9 +2846,19 @@ impl Agent { } "āš ļø User cancelled the command".to_string() } else if let Some(code) = exit_code { - let (icon, label) = if code == 0 { ("āœ…", "Completed") } else { ("āŒ", "Failed") }; + let (icon, label) = if code == 0 { + ("āœ…", "Completed") + } else { + ("āŒ", "Failed") + }; let body = format_body(&output_buffer, "Command completed with no output."); - let text = format!("{} {}: `{}`\n\n{}", icon, label, escaped_cmd, body.unwrap_or_default()); + let text = format!( + "{} {}: `{}`\n\n{}", + icon, + label, + escaped_cmd, + body.unwrap_or_default() + ); if let Err(e) = self.bot.edit_message_text(chat_id, msg.id, &text).await { warn!("Failed to update completed message: {e}"); } diff --git a/tests/command_cancel.rs b/tests/command_cancel.rs index e3313cb..d8e3f85 100644 --- a/tests/command_cancel.rs +++ b/tests/command_cancel.rs @@ -18,9 +18,8 @@ async fn test_process_group_killpg_terminates_tree() { // The child should have its own PGID equal to its PID // (process_group(0) calls setpgid(0, 0) in the child) - let child_pgid = - nix::unistd::getpgid(Some(nix::unistd::Pid::from_raw(pid as i32))) - .expect("child should have a process group"); + let child_pgid = nix::unistd::getpgid(Some(nix::unistd::Pid::from_raw(pid as i32))) + .expect("child should have a process group"); assert_eq!( child_pgid, nix::unistd::Pid::from_raw(pid as i32), From 3f95b9f32a4bd4d81af9445cd9b302711b186844 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 09:03:10 +0800 Subject: [PATCH 15/69] feat: steer messages, lightweight /btw, and markdown entities upgrade Three independent features: (A) Markdown entities upgrade (src/utils/markdown_entities.rs) - Blockquote entity via Bot API 7.0+ MessageEntityKind::Blockquote - Spoiler (||text||) and Underline (text) via PUA sentinel pre-processing - List formatting: unordered gets \u{2022} bullet, ordered gets 1./2. numbering (B) Lightweight /btw (src/agent.rs, src/platform/telegram.rs) - Replaces heavy ask_parallel with ask_parallel_lightweight - Single LLM call, no tools, no agentic loop, zero lock contention (C) Steer messages (src/agent.rs, src/platform/telegram.rs) - MidRunMode enum (Steer/Queue) with memory persistence - /mode command to toggle between modes - Injection point moved to pre-LLM-call with mode-aware formatting - Steer: ephemeral injection; Queue: persisted injection - /clear also resets mid-run mode --- docs/design-steer-btw-markdown.md | 396 +++++++++ .../plans/2026-07-09-steer-btw-markdown.md | 817 ++++++++++++++++++ src/agent.rs | 161 ++-- src/platform/telegram.rs | 74 +- src/utils/markdown_entities.rs | 228 ++++- 5 files changed, 1593 insertions(+), 83 deletions(-) create mode 100644 docs/design-steer-btw-markdown.md create mode 100644 docs/superpowers/plans/2026-07-09-steer-btw-markdown.md diff --git a/docs/design-steer-btw-markdown.md b/docs/design-steer-btw-markdown.md new file mode 100644 index 0000000..be34e33 --- /dev/null +++ b/docs/design-steer-btw-markdown.md @@ -0,0 +1,396 @@ +# Design: Steer Messages, /btw Parallel, & Markdown Upgrade + +## 1. Steer Message System + +### Problem + +Non-command messages sent while the agent is busy are queued as injections, +drained at the next iteration boundary, formatted as full user turns, and +persisted to DB. This makes them indistinguishable from new tasks — the model +treats "use v2 API instead" as a separate instruction rather than mid-turn +steering context. Also, the injection point (pre-iteration) is slow: the user +must wait for the current tool cycle to finish before their steer is seen. + +### Design + +#### MidRunMode (per-user, persisted in memory store) + +```rust +#[derive(Clone, Copy, PartialEq)] +enum MidRunMode { + Steer, // (default) inject into current turn, ephemeral, formatted as steering context + Queue, // wait for next turn, persisted, formatted as normal user message +} +``` + +- Default: `Steer` +- Switch via new command: `/mode queue` or `/mode steer` +- Persisted in memory store per user via `memory.remember("settings", "mid_run_mode_{user_id}", "steer", None)` / `memory.recall("settings", "mid_run_mode_{user_id}")` +- Default when no stored value: `Steer` (enforced in code as `unwrap_or(MidRunMode::Steer)`) +- `/clear` resets to default (Steer): in the existing `/clear` handler (`agent.rs ~line 1874`), add `self.memory.delete("settings", &format!("mid_run_mode_{}", user_id)).await.ok();` + +#### /stop remains Break + +`/stop` is unchanged — it cancels the current token, discards accumulated +state, and saves what's already been persisted. It is the explicit "break" +action and does not participate in the Steer/Queue toggle. + +#### Steer formatting + +When `mode == Steer`, the injection message is: + +``` +**[Steer]:** use v2 API instead +``` + +When `mode == Queue` (or when injection queue was filled while in Queue mode): + +``` +**[User injected mid-processing]:** some message +``` + +The `[Steer]` prefix signals the model that this is a **correction/guidance +for the in-progress turn**, not a new user request. The model should adjust +its current trajectory without starting a new task. + +#### Steer is NOT persisted + +Steer messages (mode=Steer) are appended to the in-memory messages vector for +the LLM call but are **not saved to the database**. This ensures: +- No artificial turn boundaries for compaction +- No pollution of conversation history with course-corrections +- No wasted tokens on compaction of steer messages + +Queue messages (mode=Queue) are persisted as they are today (saved to DB). + +#### Injection point: pre-LLM-call instead of pre-iteration + +Current: drain before tool execution loop (line ~757) +New: drain before `prepare_messages_for_llm()` inside the retry loop, so +injections are visible to every LLM call attempt. + +This makes steer delivery responsive — the user's correction is visible to the +model at the very next LLM call, not after the current tool loop finishes. + +```rust +// Inside retry loop, before prepare_messages_for_llm(): +if cancel_token.is_cancelled() { break; } + +// Drain steer/queue injections into messages vector +let inject_mode = self.get_mid_run_mode(user_id).await; +let injections = self.drain_injections(user_id).await; +if !injections.is_empty() { + let label = if inject_mode == MidRunMode::Steer { "[Steer]" } else { "[User injected mid-processing]" }; + for text in &injections { + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("**{label}:** {text}"))), + tool_calls: None, + tool_call_id: None, + }; + messages.push(msg); + if inject_mode == MidRunMode::Queue { + self.memory.save_message(&conversation_id, &msg).await.ok(); + } + } +} + +// Re-clone from messages (now includes injections) before LLM call +base_prompt = prepare_messages_for_llm(&messages, &conv_meta, context_window)?; +let response = self.llm.chat_completion_with_model(&base_prompt.messages, &all_tools, &model).await; +``` + +#### Compaction awareness + +Steer messages are never persisted so they never reach compaction. Queue-mode +injections are persisted and compacted normally as user messages. + +### Commands + +| Command | Action | +|---------|--------| +| `/mode` | Show current mode (e.g. "Current mode: **steer**") | +| `/mode steer` | Switch to steer mode (default) | +| `/mode queue` | Switch to queue mode | +| `/stop` | Break — cancel current processing (unchanged) | + +Register `/mode` in `supported_commands()` (`telegram.rs:78`) alongside existing commands. + +#### Steer vs Queue confirmation messages + +When a non-command message is queued during processing (`telegram.rs:999`), the +confirmation text changes based on mode: +- Steer mode: `šŸ“Ø **Steer queued** — will inject into current processing at next step.` +- Queue mode: `šŸ“Ø **Message queued** — will process after current task completes.` + +## 2. /btw True Parallel Processing + +### Problem + +`ask_parallel()` calls `run_subagent(None, ...)` which spins up a full +agentic loop (up to max_iterations) with tool access. This is slow, expensive +in tokens, and the tool access is unnecessary — `/btw` should be a quick +read-only knowledge question. Additionally, the function accesses `self.memory` +and other shared resources, creating lock contention. + +### Design + +Replace with `ask_parallel_lightweight`: + +```rust +pub async fn ask_parallel_lightweight(&self, question: &str) -> Result { + let system = format!( + "Answer the user's side question concisely from your knowledge. \ + You have NO tools available. Respond in a single message. \ + Current time: {}", + self.build_system_context().await, + ); + let messages = vec![ + ChatMessage { + role: "system".to_string(), + content: Some(MessageContent::from_text(system)), + tool_calls: None, + tool_call_id: None, + }, + ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(question)), + tool_calls: None, + tool_call_id: None, + }, + ]; + let response = self.llm.chat(&messages, &[]).await?; + Ok(response.content.as_ref().map(|c| c.as_text()).unwrap_or_default()) +} +``` + +Properties: +- **Single LLM call** — no agentic loop, no tool execution +- **No tool access** — empty tool list `&[]` +- **No memory/DB access** — zero lock contention with main process +- **No cache write** — ephemeral, won't pollute prompt cache +- **Uses same `self.llm`** — `reqwest::Client` is `Arc` internally, safe for concurrent HTTP +- **Ephemeral output** — answer is sent via Telegram but NOT saved to conversation history + +### /btw while main process is NOT processing + +If the agent is idle (no active `process_message` for this user), `/btw` still +works identically — it's a light side question that returns an answer without +touching conversation history. + +### Remove tool access from /btw + +Current `ask_parallel` grants `[read_file, write_file, list_files, execute_command]` tools (via `subagents.default_tools`). `/btw` should have **zero tools** — it's a pure knowledge question from the model's training data + conversation context. + +## 3. Markdown Entities Upgrade + +### Problem audit + +Current `markdown_to_entities()` in `markdown_entities.rs`: + +| Feature | Status | Issue | +|---------|-------|-------| +| `**bold**` | āœ… Bold entity | | +| `*italic*` | āœ… Italic entity | | +| `` `code` `` | āœ… Code entity | | +| ` ```rust...``` ` | āœ… Pre { language } | | +| `[text](url)` | āœ… TextLink | | +| `# Heading` | āœ… Bold entity | Low-fi but works | +| `~~strikethrough~~` | āœ… Strikethrough | | +| `> blockquote` | āŒ Text-only `> ` prefix | No Blockquote entity | +| `||spoiler||` | āŒ Not parsed at all | Raw text shown | +| `underline` | āŒ Not parsed | pulldown-cmark ignores raw HTML | +| `- list item` | āš ļø Text only | No bullet, no indent | +| `1. ordered` | āš ļø Text only | No numbering entity | +| Tables | āš ļø Text `|` sep | No Table entity (Telegram doesn't have one) | +| Nested bold+italic | āš ļø Stack handles nesting | Could overlap incorrectly | + +### Upgrades + +#### Blockquote entity (Bot API 7.0+) + +```diff ++ // Track blockquote start for entity emission ++ let mut blockquote_start: Option = None; +... + Tag::BlockQuote(_) => { + in_blockquote = true; ++ blockquote_start = Some(plain_utf16_len); + } +... + TagEnd::BlockQuote(_) => { + in_blockquote = false; ++ // Emit Blockquote entity (Bot API 7.0+); REMOVE old `> ` text prefix ++ if let Some(start) = blockquote_start.take() { ++ let length = plain_utf16_len.saturating_sub(start); ++ if length > 0 { ++ // MessageEntity is a pub-fields struct (no convenience constructor for Blockquote) ++ entities.push(MessageEntity { ++ kind: MessageEntityKind::Blockquote, ++ offset: start, ++ length, ++ }); ++ } ++ } + } +``` + +Key change: **remove the `> ` prefix from `Event::Text`** in the `in_blockquote` branch +(currently `markdown_entities.rs:63-68`). Replace it with plain text output — the +Blockquote entity handles the visual formatting. This avoids double-rendering. + +Update existing test `test_blockquote_prefixes_with_gt` to assert the text contains +the content **without** `> ` prefix and assert a Blockquote entity is present instead. + +Note: The struct literal requires `use teloxide::types::MessageEntityKind;` in scope. + +#### Spoiler (`||text||`) & Underline (`text`) + +pulldown-cmark does not parse `||spoiler||` or `underline`. Solution: +**single pre-processing pass** with PUA sentinel replacement + post-scan for entities: + +```rust +// Sentinel chars (Private Use Area — guaranteed absent from real Markdown, valid UTF-8) +const SPOILER_START: char = '\u{E000}'; // followed by 'S' +const SPOILER_END: char = '\u{E001}'; // followed by "/s" +const UL_START: char = '\u{E002}'; // followed by 'U' +const UL_END: char = '\u{E003}'; // followed by "/u" + +/// Pre-process markdown before pulldown-cmark parsing: +/// 1. `text` → `\u{E002}Utext\u{E003}/u` +/// 2. `||text||` → `\u{E000}Stext\u{E001}/s` +fn preprocess_markdown(md: &str) -> String { + let md = md + .replace("", format!("{}U", UL_START).as_str()) + .replace("", format!("{}/u", UL_END).as_str()); + let re = regex::Regex::new(r"\|\|(.*?)\|\|").unwrap(); + re.replace_all(&md, format!("{}S$1{}/s", SPOILER_START, SPOILER_END).as_str()) + .to_string() +} +``` + +After pulldown-cmark parsing, scan the plain text for sentinel markers, remove +them, and emit corresponding entities at correct UTF-16 offsets: + +```rust +/// Post-process: remove sentinel markers from plain text, emit spoiler & underline entities. +/// Requires `use teloxide::types::MessageEntityKind;` in scope. +fn postprocess_entities(plain: &mut String, entities: &mut Vec) { + let mut utf16_offset = 0usize; + let mut out = String::new(); + let mut stack: Vec<(MessageEntityKind, usize)> = Vec::new(); // (kind, utf16_start) + let chars: Vec = plain.chars().collect(); + let mut i = 0; + + while i < chars.len() { + match chars[i] { + c if c == SPOILER_START && i+1 < chars.len() && chars[i+1] == 'S' => { + stack.push((MessageEntityKind::Spoiler, utf16_offset)); + i += 2; continue; + } + c if c == SPOILER_END && i+2 < chars.len() && chars[i+1] == '/' && chars[i+2] == 's' => { + if let Some(idx) = stack.iter().rposition(|(k,_)| *k == MessageEntityKind::Spoiler) { + if let Some((_, start)) = Some(stack.remove(idx)) { + let len = utf16_offset - start; + if len > 0 { entities.push(MessageEntity::spoiler(start, len)); } + } + } + i += 3; continue; + } + c if c == UL_START && i+1 < chars.len() && chars[i+1] == 'U' => { + stack.push((MessageEntityKind::Underline, utf16_offset)); + i += 2; continue; + } + c if c == UL_END && i+2 < chars.len() && chars[i+1] == '/' && chars[i+2] == 'u' => { + if let Some(idx) = stack.iter().rposition(|(k,_)| *k == MessageEntityKind::Underline) { + if let Some((_, start)) = Some(stack.remove(idx)) { + let len = utf16_offset - start; + if len > 0 { entities.push(MessageEntity::underline(start, len)); } + } + } + i += 3; continue; + } + _ => { + out.push(chars[i]); + utf16_offset += chars[i].len_utf16(); + i += 1; + } + } + } + *plain = out; +} +``` + +`MessageEntity::spoiler(offset, length)` and `MessageEntity::underline(offset, length)` +both exist in teloxide as convenience constructors (verified). + +#### List formatting + +Telegram has no list entity type. Lists should render as clean text: +- Unordered: `• item1\n• item2` (using `•` bullet character) +- Ordered: `1. item1\n2. item2` + +Current pulldown-cmark already renders list items with `\n` separators. The +fix is ensuring the list marker text is clean. + +**Implementation:** Add list tracking state + prefix injection in `Event::Text`: + +```rust +// Track list state +let mut list_counter: Option = None; // None = unordered, Some(n) = ordered +let mut needs_list_prefix = false; // true before an item's first text + +// In Event::Start match: +Tag::List { start } => { + list_counter = start; // None for unordered, Some(1|start_number) +} +Tag::Item => { + needs_list_prefix = true; +} + +// In Event::Text match (before appending, when needs_list_prefix is true): +if needs_list_prefix { + let prefix = match list_counter { + None => "• ", // unordered bullet + Some(ref mut n) => { let p = format!("{}. ", n); *n += 1; p } // ordered + }; + plain.push_str(&prefix); + plain_utf16_len += prefix.encode_utf16().count(); + needs_list_prefix = false; +} +// ... then append text as usual + +// In Event::End match: +TagEnd::List(_) => { + list_counter = None; + needs_list_prefix = false; +} +TagEnd::Item => { + plain.push('\n'); + plain_utf16_len += 1; +} +``` + +### Testing + +Add tests for each upgraded feature: +- Blockquote entity with correct UTF-16 offset +- Spoiler span with correct UTF-16 offset +- Underline with correct UTF-16 offset +- Unordered list rendering with bullet prefix +- Ordered list rendering with number prefix +- Nested bold + spoiler +- Mixed formatting inside blockquote + +## 4. File Manifest + +### New files +- None (all changes are edits to existing files) + +### Files to modify + +| File | Changes | +|------|---------| +| `src/agent.rs` | Add `MidRunMode` enum, `get_mid_run_mode()`, modify injection point, add steer prefix formatting | +| `src/platform/telegram.rs` | Add `/mode` command handler, modify injection callers for steer vs queue, replace `ask_parallel` call | +| `src/utils/markdown_entities.rs` | Add blockquote entity, spoiler detection, underline detection, list rendering fixes | \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-09-steer-btw-markdown.md b/docs/superpowers/plans/2026-07-09-steer-btw-markdown.md new file mode 100644 index 0000000..cd2a505 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-steer-btw-markdown.md @@ -0,0 +1,817 @@ +# Steer Messages, /btw Parallel & Markdown Upgrade — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement three independent features: (A) Markdown entities upgrade with blockquote/spoiler/underline/lists, (B) lightweight /btw with single LLM call, (C) steer message system with configurable MidRunMode. + +**Architecture:** All changes are edits to existing files. No new files. Feature A is isolated to `src/utils/markdown_entities.rs`. Features B and C touch `src/agent.rs` and `src/platform/telegram.rs` with no logical dependency on A. + +**Tech Stack:** Rust, teloxide, pulldown-cmark, regex (already in Cargo.toml) + +--- + +### Task 1: Blockquote entity support + +**Files:** +- Modify: `src/utils/markdown_entities.rs` +- Test: inline (`mod tests` block) + +- [ ] **Step 1: Add `use teloxide::types::MessageEntityKind` import** + +```rust +// At top of markdown_entities.rs, add to existing use block: +use teloxide::types::{MessageEntity, MessageEntityKind}; +``` + +- [ ] **Step 2: Add `blockquote_start` tracking variable** + +After `let mut in_blockquote = false;` (line 57), add: +```rust +let mut blockquote_start: Option = None; +``` + +- [ ] **Step 3: Set `blockquote_start` in `Tag::BlockQuote` handler** + +In the `Event::Start(tag)` match, replace: +```rust +Tag::BlockQuote(_) => { + in_blockquote = true; +} +``` +with: +```rust +Tag::BlockQuote(_) => { + in_blockquote = true; + blockquote_start = Some(plain_utf16_len); +} +``` + +- [ ] **Step 4: Remove `> ` prefix from `Event::Text` blockquote rendering** + +Replace the `in_blockquote` branch in `Event::Text` (lines 63-75): +```rust +if in_blockquote { + let quoted: String = text + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n"); + plain.push_str("ed); + plain_utf16_len += quoted.encode_utf16().count(); +} else { + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); +} +``` +with just plain text (no prefix): +```rust +plain.push_str(&text); +plain_utf16_len += text.encode_utf16().count(); +``` + +- [ ] **Step 5: Emit `Blockquote` entity on `TagEnd::BlockQuote`** + +Replace the `TagEnd::BlockQuote(_)` handler (start of line 217): +```rust +TagEnd::BlockQuote(_) => { + in_blockquote = false; + if !plain.ends_with('\n') { + plain.push('\n'); + plain_utf16_len += 1; + } +} +``` +with: +```rust +TagEnd::BlockQuote(_) => { + in_blockquote = false; + if let Some(start) = blockquote_start.take() { + let length = plain_utf16_len.saturating_sub(start); + if length > 0 { + entities.push(MessageEntity { + kind: MessageEntityKind::Blockquote, + offset: start, + length, + }); + } + } + if !plain.ends_with('\n') { + plain.push('\n'); + plain_utf16_len += 1; + } +} +``` + +- [ ] **Step 6: Update `test_blockquote_prefixes_with_gt` test** + +Replace the existing test (lines 643-653): +```rust +#[test] +fn test_blockquote_emits_entity() { + let (text, entities) = markdown_to_entities("> This is a quote"); + assert!( + text.contains("This is a quote"), + "blockquote text must be present: {text}" + ); + assert!( + !text.contains("> "), + "blockquote must NOT have '> ' prefix when entity is used" + ); + let blockquote = entities.iter().find(|e| { + matches!(e.kind, MessageEntityKind::Blockquote) + }); + assert!( + blockquote.is_some(), + "blockquote must produce a Blockquote entity" + ); +} +``` + +- [ ] **Step 7: Run tests to verify** + +Run: `cargo test -p rustfox markdown_entities -- --test-threads=1` +Expected: ALL tests pass (including updated blockquote test) + +- [ ] **Step 8: Commit** + +```bash +git add src/utils/markdown_entities.rs +git commit -m "feat(markdown): add Blockquote entity for Telegram Bot API 7.0+" +``` + +--- + +### Task 2: Spoiler and Underline support + +**Files:** +- Modify: `src/utils/markdown_entities.rs` +- Test: inline + +- [ ] **Step 1: Add sentinel constants and helper functions** + +After the last import line, add: +```rust +/// Private Use Area sentinels for Telegram-specific inline formatting. +/// These characters cannot appear in valid Markdown but are valid UTF-8. +const SPOILER_START: char = '\u{E000}'; +const SPOILER_END: char = '\u{E001}'; +const UL_START: char = '\u{E002}'; +const UL_END: char = '\u{E003}'; + +/// Pre-process markdown before pulldown-cmark parsing: +/// 1. `text` → `\u{E002}Utext\u{E003}/u` +/// 2. `||text||` → `\u{E000}Stext\u{E001}/s` +fn preprocess_markdown(md: &str) -> String { + let md = md + .replace("", { + let mut s = String::with_capacity(2); + s.push(UL_START); + s.push('U'); + s + }) + .replace("", { + let mut s = String::with_capacity(3); + s.push(UL_END); + s.push('/'); + s.push('u'); + s + }); + let re = regex::Regex::new(r"\|\|(.*?)\|\|").unwrap(); + re.replace_all(&md, { + let mut prefix = String::with_capacity(2); + prefix.push(SPOILER_START); + prefix.push('S'); + let mut suffix = String::with_capacity(3); + suffix.push(SPOILER_END); + suffix.push('/'); + suffix.push('s'); + format!("{}$1{}", prefix, suffix) + }) + .to_string() +} + +/// Post-process: remove sentinel markers from plain text, emit spoiler & underline entities. +/// Requires `use teloxide::types::MessageEntityKind;` in scope. +fn postprocess_entities(plain: &mut String, entities: &mut Vec) { + let mut utf16_offset = 0usize; + let mut out = String::new(); + let mut stack: Vec<(MessageEntityKind, usize)> = Vec::new(); + let chars: Vec = plain.chars().collect(); + let mut i = 0; + + while i < chars.len() { + match chars[i] { + c if c == SPOILER_START && i + 1 < chars.len() && chars[i + 1] == 'S' => { + stack.push((MessageEntityKind::Spoiler, utf16_offset)); + i += 2; + continue; + } + c if c == SPOILER_END + && i + 2 < chars.len() + && chars[i + 1] == '/' + && chars[i + 2] == 's' => + { + if let Some(idx) = stack + .iter() + .rposition(|(k, _)| *k == MessageEntityKind::Spoiler) + { + let (_, start) = stack.remove(idx); + let len = utf16_offset - start; + if len > 0 { + entities.push(MessageEntity::spoiler(start, len)); + } + } + i += 3; + continue; + } + c if c == UL_START && i + 1 < chars.len() && chars[i + 1] == 'U' => { + stack.push((MessageEntityKind::Underline, utf16_offset)); + i += 2; + continue; + } + c if c == UL_END + && i + 2 < chars.len() + && chars[i + 1] == '/' + && chars[i + 2] == 'u' => + { + if let Some(idx) = stack + .iter() + .rposition(|(k, _)| *k == MessageEntityKind::Underline) + { + let (_, start) = stack.remove(idx); + let len = utf16_offset - start; + if len > 0 { + entities.push(MessageEntity::underline(start, len)); + } + } + i += 3; + continue; + } + _ => { + out.push(chars[i]); + utf16_offset += chars[i].len_utf16(); + i += 1; + } + } + } + *plain = out; +} +``` + +- [ ] **Step 2: Wire `preprocess_markdown` at the start of `markdown_to_entities`** + +At line 44, change: +```rust +let parser = Parser::new_ext(markdown, options); +``` +to: +```rust +let processed = preprocess_markdown(markdown); +let parser = Parser::new_ext(&processed, options); +``` + +- [ ] **Step 3: Wire `postprocess_entities` before the return** + +Before the final `(plain, entities)` return (line 253), add: +```rust +postprocess_entities(&mut plain, &mut entities); +``` + +- [ ] **Step 4: Add spoiler/underline tests** + +In the `mod tests` block, add: +```rust +#[test] +fn test_spoiler_converts_to_entity() { + let (text, entities) = markdown_to_entities("||hidden||"); + assert_eq!(text, "hidden"); + assert!(entities.iter().any(|e| matches!(e.kind, MessageEntityKind::Spoiler))); +} + +#[test] +fn test_underline_converts_to_entity() { + let (text, entities) = markdown_to_entities("underlined"); + assert_eq!(text, "underlined"); + assert!(entities.iter().any(|e| matches!(e.kind, MessageEntityKind::Underline))); +} + +#[test] +fn test_spoiler_with_bold() { + let (text, entities) = markdown_to_entities("**bold** and ||spoiler||"); + assert!(text.contains("bold")); + assert!(text.contains("spoiler")); + assert!(entities.iter().any(|e| matches!(e.kind, MessageEntityKind::Bold))); + assert!(entities.iter().any(|e| matches!(e.kind, MessageEntityKind::Spoiler))); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p rustfox markdown_entities -- --test-threads=1` +Expected: ALL tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/markdown_entities.rs +git commit -m "feat(markdown): add Spoiler and Underline entity support via sentinel pre-processing" +``` + +--- + +### Task 3: List formatting with bullet/number prefixes + +**Files:** +- Modify: `src/utils/markdown_entities.rs` +- Test: inline + +- [ ] **Step 1: Add list tracking state variables** + +After `let mut in_blockquote = false;` (line 57), add: +```rust +let mut list_counter: Option = None; // None = unordered, Some(n) = ordered +let mut needs_list_prefix = false; // true before Item's first Text +``` + +- [ ] **Step 2: Handle `Tag::List` and `Tag::Item` in `Event::Start`** + +In the `Event::Start(tag)` match, add before `_ => {}`: +```rust +Tag::List { start } => { + list_counter = start; +} +Tag::Item => { + needs_list_prefix = true; +} +``` + +- [ ] **Step 3: Inject prefix in `Event::Text` when `needs_list_prefix` is true** + +Inside `Event::Text`, before `plain.push_str(&text);`, add: +```rust +if needs_list_prefix { + let prefix: String = match list_counter { + None => "• ".to_string(), + Some(ref mut n) => { + let p = format!("{}. ", n); + *n += 1; + p + } + }; + plain.push_str(&prefix); + plain_utf16_len += prefix.encode_utf16().count(); + needs_list_prefix = false; +} +``` + +- [ ] **Step 4: Handle `TagEnd::Item` and `TagEnd::List`** + +In `Event::End`, update `TagEnd::Item` (line 213-216): +```rust +TagEnd::Item => { + plain.push('\n'); + plain_utf16_len += 1; + // needs_list_prefix stays false — it was already consumed in Text +} +``` + +Add before `_ => {}`: +```rust +TagEnd::List(_) => { + list_counter = None; + needs_list_prefix = false; +} +``` + +- [ ] **Step 5: Add list rendering tests** + +```rust +#[test] +fn test_unordered_list_renders_with_bullets() { + let input = "- item one\n- item two"; + let (text, _) = markdown_to_entities(input); + assert!( + text.contains("• item one"), + "unordered list must use bullet: {text}" + ); + assert!( + text.contains("• item two"), + "second item must also have bullet: {text}" + ); +} + +#[test] +fn test_ordered_list_renders_with_numbers() { + let input = "1. first\n2. second"; + let (text, _) = markdown_to_entities(input); + assert!( + text.contains("1. first"), + "ordered list must use number: {text}" + ); + assert!( + text.contains("2. second"), + "second item must use next number: {text}" + ); +} +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p rustfox markdown_entities -- --test-threads=1` +Expected: ALL tests pass + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/markdown_entities.rs +git commit -m "feat(markdown): add list rendering with bullet and number prefixes" +``` + +--- + +### Task 4: Remove old `ask_parallel`, add `ask_parallel_lightweight` + +**Files:** +- Modify: `src/agent.rs` + +- [ ] **Step 1: Add `ask_parallel_lightweight` method to Agent** + +In `src/agent.rs`, replace the existing `ask_parallel` method (starts at line 2601) with the lightweight version: + +```rust + /// Ask a parallel question while the main agent is processing. + /// Single LLM call, no tools, no DB access — truly parallel, zero lock contention. + /// Answer is ephemeral and NOT saved to conversation history. + pub async fn ask_parallel_lightweight(&self, question: &str) -> Result { + let system = format!( + "Answer the user's side question concisely from your knowledge. \ + You have NO tools available. Respond in a single message. \ + Current time: {}", + self.build_system_context().await, + ); + let messages = vec![ + ChatMessage { + role: "system".to_string(), + content: Some(MessageContent::from_text(system)), + tool_calls: None, + tool_call_id: None, + }, + ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(question.to_string())), + tool_calls: None, + tool_call_id: None, + }, + ]; + let response = self.llm.chat(&messages, &[]).await?; + Ok(response + .content + .as_ref() + .map(|c| c.as_text()) + .unwrap_or_default()) + } +``` + +- [ ] **Step 2: Build and verify compilation** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/agent.rs +git commit -m "feat(btw): replace ask_parallel with lightweight single-LLM-call version" +``` + +--- + +### Task 5: Update `/btw` handler in telegram.rs to use lightweight version + +**Files:** +- Modify: `src/platform/telegram.rs` + +- [ ] **Step 1: Replace `ask_parallel` call with `ask_parallel_lightweight`** + +In the `/btw` handler (lines 816-830), change: +```rust +tokio::spawn(async move { + match agent_clone.ask_parallel(&btw_text).await { +``` +to: +```rust +tokio::spawn(async move { + match agent_clone.ask_parallel_lightweight(&btw_text).await { +``` + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat(btw): use ask_parallel_lightweight for true parallel side questions" +``` + +--- + +### Task 6: Add `MidRunMode` enum and persistence helpers + +**Files:** +- Modify: `src/agent.rs` + +- [ ] **Step 1: Add `MidRunMode` enum** + +Near the top of `src/agent.rs`, after the last import, add: +```rust +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MidRunMode { + Steer, + Queue, +} + +impl MidRunMode { + pub fn as_str(&self) -> &'static str { + match self { + MidRunMode::Steer => "steer", + MidRunMode::Queue => "queue", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "steer" => Some(MidRunMode::Steer), + "queue" => Some(MidRunMode::Queue), + _ => None, + } + } +} +``` + +- [ ] **Step 2: Add `get_mid_run_mode` helper to Agent** + +Add to `impl Agent` block (near other accessors like `queue_injection`): +```rust + /// Get the current MidRunMode for a user. Defaults to Steer. + pub async fn get_mid_run_mode(&self, user_id: &str) -> MidRunMode { + let key = format!("mid_run_mode_{}", user_id); + self.memory + .recall("settings", &key) + .await + .ok() + .flatten() + .and_then(|v| MidRunMode::from_str(&v)) + .unwrap_or(MidRunMode::Steer) + } + + /// Set the MidRunMode for a user. + pub async fn set_mid_run_mode(&self, user_id: &str, mode: MidRunMode) { + let key = format!("mid_run_mode_{}", user_id); + self.memory + .remember("settings", &key, mode.as_str(), None) + .await + .ok(); + } + + /// Delete the MidRunMode for a user (resets to default). + pub async fn delete_mid_run_mode(&self, user_id: &str) { + let key = format!("mid_run_mode_{}", user_id); + self.memory.forget("settings", &key).await.ok(); + } +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.rs +git commit -m "feat(steer): add MidRunMode enum with persistence helpers" +``` + +--- + +### Task 7: Add `/mode` command handler in telegram.rs + +**Files:** +- Modify: `src/platform/telegram.rs` + +- [ ] **Step 1: Register `/mode` in `supported_commands()`** + +In `supported_commands()` (line 96), add before `BotCommand::new("stop"`: +```rust +BotCommand::new("mode", "Set steer/queue mode for mid-processing messages"), +``` + +- [ ] **Step 2: Add `/mode` handler before `/stop` handler** + +In `handle_message`, add before the `/stop` check (line 977): +```rust + // Handle /mode command + if text.starts_with("/mode") { + let parts: Vec<&str> = text.splitn(2, |c: char| c.is_whitespace()).collect(); + let sub = parts.get(1).copied().unwrap_or(""); + if sub == "steer" { + agent.set_mid_run_mode(&user_id.to_string(), MidRunMode::Steer).await; + return send_markdown_message( + &bot, msg.chat.id, + "šŸ”„ **Mode set to steer.** Mid-processing messages will be injected as steering context.", + ).await; + } else if sub == "queue" { + agent.set_mid_run_mode(&user_id.to_string(), MidRunMode::Queue).await; + return send_markdown_message( + &bot, msg.chat.id, + "šŸ”„ **Mode set to queue.** Mid-processing messages will wait for the next turn.", + ).await; + } else if sub.is_empty() { + let current = agent.get_mid_run_mode(&user_id.to_string()).await; + let mode_str = current.as_str(); + return send_markdown_message( + &bot, msg.chat.id, + &format!("Current mode: **{}**\n\nUse `/mode steer` or `/mode queue` to change.", mode_str), + ).await; + } else { + return send_markdown_message( + &bot, msg.chat.id, + "Unknown mode. Use `/mode steer` or `/mode queue`.", + ).await; + } + } +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat(steer): add /mode command to toggle between steer and queue modes" +``` + +--- + +### Task 8: Move injection point to pre-LLM-call and add steer formatting + +**Files:** +- Modify: `src/agent.rs` + +- [ ] **Step 1: Remove the old injection drain from pre-iteration** + +Find the current injection drain at the start of the main agentic iteration loop (around line 757): +```rust +// CHECK: pending injections from user? +let injections = self.drain_injections(user_id).await; +for text in &injections { + let inject_msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!( + "**[User injected mid-processing]:** {}", + text + ))), + tool_calls: None, + tool_call_id: None, + }; + // Save to persistent memory + if let Err(e) = self + .memory + .save_message(&conversation_id, &inject_msg) + .await + { + warn!("Failed to persist injected message: {}", e); + } + messages.push(inject_msg); +} +``` + +Replace it with nothing (remove those lines entirely — the drain moves to the new location). + +- [ ] **Step 2: Add the new injection drain before `prepare_messages_for_llm`** + +Find `let base_prompt = prepare_messages_for_llm(&messages, context_window);` (line 839). +Just before it, add the new injection drain with mode-aware formatting: + +```rust + // CHECK: pending injections from user (steer or queue based on mode) + let inject_mode = self.get_mid_run_mode(user_id).await; + let injections = self.drain_injections(user_id).await; + if !injections.is_empty() { + let label = if inject_mode == MidRunMode::Steer { + "**[Steer]:** " + } else { + "**[User injected mid-processing]:** " + }; + for text in &injections { + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("{}{}", label, text))), + tool_calls: None, + tool_call_id: None, + }; + messages.push(msg); + if inject_mode == MidRunMode::Queue { + if let Err(e) = self + .memory + .save_message(&conversation_id, &msg) + .await + { + warn!("Failed to persist queued injection: {}", e); + } + } + } + } + + // Tiers 1-2: sync compaction + let base_prompt = prepare_messages_for_llm(&messages, context_window); +``` + +- [ ] **Step 3: Update `/clear` to also reset MidRunMode** + +In `clear_conversation` (line 1872), add: +```rust + pub async fn clear_conversation(&self, platform: &str, user_id: &str) -> Result<()> { + self.memory.clear_conversation(platform, user_id).await?; + // Reset mid-run mode to default (Steer) + self.delete_mid_run_mode(user_id).await; + Ok(()) + } +``` + +- [ ] **Step 4: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 5: Update the injection confirmation message in telegram.rs** + +In the injection check (line 992-1010), replace the confirmation messages to be mode-aware: + +```rust + // CHECK: if user is currently being processed, queue non-command messages as injection + if !text.starts_with('/') && agent.is_processing(&user_id.to_string()).await { + let current_mode = agent.get_mid_run_mode(&user_id.to_string()).await; + let maxed = !agent.queue_injection(&user_id.to_string(), &text).await; + if maxed { + return send_markdown_message( + &bot, + msg.chat.id, + "āš ļø **Injection queue full** (max 10). Please wait for current processing to finish.", + ) + .await; + } + let confirm = match current_mode { + MidRunMode::Steer => "šŸ“Ø **Steer queued** — will inject into current processing at next step.", + MidRunMode::Queue => "šŸ“Ø **Message queued** — will process after current task completes.", + }; + return send_markdown_message(&bot, msg.chat.id, confirm).await; + } +``` + +- [ ] **Step 6: Ensure `MidRunMode` is imported in telegram.rs** + +At the top of telegram.rs, add to the existing `use crate::agent::Agent;` or create a new use: +```rust +use crate::agent::{Agent, MidRunMode}; +``` + +- [ ] **Step 7: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 8: Commit** + +```bash +git add src/agent.rs src/platform/telegram.rs +git commit -m "feat(steer): move injection to pre-LLM-call with MidRunMode-aware formatting and persistence" +``` + +--- + +### Task 9: Final verification + +**Files:** (no changes) + +- [ ] **Step 1: Run full build** + +Run: `cargo build` +Expected: Compiles with no errors + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -- -D warnings` +Expected: No warnings + +- [ ] **Step 3: Run all tests** + +Run: `cargo test` +Expected: All tests pass, including all markdown_entities tests + diff --git a/src/agent.rs b/src/agent.rs index df784a3..edb5464 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -32,6 +32,33 @@ use std::collections::HashMap; use tokio::process::Command as TokioCommand; use tokio::sync::oneshot; +/// Mid-run mode determines how a user's message is handled when the agent +/// is already processing a previous turn. `Steer` injects the message into +/// the active run (interrupt the current trajectory). `Queue` stores it for +/// the next run instead. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MidRunMode { + Steer, + Queue, +} + +impl MidRunMode { + pub fn as_str(&self) -> &'static str { + match self { + MidRunMode::Steer => "steer", + MidRunMode::Queue => "queue", + } + } + + pub fn from_mode_str(s: &str) -> Option { + match s { + "steer" => Some(MidRunMode::Steer), + "queue" => Some(MidRunMode::Queue), + _ => None, + } + } +} + /// Number of context snippets to retrieve from conversation history for /// compaction summarization. const COMPACTION_RAG_LIMIT: usize = 5; @@ -457,6 +484,33 @@ impl Agent { map.remove(user_id).unwrap_or_default() } + /// Get the current MidRunMode for a user. Defaults to Steer. + pub async fn get_mid_run_mode(&self, user_id: &str) -> MidRunMode { + let key = format!("mid_run_mode_{}", user_id); + self.memory + .recall("settings", &key) + .await + .ok() + .flatten() + .and_then(|v| MidRunMode::from_mode_str(&v)) + .unwrap_or(MidRunMode::Steer) + } + + /// Set the MidRunMode for a user. + pub async fn set_mid_run_mode(&self, user_id: &str, mode: MidRunMode) { + let key = format!("mid_run_mode_{}", user_id); + self.memory + .remember("settings", &key, mode.as_str(), None) + .await + .ok(); + } + + /// Delete the MidRunMode for a user (resets to default). + pub async fn delete_mid_run_mode(&self, user_id: &str) { + let key = format!("mid_run_mode_{}", user_id); + self.memory.forget("settings", &key).await.ok(); + } + /// Remove cancel token for a user (called on process_message exit). pub async fn clear_cancel_token(&self, user_id: &str) { self.cancel_token_registry.lock().await.remove(user_id); @@ -754,29 +808,6 @@ impl Agent { break; } - // CHECK: pending injections from user? - let injections = self.drain_injections(user_id).await; - for text in &injections { - let inject_msg = ChatMessage { - role: "user".to_string(), - content: Some(MessageContent::from_text(format!( - "**[User injected mid-processing]:** {}", - text - ))), - tool_calls: None, - tool_call_id: None, - }; - // Save to persistent memory - if let Err(e) = self - .memory - .save_message(&conversation_id, &inject_msg) - .await - { - warn!("Failed to persist injected message: {}", e); - } - messages.push(inject_msg); - } - // --- Empty response recovery: retry loop --- let mut retry_count = 0u32; let response: ChatMessage; @@ -835,6 +866,31 @@ impl Agent { } } + // CHECK: pending injections from user (steer or queue based on mode) + let inject_mode = self.get_mid_run_mode(user_id).await; + let injections = self.drain_injections(user_id).await; + if !injections.is_empty() { + let label = if inject_mode == MidRunMode::Steer { + "**[Steer]:** " + } else { + "**[User injected mid-processing]:** " + }; + for text in &injections { + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("{}{}", label, text))), + tool_calls: None, + tool_call_id: None, + }; + if inject_mode == MidRunMode::Queue { + if let Err(e) = self.memory.save_message(&conversation_id, &msg).await { + warn!("Failed to persist queued injection: {}", e); + } + } + messages.push(msg); + } + } + // Tiers 1-2: sync compaction let base_prompt = prepare_messages_for_llm(&messages, context_window); @@ -1871,7 +1927,10 @@ impl Agent { /// Clear conversation history for a user pub async fn clear_conversation(&self, platform: &str, user_id: &str) -> Result<()> { - self.memory.clear_conversation(platform, user_id).await + self.memory.clear_conversation(platform, user_id).await?; + // Reset mid-run mode to default (Steer) + self.delete_mid_run_mode(user_id).await; + Ok(()) } /// Get all tool definitions for display @@ -2599,31 +2658,35 @@ impl Agent { } /// Ask a parallel question while the main agent is processing. - /// Spawns an isolated ad-hoc subagent with timestamp/location context. - /// Returns the subagent's answer or an error message. - pub async fn ask_parallel(&self, question: &str) -> Result { - let answer = self - .run_subagent( - None, - "Answer the user's follow-up question concisely and accurately using your knowledge.", - question, - None, - None, - ) - .await; - // Detect error patterns from run_subagent/run_subagent_loop: - // - "Subagent '...' error: ..." (API error) - // - "Subagent '...' reached the maximum number of iterations" (max iterations) - // - "Subagent '...' returned an empty response after ... attempts" (empty response) - if answer.starts_with("Subagent '") - && (answer.contains("error") - || answer.contains("reached the maximum") - || answer.contains("empty response")) - { - Err(anyhow::anyhow!("{}", answer)) - } else { - Ok(answer) - } + /// Single LLM call, no tools, no DB access — truly parallel, zero lock contention. + /// Answer is ephemeral and NOT saved to conversation history. + pub async fn ask_parallel_lightweight(&self, question: &str) -> Result { + let system = format!( + "Answer the user's side question concisely from your knowledge. \ + You have NO tools available. Respond in a single message. \ + Current time: {}", + self.build_system_context().await, + ); + let messages = vec![ + ChatMessage { + role: "system".to_string(), + content: Some(MessageContent::from_text(system)), + tool_calls: None, + tool_call_id: None, + }, + ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(question.to_string())), + tool_calls: None, + tool_call_id: None, + }, + ]; + let response = self.llm.chat(&messages, &[]).await?; + Ok(response + .content + .as_ref() + .map(|c| c.as_text()) + .unwrap_or_default()) } /// Get the path for a soul file by name. diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 4ffcd94..380db90 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -8,7 +8,7 @@ use teloxide::prelude::*; use teloxide::types::ParseMode; use tracing::{error, info, warn}; -use crate::agent::Agent; +use crate::agent::{Agent, MidRunMode}; use crate::platform::{Attachment, AttachmentKind, IncomingMessage}; use crate::provider::Provider; use crate::utils::markdown_entities::{markdown_to_entities, split_entities}; @@ -92,6 +92,7 @@ pub(crate) fn supported_commands() -> Vec { "Upgrade the bot to the latest version (source or release binary)", ), BotCommand::new("models", "Browse and change the OpenRouter model"), + BotCommand::new("mode", "Set steer/queue mode for mid-processing messages"), BotCommand::new("stop", "Cancel the current processing gracefully"), BotCommand::new("btw", "Ask a parallel question while the bot is busy"), ] @@ -814,7 +815,7 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe let bot_clone = bot.clone(); let chat_id = msg.chat.id; tokio::spawn(async move { - match agent_clone.ask_parallel(&btw_text).await { + match agent_clone.ask_parallel_lightweight(&btw_text).await { Ok(answer) => { let _ = send_markdown_message(&bot_clone, chat_id, &answer).await; } @@ -974,6 +975,50 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe } } + // Handle /mode command + if text.starts_with("/mode") { + let parts: Vec<&str> = text.splitn(2, |c: char| c.is_whitespace()).collect(); + let sub = parts.get(1).copied().unwrap_or(""); + if sub == "steer" { + agent + .set_mid_run_mode(&user_id.to_string(), MidRunMode::Steer) + .await; + return send_markdown_message( + &bot, msg.chat.id, + "šŸ”„ **Mode set to steer.** Mid-processing messages will be injected as steering context.", + ).await; + } else if sub == "queue" { + agent + .set_mid_run_mode(&user_id.to_string(), MidRunMode::Queue) + .await; + return send_markdown_message( + &bot, + msg.chat.id, + "šŸ”„ **Mode set to queue.** Mid-processing messages will wait for the next turn.", + ) + .await; + } else if sub.is_empty() { + let current = agent.get_mid_run_mode(&user_id.to_string()).await; + let mode_str = current.as_str(); + return send_markdown_message( + &bot, + msg.chat.id, + &format!( + "Current mode: **{}**\n\nUse `/mode steer` or `/mode queue` to change.", + mode_str + ), + ) + .await; + } else { + return send_markdown_message( + &bot, + msg.chat.id, + "Unknown mode. Use `/mode steer` or `/mode queue`.", + ) + .await; + } + } + // Handle /stop command if text == "/stop" { if agent.cancel_processing(&user_id.to_string()).await { @@ -991,15 +1036,9 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe // CHECK: if user is currently being processed, queue non-command messages as injection if !text.starts_with('/') && agent.is_processing(&user_id.to_string()).await { - if agent.queue_injection(&user_id.to_string(), &text).await { - info!("Queued '{}' as injection for user {}", text, user_id); - return send_markdown_message( - &bot, - msg.chat.id, - "šŸ“Ø **Message queued** — will inject into current processing at the next step.", - ) - .await; - } else { + let current_mode = agent.get_mid_run_mode(&user_id.to_string()).await; + let maxed = !agent.queue_injection(&user_id.to_string(), &text).await; + if maxed { return send_markdown_message( &bot, msg.chat.id, @@ -1007,6 +1046,19 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe ) .await; } + info!( + "Queued '{}' as injection for user {} (mode: {:?})", + text, user_id, current_mode + ); + let confirm = match current_mode { + MidRunMode::Steer => { + "šŸ“Ø **Steer queued** — will inject into current processing at next step." + } + MidRunMode::Queue => { + "šŸ“Ø **Message queued** — will process after current task completes." + } + }; + return send_markdown_message(&bot, msg.chat.id, confirm).await; } // Send "typing" indicator diff --git a/src/utils/markdown_entities.rs b/src/utils/markdown_entities.rs index e914cf4..7922c83 100644 --- a/src/utils/markdown_entities.rs +++ b/src/utils/markdown_entities.rs @@ -19,9 +19,92 @@ //! characters whose UTF-16 representation differs from UTF-8. use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; -use teloxide::types::MessageEntity; +use teloxide::types::{MessageEntity, MessageEntityKind}; use tracing::warn; +/// Private Use Area sentinels for Telegram-specific inline formatting. +const SPOILER_START: char = '\u{E000}'; +const SPOILER_END: char = '\u{E001}'; +const UL_START: char = '\u{E002}'; +const UL_END: char = '\u{E003}'; + +fn preprocess_markdown(md: &str) -> String { + let ul_open: String = [UL_START, 'U'].iter().collect(); + let ul_close: String = [UL_END, '/', 'u'].iter().collect(); + let spoiler_open: String = [SPOILER_START, 'S'].iter().collect(); + let spoiler_close: String = [SPOILER_END, '/', 's'].iter().collect(); + + let md = md.replace("", &ul_open).replace("", &ul_close); + let re = regex::Regex::new(r"\|\|(.*?)\|\|").unwrap(); + re.replace_all(&md, format!("{spoiler_open}$1{spoiler_close}")) + .to_string() +} + +fn postprocess_entities(plain: &mut String, entities: &mut Vec) { + let mut utf16_offset = 0usize; + let mut out = String::new(); + let mut stack: Vec<(MessageEntityKind, usize)> = Vec::new(); + let chars: Vec = plain.chars().collect(); + let mut i = 0; + + while i < chars.len() { + match chars[i] { + c if c == SPOILER_START && i + 1 < chars.len() && chars[i + 1] == 'S' => { + stack.push((MessageEntityKind::Spoiler, utf16_offset)); + i += 2; + continue; + } + c if c == SPOILER_END + && i + 2 < chars.len() + && chars[i + 1] == '/' + && chars[i + 2] == 's' => + { + if let Some(idx) = stack + .iter() + .rposition(|(k, _)| *k == MessageEntityKind::Spoiler) + { + let (_, start) = stack.remove(idx); + let len = utf16_offset - start; + if len > 0 { + entities.push(MessageEntity::spoiler(start, len)); + } + } + i += 3; + continue; + } + c if c == UL_START && i + 1 < chars.len() && chars[i + 1] == 'U' => { + stack.push((MessageEntityKind::Underline, utf16_offset)); + i += 2; + continue; + } + c if c == UL_END + && i + 2 < chars.len() + && chars[i + 1] == '/' + && chars[i + 2] == 'u' => + { + if let Some(idx) = stack + .iter() + .rposition(|(k, _)| *k == MessageEntityKind::Underline) + { + let (_, start) = stack.remove(idx); + let len = utf16_offset - start; + if len > 0 { + entities.push(MessageEntity::underline(start, len)); + } + } + i += 3; + continue; + } + _ => { + out.push(chars[i]); + utf16_offset += chars[i].len_utf16(); + i += 1; + } + } + } + *plain = out; +} + /// Convert `markdown` to a `(plain_text, entities)` pair ready to pass to Telegram. /// /// The returned `plain_text` contains no Markdown syntax — all formatting information @@ -41,7 +124,8 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_GFM); - let parser = Parser::new_ext(markdown, options); + let processed = preprocess_markdown(markdown); + let parser = Parser::new_ext(&processed, options); let mut plain = String::new(); let mut entities: Vec = Vec::new(); @@ -53,25 +137,32 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { // Track UTF-16 length incrementally to avoid O(n²) rescanning let mut plain_utf16_len = 0usize; - // State for blockquote rendering - let mut in_blockquote = false; + // State for blockquote entity + let mut blockquote_start: Option = None; + + // State for list rendering: None = unordered, Some(n) = ordered starting at n + let mut list_counter: Option = None; + let mut needs_list_prefix = false; for event in parser { match event { // --- Text content --- Event::Text(text) => { - if in_blockquote { - let quoted: String = text - .lines() - .map(|line| format!("> {}", line)) - .collect::>() - .join("\n"); - plain.push_str("ed); - plain_utf16_len += quoted.encode_utf16().count(); - } else { - plain.push_str(&text); - plain_utf16_len += text.encode_utf16().count(); + if needs_list_prefix { + let prefix: String = match list_counter { + None => "\u{2022} ".to_string(), + Some(ref mut n) => { + let p = format!("{}. ", n); + *n += 1; + p + } + }; + plain.push_str(&prefix); + plain_utf16_len += prefix.encode_utf16().count(); + needs_list_prefix = false; } + plain.push_str(&text); + plain_utf16_len += text.encode_utf16().count(); } Event::Code(text) => { // Inline code: emit as a Code entity @@ -125,13 +216,19 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { stack.push((StackTag::CodeBlock(lang), plain_utf16_len)); } Tag::BlockQuote(_) => { - in_blockquote = true; + blockquote_start = Some(plain_utf16_len); } Tag::Table(_) => { // Table alignment metadata is discarded — rendered as plain text } Tag::TableHead | Tag::TableRow => {} Tag::TableCell => {} + Tag::List(start) => { + list_counter = start.map(|n| n as usize); + } + Tag::Item => { + needs_list_prefix = true; + } // Paragraph, list, etc. — no entity emitted on start. _ => {} }, @@ -215,7 +312,16 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { plain_utf16_len += 1; } TagEnd::BlockQuote(_) => { - in_blockquote = false; + if let Some(start) = blockquote_start.take() { + let length = plain_utf16_len.saturating_sub(start); + if length > 0 { + entities.push(MessageEntity { + kind: MessageEntityKind::Blockquote, + offset: start, + length, + }); + } + } if !plain.ends_with('\n') { plain.push('\n'); plain_utf16_len += 1; @@ -234,6 +340,10 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { plain.push('\n'); plain_utf16_len += 1; } + TagEnd::List(_) => { + list_counter = None; + needs_list_prefix = false; + } _ => {} } } @@ -249,6 +359,8 @@ pub fn markdown_to_entities(markdown: &str) -> (String, Vec) { plain_utf16_len -= 1; } + postprocess_entities(&mut plain, &mut entities); + (plain, entities) } @@ -640,16 +752,23 @@ mod tests { // --- Blockquotes --- #[test] - fn test_blockquote_prefixes_with_gt() { - let (text, _) = markdown_to_entities("> This is a quote"); - assert!( - text.contains("> "), - "blockquote must be prefixed with '> ': {text}" - ); + fn test_blockquote_emits_entity() { + let (text, entities) = markdown_to_entities("> This is a quote"); assert!( text.contains("This is a quote"), "blockquote text must be present: {text}" ); + assert!( + !text.contains("> "), + "blockquote must NOT have '> ' prefix when entity is used" + ); + let blockquote = entities + .iter() + .find(|e| matches!(e.kind, MessageEntityKind::Blockquote)); + assert!( + blockquote.is_some(), + "blockquote must produce a Blockquote entity" + ); } // --- Tables --- @@ -663,4 +782,67 @@ mod tests { assert!(text.contains('1'), "row 1 col 1 must be in output: {text}"); assert!(text.contains('2'), "row 1 col 2 must be in output: {text}"); } + + // --- Spoilers and Underline --- + + #[test] + fn test_spoiler_converts_to_entity() { + let (text, entities) = markdown_to_entities("||hidden||"); + assert_eq!(text, "hidden"); + assert!(entities + .iter() + .any(|e| matches!(e.kind, MessageEntityKind::Spoiler))); + } + + #[test] + fn test_underline_converts_to_entity() { + let (text, entities) = markdown_to_entities("underlined"); + assert_eq!(text, "underlined"); + assert!(entities + .iter() + .any(|e| matches!(e.kind, MessageEntityKind::Underline))); + } + + #[test] + fn test_spoiler_with_bold() { + let (text, entities) = markdown_to_entities("**bold** and ||spoiler||"); + assert!(text.contains("bold")); + assert!(text.contains("spoiler")); + assert!(entities + .iter() + .any(|e| matches!(e.kind, MessageEntityKind::Bold))); + assert!(entities + .iter() + .any(|e| matches!(e.kind, MessageEntityKind::Spoiler))); + } + + // --- Lists --- + + #[test] + fn test_unordered_list_renders_with_bullets() { + let input = "- item one\n- item two"; + let (text, _) = markdown_to_entities(input); + assert!( + text.contains("\u{2022} item one"), + "unordered list must use bullet: {text}" + ); + assert!( + text.contains("\u{2022} item two"), + "second item must also have bullet: {text}" + ); + } + + #[test] + fn test_ordered_list_renders_with_numbers() { + let input = "1. first\n2. second"; + let (text, _) = markdown_to_entities(input); + assert!( + text.contains("1. first"), + "ordered list must use number: {text}" + ); + assert!( + text.contains("2. second"), + "second item must use next number: {text}" + ); + } } From 1c9103f7b9cf73206482b880e0dabb1356543057 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 10:27:53 +0800 Subject: [PATCH 16/69] docs: add design spec for RichBlockTable / sendRichMessage conversion pipeline --- ...07-10-richblock-table-conversion-design.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md diff --git a/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md b/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md new file mode 100644 index 0000000..cb45e71 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md @@ -0,0 +1,171 @@ +# Rich Message Conversion — Native Tables via sendRichMessage + +**Date:** 2026-07-10 +**Feature:** Convert RustFox's markdown→entity pipeline to use `sendRichMessage` (Bot API 10.1) +**Primary benefit:** Native `RichBlockTable` rendering for markdown pipe tables + +## Problem + +RustFox currently renders markdown pipe tables as plain text with pipe separators: + +``` +A | B +1 | 2 +``` + +Telegram Bot API 10.1 (June 11, 2026) introduced `sendRichMessage` with `RichBlockTable` +— native styled tables with borders, striping, captions, and per-cell formatting. The +existing entity-based `sendMessage` path cannot produce these tables. + +## Solution + +Add a new `sendRichMessage`-based sending path that tunnels raw markdown through the +`InputRichMessage` API. Telegram's server-side RichMessage markdown parser recognizes +pipe tables and converts them to `RichBlockTable` blocks automatically. + +### Architecture + +``` +LLM output (markdown with | tables |) + │ + ā–¼ +preprocess_markdown() (spoiler ||...|| / ...) + │ + ā–¼ +send_rich_message() ──► POST /sendRichMessage + │ │ + │ ā”œā”€ success (200) → native Telegram rendering + │ │ (RichBlockTable, rich text, lists, etc.) + │ │ + │ └─ HTTP 400 ──► sendMessage(entities) fallback + │ + ā–¼ +edit_rich_message() ──► POST /editMessageText { rich_message } + (streaming final flush only) +``` + +### New components + +#### `src/utils/rich_sender.rs` — 3 public functions + +##### `send_rich_message(bot_token, chat_id, markdown) -> Result` + +Single message via `POST /bot{token}/sendRichMessage` with payload: + +```json +{ + "chat_id": 12345, + "rich_message": { + "markdown": "...", + "skip_entity_detection": true + } +} +``` + +- Pre-processes markdown using existing `preprocess_markdown()` (spoiler/underline) +- Returns the `Message` on success +- Returns `Err` on HTTP 400 (parse failure triggers fallback) or network errors + +##### `edit_rich_message(bot_token, chat_id, msg_id, markdown) -> Result` + +Edit existing message via `POST /bot{token}/editMessageText` with `rich_message` parameter. +Same payload shape minus `chat_id`/`message_id`. + +##### `send_rich_messages(bot_token, chat_id, markdown) -> Result<()>` + +Auto-chunks long markdown content at `\n` boundaries (max 4000 UTF-16 code units). +Sends chunks sequentially via `send_rich_message`. Returns error if the first chunk +fails (subsequent chunk errors are logged but ignored, matching current behaviour). + +#### `try_send_rich_fallback(bot_token, chat_id, markdown, entity_sender)` — fallback helper + +1. Pre-process markdown +2. Try `send_rich_messages` +3. On HTTP 400: call `entity_sender` closure (the existing entity pipeline) +4. On network error: propagate error + +### Changes to existing files + +#### `src/platform/telegram.rs` + +**`send_markdown_message()` (line 225):** + +Replace current entity-only implementation with: + +```rust +async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { + let token = bot.inner().token(); // or passed from main + let entity_sender = |md: &str| { + let (text, entities) = markdown_to_entities(md); + let chunks = split_entities(&text, &entities, 4090); + for (i, (t, e)) in chunks.iter().enumerate() { + if i == 0 { + bot.send_message(chat_id, t).entities(e.clone()).await?; + } else { + bot.send_message(chat_id, t).entities(e.clone()).await.ok(); + } + } + Ok(()) + }; + try_send_rich_fallback(token, chat_id, markdown, entity_sender).await?; + Ok(()) +} +``` + +**Streaming final flush (lines 1214-1241):** + +On final flush, for the first chunk (which has an existing `msg_id` from streaming): +```rust +edit_rich_message(token, chat_id, msg_id, chunk_markdown).await.ok() +``` +Fall back to current entity-based edit on failure. + +For trailing chunks (new messages): +```rust +send_rich_message(token, chat_id, chunk_markdown).await.ok() +``` + +The streaming path pre-processes the full accumulated markdown. + +#### `src/utils/markdown_entities.rs` + +No changes needed — entity pipeline is retained as the fallback path. + +### Data flow + +1. LLM produces markdown (may include `| A | B |\n|---|---|\n| 1 | 2 |` tables) +2. `send_markdown_message` receives the markdown string +3. `preprocess_markdown()` converts `||spoiler||` and `underline` (same as today) +4. `send_rich_messages` chunks at `\n` boundaries, max 4000 UTF-16 per chunk +5. Each chunk POSTed to `/sendRichMessage` with `InputRichMessage { markdown, skip_entity_detection: true }` +6. Telegram server parses markdown into RichBlock tree — tables become `RichBlockTable` +7. On HTTP 400: retry with `markdown_to_entities` + `sendMessage(entities)` path + +### Error handling + +| Scenario | Behaviour | +|----------|-----------| +| `sendRichMessage` returns 400 (bad markdown) | Fall back to entity-based `sendMessage` | +| `sendRichMessage` returns 5xx or network error | Propagate to caller (same as current) | +| First chunk fails | Return error to caller | +| Subsequent chunk fails | Log warning, skip chunk (same as current entity split) | +| `edit_rich_message` fails (streaming flush) | Fall back to entity-based edit | + +### Testing + +- **Unit test `test_rich_sender_chunking`**: verify markdown is split at newline boundaries, max 4000 UTF-16 +- **Integration test `test_rich_sender_api`**: mock HTTP responses for sendRichMessage +- **Existing entity tests unchanged**: entity path still works as fallback + +### Dependencies + +No new crate dependencies. `reqwest` already in `Cargo.toml` (used by `llm.rs`). +`serde_json` already in `Cargo.toml` (used everywhere). + +### Implementation order + +1. Create `src/utils/rich_sender.rs` with `send_rich_message`, `send_rich_messages`, `edit_rich_message`, `try_send_rich_fallback` +2. Modify `src/platform/telegram.rs` — replace `send_markdown_message` to try rich first +3. Modify streaming final flush to try rich message for edits +4. Add unit tests for chunking + API error fallback +5. Build, lint, test From a3637e307c597b0cfdbe84890f595fa5ced5bf43 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 10:33:29 +0800 Subject: [PATCH 17/69] =?UTF-8?q?docs:=20address=20spec=20review=20?= =?UTF-8?q?=E2=80=94=20token=20access,=20error=20types,=20struct=20definit?= =?UTF-8?q?ions,=20fallback=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...07-10-richblock-table-conversion-design.md | 196 ++++++++++++++---- 1 file changed, 150 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md b/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md index cb45e71..d524796 100644 --- a/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md +++ b/docs/superpowers/specs/2026-07-10-richblock-table-conversion-design.md @@ -37,18 +37,57 @@ send_rich_message() ──► POST /sendRichMessage │ ā”œā”€ success (200) → native Telegram rendering │ │ (RichBlockTable, rich text, lists, etc.) │ │ - │ └─ HTTP 400 ──► sendMessage(entities) fallback + │ └─ RichSenderError::BadMarkdown(400) + │ ──► sendMessage(entities) fallback │ ā–¼ -edit_rich_message() ──► POST /editMessageText { rich_message } +edit_rich_message() ──► POST /editMessageText { message_id, rich_message } (streaming final flush only) + +Error type: + RichSenderError::BadMarkdown(StatusCode) → triggers fallback + RichSenderError::Network(String) → propagated to caller (fatal) ``` -### New components +### `src/utils/rich_sender.rs` — new module + +#### Rust structs for JSON serialization + +```rust +#[derive(Serialize)] +struct InputRichMessage { + markdown: String, + #[serde(rename = "skip_entity_detection")] + skip_entity_detection: bool, +} + +#[derive(Serialize)] +struct SendRichMessagePayload { + chat_id: i64, + rich_message: InputRichMessage, +} -#### `src/utils/rich_sender.rs` — 3 public functions +#[derive(Serialize)] +struct EditRichMessagePayload { + chat_id: i64, + message_id: i32, + rich_message: InputRichMessage, +} +``` -##### `send_rich_message(bot_token, chat_id, markdown) -> Result` +#### `RichSenderError` enum + +```rust +#[derive(Debug)] +pub enum RichSenderError { + /// HTTP 400 from Telegram — bad markdown, triggers entity fallback. + BadMarkdown(String), + /// HTTP 5xx, network error, etc. — propagated as fatal. + Network(anyhow::Error), +} +``` + +#### `send_rich_message(token, chat_id, markdown) -> Result` Single message via `POST /bot{token}/sendRichMessage` with payload: @@ -62,39 +101,75 @@ Single message via `POST /bot{token}/sendRichMessage` with payload: } ``` -- Pre-processes markdown using existing `preprocess_markdown()` (spoiler/underline) -- Returns the `Message` on success -- Returns `Err` on HTTP 400 (parse failure triggers fallback) or network errors +- Pre-processes markdown using `preprocess_markdown()` (made `pub(crate)` in `markdown_entities.rs`) +- Returns `Message` deserialized from Telegram's response on success +- Returns `RichSenderError::BadMarkdown` on HTTP 400 +- Returns `RichSenderError::Network` on 5xx, timeout, connection failure -##### `edit_rich_message(bot_token, chat_id, msg_id, markdown) -> Result` +#### `edit_rich_message(token, chat_id, msg_id, markdown) -> Result` -Edit existing message via `POST /bot{token}/editMessageText` with `rich_message` parameter. -Same payload shape minus `chat_id`/`message_id`. +Edit existing message via `POST /bot{token}/editMessageText` with payload: -##### `send_rich_messages(bot_token, chat_id, markdown) -> Result<()>` +```json +{ + "chat_id": 12345, + "message_id": 678, + "rich_message": { + "markdown": "...", + "skip_entity_detection": true + } +} +``` -Auto-chunks long markdown content at `\n` boundaries (max 4000 UTF-16 code units). -Sends chunks sequentially via `send_rich_message`. Returns error if the first chunk -fails (subsequent chunk errors are logged but ignored, matching current behaviour). +Note: `editMessageText` requires both `chat_id` AND `message_id`. +The `rich_message` field is an additional parameter alongside the existing fields. -#### `try_send_rich_fallback(bot_token, chat_id, markdown, entity_sender)` — fallback helper +#### `send_rich_messages(token, chat_id, markdown) -> Result<(), RichSenderError>` -1. Pre-process markdown +Auto-chunks long markdown content at `\n` boundaries (max 4090 UTF-16 code units — +matching the existing entity split limit for consistency). Sends chunks sequentially +via `send_rich_message`. Returns error if the first chunk fails (subsequent chunk +errors are logged but ignored). + +#### `try_send_rich_fallback(token, chat_id, markdown, entity_sender) -> Result<()>` + +Helper that implements the try-rich-then-fallback pattern: + +1. Pre-process markdown via `preprocess_markdown()` 2. Try `send_rich_messages` -3. On HTTP 400: call `entity_sender` closure (the existing entity pipeline) -4. On network error: propagate error +3. On `RichSenderError::BadMarkdown`: call `entity_sender` closure with original markdown +4. On `RichSenderError::Network`: propagate to caller (fatal) ### Changes to existing files +#### `src/utils/markdown_entities.rs` + +- Change `preprocess_markdown()` from private `fn` to `pub(crate) fn` so `rich_sender.rs` can call it. +- `postprocess_entities()`, `markdown_to_entities()`, `split_entities()` — unchanged (used by fallback path). + #### `src/platform/telegram.rs` -**`send_markdown_message()` (line 225):** +**Token storage:** + +Add a module-level `OnceLock` to store the bot token at startup: + +```rust +use std::sync::OnceLock; + +static BOT_TOKEN: OnceLock = OnceLock::new(); -Replace current entity-only implementation with: +pub fn init_bot_token(token: String) { + BOT_TOKEN.set(token).ok(); +} +``` + +Called once during `run_bot()` in `main.rs` after `Bot::new(&config.telegram.bot_token)`. + +**`send_markdown_message()` (line 225):** ```rust async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { - let token = bot.inner().token(); // or passed from main + let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized"); let entity_sender = |md: &str| { let (text, entities) = markdown_to_entities(md); let chunks = split_entities(&text, &entities, 4090); @@ -105,67 +180,96 @@ async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> Re bot.send_message(chat_id, t).entities(e.clone()).await.ok(); } } - Ok(()) + Ok::<_, teloxide::RequestError>(()) }; - try_send_rich_fallback(token, chat_id, markdown, entity_sender).await?; - Ok(()) + match try_send_rich_fallback(token, chat_id, markdown, &entity_sender).await { + Ok(()) => Ok(()), + Err(RichSenderError::Network(e)) => { + tracing::warn!(error = %e, "send_rich_message network error, fallback skipped"); + // entity_sender already called inside try_send_rich_fallback on BadMarkdown; + // Network errors mean we propagate + entity_sender(markdown).await.map_err(|_| { + teloxide::request::RequestError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + }) + } + } } ``` **Streaming final flush (lines 1214-1241):** -On final flush, for the first chunk (which has an existing `msg_id` from streaming): +On final flush, for the first chunk (existing `msg_id` from streaming): ```rust -edit_rich_message(token, chat_id, msg_id, chunk_markdown).await.ok() +if let Some(msg_id) = current_msg_id { + if edit_rich_message(token, stream_chat_id, msg_id.0 as i32, &chunk_markdown).await.is_err() { + // fallback to entity edit + stream_bot.edit_message_text(stream_chat_id, msg_id, chunk_text) + .entities(chunk_entities.clone()).await.ok(); + } +} ``` -Fall back to current entity-based edit on failure. For trailing chunks (new messages): ```rust -send_rich_message(token, chat_id, chunk_markdown).await.ok() +if send_rich_message(token, stream_chat_id, &chunk_markdown).await.is_err() { + stream_bot.send_message(stream_chat_id, chunk_text) + .entities(chunk_entities.clone()).await.ok(); +} ``` -The streaming path pre-processes the full accumulated markdown. +#### `src/main.rs` -#### `src/utils/markdown_entities.rs` +After `let bot = Arc::new(teloxide::Bot::new(&config.telegram.bot_token));`, add: -No changes needed — entity pipeline is retained as the fallback path. +```rust +rustfox::platform::telegram::init_bot_token(config.telegram.bot_token.clone()); +``` ### Data flow 1. LLM produces markdown (may include `| A | B |\n|---|---|\n| 1 | 2 |` tables) 2. `send_markdown_message` receives the markdown string 3. `preprocess_markdown()` converts `||spoiler||` and `underline` (same as today) -4. `send_rich_messages` chunks at `\n` boundaries, max 4000 UTF-16 per chunk +4. `send_rich_messages` chunks at `\n` boundaries, max 4090 UTF-16 per chunk 5. Each chunk POSTed to `/sendRichMessage` with `InputRichMessage { markdown, skip_entity_detection: true }` 6. Telegram server parses markdown into RichBlock tree — tables become `RichBlockTable` -7. On HTTP 400: retry with `markdown_to_entities` + `sendMessage(entities)` path +7. On `RichSenderError::BadMarkdown`: retry with `markdown_to_entities` + `sendMessage(entities)` path +8. On `RichSenderError::Network`: log warning, try entity fallback as last resort ### Error handling | Scenario | Behaviour | |----------|-----------| -| `sendRichMessage` returns 400 (bad markdown) | Fall back to entity-based `sendMessage` | -| `sendRichMessage` returns 5xx or network error | Propagate to caller (same as current) | -| First chunk fails | Return error to caller | -| Subsequent chunk fails | Log warning, skip chunk (same as current entity split) | -| `edit_rich_message` fails (streaming flush) | Fall back to entity-based edit | +| `sendRichMessage` returns 400 (bad markdown) | Fall back to entity-based `sendMessage` for full content | +| `sendRichMessage` returns 5xx or network error | Log warning, attempt entity fallback | +| First chunk fails (any error) | Full content retried via entity fallback (entire message falls back) | +| Subsequent chunk fails after rich success | Log warning, skip chunk (degradation: part of message lost) | +| `edit_rich_message` fails (streaming flush) | Fall back to entity-based edit for that chunk | +| Bot API server too old (no `sendRichMessage`) | Every call returns 400, all messages fall back to entities | +| `preprocess_markdown` is called on original markdown (not pre-processed) for entity fallback | Entities handle spoiler/underline independently via their own pipeline | ### Testing -- **Unit test `test_rich_sender_chunking`**: verify markdown is split at newline boundaries, max 4000 UTF-16 -- **Integration test `test_rich_sender_api`**: mock HTTP responses for sendRichMessage +- **Unit test `test_rich_sender_chunking`**: verify markdown is split at newline boundaries, max 4090 UTF-16 +- **Unit test `test_rich_sender_error_type`**: verify `RichSenderError` variants match expected discriminator +- **Integration test `test_rich_sender_api`**: mock HTTP responses for sendRichMessage (400 vs 200) - **Existing entity tests unchanged**: entity path still works as fallback ### Dependencies No new crate dependencies. `reqwest` already in `Cargo.toml` (used by `llm.rs`). `serde_json` already in `Cargo.toml` (used everywhere). +`once_cell` already in dependency tree (transitive from teloxide); prefer `std::sync::OnceLock` (Rust 1.70+). ### Implementation order -1. Create `src/utils/rich_sender.rs` with `send_rich_message`, `send_rich_messages`, `edit_rich_message`, `try_send_rich_fallback` -2. Modify `src/platform/telegram.rs` — replace `send_markdown_message` to try rich first -3. Modify streaming final flush to try rich message for edits -4. Add unit tests for chunking + API error fallback -5. Build, lint, test +1. Make `preprocess_markdown` `pub(crate)` in `markdown_entities.rs` +2. Create `src/utils/rich_sender.rs` with structs, `RichSenderError`, `send_rich_message`, `send_rich_messages`, `edit_rich_message`, `try_send_rich_fallback` +3. Add `init_bot_token()` and `BOT_TOKEN` static to `telegram.rs`; wire in `main.rs` +4. Modify `send_markdown_message()` in `telegram.rs` — try rich first, fall back to entities +5. Modify streaming final flush to try rich message for edits (new messages too) +6. Add unit tests for chunking + API error fallback +7. `cargo build`, `cargo clippy -- -D warnings`, `cargo test` From 44ddd8bd052ed948e594a81a6e0a4dbf23b4e88e Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 11:15:00 +0800 Subject: [PATCH 18/69] feat(rich): make preprocess_markdown pub(crate) for rich_sender reuse --- src/utils/markdown_entities.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/markdown_entities.rs b/src/utils/markdown_entities.rs index 7922c83..2d39e56 100644 --- a/src/utils/markdown_entities.rs +++ b/src/utils/markdown_entities.rs @@ -28,7 +28,7 @@ const SPOILER_END: char = '\u{E001}'; const UL_START: char = '\u{E002}'; const UL_END: char = '\u{E003}'; -fn preprocess_markdown(md: &str) -> String { +pub(crate) fn preprocess_markdown(md: &str) -> String { let ul_open: String = [UL_START, 'U'].iter().collect(); let ul_close: String = [UL_END, '/', 'u'].iter().collect(); let spoiler_open: String = [SPOILER_START, 'S'].iter().collect(); From 239dcba0ac55291f51e6361f72ccf951250c224e Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 11:18:47 +0800 Subject: [PATCH 19/69] feat(rich): add rich_sender module wrapping sendRichMessage API --- src/utils/mod.rs | 1 + src/utils/rich_sender.rs | 244 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/utils/rich_sender.rs diff --git a/src/utils/mod.rs b/src/utils/mod.rs index eccfbdb..3a19678 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod markdown_entities; +pub mod rich_sender; pub mod strings; pub mod telegram_markdown; diff --git a/src/utils/rich_sender.rs b/src/utils/rich_sender.rs new file mode 100644 index 0000000..6849d4c --- /dev/null +++ b/src/utils/rich_sender.rs @@ -0,0 +1,244 @@ +use serde::{Deserialize, Serialize}; +use std::future::Future; +use tracing::warn; + +/// Error type distinguishing bad-markdown (retriable) from network (fatal). +#[derive(Debug)] +pub enum RichSenderError { + /// HTTP 400 from Telegram — bad markdown, triggers entity fallback. + BadMarkdown(String), + /// HTTP 5xx, network error, etc. — propagated as fatal. + Network(anyhow::Error), +} + +impl std::fmt::Display for RichSenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RichSenderError::BadMarkdown(msg) => write!(f, "bad markdown: {msg}"), + RichSenderError::Network(e) => write!(f, "network error: {e}"), + } + } +} + +impl std::error::Error for RichSenderError {} + +// --------------------------------------------------------------------------- +// JSON payload shapes for the Telegram Bot API +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct InputRichMessage { + markdown: String, + #[serde(rename = "skip_entity_detection")] + skip_entity_detection: bool, +} + +#[derive(Serialize)] +struct SendRichMessagePayload { + chat_id: i64, + rich_message: InputRichMessage, +} + +#[derive(Serialize)] +struct EditRichMessagePayload { + chat_id: i64, + message_id: i32, + rich_message: InputRichMessage, +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +fn build_client() -> reqwest::Client { + reqwest::Client::new() +} + +fn api_url(token: &str, method: &str) -> String { + format!("https://api.telegram.org/bot{token}/{method}") +} + +async fn parse_response(response: reqwest::Response) -> Result { + let status = response.status(); + let body = response.text().await; + + #[derive(Deserialize)] + struct TgResponse { + ok: bool, + description: Option, + result: Option, + } + + let body = match body { + Ok(b) => b, + Err(e) => return Err(RichSenderError::Network(e.into())), + }; + + let parsed: TgResponse = match serde_json::from_str(&body) { + Ok(p) => p, + Err(e) => return Err(RichSenderError::Network(e.into())), + }; + + if parsed.ok { + Ok(parsed.result.unwrap_or(serde_json::Value::Null)) + } else if status == 400 || status == 422 { + Err(RichSenderError::BadMarkdown( + parsed.description.unwrap_or_default(), + )) + } else { + Err(RichSenderError::Network(anyhow::anyhow!( + "Telegram API error ({}): {}", + status, + parsed.description.unwrap_or_default() + ))) + } +} + +/// Send a single message via `sendRichMessage`. +pub async fn send_rich_message( + token: &str, + chat_id: i64, + markdown: &str, +) -> Result { + let client = build_client(); + let payload = SendRichMessagePayload { + chat_id, + rich_message: InputRichMessage { + markdown: markdown.to_string(), + skip_entity_detection: true, + }, + }; + + let response = client + .post(api_url(token, "sendRichMessage")) + .json(&payload) + .send() + .await + .map_err(|e| RichSenderError::Network(e.into()))?; + + parse_response(response).await +} + +/// Edit an existing message via `editMessageText` with `rich_message` param. +pub async fn edit_rich_message( + token: &str, + chat_id: i64, + message_id: i32, + markdown: &str, +) -> Result { + let client = build_client(); + let payload = EditRichMessagePayload { + chat_id, + message_id, + rich_message: InputRichMessage { + markdown: markdown.to_string(), + skip_entity_detection: true, + }, + }; + + let response = client + .post(api_url(token, "editMessageText")) + .json(&payload) + .send() + .await + .map_err(|e| RichSenderError::Network(e.into()))?; + + parse_response(response).await +} + +/// Send potentially-long markdown split at newline boundaries (max 4090 UTF-16). +/// Returns error only if the FIRST chunk fails (subsequent errors logged only). +pub async fn send_rich_messages( + token: &str, + chat_id: i64, + markdown: &str, +) -> Result<(), RichSenderError> { + const MAX_UTF16: usize = 4090; + + let total_utf16 = markdown.encode_utf16().count(); + if total_utf16 <= MAX_UTF16 { + return send_rich_message(token, chat_id, markdown) + .await + .map(|_| ()); + } + + let chunks = split_markdown_at_newlines(markdown, MAX_UTF16); + + for (i, chunk) in chunks.iter().enumerate() { + if i == 0 { + send_rich_message(token, chat_id, chunk).await?; + } else if let Err(e) = send_rich_message(token, chat_id, chunk).await { + warn!("send_rich_message trailing chunk {i} failed: {e}"); + } + } + Ok(()) +} + +/// Split markdown at newline boundaries so each chunk fits within `max_utf16`. +pub(crate) fn split_markdown_at_newlines(text: &str, max_utf16: usize) -> Vec { + let mut result = Vec::new(); + let mut start = 0usize; + let total = text.encode_utf16().count(); + + while start < total { + let ideal_end = (start + max_utf16).min(total); + // Find the closest newline before ideal_end + let mut split_at = ideal_end; + // Convert byte positions for substring search + let byte_start = char_boundary_from_utf16(text, start); + let byte_ideal = char_boundary_from_utf16(text, ideal_end); + if let Some(newline_byte) = text[byte_start..byte_ideal].rfind('\n') { + let newline_utf16 = text[..byte_start + newline_byte + 1].encode_utf16().count(); + if newline_utf16 > start { + split_at = newline_utf16; + } + } + + // convert to byte slice + let byte_start = char_boundary_from_utf16(text, start); + let byte_end = char_boundary_from_utf16(text, split_at); + result.push(text[byte_start..byte_end].to_string()); + start = split_at; + } + + result +} + +fn char_boundary_from_utf16(text: &str, utf16_offset: usize) -> usize { + let mut utf16_so_far = 0; + for (byte_pos, ch) in text.char_indices() { + if utf16_so_far >= utf16_offset { + return byte_pos; + } + utf16_so_far += ch.len_utf16(); + } + text.len() +} + +/// Try sending via sendRichMessage; on BadMarkdown, call `entity_sender` as fallback. +pub async fn try_send_rich_fallback( + token: &str, + chat_id: i64, + markdown: &str, + entity_sender: F, +) -> Result<(), RichSenderError> +where + F: FnOnce() -> Fut, + Fut: Future>, + E: std::fmt::Display, +{ + let processed = crate::utils::markdown_entities::preprocess_markdown(markdown); + match send_rich_messages(token, chat_id, &processed).await { + Ok(()) => Ok(()), + Err(RichSenderError::BadMarkdown(msg)) => { + warn!("sendRichMessage failed (bad markdown), falling back to entities: {msg}"); + entity_sender() + .await + .map_err(|e| RichSenderError::Network(anyhow::anyhow!("fallback: {e}"))) + } + Err(e @ RichSenderError::Network(_)) => { + warn!("sendRichMessage network error, propagating to caller: {e}"); + Err(e) + } + } +} From d38ec1875d9654c6397cecdca60a6059655d838f Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 11:27:22 +0800 Subject: [PATCH 20/69] feat(rich): add BOT_TOKEN static and init_bot_token to telegram module --- src/main.rs | 2 ++ src/platform/telegram.rs | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/main.rs b/src/main.rs index 233e077..dc3906f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,6 +208,8 @@ async fn main() -> Result<()> { // Create Bot early so it can be passed to Agent let bot = Arc::new(teloxide::Bot::new(&config.telegram.bot_token)); + rustfox::platform::telegram::init_bot_token(config.telegram.bot_token.clone()); + // Channel for dispatching scheduled job work from fire closures to background runner let (job_tx, mut job_rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 380db90..8cb0eb8 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -13,6 +13,14 @@ use crate::platform::{Attachment, AttachmentKind, IncomingMessage}; use crate::provider::Provider; use crate::utils::markdown_entities::{markdown_to_entities, split_entities}; use crate::utils::telegram_markdown::escape_text; +use std::sync::OnceLock; + +static BOT_TOKEN: OnceLock = OnceLock::new(); + +/// Must be called once at startup after the Bot is created. +pub fn init_bot_token(token: String) { + BOT_TOKEN.set(token).ok(); +} /// Split long messages for Telegram's 4096 char limit #[cfg(test)] From 4d24da453440185d2f298968a49e4c7c1488a291 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 11:49:18 +0800 Subject: [PATCH 21/69] feat(rich): make send_markdown_message try sendRichMessage first with entity fallback --- src/platform/telegram.rs | 55 +++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 8cb0eb8..188c554 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -12,6 +12,7 @@ use crate::agent::{Agent, MidRunMode}; use crate::platform::{Attachment, AttachmentKind, IncomingMessage}; use crate::provider::Provider; use crate::utils::markdown_entities::{markdown_to_entities, split_entities}; +use crate::utils::rich_sender; use crate::utils::telegram_markdown::escape_text; use std::sync::OnceLock; @@ -228,31 +229,45 @@ pub async fn run( Ok(()) } -/// Send a markdown string as entity-formatted message(s), splitting if needed. -/// Returns Ok if at least one message was sent successfully. +/// Send a markdown string as a rich message via sendRichMessage, falling back +/// to entity-formatted sendMessage on failure. async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { - const MAX_UTF16: usize = 4090; - let (plain_text, entities) = markdown_to_entities(markdown); - let chunks = split_entities(&plain_text, &entities, MAX_UTF16); + let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized"); - if chunks.is_empty() { - bot.send_message(chat_id, "Done.").await?; - return Ok(()); - } + let entity_sender = || async { + let (text, entities) = markdown_to_entities(markdown); + let chunks = split_entities(&text, &entities, 4090); + if chunks.is_empty() { + return Ok::<_, teloxide::RequestError>(()); + } + for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { + if i == 0 { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await?; + } else { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } + } + Ok(()) + }; - for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { - if i == 0 { - bot.send_message(chat_id, chunk_text) - .entities(chunk_entities.clone()) - .await?; - } else { - bot.send_message(chat_id, chunk_text) - .entities(chunk_entities.clone()) - .await - .ok(); + match rich_sender::try_send_rich_fallback(token, chat_id.0, markdown, &entity_sender).await { + Ok(()) => Ok(()), + Err(e) => { + // try_send_rich_fallback already handled BadMarkdown by calling + // entity_sender internally. If that fallback also failed (or the + // rich path had a network error), propagate the error — retrying + // the entity path here would re-send already-delivered chunks. + warn!("send_rich_message all paths failed: {e}"); + Err(teloxide::RequestError::Io(Arc::new(std::io::Error::other( + format!("{e}"), + )))) } } - Ok(()) } fn is_verbose_enabled(value: Option<&str>) -> bool { From 057eb28005f062dcb1126fc2c6dc672b079bd747 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 11:59:59 +0800 Subject: [PATCH 22/69] feat(rich): update streaming final flush to try sendRichMessage --- src/platform/telegram.rs | 81 ++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 188c554..88e7d30 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -1231,36 +1231,69 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe } if !split_contents.is_empty() { - // First, rebuild the full text for proper markdown parsing let full_text: String = split_contents.join(""); const MAX_UTF16: usize = 4090; + + // Pre-process markdown for spoiler/underline + let processed = crate::utils::markdown_entities::preprocess_markdown(&full_text); + + // For the rich path: split pre-processed markdown at newline boundaries. + // For the entity fallback: compute entities from the raw markdown. let (plain_text, entities) = markdown_to_entities(&full_text); - let chunks = split_entities(&plain_text, &entities, MAX_UTF16); - - // The first msg_id in the current message (if any) corresponds to the - // first chunk. Tracked split IDs from sent messages correspond to their - // own chunks. - for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { - if i == 0 { - if let Some(msg_id) = current_msg_id { - stream_bot - .edit_message_text(stream_chat_id, msg_id, chunk_text) - .entities(chunk_entities.clone()) + let entity_chunks = split_entities(&plain_text, &entities, MAX_UTF16); + let total_utf16 = processed.encode_utf16().count(); + let rich_chunks = rich_sender::split_markdown_at_newlines(&processed, MAX_UTF16); + + // Helper: try rich first, fall back to entity chunk i on failure + let try_rich_or_fallback = |i: usize, msg_id: Option| { + let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized").clone(); + let rich_chunks_ref = &rich_chunks; + let entity_chunks_ref = &entity_chunks; + let stream_bot_ref = &stream_bot; + async move { + if let Some(chunk_md) = rich_chunks_ref.get(i) { + let result = if let Some(mid) = msg_id { + rich_sender::edit_rich_message( + &token, + stream_chat_id.0, + mid.0, + chunk_md, + ) .await - .ok(); + } else { + rich_sender::send_rich_message(&token, stream_chat_id.0, chunk_md).await + }; + if result.is_err() { + // Fallback: use entity chunk i + if let Some((ct, ce)) = entity_chunks_ref.get(i) { + if let Some(mid) = msg_id { + stream_bot_ref + .edit_message_text(stream_chat_id, mid, ct) + .entities(ce.clone()) + .await + .ok(); + } else { + stream_bot_ref + .send_message(stream_chat_id, ct) + .entities(ce.clone()) + .await + .ok(); + } + } + } + } + } + }; + + if total_utf16 <= MAX_UTF16 { + try_rich_or_fallback(0, current_msg_id).await; + } else { + for (i, _chunk_md) in rich_chunks.iter().enumerate() { + if i == 0 { + try_rich_or_fallback(0, current_msg_id).await; } else { - stream_bot - .send_message(stream_chat_id, chunk_text) - .entities(chunk_entities.clone()) - .await - .ok(); + try_rich_or_fallback(i, None).await; } - } else { - stream_bot - .send_message(stream_chat_id, chunk_text) - .entities(chunk_entities.clone()) - .await - .ok(); } } } From e69d2199ebc1109d9ca5273ba4b52eda4bc20cfc Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 12:09:48 +0800 Subject: [PATCH 23/69] test(rich): add chunking unit tests for rich_sender --- src/utils/rich_sender.rs | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/utils/rich_sender.rs b/src/utils/rich_sender.rs index 6849d4c..9091f40 100644 --- a/src/utils/rich_sender.rs +++ b/src/utils/rich_sender.rs @@ -242,3 +242,72 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_markdown_short_text_not_split() { + let chunks = split_markdown_at_newlines("hello", 4090); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], "hello"); + } + + #[test] + fn test_split_markdown_at_newline_boundary() { + let text = "A".repeat(2000) + "\n" + &"B".repeat(2000); + let chunks = split_markdown_at_newlines(&text, 3000); + assert!(chunks.len() >= 2, "should split into at least 2 chunks"); + assert!( + chunks[0].ends_with('\n'), + "first chunk should end with newline" + ); + assert!( + !chunks[1].starts_with('\n'), + "second chunk should not start with newline" + ); + } + + #[test] + fn test_split_markdown_utf16_cjk() { + // Each CJK char = 1 UTF-16 unit, "你儽" = 2 units + let text = "你儽".repeat(3000); // 6000 UTF-16 units + let chunks = split_markdown_at_newlines(&text, 4090); + assert!(chunks.len() > 1, "long CJK text must be split"); + for chunk in &chunks { + let utf16_len = chunk.encode_utf16().count(); + assert!( + utf16_len <= 4090, + "chunk must not exceed max_utf16: {utf16_len} > 4090" + ); + } + } + + #[test] + fn test_preprocess_markdown_pub() { + // Verify preprocess_markdown is accessible + let result = crate::utils::markdown_entities::preprocess_markdown("**bold**"); + assert!( + result.contains("**bold**"), + "preprocess should pass through normal markdown" + ); + } + + #[test] + fn test_split_markdown_exact_small() { + let chunks = split_markdown_at_newlines("short", 10); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], "short"); + } + + #[test] + fn test_rich_sender_error_type() { + let bad_md = RichSenderError::BadMarkdown("bad".into()); + let net = RichSenderError::Network(anyhow::anyhow!("timeout")); + assert!(matches!(bad_md, RichSenderError::BadMarkdown(_))); + assert!(matches!(net, RichSenderError::Network(_))); + assert!(!matches!(bad_md, RichSenderError::Network(_))); + assert!(!matches!(net, RichSenderError::BadMarkdown(_))); + } +} From da80474aa0b45bb8a4923d1b94c0e903cf742588 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 16:54:31 +0800 Subject: [PATCH 24/69] fix: drain steer messages between tool call iterations --- src/agent.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index edb5464..e71c7d4 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1383,6 +1383,35 @@ impl Agent { messages.push(tool_msg); } + // --- Steer injection: drain pending messages between iterations --- + // Without this, a steer sent during tool execution is only visible + // after the next LLM call completes (the drain at line 869 fires + // after the LLM call starts the next iteration). + let inject_mode = self.get_mid_run_mode(user_id).await; + let injections = self.drain_injections(user_id).await; + if !injections.is_empty() { + for text in &injections { + let label = if inject_mode == MidRunMode::Steer { + "**[Steer]:** " + } else { + "**[User injected mid-processing]:** " + }; + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("{}{}", label, text))), + tool_calls: None, + tool_call_id: None, + }; + if inject_mode == MidRunMode::Queue { + if let Err(e) = self.memory.save_message(&conversation_id, &msg).await { + warn!("Failed to persist queued injection: {}", e); + } + } + messages.push(msg); + } + } + // --- End steer injection --- + iteration_count = iteration + 1; continue; } From a8f1d8bf93d7d7929883055feeb6eca45a99282c Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 17:09:14 +0800 Subject: [PATCH 25/69] feat: upgrade /btw to context-forked side query (Claude Code pattern) --- src/agent.rs | 196 ++++++++++++++++++++++++++++++++------- src/platform/telegram.rs | 33 ++++++- 2 files changed, 192 insertions(+), 37 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index e71c7d4..06d42f8 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -19,7 +19,7 @@ use crate::config::Config; use crate::langsmith::LangSmithClient; use crate::llm::{ is_empty_assistant_response, ChatMessage, ContentPart, FunctionDefinition, LlmClient, - MessageContent, ToolDefinition, + MessageContent, ToolCall, ToolDefinition, }; use crate::mcp::McpManager; use crate::memory::MemoryStore; @@ -2686,38 +2686,6 @@ impl Agent { ) } - /// Ask a parallel question while the main agent is processing. - /// Single LLM call, no tools, no DB access — truly parallel, zero lock contention. - /// Answer is ephemeral and NOT saved to conversation history. - pub async fn ask_parallel_lightweight(&self, question: &str) -> Result { - let system = format!( - "Answer the user's side question concisely from your knowledge. \ - You have NO tools available. Respond in a single message. \ - Current time: {}", - self.build_system_context().await, - ); - let messages = vec![ - ChatMessage { - role: "system".to_string(), - content: Some(MessageContent::from_text(system)), - tool_calls: None, - tool_call_id: None, - }, - ChatMessage { - role: "user".to_string(), - content: Some(MessageContent::from_text(question.to_string())), - tool_calls: None, - tool_call_id: None, - }, - ]; - let response = self.llm.chat(&messages, &[]).await?; - Ok(response - .content - .as_ref() - .map(|c| c.as_text()) - .unwrap_or_default()) - } - /// Get the path for a soul file by name. fn soul_file_path(&self, file_name: &str) -> anyhow::Result { let home = self @@ -3962,6 +3930,83 @@ impl Agent { } } +/// Build a context-forked message list for a /btw side question. +/// +/// Follows Claude Code's pattern: fork the current conversation messages, +/// strip orphaned tool_use blocks (no matching tool_result), and append a +/// strict system-reminder that constrains the model to answer from context +/// only, with no tools and no follow-up turns. +/// +/// The returned messages are ephemeral — they are NOT saved to conversation +/// history and the /btw response is sent asynchronously. +/// +/// This is a free function (not a method) because it only uses its arguments. +pub fn build_btw_context(messages: &[ChatMessage], question: &str) -> Vec { + // 1. Collect all tool_call_ids that have a matching tool_result. + let mut resolved_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for msg in messages.iter().rev() { + if msg.role == "tool" { + if let Some(ref id) = msg.tool_call_id { + resolved_ids.insert(id.as_str()); + } + } + } + + // 2. Walk messages and strip orphaned tool_use blocks from assistant messages. + let forked: Vec = messages + .iter() + .map(|msg| { + if msg.role == "assistant" { + if let Some(ref calls) = msg.tool_calls { + let kept: Vec = calls + .iter() + .filter(|tc| resolved_ids.contains(tc.id.as_str())) + .cloned() + .collect(); + if kept.len() != calls.len() { + let mut stripped = msg.clone(); + if kept.is_empty() { + stripped.tool_calls = None; + } else { + stripped.tool_calls = Some(kept); + } + return stripped; + } + } + } + msg.clone() + }) + .collect(); + + // 3. Append strict system-reminder with the question. + let reminder = format!( + r#" +This is a side question from the user. You must answer this question directly in a single response. + +CRITICAL CONSTRAINTS: +- You have NO tools available — you cannot read files, run commands, search, or take any actions +- This is a one-off response — there will be no follow-up turns +- You can ONLY provide information based on what you already know from the conversation context +- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action +- If you don't know the answer, say so — do not offer to look it up or investigate + +Simply answer the question with the information you have. + + +{}"#, + question + ); + + let mut result = forked; + result.push(ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(reminder)), + tool_calls: None, + tool_call_id: None, + }); + result +} + /// Parse an ISO 8601 datetime string and return the Duration until it fires. /// Returns Err if the string is invalid or the time is in the past. fn parse_one_shot_delay(trigger_value: &str) -> anyhow::Result { @@ -4482,4 +4527,89 @@ mod tests { "section should embed the shared preamble exactly" ); } + + #[test] + fn test_build_btw_context_removes_orphaned_tool_use() { + use crate::llm::{FunctionCall, ToolCall}; + let assistant = ChatMessage { + role: "assistant".to_string(), + content: None, + tool_calls: Some(vec![ToolCall { + id: "orphaned_call".into(), + call_type: "function".into(), + function: FunctionCall { + name: "read_file".into(), + arguments: r#"{"path":"x"}"#.into(), + }, + }]), + tool_call_id: None, + }; + let msgs = vec![assistant]; + let result = build_btw_context(&msgs, "test question"); + let forked = &result[..result.len() - 1]; + for msg in forked { + if let Some(ref calls) = msg.tool_calls { + assert!(calls.is_empty(), "orphaned tool_use should be stripped"); + } + } + } + + #[test] + fn test_build_btw_context_preserves_matched_tool_calls() { + use crate::llm::{FunctionCall, ToolCall}; + let tool_msg = ChatMessage { + role: "tool".to_string(), + content: Some(MessageContent::from_text("result")), + tool_calls: None, + tool_call_id: Some("call_1".into()), + }; + let assistant = ChatMessage { + role: "assistant".to_string(), + content: None, + tool_calls: Some(vec![ToolCall { + id: "call_1".into(), + call_type: "function".into(), + function: FunctionCall { + name: "read_file".into(), + arguments: r#"{"path":"x"}"#.into(), + }, + }]), + tool_call_id: None, + }; + let msgs = vec![tool_msg, assistant]; + let result = build_btw_context(&msgs, "test question"); + let forked = &result[..result.len() - 1]; + let has_tool_calls = forked + .iter() + .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())); + assert!(has_tool_calls, "matched tool_use should be preserved"); + } + + #[test] + fn test_build_btw_context_text_only_messages_unchanged() { + let msgs = vec![ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text("hello")), + tool_calls: None, + tool_call_id: None, + }]; + let result = build_btw_context(&msgs, "question"); + assert!(result.len() > msgs.len(), "should append question"); + assert_eq!( + result[0].content.as_ref().map(|c| c.as_text()), + Some("hello".to_string()) + ); + } + + #[test] + fn test_build_btw_context_empty_list() { + let result = build_btw_context(&[], "question"); + assert_eq!(result.len(), 1, "only the question message"); + assert!(result[0] + .content + .as_ref() + .map(|c| c.as_text()) + .unwrap_or_default() + .contains("question")); + } } diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 88e7d30..8a48465 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -821,7 +821,7 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe return send_markdown_message(&bot, msg.chat.id, reply).await; } - // Handle /btw for parallel question via isolated subagent + // Handle /btw for context-forked side question if text == "/btw" || text.starts_with("/btw ") { let btw_text = text .strip_prefix("/btw") @@ -834,13 +834,38 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe let _ = send_markdown_message(&bot, msg.chat.id, "ā³ **BTW question sent to subagent...**") .await; + // Load current conversation messages for context fork + let conversation_id = agent + .memory + .get_or_create_conversation("telegram", &user_id.to_string()) + .await; + let conversation_id = match conversation_id { + Ok(id) => id, + Err(e) => { + let _ = send_markdown_message(&bot, msg.chat.id, &format!("**BTW error:** {}", e)) + .await; + return Ok(()); + } + }; + let messages = agent + .memory + .load_messages_with_limit(&conversation_id, agent.config.memory.max_raw_messages) + .await + .unwrap_or_default(); + let agent_clone = agent.clone(); let bot_clone = bot.clone(); let chat_id = msg.chat.id; tokio::spawn(async move { - match agent_clone.ask_parallel_lightweight(&btw_text).await { - Ok(answer) => { - let _ = send_markdown_message(&bot_clone, chat_id, &answer).await; + let forked = crate::agent::build_btw_context(&messages, &btw_text); + match agent_clone.llm.chat(&forked, &[]).await { + Ok(response) => { + let text = response + .content + .as_ref() + .map(|c| c.as_text()) + .unwrap_or_default(); + let _ = send_markdown_message(&bot_clone, chat_id, &text).await; } Err(e) => { let _ = send_markdown_message( From a464ada9355f8cc574fd42cd536d8b00694e8d3b Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 17:35:42 +0800 Subject: [PATCH 26/69] feat: add LoopDetector module with exact-repetition detection --- Cargo.lock | 5 +- Cargo.toml | 1 + src/lib.rs | 1 + src/loop_detector.rs | 231 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 src/loop_detector.rs diff --git a/Cargo.lock b/Cargo.lock index 2bf5ab0..974aaa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2645,9 +2645,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2684,6 +2684,7 @@ dependencies = [ "rmcp", "rten", "rusqlite", + "rustc-hash", "self_update", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 6a03306..4f7afa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,7 @@ dirs = "5" # Unix process group management (kill child process trees) nix = { version = "0.31", features = ["signal"] } +rustc-hash = "2.1.3" [lib] name = "rustfox" diff --git a/src/lib.rs b/src/lib.rs index 8c1eac2..cd4eb55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod home; pub mod langsmith; pub mod learning; pub mod llm; +pub mod loop_detector; pub mod mcp; pub mod memory; pub mod platform; diff --git a/src/loop_detector.rs b/src/loop_detector.rs new file mode 100644 index 0000000..06f9e24 --- /dev/null +++ b/src/loop_detector.rs @@ -0,0 +1,231 @@ +use std::collections::VecDeque; + +use crate::llm::ToolCall; + +/// A recorded tool call in the rolling window. +#[derive(Debug, Clone)] +pub struct ToolCallRecord { + pub tool_name: String, + /// Hash of (tool_name + normalized JSON arguments). + pub args_hash: u64, + /// Iteration index when this call was made. + pub iteration: usize, +} + +/// Information returned when a loop is detected. +#[derive(Debug, Clone)] +pub struct LoopInfo { + pub tool_name: String, + pub call_count: usize, +} + +/// Detects exact-repetition loops in tool call sequences. +/// +/// Maintains a rolling FIFO window of recent tool calls. A loop is declared +/// when the last N entries all have the same (tool_name, args_hash). +pub struct LoopDetector { + window: VecDeque, + threshold: usize, +} + +impl LoopDetector { + pub fn new(threshold: usize) -> Self { + Self { + window: VecDeque::with_capacity(threshold + 1), + threshold, + } + } + + /// Normalize and hash tool call arguments for comparison. + /// + /// Sorts JSON keys alphabetically, trims whitespace, then computes a + /// non-cryptographic hash of (tool_name + "|" + normalized_args). + pub fn compute_hash(name: &str, arguments: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + // Normalize: parse as JSON, sort keys, re-serialize. + let normalized = serde_json::from_str::(arguments) + .ok() + .map(normalize_json_value) + .unwrap_or_else(|| arguments.trim().to_string()); + + let mut hasher = rustc_hash::FxHasher::default(); + name.hash(&mut hasher); + "|".hash(&mut hasher); + normalized.hash(&mut hasher); + hasher.finish() + } + + /// Record a batch of tool calls from one iteration. + pub fn record(&mut self, tool_calls: &[ToolCall], iteration: usize) { + for tc in tool_calls { + let hash = Self::compute_hash(&tc.function.name, &tc.function.arguments); + self.window.push_back(ToolCallRecord { + tool_name: tc.function.name.clone(), + args_hash: hash, + iteration, + }); + while self.window.len() > self.threshold { + self.window.pop_front(); + } + } + } + + /// Check whether a loop is currently detected. + /// + /// Returns `Some(LoopInfo)` when the last N entries all share the same + /// (tool_name, args_hash), where N == threshold. + pub fn detect_loop(&self) -> Option { + if self.window.len() < self.threshold { + return None; + } + + let first = self.window.front()?; + let all_same = self.window.iter().all(|r| r.args_hash == first.args_hash); + + if all_same { + Some(LoopInfo { + tool_name: first.tool_name.clone(), + call_count: self.window.len(), + }) + } else { + None + } + } + + /// Clear the window — used after user approves continuation. + pub fn clear(&mut self) { + self.window.clear(); + } +} + +/// Recursively sort all JSON object keys for deterministic comparison. +fn normalize_json_value(value: serde_json::Value) -> String { + match value { + serde_json::Value::Object(map) => { + let mut entries: Vec<(String, String)> = map + .into_iter() + .map(|(k, v)| (k, normalize_json_value(v))) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let inner: Vec = entries + .into_iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect(); + format!("{{{}}}", inner.join(",")) + } + serde_json::Value::Array(arr) => { + let items: Vec = arr.into_iter().map(normalize_json_value).collect(); + format!("[{}]", items.join(",")) + } + serde_json::Value::String(s) => format!("\"{}\"", s.trim()), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::{FunctionCall, ToolCall}; + + fn make_tool_call(name: &str, args: &str) -> ToolCall { + ToolCall { + id: "test_id".into(), + call_type: "function".into(), + function: FunctionCall { + name: name.into(), + arguments: args.into(), + }, + } + } + + #[test] + fn test_compute_hash_same_args_same_hash() { + let a = LoopDetector::compute_hash("read_file", r#"{"path": "foo.txt"}"#); + let b = LoopDetector::compute_hash("read_file", r#"{"path": "foo.txt"}"#); + assert_eq!(a, b); + } + + #[test] + fn test_compute_hash_different_args_different_hash() { + let a = LoopDetector::compute_hash("read_file", r#"{"path": "a.txt"}"#); + let b = LoopDetector::compute_hash("read_file", r#"{"path": "b.txt"}"#); + assert_ne!(a, b); + } + + #[test] + fn test_compute_hash_key_order_invariance() { + let a = LoopDetector::compute_hash("write_file", r#"{"content": "x", "path": "f.txt"}"#); + let b = LoopDetector::compute_hash("write_file", r#"{"path": "f.txt", "content": "x"}"#); + assert_eq!(a, b); + } + + #[test] + fn test_compute_hash_whitespace_invariance() { + let a = LoopDetector::compute_hash("read_file", r#"{"path":"x"}"#); + let b = LoopDetector::compute_hash("read_file", r#"{"path": "x"}"#); + assert_eq!(a, b); + } + + #[test] + fn test_detect_below_threshold_returns_none() { + let mut d = LoopDetector::new(3); + d.record(&[make_tool_call("read", r#"{"path":"x"}"#)], 0); + assert!(d.detect_loop().is_none()); + } + + #[test] + fn test_detect_exact_threshold_detects() { + let mut d = LoopDetector::new(3); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + d.record(&[tc.clone()], 0); + d.record(&[tc.clone()], 1); + d.record(&[tc.clone()], 2); + let info = d.detect_loop().expect("loop should be detected"); + assert_eq!(info.tool_name, "read"); + assert_eq!(info.call_count, 3); + } + + #[test] + fn test_detect_three_different_returns_none() { + let mut d = LoopDetector::new(3); + d.record(&[make_tool_call("a", r#"{"path":"x"}"#)], 0); + d.record(&[make_tool_call("b", r#"{"path":"x"}"#)], 1); + d.record(&[make_tool_call("c", r#"{"path":"x"}"#)], 2); + assert!(d.detect_loop().is_none()); + } + + #[test] + fn test_clear_resets_detection() { + let mut d = LoopDetector::new(3); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + d.record(&[tc.clone()], 0); + d.record(&[tc.clone()], 1); + d.record(&[tc.clone()], 2); + assert!(d.detect_loop().is_some()); + d.clear(); + assert!(d.detect_loop().is_none()); + } + + #[test] + fn test_detects_across_multiple_calls_per_iteration() { + let mut d = LoopDetector::new(3); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + // Two identical calls in iteration 0, one in iteration 1 = 3 total + d.record(&[tc.clone(), tc.clone()], 0); + d.record(&[tc.clone()], 1); + let info = d.detect_loop().expect("cross-turn loop detected"); + assert_eq!(info.tool_name, "read"); + } + + #[test] + fn test_diff_tool_same_args_not_detected() { + let mut d = LoopDetector::new(3); + let tc_a = make_tool_call("read", r#"{"path":"x"}"#); + let tc_b = make_tool_call("write", r#"{"path":"x"}"#); + d.record(&[tc_a], 0); + d.record(&[tc_b], 1); + d.record(&[make_tool_call("read", r#"{"path":"x"}"#)], 2); + assert!(d.detect_loop().is_none()); + } +} From c6e4415f782df395a853ea29f002e44cfd0e0ceb Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 17:43:46 +0800 Subject: [PATCH 27/69] feat: add LoopDetectionConfig to agent configuration --- config.example.toml | 5 ++ src/config.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/config.example.toml b/config.example.toml index e2ba4f2..223bcb7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -103,6 +103,11 @@ Be concise and helpful.""" # empty_response_retry_limit = 3 # Recovery attempts for empty model responses (default 3; 0 = fail immediately) # parse_retry_limit = 3 # Retries when API response is missing 'choices' field (default 3; 0 = fail immediately; exponential backoff: 1s, 2s, 4s...) +# [agent.loop_detection] +# enabled = true # Detect exact tool-call repetition inside the agent loop (default true) +# threshold = 3 # Repetitions of the same tool+args before a loop is flagged (default 3) +# timeout_seconds = 120 # Idle window for the loop detector; tool calls older than this don't count (default 120) + # LangSmith observability (optional) # Traces every LLM call and tool execution for debugging in the LangSmith UI. # Get your API key at https://smith.langchain.com → Settings → API Keys diff --git a/src/config.rs b/src/config.rs index 19d6eee..e933851 100644 --- a/src/config.rs +++ b/src/config.rs @@ -325,6 +325,45 @@ pub struct AgentConfig { pub empty_response_retry_limit: u32, #[serde(default = "default_parse_retry_limit")] pub parse_retry_limit: u32, + #[serde(default)] + pub loop_detection: LoopDetectionConfig, +} + +/// Tunables for the agentic-loop repetition detector. When `enabled` is true +/// and the model emits the same tool call (same tool name + identical +/// arguments) at least `threshold` times within a sliding window, the loop +/// detector surfaces a `LoopDetected` event to the agent loop so the user +/// can be notified and choose to break the cycle. +#[derive(Debug, Deserialize, Clone)] +pub struct LoopDetectionConfig { + #[serde(default = "default_loop_detection_enabled")] + pub enabled: bool, + #[serde(default = "default_loop_detection_threshold")] + pub threshold: usize, + #[serde(default = "default_loop_detection_timeout_seconds")] + pub timeout_seconds: u64, +} + +impl Default for LoopDetectionConfig { + fn default() -> Self { + Self { + enabled: default_loop_detection_enabled(), + threshold: default_loop_detection_threshold(), + timeout_seconds: default_loop_detection_timeout_seconds(), + } + } +} + +fn default_loop_detection_enabled() -> bool { + true +} + +fn default_loop_detection_threshold() -> usize { + 3 +} + +fn default_loop_detection_timeout_seconds() -> u64 { + 120 } #[derive(Debug, Deserialize, Clone)] @@ -469,6 +508,7 @@ fn default_agent_config() -> AgentConfig { max_iterations: default_max_iterations(), empty_response_retry_limit: default_empty_response_retry_limit(), parse_retry_limit: default_parse_retry_limit(), + loop_detection: LoopDetectionConfig::default(), } } @@ -551,6 +591,13 @@ impl Config { self.agent.parse_retry_limit } + /// Loop detection tunables (from [agent.loop_detection], defaults: enabled, + /// threshold 3, timeout 120s). Used by the agent loop to short-circuit + /// exact-repetition cycles and surface a `LoopDetected` event to the user. + pub fn loop_detection_config(&self) -> &LoopDetectionConfig { + &self.agent.loop_detection + } + /// Resolve the home root and every data path, create directories, and write /// the resolved paths back into the config fields. Unset paths are /// materialized to absolute paths under the home root; absolute overrides @@ -1093,6 +1140,75 @@ mod tests { assert_eq!(cfg.parse_retry_limit(), 0); } + #[test] + fn test_loop_detection_defaults_when_agent_section_omitted() { + let toml = r#" + [telegram] + bot_token = "tok" + allowed_user_ids = [1] + [openrouter] + api_key = "key" + [sandbox] + allowed_directory = "/tmp" + "#; + let cfg: Config = toml::from_str(toml).unwrap(); + let ld = cfg.loop_detection_config(); + assert!(ld.enabled, "loop_detection.enabled must default to true"); + assert_eq!( + ld.threshold, 3, + "loop_detection.threshold must default to 3" + ); + assert_eq!( + ld.timeout_seconds, 120, + "loop_detection.timeout_seconds must default to 120" + ); + } + + #[test] + fn test_loop_detection_can_be_overridden() { + let toml = r#" + [telegram] + bot_token = "tok" + allowed_user_ids = [1] + [openrouter] + api_key = "key" + [sandbox] + allowed_directory = "/tmp" + [agent.loop_detection] + enabled = false + threshold = 5 + timeout_seconds = 30 + "#; + let cfg: Config = toml::from_str(toml).unwrap(); + let ld = cfg.loop_detection_config(); + assert!(!ld.enabled); + assert_eq!(ld.threshold, 5); + assert_eq!(ld.timeout_seconds, 30); + } + + #[test] + fn test_loop_detection_partial_override_uses_defaults_for_rest() { + let toml = r#" + [telegram] + bot_token = "tok" + allowed_user_ids = [1] + [openrouter] + api_key = "key" + [sandbox] + allowed_directory = "/tmp" + [agent.loop_detection] + threshold = 7 + "#; + let cfg: Config = toml::from_str(toml).unwrap(); + let ld = cfg.loop_detection_config(); + assert!( + ld.enabled, + "enabled should keep its default when only threshold is set" + ); + assert_eq!(ld.threshold, 7); + assert_eq!(ld.timeout_seconds, 120); + } + #[test] fn test_provider_section_parses_ollama() { let toml = r#" From 21bb769aaffd9428794203a87be1239794393506 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 10 Jul 2026 17:47:01 +0800 Subject: [PATCH 28/69] feat: add loop detection callback registry to Agent --- src/agent.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index 06d42f8..03784e8 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -59,6 +59,14 @@ impl MidRunMode { } } +/// User's choice when a loop is detected and an inline keyboard is shown. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LoopCallbackChoice { + Continue, + Stop, + AddInstruction, +} + /// Number of context snippets to retrieve from conversation history for /// compaction summarization. const COMPACTION_RAG_LIMIT: usize = 5; @@ -107,6 +115,15 @@ pub struct Agent { /// Per-user pending injection messages (Steer/Inject), max 10 per user. /// When a non-command message arrives while processing is active, it's queued here. pub pending_injections: Arc>>>, + /// One-shot senders for loop detection callbacks, keyed by user_id. + /// The agent loop creates a oneshot channel, stores the sender here, + /// then awaits the receiver. The Telegram callback handler resolves + /// the sender with the user's choice. + pub pending_loop_callbacks: Arc< + tokio::sync::Mutex< + std::collections::HashMap>, + >, + >, } /// A task parsed from the spawn_agents tool arguments, after validation. @@ -186,6 +203,9 @@ impl Agent { running_commands: Arc::new(tokio::sync::Mutex::new(HashMap::new())), cancel_token_registry: Arc::new(tokio::sync::Mutex::new(HashMap::new())), pending_injections: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + pending_loop_callbacks: Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), } } @@ -484,6 +504,27 @@ impl Agent { map.remove(user_id).unwrap_or_default() } + /// Register a oneshot sender for a user's loop detection callback. + /// Returns the old sender if one was already registered (should not happen + /// in practice since one user has one active process_message). + pub async fn register_loop_callback( + &self, + user_id: &str, + sender: tokio::sync::oneshot::Sender, + ) -> Option> { + let mut map = self.pending_loop_callbacks.lock().await; + map.insert(user_id.to_string(), sender) + } + + /// Take the loop callback sender for a user, if any. + pub async fn take_loop_callback( + &self, + user_id: &str, + ) -> Option> { + let mut map = self.pending_loop_callbacks.lock().await; + map.remove(user_id) + } + /// Get the current MidRunMode for a user. Defaults to Steer. pub async fn get_mid_run_mode(&self, user_id: &str) -> MidRunMode { let key = format!("mid_run_mode_{}", user_id); From af09afdbdf7c9d6bf0ca5788d6373d13b2ae6551 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 08:59:38 +0800 Subject: [PATCH 29/69] feat: integrate loop detection into main agent loop --- src/agent.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 03784e8..cc8999d 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -7,7 +7,7 @@ use tracing::{debug, error, info, warn}; use teloxide::payloads::{SendDocumentSetters, SendMessageSetters}; use teloxide::prelude::Requester; -use teloxide::types::{ChatId, InputFile}; +use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile}; use teloxide::Bot; use crate::agent_prompt::{ @@ -831,6 +831,16 @@ impl Agent { self.registry.effective_context_window(&model) }; + // Loop detection state (cross-turn, resets each process_message call) + let loop_config = self.config.loop_detection_config(); + let loop_threshold = if loop_config.enabled { + loop_config.threshold + } else { + usize::MAX // when disabled, a threshold that never triggers + }; + let mut loop_detector = crate::loop_detector::LoopDetector::new(loop_threshold); + let loop_timeout = std::time::Duration::from_secs(loop_config.timeout_seconds); + 'outer: for iteration in 0..max_iterations { debug!( "Trying iteration {}: messages length: {}", @@ -1222,6 +1232,128 @@ impl Agent { break; } + // --- Loop detection: record tool calls and check for repetition --- + if loop_config.enabled { + if let Some(ref tool_calls) = response.tool_calls { + loop_detector.record(tool_calls, iteration as usize); + if let Some(loop_info) = loop_detector.detect_loop() { + info!( + user_id = %user_id, + tool = %loop_info.tool_name, + count = loop_info.call_count, + "Loop detected — pausing for user approval" + ); + + // Build preview: tool name + first argument snippet + let preview = if let Some(first) = + response.tool_calls.as_ref().and_then(|c| c.first()) + { + let preview = &first.function.arguments; + let preview = if preview.len() > 80 { + format!("{}...", &preview[..80]) + } else { + preview.to_string() + }; + format!("{}({})", loop_info.tool_name, preview) + } else { + loop_info.tool_name.clone() + }; + + // Create oneshot channel for the callback + let (cb_tx, cb_rx) = tokio::sync::oneshot::channel::(); + + // Register callback sender + self.register_loop_callback(user_id, cb_tx).await; + + // Send Telegram inline keyboard + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback( + "Continue", + r#"{"type":"loop","action":"continue"}"#, + ), + InlineKeyboardButton::callback( + "Stop", + r#"{"type":"loop","action":"stop"}"#, + ), + ], + vec![InlineKeyboardButton::callback( + "Add instruction", + r#"{"type":"loop","action":"add_instruction"}"#, + )], + ]); + let bot_for_msg = self.bot.clone(); + let chat_id_for_msg = parsed_chat_id; + let _ = bot_for_msg + .send_message( + chat_id_for_msg, + format!( + "I seem to be calling the same tool repeatedly:\n {} called {} times", + preview, + loop_info.call_count, + ), + ) + .reply_markup(keyboard) + .await; + + // Await user's choice (with timeout) + match tokio::time::timeout(loop_timeout, cb_rx).await { + Ok(Ok(LoopCallbackChoice::Continue)) => { + info!("User approved — continuing loop"); + loop_detector.clear(); + // `continue` targets the 'outer for loop — starts a + // fresh iteration (re-invokes the LLM), skipping any + // remaining tool execution from this iteration. + continue; + } + Ok(Ok(LoopCallbackChoice::Stop)) => { + info!("User requested stop — breaking loop"); + was_cancelled = true; + break 'outer; + } + Ok(Ok(LoopCallbackChoice::AddInstruction)) => { + info!("User requested add instruction — waiting for input"); + let _ = bot_for_msg + .send_message( + chat_id_for_msg, + "Please type your instruction as your next message. \ + Then tap Continue to resume.", + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback( + "Continue", + r#"{"type":"loop","action":"continue"}"#, + ), + ]])) + .await; + + // Wait for second callback + let (cb2_tx, cb2_rx) = + tokio::sync::oneshot::channel::(); + self.register_loop_callback(user_id, cb2_tx).await; + match tokio::time::timeout(loop_timeout, cb2_rx).await { + Ok(Ok(LoopCallbackChoice::Continue)) => { + loop_detector.clear(); + continue; + } + _ => { + was_cancelled = true; + break 'outer; + } + } + } + _ => { + // Timeout or channel closed — auto-stop + warn!("Loop callback timed out — stopping"); + was_cancelled = true; + break 'outer; + } + } + } + } + } + // --- End loop detection --- + if let Some(tool_calls) = &response.tool_calls { if !tool_calls.is_empty() { tool_call_count += tool_calls.len() as u32; @@ -1439,12 +1571,17 @@ impl Agent { }; let msg = ChatMessage { role: "user".to_string(), - content: Some(MessageContent::from_text(format!("{}{}", label, text))), + content: Some(MessageContent::from_text(format!( + "{}{}", + label, text + ))), tool_calls: None, tool_call_id: None, }; if inject_mode == MidRunMode::Queue { - if let Err(e) = self.memory.save_message(&conversation_id, &msg).await { + if let Err(e) = + self.memory.save_message(&conversation_id, &msg).await + { warn!("Failed to persist queued injection: {}", e); } } From 9dc584728c7106a7d7083a603de25fda0b790599 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 09:03:03 +0800 Subject: [PATCH 30/69] feat: add callback query handler for loop detection inline keyboard --- src/platform/telegram.rs | 49 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 8a48465..548d73b 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -8,7 +8,7 @@ use teloxide::prelude::*; use teloxide::types::ParseMode; use tracing::{error, info, warn}; -use crate::agent::{Agent, MidRunMode}; +use crate::agent::{Agent, LoopCallbackChoice, MidRunMode}; use crate::platform::{Attachment, AttachmentKind, IncomingMessage}; use crate::provider::Provider; use crate::utils::markdown_entities::{markdown_to_entities, split_entities}; @@ -212,9 +212,23 @@ pub async fn run( }) .endpoint(handle_model_callback); + let loop_callback_handler = Update::filter_callback_query() + .filter_map(|q: CallbackQuery| { + if q.data + .as_deref() + .is_some_and(|d| d.contains(r#""type":"loop""#)) + { + Some(q) + } else { + None + } + }) + .endpoint(handle_loop_callback); + let handler = dptree::entry() .branch(message_handler) - .branch(callback_handler); + .branch(callback_handler) + .branch(loop_callback_handler); Dispatcher::builder(bot, handler) .dependencies(dptree::deps![agent]) @@ -1410,6 +1424,37 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe Ok(()) } +/// Handle callback query from loop detection inline keyboard. +/// Resolves the oneshot sender so the suspended agent loop can continue. +async fn handle_loop_callback(bot: Bot, q: CallbackQuery, agent: Arc) -> ResponseResult<()> { + let user_id = q.from.id.to_string(); + let data = match q.data { + Some(ref d) => d.clone(), + None => return Ok(()), + }; + + // Parse the user's choice from callback data + let choice = if data.contains(r#""action":"continue""#) { + LoopCallbackChoice::Continue + } else if data.contains(r#""action":"stop""#) { + LoopCallbackChoice::Stop + } else if data.contains(r#""action":"add_instruction""#) { + LoopCallbackChoice::AddInstruction + } else { + // Unknown action — answer and ignore + bot.answer_callback_query(q.id).await.ok(); + return Ok(()); + }; + + // Send the choice to the waiting agent loop (if any) + if let Some(sender) = agent.take_loop_callback(&user_id).await { + let _ = sender.send(choice); + } + + bot.answer_callback_query(q.id).await.ok(); + Ok(()) +} + /// Handle callback queries from inline keyboard buttons (e.g. model selection). async fn handle_model_callback( bot: Bot, From 7d37a7e0d6ade3e5ee4be6ba3e951cc2c39d46db Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 09:05:40 +0800 Subject: [PATCH 31/69] feat: add loop detection with recovery nudge to subagent loop --- src/agent.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index cc8999d..cfb61f3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2748,6 +2748,14 @@ impl Agent { ) -> String { let empty_response_retry_limit = self.config.empty_response_retry_limit(); + let loop_config = self.config.loop_detection_config(); + let sub_threshold = if loop_config.enabled { + loop_config.threshold + } else { + usize::MAX + }; + let mut loop_detector_sub = crate::loop_detector::LoopDetector::new(sub_threshold); + for _iteration in 0..max_iter { // CHECK: cancelled by /stop? if let Some(ref token) = cancel_token { @@ -2809,6 +2817,39 @@ impl Agent { break; } + // --- Subagent loop detection: auto-recover with nudge --- + if loop_config.enabled { + if let Some(ref tool_calls) = response.tool_calls { + loop_detector_sub.record(tool_calls, _iteration as usize); + if let Some(loop_info) = loop_detector_sub.detect_loop() { + warn!( + subagent = %label, + tool = %loop_info.tool_name, + count = loop_info.call_count, + "Subagent loop detected — injecting recovery nudge" + ); + + // Inject recovery message as a tool result + let nudge_text = format!( + "Error: You have called {} {} times with the same arguments. \ + The result has not changed. Try a different approach.", + loop_info.tool_name, + loop_info.call_count, + ); + messages.push(ChatMessage { + role: "tool".to_string(), + content: Some(MessageContent::from_text(nudge_text)), + tool_calls: None, + tool_call_id: Some("loop_recovery_nudge".to_string()), + }); + + loop_detector_sub.clear(); + continue; + } + } + } + // --- End subagent loop detection --- + if let Some(tool_calls) = &response.tool_calls { if !tool_calls.is_empty() { messages.push(response.clone()); From f85f11ec2e4775cfabdbab2713c6e69a3f368eab Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 09:07:11 +0800 Subject: [PATCH 32/69] chore: final integration build for loop detection features --- src/agent.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index cfb61f3..042bb17 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2833,8 +2833,7 @@ impl Agent { let nudge_text = format!( "Error: You have called {} {} times with the same arguments. \ The result has not changed. Try a different approach.", - loop_info.tool_name, - loop_info.call_count, + loop_info.tool_name, loop_info.call_count, ); messages.push(ChatMessage { role: "tool".to_string(), From bb3de72f08a2deceb98fcfc599de6f601b8be9bd Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 09:17:23 +0800 Subject: [PATCH 33/69] =?UTF-8?q?fix:=20address=20code=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20clippy,=20nudge=20orphan,=20UTF-8,=20steer=20hel?= =?UTF-8?q?per,=20continue=20save,=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent.rs | 136 ++++++++++++++++++++++++------------------- src/loop_detector.rs | 14 ++--- 2 files changed, 83 insertions(+), 67 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 042bb17..5a0f1f5 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -504,6 +504,48 @@ impl Agent { map.remove(user_id).unwrap_or_default() } + /// Drain pending steer/queue injections for the given user and push them + /// into `messages`. Returns `true` when at least one injection was applied. + /// + /// Used both at the start of each outer iteration (before the LLM call) + /// and right after a tool batch commits (so steer traffic that arrives + /// during a long tool batch is still visible on the next turn). + /// + /// `Queue` mode additionally persists the message into conversation memory + /// so it survives a process_message boundary. + pub async fn drain_and_inject_steer( + &self, + user_id: &str, + conversation_id: &str, + messages: &mut Vec, + ) -> bool { + let inject_mode = self.get_mid_run_mode(user_id).await; + let injections = self.drain_injections(user_id).await; + if injections.is_empty() { + return false; + } + for text in &injections { + let label = if inject_mode == MidRunMode::Steer { + "**[Steer]:** " + } else { + "**[User injected mid-processing]:** " + }; + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("{}{}", label, text))), + tool_calls: None, + tool_call_id: None, + }; + if inject_mode == MidRunMode::Queue { + if let Err(e) = self.memory.save_message(conversation_id, &msg).await { + warn!("Failed to persist queued injection: {}", e); + } + } + messages.push(msg); + } + true + } + /// Register a oneshot sender for a user's loop detection callback. /// Returns the old sender if one was already registered (should not happen /// in practice since one user has one active process_message). @@ -918,29 +960,8 @@ impl Agent { } // CHECK: pending injections from user (steer or queue based on mode) - let inject_mode = self.get_mid_run_mode(user_id).await; - let injections = self.drain_injections(user_id).await; - if !injections.is_empty() { - let label = if inject_mode == MidRunMode::Steer { - "**[Steer]:** " - } else { - "**[User injected mid-processing]:** " - }; - for text in &injections { - let msg = ChatMessage { - role: "user".to_string(), - content: Some(MessageContent::from_text(format!("{}{}", label, text))), - tool_calls: None, - tool_call_id: None, - }; - if inject_mode == MidRunMode::Queue { - if let Err(e) = self.memory.save_message(&conversation_id, &msg).await { - warn!("Failed to persist queued injection: {}", e); - } - } - messages.push(msg); - } - } + self.drain_and_inject_steer(user_id, &conversation_id, &mut messages) + .await; // Tiers 1-2: sync compaction let base_prompt = prepare_messages_for_llm(&messages, context_window); @@ -1250,7 +1271,13 @@ impl Agent { { let preview = &first.function.arguments; let preview = if preview.len() > 80 { - format!("{}...", &preview[..80]) + // Safe UTF-8 boundary truncation + let boundary = preview + .char_indices() + .nth(80) + .map(|(i, _)| i) + .unwrap_or(preview.len()); + format!("{}...", &preview[..boundary]) } else { preview.to_string() }; @@ -1300,6 +1327,13 @@ impl Agent { match tokio::time::timeout(loop_timeout, cb_rx).await { Ok(Ok(LoopCallbackChoice::Continue)) => { info!("User approved — continuing loop"); + // Save the looping assistant message so the LLM + // sees what was detected on the next iteration. + self.memory + .save_message(&conversation_id, &response) + .await + .ok(); + messages.push(response.clone()); loop_detector.clear(); // `continue` targets the 'outer for loop — starts a // fresh iteration (re-invokes the LLM), skipping any @@ -1558,36 +1592,10 @@ impl Agent { // --- Steer injection: drain pending messages between iterations --- // Without this, a steer sent during tool execution is only visible - // after the next LLM call completes (the drain at line 869 fires - // after the LLM call starts the next iteration). - let inject_mode = self.get_mid_run_mode(user_id).await; - let injections = self.drain_injections(user_id).await; - if !injections.is_empty() { - for text in &injections { - let label = if inject_mode == MidRunMode::Steer { - "**[Steer]:** " - } else { - "**[User injected mid-processing]:** " - }; - let msg = ChatMessage { - role: "user".to_string(), - content: Some(MessageContent::from_text(format!( - "{}{}", - label, text - ))), - tool_calls: None, - tool_call_id: None, - }; - if inject_mode == MidRunMode::Queue { - if let Err(e) = - self.memory.save_message(&conversation_id, &msg).await - { - warn!("Failed to persist queued injection: {}", e); - } - } - messages.push(msg); - } - } + // after the next LLM call completes (the drain at the top of the + // outer loop fires after the LLM call starts the next iteration). + self.drain_and_inject_steer(user_id, &conversation_id, &mut messages) + .await; // --- End steer injection --- iteration_count = iteration + 1; @@ -2829,17 +2837,24 @@ impl Agent { "Subagent loop detected — injecting recovery nudge" ); - // Inject recovery message as a tool result + // Persist the assistant tool-call message so the LLM sees it + // on the next iteration — required because a `tool`-role + // nudge without a preceding assistant tool_use would be + // an orphan message that APIs reject. + messages.push(response.clone()); + + // Inject recovery message as a user-role turn so it + // doesn't depend on a matching tool_call_id. let nudge_text = format!( "Error: You have called {} {} times with the same arguments. \ The result has not changed. Try a different approach.", loop_info.tool_name, loop_info.call_count, ); messages.push(ChatMessage { - role: "tool".to_string(), + role: "user".to_string(), content: Some(MessageContent::from_text(nudge_text)), tool_calls: None, - tool_call_id: Some("loop_recovery_nudge".to_string()), + tool_call_id: None, }); loop_detector_sub.clear(); @@ -4766,9 +4781,10 @@ mod tests { let result = build_btw_context(&msgs, "test question"); let forked = &result[..result.len() - 1]; for msg in forked { - if let Some(ref calls) = msg.tool_calls { - assert!(calls.is_empty(), "orphaned tool_use should be stripped"); - } + assert!( + msg.tool_calls.as_ref().is_none_or(|c| c.is_empty()), + "orphaned tool_use should be stripped" + ); } } diff --git a/src/loop_detector.rs b/src/loop_detector.rs index 06f9e24..9235a65 100644 --- a/src/loop_detector.rs +++ b/src/loop_detector.rs @@ -178,9 +178,9 @@ mod tests { fn test_detect_exact_threshold_detects() { let mut d = LoopDetector::new(3); let tc = make_tool_call("read", r#"{"path":"x"}"#); - d.record(&[tc.clone()], 0); - d.record(&[tc.clone()], 1); - d.record(&[tc.clone()], 2); + d.record(std::slice::from_ref(&tc), 0); + d.record(std::slice::from_ref(&tc), 1); + d.record(std::slice::from_ref(&tc), 2); let info = d.detect_loop().expect("loop should be detected"); assert_eq!(info.tool_name, "read"); assert_eq!(info.call_count, 3); @@ -199,9 +199,9 @@ mod tests { fn test_clear_resets_detection() { let mut d = LoopDetector::new(3); let tc = make_tool_call("read", r#"{"path":"x"}"#); - d.record(&[tc.clone()], 0); - d.record(&[tc.clone()], 1); - d.record(&[tc.clone()], 2); + d.record(std::slice::from_ref(&tc), 0); + d.record(std::slice::from_ref(&tc), 1); + d.record(std::slice::from_ref(&tc), 2); assert!(d.detect_loop().is_some()); d.clear(); assert!(d.detect_loop().is_none()); @@ -213,7 +213,7 @@ mod tests { let tc = make_tool_call("read", r#"{"path":"x"}"#); // Two identical calls in iteration 0, one in iteration 1 = 3 total d.record(&[tc.clone(), tc.clone()], 0); - d.record(&[tc.clone()], 1); + d.record(std::slice::from_ref(&tc), 1); let info = d.detect_loop().expect("cross-turn loop detected"); assert_eq!(info.tool_name, "read"); } From 65b39ecf705e0baa6e5bb8bf7e0963186b0e9831 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 09:39:57 +0800 Subject: [PATCH 34/69] =?UTF-8?q?fix:=20code=20review=20issues=20=E2=80=94?= =?UTF-8?q?=20drain=5Fand=5Finject=5Fsteer=20return,=20LoopDetector::new?= =?UTF-8?q?=20enabled=20param,=20=5Fiteration=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/agents/code-quality-reviewer.md | 2 +- .opencode/agents/code-reviewer.md | 2 +- .opencode/agents/implementer.md | 2 +- .opencode/agents/plan-document-reviewer.md | 2 +- .opencode/agents/spec-document-reviewer.md | 2 +- .opencode/agents/spec-reviewer.md | 2 +- ...026-07-10-loop-detection-implementation.md | 1222 +++++++++++++++++ .../2026-07-10-richblock-table-conversion.md | 658 +++++++++ .../specs/2026-07-10-loop-detection-design.md | 323 +++++ src/agent.rs | 26 +- src/loop_detector.rs | 39 +- 11 files changed, 2246 insertions(+), 34 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-10-loop-detection-implementation.md create mode 100644 docs/superpowers/plans/2026-07-10-richblock-table-conversion.md create mode 100644 docs/superpowers/specs/2026-07-10-loop-detection-design.md diff --git a/.opencode/agents/code-quality-reviewer.md b/.opencode/agents/code-quality-reviewer.md index 9f8d535..ca88900 100644 --- a/.opencode/agents/code-quality-reviewer.md +++ b/.opencode/agents/code-quality-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews implementation code quality — cleanliness, test coverage, maintainability, structure. Only dispatch after spec compliance review passes. mode: subagent -model: opencode-go/minimax-m3 +# model: opencode-go/minimax-m3 permission: read: allow glob: allow diff --git a/.opencode/agents/code-reviewer.md b/.opencode/agents/code-reviewer.md index fb921ca..725098d 100644 --- a/.opencode/agents/code-reviewer.md +++ b/.opencode/agents/code-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews completed project steps against original plans, coding standards, and best practices. Use when a major project step has been completed and needs review. mode: subagent -model: opencode-go/minimax-m3 +# model: opencode-go/minimax-m3 permission: read: allow glob: allow diff --git a/.opencode/agents/implementer.md b/.opencode/agents/implementer.md index 84875d5..5d43006 100644 --- a/.opencode/agents/implementer.md +++ b/.opencode/agents/implementer.md @@ -1,7 +1,7 @@ --- description: Implements spec-defined tasks from plans. Writes tests, implements features, verifies work, and commits. Best for mechanical implementation with clear specs. mode: subagent -model: opencode-go/minimax-m3 +# model: opencode-go/minimax-m3 permission: read: allow write: allow diff --git a/.opencode/agents/plan-document-reviewer.md b/.opencode/agents/plan-document-reviewer.md index da2d2de..dac09cd 100644 --- a/.opencode/agents/plan-document-reviewer.md +++ b/.opencode/agents/plan-document-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews implementation plans for completeness, spec alignment, task decomposition, and buildability before execution. mode: subagent -model: opencode-go/deepseek-v4-flash +# model: opencode-go/deepseek-v4-flash permission: read: allow edit: deny diff --git a/.opencode/agents/spec-document-reviewer.md b/.opencode/agents/spec-document-reviewer.md index da8bfc2..c45fde6 100644 --- a/.opencode/agents/spec-document-reviewer.md +++ b/.opencode/agents/spec-document-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews specification documents for completeness, consistency, clarity, and readiness before planning begins. mode: subagent -model: opencode-go/mimo-v2.5 +# model: opencode-go/mimo-v2.5 permission: read: allow edit: deny diff --git a/.opencode/agents/spec-reviewer.md b/.opencode/agents/spec-reviewer.md index ac4f18f..9845fc9 100644 --- a/.opencode/agents/spec-reviewer.md +++ b/.opencode/agents/spec-reviewer.md @@ -1,7 +1,7 @@ --- description: Verifies that an implementation matches its specification exactly — nothing more, nothing less. Dispatch after an implementer completes work. mode: subagent -model: opencode-go/minimax-m3 +# model: opencode-go/minimax-m3 permission: read: allow glob: allow diff --git a/docs/superpowers/plans/2026-07-10-loop-detection-implementation.md b/docs/superpowers/plans/2026-07-10-loop-detection-implementation.md new file mode 100644 index 0000000..c8b4f67 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-loop-detection-implementation.md @@ -0,0 +1,1222 @@ +# Loop Detection + Steer Fix + /btw Context Fork Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add loop detection (exact repetition), fix steer injection responsiveness, and upgrade /btw to context-forked side queries. + +**Architecture:** Three features implemented in dependency order: (1) steer injection fix (infrastructure for loop detection's "Add instruction"), (2) /btw context fork, (3) LoopDetector module + Telegram callback UX. + +**Tech Stack:** Rust, tokio, teloxide, serde_json, fxhash + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/loop_detector.rs` | **Create** | `ToolCallRecord`, `LoopDetector`, `LoopInfo`: hash + rolling window + detect | +| `src/agent.rs` | **Modify** | Add steer drain post-tools (line ~1383), loop detection checks, oneshot callback registry | +| `src/platform/telegram.rs` | **Modify** | /btw context fork (replace `ask_parallel_lightweight`), callback query handler for loop detection | +| `src/config.rs` | **Modify` | Add `LoopDetectionConfig` struct + defaults | +| `config.example.toml` | **Modify** | Add `[agent.loop_detection]` example section | +| `src/llm.rs` | **Modify** | Add `build_btw_context` (or put in agent.rs — see below) | +| `src/lib.rs` | **Modify** | Add `pub mod loop_detector` | + +--- + +### Task 1: Steer Injection Between Tool Calls + +**Files:** +- Modify: `src/agent.rs:1376-1387` + +- [ ] **Step 1: Add injection drain after tool batch commit** + +In `process_message()`, after the `for` loop that pushes tool results to `messages` (around line 1383), before `continue` (line 1387): + +```rust + // --- Non-agent tool calls run SEQUENTIALLY --- + for (idx, name, args, id) in other_group { + // ... (existing: regurgitation check, LangSmith, execute, result) ... + all_results.push((idx, tool_msg)); + } + + // Sort results by original index and push to memory + messages + all_results.sort_by_key(|(i, _)| *i); + for (_idx, tool_msg) in all_results { + self.memory + .save_message(&conversation_id, &tool_msg) + .await?; + messages.push(tool_msg); + } + + // --- Steer injection: drain pending messages between iterations --- + // Without this, a steer sent during tool execution is only visible + // after the next LLM call completes (the drain at line 869 fires + // after the LLM call starts the next iteration). + let steer_mode = self.get_mid_run_mode(user_id).await; + let injections = self.drain_injections(user_id).await; + for text in &injections { + let label = if steer_mode == MidRunMode::Steer { + "**[Steer]:** " + } else { + "**[User injected mid-processing]:** " + }; + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("{}{}", label, text))), + tool_calls: None, + tool_call_id: None, + }; + if steer_mode == MidRunMode::Queue { + self.memory.save_message(&conversation_id, &msg).await.ok(); + } + messages.push(msg); + } + // --- End steer injection --- + + iteration_count = iteration + 1; + continue; +``` + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: clean build with no errors or warnings. + +- [ ] **Step 3: Commit** + +```bash +git add src/agent.rs +git commit -m "fix: drain steer messages between tool call iterations" +``` + +--- + +### Task 2: /btw Context-Forked Side Query + +**Files:** +- Modify: `src/agent.rs` (add `build_btw_context` method) +- Modify: `src/platform/telegram.rs` (replace `ask_parallel_lightweight` usage) +- Remove (optional): `ask_parallel_lightweight` method if no other callers + +- [ ] **Step 1: Add `build_btw_context` method to Agent** + +In `src/agent.rs`, add a new method near `ask_parallel_lightweight` (around line 2660): + +```rust + /// Build a context-forked message list for a /btw side question. + /// + /// Follows Claude Code's pattern: fork the current conversation messages, + /// strip orphaned tool_use blocks (no matching tool_result), and append a + /// strict system-reminder that constrains the model to answer from context + /// only, with no tools and no follow-up turns. + /// + /// The returned messages are ephemeral — they are NOT saved to conversation + /// history and the /btw response is sent asynchronously. + /// + /// This is a free function (not a method) because it only uses its arguments. + pub fn build_btw_context( + messages: &[ChatMessage], + question: &str, + ) -> Vec { + // 1. Collect all tool_call_ids that have a matching tool_result. + let mut resolved_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for msg in messages.iter().rev() { + if msg.role == "tool" { + if let Some(ref id) = msg.tool_call_id { + resolved_ids.insert(id.as_str()); + } + } + } + + // 2. Walk messages and strip orphaned tool_use blocks from assistant messages. + let forked: Vec = messages + .iter() + .map(|msg| { + if msg.role == "assistant" { + if let Some(ref calls) = msg.tool_calls { + let kept: Vec = calls + .iter() + .filter(|tc| resolved_ids.contains(tc.id.as_str())) + .cloned() + .collect(); + if kept.len() != calls.len() { + let mut stripped = msg.clone(); + if kept.is_empty() { + stripped.tool_calls = None; + } else { + stripped.tool_calls = Some(kept); + } + return stripped; + } + } + } + msg.clone() + }) + .collect(); + + // 3. Append strict system-reminder. + let reminder = format!( + r#" +This is a side question from the user. You must answer this question directly in a single response. + +CRITICAL CONSTRAINTS: +- You have NO tools available — you cannot read files, run commands, search, or take any actions +- This is a one-off response — there will be no follow-up turns +- You can ONLY provide information based on what you already know from the conversation context +- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action +- If you don't know the answer, say so — do not offer to look it up or investigate + +Simply answer the question with the information you have. + + +{}"#, + question + ); + + let mut result = forked; + result.push(ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(reminder)), + tool_calls: None, + tool_call_id: None, + }); + result + } +``` + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 3: Add unit tests for `build_btw_context` and orphaned filter** + +Add in the same file as `build_btw_context` (under `#[cfg(test)] mod tests`): + +```rust +#[test] +fn test_build_btw_context_removes_orphaned_tool_use() { + use crate::llm::{FunctionCall, ToolCall}; + let assistant = ChatMessage { + role: "assistant".to_string(), + content: None, + tool_calls: Some(vec![ToolCall { + id: "orphaned_call".into(), + call_type: "function".into(), + function: FunctionCall { + name: "read_file".into(), + arguments: r#"{"path":"x"}"#.into(), + }, + }]), + tool_call_id: None, + }; + let msgs = vec![assistant]; + let result = crate::agent::build_btw_context(&msgs, "test question"); + let forked = &result[..result.len() - 1]; + for msg in forked { + if let Some(ref calls) = msg.tool_calls { + assert!(calls.is_empty(), "orphaned tool_use should be stripped"); + } + } +} + +#[test] +fn test_build_btw_context_preserves_matched_tool_calls() { + use crate::llm::{FunctionCall, ToolCall}; + let tool_msg = ChatMessage { + role: "tool".to_string(), + content: Some(MessageContent::from_text("result")), + tool_calls: None, + tool_call_id: Some("call_1".into()), + }; + let assistant = ChatMessage { + role: "assistant".to_string(), + content: None, + tool_calls: Some(vec![ToolCall { + id: "call_1".into(), + call_type: "function".into(), + function: FunctionCall { + name: "read_file".into(), + arguments: r#"{"path":"x"}"#.into(), + }, + }]), + tool_call_id: None, + }; + let msgs = vec![tool_msg, assistant]; + let result = crate::agent::build_btw_context(&msgs, "test question"); + let forked = &result[..result.len() - 1]; + let has_tool_calls = forked.iter().any(|m| { + m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) + }); + assert!(has_tool_calls, "matched tool_use should be preserved"); +} + +#[test] +fn test_build_btw_context_text_only_messages_unchanged() { + let msgs = vec![ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text("hello")), + tool_calls: None, + tool_call_id: None, + }]; + let result = crate::agent::build_btw_context(&msgs, "question"); + assert!(result.len() > msgs.len(), "should append question"); + assert_eq!( + result[0].content.as_ref().map(|c| c.as_text()), + Some("hello".to_string()) + ); +} + +#[test] +fn test_build_btw_context_empty_list() { + let result = crate::agent::build_btw_context(&[], "question"); + assert_eq!(result.len(), 1, "only the question message"); + assert!(result[0] + .content + .as_ref() + .map(|c| c.as_text()) + .unwrap_or_default() + .contains("question")); +} +``` + +Note: `build_btw_context` is a free function (not a method on `Agent`) since +it only operates on its arguments. It lives in `agent.rs` as a public function. + +- [ ] **Step 4: Update `/btw` handler in `telegram.rs`** + +Replace the current `/btw` handler (lines 824-857) with a version that loads conversation messages, forks context, and calls the LLM directly: + +```rust + // Handle /btw for context-forked side question + if text == "/btw" || text.starts_with("/btw ") { + let btw_text = text + .strip_prefix("/btw") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("What are you doing?") + .to_string(); + + // Reply immediately, then answer in background + let _ = send_markdown_message( + &bot, + msg.chat.id, + "ā³ **BTW question sent to subagent...**", + ) + .await; + + // Load current conversation messages for context fork + let conversation_id = agent + .memory + .get_or_create_conversation("telegram", &user_id.to_string()) + .await; + let conversation_id = match conversation_id { + Ok(id) => id, + Err(e) => { + let _ = send_markdown_message( + &bot, + msg.chat.id, + &format!("**BTW error:** {}", e), + ) + .await; + return Ok(()); + } + }; + let messages = agent + .memory + .load_messages_with_limit( + &conversation_id, + agent.config.memory.max_raw_messages, + ) + .await + .unwrap_or_default(); + + let agent_clone = agent.clone(); + let bot_clone = bot.clone(); + let chat_id = msg.chat.id; + tokio::spawn(async move { + let forked = crate::agent::build_btw_context(&messages, &btw_text); + match agent_clone.llm.chat(&forked, &[]).await { + Ok(response) => { + let text = response + .content + .as_ref() + .map(|c| c.as_text()) + .unwrap_or_default(); + let _ = send_markdown_message(&bot_clone, chat_id, &text).await; + } + Err(e) => { + let _ = send_markdown_message( + &bot_clone, + chat_id, + &format!("**BTW error:** {}", e), + ) + .await; + } + } + }); + + return Ok(()); + } +``` + +Note: The `conversation_id` load may fail if there is no existing conversation (first message is /btw). The `unwrap_or_default` handles this gracefully — empty context is fine. + +- [ ] **Step 4: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 5: Remove `ask_parallel_lightweight` if no other callers** + +Search for references to `ask_parallel_lightweight` in the codebase: + +Run: `rg "ask_parallel_lightweight" src/` +Expected: only the method definition (and possibly tests). If no callers remain, remove the method. + +- [ ] **Step 6: Commit** + +```bash +git add src/agent.rs src/platform/telegram.rs +git commit -m "feat: upgrade /btw to context-forked side query (Claude Code pattern)" +``` + +--- + +### Task 3: LoopDetector Module + +**Files:** +- Create: `src/loop_detector.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 1: Write the LoopDetector module** + +Create `src/loop_detector.rs`: + +```rust +use std::collections::VecDeque; + +use crate::llm::ToolCall; + +/// A recorded tool call in the rolling window. +#[derive(Debug, Clone)] +pub struct ToolCallRecord { + pub tool_name: String, + /// Hash of (tool_name + normalized JSON arguments). + pub args_hash: u64, + /// Iteration index when this call was made. + pub iteration: usize, +} + +/// Information returned when a loop is detected. +#[derive(Debug, Clone)] +pub struct LoopInfo { + pub tool_name: String, + pub call_count: usize, +} + +/// Detects exact-repetition loops in tool call sequences. +/// +/// Maintains a rolling FIFO window of recent tool calls. A loop is declared +/// when the last N entries all have the same (tool_name, args_hash). +pub struct LoopDetector { + window: VecDeque, + threshold: usize, +} + +impl LoopDetector { + pub fn new(threshold: usize) -> Self { + Self { + window: VecDeque::with_capacity(threshold + 1), + threshold, + } + } + + /// Normalize and hash tool call arguments for comparison. + /// + /// Sorts JSON keys alphabetically, trims whitespace, then computes a + /// non-cryptographic hash of (tool_name + "|" + normalized_args). + pub fn compute_hash(name: &str, arguments: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + // Normalize: parse as JSON, sort keys, re-serialize. + let normalized = serde_json::from_str::(arguments) + .ok() + .map(|v| normalize_json_value(v)) + .unwrap_or_else(|| arguments.trim().to_string()); + + let mut hasher = rustc_hash::FxHasher::default(); + name.hash(&mut hasher); + "|".hash(&mut hasher); + normalized.hash(&mut hasher); + hasher.finish() + } + + /// Record a batch of tool calls from one iteration. + pub fn record(&mut self, tool_calls: &[ToolCall], iteration: usize) { + for tc in tool_calls { + let hash = Self::compute_hash(&tc.function.name, &tc.function.arguments); + self.window.push_back(ToolCallRecord { + tool_name: tc.function.name.clone(), + args_hash: hash, + iteration, + }); + while self.window.len() > self.threshold { + self.window.pop_front(); + } + } + } + + /// Check whether a loop is currently detected. + /// + /// Returns `Some(LoopInfo)` when the last N entries all share the same + /// (tool_name, args_hash), where N == threshold. + pub fn detect_loop(&self) -> Option { + if self.window.len() < self.threshold { + return None; + } + + // All entries in the window must match the first (oldest) entry. + let first = self.window.front()?; + let all_same = self.window.iter().all(|r| r.args_hash == first.args_hash); + + if all_same { + Some(LoopInfo { + tool_name: first.tool_name.clone(), + call_count: self.window.len(), + }) + } else { + None + } + } + + /// Clear the window — used after user approves continuation. + pub fn clear(&mut self) { + self.window.clear(); + } +} + +/// Recursively sort all JSON object keys for deterministic comparison. +fn normalize_json_value(value: serde_json::Value) -> String { + match value { + serde_json::Value::Object(map) => { + let mut entries: Vec<(String, String)> = map + .into_iter() + .map(|(k, v)| (k, normalize_json_value(v))) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let inner: Vec = entries + .into_iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect(); + format!("{{{}}}", inner.join(",")) + } + serde_json::Value::Array(arr) => { + let items: Vec = arr.into_iter().map(normalize_json_value).collect(); + format!("[{}]", items.join(",")) + } + serde_json::Value::String(s) => format!("\"{}\"", s.trim()), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::{FunctionCall, ToolCall}; + + fn make_tool_call(name: &str, args: &str) -> ToolCall { + ToolCall { + id: "test_id".into(), + call_type: "function".into(), + function: FunctionCall { + name: name.into(), + arguments: args.into(), + }, + } + } + + #[test] + fn test_compute_hash_same_args_same_hash() { + let a = LoopDetector::compute_hash("read_file", r#"{"path": "foo.txt"}"#); + let b = LoopDetector::compute_hash("read_file", r#"{"path": "foo.txt"}"#); + assert_eq!(a, b); + } + + #[test] + fn test_compute_hash_different_args_different_hash() { + let a = LoopDetector::compute_hash("read_file", r#"{"path": "a.txt"}"#); + let b = LoopDetector::compute_hash("read_file", r#"{"path": "b.txt"}"#); + assert_ne!(a, b); + } + + #[test] + fn test_compute_hash_key_order_invariance() { + let a = LoopDetector::compute_hash("write_file", r#"{"content": "x", "path": "f.txt"}"#); + let b = LoopDetector::compute_hash("write_file", r#"{"path": "f.txt", "content": "x"}"#); + assert_eq!(a, b); + } + + #[test] + fn test_compute_hash_whitespace_invariance() { + let a = LoopDetector::compute_hash("read_file", r#"{"path":"x"}"#); + let b = LoopDetector::compute_hash("read_file", r#"{"path": "x"}"#); + assert_eq!(a, b); + } + + #[test] + fn test_detect_below_threshold_returns_none() { + let mut d = LoopDetector::new(3); + d.record(&[make_tool_call("read", r#"{"path":"x"}"#)], 0); + assert!(d.detect_loop().is_none()); + } + + #[test] + fn test_detect_exact_threshold_detects() { + let mut d = LoopDetector::new(3); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + d.record(&[tc.clone()], 0); + d.record(&[tc.clone()], 1); + d.record(&[tc.clone()], 2); + let info = d.detect_loop().expect("loop should be detected"); + assert_eq!(info.tool_name, "read"); + assert_eq!(info.call_count, 3); + } + + #[test] + fn test_detect_three_different_returns_none() { + let mut d = LoopDetector::new(3); + d.record(&[make_tool_call("a", r#"{"path":"x"}"#)], 0); + d.record(&[make_tool_call("b", r#"{"path":"x"}"#)], 1); + d.record(&[make_tool_call("c", r#"{"path":"x"}"#)], 2); + assert!(d.detect_loop().is_none()); + } + + #[test] + fn test_clear_resets_detection() { + let mut d = LoopDetector::new(3); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + d.record(&[tc.clone()], 0); + d.record(&[tc.clone()], 1); + d.record(&[tc.clone()], 2); + assert!(d.detect_loop().is_some()); + d.clear(); + assert!(d.detect_loop().is_none()); + } + + #[test] + fn test_detects_across_multiple_calls_per_iteration() { + let mut d = LoopDetector::new(3); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + // Two identical calls in iteration 0, one in iteration 1 = 3 total + d.record(&[tc.clone(), tc.clone()], 0); + d.record(&[tc.clone()], 1); + let info = d.detect_loop().expect("cross-turn loop detected"); + assert_eq!(info.tool_name, "read"); + } + + #[test] + fn test_diff_tool_same_args_not_detected() { + let mut d = LoopDetector::new(3); + let tc_a = make_tool_call("read", r#"{"path":"x"}"#); + let tc_b = make_tool_call("write", r#"{"path":"x"}"#); + d.record(&[tc_a], 0); + d.record(&[tc_b], 1); + d.record(&[make_tool_call("read", r#"{"path":"x"}"#)], 2); + assert!(d.detect_loop().is_none()); + } +} +``` + +- [ ] **Step 2: Register module in `src/lib.rs`** + +Add `pub mod loop_detector;` to `src/lib.rs`. + +- [ ] **Step 3: Add `rustc-hash` dependency** + +Run: `cargo add rustc-hash` + +- [ ] **Step 4: Run tests** + +Run: `cargo test loop_detector` +Expected: all 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/loop_detector.rs src/lib.rs Cargo.toml Cargo.lock +git commit -m "feat: add LoopDetector module with exact-repetition detection" +``` + +--- + +### Task 4: Loop Detection Configuration + +**Files:** +- Modify: `src/config.rs` +- Modify: `config.example.toml` + +- [ ] **Step 1: Add `LoopDetectionConfig` struct** + +In `src/config.rs`, add to the `AgentConfig` struct: + +```rust +#[derive(Debug, Deserialize, Clone)] +pub struct LoopDetectionConfig { + #[serde(default = "default_loop_detection_enabled")] + pub enabled: bool, + #[serde(default = "default_loop_detection_threshold")] + pub threshold: usize, + #[serde(default = "default_loop_detection_timeout_seconds")] + pub timeout_seconds: u64, +} + +fn default_loop_detection_enabled() -> bool { true } +fn default_loop_detection_threshold() -> usize { 3 } +fn default_loop_detection_timeout_seconds() -> u64 { 120 } +``` + +Add the field to `AgentConfig`: + +```rust +#[derive(Debug, Deserialize, Clone)] +pub struct AgentConfig { + #[serde(default = "default_max_iterations")] + pub max_iterations: u32, + #[serde(default = "default_empty_response_retry_limit")] + pub empty_response_retry_limit: u32, + #[serde(default = "default_parse_retry_limit")] + pub parse_retry_limit: u32, + #[serde(default)] + pub loop_detection: LoopDetectionConfig, +} +``` + +Add accessor method on `Config`: + +```rust + pub fn loop_detection_config(&self) -> &LoopDetectionConfig { + &self.agent.loop_detection + } +``` + +And a `Default` impl for `LoopDetectionConfig`: + +```rust +impl Default for LoopDetectionConfig { + fn default() -> Self { + Self { + enabled: true, + threshold: 3, + timeout_seconds: 120, + } + } +} +``` + +- [ ] **Step 2: Update `config.example.toml`** + +Add commented section: + +```toml +[agent.loop_detection] +# enabled = true +# threshold = 3 +# timeout_seconds = 120 +``` + +- [ ] **Step 3: Update `default_agent_config()`** + +In `src/config.rs`, update `default_agent_config()` (around line 467) to include the new field: + +```rust +fn default_agent_config() -> AgentConfig { + AgentConfig { + max_iterations: default_max_iterations(), + empty_response_retry_limit: default_empty_response_retry_limit(), + parse_retry_limit: default_parse_retry_limit(), + loop_detection: LoopDetectionConfig::default(), + } +} +``` + +- [ ] **Step 4: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 5: Commit** + +```bash +git add src/config.rs config.example.toml +git commit -m "feat: add LoopDetectionConfig to agent configuration" +``` + +--- + +### Task 5: Loop Detection Callback Registry + +**Files:** +- Modify: `src/agent.rs` (add `pending_loop_callbacks` field + setup methods) + +- [ ] **Step 1: Add callback channel type and Agent field** + +Add near the top of Agent's fields (around line 100): + +```rust +/// Type for the user's choice when a loop is detected. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LoopCallbackChoice { + Continue, + Stop, + AddInstruction, +} + +/// Channel sender for loop detection callbacks, keyed by user_id. +type LoopCallbackRegistry = Arc< + tokio::sync::Mutex< + std::collections::HashMap>, + >, +>; +``` + +Add field to `Agent` struct: + +```rust + pub pending_loop_callbacks: LoopCallbackRegistry, +``` + +Initialize in the constructor(s) with `Arc::new(Mutex::new(HashMap::new()))`. + +- [ ] **Step 2: Add registry methods (async, since Mutex::lock() requires .await)** + +```rust + /// Register a oneshot sender for a user's loop detection callback. + /// Returns the old sender if one was already registered (should not happen + /// in practice since one user has one active process_message). + pub async fn register_loop_callback( + &self, + user_id: &str, + sender: tokio::sync::oneshot::Sender, + ) -> Option> { + let mut map = self.pending_loop_callbacks.lock().await; + map.insert(user_id.to_string(), sender) + } + + /// Take the loop callback sender for a user, if any. + pub async fn take_loop_callback( + &self, + user_id: &str, + ) -> Option> { + let mut map = self.pending_loop_callbacks.lock().await; + map.remove(user_id) + } +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.rs +git commit -m "feat: add loop detection callback registry to Agent" +``` + +--- + +### Task 6: Loop Detection Integration in Agent Loop + +**Files:** +- Modify: `src/agent.rs` (detect loop + suspend + handle response) + +- [ ] **Step 1: Import and init LoopDetector before the main loop** + +In `process_message()`, around line 793 (before `'outer: for iteration`): + +```rust + // Loop detection state (cross-turn, resets each process_message call) + let loop_config = self.config.loop_detection_config(); + let loop_threshold = if loop_config.enabled { + loop_config.threshold + } else { + // When disabled, use a sentinel threshold that never triggers + usize::MAX + }; + let mut loop_detector = crate::loop_detector::LoopDetector::new(loop_threshold); + let loop_timeout = std::time::Duration::from_secs(loop_config.timeout_seconds); + let user_id_str = user_id.to_string(); // owned copy for move into closures +``` + +- [ ] **Step 2: Record + detect after LLM response** + +Before the tool execution section (after line 1182 `response = completion.message; break;`), add: + +```rust + // --- Loop detection: record tool calls and check for repetition --- + if loop_config.enabled { + if let Some(ref tool_calls) = response.tool_calls { + loop_detector.record(tool_calls, iteration as usize); + if let Some(loop_info) = loop_detector.detect_loop() { + info!( + user_id = %user_id, + tool = %loop_info.tool_name, + count = loop_info.call_count, + "Loop detected — pausing for user approval" + ); + + // Build preview: tool name + first argument snippet + let preview = if let Some(first) = response.tool_calls.as_ref() + .and_then(|c| c.first()) + { + let preview = &first.function.arguments; + let preview = if preview.len() > 80 { + format!("{}...", &preview[..80]) + } else { + preview.to_string() + }; + format!("{}({})", loop_info.tool_name, preview) + } else { + loop_info.tool_name.clone() + }; + + // Create oneshot channel for the callback + let (cb_tx, cb_rx) = tokio::sync::oneshot::channel::(); + + // Register callback sender + self.register_loop_callback(&user_id_str, cb_tx); + + // Send Telegram inline keyboard + let keyboard = teloxide::types::InlineKeyboardMarkup::new([ + [ + teloxide::types::InlineKeyboardButton::callback( + "Continue", + r#"{"type":"loop","action":"continue"}"#, + ), + teloxide::types::InlineKeyboardButton::callback( + "Stop", + r#"{"type":"loop","action":"stop"}"#, + ), + ], + [ + teloxide::types::InlineKeyboardButton::callback( + "Add instruction", + r#"{"type":"loop","action":"add_instruction"}"#, + ), + ], + ]); + let bot_for_msg = self.bot.clone(); + let chat_id_for_msg = parsed_chat_id; + let _ = bot_for_msg + .send_message( + chat_id_for_msg, + format!( + "I seem to be calling the same tool repeatedly:\n {} called {} times", + preview, + loop_info.call_count, + ), + ) + .reply_markup(keyboard) + .await; + + // Await user's choice (with timeout) + match tokio::time::timeout(loop_timeout, cb_rx).await { + Ok(Ok(LoopCallbackChoice::Continue)) => { + info!("User approved — continuing loop"); + loop_detector.clear(); + // `continue` targets the 'outer for loop — starts a + // fresh iteration (re-invokes the LLM), skipping any + // remaining tool execution from this iteration. + continue; + } + Ok(Ok(LoopCallbackChoice::Stop)) => { + info!("User requested stop — breaking loop"); + was_cancelled = true; + break 'outer; + } + Ok(Ok(LoopCallbackChoice::AddInstruction)) => { + info!("User requested add instruction — waiting for input"); + // The user will send a text message that gets queued + // as a steer injection. We need another callback + // for the instruction text, or we wait for the steer + // to arrive via pending_injections. + // + // Simplified approach: tell user to type their instruction, + // then wait for a second callback to confirm they're done. + let _ = bot_for_msg + .send_message( + chat_id_for_msg, + "Please type your instruction as your next message. \ + Then tap Continue to resume.", + ) + .reply_markup( + teloxide::types::InlineKeyboardMarkup::new([[ + teloxide::types::InlineKeyboardButton::callback( + "Continue", + r#"{"type":"loop","action":"continue"}"#, + ), + ]]), + ) + .await; + + // Wait for second callback + let (cb2_tx, cb2_rx) = + tokio::sync::oneshot::channel::(); + self.register_loop_callback(&user_id_str, cb2_tx); + match tokio::time::timeout(loop_timeout, cb2_rx).await { + Ok(Ok(LoopCallbackChoice::Continue)) => { + loop_detector.clear(); + continue; + } + _ => { + was_cancelled = true; + break 'outer; + } + } + } + _ => { + // Timeout or channel closed — auto-stop + warn!("Loop callback timed out — stopping"); + was_cancelled = true; + break 'outer; + } + } + } + } + } + // --- End loop detection --- +``` + +Note: The `continue;` after "Continue" goes back to the start of the `'outer` loop, which will re-run the LLM call with the steer (or at a clean iteration boundary). This avoids re-executing the same tool calls. + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.rs +git commit -m "feat: integrate loop detection into main agent loop" +``` + +--- + +### Task 7: Telegram Callback Handler for Loop Detection + +**Files:** +- Modify: `src/platform/telegram.rs` + +- [ ] **Step 1: Add callback query handler for loop detection** + +Add a new handler function in `telegram.rs` following the same pattern as the +existing `handle_model_callback`: + +```rust +async fn handle_loop_callback( + bot: Bot, + q: CallbackQuery, + agent: Arc, +) -> ResponseResult<()> { + let user_id = q.from.id.to_string(); + let data = match q.data { + Some(ref d) => d.clone(), + None => return Ok(()), + }; + + let choice = if data.contains(r#""action":"continue""#) { + crate::agent::LoopCallbackChoice::Continue + } else if data.contains(r#""action":"stop""#) { + crate::agent::LoopCallbackChoice::Stop + } else if data.contains(r#""action":"add_instruction""#) { + crate::agent::LoopCallbackChoice::AddInstruction + } else { + bot.answer_callback_query(q.id).await.ok(); + return Ok(()); + }; + + // Lookup the pending callback sender and send the choice. + // The agent loop awaits the oneshot receiver; this wakes it up. + if let Some(sender) = agent.take_loop_callback(&user_id).await { + let _ = sender.send(choice); + } + + bot.answer_callback_query(q.id).await.ok(); + Ok(()) +} +``` + +- [ ] **Step 2: Register the handler in the dispatcher** + +Register a new handler branch alongside the existing `callback_handler`, +using `.filter_map()` (the same pattern as the existing handler at line 204): + +```rust + let loop_callback_handler = Update::filter_callback_query() + .filter_map(|q: CallbackQuery| async move { + if q.data.as_deref().map_or(false, |d| d.contains(r#""type":"loop""#)) { + Some(q) + } else { + None + } + }) + .endpoint(handle_loop_callback); + + let handler = dptree::entry() + .branch(message_handler) + .branch(callback_handler) + .branch(loop_callback_handler); // new +``` + +The `agent` dependency is already injected via `.dependencies(dptree::deps![agent])` +at line 220, so `handle_loop_callback` receives it automatically. + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat: add callback query handler for loop detection inline keyboard" +``` + +--- + +### Task 8: Loop Detection in Subagent Loop + +**Files:** +- Modify: `src/agent.rs` (subagent loop) + +- [ ] **Step 1: Add recovery nudge injection** + +In `run_subagent_loop` (around line 2605, after `response = completion.message; break;`), add: + +```rust + // --- Loop detection (subagent: auto-recover, no user prompt) --- + if loop_config.enabled { + if let Some(ref tool_calls) = response.tool_calls { + loop_detector_sub.record(tool_calls, _iteration as usize); + if let Some(loop_info) = loop_detector_sub.detect_loop() { + warn!( + subagent = %label, + tool = %loop_info.tool_name, + count = loop_info.call_count, + "Subagent loop detected — injecting recovery nudge" + ); + + // Inject recovery message as a tool result + let nudge_text = format!( + "Error: You have called {} {} times with the same arguments. \ + The result has not changed. Try a different approach.", + loop_info.tool_name, + loop_info.call_count, + ); + messages.push(ChatMessage { + role: "tool".to_string(), + content: Some(MessageContent::from_text(nudge_text)), + tool_calls: None, + tool_call_id: Some("loop_recovery_nudge".to_string()), + }); + + loop_detector_sub.clear(); + continue; + } + } + } + // --- End subagent loop detection --- +``` + +This requires: +- Adding a `loop_detector_sub = LoopDetector::new(threshold)` before the + subagent `for` loop (line 2544), right after `let empty_response_retry_limit`: + + ```rust + let loop_config = self.config.loop_detection_config(); + let sub_threshold = if loop_config.enabled { + loop_config.threshold + } else { + usize::MAX + }; + let mut loop_detector_sub = crate::loop_detector::LoopDetector::new(sub_threshold); + ``` + +- The enabled check, timeout, and user_id_str from Task 6 are not needed here + because the subagent loop auto-recovers instead of prompting the user. + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: clean build. + +- [ ] **Step 3: Add tests for steer injection edge cases** + +Add tests in `src/agent.rs` under `#[cfg(test)]`: + +```rust +#[test] +fn test_steer_injection_label_format() { + // Verify the steer message label matches what the LLM sees + let label_steer = "**[Steer]:** "; + let label_queue = "**[User injected mid-processing]:** "; + assert!(label_steer.contains("Steer")); + assert!(label_queue.contains("injected")); +} +``` + +Also manually verify (cannot unit-test in isolation): +- Queue mode injection persists to DB: send text during processing → check DB +- Steer mode injection does not persist: send text during processing → check DB +- Empty injection queue is no-op: verify no message is added to the conversation + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.rs +git commit -m "feat: add loop detection with recovery nudge to subagent loop" +``` + +--- + +### Task 9: Full Integration Build and Test + +- [ ] **Step 1: Run all tests** + +Run: `cargo test` +Expected: all existing tests pass + all new loop_detector tests pass. + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -- -D warnings` +Expected: no warnings. + +- [ ] **Step 3: Run format check** + +Run: `cargo fmt --all -- --check` +Expected: no formatting issues. + +- [ ] **Step 4: Commit final integration** + +```bash +git add -A +git commit -m "chore: final integration build for loop detection features" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage:** + - Loop detection: Tasks 3-8 cover the full LoopDetector module, config, callback registry, agent loop integration, Telegram UX, and subagent recovery. + - /btw context fork: Task 2 covers the new method, handler replacement, and cleanup. + - Steer injection fix: Task 1 covers the post-tools drain. + +2. **Placeholder scan:** All steps contain actual code. No "TBD", "TODO", or "implement later". + +3. **Type consistency:** `LoopDetector::new(threshold)` is consistent between the module (Task 3), agent loop (Task 6), and subagent loop (Task 8). `LoopCallbackChoice` enum is defined in agent.rs (Task 5) and used in both telegram.rs handler (Task 7) and agent loop (Task 6). diff --git a/docs/superpowers/plans/2026-07-10-richblock-table-conversion.md b/docs/superpowers/plans/2026-07-10-richblock-table-conversion.md new file mode 100644 index 0000000..d53d321 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-richblock-table-conversion.md @@ -0,0 +1,658 @@ +# RichBlock Table Conversion — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `sendRichMessage` (Bot API 10.1) as the primary sending path so markdown pipe tables render as native `RichBlockTable` in Telegram clients. + +**Architecture:** New `src/utils/rich_sender.rs` module wraps raw HTTP calls to `POST /sendRichMessage` and `POST /editMessageText` with `rich_message` param. `send_markdown_message` tries rich first, falls back to entities on HTTP 400. Token stored in a `OnceLock` static in `telegram.rs`. + +**Tech Stack:** Rust, reqwest, serde_json, teloxide 0.17, Telegram Bot API 10.1 + +--- + +### Task 1: Expose `preprocess_markdown` as `pub(crate)` + +**Files:** +- Modify: `src/utils/markdown_entities.rs:31` + +- [ ] **Step 1: Change `fn preprocess_markdown` to `pub(crate) fn preprocess_markdown`** + +In `src/utils/markdown_entities.rs` line 31, change: +```rust +fn preprocess_markdown(md: &str) -> String { +``` +to: +```rust +pub(crate) fn preprocess_markdown(md: &str) -> String { +``` + +- [ ] **Step 2: Run tests to verify nothing broke** + +Run: `cargo test -p rustfox markdown_entities -- --test-threads=1` +Expected: ALL tests pass + +- [ ] **Step 3: Commit** + +```bash +git add src/utils/markdown_entities.rs +git commit -m "feat(rich): make preprocess_markdown pub(crate) for rich_sender reuse" +``` + +--- + +### Task 2: Create `src/utils/rich_sender.rs` module + +**Files:** +- Create: `src/utils/rich_sender.rs` +- Modify: `src/utils/mod.rs` + +- [ ] **Step 1: Register the module in `src/utils/mod.rs`** + +Add to `src/utils/mod.rs`: +```rust +pub mod rich_sender; +``` + +- [ ] **Step 2: Create `src/utils/rich_sender.rs`** + +Write the complete file: + +```rust +use serde::{Deserialize, Serialize}; +use std::future::Future; +use tracing::warn; + +/// Error type distinguishing bad-markdown (retriable) from network (fatal). +#[derive(Debug)] +pub enum RichSenderError { + /// HTTP 400 from Telegram — bad markdown, triggers entity fallback. + BadMarkdown(String), + /// HTTP 5xx, network error, etc. — propagated as fatal. + Network(anyhow::Error), +} + +impl std::fmt::Display for RichSenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RichSenderError::BadMarkdown(msg) => write!(f, "bad markdown: {msg}"), + RichSenderError::Network(e) => write!(f, "network error: {e}"), + } + } +} + +impl std::error::Error for RichSenderError {} + +// --------------------------------------------------------------------------- +// JSON payload shapes for the Telegram Bot API +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct InputRichMessage { + markdown: String, + #[serde(rename = "skip_entity_detection")] + skip_entity_detection: bool, +} + +#[derive(Serialize)] +struct SendRichMessagePayload { + chat_id: i64, + rich_message: InputRichMessage, +} + +#[derive(Serialize)] +struct EditRichMessagePayload { + chat_id: i64, + message_id: i32, + rich_message: InputRichMessage, +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +fn build_client() -> reqwest::Client { + reqwest::Client::new() +} + +fn api_url(token: &str, method: &str) -> String { + format!("https://api.telegram.org/bot{token}/{method}") +} + +async fn parse_response(response: reqwest::Response) -> Result { + let status = response.status(); + let body = response.text().await; + + #[derive(Deserialize)] + struct TgResponse { + ok: bool, + description: Option, + result: Option, + } + + let body = match body { + Ok(b) => b, + Err(e) => return Err(RichSenderError::Network(e.into())), + }; + + let parsed: TgResponse = match serde_json::from_str(&body) { + Ok(p) => p, + Err(e) => return Err(RichSenderError::Network(e.into())), + }; + + if parsed.ok { + Ok(parsed.result.unwrap_or(serde_json::Value::Null)) + } else if status == 400 || status == 422 { + Err(RichSenderError::BadMarkdown( + parsed.description.unwrap_or_default(), + )) + } else { + Err(RichSenderError::Network(anyhow::anyhow!( + "Telegram API error ({}): {}", + status, + parsed.description.unwrap_or_default() + ))) + } +} + +/// Send a single message via `sendRichMessage`. +pub async fn send_rich_message( + token: &str, + chat_id: i64, + markdown: &str, +) -> Result { + let client = build_client(); + let payload = SendRichMessagePayload { + chat_id, + rich_message: InputRichMessage { + markdown: markdown.to_string(), + skip_entity_detection: true, + }, + }; + + let response = client + .post(api_url(token, "sendRichMessage")) + .json(&payload) + .send() + .await + .map_err(|e| RichSenderError::Network(e.into()))?; + + parse_response(response).await +} + +/// Edit an existing message via `editMessageText` with `rich_message` param. +pub async fn edit_rich_message( + token: &str, + chat_id: i64, + message_id: i32, + markdown: &str, +) -> Result { + let client = build_client(); + let payload = EditRichMessagePayload { + chat_id, + message_id, + rich_message: InputRichMessage { + markdown: markdown.to_string(), + skip_entity_detection: true, + }, + }; + + let response = client + .post(api_url(token, "editMessageText")) + .json(&payload) + .send() + .await + .map_err(|e| RichSenderError::Network(e.into()))?; + + parse_response(response).await +} + +/// Send potentially-long markdown split at newline boundaries (max 4090 UTF-16). +/// Returns error only if the FIRST chunk fails (subsequent errors logged only). +pub async fn send_rich_messages( + token: &str, + chat_id: i64, + markdown: &str, +) -> Result<(), RichSenderError> { + const MAX_UTF16: usize = 4090; + + let total_utf16 = markdown.encode_utf16().count(); + if total_utf16 <= MAX_UTF16 { + return send_rich_message(token, chat_id, markdown).await.map(|_| ()); + } + + let chunks = split_markdown_at_newlines(markdown, MAX_UTF16); + + for (i, chunk) in chunks.iter().enumerate() { + if i == 0 { + send_rich_message(token, chat_id, chunk).await?; + } else { + if let Err(e) = send_rich_message(token, chat_id, chunk).await { + warn!("send_rich_message trailing chunk {i} failed: {e}"); + } + } + } + Ok(()) +} + +/// Split markdown at newline boundaries so each chunk fits within `max_utf16`. +fn split_markdown_at_newlines(text: &str, max_utf16: usize) -> Vec { + let mut result = Vec::new(); + let mut start = 0usize; + let total = text.encode_utf16().count(); + + while start < total { + let ideal_end = (start + max_utf16).min(total); + // Find the closest newline before ideal_end + let mut split_at = ideal_end; + // Convert byte positions for substring search + let byte_start = char_boundary_from_utf16(text, start); + let byte_ideal = char_boundary_from_utf16(text, ideal_end); + if let Some(newline_byte) = text[byte_start..byte_ideal].rfind('\n') { + let newline_utf16 = text[..byte_start + newline_byte + 1] + .encode_utf16() + .count(); + if newline_utf16 > start { + split_at = newline_utf16; + } + } + + let chunk_utf16_len = split_at - start; + // convert to byte slice + let byte_start = char_boundary_from_utf16(text, start); + let byte_end = char_boundary_from_utf16(text, split_at); + result.push(text[byte_start..byte_end].to_string()); + start = split_at; + } + + result +} + +fn char_boundary_from_utf16(text: &str, utf16_offset: usize) -> usize { + let mut utf16_so_far = 0; + for (byte_pos, ch) in text.char_indices() { + if utf16_so_far >= utf16_offset { + return byte_pos; + } + utf16_so_far += ch.len_utf16(); + } + text.len() +} + +/// Try sending via sendRichMessage; on BadMarkdown, call `entity_sender` as fallback. +pub async fn try_send_rich_fallback( + token: &str, + chat_id: i64, + markdown: &str, + entity_sender: F, +) -> Result<(), RichSenderError> +where + F: FnOnce() -> Fut, + Fut: Future>, + E: std::fmt::Display, +{ + let processed = crate::utils::markdown_entities::preprocess_markdown(markdown); + match send_rich_messages(token, chat_id, &processed).await { + Ok(()) => Ok(()), + Err(RichSenderError::BadMarkdown(msg)) => { + warn!("sendRichMessage failed (bad markdown), falling back to entities: {msg}"); + entity_sender().await.map_err(|e| RichSenderError::Network(anyhow::anyhow!("fallback: {e}"))) + } + Err(e @ RichSenderError::Network(_)) => { + warn!("sendRichMessage network error, propagating to caller: {e}"); + Err(e) + } + } +} +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/utils/rich_sender.rs src/utils/mod.rs +git commit -m "feat(rich): add rich_sender module wrapping sendRichMessage API" +``` + +--- + +### Task 3: Wire `BOT_TOKEN` static in `telegram.rs` and `main.rs` + +**Files:** +- Modify: `src/platform/telegram.rs` +- Modify: `src/main.rs` + +- [ ] **Step 1: Add `init_bot_token` and `BOT_TOKEN` static to `telegram.rs`** + +Add near the top of `src/platform/telegram.rs`, after the existing imports: + +```rust +use std::sync::OnceLock; + +static BOT_TOKEN: OnceLock = OnceLock::new(); + +/// Must be called once at startup after the Bot is created. +pub fn init_bot_token(token: String) { + BOT_TOKEN.set(token).ok(); +} +``` + +- [ ] **Step 2: Call `init_bot_token` from `main.rs`** + +In `src/main.rs`, after line 209 (`let bot = Arc::new(teloxide::Bot::new(&config.telegram.bot_token));`), add: + +```rust + rustfox::platform::telegram::init_bot_token(config.telegram.bot_token.clone()); +``` + +Note: ensure the `rustfox::platform::telegram` module path is visible (it is — `run_bot` already uses `rustfox::platform::telegram`). + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/telegram.rs src/main.rs +git commit -m "feat(rich): add BOT_TOKEN static and init_bot_token to telegram module" +``` + +--- + +### Task 4: Update `send_markdown_message` to try rich first + +**Files:** +- Modify: `src/platform/telegram.rs` +- Uses: `src/utils/rich_sender.rs` + +- [ ] **Step 1: Add `rich_sender` import to `telegram.rs`** + +Add after the existing `use crate::utils::telegram_markdown::escape_text;` line: +```rust +use crate::utils::rich_sender; +``` + +- [ ] **Step 2: Replace `send_markdown_message` body** + +Replace the current `send_markdown_message` function (lines 225-248) with: + +```rust +/// Send a markdown string as a rich message via sendRichMessage, falling back +/// to entity-formatted sendMessage on failure. +async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { + let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized"); + + let entity_sender = || async { + let (text, entities) = markdown_to_entities(markdown); + let chunks = split_entities(&text, &entities, 4090); + if chunks.is_empty() { + return Ok::<_, teloxide::RequestError>(()); + } + for (i, (chunk_text, chunk_entities)) in chunks.iter().enumerate() { + if i == 0 { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await?; + } else { + bot.send_message(chat_id, chunk_text) + .entities(chunk_entities.clone()) + .await + .ok(); + } + } + Ok(()) + }; + + match rich_sender::try_send_rich_fallback(token, chat_id.0, markdown, &entity_sender).await { + Ok(()) => Ok(()), + Err(e) => { + // try_send_rich_fallback already handled BadMarkdown by calling + // entity_sender internally. If that fallback also failed (or the + // rich path had a network error), propagate the error — retrying + // the entity path here would re-send already-delivered chunks. + warn!("send_rich_message all paths failed: {e}"); + Err(teloxide::RequestError::Io(Arc::new(std::io::Error::other( + format!("{e}"), + )))) + } + } +} +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat(rich): make send_markdown_message try sendRichMessage first with entity fallback" +``` + +--- + +### Task 5: Update streaming final flush to use rich messages + +**Files:** +- Modify: `src/platform/telegram.rs` + +- [ ] **Step 1: Import `serde_json::Value` if needed** + +Check if `serde_json` is already imported (it likely is via `use teloxide::prelude::*`). If not, add: +```rust +use serde_json::Value; +``` +at the top of the file. + +- [ ] **Step 2: Capture `BOT_TOKEN` before the streaming spawn** + +Before the `tokio::spawn` at line 1145, we need to capture the token. The static `BOT_TOKEN` is accessible anywhere, but the closure is `async move` and needs a reference. Use the static directly inside the closure since `OnceLock` is accessible throughout the process lifetime. + +- [ ] **Step 3: Replace the streaming final flush block** + +Replace lines 1210-1242 (the `if !split_contents.is_empty()` block) with: + +```rust + if !split_contents.is_empty() { + let full_text: String = split_contents.join(""); + const MAX_UTF16: usize = 4090; + + // Pre-process markdown for spoiler/underline + let processed = + crate::utils::markdown_entities::preprocess_markdown(&full_text); + + // For the rich path: split pre-processed markdown at newline boundaries. + // For the entity fallback: compute entities from the raw markdown. + let (plain_text, entities) = markdown_to_entities(&full_text); + let entity_chunks = split_entities(&plain_text, &entities, MAX_UTF16); + let total_utf16 = processed.encode_utf16().count(); + let rich_chunks = rich_sender::split_markdown_at_newlines(&processed, MAX_UTF16); + + // Helper: try rich first, fall back to entity chunk i on failure + let try_rich_or_fallback = |i: usize, msg_id: Option| { + let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized").clone(); + let rich_chunks_ref = &rich_chunks; + let entity_chunks_ref = &entity_chunks; + let stream_bot_ref = &stream_bot; + async move { + if let Some(chunk_md) = rich_chunks_ref.get(i) { + let result = if let Some(mid) = msg_id { + rich_sender::edit_rich_message( + &token, + stream_chat_id.0, + mid.0, + chunk_md, + ) + .await + } else { + rich_sender::send_rich_message( + &token, + stream_chat_id.0, + chunk_md, + ) + .await + }; + if result.is_err() { + // Fallback: use entity chunk i + if let Some((ct, ce)) = entity_chunks_ref.get(i) { + if let Some(mid) = msg_id { + stream_bot_ref + .edit_message_text(stream_chat_id, mid, ct) + .entities(ce.clone()) + .await + .ok(); + } else { + stream_bot_ref + .send_message(stream_chat_id, ct) + .entities(ce.clone()) + .await + .ok(); + } + } + } + } + } + }; + + if total_utf16 <= MAX_UTF16 { + try_rich_or_fallback(0, current_msg_id).await; + } else { + for (i, _chunk_md) in rich_chunks.iter().enumerate() { + if i == 0 { + try_rich_or_fallback(0, current_msg_id).await; + } else { + try_rich_or_fallback(i, None).await; + } + } + } + } +``` + +Note: Because the rich split and entity split use different strategies (newline-only vs. newline-and-space), entity_chunks[i] may not contain exactly the same text as rich_chunks[i]. The fallback per-chunk is best-effort: the text will be semantically correct, just potentially split at slightly different boundaries in the rare error case. + +- [ ] **Step 4: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add src/platform/telegram.rs src/utils/rich_sender.rs +git commit -m "feat(rich): update streaming final flush to try sendRichMessage" +``` + +--- + +### Task 6: Add unit tests + +**Files:** +- Modify: `src/utils/rich_sender.rs` (add `#[cfg(test)] mod tests`) + +- [ ] **Step 1: Add chunking tests** + +Append to `src/utils/rich_sender.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_markdown_short_text_not_split() { + let chunks = split_markdown_at_newlines("hello", 4090); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], "hello"); + } + + #[test] + fn test_split_markdown_at_newline_boundary() { + let text = "A".repeat(2000) + "\n" + &"B".repeat(2000); + let chunks = split_markdown_at_newlines(&text, 3000); + assert!(chunks.len() >= 2, "should split into at least 2 chunks"); + assert!(chunks[0].ends_with('\n'), "first chunk should end with newline"); + assert!(!chunks[1].starts_with('\n'), "second chunk should not start with newline"); + } + + #[test] + fn test_split_markdown_utf16_cjk() { + // Each CJK char = 1 UTF-16 unit, "你儽" = 2 units + let text = "你儽".repeat(3000); // 6000 UTF-16 units + let chunks = split_markdown_at_newlines(&text, 4090); + assert!(chunks.len() > 1, "long CJK text must be split"); + for chunk in &chunks { + let utf16_len = chunk.encode_utf16().count(); + assert!( + utf16_len <= 4090, + "chunk must not exceed max_utf16: {utf16_len} > 4090" + ); + } + } + + #[test] + fn test_preprocess_markdown_pub() { + // Verify preprocess_markdown is accessible + let result = crate::utils::markdown_entities::preprocess_markdown("**bold**"); + assert!(result.contains("**bold**"), "preprocess should pass through normal markdown"); + } + + #[test] + fn test_split_markdown_exact_small() { + let chunks = split_markdown_at_newlines("short", 10); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], "short"); + } + + #[test] + fn test_rich_sender_error_type() { + let bad_md = RichSenderError::BadMarkdown("bad".into()); + let net = RichSenderError::Network(anyhow::anyhow!("timeout")); + assert!(matches!(bad_md, RichSenderError::BadMarkdown(_))); + assert!(matches!(net, RichSenderError::Network(_))); + assert!(!matches!(bad_md, RichSenderError::Network(_))); + assert!(!matches!(net, RichSenderError::BadMarkdown(_))); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p rustfox -- --test-threads=1` +Expected: All tests pass (existing + new chunking tests) + +- [ ] **Step 3: Commit** + +```bash +git add src/utils/rich_sender.rs +git commit -m "test(rich): add chunking unit tests for rich_sender" +``` + +> **Note:** Integration tests against the live Telegram API (e.g., mocking HTTP responses to verify chunking + fallback) are deferred. All chunking and error-variant logic is covered by unit tests. Integration coverage is tracked as a future task. + +--- + +### Task 7: Final verification + +**Files:** (no changes) + +- [ ] **Step 1: Run full build** + +Run: `cargo build` +Expected: Compiles with no errors + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -- -D warnings` +Expected: No warnings + +- [ ] **Step 3: Run all tests** + +Run: `cargo test` +Expected: All tests pass, including all markdown_entities + rich_sender tests diff --git a/docs/superpowers/specs/2026-07-10-loop-detection-design.md b/docs/superpowers/specs/2026-07-10-loop-detection-design.md new file mode 100644 index 0000000..65d8065 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-loop-detection-design.md @@ -0,0 +1,323 @@ +# Loop Detection Mechanism for RustFox + +## Problem + +RustFox's agentic loop has no detection for repetitive tool-call patterns. The +only safeguard is `max_iterations` (default 25), which is a blunt instrument — +it kills long legitimate tasks just as readily as stuck ones, and a 3-iteration +tight loop burns 12% of the budget before any guard fires. + +Research across opencode ("doom loop"), Claude Code (leaked `query.ts` source), +and community projects (neokai, DedrooM, LangSight) shows that the #1 production +failure mode for LLM agents is calling the same tool with the same arguments +repeatedly. Opencode solves this with exact repetition detection + a permission +prompt. Claude Code relies on `max_turns` + targeted circuit breakers for +specific subsystems (compact, output tokens) but has no general tool-call +loop detector. + +## Design + +### Detection: exact repetition across turns + +The detector compares `(tool_name, normalized_args_hash)` across all tool calls +made since the last user message. If the last N calls (default N=3) are all +identical, a loop is declared. + +**Scope:** Cross-message (since last user turn), not scoped to a single +assistant message. This is the fix for opencode bug #25254 — per-message +scoping misses loops that span multiple turns. + +**Normalization:** Sort JSON keys alphabetically, strip whitespace, then hash +with a fast non-cryptographic hash (e.g. `std::hash::Hasher` or `fxhash`). + +### Action on detection: two paths + +| Loop location | Action | +|---|---| +| **Main agent loop** (`process_message`) | Send Telegram inline keyboard: [Continue] [Stop] [Add instruction]. Suspend loop, wait for user callback. | +| **Subagent loop** (`run_subagent_loop`) | Auto-inject recovery nudge into message list, continue immediately. No user interaction. | + +### Telegram UX + +When a loop is detected, the bot sends a message with inline keyboard buttons: + +``` +I seem to be calling the same tool repeatedly: + read_file("src/main.rs") called 3 times +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Continue │ │ Stop │ │ Add instruction │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +- **Continue:** Detector clears its window, loop resumes normally. +- **Stop:** Cancels the current processing, returns partial result. +- **Add instruction:** Prompts user to type guidance text, which is injected + as a user message into the conversation, then loop continues. +- **Timeout (120s default):** Auto-stops, returns partial result. + +### Subagent recovery nudge + +The injected message is a tool result containing: + +``` +Error: You have called read_file 3 times with the same arguments. +The result has not changed. Try a different approach. +``` + +This is identical to the neokai pattern — the LLM receives it as a tool result +and adapts. + +### Configuration + +New section in `config.toml`: + +```toml +[agent.loop_detection] +enabled = true +threshold = 3 +timeout_seconds = 120 +``` + +### Module: `src/loop_detector.rs` + +``` +ToolCallRecord { tool_name, args_hash, iteration } +LoopDetector { window: VecDeque, threshold, config } + +fn record(&mut self, tool_calls: &[ToolCall], iteration) + → hash each call, push to window, evict oldest if over threshold + +fn detect_loop(&self) -> Option + → check if last N records all have same args_hash + → returns tool name + call count if loop detected + +fn clear(&mut self) + → empty the window + +fn compute_hash(name: &str, args: &str) -> u64 + → sort JSON keys, trim whitespace, hash +``` + +### Callback query wiring + +When loop is detected in the main loop, `process_message` must suspend and +wait for the user's Telegram inline keyboard response. This requires bridging +the teloxide callback system into the agent loop: + +1. **`CallbackData` format** — each button carries a JSON payload: + ```json + {"type":"loop","action":"continue|stop|add_instruction"} + ``` + +2. **Callback registry** — a shared map on Agent: + ```rust + // Arc>>> + pending_loop_callbacks: ... + ``` + +3. **Flow**: + ``` + Loop detected → create oneshot::channel + → store sender in pending_loop_callbacks[key=user_id] + → send Telegram inline keyboard + → await receiver (with timeout) + → on choice: clear detector, resume/stop + → on timeout: auto-stop with partial result + ``` + +4. **Teloxide callback handler** — registered in the dispatcher at + `telegram.rs` alongside the existing `callback_handler`: + ``` + CallbackQuery received + → parse callback data + → if type="loop", lookup sender in pending_loop_callbacks[user_id] + → send choice through oneshot sender + → answer callback query (remove keyboard loading state) + ``` + +5. **Edge case — user sends new message while suspended**: The new message + hits the injection path at telegram.rs:1060 (since user is processing). + It gets queued as a steer message. When the callback timeout fires and + processing resumes, the steer messages are processed normally at line 869. + +### Integration points in `src/agent.rs` + +| Point | Location | Change | +|---|---|---| +| Before main `for` loop | ~line 793 | Initialize `LoopDetector` on stack | +| After LLM response, before tool exec | ~line 1183 | `detector.record(tool_calls, iteration)` then `detector.detect_loop()` | +| On loop detected (main) | ~line 1185 | Create oneshot channel, register sender, send Telegram keyboard, await with timeout | +| On loop detected (subagent) | ~line 2605 | Inject recovery nudge, `continue` | +| New user message (fresh call) | ~line 571 | Fresh `LoopDetector` on each `process_message` | + +### Ownership + +The `LoopDetector` lives on the stack within `process_message`. The user's +choice is communicated back via a `oneshot::Sender` stored in a shared +registry on `Agent` (behind `Arc>`). On timeout (120s default), +the receiver drops and `process_message` auto-stops. + +The subagent loop does not need the callback mechanism — it auto-injects a +recovery nudge and continues. + +## Fix 1: `/btw` → Context-Forked Side Query (Claude Code pattern) + +### Problem + +The current `/btw` implementation at `telegram.rs:824` calls +`ask_parallel_lightweight()` which builds a blank system prompt + user message +with **zero conversation context**. The LLM cannot answer questions like "what +was that config file name?" because it doesn't see the ongoing conversation. + +### Design: Context fork with strict constraints + +Following Claude Code's leaked `/btw` implementation: + +1. **Fork the conversation context** — pass the current `messages` vector + through a filter that strips orphaned `tool_use` blocks (tool calls without + corresponding `tool_result`), producing a clean context snapshot. + Algorithm: collect all `tool_call_id` values from `role: "tool"` messages + into a set. Walk the messages list; for each `role: "assistant"` message, + keep only those tool calls whose `id` exists in the set. Messages with + no remaining tool calls are kept as-is (text-only responses are fine). +2. **Strict system reminder** — inject a `` message before the + user question: + + > You must answer this question directly in a single response. + > CRITICAL CONSTRAINTS: + > - You have NO tools available + > - This is a one-off response — there will be no follow-up turns + > - Answer based on the conversation context provided above + > - NEVER say "let me try", "I'll now", "let me check" + > - If you don't know, say so — do not offer to investigate + +3. **Single LLM call** — same as current, no agentic loop, no tools passed. +4. **Ephemeral** — response is NOT saved to DB or conversation history. +5. **Parallel** — runs in `tokio::spawn`, does not interrupt main loop. + +### Changes required + +| File | Change | +|---|---| +| `telegram.rs` (handle_message, ~line 840) | Instead of `agent.ask_parallel_lightweight()`, build forked context + system-reminder, call `agent.llm.chat()` directly with the full context. | +| `agent.rs` | Add method `build_btw_context(messages: &[ChatMessage]) → Vec` that filters and constructs the btw prompt. | + +### Cleanup + +The old `ask_parallel_lightweight` method in `agent.rs` is replaced by this +new implementation. If it has no remaining callers after this change, remove +the method to avoid dead code. + +### The /btw flow (new) + +``` +User: /btw what config file did we edit? + → telegram.rs: sends "ā³ BTW..." immediate reply + → reads current messages: `agent.memory.load_messages_with_limit(conversation_id, limit)` + → filters: strips orphaned tool_use blocks + → builds: [filtered_context..., system_reminder, user_question] + → tokio::spawn { agent.llm.chat(forked_messages, &[]) } + → sends answer asynchronously + → answer NOT saved to conversation history +``` + +## Fix 2: Steer Injection Between Tool Calls + +### Problem + +The injection drain at `agent.rs:869-892` runs **once per iteration**, before +the LLM call. If a user sends a steer message during a long tool execution in +the `other_group` sequential loop (lines 1305-1375), the steer sits in the +`pending_injections` queue until ALL tools finish AND the next iteration's +LLM call completes — potentially minutes of delay. + +### Design + +Inject steer messages **after the sorted tool-result batch is committed to +`messages`**, before the `continue` that starts the next iteration. This saves +one full LLM call round-trip compared to the current behavior (which only +drains at the next iteration's heading, after the LLM call). + +In the multi-tool case: if multiple tools run sequentially in the batch, the +drain fires once after ALL of them complete. An injection check between each +individual tool is not specified here — it would require restructuring the +batch flow (breaking the sort-and-commit batch into per-tool steps). This +is a future optimization if multi-tool responses are frequent. + +### Changes required + +In `agent.rs` `process_message()`, after line 1383 (the sorted batch push to +`messages`), before the `continue` at line 1387: + +```rust +// Drain and inject pending steer messages before next iteration. +// Without this, steer is not visible until the next LLM call completes +// (the check at line 869 fires after the LLM call starts the next iteration). +let steer_mode = self.get_mid_run_mode(user_id).await; +let injections = self.drain_injections(user_id).await; +for text in &injections { + let label = if steer_mode == MidRunMode::Steer { + "**[Steer]:** " + } else { + "**[User injected mid-processing]:** " + }; + let msg = ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::from_text(format!("{}{}", label, text))), + tool_calls: None, + tool_call_id: None, + }; + if steer_mode == MidRunMode::Queue { + self.memory.save_message(&conversation_id, &msg).await.ok(); + } + messages.push(msg); +} +``` + +Variable `conversation_id` is in scope (declared at line 586, lives until +function return). `user_id` is also in scope (line 578). The existing +`drain_injections()` method already handles the queue. + +### Why not per-tool or during LLM call + +For `tokio::select!` based interruption of in-flight LLM calls: adds +architectural complexity for minimal gain (LLM calls are typically 5-15s). +For per-tool draining within the batch: would require restructuring the +sort-and-commit flow. Both are future optimizations. + +## Testing strategy + +### Unit-testable components + +| Component | Test cases | +|---|---| +| `LoopDetector::compute_hash` | Same args produces same hash; different args produces different hash; JSON key order invariance; whitespace invariance | +| `LoopDetector::detect_loop` | Below threshold returns None; exactly at threshold (3 identical) returns Some; 3 different returns None; 2 identical + 1 different returns None | +| `LoopDetector::clear` | After clear, detect_loop returns None regardless of prior calls | +| Orphaned `tool_use` filter | Removes tool calls without matching tool_result; preserves calls with matching result; handles text-only messages; handles empty messages list | +| Steer injection edge case | Injection during Queue mode persists to DB; injection during Steer mode does not persist; injection with empty queue is no-op | + +### Integration testing + +The loop detection Telegram callback flow is harder to test in isolation +(teloxide dispatcher, real Telegram API). Cover this with manual testing: +1. Send 3 identical tool calls in sequence → verify inline keyboard appears +2. Tap "Continue" → verify loop resumes +3. Tap "Stop" → verify processing cancels +4. Tap "Add instruction" → verify next response considers the new guidance +5. Wait for timeout → verify auto-stop with partial result + +### Regression testing + +- Verify `/btw` still works (existing test: the immediate reply and async + answer pattern) +- Verify steer injection still works at iteration boundary (existing behavior) +- Verify `/stop` still cancels processing immediately + +## Future extensibility (not in this spec) + +- Cycle detection (A→B→A→B pattern) +- Frequency-based detection (same tool N times in M seconds) +- Per-tool configurable thresholds (e.g., `read_file=5`, `execute_command=3`) +- Semantic similarity for near-identical arguments +- Audit log of detected loops diff --git a/src/agent.rs b/src/agent.rs index 5a0f1f5..bba0795 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -518,12 +518,9 @@ impl Agent { user_id: &str, conversation_id: &str, messages: &mut Vec, - ) -> bool { + ) { let inject_mode = self.get_mid_run_mode(user_id).await; let injections = self.drain_injections(user_id).await; - if injections.is_empty() { - return false; - } for text in &injections { let label = if inject_mode == MidRunMode::Steer { "**[Steer]:** " @@ -543,7 +540,6 @@ impl Agent { } messages.push(msg); } - true } /// Register a oneshot sender for a user's loop detection callback. @@ -875,12 +871,8 @@ impl Agent { // Loop detection state (cross-turn, resets each process_message call) let loop_config = self.config.loop_detection_config(); - let loop_threshold = if loop_config.enabled { - loop_config.threshold - } else { - usize::MAX // when disabled, a threshold that never triggers - }; - let mut loop_detector = crate::loop_detector::LoopDetector::new(loop_threshold); + let mut loop_detector = + crate::loop_detector::LoopDetector::new(loop_config.threshold, loop_config.enabled); let loop_timeout = std::time::Duration::from_secs(loop_config.timeout_seconds); 'outer: for iteration in 0..max_iterations { @@ -2757,14 +2749,10 @@ impl Agent { let empty_response_retry_limit = self.config.empty_response_retry_limit(); let loop_config = self.config.loop_detection_config(); - let sub_threshold = if loop_config.enabled { - loop_config.threshold - } else { - usize::MAX - }; - let mut loop_detector_sub = crate::loop_detector::LoopDetector::new(sub_threshold); + let mut loop_detector_sub = + crate::loop_detector::LoopDetector::new(loop_config.threshold, loop_config.enabled); - for _iteration in 0..max_iter { + for iteration in 0..max_iter { // CHECK: cancelled by /stop? if let Some(ref token) = cancel_token { if token.is_cancelled() { @@ -2828,7 +2816,7 @@ impl Agent { // --- Subagent loop detection: auto-recover with nudge --- if loop_config.enabled { if let Some(ref tool_calls) = response.tool_calls { - loop_detector_sub.record(tool_calls, _iteration as usize); + loop_detector_sub.record(tool_calls, iteration as usize); if let Some(loop_info) = loop_detector_sub.detect_loop() { warn!( subagent = %label, diff --git a/src/loop_detector.rs b/src/loop_detector.rs index 9235a65..dc3099a 100644 --- a/src/loop_detector.rs +++ b/src/loop_detector.rs @@ -23,16 +23,21 @@ pub struct LoopInfo { /// /// Maintains a rolling FIFO window of recent tool calls. A loop is declared /// when the last N entries all have the same (tool_name, args_hash). +/// When `enabled` is false, `record` and `detect_loop` are no-ops and no +/// memory is allocated for the window. pub struct LoopDetector { window: VecDeque, threshold: usize, + enabled: bool, } impl LoopDetector { - pub fn new(threshold: usize) -> Self { + pub fn new(threshold: usize, enabled: bool) -> Self { + let capacity = if enabled { threshold + 1 } else { 0 }; Self { - window: VecDeque::with_capacity(threshold + 1), + window: VecDeque::with_capacity(capacity), threshold, + enabled, } } @@ -58,6 +63,9 @@ impl LoopDetector { /// Record a batch of tool calls from one iteration. pub fn record(&mut self, tool_calls: &[ToolCall], iteration: usize) { + if !self.enabled { + return; + } for tc in tool_calls { let hash = Self::compute_hash(&tc.function.name, &tc.function.arguments); self.window.push_back(ToolCallRecord { @@ -76,7 +84,7 @@ impl LoopDetector { /// Returns `Some(LoopInfo)` when the last N entries all share the same /// (tool_name, args_hash), where N == threshold. pub fn detect_loop(&self) -> Option { - if self.window.len() < self.threshold { + if !self.enabled || self.window.len() < self.threshold { return None; } @@ -167,16 +175,29 @@ mod tests { assert_eq!(a, b); } + #[test] + fn test_disabled_never_detects() { + let mut d = LoopDetector::new(3, false); + let tc = make_tool_call("read", r#"{"path":"x"}"#); + d.record(std::slice::from_ref(&tc), 0); + d.record(std::slice::from_ref(&tc), 1); + d.record(std::slice::from_ref(&tc), 2); + assert!( + d.detect_loop().is_none(), + "disabled detector should not detect" + ); + } + #[test] fn test_detect_below_threshold_returns_none() { - let mut d = LoopDetector::new(3); + let mut d = LoopDetector::new(3, true); d.record(&[make_tool_call("read", r#"{"path":"x"}"#)], 0); assert!(d.detect_loop().is_none()); } #[test] fn test_detect_exact_threshold_detects() { - let mut d = LoopDetector::new(3); + let mut d = LoopDetector::new(3, true); let tc = make_tool_call("read", r#"{"path":"x"}"#); d.record(std::slice::from_ref(&tc), 0); d.record(std::slice::from_ref(&tc), 1); @@ -188,7 +209,7 @@ mod tests { #[test] fn test_detect_three_different_returns_none() { - let mut d = LoopDetector::new(3); + let mut d = LoopDetector::new(3, true); d.record(&[make_tool_call("a", r#"{"path":"x"}"#)], 0); d.record(&[make_tool_call("b", r#"{"path":"x"}"#)], 1); d.record(&[make_tool_call("c", r#"{"path":"x"}"#)], 2); @@ -197,7 +218,7 @@ mod tests { #[test] fn test_clear_resets_detection() { - let mut d = LoopDetector::new(3); + let mut d = LoopDetector::new(3, true); let tc = make_tool_call("read", r#"{"path":"x"}"#); d.record(std::slice::from_ref(&tc), 0); d.record(std::slice::from_ref(&tc), 1); @@ -209,7 +230,7 @@ mod tests { #[test] fn test_detects_across_multiple_calls_per_iteration() { - let mut d = LoopDetector::new(3); + let mut d = LoopDetector::new(3, true); let tc = make_tool_call("read", r#"{"path":"x"}"#); // Two identical calls in iteration 0, one in iteration 1 = 3 total d.record(&[tc.clone(), tc.clone()], 0); @@ -220,7 +241,7 @@ mod tests { #[test] fn test_diff_tool_same_args_not_detected() { - let mut d = LoopDetector::new(3); + let mut d = LoopDetector::new(3, true); let tc_a = make_tool_call("read", r#"{"path":"x"}"#); let tc_b = make_tool_call("write", r#"{"path":"x"}"#); d.record(&[tc_a], 0); From 5dad31b59fb5389ed9826b6f63f389b2df65539b Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 10:45:06 +0800 Subject: [PATCH 35/69] docs: add scheduled task isolation design spec --- ...6-07-13-scheduled-task-isolation-design.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md diff --git a/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md b/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md new file mode 100644 index 0000000..24fb34b --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md @@ -0,0 +1,178 @@ +# Scheduled Task Isolation — Execution History, Context Isolation, Rich Response Formatting + +**Date:** 2026-07-13 +**Feature:** Three fixes for scheduled tasks: (1) context isolation from user conversation, (2) execution history persistence with new tools, (3) rich message formatting for scheduled responses + +## Problem + +### Issue 1: Context mixing + +When a scheduled task fires, it calls `agent.process_message()` with the user's `platform` (`"telegram"`) and `user_id`. This causes: + +1. The scheduled task loads the **user's main conversation** (`get_or_create_conversation("telegram", user_id)`) — including all prior chat history +2. The scheduled task registers a **cancel token** under the user's user_id, causing `is_processing(uid)` to return `true` for the user +3. Any user message sent while the scheduled task runs gets queued as a **steer injection** into the scheduled task's processing loop +4. The scheduled task's execution messages (tool calls, results) get **saved into the user's main conversation history** + +### Issue 2: No execution history or re-run capability + +The `scheduled_tasks` table stores only task definitions (prompt, trigger, status). Once a task runs, there is no record of what happened — no response, no error, no timestamp. There is no tool to re-execute a past task. + +### Issue 3: Raw markdown in scheduled responses + +The background runner (`main.rs:268-275`) sends response text via `bot.send_message(chat, &chunk)` — plain raw markdown without any formatting conversion. All other message paths (normal chat, streaming) use the `sendRichMessage` → entity fallback pipeline for proper rendering. + +## Architecture + +``` + schedule_task tool fire + │ + ā–¼ + fire closure creates IncomingMessage + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ platform: "scheduled_task" │ + │ user_id: "{real_uid}:{task_id}" │ ← unique per task run + │ chat_id: real_chat_id │ + │ text: task prompt │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ + job_tx.send(ScheduledJobRequest) + │ + ā–¼ + Background runner (tokio::spawn) + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ 1. process_message() │ + │ → dedicated SQLite conversation │ + │ → no cancel token for real user │ + │ → no steer injection from user │ + │ │ + │ 2. Send response via │ + │ send_markdown_message() │ + │ → tries sendRichMessage first │ + │ → falls back to entity sender │ + │ │ + │ 3. INSERT INTO scheduled_task_runs │ + │ → persist execution result │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Database + +New table `scheduled_task_runs` (auto-created via `CREATE TABLE IF NOT EXISTS` in `memory/mod.rs` alongside existing tables): + +```sql +CREATE TABLE IF NOT EXISTS scheduled_task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + run_at TEXT NOT NULL, + response TEXT, + error TEXT, + status TEXT NOT NULL DEFAULT 'completed', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task + ON scheduled_task_runs(task_id, run_at); +``` + +### Tools + +Two new tools added to `scheduling_tool_definitions()` in `agent.rs`: + +**`get_scheduled_task_history(task_id)`** + +Returns all execution records for a task, most recent first. Each record shows: run_at, status (completed/failed), response (truncated to 2000 chars), error (if any). Allows the LLM to answer "what happened with my scheduled task?". + +**`rerun_scheduled_task(task_id)`** + +Fetches the task from DB, creates a new one-shot job with 0-second delay (fires immediately). Returns the new run's ID. Does NOT cancel any future recurring occurrences — only adds an extra execution. + +### Changes per file + +#### `src/memory/mod.rs` + +- Add `scheduled_task_runs` table + index creation inside the existing initialization block (after `scheduled_tasks` table) + +#### `src/agent.rs` + +**`schedule_task` handler** (line 3223): + +- Change `IncomingMessage` construction in the fire closure: + - `platform: "scheduled_task".to_string()` (was `"telegram"`) + - `user_id: format!("{user_id}:{task_id}")` (was `user_id.to_string()`) + - (chat_id, text, attachments unchanged) + +**`scheduling_tool_definitions()`** (line 2208): + +- Add two new `ToolDefinition` entries: `get_scheduled_task_history` and `rerun_scheduled_task` + +**`execute_tool()` dispatch** (around line 3400): + +- Add match arms for `"get_scheduled_task_history"` and `"rerun_scheduled_task"`: + - `get_scheduled_task_history`: query `scheduled_task_runs` for task_id, format as text + - `rerun_scheduled_task`: fetch scheduled_task, build fire closure + dispatch one-shot + +**Background runner in `restore_scheduled_tasks()`** (line 2022): + +- No changes needed — the fire closures built here already use `job_tx.send()`, which routes through the same background runner. The fix is in the fire closure itself (platform + user_id). + +#### `src/platform/telegram.rs` + +- Make `send_markdown_message` function `pub` (currently private) so it can be called from the background runner in `main.rs` + +#### `src/main.rs` + +- Add import: `use rustfox::platform::telegram::send_markdown_message;` + +**Background runner** (line 238): + +- After `process_message` returns, persist execution result via `req.task_store.insert_run()` +- Replace raw `bot.send_message(chat, &chunk).await` loop with: + ```rust + let chat = teloxide::types::ChatId(chat_id_val); + if let Err(e) = send_markdown_message(&req.bot, chat, &response).await { + tracing::error!("Failed to send scheduled response: {}", e); + } + ``` + +- Error path: set status to "failed", insert run record with error, send error via `send_markdown_message` + +#### `src/scheduler/reminders.rs` + +- Add `insert_run(task_id, run_at, response, error, status)` method to `ScheduledTaskStore` for persisting execution results. Creates new UUID for run id. +- Add `get_task_runs(task_id, limit)` method returning `Vec` for history queries +- Add `ScheduledTaskRun` struct: + ```rust + #[derive(Debug, Clone)] + pub struct ScheduledTaskRun { + pub id: String, + pub task_id: String, + pub run_at: String, + pub response: Option, + pub error: Option, + pub status: String, + pub created_at: String, + } + ``` + +### Error handling + +| Scenario | Behaviour | +|----------|-----------| +| `send_markdown_message` fails for scheduled response | Log error, skip (degradation: user doesn't see result) | +| `scheduled_task_runs` insert fails | Log warning, response still sent to user | +| `rerun_scheduled_task` on unknown task_id | Return error string to LLM | +| `get_scheduled_task_history` on unknown task_id | Return empty history | +| Scheduled task fails during `process_message` | Persist record with status="failed" + error text, send error to user via `send_markdown_message` | + +### Testing + +- No existing tests for scheduled task execution; manual verification recommended +- Unit test for `ScheduledTaskStore.insert_run()` and `get_task_runs()` in `reminders.rs` +- Unit test for new tool definitions parsing + +### Dependencies + +No new crate dependencies. `uuid`, `chrono`, `rusqlite` already in `Cargo.toml`. From 42b612f82f7a183c62f1b394183d2c7e1a97e5b0 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 10:48:23 +0800 Subject: [PATCH 36/69] =?UTF-8?q?docs:=20fix=20spec=20per=20review=20?= =?UTF-8?q?=E2=80=94=20restore=5Fscheduled=5Ftasks=20needs=20same=20fix,?= =?UTF-8?q?=20run=5Fat=20timing,=20friendly=5Ftool=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-07-13-scheduled-task-isolation-design.md | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md b/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md index 24fb34b..874f7cd 100644 --- a/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md +++ b/docs/superpowers/specs/2026-07-13-scheduled-task-isolation-design.md @@ -25,7 +25,7 @@ The background runner (`main.rs:268-275`) sends response text via `bot.send_mess ## Architecture ``` - schedule_task tool fire + schedule_task tool fire / restore_scheduled_tasks() │ ā–¼ fire closure creates IncomingMessage @@ -40,20 +40,23 @@ The background runner (`main.rs:268-275`) sends response text via `bot.send_mess job_tx.send(ScheduledJobRequest) │ ā–¼ - Background runner (tokio::spawn) + Background runner (main.rs tokio::spawn) ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ 1. process_message() │ + │ 1. Persist run record (status=running)│ + │ → capture run_at = fire time │ + │ │ + │ 2. process_message() │ │ → dedicated SQLite conversation │ │ → no cancel token for real user │ │ → no steer injection from user │ │ │ - │ 2. Send response via │ + │ 3. Update run record (status=done) │ + │ → store response / error │ + │ │ + │ 4. Send response via │ │ send_markdown_message() │ │ → tries sendRichMessage first │ │ → falls back to entity sender │ - │ │ - │ 3. INSERT INTO scheduled_task_runs │ - │ → persist execution result │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ``` @@ -114,9 +117,14 @@ Fetches the task from DB, creates a new one-shot job with 0-second delay (fires - `get_scheduled_task_history`: query `scheduled_task_runs` for task_id, format as text - `rerun_scheduled_task`: fetch scheduled_task, build fire closure + dispatch one-shot -**Background runner in `restore_scheduled_tasks()`** (line 2022): +**`restore_scheduled_tasks()`** (line 2022): + +- Apply the same `IncomingMessage` changes to the fire closure inside `restore_scheduled_tasks()` (lines 2053-2060): + - `platform: "scheduled_task".to_string()` (was `"telegram"`) + - `user_id: format!("{uid}:{tid}")` (was `uid` directly) +- **Do NOT skip this function** — it builds identical fire closures for tasks restored after bot restart. Without these changes, restored tasks would still share conversation context even though newly-created tasks from `schedule_task` handler are fixed. -- No changes needed — the fire closures built here already use `job_tx.send()`, which routes through the same background runner. The fix is in the fire closure itself (platform + user_id). +> **Design note:** Both `schedule_task` (line 3295) and `restore_scheduled_tasks()` (line 2043) contain nearly identical fire closure code. Consider extracting a shared helper method to prevent future divergence. #### `src/platform/telegram.rs` @@ -128,7 +136,16 @@ Fetches the task from DB, creates a new one-shot job with 0-second delay (fires **Background runner** (line 238): -- After `process_message` returns, persist execution result via `req.task_store.insert_run()` +- BEFORE calling `process_message`, capture `run_at` timestamp and persist a run record with `status = 'running'`: + ```rust + let run_id = uuid::Uuid::new_v4().to_string(); + let run_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + let _ = req.task_store.insert_run(&run_id, &req.task_id, &run_at, None, None, "running").await; + ``` +- AFTER `process_message` completes (both success and error), update the run record: + ```rust + let _ = req.task_store.update_run(&run_id, &response, error_opt, "completed").await; + ``` - Replace raw `bot.send_message(chat, &chunk).await` loop with: ```rust let chat = teloxide::types::ChatId(chat_id_val); @@ -136,13 +153,12 @@ Fetches the task from DB, creates a new one-shot job with 0-second delay (fires tracing::error!("Failed to send scheduled response: {}", e); } ``` + Note: `send_markdown_message` returns `ResponseResult<()>` (teloxide error type), not `anyhow::Result`. The `if let Err(e)` pattern handles this correctly. -- Error path: set status to "failed", insert run record with error, send error via `send_markdown_message` +- Error path: update run record with `status = "failed"` and error text, send error via `send_markdown_message` #### `src/scheduler/reminders.rs` -- Add `insert_run(task_id, run_at, response, error, status)` method to `ScheduledTaskStore` for persisting execution results. Creates new UUID for run id. -- Add `get_task_runs(task_id, limit)` method returning `Vec` for history queries - Add `ScheduledTaskRun` struct: ```rust #[derive(Debug, Clone)] @@ -156,6 +172,9 @@ Fetches the task from DB, creates a new one-shot job with 0-second delay (fires pub created_at: String, } ``` +- Add `insert_run(id, task_id, run_at, response, error, status)` method — creates a new run row with given id +- Add `update_run(id, response, error, status)` method — updates an existing run record +- Add `get_task_runs(task_id, limit)` method returning `Vec` ordered by `run_at DESC` ### Error handling @@ -171,7 +190,7 @@ Fetches the task from DB, creates a new one-shot job with 0-second delay (fires - No existing tests for scheduled task execution; manual verification recommended - Unit test for `ScheduledTaskStore.insert_run()` and `get_task_runs()` in `reminders.rs` -- Unit test for new tool definitions parsing +- `friendly_tool_name()` entries for the two new tools must be added to `tool_notifier.rs` so they show human-friendly labels when verbose tool UI is enabled ### Dependencies From fddc657a1e23c307295cf31d6f9efec1a682070d Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 10:57:04 +0800 Subject: [PATCH 37/69] docs: add scheduled task isolation implementation plan --- .../2026-07-13-scheduled-task-isolation.md | 709 ++++++++++++++++++ 1 file changed, 709 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md diff --git a/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md b/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md new file mode 100644 index 0000000..086a599 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md @@ -0,0 +1,709 @@ +# Scheduled Task Isolation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix three scheduled task issues — context isolation from user conversations, execution history persistence with new tools, and rich message formatting for scheduled responses. + +**Architecture:** Use dedicated `platform: "scheduled_task"` + `user_id: "{real_uid}:{task_id}"` in `IncomingMessage` to create isolated SQLite conversations. Persist execution results in a new `scheduled_task_runs` table. Use existing `send_markdown_message()` (rich→entity fallback) for sending responses from the background runner. + +**Tech Stack:** Rust, rusqlite, tokio-cron-scheduler, teloxide, serde_json + +--- + +## Files Summary + +| File | Change | +|------|--------| +| `src/memory/mod.rs` | Add `CREATE TABLE scheduled_task_runs` + index | +| `src/scheduler/reminders.rs` | Add `ScheduledTaskRun` struct + `insert_run`, `update_run`, `get_task_runs` methods | +| `src/agent.rs` | Fix fire closures in 2 places; add tool definitions + dispatch for 2 new tools | +| `src/platform/telegram.rs` | Make `send_markdown_message` `pub` | +| `src/platform/tool_notifier.rs` | Add `friendly_tool_name` entries for 2 new tools | +| `src/main.rs` | Replace raw `send_message` with `send_markdown_message`; persist run records | + +--- + +### Task 1: Add `scheduled_task_runs` table + +**Files:** +- Modify: `src/memory/mod.rs` (after `scheduled_tasks` table, around line 220) + +- [ ] **Step 1: Add the new table DDL** + +In `src/memory/mod.rs`, after the `scheduled_tasks` index at line 220, add: + +```rust + -- Scheduled task execution history + CREATE TABLE IF NOT EXISTS scheduled_task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + run_at TEXT NOT NULL, + response TEXT, + error TEXT, + status TEXT NOT NULL DEFAULT 'completed', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) + ); + + CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task + ON scheduled_task_runs(task_id, run_at); +``` + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/memory/mod.rs +git commit -m "feat(scheduler): add scheduled_task_runs table for execution history" +``` + +--- + +### Task 2: Add `ScheduledTaskRun` struct and CRUD methods to `ScheduledTaskStore` + +**Files:** +- Modify: `src/scheduler/reminders.rs` + +- [ ] **Step 1: Add `ScheduledTaskRun` struct after `ScheduledTask`** + +In `src/scheduler/reminders.rs`, after the existing `ScheduledTask` struct, add: + +```rust +#[derive(Debug, Clone)] +pub struct ScheduledTaskRun { + pub id: String, + pub task_id: String, + pub run_at: String, + pub response: Option, + pub error: Option, + pub status: String, + pub created_at: String, +} +``` + +- [ ] **Step 2: Add `insert_run` method** + +Add after the existing `update_next_run_at` method: + +```rust + pub async fn insert_run( + &self, + id: &str, + task_id: &str, + run_at: &str, + response: Option<&str>, + error: Option<&str>, + status: &str, + ) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO scheduled_task_runs (id, task_id, run_at, response, error, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![id, task_id, run_at, response, error, status], + ) + .context("Failed to insert scheduled task run")?; + Ok(()) + } +``` + +- [ ] **Step 3: Add `update_run` method** + +Add after `insert_run`: + +```rust + pub async fn update_run( + &self, + id: &str, + response: Option<&str>, + error: Option<&str>, + status: &str, + ) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE scheduled_task_runs SET response = ?1, error = ?2, status = ?3 WHERE id = ?4", + rusqlite::params![response, error, status, id], + ) + .context("Failed to update scheduled task run")?; + Ok(()) + } +``` + +- [ ] **Step 4: Add `get_task_runs` method** + +Add after `update_run`: + +```rust + pub async fn get_task_runs(&self, task_id: &str, limit: usize) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn + .prepare( + "SELECT id, task_id, run_at, response, error, status, created_at + FROM scheduled_task_runs + WHERE task_id = ?1 + ORDER BY run_at DESC + LIMIT ?2", + ) + .context("Failed to prepare get_task_runs query")?; + let runs = stmt + .query_map(rusqlite::params![task_id, limit as i64], |row| { + Ok(ScheduledTaskRun { + id: row.get(0)?, + task_id: row.get(1)?, + run_at: row.get(2)?, + response: row.get(3)?, + error: row.get(4)?, + status: row.get(5)?, + created_at: row.get(6)?, + }) + }) + .context("Failed to map rows")? + .collect::>>() + .context("Failed to collect rows")?; + Ok(runs) + } +``` + +- [ ] **Step 5: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 6: Commit** + +```bash +git add src/scheduler/reminders.rs +git commit -m "feat(scheduler): add ScheduledTaskRun struct with insert/update/get methods" +``` + +--- + +### Task 3: Fix fire closures in `schedule_task` handler and `restore_scheduled_tasks()` + +**Files:** +- Modify: `src/agent.rs` + +- [ ] **Step 1: Fix `schedule_task` handler fire closure (line ~3305)** + +In the `"schedule_task"` match arm, change the `IncomingMessage` construction inside the fire closure: + +Old: +```rust +let incoming = crate::platform::IncomingMessage { + platform: "telegram".to_string(), + user_id: uid, + ... +}; +``` + +New: +```rust +let incoming = crate::platform::IncomingMessage { + platform: "scheduled_task".to_string(), + user_id: format!("{uid}:{tid}"), + ... +}; +``` + +Note: the local `tid` is `task_id` (the UUID of the task). The `uid` is the user's real ID. The closure captures `tid` and `uid` by clone. Since `fire` already captures `uid` and `tid` (as `uid` and `tid` variables), the format string uses those names. Verify the captured variable names match the actual closure code. + +- [ ] **Step 2: Fix `restore_scheduled_tasks()` fire closure (line ~2053)** + +In `restore_scheduled_tasks()`, same change to the `IncomingMessage` construction inside the fire closure: + +Old: +```rust +let incoming = crate::platform::IncomingMessage { + platform: "telegram".to_string(), + user_id: uid, + ... +}; +``` + +New: +```rust +let incoming = crate::platform::IncomingMessage { + platform: "scheduled_task".to_string(), + user_id: format!("{uid}:{tid}"), + ... +}; +``` + +Here `uid` and `tid` are already captured variables (`let uid = task.user_id.clone()` and `let tid = task.id.clone()`). Verify exact captured variable names against the actual code. + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.rs +git commit -m "fix(scheduler): isolate scheduled task conversations with dedicated platform/user_id" +``` + +--- + +### Task 4: Add `get_scheduled_task_history` and `rerun_scheduled_task` tool definitions + +**Files:** +- Modify: `src/agent.rs` (in `scheduling_tool_definitions()` around line 2208) + +- [ ] **Step 1: Add two new tool definitions** + +In `scheduling_tool_definitions()`, after the `cancel_scheduled_task` entry, add: + +```rust + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "get_scheduled_task_history".to_string(), + description: "Retrieve execution history for a scheduled task, including run timestamps, status, and response text.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "The task ID from list_scheduled_tasks" } + }, + "required": ["task_id"] + }), + }, + }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "rerun_scheduled_task".to_string(), + description: "Execute a scheduled task immediately, regardless of its normal schedule. Does not cancel future occurrences.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "The task ID to execute now" } + }, + "required": ["task_id"] + }), + }, + }, +``` + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/agent.rs +git commit -m "feat(scheduler): add get_scheduled_task_history and rerun_scheduled_task tool defs" +``` + +--- + +### Task 5: Add dispatch handlers for the two new tools + +**Files:** +- Modify: `src/agent.rs` (in `execute_tool()` around line 3400) + +- [ ] **Step 1: Add `get_scheduled_task_history` handler** + +After the `cancel_scheduled_task` match arm (around line 3399), add: + +```rust + "get_scheduled_task_history" => { + let task_id = match arguments["task_id"].as_str() { + Some(id) => id.to_string(), + None => return "Missing task_id".to_string(), + }; + match self.task_store.get_task_runs(&task_id, 20).await { + Ok(runs) if runs.is_empty() => { + format!("No execution history for task '{}'.", task_id) + } + Ok(runs) => { + let mut out = format!("Execution history for task '{}' ({} runs):\n\n", task_id, runs.len()); + for r in &runs { + let resp = r.response.as_deref().unwrap_or("(no response)"); + let err = r.error.as_deref().map(|e| format!("\nError: {}", e)).unwrap_or_default(); + let truncated = if resp.len() > 2000 { + format!("{}... (truncated)", &resp[..2000]) + } else { + resp.to_string() + }; + out.push_str(&format!( + "Run at: {} | Status: {}\n{}{}\n\n", + r.run_at, r.status, truncated, err + )); + } + out + } + Err(e) => format!("Failed to query task history: {}", e), + } + } +``` + +- [ ] **Step 2: Add `rerun_scheduled_task` handler** + +After the `get_scheduled_task_history` arm, add: + +```rust + "rerun_scheduled_task" => { + let task_id = match arguments["task_id"].as_str() { + Some(id) => id.to_string(), + None => return "Missing task_id".to_string(), + }; + let task = match self.task_store.get_by_id(&task_id).await { + Ok(Some(t)) => t, + Ok(None) => return format!("Task '{}' not found.", task_id), + Err(e) => return format!("Failed to look up task: {}", e), + }; + // Build fire closure (same pattern as schedule_task handler) + let job_tx = self.job_tx.clone(); + let bot_clone = Arc::clone(&self.bot); + let store_clone = self.task_store.clone(); + let tid = task.id.clone(); + let uid = task.user_id.clone(); + let cid = task.chat_id.clone(); + let prompt_cap = task.prompt.clone(); + let is_recurring = false; + + let fire = move || { + let tx = job_tx.clone(); + let bot = bot_clone.clone(); + let store = store_clone.clone(); + let tid = tid.clone(); + let uid = uid.clone(); + let cid = cid.clone(); + let prompt = prompt_cap.clone(); + Box::pin(async move { + let incoming = crate::platform::IncomingMessage { + platform: "scheduled_task".to_string(), + user_id: format!("{uid}:{tid}"), + chat_id: cid, + user_name: String::new(), + text: prompt, + attachments: vec![], + }; + let req = crate::agent::ScheduledJobRequest { + incoming, + bot, + task_id: tid, + is_recurring, + task_store: store, + }; + if let Err(e) = tx.send(req) { + tracing::error!("Failed to dispatch rerun scheduled job: {}", e); + } + }) + as std::pin::Pin + Send>> + }; + + // Fire immediately with a 1-second delay to allow the response to return + match self.scheduler.add_one_shot_job( + std::time::Duration::from_secs(1), + &format!("rerun-{}", task.description), + fire, + ).await { + Ok(sched_id) => { + format!("Task '{}' scheduled for immediate re-execution (scheduler ID: {}).", task_id, sched_id) + } + Err(e) => format!("Failed to re-run task: {}", e), + } + } +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.rs +git commit -m "feat(scheduler): add handler dispatch for get_scheduled_task_history and rerun_scheduled_task" +``` + +--- + +### Task 6: Make `send_markdown_message` public + +**Files:** +- Modify: `src/platform/telegram.rs` + +- [ ] **Step 1: Change `send_markdown_message` from private to `pub`** + +Find `async fn send_markdown_message` at line 248 and change it to: + +```rust +pub async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { +``` + +- [ ] **Step 2: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/platform/telegram.rs +git commit -m "feat(telegram): make send_markdown_message pub for scheduled task use" +``` + +--- + +### Task 7: Update background runner in `main.rs` for rich formatting + run persistence + +**Files:** +- Modify: `src/main.rs` + +- [ ] **Step 1: Add import for `send_markdown_message`** + +At the top of `src/main.rs`, add to the existing `use` block: + +```rust +use rustfox::platform::telegram::send_markdown_message; +``` + +- [ ] **Step 2: Replace the background runner body** + +Replace the entire background runner `tokio::spawn` block (lines 236-277) with: + +```rust + // Spawn background runner: receives ScheduledJobRequest, calls process_message, persists result, sends reply + let agent_for_runner = Arc::clone(&agent); + tokio::spawn(async move { + use teloxide::prelude::*; + while let Some(req) = job_rx.recv().await { + let agent = Arc::clone(&agent_for_runner); + + // Persist run record BEFORE processing (capture fire time) + let run_id = uuid::Uuid::new_v4().to_string(); + let run_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + let _ = req.task_store.insert_run( + &run_id, &req.task_id, &run_at, None, None, "running", + ).await; + + let response = match agent.process_message(&req.incoming, None, None).await { + Ok(r) => { + let _ = req.task_store.update_run( + &run_id, Some(&r), None, "completed", + ).await; + r + } + Err(e) => { + tracing::error!("Scheduled task {} failed: {}", req.task_id, e); + let err_str = format!("{:#}", e); + let _ = req.task_store.update_run( + &run_id, None, Some(&err_str), "failed", + ).await; + if !req.is_recurring { + let _ = req.task_store.set_status(&req.task_id, "failed").await; + } + // Send error to user via rich message + let chat_id_val: i64 = match req.incoming.chat_id.parse() { + Ok(v) => v, + Err(_) => { + tracing::error!( + "Unparseable chat_id '{}' for task {}", + req.incoming.chat_id, + req.task_id + ); + continue; + } + }; + let chat = teloxide::types::ChatId(chat_id_val); + let error_msg = format!("**Scheduled task failed:** {}", e); + let _ = send_markdown_message(&req.bot, chat, &error_msg).await; + continue; + } + }; + + let chat_id_val: i64 = match req.incoming.chat_id.parse() { + Ok(v) => v, + Err(_) => { + tracing::error!( + "Unparseable chat_id '{}' for task {}", + req.incoming.chat_id, + req.task_id + ); + continue; + } + }; + let chat = teloxide::types::ChatId(chat_id_val); + if let Err(e) = send_markdown_message(&req.bot, chat, &response).await { + tracing::error!("Failed to send scheduled response: {}", e); + } + } + }); +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo check` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add src/main.rs +git commit -m "fix(scheduler): use send_markdown_message and persist run records in background runner" +``` + +--- + +### Task 8: Add `friendly_tool_name` entries for new tools + +**Files:** +- Modify: `src/platform/tool_notifier.rs` + +- [ ] **Step 1: Add entries in `friendly_tool_name()`** + +In `friendly_tool_name()` at line 282, after the `"cancel_scheduled_task"` entry, add: + +```rust + "get_scheduled_task_history" => return "šŸ“‹ Checking task history".to_string(), + "rerun_scheduled_task" => return "šŸ”„ Re-running scheduled task".to_string(), +``` + +- [ ] **Step 2: Add unit tests for the new entries** + +In `tool_notifier.rs`, after the `#[cfg(test)]` section, the existing test module `mod tests` at the bottom of the file. After the relevant test functions (around line 1131), add: + +```rust + #[test] + fn test_friendly_tool_name_get_scheduled_task_history() { + assert_eq!( + friendly_tool_name("get_scheduled_task_history"), + "šŸ“‹ Checking task history" + ); + } + + #[test] + fn test_friendly_tool_name_rerun_scheduled_task() { + assert_eq!( + friendly_tool_name("rerun_scheduled_task"), + "šŸ”„ Re-running scheduled task" + ); + } +``` + +- [ ] **Step 3: Build and test** + +Run: `cargo test -p rustfox tool_notifier -- --test-threads=1` +Expected: All tests pass including the two new ones + +- [ ] **Step 4: Commit** + +```bash +git add src/platform/tool_notifier.rs +git commit -m "feat(notifier): add friendly_tool_name entries for new scheduled task tools" +``` + +--- + +### Task 9: Add unit tests for `ScheduledTaskStore` run methods + +**Files:** +- Modify: `src/scheduler/reminders.rs` (add `#[cfg(test)] mod tests`) + +- [ ] **Step 1: Add test module** + +Append to `src/scheduler/reminders.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::MemoryStore; + + #[tokio::test] + async fn test_insert_and_get_task_runs() { + let memory = MemoryStore::open_in_memory().unwrap(); + let store = ScheduledTaskStore::new(memory.connection()); + + store + .insert_run("run-1", "task-1", "2026-07-13T10:00:00", Some("hello"), None, "completed") + .await + .unwrap(); + store + .insert_run("run-2", "task-1", "2026-07-13T11:00:00", None, Some("error"), "failed") + .await + .unwrap(); + + let runs = store.get_task_runs("task-1", 10).await.unwrap(); + assert_eq!(runs.len(), 2); + // Most recent first + assert_eq!(runs[0].id, "run-2"); + assert_eq!(runs[1].id, "run-1"); + assert_eq!(runs[0].response, None); + assert_eq!(runs[0].error.as_deref(), Some("error")); + assert_eq!(runs[1].response.as_deref(), Some("hello")); + } + + #[tokio::test] + async fn test_get_task_runs_empty() { + let memory = MemoryStore::open_in_memory().unwrap(); + let store = ScheduledTaskStore::new(memory.connection()); + + let runs = store.get_task_runs("nonexistent", 10).await.unwrap(); + assert!(runs.is_empty()); + } + + #[tokio::test] + async fn test_update_run() { + let memory = MemoryStore::open_in_memory().unwrap(); + let store = ScheduledTaskStore::new(memory.connection()); + + store + .insert_run("run-x", "task-x", "2026-07-13T12:00:00", None, None, "running") + .await + .unwrap(); + + store + .update_run("run-x", Some("result"), None, "completed") + .await + .unwrap(); + + let runs = store.get_task_runs("task-x", 10).await.unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].response.as_deref(), Some("result")); + assert_eq!(runs[0].status, "completed"); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p rustfox reminders -- --test-threads=1` +Expected: All tests pass + +- [ ] **Step 3: Commit** + +```bash +git add src/scheduler/reminders.rs +git commit -m "test(scheduler): add unit tests for ScheduledTaskStore run methods" +``` + +--- + +### Task 10: Final verification + +**Files:** (no changes) + +- [ ] **Step 1: Run full build** + +Run: `cargo build` +Expected: Compiles with no errors + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -- -D warnings` +Expected: No warnings + +- [ ] **Step 3: Run all tests** + +Run: `cargo test` +Expected: All tests pass From a200d1d2b4e78578343b762fc0bcddd90b6eba91 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:01:30 +0800 Subject: [PATCH 38/69] =?UTF-8?q?docs:=20fix=20plan=20per=20review=20?= =?UTF-8?q?=E2=80=94=20run=20ID=20msg,=20error=20logging,=200s=20delay,=20?= =?UTF-8?q?line=20numbers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-13-scheduled-task-isolation.md | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md b/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md index 086a599..4026995 100644 --- a/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md +++ b/docs/superpowers/plans/2026-07-13-scheduled-task-isolation.md @@ -399,14 +399,15 @@ After the `get_scheduled_task_history` arm, add: as std::pin::Pin + Send>> }; - // Fire immediately with a 1-second delay to allow the response to return + // Fire immediately with 0-second delay (fires on next scheduler tick) + // The run ID will be generated by the background runner when the job executes. match self.scheduler.add_one_shot_job( - std::time::Duration::from_secs(1), + std::time::Duration::ZERO, &format!("rerun-{}", task.description), fire, ).await { - Ok(sched_id) => { - format!("Task '{}' scheduled for immediate re-execution (scheduler ID: {}).", task_id, sched_id) + Ok(_sched_id) => { + format!("Task '{}' scheduled for immediate re-execution.", task_id) } Err(e) => format!("Failed to re-run task: {}", e), } @@ -482,23 +483,29 @@ Replace the entire background runner `tokio::spawn` block (lines 236-277) with: // Persist run record BEFORE processing (capture fire time) let run_id = uuid::Uuid::new_v4().to_string(); let run_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); - let _ = req.task_store.insert_run( + if let Err(e) = req.task_store.insert_run( &run_id, &req.task_id, &run_at, None, None, "running", - ).await; + ).await { + tracing::warn!("Failed to persist scheduled task run record: {}", e); + } let response = match agent.process_message(&req.incoming, None, None).await { Ok(r) => { - let _ = req.task_store.update_run( + if let Err(e) = req.task_store.update_run( &run_id, Some(&r), None, "completed", - ).await; + ).await { + tracing::warn!("Failed to update scheduled task run record: {}", e); + } r } Err(e) => { tracing::error!("Scheduled task {} failed: {}", req.task_id, e); let err_str = format!("{:#}", e); - let _ = req.task_store.update_run( + if let Err(e) = req.task_store.update_run( &run_id, None, Some(&err_str), "failed", - ).await; + ).await { + tracing::warn!("Failed to update failed scheduled task run record: {}", e); + } if !req.is_recurring { let _ = req.task_store.set_status(&req.task_id, "failed").await; } @@ -561,7 +568,7 @@ git commit -m "fix(scheduler): use send_markdown_message and persist run records - [ ] **Step 1: Add entries in `friendly_tool_name()`** -In `friendly_tool_name()` at line 282, after the `"cancel_scheduled_task"` entry, add: +In `friendly_tool_name()`, after the `"cancel_scheduled_task"` entry, add: ```rust "get_scheduled_task_history" => return "šŸ“‹ Checking task history".to_string(), @@ -570,7 +577,7 @@ In `friendly_tool_name()` at line 282, after the `"cancel_scheduled_task"` entry - [ ] **Step 2: Add unit tests for the new entries** -In `tool_notifier.rs`, after the `#[cfg(test)]` section, the existing test module `mod tests` at the bottom of the file. After the relevant test functions (around line 1131), add: +At the end of the test module in `tool_notifier.rs`, before the closing `}` of `mod tests`, add: ```rust #[test] From d015dd1c947050823d7eb8bc0d08599b97763d28 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:21:37 +0800 Subject: [PATCH 39/69] feat(scheduler): add scheduled_task_runs table for execution history --- src/memory/mod.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 00c1aa3..5257775 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -219,6 +219,21 @@ impl MemoryStore { CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_user ON scheduled_tasks(user_id, status); + -- Scheduled task execution history + CREATE TABLE IF NOT EXISTS scheduled_task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + run_at TEXT NOT NULL, + response TEXT, + error TEXT, + status TEXT NOT NULL DEFAULT 'completed', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) + ); + + CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task + ON scheduled_task_runs(task_id, run_at); + -- Supervisor: tasks CREATE TABLE IF NOT EXISTS sup_tasks ( id TEXT PRIMARY KEY, From ca024c907b0bdd757dd31667175a5471e4bc9017 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:23:28 +0800 Subject: [PATCH 40/69] feat(scheduler): add ScheduledTaskRun struct with insert/update/get methods --- src/scheduler/reminders.rs | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/scheduler/reminders.rs b/src/scheduler/reminders.rs index 2f57637..e44eab9 100644 --- a/src/scheduler/reminders.rs +++ b/src/scheduler/reminders.rs @@ -20,6 +20,18 @@ pub struct ScheduledTask { pub next_run_at: Option, } +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ScheduledTaskRun { + pub id: String, + pub task_id: String, + pub run_at: String, + pub response: Option, + pub error: Option, + pub status: String, + pub created_at: String, +} + #[derive(Clone)] #[allow(dead_code)] pub struct ScheduledTaskStore { @@ -136,6 +148,70 @@ impl ScheduledTaskStore { Ok(()) } + pub async fn insert_run( + &self, + id: &str, + task_id: &str, + run_at: &str, + response: Option<&str>, + error: Option<&str>, + status: &str, + ) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO scheduled_task_runs (id, task_id, run_at, response, error, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![id, task_id, run_at, response, error, status], + ) + .context("Failed to insert scheduled task run")?; + Ok(()) + } + + pub async fn update_run( + &self, + id: &str, + response: Option<&str>, + error: Option<&str>, + status: &str, + ) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE scheduled_task_runs SET response = ?1, error = ?2, status = ?3 WHERE id = ?4", + rusqlite::params![response, error, status, id], + ) + .context("Failed to update scheduled task run")?; + Ok(()) + } + + pub async fn get_task_runs(&self, task_id: &str, limit: usize) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn + .prepare( + "SELECT id, task_id, run_at, response, error, status, created_at + FROM scheduled_task_runs + WHERE task_id = ?1 + ORDER BY run_at DESC + LIMIT ?2", + ) + .context("Failed to prepare get_task_runs query")?; + let runs = stmt + .query_map(rusqlite::params![task_id, limit as i64], |row| { + Ok(ScheduledTaskRun { + id: row.get(0)?, + task_id: row.get(1)?, + run_at: row.get(2)?, + response: row.get(3)?, + error: row.get(4)?, + status: row.get(5)?, + created_at: row.get(6)?, + }) + }) + .context("Failed to map rows")? + .collect::>>() + .context("Failed to collect rows")?; + Ok(runs) + } + // Private helper — executes SELECT with a WHERE clause fragment. // Takes &Connection directly (caller already holds the lock). fn query_tasks( From d9cd6741045f6501ece386a27d554b1436c899ec Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:25:10 +0800 Subject: [PATCH 41/69] fix(scheduler): isolate scheduled task conversations with dedicated platform/user_id --- src/agent.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index bba0795..fbdc9d4 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2051,8 +2051,8 @@ impl Agent { let recurring = is_recurring; Box::pin(async move { let incoming = crate::platform::IncomingMessage { - platform: "telegram".to_string(), - user_id: uid, + platform: "scheduled_task".to_string(), + user_id: format!("{uid}:{tid}"), chat_id: cid, user_name: String::new(), text: prompt, @@ -3303,8 +3303,8 @@ impl Agent { let recurring = is_recurring; Box::pin(async move { let incoming = crate::platform::IncomingMessage { - platform: "telegram".to_string(), - user_id: uid, + platform: "scheduled_task".to_string(), + user_id: format!("{uid}:{tid}"), chat_id: cid, user_name: String::new(), text: prompt, From ef5edfc677d2e68b28181e1fe342c86da36ce0a3 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:28:05 +0800 Subject: [PATCH 42/69] feat(scheduler): add get_scheduled_task_history and rerun_scheduled_task tool defs --- src/agent.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index fbdc9d4..9108a4b 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2264,6 +2264,34 @@ impl Agent { }), }, }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "get_scheduled_task_history".to_string(), + description: "Retrieve execution history for a scheduled task, including run timestamps, status, and response text.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "The task ID from list_scheduled_tasks" } + }, + "required": ["task_id"] + }), + }, + }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "rerun_scheduled_task".to_string(), + description: "Execute a scheduled task immediately, regardless of its normal schedule. Does not cancel future occurrences.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "The task ID to execute now" } + }, + "required": ["task_id"] + }), + }, + }, ] } @@ -3398,6 +3426,94 @@ impl Agent { Err(e) => format!("Failed to update task status: {}", e), } } + "get_scheduled_task_history" => { + let task_id = match arguments["task_id"].as_str() { + Some(id) => id.to_string(), + None => return "Missing task_id".to_string(), + }; + match self.task_store.get_task_runs(&task_id, 20).await { + Ok(runs) if runs.is_empty() => { + format!("No execution history for task '{}'.", task_id) + } + Ok(runs) => { + let mut out = format!("Execution history for task '{}' ({} runs):\n\n", task_id, runs.len()); + for r in &runs { + let resp = r.response.as_deref().unwrap_or("(no response)"); + let err = r.error.as_deref().map(|e| format!("\nError: {}", e)).unwrap_or_default(); + let truncated = crate::utils::strings::truncate_chars(resp, 2000); + out.push_str(&format!( + "Run at: {} | Status: {}\n{}{}\n\n", + r.run_at, r.status, truncated, err + )); + } + out + } + Err(e) => format!("Failed to query task history: {}", e), + } + } + "rerun_scheduled_task" => { + let task_id = match arguments["task_id"].as_str() { + Some(id) => id.to_string(), + None => return "Missing task_id".to_string(), + }; + let task = match self.task_store.get_by_id(&task_id).await { + Ok(Some(t)) => t, + Ok(None) => return format!("Task '{}' not found.", task_id), + Err(e) => return format!("Failed to look up task: {}", e), + }; + // Build fire closure (same pattern as schedule_task handler) + let job_tx = self.job_tx.clone(); + let bot_clone = Arc::clone(&self.bot); + let store_clone = self.task_store.clone(); + let tid = task.id.clone(); + let uid = task.user_id.clone(); + let cid = task.chat_id.clone(); + let prompt_cap = task.prompt.clone(); + let is_recurring = false; + + let fire = move || { + let tx = job_tx.clone(); + let bot = bot_clone.clone(); + let store = store_clone.clone(); + let tid = tid.clone(); + let uid = uid.clone(); + let cid = cid.clone(); + let prompt = prompt_cap.clone(); + Box::pin(async move { + let incoming = crate::platform::IncomingMessage { + platform: "scheduled_task".to_string(), + user_id: format!("{uid}:{tid}"), + chat_id: cid, + user_name: String::new(), + text: prompt, + attachments: vec![], + }; + let req = crate::agent::ScheduledJobRequest { + incoming, + bot, + task_id: tid, + is_recurring, + task_store: store, + }; + if let Err(e) = tx.send(req) { + tracing::error!("Failed to dispatch rerun scheduled job: {}", e); + } + }) + as std::pin::Pin + Send>> + }; + + // Fire immediately with 0-second delay (fires on next scheduler tick) + match self.scheduler.add_one_shot_job( + std::time::Duration::ZERO, + &format!("rerun-{}", task.description), + fire, + ).await { + Ok(_sched_id) => { + format!("Task '{}' scheduled for immediate re-execution.", task_id) + } + Err(e) => format!("Failed to re-run task: {}", e), + } + } "read_skill_file" => { let skill_name = match arguments["skill_name"].as_str() { Some(n) => n.to_string(), From f603d334b3059b1cb47e518fb2e6d63f37ff78be Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:31:23 +0800 Subject: [PATCH 43/69] feat(telegram): make send_markdown_message pub for scheduled task use --- src/platform/telegram.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 548d73b..22e5896 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -245,7 +245,7 @@ pub async fn run( /// Send a markdown string as a rich message via sendRichMessage, falling back /// to entity-formatted sendMessage on failure. -async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { +pub async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized"); let entity_sender = || async { From 6c21e5e69e47770ac726f14615c29addedbaece2 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:31:56 +0800 Subject: [PATCH 44/69] fix(scheduler): use send_markdown_message and persist run records in background runner --- src/main.rs | 55 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index dc3906f..6a08716 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,26 +233,60 @@ async fn main() -> Result<()> { ) }); - // Spawn background runner: receives ScheduledJobRequest, calls process_message, sends reply + // Spawn background runner: receives ScheduledJobRequest, calls process_message, persists result, sends reply let agent_for_runner = Arc::clone(&agent); tokio::spawn(async move { - use teloxide::prelude::*; while let Some(req) = job_rx.recv().await { let agent = Arc::clone(&agent_for_runner); - // Mark one-shot as completed (before running, so failure can override) - if !req.is_recurring { - let _ = req.task_store.set_status(&req.task_id, "completed").await; + + // Persist run record BEFORE processing (capture fire time) + let run_id = uuid::Uuid::new_v4().to_string(); + let run_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + if let Err(e) = req.task_store.insert_run( + &run_id, &req.task_id, &run_at, None, None, "running", + ).await { + tracing::warn!("Failed to persist scheduled task run record: {}", e); } + let response = match agent.process_message(&req.incoming, None, None).await { - Ok(r) => r, + Ok(r) => { + if let Err(e) = req.task_store.update_run( + &run_id, Some(&r), None, "completed", + ).await { + tracing::warn!("Failed to update scheduled task run record: {}", e); + } + r + } Err(e) => { tracing::error!("Scheduled task {} failed: {}", req.task_id, e); + let err_str = format!("{:#}", e); + if let Err(e) = req.task_store.update_run( + &run_id, None, Some(&err_str), "failed", + ).await { + tracing::warn!("Failed to update failed scheduled task run record: {}", e); + } if !req.is_recurring { let _ = req.task_store.set_status(&req.task_id, "failed").await; } + // Send error to user via rich message + let chat_id_val: i64 = match req.incoming.chat_id.parse() { + Ok(v) => v, + Err(_) => { + tracing::error!( + "Unparseable chat_id '{}' for task {}", + req.incoming.chat_id, + req.task_id + ); + continue; + } + }; + let chat = teloxide::types::ChatId(chat_id_val); + let error_msg = format!("**Scheduled task failed:** {}", e); + let _ = rustfox::platform::telegram::send_markdown_message(&req.bot, chat, &error_msg).await; continue; } }; + let chat_id_val: i64 = match req.incoming.chat_id.parse() { Ok(v) => v, Err(_) => { @@ -265,13 +299,8 @@ async fn main() -> Result<()> { } }; let chat = teloxide::types::ChatId(chat_id_val); - for chunk in rustfox::agent::split_response_chunks(&response, 4000) { - if chunk.is_empty() { - continue; - } - if let Err(e) = req.bot.send_message(chat, &chunk).await { - tracing::error!("Failed to send scheduled response: {}", e); - } + if let Err(e) = rustfox::platform::telegram::send_markdown_message(&req.bot, chat, &response).await { + tracing::error!("Failed to send scheduled response: {}", e); } } }); From 6a976ecd36e7249db4348f9e3c0dbb2185c1c8fb Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:33:40 +0800 Subject: [PATCH 45/69] feat(notifier): add friendly_tool_name entries for new scheduled task tools --- src/platform/tool_notifier.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/platform/tool_notifier.rs b/src/platform/tool_notifier.rs index b83233c..048cba8 100644 --- a/src/platform/tool_notifier.rs +++ b/src/platform/tool_notifier.rs @@ -290,6 +290,8 @@ pub fn friendly_tool_name(name: &str) -> String { "schedule_task" => return "šŸ—“ļø Scheduling a task".to_string(), "list_scheduled_tasks" => return "šŸ—“ļø Checking scheduled tasks".to_string(), "cancel_scheduled_task" => return "šŸ—“ļø Cancelling a task".to_string(), + "get_scheduled_task_history" => return "šŸ“‹ Checking task history".to_string(), + "rerun_scheduled_task" => return "šŸ”„ Re-running scheduled task".to_string(), "invoke_agent" => return "šŸ¤– Calling a specialist".to_string(), "plan_create" | "plan_update" | "plan_view" => return "šŸ“‹ Managing plan".to_string(), "read_skill_file" => return "šŸ“– Reading skill".to_string(), @@ -1134,4 +1136,20 @@ mod tests { "verb prefix should be stripped: {friendly}" ); } + + #[test] + fn test_friendly_tool_name_get_scheduled_task_history() { + assert_eq!( + friendly_tool_name("get_scheduled_task_history"), + "šŸ“‹ Checking task history" + ); + } + + #[test] + fn test_friendly_tool_name_rerun_scheduled_task() { + assert_eq!( + friendly_tool_name("rerun_scheduled_task"), + "šŸ”„ Re-running scheduled task" + ); + } } From 10b9ec791ed141a7053fca78f730e1fad1c977b1 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 13 Jul 2026 11:35:41 +0800 Subject: [PATCH 46/69] test(scheduler): add unit tests for ScheduledTaskStore run methods --- src/scheduler/reminders.rs | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/scheduler/reminders.rs b/src/scheduler/reminders.rs index e44eab9..9d0e90d 100644 --- a/src/scheduler/reminders.rs +++ b/src/scheduler/reminders.rs @@ -344,4 +344,66 @@ mod tests { let tasks = store.list_all_active().await.unwrap(); assert_eq!(tasks[0].scheduler_job_id.as_deref(), Some("sched-uuid-123")); } + + #[tokio::test] + async fn test_insert_and_get_task_runs() { + let memory = MemoryStore::open_in_memory().unwrap(); + let store = ScheduledTaskStore::new(memory.connection()); + + // Create parent task first for FOREIGN KEY + let task = make_task("task-1", "user-1", "one_shot"); + store.create(&task).await.unwrap(); + + store + .insert_run("run-1", "task-1", "2026-07-13T10:00:00", Some("hello"), None, "completed") + .await + .unwrap(); + store + .insert_run("run-2", "task-1", "2026-07-13T11:00:00", None, Some("error"), "failed") + .await + .unwrap(); + + let runs = store.get_task_runs("task-1", 10).await.unwrap(); + assert_eq!(runs.len(), 2); + // Most recent first + assert_eq!(runs[0].id, "run-2"); + assert_eq!(runs[1].id, "run-1"); + assert_eq!(runs[0].response, None); + assert_eq!(runs[0].error.as_deref(), Some("error")); + assert_eq!(runs[1].response.as_deref(), Some("hello")); + } + + #[tokio::test] + async fn test_get_task_runs_empty() { + let memory = MemoryStore::open_in_memory().unwrap(); + let store = ScheduledTaskStore::new(memory.connection()); + + let runs = store.get_task_runs("nonexistent", 10).await.unwrap(); + assert!(runs.is_empty()); + } + + #[tokio::test] + async fn test_update_run() { + let memory = MemoryStore::open_in_memory().unwrap(); + let store = ScheduledTaskStore::new(memory.connection()); + + // Create parent task first for FOREIGN KEY + let task = make_task("task-x", "user-1", "one_shot"); + store.create(&task).await.unwrap(); + + store + .insert_run("run-x", "task-x", "2026-07-13T12:00:00", None, None, "running") + .await + .unwrap(); + + store + .update_run("run-x", Some("result"), None, "completed") + .await + .unwrap(); + + let runs = store.get_task_runs("task-x", 10).await.unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].response.as_deref(), Some("result")); + assert_eq!(runs[0].status, "completed"); + } } From 448b494505e54450c03e9cf3c29d9a084ae88aed Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Tue, 14 Jul 2026 08:44:17 +0800 Subject: [PATCH 47/69] fix: answer callback queries even when no data present --- src/platform/telegram.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 22e5896..be4f3a3 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -227,8 +227,8 @@ pub async fn run( let handler = dptree::entry() .branch(message_handler) - .branch(callback_handler) - .branch(loop_callback_handler); + .branch(loop_callback_handler) + .branch(callback_handler); Dispatcher::builder(bot, handler) .dependencies(dptree::deps![agent]) @@ -1430,7 +1430,11 @@ async fn handle_loop_callback(bot: Bot, q: CallbackQuery, agent: Arc) -> let user_id = q.from.id.to_string(); let data = match q.data { Some(ref d) => d.clone(), - None => return Ok(()), + None => { + // Even if there's no data, we must answer the callback query + bot.answer_callback_query(q.id).await.ok(); + return Ok(()); + } }; // Parse the user's choice from callback data @@ -1464,7 +1468,11 @@ async fn handle_model_callback( let callback_id = q.id.clone(); let data = match q.data { Some(ref d) => d.clone(), - None => return Ok(()), + None => { + // Even if there's no data, we must answer the callback query + bot.answer_callback_query(callback_id).await.ok(); + return Ok(()); + } }; let msg = q.regular_message().cloned(); From b3ccc8c64808dd29b240bdebba31db117621e21a Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 14 Jul 2026 08:54:09 +0800 Subject: [PATCH 48/69] fix: reformat code to comply with rustfmt --- src/agent.rs | 26 +++++++++++++++++++------- src/main.rs | 33 ++++++++++++++++++++++----------- src/platform/telegram.rs | 6 +++++- src/scheduler/reminders.rs | 33 +++++++++++++++++++++++++++++---- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 9108a4b..b0180d3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -3436,10 +3436,18 @@ impl Agent { format!("No execution history for task '{}'.", task_id) } Ok(runs) => { - let mut out = format!("Execution history for task '{}' ({} runs):\n\n", task_id, runs.len()); + let mut out = format!( + "Execution history for task '{}' ({} runs):\n\n", + task_id, + runs.len() + ); for r in &runs { let resp = r.response.as_deref().unwrap_or("(no response)"); - let err = r.error.as_deref().map(|e| format!("\nError: {}", e)).unwrap_or_default(); + let err = r + .error + .as_deref() + .map(|e| format!("\nError: {}", e)) + .unwrap_or_default(); let truncated = crate::utils::strings::truncate_chars(resp, 2000); out.push_str(&format!( "Run at: {} | Status: {}\n{}{}\n\n", @@ -3503,11 +3511,15 @@ impl Agent { }; // Fire immediately with 0-second delay (fires on next scheduler tick) - match self.scheduler.add_one_shot_job( - std::time::Duration::ZERO, - &format!("rerun-{}", task.description), - fire, - ).await { + match self + .scheduler + .add_one_shot_job( + std::time::Duration::ZERO, + &format!("rerun-{}", task.description), + fire, + ) + .await + { Ok(_sched_id) => { format!("Task '{}' scheduled for immediate re-execution.", task_id) } diff --git a/src/main.rs b/src/main.rs index 6a08716..1e3bc5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -242,17 +242,21 @@ async fn main() -> Result<()> { // Persist run record BEFORE processing (capture fire time) let run_id = uuid::Uuid::new_v4().to_string(); let run_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); - if let Err(e) = req.task_store.insert_run( - &run_id, &req.task_id, &run_at, None, None, "running", - ).await { + if let Err(e) = req + .task_store + .insert_run(&run_id, &req.task_id, &run_at, None, None, "running") + .await + { tracing::warn!("Failed to persist scheduled task run record: {}", e); } let response = match agent.process_message(&req.incoming, None, None).await { Ok(r) => { - if let Err(e) = req.task_store.update_run( - &run_id, Some(&r), None, "completed", - ).await { + if let Err(e) = req + .task_store + .update_run(&run_id, Some(&r), None, "completed") + .await + { tracing::warn!("Failed to update scheduled task run record: {}", e); } r @@ -260,9 +264,11 @@ async fn main() -> Result<()> { Err(e) => { tracing::error!("Scheduled task {} failed: {}", req.task_id, e); let err_str = format!("{:#}", e); - if let Err(e) = req.task_store.update_run( - &run_id, None, Some(&err_str), "failed", - ).await { + if let Err(e) = req + .task_store + .update_run(&run_id, None, Some(&err_str), "failed") + .await + { tracing::warn!("Failed to update failed scheduled task run record: {}", e); } if !req.is_recurring { @@ -282,7 +288,10 @@ async fn main() -> Result<()> { }; let chat = teloxide::types::ChatId(chat_id_val); let error_msg = format!("**Scheduled task failed:** {}", e); - let _ = rustfox::platform::telegram::send_markdown_message(&req.bot, chat, &error_msg).await; + let _ = rustfox::platform::telegram::send_markdown_message( + &req.bot, chat, &error_msg, + ) + .await; continue; } }; @@ -299,7 +308,9 @@ async fn main() -> Result<()> { } }; let chat = teloxide::types::ChatId(chat_id_val); - if let Err(e) = rustfox::platform::telegram::send_markdown_message(&req.bot, chat, &response).await { + if let Err(e) = + rustfox::platform::telegram::send_markdown_message(&req.bot, chat, &response).await + { tracing::error!("Failed to send scheduled response: {}", e); } } diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index be4f3a3..c593c9f 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -245,7 +245,11 @@ pub async fn run( /// Send a markdown string as a rich message via sendRichMessage, falling back /// to entity-formatted sendMessage on failure. -pub async fn send_markdown_message(bot: &Bot, chat_id: ChatId, markdown: &str) -> ResponseResult<()> { +pub async fn send_markdown_message( + bot: &Bot, + chat_id: ChatId, + markdown: &str, +) -> ResponseResult<()> { let token = BOT_TOKEN.get().expect("BOT_TOKEN not initialized"); let entity_sender = || async { diff --git a/src/scheduler/reminders.rs b/src/scheduler/reminders.rs index 9d0e90d..866bef5 100644 --- a/src/scheduler/reminders.rs +++ b/src/scheduler/reminders.rs @@ -183,7 +183,11 @@ impl ScheduledTaskStore { Ok(()) } - pub async fn get_task_runs(&self, task_id: &str, limit: usize) -> Result> { + pub async fn get_task_runs( + &self, + task_id: &str, + limit: usize, + ) -> Result> { let conn = self.conn.lock().await; let mut stmt = conn .prepare( @@ -355,11 +359,25 @@ mod tests { store.create(&task).await.unwrap(); store - .insert_run("run-1", "task-1", "2026-07-13T10:00:00", Some("hello"), None, "completed") + .insert_run( + "run-1", + "task-1", + "2026-07-13T10:00:00", + Some("hello"), + None, + "completed", + ) .await .unwrap(); store - .insert_run("run-2", "task-1", "2026-07-13T11:00:00", None, Some("error"), "failed") + .insert_run( + "run-2", + "task-1", + "2026-07-13T11:00:00", + None, + Some("error"), + "failed", + ) .await .unwrap(); @@ -392,7 +410,14 @@ mod tests { store.create(&task).await.unwrap(); store - .insert_run("run-x", "task-x", "2026-07-13T12:00:00", None, None, "running") + .insert_run( + "run-x", + "task-x", + "2026-07-13T12:00:00", + None, + None, + "running", + ) .await .unwrap(); From 70a3143b0bb6b5cf7875054c8d1b73b5fca5981c Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Tue, 14 Jul 2026 08:56:50 +0800 Subject: [PATCH 49/69] update opencode agent for better CP model --- .opencode/agents/code-quality-reviewer.md | 2 +- .opencode/agents/code-reviewer.md | 2 +- .opencode/agents/implementer.md | 2 +- .opencode/agents/plan-document-reviewer.md | 2 +- .opencode/agents/spec-document-reviewer.md | 2 +- .opencode/agents/spec-reviewer.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.opencode/agents/code-quality-reviewer.md b/.opencode/agents/code-quality-reviewer.md index ca88900..9f8d535 100644 --- a/.opencode/agents/code-quality-reviewer.md +++ b/.opencode/agents/code-quality-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews implementation code quality — cleanliness, test coverage, maintainability, structure. Only dispatch after spec compliance review passes. mode: subagent -# model: opencode-go/minimax-m3 +model: opencode-go/minimax-m3 permission: read: allow glob: allow diff --git a/.opencode/agents/code-reviewer.md b/.opencode/agents/code-reviewer.md index 725098d..fb921ca 100644 --- a/.opencode/agents/code-reviewer.md +++ b/.opencode/agents/code-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews completed project steps against original plans, coding standards, and best practices. Use when a major project step has been completed and needs review. mode: subagent -# model: opencode-go/minimax-m3 +model: opencode-go/minimax-m3 permission: read: allow glob: allow diff --git a/.opencode/agents/implementer.md b/.opencode/agents/implementer.md index 5d43006..73cfe1b 100644 --- a/.opencode/agents/implementer.md +++ b/.opencode/agents/implementer.md @@ -1,7 +1,7 @@ --- description: Implements spec-defined tasks from plans. Writes tests, implements features, verifies work, and commits. Best for mechanical implementation with clear specs. mode: subagent -# model: opencode-go/minimax-m3 +model: opencode-go/deepseek-v4-flash permission: read: allow write: allow diff --git a/.opencode/agents/plan-document-reviewer.md b/.opencode/agents/plan-document-reviewer.md index dac09cd..6ac2572 100644 --- a/.opencode/agents/plan-document-reviewer.md +++ b/.opencode/agents/plan-document-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews implementation plans for completeness, spec alignment, task decomposition, and buildability before execution. mode: subagent -# model: opencode-go/deepseek-v4-flash +model: opencode-go/minimax-m3 permission: read: allow edit: deny diff --git a/.opencode/agents/spec-document-reviewer.md b/.opencode/agents/spec-document-reviewer.md index c45fde6..2ffede6 100644 --- a/.opencode/agents/spec-document-reviewer.md +++ b/.opencode/agents/spec-document-reviewer.md @@ -1,7 +1,7 @@ --- description: Reviews specification documents for completeness, consistency, clarity, and readiness before planning begins. mode: subagent -# model: opencode-go/mimo-v2.5 +model: opencode-go/kimi-k2.7-code permission: read: allow edit: deny diff --git a/.opencode/agents/spec-reviewer.md b/.opencode/agents/spec-reviewer.md index 9845fc9..dd8398e 100644 --- a/.opencode/agents/spec-reviewer.md +++ b/.opencode/agents/spec-reviewer.md @@ -1,7 +1,7 @@ --- description: Verifies that an implementation matches its specification exactly — nothing more, nothing less. Dispatch after an implementer completes work. mode: subagent -# model: opencode-go/minimax-m3 +model: opencode-go/kimi-k2.7-code permission: read: allow glob: allow From d9bd5ab06227d9c30b371d710138493f133f4bd1 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Tue, 14 Jul 2026 11:46:46 +0800 Subject: [PATCH 50/69] Installed Skills For Real Engineers --- .agents/skills/ask-matt/SKILL.md | 78 +++++++ .agents/skills/ask-matt/agents/openai.yaml | 5 + .agents/skills/code-review/SKILL.md | 89 ++++++++ .agents/skills/code-review/agents/openai.yaml | 3 + .agents/skills/codebase-design/DEEPENING.md | 37 ++++ .../skills/codebase-design/DESIGN-IT-TWICE.md | 44 ++++ .agents/skills/codebase-design/SKILL.md | 114 ++++++++++ .../skills/codebase-design/agents/openai.yaml | 3 + .agents/skills/design-an-interface/SKILL.md | 94 ++++++++ .../design-an-interface/agents/openai.yaml | 3 + .agents/skills/diagnosing-bugs/SKILL.md | 134 ++++++++++++ .../skills/diagnosing-bugs/agents/openai.yaml | 3 + .../scripts/hitl-loop.template.sh | 41 ++++ .agents/skills/domain-modeling/ADR-FORMAT.md | 47 ++++ .../skills/domain-modeling/CONTEXT-FORMAT.md | 60 +++++ .agents/skills/domain-modeling/SKILL.md | 74 +++++++ .../skills/domain-modeling/agents/openai.yaml | 3 + .agents/skills/grill-me/SKILL.md | 7 + .agents/skills/grill-me/agents/openai.yaml | 5 + .agents/skills/grill-with-docs/SKILL.md | 7 + .../skills/grill-with-docs/agents/openai.yaml | 5 + .agents/skills/grilling/SKILL.md | 12 + .agents/skills/grilling/agents/openai.yaml | 3 + .agents/skills/handoff/SKILL.md | 16 ++ .agents/skills/handoff/agents/openai.yaml | 5 + .agents/skills/implement/SKILL.md | 15 ++ .agents/skills/implement/agents/openai.yaml | 5 + .../HTML-REPORT.md | 123 +++++++++++ .../improve-codebase-architecture/SKILL.md | 71 ++++++ .../agents/openai.yaml | 5 + .agents/skills/prototype/LOGIC.md | 79 +++++++ .agents/skills/prototype/SKILL.md | 26 +++ .agents/skills/prototype/UI.md | 112 ++++++++++ .agents/skills/prototype/agents/openai.yaml | 3 + .agents/skills/research/SKILL.md | 12 + .agents/skills/research/agents/openai.yaml | 3 + .../skills/resolving-merge-conflicts/SKILL.md | 14 ++ .../agents/openai.yaml | 3 + .../skills/setup-matt-pocock-skills/SKILL.md | 116 ++++++++++ .../agents/openai.yaml | 5 + .../skills/setup-matt-pocock-skills/domain.md | 51 +++++ .../issue-tracker-github.md | 45 ++++ .../issue-tracker-gitlab.md | 46 ++++ .../issue-tracker-local.md | 30 +++ .../setup-matt-pocock-skills/triage-labels.md | 15 ++ .agents/skills/tdd/SKILL.md | 36 +++ .agents/skills/tdd/agents/openai.yaml | 3 + .agents/skills/tdd/mocking.md | 59 +++++ .agents/skills/tdd/tests.md | 77 +++++++ .agents/skills/teach/GLOSSARY-FORMAT.md | 35 +++ .../skills/teach/LEARNING-RECORD-FORMAT.md | 46 ++++ .agents/skills/teach/MISSION-FORMAT.md | 31 +++ .agents/skills/teach/RESOURCES-FORMAT.md | 32 +++ .agents/skills/teach/SKILL.md | 140 ++++++++++++ .agents/skills/teach/agents/openai.yaml | 5 + .agents/skills/to-spec/SKILL.md | 75 +++++++ .agents/skills/to-spec/agents/openai.yaml | 5 + .agents/skills/to-tickets/SKILL.md | 107 +++++++++ .agents/skills/to-tickets/agents/openai.yaml | 5 + .agents/skills/triage/AGENT-BRIEF.md | 207 ++++++++++++++++++ .agents/skills/triage/OUT-OF-SCOPE.md | 105 +++++++++ .agents/skills/triage/SKILL.md | 112 ++++++++++ .agents/skills/triage/agents/openai.yaml | 5 + .agents/skills/wayfinder/SKILL.md | 128 +++++++++++ .agents/skills/wayfinder/agents/openai.yaml | 5 + .../skills/writing-great-skills/GLOSSARY.md | 201 +++++++++++++++++ .agents/skills/writing-great-skills/SKILL.md | 83 +++++++ .../writing-great-skills/agents/openai.yaml | 5 + CLAUDE.md | 14 ++ docs/agents/domain.md | 51 +++++ docs/agents/issue-tracker.md | 45 ++++ docs/agents/triage-labels.md | 15 ++ skills-lock.json | 143 ++++++++++++ 73 files changed, 3446 insertions(+) create mode 100644 .agents/skills/ask-matt/SKILL.md create mode 100644 .agents/skills/ask-matt/agents/openai.yaml create mode 100644 .agents/skills/code-review/SKILL.md create mode 100644 .agents/skills/code-review/agents/openai.yaml create mode 100644 .agents/skills/codebase-design/DEEPENING.md create mode 100644 .agents/skills/codebase-design/DESIGN-IT-TWICE.md create mode 100644 .agents/skills/codebase-design/SKILL.md create mode 100644 .agents/skills/codebase-design/agents/openai.yaml create mode 100644 .agents/skills/design-an-interface/SKILL.md create mode 100644 .agents/skills/design-an-interface/agents/openai.yaml create mode 100644 .agents/skills/diagnosing-bugs/SKILL.md create mode 100644 .agents/skills/diagnosing-bugs/agents/openai.yaml create mode 100644 .agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh create mode 100644 .agents/skills/domain-modeling/ADR-FORMAT.md create mode 100644 .agents/skills/domain-modeling/CONTEXT-FORMAT.md create mode 100644 .agents/skills/domain-modeling/SKILL.md create mode 100644 .agents/skills/domain-modeling/agents/openai.yaml create mode 100644 .agents/skills/grill-me/SKILL.md create mode 100644 .agents/skills/grill-me/agents/openai.yaml create mode 100644 .agents/skills/grill-with-docs/SKILL.md create mode 100644 .agents/skills/grill-with-docs/agents/openai.yaml create mode 100644 .agents/skills/grilling/SKILL.md create mode 100644 .agents/skills/grilling/agents/openai.yaml create mode 100644 .agents/skills/handoff/SKILL.md create mode 100644 .agents/skills/handoff/agents/openai.yaml create mode 100644 .agents/skills/implement/SKILL.md create mode 100644 .agents/skills/implement/agents/openai.yaml create mode 100644 .agents/skills/improve-codebase-architecture/HTML-REPORT.md create mode 100644 .agents/skills/improve-codebase-architecture/SKILL.md create mode 100644 .agents/skills/improve-codebase-architecture/agents/openai.yaml create mode 100644 .agents/skills/prototype/LOGIC.md create mode 100644 .agents/skills/prototype/SKILL.md create mode 100644 .agents/skills/prototype/UI.md create mode 100644 .agents/skills/prototype/agents/openai.yaml create mode 100644 .agents/skills/research/SKILL.md create mode 100644 .agents/skills/research/agents/openai.yaml create mode 100644 .agents/skills/resolving-merge-conflicts/SKILL.md create mode 100644 .agents/skills/resolving-merge-conflicts/agents/openai.yaml create mode 100644 .agents/skills/setup-matt-pocock-skills/SKILL.md create mode 100644 .agents/skills/setup-matt-pocock-skills/agents/openai.yaml create mode 100644 .agents/skills/setup-matt-pocock-skills/domain.md create mode 100644 .agents/skills/setup-matt-pocock-skills/issue-tracker-github.md create mode 100644 .agents/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md create mode 100644 .agents/skills/setup-matt-pocock-skills/issue-tracker-local.md create mode 100644 .agents/skills/setup-matt-pocock-skills/triage-labels.md create mode 100644 .agents/skills/tdd/SKILL.md create mode 100644 .agents/skills/tdd/agents/openai.yaml create mode 100644 .agents/skills/tdd/mocking.md create mode 100644 .agents/skills/tdd/tests.md create mode 100644 .agents/skills/teach/GLOSSARY-FORMAT.md create mode 100644 .agents/skills/teach/LEARNING-RECORD-FORMAT.md create mode 100644 .agents/skills/teach/MISSION-FORMAT.md create mode 100644 .agents/skills/teach/RESOURCES-FORMAT.md create mode 100644 .agents/skills/teach/SKILL.md create mode 100644 .agents/skills/teach/agents/openai.yaml create mode 100644 .agents/skills/to-spec/SKILL.md create mode 100644 .agents/skills/to-spec/agents/openai.yaml create mode 100644 .agents/skills/to-tickets/SKILL.md create mode 100644 .agents/skills/to-tickets/agents/openai.yaml create mode 100644 .agents/skills/triage/AGENT-BRIEF.md create mode 100644 .agents/skills/triage/OUT-OF-SCOPE.md create mode 100644 .agents/skills/triage/SKILL.md create mode 100644 .agents/skills/triage/agents/openai.yaml create mode 100644 .agents/skills/wayfinder/SKILL.md create mode 100644 .agents/skills/wayfinder/agents/openai.yaml create mode 100644 .agents/skills/writing-great-skills/GLOSSARY.md create mode 100644 .agents/skills/writing-great-skills/SKILL.md create mode 100644 .agents/skills/writing-great-skills/agents/openai.yaml create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md create mode 100644 skills-lock.json diff --git a/.agents/skills/ask-matt/SKILL.md b/.agents/skills/ask-matt/SKILL.md new file mode 100644 index 0000000..70b807b --- /dev/null +++ b/.agents/skills/ask-matt/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ask-matt +description: Ask which skill or flow fits your situation. A router over the skills in this repo. +disable-model-invocation: true +--- + +# Ask Matt + +You don't remember every skill, so ask. + +A **flow** is a path through the skills. Most paths run along one **main flow**, and two **on-ramps** merge onto it. Everything else is standalone, or a vocabulary layer that runs underneath. + +## The main flow: idea → ship + +The route most work travels. You have an idea and want it built. + +1. **`/grill-with-docs`** — sharpen the idea by interview. Start here when you **have a codebase**: it's stateful, retaining what it learns in `CONTEXT.md` and ADRs. (No codebase? Use `/grill-me` — see Standalone. Both run the same `/grilling` primitive; `grill-with-docs` is the one that leaves a paper trail.) +2. **Branch — can you settle every question in conversation?** If a question needs a runnable answer (state, business logic, a UI you have to see), detour through a prototype, bridged by **`/handoff`** in both directions (see Crossing sessions): + - **`/handoff`** out, then open a fresh session against that file, + - **`/prototype`** to answer the question with throwaway code, + - **`/handoff`** back what you learned, and reference it from the original idea thread. +3. **Branch — is this a multi-session build?** + - **Yes** → **`/to-spec`** (turn the thread into a spec), then **`/to-tickets`** to split it into tracer-bullet tickets, each declaring its **blocking edges**. On a local tracker that's one file per ticket under `.scratch//issues/`, worked blockers-first by hand; on a real tracker the edges become native blocking links, so any ticket whose blockers are done can be grabbed — kick off **`/implement`** per ticket, **clearing context between each one**. + - **No** → **`/implement`** right here, in the same context window. + + Either way, **`/implement`** builds each issue by driving **`/tdd`** internally — one red-green slice at a time — then closes out by running **`/code-review`**, a two-axis review (Standards + Spec) of the diff, before committing. Reach for **`/tdd`** on its own when you just want to build a concrete behaviour test-first without a full spec, and **`/code-review`** on its own whenever you want to review a branch or PR against a fixed point. + +### Context hygiene + +Keep steps 1–3 in **one unbroken context window** — don't compact or clear until after `/to-tickets` — so the grilling, spec, and tickets all build on the same thinking. Each `/implement` then starts fresh, working from the ticket. + +The limit on this is the **[smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone)**: the window (~120k tokens on state-of-the-art models) within which the model still reasons sharply. If a session approaches it before `/to-tickets`, don't push on degraded — `/handoff` and continue in a fresh thread. + +## On-ramps + +A starting situation that generates work, then merges onto the main flow. + +- **Bugs and requests piling up** → **`/triage`**. It moves issues through triage roles and produces agent-ready issues, which **`/implement`** later picks up. + + Triage is only for issues **you didn't create** — bug reports, incoming feature requests, anything that arrives raw. Tickets that `/to-tickets` produced are already agent-ready, so **don't triage them**. + +- **Something's broken** → **`/diagnosing-bugs`**. For the hard ones: the bug that resists a first glance, the intermittent flake, the regression that crept in between two known-good states. It refuses to theorise until it has a **tight feedback loop** — one command that already goes red on *this* bug — then fixes with a regression test. Its post-mortem hands off to **`/improve-codebase-architecture`** when the real finding is that there's no good seam to lock the bug down. + +- **A huge, foggy effort — a greenfield project or a huge feature build, too big for one session** → **`/wayfinder`**, the most cognitively demanding flow here. When the way from here to the destination isn't visible yet, it charts a **shared map** of **decision tickets** on the issue tracker and resolves them one at a time — producing **decisions, not deliverables** — until the fog is pushed back and the way is clear. Where **`/grill-with-docs`** sharpens an idea you can hold in one session, wayfinder is for the idea you can't — and it's slower and denser, so save it for exactly that, never a well-scoped feature. + + When the map clears, **it hands off, it doesn't build**: merge onto the main flow at **`/to-spec`**, which collapses the map's linked decisions into a buildable plan, then `/to-tickets` and `/implement` as usual. Looping the map straight into `/implement` skips that collapse and throws the linked detail away — go straight to `/implement` only when the effort turned out genuinely small. + +## Codebase health + +Not feature work — upkeep. + +- **`/improve-codebase-architecture`** — run whenever you have a spare moment to keep the codebase good for agents to operate in. It surfaces **deepening opportunities**; picking one _generates an idea_ you can take into the main flow at `/grill-with-docs`. It's the survey that finds the candidates; **`/codebase-design`** (below) is the bench you design the chosen one on. + +## Vocabulary underneath + +Two model-invoked references that run *beneath* the other skills — each the single source of truth for its vocabulary. Reach for them directly when the **words**, not the process, are the problem; or let the skills above pull them in. + +- **`/domain-modeling`** — sharpen the project's *domain* language: challenge a fuzzy term, resolve an overloaded word ("account" doing three jobs), record a hard-to-reverse decision as an ADR. It's the active discipline `/grill-with-docs` drives to keep `CONTEXT.md` a clean glossary. +- **`/codebase-design`** — the deep-module vocabulary (module, interface, depth, seam, adapter, leverage, locality) for designing a module's *shape*: a lot of behaviour behind a small interface at a clean seam. `/tdd` and `/improve-codebase-architecture` both speak it. + +## Crossing sessions + +- **`/handoff`** — when a thread is full or you need to branch off (e.g. into a `/prototype` session), this compacts the conversation into a markdown file. You don't continue in place — you **open a new session and reference that file** to carry the context across. It's the bridge between context windows, in either direction. Use it when you want a **fresh session** but need the **current conversation preserved**. +- **`/compact`** (built-in) — stay in the **same conversation**, letting the earlier turns be summarized. Use it at **intentional breaks between phases**, when you don't mind losing the verbatim history. Don't compact mid-phase — the agent can lose its way. `/handoff` forks; `/compact` continues. + +## Standalone + +Off the main flow entirely. + +- **`/grill-me`** — the same relentless interview as `/grill-with-docs`, but for when you have **no codebase**. Stateless: it saves nothing locally, builds no `CONTEXT.md`. Reach for it to sharpen any plan or design that doesn't live in a repo. +- **`/prototype`** — a small, throwaway program that answers one design question: does this state model feel right, or what should this UI look like. Throwaway from day one — keep the answer, delete the code. It's the detour in step 2 of the main flow, but reach for it any time a design question is hard to settle on paper. +- **`/research`** — delegate reading legwork to a **background agent**: it investigates a question against **primary sources**, then leaves a cited Markdown file in the repo. Keep working while it reads. The file it produces is something to take *into* the main flow at `/grill-with-docs` — research feeds the thinking, it doesn't replace it. +- **`/teach`** — learn a concept over multiple sessions, using the current directory as a stateful workspace. +- **`/writing-great-skills`** — reference for writing and editing skills well. + +## Precondition + +**`/setup-matt-pocock-skills`** — run before your first engineering flow to configure the issue tracker, triage labels, and doc layout the other skills assume. Custom issue trackers also work. diff --git a/.agents/skills/ask-matt/agents/openai.yaml b/.agents/skills/ask-matt/agents/openai.yaml new file mode 100644 index 0000000..5c60d51 --- /dev/null +++ b/.agents/skills/ask-matt/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Ask Matt" + short_description: "Find the right skill or workflow" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000..2a0b524 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,89 @@ +--- +name: code-review +description: Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/PRD asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X". +--- + +Two-axis review of the diff between `HEAD` and a fixed point the user supplies: + +- **Standards** — does the code conform to this repo's documented coding standards? +- **Spec** — does the code faithfully implement the originating issue / PRD / spec? + +Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. + +The issue tracker should have been provided to you — run `/setup-matt-pocock-skills` if `docs/agents/issue-tracker.md` is missing. + +## Process + +### 1. Pin the fixed point + +Whatever the user said is the fixed point — a commit SHA, branch name, tag, `main`, `HEAD~5`, etc. If they didn't specify one, ask for it. + +Capture the diff command once: `git diff ...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log ..HEAD --oneline`. + +Before going further, confirm the fixed point resolves (`git rev-parse `) and the diff is non-empty. A bad ref or empty diff should fail here — not inside two parallel sub-agents. + +### 2. Identify the spec source + +Look for the originating spec, in this order: + +1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.) — fetch via the workflow in `docs/agents/issue-tracker.md`. +2. A path the user passed as an argument. +3. A PRD/spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature. +4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available". + +### 3. Identify the standards sources + +Anything in the repo that documents how code should be written, such as `CODING_STANDARDS.md` or `CONTRIBUTING.md`. + +On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below — a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it: + +- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell. +- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation — and, like any standard here, skip anything tooling already enforces. + +Each smell reads *what it is* → *how to fix*; match it against the diff: + +- **Mysterious Name** — a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky. +- **Duplicated Code** — the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both. +- **Feature Envy** — a method that reaches into another object's data more than its own. → move the method onto the data it envies. +- **Data Clumps** — the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that. +- **Primitive Obsession** — a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type. +- **Repeated Switches** — the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share. +- **Shotgun Surgery** — one logical change forces scattered edits across many files in the diff. → gather what changes together into one module. +- **Divergent Change** — one file or module is edited for several unrelated reasons. → split so each module changes for one reason. +- **Speculative Generality** — abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows. +- **Message Chains** — long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object. +- **Middle Man** — a class or function that mostly just delegates onward. → cut it, call the real target direct. +- **Refused Bequest** — a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition. + +### 4. Spawn both sub-agents in parallel + +Send a single message with two `Agent` tool calls. Use the `general-purpose` subagent for both. + +**Standards sub-agent prompt** — include: + +- The full diff command and commit list. +- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full — the sub-agent has no other access to it. +- The brief: "Report — per file/hunk where relevant — (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls — documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words." + +**Spec sub-agent prompt** — include: + +- The diff command and commit list. +- The path or fetched contents of the spec. +- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words." + +If the spec is missing, skip the Spec sub-agent and note this in the final report. + +### 5. Aggregate + +Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings — the two axes are deliberately separate (see _Why two axes_). + +End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes — that's the reranking the separation exists to prevent. + +## Why two axes + +A change can pass one axis and fail the other: + +- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.** +- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.** + +Reporting them separately stops one axis from masking the other. diff --git a/.agents/skills/code-review/agents/openai.yaml b/.agents/skills/code-review/agents/openai.yaml new file mode 100644 index 0000000..9076774 --- /dev/null +++ b/.agents/skills/code-review/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Code Review" + short_description: "Review a diff on standards and spec" diff --git a/.agents/skills/codebase-design/DEEPENING.md b/.agents/skills/codebase-design/DEEPENING.md new file mode 100644 index 0000000..3938457 --- /dev/null +++ b/.agents/skills/codebase-design/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [SKILL.md](SKILL.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.agents/skills/codebase-design/DESIGN-IT-TWICE.md b/.agents/skills/codebase-design/DESIGN-IT-TWICE.md new file mode 100644 index 0000000..49a7c42 --- /dev/null +++ b/.agents/skills/codebase-design/DESIGN-IT-TWICE.md @@ -0,0 +1,44 @@ +# Design It Twice + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [SKILL.md](SKILL.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [SKILL.md](SKILL.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.agents/skills/codebase-design/SKILL.md b/.agents/skills/codebase-design/SKILL.md new file mode 100644 index 0000000..16620c2 --- /dev/null +++ b/.agents/skills/codebase-design/SKILL.md @@ -0,0 +1,114 @@ +--- +name: codebase-design +description: Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary. +--- + +# Codebase Design + +Design **deep modules**: a lot of behaviour behind a small interface, placed at a clean seam, testable through that interface. Use this language and these principles wherever code is being designed or restructured. The aim is leverage for callers, locality for maintainers, and testability for everyone. + +## Glossary + +Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +**Module** — anything with an interface and an implementation. Deliberately scale-agnostic: a function, class, package, or tier-spanning slice. _Avoid_: unit, component, service. + +**Interface** — everything a caller must know to use the module correctly: the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. _Avoid_: API, signature (too narrow — they refer only to the type-level surface). + +**Implementation** — what's inside a module, its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** — leverage at the interface: the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface, **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(Michael Feathers)_ — a place where you can alter behaviour without editing in that place; the *location* at which a module's interface lives. Where to put the seam is its own design decision, distinct from what goes behind it. _Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** — a concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage** — what callers get from depth: more capability per unit of interface they learn. One implementation pays back across N call sites and M tests. + +**Locality** — what maintainers get from depth: change, bugs, knowledge, and verification concentrate in one place rather than spreading across callers. Fix once, fixed everywhere. + +## Deep vs shallow + +**Deep module** = small interface + lots of implementation: + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Small Interface │ ← Few methods, simple params +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ │ +│ Deep Implementation│ ← Complex logic hidden +│ │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +**Shallow module** = large interface + little implementation (avoid): + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Large Interface │ ← Many methods, complex params +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ Thin Implementation │ ← Just passes through +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +When designing an interface, ask: + +- Can I reduce the number of methods? +- Can I simplify the parameters? +- Can I hide more complexity inside? + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Designing for testability + +Good interfaces make testing natural: + +1. **Accept dependencies, don't create them.** + + ```typescript + // Testable + function processOrder(order, paymentGateway) {} + + // Hard to test + function processOrder(order) { + const gateway = new StripeGateway(); + } + ``` + +2. **Return results, don't produce side effects.** + + ```typescript + // Testable + function calculateDiscount(cart): Discount {} + + // Hard to test + function applyDiscount(cart): void { + cart.total -= discount; + } + ``` + +3. **Small surface area.** Fewer methods = fewer tests needed. Fewer params = simpler test setup. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. + +## Going deeper + +- **Deepening a cluster given its dependencies** — see [DEEPENING.md](DEEPENING.md): dependency categories, seam discipline, and replace-don't-layer testing. +- **Exploring alternative interfaces** — see [DESIGN-IT-TWICE.md](DESIGN-IT-TWICE.md): spin up parallel sub-agents to design the interface several radically different ways, then compare on depth, locality, and seam placement. diff --git a/.agents/skills/codebase-design/agents/openai.yaml b/.agents/skills/codebase-design/agents/openai.yaml new file mode 100644 index 0000000..3180715 --- /dev/null +++ b/.agents/skills/codebase-design/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Codebase Design" + short_description: "Vocabulary for deep-module design" diff --git a/.agents/skills/design-an-interface/SKILL.md b/.agents/skills/design-an-interface/SKILL.md new file mode 100644 index 0000000..d056bd1 --- /dev/null +++ b/.agents/skills/design-an-interface/SKILL.md @@ -0,0 +1,94 @@ +--- +name: design-an-interface +description: Generate multiple radically different interface designs for a module using parallel sub-agents. Use when user wants to design an API, explore interface options, compare module shapes, or mentions "design it twice". +--- + +# Design an Interface + +Based on "Design It Twice" from "A Philosophy of Software Design": your first idea is unlikely to be the best. Generate multiple radically different designs, then compare. + +## Workflow + +### 1. Gather Requirements + +Before designing, understand: + +- [ ] What problem does this module solve? +- [ ] Who are the callers? (other modules, external users, tests) +- [ ] What are the key operations? +- [ ] Any constraints? (performance, compatibility, existing patterns) +- [ ] What should be hidden inside vs exposed? + +Ask: "What does this module need to do? Who will use it?" + +### 2. Generate Designs (Parallel Sub-Agents) + +Spawn 3+ sub-agents simultaneously using Task tool. Each must produce a **radically different** approach. + +``` +Prompt template for each sub-agent: + +Design an interface for: [module description] + +Requirements: [gathered requirements] + +Constraints for this design: [assign a different constraint to each agent] +- Agent 1: "Minimize method count - aim for 1-3 methods max" +- Agent 2: "Maximize flexibility - support many use cases" +- Agent 3: "Optimize for the most common case" +- Agent 4: "Take inspiration from [specific paradigm/library]" + +Output format: +1. Interface signature (types/methods) +2. Usage example (how caller uses it) +3. What this design hides internally +4. Trade-offs of this approach +``` + +### 3. Present Designs + +Show each design with: + +1. **Interface signature** - types, methods, params +2. **Usage examples** - how callers actually use it in practice +3. **What it hides** - complexity kept internal + +Present designs sequentially so user can absorb each approach before comparison. + +### 4. Compare Designs + +After showing all designs, compare them on: + +- **Interface simplicity**: fewer methods, simpler params +- **General-purpose vs specialized**: flexibility vs focus +- **Implementation efficiency**: does shape allow efficient internals? +- **Depth**: small interface hiding significant complexity (good) vs large interface with thin implementation (bad) +- **Ease of correct use** vs **ease of misuse** + +Discuss trade-offs in prose, not tables. Highlight where designs diverge most. + +### 5. Synthesize + +Often the best design combines insights from multiple options. Ask: + +- "Which design best fits your primary use case?" +- "Any elements from other designs worth incorporating?" + +## Evaluation Criteria + +From "A Philosophy of Software Design": + +**Interface simplicity**: Fewer methods, simpler params = easier to learn and use correctly. + +**General-purpose**: Can handle future use cases without changes. But beware over-generalization. + +**Implementation efficiency**: Does interface shape allow efficient implementation? Or force awkward internals? + +**Depth**: Small interface hiding significant complexity = deep module (good). Large interface with thin implementation = shallow module (avoid). + +## Anti-Patterns + +- Don't let sub-agents produce similar designs - enforce radical difference +- Don't skip comparison - the value is in contrast +- Don't implement - this is purely about interface shape +- Don't evaluate based on implementation effort diff --git a/.agents/skills/design-an-interface/agents/openai.yaml b/.agents/skills/design-an-interface/agents/openai.yaml new file mode 100644 index 0000000..c86df18 --- /dev/null +++ b/.agents/skills/design-an-interface/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Design an Interface" + short_description: "Explore alternative module interfaces" diff --git a/.agents/skills/diagnosing-bugs/SKILL.md b/.agents/skills/diagnosing-bugs/SKILL.md new file mode 100644 index 0000000..f400de7 --- /dev/null +++ b/.agents/skills/diagnosing-bugs/SKILL.md @@ -0,0 +1,134 @@ +--- +name: diagnosing-bugs +description: Diagnosis loop for hard bugs and performance regressions. Use when the user says "diagnose"/"debug this", or reports something broken/throwing/failing/slow. +--- + +# Diagnosing Bugs + +A discipline for hard bugs. Skip phases only when explicitly justified. + +When exploring the codebase, read `CONTEXT.md` (if it exists) to get a clear mental model of the relevant modules, and check ADRs in the area you're touching. + +## Phase 1 — Build a feedback loop + +**This is the skill.** Everything else is mechanical. If you have a **tight** pass/fail signal for the bug — one that goes red on _this_ bug — you will find the cause; bisection, hypothesis-testing, and instrumentation all just consume it. If you don't have one, no amount of staring at code will save you. + +Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** + +### Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +Build the right feedback loop, and the bug is 90% fixed. + +### Tighten the loop + +Treat the loop as a product. Once you have _a_ loop, **tighten** it: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop; a 2-second deterministic one is tight — a debugging superpower. + +### Non-deterministic bugs + +The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100Ɨ, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +### When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. + +### Completion criterion — a tight loop that goes red + +Phase 1 is done when the loop is **tight** and **red-capable**: you can name **one command** — a script path, a test invocation, a curl — that you have **already run at least once** (paste the invocation and its output), and that is: + +- [ ] **Red-capable** — it drives the actual bug code path and asserts the **user's exact symptom**, so it can go red on this bug and green once fixed. Not "runs without erroring" — it must be able to _catch this specific bug_. +- [ ] **Deterministic** — same verdict every run (flaky bugs: a pinned, high reproduction rate, per above). +- [ ] **Fast** — seconds, not minutes. +- [ ] **Agent-runnable** — you can run it unattended; a human in the loop only via `scripts/hitl-loop.template.sh`. + +If you catch yourself reading code to build a theory before this command exists, **stop — jumping straight to a hypothesis is the exact failure this skill prevents.** No red-capable command, no Phase 2. + +## Phase 2 — Reproduce + minimise + +Run the loop. Watch it go red — the bug appears. + +Confirm: + +- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. +- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. + +### Minimise + +Once it's red, shrink the repro to the **smallest scenario that still goes red**. Cut inputs, callers, config, data, and steps **one at a time**, re-running the loop after each cut — keep only what's load-bearing for the failure. + +Why bother: a minimal repro shrinks the hypothesis space in Phase 3 (fewer moving parts left to suspect) and becomes the clean regression test in Phase 5. + +Done when **every remaining element is load-bearing** — removing any one of them makes the loop go green. + +Do not proceed until you have reproduced **and** minimised. + +## Phase 3 — Hypothesise + +Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea. + +Each hypothesis must be **falsifiable**: state the prediction it makes. + +> Format: "If is the cause, then will make the bug disappear / will make it worse." + +If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it. + +**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK. + +## Phase 4 — Instrument + +Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.** + +Tool preference: + +1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs. +2. **Targeted logs** at the boundaries that distinguish hypotheses. +3. Never "log everything and grep". + +**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die. + +**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second. + +## Phase 5 — Fix + regression test + +Write the regression test **before the fix** — but only if there is a **correct seam** for it. + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. + +**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. + +If a correct seam exists: + +1. Turn the minimised repro into a failing test at that seam. +2. Watch it fail. +3. Apply the fix. +4. Watch it pass. +5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. + +## Phase 6 — Cleanup + post-mortem + +Required before declaring done: + +- [ ] Original repro no longer reproduces (re-run the Phase 1 loop) +- [ ] Regression test passes (or absence of seam is documented) +- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix) +- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location) +- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns + +**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started. diff --git a/.agents/skills/diagnosing-bugs/agents/openai.yaml b/.agents/skills/diagnosing-bugs/agents/openai.yaml new file mode 100644 index 0000000..a13a755 --- /dev/null +++ b/.agents/skills/diagnosing-bugs/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Diagnosing Bugs" + short_description: "Diagnose hard bugs and regressions" diff --git a/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh b/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh new file mode 100644 index 0000000..40afc46 --- /dev/null +++ b/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Human-in-the-loop reproduction loop. +# Copy this file, edit the steps below, and run it. +# The agent runs the script; the user follows prompts in their terminal. +# +# Usage: +# bash hitl-loop.template.sh +# +# Two helpers: +# step "" → show instruction, wait for Enter +# capture VAR "" → show question, read response into VAR +# +# At the end, captured values are printed as KEY=VALUE for the agent to parse. + +set -euo pipefail + +step() { + printf '\n>>> %s\n' "$1" + read -r -p " [Enter when done] " _ +} + +capture() { + local var="$1" question="$2" answer + printf '\n>>> %s\n' "$question" + read -r -p " > " answer + printf -v "$var" '%s' "$answer" +} + +# --- edit below --------------------------------------------------------- + +step "Open the app at http://localhost:3000 and sign in." + +capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)" + +capture ERROR_MSG "Paste the error message (or 'none'):" + +# --- edit above --------------------------------------------------------- + +printf '\n--- Captured ---\n' +printf 'ERRORED=%s\n' "$ERRORED" +printf 'ERROR_MSG=%s\n' "$ERROR_MSG" diff --git a/.agents/skills/domain-modeling/ADR-FORMAT.md b/.agents/skills/domain-modeling/ADR-FORMAT.md new file mode 100644 index 0000000..da7e78e --- /dev/null +++ b/.agents/skills/domain-modeling/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily — only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited +- **Considered Options** — only when the rejected alternatives are worth remembering +- **Consequences** — only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months. diff --git a/.agents/skills/domain-modeling/CONTEXT-FORMAT.md b/.agents/skills/domain-modeling/CONTEXT-FORMAT.md new file mode 100644 index 0000000..eaf2a18 --- /dev/null +++ b/.agents/skills/domain-modeling/CONTEXT-FORMAT.md @@ -0,0 +1,60 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A one or two sentence description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`. +- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/.agents/skills/domain-modeling/SKILL.md b/.agents/skills/domain-modeling/SKILL.md new file mode 100644 index 0000000..d0f7e1a --- /dev/null +++ b/.agents/skills/domain-modeling/SKILL.md @@ -0,0 +1,74 @@ +--- +name: domain-modeling +description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model. +--- + +# Domain Modeling + +Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.) + +## File structure + +Most repos have a single context: + +``` +/ +ā”œā”€ā”€ CONTEXT.md +ā”œā”€ā”€ docs/ +│ └── adr/ +│ ā”œā”€ā”€ 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +ā”œā”€ā”€ CONTEXT-MAP.md +ā”œā”€ā”€ docs/ +│ └── adr/ ← system-wide decisions +ā”œā”€ā”€ src/ +│ ā”œā”€ā”€ ordering/ +│ │ ā”œā”€ā”€ CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ā”œā”€ā”€ CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). diff --git a/.agents/skills/domain-modeling/agents/openai.yaml b/.agents/skills/domain-modeling/agents/openai.yaml new file mode 100644 index 0000000..7f1522d --- /dev/null +++ b/.agents/skills/domain-modeling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Domain Modeling" + short_description: "Build and sharpen a domain model" diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 0000000..9470cfc --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -0,0 +1,7 @@ +--- +name: grill-me +description: A relentless interview to sharpen a plan or design. +disable-model-invocation: true +--- + +Run a `/grilling` session. diff --git a/.agents/skills/grill-me/agents/openai.yaml b/.agents/skills/grill-me/agents/openai.yaml new file mode 100644 index 0000000..4d6fb0c --- /dev/null +++ b/.agents/skills/grill-me/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill Me" + short_description: "Sharpen a plan through interview" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 0000000..bed05d2 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -0,0 +1,7 @@ +--- +name: grill-with-docs +description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go. +disable-model-invocation: true +--- + +Run a `/grilling` session, using the `/domain-modeling` skill. diff --git a/.agents/skills/grill-with-docs/agents/openai.yaml b/.agents/skills/grill-with-docs/agents/openai.yaml new file mode 100644 index 0000000..5dbe278 --- /dev/null +++ b/.agents/skills/grill-with-docs/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill with Docs" + short_description: "Grill a design and write its docs" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grilling/SKILL.md b/.agents/skills/grilling/SKILL.md new file mode 100644 index 0000000..52d8eb3 --- /dev/null +++ b/.agents/skills/grilling/SKILL.md @@ -0,0 +1,12 @@ +--- +name: grilling +description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. +--- + +Interview me relentlessly about every aspect of this until we reach a shared understanding. Walk down each branch of the decision tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering. + +If a *fact* can be found by exploring the environment (filesystem, tools, etc.), look it up rather than asking me. The *decisions*, though, are mine — put each one to me and wait for my answer. + +Do not act on it until I confirm we have reached a shared understanding. diff --git a/.agents/skills/grilling/agents/openai.yaml b/.agents/skills/grilling/agents/openai.yaml new file mode 100644 index 0000000..85b1260 --- /dev/null +++ b/.agents/skills/grilling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Grilling" + short_description: "Stress-test thinking one question at a time" diff --git a/.agents/skills/handoff/SKILL.md b/.agents/skills/handoff/SKILL.md new file mode 100644 index 0000000..043d9e1 --- /dev/null +++ b/.agents/skills/handoff/SKILL.md @@ -0,0 +1,16 @@ +--- +name: handoff +description: Compact the current conversation into a handoff document for another agent to pick up. +argument-hint: "What will the next session be used for?" +disable-model-invocation: true +--- + +Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace. + +Include a "suggested skills" section in the document, which suggests skills that the agent should invoke. + +Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead. + +Redact any sensitive information, such as API keys, passwords, or personally identifiable information. + +If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly. diff --git a/.agents/skills/handoff/agents/openai.yaml b/.agents/skills/handoff/agents/openai.yaml new file mode 100644 index 0000000..6e1d8da --- /dev/null +++ b/.agents/skills/handoff/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Handoff" + short_description: "Compact a conversation into a handoff" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/implement/SKILL.md b/.agents/skills/implement/SKILL.md new file mode 100644 index 0000000..7a0b11f --- /dev/null +++ b/.agents/skills/implement/SKILL.md @@ -0,0 +1,15 @@ +--- +name: implement +description: "Implement a piece of work based on a spec or set of tickets." +disable-model-invocation: true +--- + +Implement the work described by the user in the spec or tickets. + +Use /tdd where possible, at pre-agreed seams. + +Run typechecking regularly, single test files regularly, and the full test suite once at the end. + +Once done, use /code-review to review the work. + +Commit your work to the current branch. diff --git a/.agents/skills/implement/agents/openai.yaml b/.agents/skills/implement/agents/openai.yaml new file mode 100644 index 0000000..f8794dc --- /dev/null +++ b/.agents/skills/implement/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Implement" + short_description: "Build work from a spec or tickets" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/improve-codebase-architecture/HTML-REPORT.md b/.agents/skills/improve-codebase-architecture/HTML-REPORT.md new file mode 100644 index 0000000..17f6d2c --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/HTML-REPORT.md @@ -0,0 +1,123 @@ +# HTML Report Format + +The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two — don't lean on Mermaid for everything, it'll start to look generic. + +## Scaffold + +```html + + + + + Architecture review — {{repo name}} + + + + + +
+
...
+
...
+
...
+
+ + +``` + +## Header + +Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates. + +## Candidate card + +The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony. + +Each candidate is one `
`: + +- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline"). +- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`). +- **Files** — monospaced list, `font-mono text-sm`. +- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below. +- **Problem** — one sentence. What hurts. +- **Solution** — one sentence. What changes. +- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers". +- **ADR callout** (if applicable) — one line in an amber-tinted box. + +No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram. + +## Diagram patterns + +Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point. + +### Mermaid graph (the workhorse for dependencies / call flow) + +Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1." + +```html +
+
+    flowchart LR
+      A[OrderHandler] --> B[OrderValidator]
+      B --> C[OrderRepo]
+      C -.leak.-> D[PricingClient]
+      classDef leak stroke:#dc2626,stroke-width:2px;
+      class C,D leak
+  
+
+``` + +### Hand-built boxes-and-arrows (when Mermaid's layout fights you) + +Modules as `
`s with borders and labels. Arrows as inline SVG `` or `` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight. + +### Cross-section (good for layered shallowness) + +Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility. + +### Mass diagram (good for "interface as wide as implementation") + +Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep). + +### Call-graph collapse + +Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it. + +## Style guidance + +- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate). +- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings. +- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling. +- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI. +- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering. + +## Top recommendation section + +One larger card. Candidate name, one sentence on why, anchor link to its card. That's it. + +## Tone + +Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift. + +**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality. + +**Never substitute:** component, service, unit (for module) Ā· API, signature (for interface) Ā· boundary (for seam) Ā· layer, wrapper (for module, when you mean module). + +**Phrasings that fit the style:** + +- "Order intake module is shallow — interface nearly matches the implementation." +- "Pricing leaks across the seam." +- "Deepen: one interface, one place to test." +- "Two adapters justify the seam: HTTP in prod, in-memory in tests." + +**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place. + +No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one. diff --git a/.agents/skills/improve-codebase-architecture/SKILL.md b/.agents/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 0000000..b56969e --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick. +disable-model-invocation: true +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +This command is _informed_ by the project's domain model and built on a shared design vocabulary: + +- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary." +- The domain language in `CONTEXT.md` gives names to good seams; ADRs in `docs/adr/` record decisions this command should not re-litigate. + +## Process + +### 1. Explore + +**Scope before you scan — YAGNI.** Deepening a module pays off by making future changes to it easier, so put extra weight on the parts of the codebase that have recently changed. Decide *where* to look before you look: + +- If the user named a direction — a module, a subsystem, a pain point — take it, and skip the inference below. +- Otherwise, walk back a good stretch of the commit history (`git log --oneline`) to find the codebase's hot spots — the files and areas that keep coming up — and let those paths pull your attention first. If the changes are scattered with no clear hot spot, widen the net. + +Read the project's domain glossary (`CONTEXT.md`) and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates as an HTML report + +Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `/architecture-review-.html` so each run gets a fresh file. Open it for the user — `xdg-open ` on Linux, `open ` on macOS, `start ` on Windows — and tell them the absolute path. + +The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual. + +For each candidate, render a card with: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and how tests would improve +- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening +- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge + +End the report with a **Top recommendation** section: which candidate you'd tackle first and why. + +**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance. + +Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, run the `/grilling` skill to walk the decision tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize — run the `/domain-modeling` skill to keep the domain model current as you go: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. +- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern. diff --git a/.agents/skills/improve-codebase-architecture/agents/openai.yaml b/.agents/skills/improve-codebase-architecture/agents/openai.yaml new file mode 100644 index 0000000..706fdca --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Improve Codebase Architecture" + short_description: "Find and grill architecture improvements" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/prototype/LOGIC.md b/.agents/skills/prototype/LOGIC.md new file mode 100644 index 0000000..fe9a2c2 --- /dev/null +++ b/.agents/skills/prototype/LOGIC.md @@ -0,0 +1,79 @@ +# Logic Prototype + +A tiny interactive terminal app that lets the user drive a state model by hand. Use this when the question is about **business logic, state transitions, or data shape** — the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases. + +## When this is the right shape + +- "I'm not sure if this state machine handles the edge case where X then Y." +- "Does this data model actually let me represent the case where..." +- "I want to feel out what the API should look like before writing it." +- Anything where the user wants to **press buttons and watch state change**. + +If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). + +## Process + +### 1. State the question + +Before writing code, write down what state model and what question you're prototyping. One paragraph, in the prototype's README or a comment at the top of the file. A logic prototype that answers the wrong question is pure waste — make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK. + +### 2. Pick the language + +Use whatever the host project uses. If the project has no obvious runtime (e.g. a docs repo), ask. + +Match the project's existing conventions for tooling — don't add a new package manager or runtime just for the prototype. + +### 3. Isolate the logic in a portable module + +Put the actual logic — the bit that's answering the question — behind a small, pure interface that could be lifted out and dropped into the real codebase later. The TUI around it is throwaway; the logic module shouldn't be. + +The right shape depends on the question: + +- **A pure reducer** — `(state, action) => state`. Good when actions are discrete events and state is a single value. +- **A state machine** — explicit states and transitions. Good when "which actions are even legal right now" is part of the question. +- **A small set of pure functions** over a plain data type. Good when there's no implicit current state — just transformations. +- **A class or module with a clear method surface** when the logic genuinely owns ongoing internal state. + +Pick whichever shape best fits the question being asked, *not* whichever is easiest to wire to a TUI. Keep it pure: no I/O, no terminal code, no `console.log` for control flow. The TUI imports it and calls into it; nothing flows the other direction. + +This is what makes the prototype useful past its own lifetime: when the question's been answered, the validated reducer / machine / function set can be lifted into the real module on its own. + +### 4. Build the smallest TUI that exposes the state + +Build it as a **lightweight TUI** — on every tick, clear the screen (`console.clear()` / `print("\033[2J\033[H")` / equivalent) and re-render the whole frame. The user should always see one stable view, not an ever-growing scrollback. + +Each frame has two parts, in this order: + +1. **Current state**, pretty-printed and diff-friendly (one field per line, or formatted JSON). Use **bold** for field names or section headers and **dim** for less important context (timestamps, IDs, derived values). Native ANSI escape codes are fine — `\x1b[1m` bold, `\x1b[2m` dim, `\x1b[0m` reset. No need to pull in a styling library unless one is already in the project. +2. **Keyboard shortcuts**, listed at the bottom: `[a] add user [d] delete user [t] tick clock [q] quit`. Bold the key, dim the description, or vice-versa — whatever reads cleanly. + +Behaviour: + +1. **Initialise state** — a single in-memory object/struct. Render the first frame on start. +2. **Read one keystroke (or one line)** at a time, dispatch to a handler that mutates state. +3. **Re-render** the full frame after every action — don't append, replace. +4. **Loop until quit.** + +The whole frame should fit on one screen. + +### 5. Make it runnable in one command + +Add a script to the project's existing task runner (`package.json` scripts, `Makefile`, `justfile`, `pyproject.toml`). The user should run `pnpm run ` or equivalent — never need to remember a path. + +If the host project has no task runner, just put the command at the top of the prototype's README. + +### 6. Hand it over + +Give the user the run command. They'll drive it themselves; the interesting moments are when they say "wait, that shouldn't be possible" or "huh, I assumed X would be different" — those are the bugs in the _idea_, which is the whole point. If they want new actions added, add them. Prototypes evolve. + +### 7. Capture the answer and the prototype + +Once the prototype has answered its question, capture the answer, then capture the prototype the way the [SKILL](SKILL.md) describes. The logic-specific mapping: the validated reducer / machine / function set lifts into the real module (the decision, absorbed); the TUI shell rides along to the throwaway branch that keeps the prototype as a primary source. + +## Anti-patterns + +- **Don't add tests.** A prototype that needs tests is no longer a prototype. +- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence. +- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question. +- **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module. +- **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping. diff --git a/.agents/skills/prototype/SKILL.md b/.agents/skills/prototype/SKILL.md new file mode 100644 index 0000000..e75d533 --- /dev/null +++ b/.agents/skills/prototype/SKILL.md @@ -0,0 +1,26 @@ +--- +name: prototype +description: Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like. +--- + +# Prototype + +A prototype is **throwaway code that answers a question**. The question decides the shape. + +## Pick a branch + +Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around: + +- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper. +- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar. + +The two branches produce very different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype. + +## Rules that apply to both + +1. **Throwaway from day one, and clearly marked as such.** Locate the prototype code close to where it will actually be used (next to the module or page it's prototyping for) so context is obvious — but name it so a casual reader can see it's a prototype, not production. For throwaway UI routes, obey whatever routing convention the project already uses; don't invent a new top-level structure. +2. **One command to run.** Whatever the project's existing task runner supports — `pnpm `, `python `, `bun `, etc. The user must be able to start it without thinking. +3. **No persistence by default.** State lives in memory. Persistence is the thing the prototype is _checking_, not something it should depend on. If the question explicitly involves a database, hit a scratch DB or a local file with a clear "PROTOTYPE — wipe me" name. +4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast. +5. **Surface the state.** After every action (logic) or on every variant switch (UI), print or render the full relevant state so the user can see what changed. +6. **Capture it when done.** Fold any validated decision into the real code, then capture the prototype itself as a **primary source**: commit it to a throwaway branch, out of main, and leave a context pointer to that branch on the implementation issue. Capture the answer too — the verdict and the question it settled — in the issue or a commit. The main branch keeps only the validated decision. diff --git a/.agents/skills/prototype/UI.md b/.agents/skills/prototype/UI.md new file mode 100644 index 0000000..76c0f60 --- /dev/null +++ b/.agents/skills/prototype/UI.md @@ -0,0 +1,112 @@ +# UI Prototype + +Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away. + +If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md). + +## When this is the right shape + +- "What should this page look like?" +- "I want to see a few options for this dashboard before committing." +- "Try a different layout for the settings screen." +- Any time the user would otherwise spend a day picking between three vague mockups in their head. + +## Two sub-shapes — strongly prefer sub-shape A + +A UI prototype is much easier to judge when it's **butting up against the rest of the app** — real header, real sidebar, real data, real density. A throwaway route on its own is a vacuum: every variant looks fine in isolation. Default to sub-shape A whenever there's a plausible existing page to host the variants. Only reach for sub-shape B if the prototype genuinely has no nearby home. + +### Sub-shape A — adjustment to an existing page (preferred) + +The route already exists. Variants are rendered **on the same route**, gated by a `?variant=` URL search param. The existing data fetching, params, and auth all stay — only the rendering swaps. This is the default; pick it unless there's a specific reason not to. + +If the prototype is for something that doesn't yet have a page but *would naturally live inside one* (a new section of the dashboard, a new card on the settings screen, a new step in an existing flow) — that's still sub-shape A. Mount the variants inside the host page. + +### Sub-shape B — a new page (last resort) + +Only use this when the thing being prototyped genuinely has no existing page to live inside — e.g. an entirely new top-level surface, or a flow that can't be embedded anywhere sensible. + +Create a **throwaway route** following whatever routing convention the project already uses — don't invent a new top-level structure. Name it so it's obviously a prototype (e.g. include the word `prototype` in the path or filename). Same `?variant=` pattern. + +Before committing to sub-shape B, sanity-check: is there really no existing page this could be embedded in? An empty route hides design problems that a populated one would expose. + +In both sub-shapes the floating bottom bar is identical. + +## Process + +### 1. State the question and pick N + +Default to **3 variants**. More than 5 stops being radically different and starts being noise — cap there. + +Write down the plan in one line, in the prototype's location or a top-of-file comment: + +> "Three variants of the settings page, switchable via `?variant=`, on the existing `/settings` route." + +This works whether the user is here to push back or not. + +### 2. Generate radically different variants + +Draft each variant. Hold each one to: + +- The page's purpose and the data it has access to. +- The project's component library / styling system (TailwindCSS, shadcn, MUI, plain CSS, whatever). +- A clear exported component name, e.g. `VariantA`, `VariantB`, `VariantC`. + +Variants must be **structurally different** — different layout, different information hierarchy, different primary affordance, not just different colours. Three slightly-tweaked card grids isn't a UI prototype, it's wallpaper. If two drafts come out too similar, redo one with explicit "do not use a card grid" guidance. + +### 3. Wire them together + +Create a single switcher component on the route: + +```tsx +// pseudo-code — adapt to the project's framework +const variant = searchParams.get('variant') ?? 'A'; +return ( + <> + {variant === 'A' && } + {variant === 'B' && } + {variant === 'C' && } + + +); +``` + +For sub-shape A (existing page): keep all the existing data fetching above the switcher; only the rendered subtree changes per variant. + +For sub-shape B (new page): the throwaway route under `/prototype/` mounts the same switcher. + +### 4. Build the floating switcher + +A small fixed-position bar at the bottom-centre of the screen with three pieces: + +- **Left arrow** — cycles to the previous variant (wraps around). +- **Variant label** — shows the current variant key and, if the variant exports a name, that name too. e.g. `B — Sidebar layout`. +- **Right arrow** — cycles forward (wraps around). + +Behaviour: + +- Clicking an arrow updates the URL search param (use the framework's router — `router.replace` on Next, `navigate` on React Router, etc) so the variant is shareable and reload-stable. +- Keyboard: `←` and `→` arrow keys also cycle. Don't intercept arrow keys when an ``, `