Skip to content

Commit 8278bc7

Browse files
committed
feat: per-tool trace messages + LLM reasoning in Telegram bot
- Replace single edited trace message with individual messages per tool call - Show LLM reasoning content before tool execution via IterationCallback - Add deleteToolTraceMessages helper to clean up after response delivery - IterationInfo gains ReasoningContent and IsPreTool fields
1 parent 3bdaf50 commit 8278bc7

3 files changed

Lines changed: 268 additions & 48 deletions

File tree

cmd/odek/telegram.go

Lines changed: 60 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,12 +1025,11 @@ func handleChatMessage(
10251025
}()
10261026

10271027
// ── Tool Tracing ───────────────────────────────────────────────
1028-
// Single editable message showing live tool execution progress.
1029-
// In verbose mode this shows raw tool names/results; in engaging
1030-
// mode it's replaced by the narrator progress message above.
1031-
var traceMsgID int
1032-
var traceMu sync.Mutex
1033-
traceLines := make([]string, 0, 8)
1028+
// Individual messages per tool call so the user can follow the
1029+
// agent's journey step by step. Reasoning is shown before tool
1030+
// calls. All trace messages are deleted after the agent responds.
1031+
// toolMsgIDs tracks per-turn tool message IDs for editing on result.
1032+
var toolMsgIDs sync.Map // map[string]int
10341033

10351034
// truncate shortens a string for display, appending "…" if trimmed.
10361035
truncate := func(s string, max int) string {
@@ -1040,6 +1039,15 @@ func handleChatMessage(
10401039
return s
10411040
}
10421041

1042+
// truncateWords limits text to maxWords, appending "…" if trimmed.
1043+
truncateWords := func(s string, maxWords int) string {
1044+
words := strings.Fields(s)
1045+
if len(words) <= maxWords {
1046+
return s
1047+
}
1048+
return strings.Join(words[:maxWords], " ") + "…"
1049+
}
1050+
10431051
// Collect agent run stats via the iteration callback.
10441052
var runInfo loop.IterationInfo
10451053
var allToolsMu sync.Mutex
@@ -1127,9 +1135,7 @@ func handleChatMessage(
11271135
Renderer: rend,
11281136
ToolEventHandler: func(event string, name string, data string) {
11291137
// Engaging mode: update the progress message with narrated tool
1130-
// descriptions instead of raw traces. We skip tool_result events
1131-
// here — the next tool_call will overwrite the message anyway,
1132-
// keeping the chat scannable and avoiding flash edits.
1138+
// descriptions instead of raw traces.
11331139
if resolved.InteractionMode != "verbose" {
11341140
if progressMsgID != 0 && event == "tool_call" && narrator != nil {
11351141
if msg := narrator.ToolCallMessage(name, data); msg != "" {
@@ -1138,41 +1144,43 @@ func handleChatMessage(
11381144
}
11391145
return
11401146
}
1141-
traceMu.Lock()
1142-
defer traceMu.Unlock()
1143-
1144-
// Lazy-init: create the trace message on the first tool call.
1145-
if traceMsgID == 0 && event == "tool_call" {
1146-
if msg, err := bot.SendMessage(chatID, "🔧 …", nil); err == nil {
1147-
traceMsgID = msg.ID
1148-
} else {
1149-
return
1150-
}
1151-
}
1152-
if traceMsgID == 0 {
1153-
return
1154-
}
11551147

1148+
// Verbose mode: individual messages per tool call so the user
1149+
// can follow the agent's journey step by step.
11561150
switch event {
11571151
case "tool_call":
11581152
args := truncate(data, 150)
1159-
line := fmt.Sprintf("%s %s(%s) ⏳", render.ToolEmoji(name), name, args)
1160-
traceLines = append(traceLines, line)
1161-
bot.EditMessageText(chatID, traceMsgID, strings.Join(traceLines, "\n"), nil)
1153+
line := fmt.Sprintf("%s `%s` %s", render.ToolEmoji(name), name, args)
1154+
if msg, err := bot.SendMessage(chatID, line, nil); err == nil {
1155+
toolMsgIDs.Store(name, msg.ID)
1156+
}
11621157

11631158
case "tool_result":
1164-
sizeLabel := fmt.Sprintf("%dB", len(data))
1165-
if len(data) > 1024 {
1166-
sizeLabel = fmt.Sprintf("%dKB", len(data)/1024)
1167-
}
1168-
if len(traceLines) > 0 {
1169-
last := traceLines[len(traceLines)-1]
1170-
traceLines[len(traceLines)-1] = strings.Replace(last, " ⏳", " ✅ ("+sizeLabel+")", 1)
1171-
bot.EditMessageText(chatID, traceMsgID, strings.Join(traceLines, "\n"), nil)
1159+
if msgIDVal, ok := toolMsgIDs.Load(name); ok {
1160+
msgID := msgIDVal.(int)
1161+
sizeLabel := fmt.Sprintf("%dB", len(data))
1162+
if len(data) > 1024 {
1163+
sizeLabel = fmt.Sprintf("%dKB", len(data)/1024)
1164+
}
1165+
bot.EditMessageText(chatID, msgID,
1166+
fmt.Sprintf("%s `%s` ✅ (%s)", render.ToolEmoji(name), name, sizeLabel), nil)
11721167
}
11731168
}
11741169
},
11751170
IterationCallback: func(info loop.IterationInfo) {
1171+
// Pre-tool callback: show LLM reasoning before tools run.
1172+
if info.IsPreTool {
1173+
if info.ReasoningContent != "" {
1174+
reasoning := truncateWords(info.ReasoningContent, 50)
1175+
if reasoning != "" {
1176+
bot.SendMessage(chatID, "💭 "+reasoning,
1177+
&telegram.SendOpts{ReplyToMessageID: messageID})
1178+
}
1179+
}
1180+
return
1181+
}
1182+
1183+
// Post-tool / final-answer callback: collect tool stats.
11761184
allToolsMu.Lock()
11771185
for _, name := range info.ToolNames {
11781186
if _, ok := allTools[name]; !ok {
@@ -1257,11 +1265,8 @@ func handleChatMessage(
12571265
// Run the agent with the full message history (multi-turn).
12581266
response, updatedMessages, err := agent.RunWithMessages(agentCtx, cs.Messages)
12591267
if err != nil {
1260-
// Clean up trace message on error too.
1261-
if traceMsgID != 0 {
1262-
bot.DeleteMessage(chatID, traceMsgID)
1263-
traceMsgID = 0
1264-
}
1268+
// Clean up any tool trace messages on error.
1269+
deleteToolTraceMessages(bot, chatID, &toolMsgIDs)
12651270

12661271
// If the context was cancelled (by /stop or restart), save partial
12671272
// state and notify the user with a cancellation summary.
@@ -1314,13 +1319,8 @@ func handleChatMessage(
13141319
if response != "" {
13151320
handler.SendResponse(chatID, response, messageID)
13161321

1317-
// Clean up the tool trace message — it's stale after the response.
1318-
if traceMsgID != 0 {
1319-
if err := bot.DeleteMessage(chatID, traceMsgID); err != nil {
1320-
log.Debug("delete trace message failed", "chat_id", chatID, "msg_id", traceMsgID, "error", err)
1321-
}
1322-
traceMsgID = 0
1323-
}
1322+
// Clean up all tool trace messages — they're stale after the response.
1323+
deleteToolTraceMessages(bot, chatID, &toolMsgIDs)
13241324

13251325
// Send run stats as a separate message directly via Bot.SendMessage
13261326
// (bypassing SendResponse/FormatResponse) so MarkdownV2 backtick code
@@ -1659,3 +1659,17 @@ func buttonsToMarkup(buttons [][]map[string]string) *telegram.InlineKeyboardMark
16591659
}
16601660
return markup
16611661
}
1662+
1663+
// deleteToolTraceMessages deletes all individual tool trace messages for a chat.
1664+
// Used to clean up after the agent finishes (success or error).
1665+
func deleteToolTraceMessages(bot *telegram.Bot, chatID int64, msgIDs *sync.Map) {
1666+
msgIDs.Range(func(key, value any) bool {
1667+
if msgID, ok := value.(int); ok {
1668+
if err := bot.DeleteMessage(chatID, msgID); err != nil {
1669+
fmt.Fprintf(os.Stderr, "odek telegram: delete tool message failed: %v\n", err)
1670+
}
1671+
}
1672+
msgIDs.Delete(key)
1673+
return true
1674+
})
1675+
}

internal/loop/loop.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ type IterationInfo struct {
4545
CachedTokens int // cumulative cached tokens (OpenAI)
4646
TotalLatency time.Duration // cumulative wall time
4747
HasFinalAnswer bool // true when the agent reached a final answer
48+
ReasoningContent string // LLM reasoning before tool calls (empty if none)
49+
IsPreTool bool // true when fired BEFORE tool execution (shows reasoning + tools)
4850
}
4951

5052
// IterationCallback is an optional callback invoked after each iteration
@@ -582,11 +584,31 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
582584

583585
// ACT: execute each tool call in parallel with bounded concurrency
584586
toolNames := make([]string, 0, len(result.ToolCalls))
585-
586-
// Phase 1: fire all tool_call events synchronously (rendering + events)
587587
for _, tc := range result.ToolCalls {
588588
toolNames = append(toolNames, tc.Function.Name)
589+
}
590+
591+
// Fire iteration callback BEFORE tool execution so UIs can show
592+
// the LLM's reasoning and which tools are about to run.
593+
if e.iterationCallback != nil {
594+
e.iterationCallback(IterationInfo{
595+
Turn: i + 1,
596+
MaxTurns: e.maxIter,
597+
ToolNames: toolNames,
598+
InputTokens: e.TotalInputTokens,
599+
OutputTokens: e.TotalOutputTokens,
600+
CacheCreationTokens: e.TotalCacheCreationTokens,
601+
CacheReadTokens: e.TotalCacheReadTokens,
602+
CachedTokens: e.TotalCachedTokens,
603+
TotalLatency: time.Since(startTime),
604+
HasFinalAnswer: false,
605+
ReasoningContent: result.Content,
606+
IsPreTool: true,
607+
})
608+
}
589609

610+
// Phase 1: fire all tool_call events synchronously (rendering + events)
611+
for _, tc := range result.ToolCalls {
590612
if e.narrator != nil {
591613
if msg := e.narrator.ToolCallMessage(tc.Function.Name, tc.Function.Arguments); msg != "" {
592614
if e.renderer != nil {

notify-channel-proposal.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Proposal: Default Communication Channel for odek
2+
3+
**Goal:** Allow external systems (cron, webhooks, other tools) to invoke `odek run` and have the output/result reach the operator via a configurable communication channel. Telegram first, extensible later.
4+
5+
---
6+
7+
## 1. What Hermes Does
8+
9+
Hermes has a multi-channel architecture. Each platform (Telegram, Mattermost, Matrix, WhatsApp) has its own config section with allowed chats/rooms, channel prompts, etc. When cron jobs run, `wrap_response: true` in the `cron:` section wraps the output and routes it through the configured channels to reach the operator.
10+
11+
**Key Hermes patterns we're adopting:**
12+
- Top-level `notify` configuration that's platform-agnostic
13+
- Channel routing: "send this to the operator via channel X"
14+
- Non-interactive awareness: different behavior when no TTY
15+
16+
---
17+
18+
## 2. Proposed Design
19+
20+
### 2.1 New `notify` config section
21+
22+
```json
23+
// ~/.odek/config.json
24+
{
25+
"notify": {
26+
"channel": "telegram",
27+
"telegram_chat_id": 8592463065
28+
}
29+
}
30+
```
31+
32+
**Fields:**
33+
34+
| Field | Type | Description |
35+
|-------|------|-------------|
36+
| `channel` | string | Which channel to use: `"telegram"` (default: `""` = stdout only) |
37+
| `telegram_chat_id` | int64 | Target chat ID for Telegram notifications |
38+
39+
**Future channels** (not implementing now, but design accommodates):
40+
- `"webhook"` — POST to a URL
41+
- `"email"` — SMTP
42+
- `"discord"` — Discord webhook
43+
44+
### 2.2 Config wiring (7-layer chain)
45+
46+
Following odek's existing pattern (global → project → env → CLI):
47+
48+
| Layer | Key/Flag |
49+
|-------|----------|
50+
| `~/.odek/config.json` | `notify.channel`, `notify.telegram_chat_id` |
51+
| `./odek.json` | same keys (project overrides) |
52+
| Env var | `ODEK_NOTIFY_CHANNEL`, `ODEK_NOTIFY_TELEGRAM_CHAT_ID` |
53+
| CLI flag | `--notify-channel`, `--notify-telegram-chat-id` |
54+
55+
### 2.3 Go types
56+
57+
**`internal/notify/notify.go`** (new package):
58+
59+
```go
60+
package notify
61+
62+
// Config holds the notification routing configuration.
63+
type Config struct {
64+
Channel string `json:"channel,omitempty"` // "telegram" or ""
65+
TelegramChatID int64 `json:"telegram_chat_id,omitempty"` // target chat
66+
}
67+
68+
func DefaultConfig() Config {
69+
return Config{} // channel="" means stdout only
70+
}
71+
```
72+
73+
**Added to `FileConfig`** in `internal/config/loader.go`:
74+
75+
```go
76+
Notify *notify.Config `json:"notify,omitempty"`
77+
```
78+
79+
**Added to `ResolvedConfig`** in `internal/config/loader.go`:
80+
81+
```go
82+
Notify notify.Config
83+
```
84+
85+
### 2.4 Integration point: `odek run`
86+
87+
In `cmd/odek/main.go``run()`, after the agent completes:
88+
89+
```go
90+
// After agent.Run() returns:
91+
if !isTerminal() && resolved.Notify.Channel == "telegram" && resolved.Notify.TelegramChatID != 0 {
92+
// Send the final answer to the operator via Telegram
93+
sendTelegramNotification(resolved, result)
94+
}
95+
```
96+
97+
The `sendTelegramNotification` function:
98+
1. Creates a minimal Bot client using the existing `Telegram.Token` from config
99+
2. Sends the agent's final answer as a Markdown message to the configured `TelegramChatID`
100+
3. Also sends any errors if the run failed
101+
102+
### 2.5 When does notification fire?
103+
104+
| Scenario | Notify? |
105+
|----------|---------|
106+
| `odek run` from terminal (TTY) | No — user sees output directly |
107+
| `odek run` from cron (no TTY) | Yes — if `notify.channel` is configured |
108+
| `odek run` from another script (no TTY) | Yes — same as cron |
109+
| `odek telegram` (bot mode) | No — already interactive via Telegram |
110+
| `odek repl` | No — interactive |
111+
112+
**Detection:** Use `!isTerminal()` (we already check stdin for color disable — reuse `terminal.IsTerminal`).
113+
114+
---
115+
116+
## 3. Files Changed
117+
118+
| File | Change |
119+
|------|--------|
120+
| `internal/notify/notify.go` | **NEW** — Config type + DefaultConfig |
121+
| `internal/notify/notify_test.go` | **NEW** — Tests for config validation |
122+
| `internal/config/loader.go` | Add `Notify` to `FileConfig`, `CLIFlags`, `ResolvedConfig`; wire env vars + CLI flags |
123+
| `cmd/odek/main.go` | Add `--notify-channel`, `--notify-telegram-chat-id` flags; add post-run notification dispatch in `run()` |
124+
125+
---
126+
127+
## 4. Cron Usage Example
128+
129+
Once configured, a cron job like this:
130+
131+
```bash
132+
# /etc/cron.d/odek-health
133+
0 9 * * * root /usr/local/bin/odek run "Check system health and report" >> /var/log/odek-cron.log 2>&1
134+
```
135+
136+
Would:
137+
1. Run the agent task
138+
2. Log raw output to `/var/log/odek-cron.log`
139+
3. **Also** send the final answer via Telegram to chat ID `8592463065`
140+
141+
The operator gets the report on their phone without checking logs.
142+
143+
---
144+
145+
## 5. What We Don't Do (Yet)
146+
147+
- **Streaming/progress updates** — notification sends only the final answer, not intermediate tool calls
148+
- **Multi-channel fanout** — only one channel at a time
149+
- **Per-task channel override** — uses global config only
150+
- **Channel-specific formatting** — Telegram uses Markdown, future channels will adapt
151+
152+
---
153+
154+
## 6. Implementation Phases
155+
156+
### Phase 1: Types + Config (2 files, ~80 LOC)
157+
- Create `internal/notify/notify.go`
158+
- Wire into `loader.go` (FileConfig, ResolvedConfig, env vars, CLI flags, default overlay)
159+
160+
### Phase 2: Dispatch logic (1 file, ~50 LOC)
161+
- Add post-run notification to `cmd/odek/main.go``run()`
162+
- `sendTelegramNotification()` helper
163+
164+
### Phase 3: Tests
165+
- Config loading tests (defaults, env vars, CLI override)
166+
- Notification dispatch test (mock Telegram server)
167+
168+
---
169+
170+
## 7. Verification
171+
172+
```bash
173+
# Config loading
174+
grep -o '"notify"' ~/.odek/config.json
175+
176+
# Manual test (with TTY — no notification fires)
177+
odek run --notify-channel telegram --notify-telegram-chat-id 8592463065 "Say hello"
178+
179+
# Cron simulation (no TTY — notification SHOULD fire)
180+
echo "Say hello" | odek run --notify-channel telegram --notify-telegram-chat-id 8592463065
181+
182+
# Full test suite
183+
go test ./internal/notify/... ./internal/config/... -count=1
184+
```

0 commit comments

Comments
 (0)