Skip to content

Commit b9ceff2

Browse files
committed
v0.43.0: Telegram narrator upgrade — Hermes-parity tool progress
- Smart previews: render.ToolPreview() extracts meaningful context from tool call args (filename, command, URL, query, etc.) - Edit throttling: 1.5s min between edits to avoid flood control - Tool dedup: consecutive same-tool collapses into (×N) counter - Flood fallback: switches to new messages on 429 errors - Content reset: send_message mid-run resets progress bubble - tool_progress config: all|new|verbose|off (independent from interaction_mode) - tool_progress_cleanup: configurable progress message deletion - render.ToolEmoji() exported for Telegram bot use - narrate.Narrator deprecated (absorbed into telegram.go+render.go) - docs: CHANGELOG v0.43.0, TELEGRAM.md tool_progress section, CONFIG.md env var reference
1 parent d03e4fb commit b9ceff2

7 files changed

Lines changed: 318 additions & 77 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ It provides context about the project's architecture, conventions, and how to up
1212
- **Binary:** `odek` — single static binary, ~12 MB, instant startup.
1313
- **Config:** Five-layer priority: `~/.odek/secrets.env``~/.odek/config.json``./odek.json``ODEK_*` env vars → CLI flags.
1414
- **Benchmark:** AIEB v2.0 — 80.3% (highest published agent score on the Autonomous Intelligence Engineering Benchmark).
15-
- **Version:** v0.42.1 — see latest tag at https://github.com/BackendStack21/odek/releases
15+
- **Version:** v0.43.0 — see latest tag at https://github.com/BackendStack21/odek/releases
1616

1717
## Source Layout
1818

cmd/odek/telegram.go

Lines changed: 135 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"github.com/BackendStack21/kode/internal/llm"
2222
"github.com/BackendStack21/kode/internal/loop"
2323
"github.com/BackendStack21/kode/internal/render"
24-
"github.com/BackendStack21/kode/internal/narrate"
2524
"github.com/BackendStack21/kode/internal/session"
2625
"github.com/BackendStack21/kode/internal/skills"
2726
"github.com/BackendStack21/kode/internal/telegram"
@@ -1032,53 +1031,120 @@ func handleChatMessage(
10321031
}()
10331032

10341033
// ── Progress Mode Setup ───────────────────────────────────────
1035-
// Send an instant "working on it" message so the user sees feedback
1036-
// within milliseconds, not seconds.
1034+
// The tool_progress config controls Telegram progress bubbles:
1035+
// "all" — single editable bubble with smart previews, throttled edits,
1036+
// dedup counter, and flood-control fallback (recommended default)
1037+
// "new" — like "all" but only updates when the tool name changes
1038+
// "verbose" — raw tool args in per-tool messages with result replacement
1039+
// "off" — no per-tool progress (just thinking + final answer)
10371040
//
1038-
// engaging: updated with narrated tool descriptions (edits), then
1039-
// deleted when the final answer arrives.
1040-
// enhance: kept as a per-iteration header; narrated tool messages
1041-
// are appended below it as new messages (no edits).
1042-
// verbose: no progress message — uses raw tool traces instead.
1043-
var narrator *narrate.Narrator
1044-
isEngaging := resolved.InteractionMode == "engaging"
1041+
// When interaction_mode is "enhance", the old per-tool narrated-message
1042+
// behavior is used regardless of tool_progress (preserves existing UX).
1043+
toolProgress := resolved.ToolProgress
10451044
isEnhance := resolved.InteractionMode == "enhance"
1046-
if isEngaging || isEnhance {
1047-
narrator = narrate.New(true)
1045+
if isEnhance {
1046+
toolProgress = "enhance"
10481047
}
1048+
10491049
var progressMsgID int
1050-
if isEngaging || isEnhance {
1050+
var progressLines []string
1051+
var lastProgressMsg string
1052+
var repeatCount int
1053+
var canEdit = true
1054+
var lastEditTime time.Time
1055+
const editThrottle = 1500 * time.Millisecond
1056+
1057+
// Send an initial "working on it" message for all modes except "off".
1058+
if toolProgress != "off" {
10511059
msg, err := bot.SendMessage(chatID, "🤔 Looking into that...",
10521060
&telegram.SendOpts{ReplyToMessageID: messageID})
10531061
if err == nil {
10541062
progressMsgID = msg.ID
10551063
}
10561064
}
1065+
// Cleanup progress messages when the task finishes.
10571066
defer func() {
1058-
// Clean up the progress message when the task finishes.
1059-
// In engaging mode, deletes the stale "thinking" message.
1060-
// In enhance mode, preserves it as a per-iteration header
1061-
// with the narrated tool messages below it.
1062-
if progressMsgID != 0 && isEngaging {
1067+
if progressMsgID != 0 && resolved.ToolProgressCleanup {
10631068
bot.DeleteMessage(chatID, progressMsgID)
10641069
}
10651070
}()
10661071

1067-
// ── Tool Tracing ───────────────────────────────────────────────
1068-
// Individual messages per tool call so the user can follow the
1069-
// agent's journey step by step. Reasoning is shown before tool
1070-
// calls. All trace messages are deleted after the agent responds.
1071-
// toolMsgIDs tracks per-turn tool message IDs for editing on result.
1072+
// ── Tool Tracing (legacy verbose mode fallback) ──────────────
1073+
// Used when interaction_mode is "verbose" AND tool_progress is NOT set
1074+
// (or set to something other than "verbose"). These per-tool trace
1075+
// messages are preserved for the old verbose interaction mode.
10721076
var toolMsgIDs sync.Map // map[string]int
10731077

1074-
// truncate shortens a string for display, appending "…" if trimmed.
1075-
truncate := func(s string, max int) string {
1076-
if len(s) > max {
1077-
return s[:max] + "…"
1078+
// Helper: build a progress line for a tool call.
1079+
buildProgressLine := func(name, data string) string {
1080+
emoji := render.ToolEmoji(name)
1081+
preview := render.ToolPreview(name, data)
1082+
if preview != "" {
1083+
return fmt.Sprintf("%s %s: \"%s\"", emoji, name, preview)
1084+
}
1085+
return fmt.Sprintf("%s %s...", emoji, name)
1086+
}
1087+
1088+
// Helper: send or edit the progress bubble.
1089+
sendProgress := func(line string) {
1090+
if toolProgress == "off" || progressMsgID == 0 {
1091+
return
1092+
}
1093+
1094+
// "new" mode: only show a new message when the tool changes.
1095+
if toolProgress == "new" && len(progressLines) > 0 && line == lastProgressMsg {
1096+
return
1097+
}
1098+
1099+
// Dedup: collapse consecutive identical messages.
1100+
if line == lastProgressMsg {
1101+
repeatCount++
1102+
if len(progressLines) > 0 {
1103+
progressLines[len(progressLines)-1] = fmt.Sprintf("%s (×%d)", line, repeatCount+1)
1104+
}
1105+
} else {
1106+
lastProgressMsg = line
1107+
repeatCount = 0
1108+
progressLines = append(progressLines, line)
1109+
}
1110+
1111+
fullText := strings.Join(progressLines, "\n")
1112+
1113+
// Throttle edits to avoid Telegram flood control.
1114+
if time.Since(lastEditTime) < editThrottle {
1115+
return // will be flushed on next non-throttled tick
1116+
}
1117+
1118+
if canEdit {
1119+
err := bot.EditMessageText(chatID, progressMsgID, fullText, nil)
1120+
if err != nil {
1121+
errStr := err.Error()
1122+
if strings.Contains(errStr, "flood") || strings.Contains(errStr, "retry after") {
1123+
canEdit = false
1124+
// Fallback: send as new message
1125+
msg, err2 := bot.SendMessage(chatID, line,
1126+
&telegram.SendOpts{ReplyToMessageID: messageID})
1127+
if err2 == nil {
1128+
progressMsgID = msg.ID
1129+
}
1130+
}
1131+
}
1132+
lastEditTime = time.Now()
1133+
} else {
1134+
// Editing failed previously — send new messages
1135+
msg, err := bot.SendMessage(chatID, line,
1136+
&telegram.SendOpts{ReplyToMessageID: messageID})
1137+
if err == nil {
1138+
progressMsgID = msg.ID
1139+
}
10781140
}
1079-
return s
10801141
}
10811142

1143+
// Collect agent run stats via the iteration callback.
1144+
var runInfo loop.IterationInfo
1145+
var allToolsMu sync.Mutex
1146+
allTools := make(map[string]int)
1147+
10821148
// truncateWords limits text to maxWords, appending "…" if trimmed.
10831149
truncateWords := func(s string, maxWords int) string {
10841150
words := strings.Fields(s)
@@ -1088,11 +1154,6 @@ func handleChatMessage(
10881154
return strings.Join(words[:maxWords], " ") + "…"
10891155
}
10901156

1091-
// Collect agent run stats via the iteration callback.
1092-
var runInfo loop.IterationInfo
1093-
var allToolsMu sync.Mutex
1094-
allTools := make(map[string]int)
1095-
10961157
// ── Clarify Tool ───────────────────────────────────────────────
10971158
// Wire the clarify tool with a Telegram-native answer function.
10981159
// When the agent calls clarify(question), the bot sends an inline
@@ -1178,62 +1239,60 @@ func handleChatMessage(
11781239
Renderer: rend,
11791240
ToolEventHandler: func(event string, name string, data string) {
11801241
// Enhance mode: send new messages with narrated descriptions.
1181-
// Each tool call gets its own message (no edits, no cleanup).
11821242
if isEnhance {
1243+
// Content reset: interim messages reset the progress bubble.
1244+
if event == "tool_call" && name == "send_message" {
1245+
progressMsgID = 0
1246+
progressLines = nil
1247+
lastProgressMsg = ""
1248+
repeatCount = 0
1249+
return
1250+
}
11831251
switch event {
11841252
case "tool_call":
1185-
if narrator != nil {
1186-
if msg := narrator.ToolCallMessage(name, data); msg != "" {
1187-
escapedMsg := telegram.EscapeMarkdown(msg)
1188-
bot.SendMessage(chatID, escapedMsg,
1189-
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
1190-
}
1191-
}
1192-
case "tool_result":
1193-
// silent in enhance mode -- the narrated tool_call
1194-
// message already describes what's happening.
1195-
}
1196-
return
1197-
}
1198-
1199-
// Engaging mode: update the progress message with narrated tool
1200-
// descriptions instead of raw traces.
1201-
if isEngaging {
1202-
if progressMsgID != 0 && event == "tool_call" && narrator != nil {
1203-
if msg := narrator.ToolCallMessage(name, data); msg != "" {
1204-
bot.EditMessageText(chatID, progressMsgID, msg, nil)
1205-
}
1253+
line := buildProgressLine(name, data)
1254+
bot.SendMessage(chatID, telegram.EscapeMarkdown(line),
1255+
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
12061256
}
12071257
return
12081258
}
12091259

1210-
// Verbose mode: individual messages per tool call so the user
1211-
// can follow the agent's journey step by step.
1260+
// Progress modes: "all", "new", "verbose", "off".
12121261
switch event {
12131262
case "tool_call":
1214-
args := truncate(data, 150)
1215-
line := fmt.Sprintf("%s `%s` %s", render.ToolEmoji(name), name, args)
1216-
line = telegram.EscapeMarkdown(line)
1217-
if msg, err := bot.SendMessage(chatID, line, &telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2}); err == nil {
1218-
toolMsgIDs.Store(name, msg.ID)
1263+
// Content reset: if send_message fires mid-run, reset the
1264+
// progress bubble so it appears below the sent message.
1265+
if name == "send_message" {
1266+
progressMsgID = 0
1267+
progressLines = nil
1268+
lastProgressMsg = ""
1269+
repeatCount = 0
1270+
return
12191271
}
12201272

1221-
case "tool_result":
1222-
// Send a new message for the result so the full timeline
1223-
// is preserved in chat history.
1224-
sizeLabel := fmt.Sprintf("%dB", len(data))
1225-
if len(data) > 1024 {
1226-
sizeLabel = fmt.Sprintf("%dKB", len(data)/1024)
1273+
if toolProgress == "verbose" {
1274+
line := fmt.Sprintf("%s `%s` %s", render.ToolEmoji(name), name, data)
1275+
line = telegram.EscapeMarkdown(line)
1276+
bot.SendMessage(chatID, line,
1277+
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
1278+
} else {
1279+
line := buildProgressLine(name, data)
1280+
sendProgress(line)
12271281
}
1228-
line := fmt.Sprintf("%s `%s` ✅ (%s)", render.ToolEmoji(name), name, sizeLabel)
1229-
line = telegram.EscapeMarkdown(line)
1230-
bot.SendMessage(chatID, line,
1231-
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
1232-
// Clean up the initial tool_call message — it's stale now.
1233-
if msgIDVal, ok := toolMsgIDs.Load(name); ok {
1234-
bot.DeleteMessage(chatID, msgIDVal.(int))
1282+
1283+
case "tool_result":
1284+
if toolProgress == "verbose" {
1285+
sizeLabel := fmt.Sprintf("%dB", len(data))
1286+
if len(data) > 1024 {
1287+
sizeLabel = fmt.Sprintf("%dKB", len(data)/1024)
1288+
}
1289+
line := fmt.Sprintf("%s `%s` ✅ (%s)", render.ToolEmoji(name), name, sizeLabel)
1290+
line = telegram.EscapeMarkdown(line)
1291+
bot.SendMessage(chatID, line,
1292+
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
12351293
}
1236-
toolMsgIDs.Delete(name)
1294+
// "all"/"new" modes: tool_result is silent (progress
1295+
// bubble already shows the narrated tool call).
12371296
}
12381297
},
12391298
IterationCallback: func(info loop.IterationInfo) {

docs/CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# Changelog
22

3+
## v0.43.0 (2026-05-24) — Telegram Narrator Upgrade
4+
5+
### New Features
6+
- **Telegram tool progress system** — completely rewritten progress display with Hermes-parity features:
7+
- **Smart previews**`📝 read_file: "main.go"` instead of generic narrated templates. Extracts meaningful context from tool args (filename, command, URL, query, etc.)
8+
- **Edit throttling** — 1.5s minimum between edits to avoid Telegram flood control (no more 429 errors)
9+
- **Tool dedup** — consecutive same-tool calls collapse into `(×N)` counter, reducing chat noise
10+
- **Flood control fallback** — when edit hits a rate limit, automatically switches to new messages
11+
- **Content reset** — when `send_message` fires mid-run, progress bubble resets below the sent content
12+
- **`tool_progress` config** — new independent config field with four modes:
13+
- `"all"` (default) — single editable progress bubble with smart previews, throttling, dedup
14+
- `"new"` — only updates when the tool name changes
15+
- `"verbose"` — raw tool args in per-tool messages
16+
- `"off"` — no per-tool progress (just thinking + final answer)
17+
- **`tool_progress_cleanup` config** — whether to delete progress messages after the final answer (default: `true`)
18+
- **`render.ToolPreview()`** — exported function extracts meaningful previews from tool call JSON args. Covers all 20+ native tools (read_file, shell, browser, memory, transcribe, send_message, etc.)
19+
- **`render.ToolEmoji()`** — now exported for use by Telegram bot (was internal-only)
20+
21+
### Breaking Changes
22+
- `narrate.Narrator` package is deprecated — all functionality absorbed into `telegram.go` + `render.go`. The package is kept for build compatibility but no longer used by Telegram bot
23+
24+
### Config
25+
```json
26+
{
27+
"tool_progress": "all",
28+
"tool_progress_cleanup": true
29+
}
30+
```
31+
32+
### Stats
33+
- 120+ insertions across 3 files (telegram.go, render.go, loader.go)
34+
- All 19 packages pass with `-race`
35+
36+
---
37+
338
## v0.42.1 (2026-05-24) — OGG Opus Transcribe Fix
439

540
### Bug Fixes

docs/CONFIG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Every config knob has a `ODEK_*` counterpart:
102102
| `ODEK_SYSTEM` | `--system` | string |
103103
| `ODEK_SKILLS_LEARN` | `skills.learn` | bool |
104104
| `ODEK_PROMPT_CACHING` | `prompt_caching` | bool |
105+
| `ODEK_TOOL_PROGRESS` | `tool_progress` | string (all\|new\|verbose\|off) |
105106
| `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string |
106107
| `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string |
107108
| `ODEK_SANDBOX_READONLY` | `--sandbox-readonly` | bool |

docs/TELEGRAM.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,33 @@ Voice message received → DownloadVoice (OGG Opus to disk)
307307

308308
**Fallback:** If auto-transcribe fails (ffmpeg unavailable, corrupt audio, whisper error), the agent receives the file path with a suggestion to use the `transcribe()` tool manually.
309309

310+
### Tool Progress (Narrator)
311+
312+
Tool progress shows what the agent is doing in real time. Controlled by the `tool_progress` config field (independent from `interaction_mode`):
313+
314+
| Mode | Behavior |
315+
|------|----------|
316+
| `all` (default) | Single editable progress bubble with smart previews — e.g. `📝 read_file: "main.go"`. With edit throttling (1.5s), dedup (`×N` counter), and flood-control fallback |
317+
| `new` | Like `all` but only updates when the tool name changes — skips consecutive same-tool repeats |
318+
| `verbose` | Raw tool arguments as separate messages — `📝 `read_file` {"path":"main.go"}` then `📝 `read_file` ✅ (2KB)` on completion |
319+
| `off` | No per-tool progress messages — just the thinking preamble and final answer |
320+
321+
**Key features:**
322+
- **Smart previews** — extracts meaningful context: filename for file tools, command for shell, URL for browser, query for memory, filename for transcribe
323+
- **Edit throttling** — 1.5s minimum between edits prevents Telegram flood control (429 errors)
324+
- **Tool dedup** — if the same tool runs N times in a row (common with parallel batches), shows `📝 read_file: "main.go" (×5)` instead of 5 identical lines
325+
- **Flood fallback** — if an edit fails with "flood" or "retry after", automatically switches to sending new messages
326+
- **Content reset** — when `send_message` fires mid-run, the progress bubble resets below the sent content
327+
- **Cleanup** — progress message deleted after final answer (configurable via `tool_progress_cleanup: false`)
328+
329+
Config example:
330+
```json
331+
{
332+
"tool_progress": "all",
333+
"tool_progress_cleanup": true
334+
}
335+
```
336+
310337
## Types (`types.go`)
311338

312339
The package defines Telegram API types used throughout:

0 commit comments

Comments
 (0)