Skip to content

Commit 3d2c58f

Browse files
committed
v0.44.0: reasoning-first progress + language matching
- LLM reasoning first sentence becomes live progress indicator header - System prompt enforces user-facing <20 word reasoning with examples - Language matching: bot replies in user's language for everything - render.FirstSentence() extracts first sentence, strips I will/I'll - Telegram progress bubble: reasoning header + tool previews below - Fixed memMsgIdx desync after context trimming in loop.go - Updated CHANGELOG, AGENTS.md, TELEGRAM.md docs
1 parent 21addd4 commit 3d2c58f

8 files changed

Lines changed: 231 additions & 26 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.43.0 — see latest tag at https://github.com/BackendStack21/odek/releases
15+
- **Version:** v0.44.0 — see latest tag at https://github.com/BackendStack21/odek/releases
1616

1717
## Source Layout
1818

cmd/odek/telegram.go

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,18 @@ func telegramCmd(args []string) error {
173173
systemMessage += "- If a tool fails with 'no such file' or returns empty, check pwd first.\n"
174174
systemMessage += "- NEVER run 'find /' or recursive searches from root — they hang.\n"
175175
systemMessage += "- A single failure means the path or assumption was wrong — fix that,\n"
176-
systemMessage += " don't escalate to a broader search. Narrow, don't widen."
177-
176+
systemMessage += " don't escalate to a broader search. Narrow, don't widen.\n"
177+
systemMessage += "\n"
178+
systemMessage += "## REASONING REMINDER\n"
179+
systemMessage += "The first sentence of your reasoning block is the user's live progress indicator. "
180+
systemMessage += "Make it brief (<20 words), user-facing, and specific to what you are "
181+
systemMessage += "doing right now. Generic self-talk like 'Let me think about this...' "
182+
systemMessage += "is useless — users see nothing useful. Start with a real explanation.\n"
183+
systemMessage += "\n"
184+
systemMessage += "## LANGUAGE REMINDER\n"
185+
systemMessage += "Always reply in the exact same language the user writes in. "
186+
systemMessage += "Match their language for the answer, the thinking message, "
187+
systemMessage += "and the progress indicator. Never switch languages.\n"
178188
// Set working directory to the configured repo directory.
179189
// This ensures tools like search_files scan the project, not /root.
180190
if resolved.GithubRepoDirectory != "" {
@@ -1140,20 +1150,15 @@ func handleChatMessage(
11401150
}
11411151
}
11421152

1143-
// Collect agent run stats via the iteration callback.
1153+
// reasoningProgressLine captures the first sentence of LLM reasoning
1154+
// for use as the progress bubble content in "all"/"new" modes.
1155+
// Reset at the start of each IsPreTool callback. When empty (no reasoning),
1156+
// the ToolEventHandler falls back to the old ToolPreview approach.
1157+
var reasoningProgressLine string
11441158
var runInfo loop.IterationInfo
11451159
var allToolsMu sync.Mutex
11461160
allTools := make(map[string]int)
11471161

1148-
// truncateWords limits text to maxWords, appending "…" if trimmed.
1149-
truncateWords := func(s string, maxWords int) string {
1150-
words := strings.Fields(s)
1151-
if len(words) <= maxWords {
1152-
return s
1153-
}
1154-
return strings.Join(words[:maxWords], " ") + "…"
1155-
}
1156-
11571162
// ── Clarify Tool ───────────────────────────────────────────────
11581163
// Wire the clarify tool with a Telegram-native answer function.
11591164
// When the agent calls clarify(question), the bot sends an inline
@@ -1275,7 +1280,17 @@ func handleChatMessage(
12751280
line = telegram.EscapeMarkdown(line)
12761281
bot.SendMessage(chatID, line,
12771282
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
1278-
} else {
1283+
} else if toolProgress == "all" || toolProgress == "new" {
1284+
// Insert reasoning header (first sentence) at top of bubble
1285+
if reasoningProgressLine != "" {
1286+
if len(progressLines) == 0 || progressLines[0] != reasoningProgressLine {
1287+
// Insert reasoning header as first line
1288+
progressLines = append([]string{reasoningProgressLine}, progressLines...)
1289+
lastProgressMsg = reasoningProgressLine
1290+
}
1291+
reasoningProgressLine = "" // consumed, reset for next call
1292+
}
1293+
// Always add tool-specific progress line
12791294
line := buildProgressLine(name, data)
12801295
sendProgress(line)
12811296
}
@@ -1295,13 +1310,21 @@ func handleChatMessage(
12951310
// bubble already shows the narrated tool call).
12961311
}
12971312
},
1313+
// reasoningProgressLine is set in IsPreTool callback and consumed
1314+
// by the ToolEventHandler to insert the reasoning header into the
1315+
// progress bubble, followed by individual tool lines.
12981316
IterationCallback: func(info loop.IterationInfo) {
1299-
// Pre-tool callback: show LLM reasoning before tools run.
1317+
// Pre-tool callback: extract first reasoning sentence for progress.
13001318
if info.IsPreTool {
1319+
// Reset from previous iteration
1320+
reasoningProgressLine = ""
1321+
13011322
if info.ReasoningContent != "" {
1302-
reasoning := truncateWords(info.ReasoningContent, 50)
1303-
if reasoning != "" {
1304-
bot.SendMessage(chatID, "💭 "+reasoning,
1323+
firstSentence := render.FirstSentence(info.ReasoningContent)
1324+
if firstSentence != "" {
1325+
reasoningProgressLine = firstSentence
1326+
// Show as a compact thinking message
1327+
bot.SendMessage(chatID, "💭 "+firstSentence,
13051328
&telegram.SendOpts{ReplyToMessageID: messageID})
13061329
}
13071330
}

docs/CHANGELOG.md

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

3+
## v0.44.0 (2026-05-24) — Reasoning-First Progress & Language Matching
4+
5+
### New Features
6+
- **Reasoning-first progress** — the first sentence of the LLM's internal reasoning (under 20 words, user-facing) now appears at the top of the progress bubble, followed by individual tool previews. The LLM is prompted to make this sentence specific, funny, and engaging:
7+
- System prompt includes imperative `REASONING RULE` with ✅/❌ examples and violation consequences
8+
- Bottom-of-prompt `REASONING REMINDER` for recency bias
9+
- `render.FirstSentence()` extracts the first sentence from reasoning content (handles `. ! ?` boundaries, strips "I will"/"I'll", truncates to 20 words)
10+
- Falls back to classic `ToolPreview()` when the model produces no reasoning content
11+
- Dedup still works on the reasoning header (`(×N)` for repeated iterations)
12+
- **Language matching** — the bot always replies in the exact same language the user writes in, enforced at both the top and bottom of the system prompt:
13+
- Applies to the final answer, the 💭 thinking message, and the progress indicator
14+
- Includes `LANGUAGE RULE` with examples and consequences
15+
- Bottom-of-prompt `LANGUAGE REMINDER` for recency bias
16+
17+
### Bug Fixes
18+
- **`internal/loop/loop.go`** — fixed `memMsgIdx` desync after context trimming: when `trimContext` injects a context-warning system message, the memory message index shifts by 1, causing memory content to be silently dropped. Now detects and adjusts the index
19+
20+
### Documentation
21+
- **TELEGRAM.md** — updated Tool Progress docs to describe reasoning-first behavior and language matching
22+
23+
### Internal
24+
- **`render.FirstSentence()`** — new exported function with 6 tests (empty, simple, exclamation, "I will" stripping, no-boundary, long truncation)
25+
- **`render/render_test.go`** — restored existing test suite (was accidentally overwritten) and appended FirstSentence tests
26+
- **`cmd/odek/telegram.go`** — removed unused local `truncateWords` closure; replaced with `render.FirstSentence()`
27+
328
## v0.43.1 (2026-05-24) — Tool Progress Docs & /mode Command
429

530
### Documentation

docs/TELEGRAM.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,14 @@ Tool progress shows what the agent is doing in real time. Controlled by the `too
313313

314314
| Mode | Behavior |
315315
|------|----------|
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 |
316+
| `all` (default) | Reasoning-first progress: LLM's first reasoning sentence as header, then individual tool previews below. Eg: `"Let me search that file..."` then `📝 read_file: "main.go"`. With edit throttling (1.5s), dedup, and flood-control fallback |
317+
| `new` | Like `all` but reasoning header only updates on new iteration |
318318
| `verbose` | Raw tool arguments as separate messages — `📝 `read_file` {"path":"main.go"}` then `📝 `read_file` ✅ (2KB)` on completion |
319319
| `off` | No per-tool progress messages — just the thinking preamble and final answer |
320320

321321
**Key features:**
322+
- **Reasoning-first progress** — the first sentence of the LLM's internal reasoning (under 20 words) appears at the top of the progress bubble, followed by individual tool previews. The LLM is prompted to make this sentence user-facing, specific, and engaging
323+
- **Language matching** — the bot always replies in the same language the user writes in, including the thinking message and progress indicator
322324
- **Smart previews** — extracts meaningful context: filename for file tools, command for shell, URL for browser, query for memory, filename for transcribe
323325
- **Edit throttling** — 1.5s minimum between edits prevents Telegram flood control (429 errors)
324326
- **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

internal/loop/loop.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,19 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
410410
// Trim context to stay within model's context window
411411
messages = e.trimContext(messages, tools)
412412

413+
// Resync memMsgIdx after trimContext — when trimContext injects a
414+
// context-warning message at index 1, all subsequent messages shift
415+
// right by 1, making e.memMsgIdx point to the warning instead of
416+
// memory. Detect this by checking if the message at our tracked
417+
// position is a trim warning.
418+
if e.memMsgIdx > 0 && e.memMsgIdx < len(messages) {
419+
if strings.Contains(messages[e.memMsgIdx].Content, "[Context trimmed:") {
420+
// Trim warning was injected before our memory message.
421+
// The actual memory message is now at memMsgIdx + 1.
422+
e.memMsgIdx++
423+
}
424+
}
425+
413426
// Load relevant skills based on latest user input (once per message)
414427
if e.skillLoader != nil {
415428
if userMsg := lastUserMessage(messages); userMsg != "" && userMsg != e.lastSkillMsg {

internal/render/render.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,58 @@ func ToolPreview(name, args string) string {
269269
return ""
270270
}
271271

272+
// FirstSentence extracts the first sentence from reasoning/thinking text.
273+
// Returns a user-facing preview under 20 words. Falls back to truncation
274+
// if no sentence boundary is found. Returns empty string for empty input.
275+
// Handles standard punctuation (. ! ?) followed by space or newline, and
276+
// also handles ellipsis (...) and end-of-input as boundaries.
277+
func FirstSentence(text string) string {
278+
if text == "" {
279+
return ""
280+
}
281+
282+
// Clean leading whitespace and reasoning markers
283+
text = strings.TrimSpace(text)
284+
text = strings.TrimPrefix(text, "I'll")
285+
text = strings.TrimPrefix(text, "I will")
286+
text = strings.TrimSpace(text)
287+
288+
// Try standard sentence boundaries
289+
for _, sep := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n", "...\n"} {
290+
if idx := strings.Index(text, sep); idx > 0 {
291+
sentence := strings.TrimSpace(text[:idx+1])
292+
if sentence != "" {
293+
return truncateWords(sentence, 20)
294+
}
295+
}
296+
}
297+
298+
// No boundary found — check if the whole thing is short enough
299+
if wordCount(text) <= 20 {
300+
return text
301+
}
302+
303+
// Truncate to 20 words
304+
return truncateWords(text, 20)
305+
}
306+
307+
// wordCount returns the number of whitespace-delimited words in s.
308+
func wordCount(s string) int {
309+
if strings.TrimSpace(s) == "" {
310+
return 0
311+
}
312+
return len(strings.Fields(s))
313+
}
314+
315+
// truncateWords limits s to maxWords, appending "…" if trimmed.
316+
func truncateWords(s string, maxWords int) string {
317+
words := strings.Fields(s)
318+
if len(words) <= maxWords {
319+
return s
320+
}
321+
return strings.Join(words[:maxWords], " ") + "…"
322+
}
323+
272324
// extractJSONField extracts the value of a top-level string field from a JSON blob.
273325
func extractJSONField(jsonStr, field string) string {
274326
prefix := `"` + field + `": "`

internal/render/render_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,3 +751,54 @@ func TestRenderer_NarratorMessage_NoColor(t *testing.T) {
751751
t.Errorf("missing message in no-color output: %q", out)
752752
}
753753
}
754+
755+
func TestFirstSentence_Empty(t *testing.T) {
756+
if got := FirstSentence(""); got != "" {
757+
t.Errorf("FirstSentence('') = %q, want ''", got)
758+
}
759+
}
760+
761+
func TestFirstSentence_Simple(t *testing.T) {
762+
input := "Let me search for that file. I will use grep to find the pattern."
763+
got := FirstSentence(input)
764+
want := "Let me search for that file."
765+
if got != want {
766+
t.Errorf("FirstSentence = %q, want %q", got, want)
767+
}
768+
}
769+
770+
func TestFirstSentence_StripsIWill(t *testing.T) {
771+
input := "I will check the server logs for errors. Then I can debug further."
772+
got := FirstSentence(input)
773+
want := "check the server logs for errors."
774+
if got != want {
775+
t.Errorf("FirstSentence = %q, want %q", got, want)
776+
}
777+
}
778+
779+
func TestFirstSentence_NoBoundary(t *testing.T) {
780+
input := "Just a single short phrase"
781+
got := FirstSentence(input)
782+
want := "Just a single short phrase"
783+
if got != want {
784+
t.Errorf("FirstSentence = %q, want %q", got, want)
785+
}
786+
}
787+
788+
func TestFirstSentence_TruncateLong(t *testing.T) {
789+
input := "I think the best approach would be to first check the configuration file and then run the application to see if it works correctly without any errors."
790+
got := FirstSentence(input)
791+
words := len(strings.Fields(got))
792+
if words > 20 {
793+
t.Errorf("FirstSentence = %q has %d words, want ≤20", got, words)
794+
}
795+
}
796+
797+
func TestFirstSentence_WithExclamation(t *testing.T) {
798+
input := "Found it! Now let me read the contents."
799+
got := FirstSentence(input)
800+
want := "Found it!"
801+
if got != want {
802+
t.Errorf("FirstSentence = %q, want %q", got, want)
803+
}
804+
}

runtime.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,51 @@ func BuildRuntimeContext(platform string) string {
2828

2929
switch platform {
3030
case "telegram":
31-
ctx += "\n\nYou are on a text messaging communication platform, Telegram. " +
32-
"Standard markdown is supported: **bold**, *italic*, ~~strikethrough~~, " +
33-
"||spoiler||, `inline code`, ```code blocks```, [links](url), and ## headers. " +
34-
"Use the send_message tool to send intermediate messages, files (photo/document/voice), " +
35-
"or interactive inline keyboard buttons (buttons parameter with cb: prefix). " +
36-
"For final answers, just return the text directly — no need to use send_message."
31+
// ═══ REASONING RULE (MANDATORY) — placed FIRST so LLM sees it immediately
32+
telegramCtx := "## ⚡ REASONING RULE — FOLLOW THIS EXACTLY\n" +
33+
"You MUST start your internal reasoning block with a brief " +
34+
"user-facing explanation of what you are about to do.\n" +
35+
"- This first sentence becomes the LIVE PROGRESS INDICATOR users see\n" +
36+
"- Keep it under 20 words\n" +
37+
"- Be specific, funny, and engaging when possible\n" +
38+
"- Write it FOR THE USER, not for yourself\n" +
39+
"\n" +
40+
"✅ GOOD examples:\n" +
41+
" \"Let me dig into that log file for clues...\"\n" +
42+
" \"Alright, pulling the latest changes from git!\"\n" +
43+
" \"One moment — running that test suite to check...\"\n" +
44+
" \"Let me search the codebase for where that error hides...\"\n" +
45+
"\n" +
46+
"❌ BAD examples (too generic, no user value):\n" +
47+
" \"I'll break this down step by step...\"\n" +
48+
" \"Let me think about this problem...\"\n" +
49+
" \"Okay, let me analyze what's needed here...\"\n" +
50+
" \"First, I need to understand the request...\"\n" +
51+
"\n" +
52+
"VIOLATION CONSEQUENCE: If you write a generic self-directed first sentence, " +
53+
"users see NOTHING useful while you work — they have no clue what is happening. " +
54+
"The bot looks broken. Always start with a real explanation.\n\n" +
55+
"## 🌐 LANGUAGE RULE — FOLLOW THIS EXACTLY\n" +
56+
"You MUST reply in the EXACT SAME LANGUAGE the user writes in.\n" +
57+
"- Read the user's language from their message and match it\n" +
58+
"- This includes the final answer, the 💭 thinking message, AND the progress indicator\n" +
59+
"- NEVER switch languages mid-conversation\n" +
60+
"- If unsure, detect the language from the message content\n" +
61+
"\n" +
62+
"Examples: user writes in Portuguese → reply in Portuguese. " +
63+
"User writes in English → reply in English. " +
64+
"User writes in Spanish → reply in Spanish.\n" +
65+
"\n" +
66+
"VIOLATION CONSEQUENCE: Replying in the wrong language confuses the user " +
67+
"and makes the bot unusable. Always match the user's language.\n\n" +
68+
"You are on a text messaging communication platform, Telegram. " +
69+
"Standard markdown is supported: **bold**, *italic*, ~~strikethrough~~, " +
70+
"||spoiler||, `inline code`, ```code blocks```, [links](url), and ## headers. " +
71+
"Use the send_message tool to send intermediate messages, files (photo/document/voice), " +
72+
"or interactive inline keyboard buttons (buttons parameter with cb: prefix). " +
73+
"For final answers, just return the text directly — no need to use send_message."
74+
// The caller (odek.New) prepends runtimeContext to systemMessage already.
75+
ctx += telegramCtx
3776

3877
case "web":
3978
ctx += "\n\nYou are running in a web UI. " +

0 commit comments

Comments
 (0)