Skip to content

Commit 1520025

Browse files
committed
feat(telegram): per-response stats with cache tracking and code-block formatting
- Add IterationInfo/IterationCallback to loop engine for progress reporting - Wire iteration callback in Telegram handler to collect per-run stats - Show compact stats after each response: turns, tokens, cache, latency, tools - Format stats as MarkdownV2 code block for clear visual distinction - Always show cache line (cache: Xcr+Yrd+Zct) even when zero - Send stats via Bot.SendMessage directly (bypass FormatResponse conversion) - Add EditMessageText method to Bot for future progress editing
1 parent 9537c8f commit 1520025

4 files changed

Lines changed: 174 additions & 6 deletions

File tree

cmd/odek/telegram.go

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"os/signal"
8+
"sort"
89
"strings"
910
"sync"
1011
"sync/atomic"
@@ -14,6 +15,7 @@ import (
1415
"github.com/BackendStack21/kode"
1516
"github.com/BackendStack21/kode/internal/config"
1617
"github.com/BackendStack21/kode/internal/llm"
18+
"github.com/BackendStack21/kode/internal/loop"
1719
"github.com/BackendStack21/kode/internal/render"
1820
"github.com/BackendStack21/kode/internal/session"
1921
"github.com/BackendStack21/kode/internal/telegram"
@@ -102,7 +104,7 @@ func telegramCmd(args []string) error {
102104
// dispatch prevents deadlock.
103105
handler.OnTextMessage = func(chatID int64, text string) (string, error) {
104106
go handleChatMessage(chatID, text, bot, handler, sessionManager,
105-
resolved, systemMessage)
107+
resolved, systemMessage, handlerLog)
106108
return "", nil
107109
}
108110

@@ -158,13 +160,13 @@ func telegramCmd(args []string) error {
158160

159161
handler.OnVoiceMessage = func(chatID int64, fileID string) (string, error) {
160162
go handleChatMessage(chatID, "[voice message: "+fileID+"]",
161-
bot, handler, sessionManager, resolved, systemMessage)
163+
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
162164
return "", nil
163165
}
164166

165167
handler.OnPhotoMessage = func(chatID int64, fileIDs []string) (string, error) {
166168
go handleChatMessage(chatID, "[photo message: "+strings.Join(fileIDs, ",")+"]",
167-
bot, handler, sessionManager, resolved, systemMessage)
169+
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
168170
return "", nil
169171
}
170172

@@ -239,6 +241,7 @@ func handleChatMessage(
239241
sessionManager *telegram.SessionManager,
240242
resolved config.ResolvedConfig,
241243
systemMessage string,
244+
log telegram.Logger,
242245
) {
243246
// Serialize per chat: only one agent loop runs per chat at a time.
244247
// Prevents same-chat message racing that would corrupt session history.
@@ -272,7 +275,12 @@ func handleChatMessage(
272275

273276
rend := render.New(os.Stderr, false).WithModel(modelLabel)
274277

275-
agent, err := odek.New(odek.Config{
278+
// Collect agent run stats via the iteration callback.
279+
var runInfo loop.IterationInfo
280+
var allToolsMu sync.Mutex
281+
allTools := make(map[string]int)
282+
283+
agentCfg := odek.Config{
276284
Model: resolved.Model,
277285
BaseURL: resolved.BaseURL,
278286
APIKey: resolved.APIKey,
@@ -282,7 +290,23 @@ func handleChatMessage(
282290
Thinking: resolved.Thinking,
283291
Tools: tools,
284292
Renderer: rend,
285-
})
293+
IterationCallback: func(info loop.IterationInfo) {
294+
allToolsMu.Lock()
295+
for _, name := range info.ToolNames {
296+
if _, ok := allTools[name]; !ok {
297+
allTools[name] = 0
298+
}
299+
allTools[name]++
300+
}
301+
allToolsMu.Unlock()
302+
303+
if info.HasFinalAnswer {
304+
runInfo = info
305+
}
306+
},
307+
}
308+
309+
agent, err := odek.New(agentCfg)
286310
if err != nil {
287311
reportError(bot, chatID, "Failed to create agent: "+err.Error())
288312
return
@@ -317,9 +341,30 @@ func handleChatMessage(
317341
fmt.Fprintf(os.Stderr, "odek telegram: session save: %v\n", err)
318342
}
319343

320-
// Send the response.
344+
// Send the response, then append compact stats as a separate message.
321345
if response != "" {
322346
handler.SendResponse(chatID, response)
347+
348+
// Send run stats as a separate message directly via Bot.SendMessage
349+
// (bypassing SendResponse/FormatResponse) so MarkdownV2 backtick code
350+
// formatting is handled natively by Telegram's parser.
351+
if runInfo.Turn > 0 {
352+
allToolsMu.Lock()
353+
toolList := sortedToolKeys(allTools)
354+
allToolsMu.Unlock()
355+
356+
statsLine := formatTelegramStats(runInfo, toolList)
357+
if _, err := bot.SendMessage(chatID, statsLine, &telegram.SendOpts{
358+
ParseMode: telegram.ParseModeMarkdownV2,
359+
}); err != nil {
360+
// Fallback: send as plain text so the info isn't lost
361+
if _, err2 := bot.SendMessage(chatID, statsLine, nil); err2 != nil {
362+
fmt.Fprintf(os.Stderr, "odek telegram: stats send fallback failed: %v (orig: %v)\n", err2, err)
363+
}
364+
}
365+
} else {
366+
fmt.Fprintf(os.Stderr, "odek telegram: stats skipped (runInfo.Turn=%d)\n", runInfo.Turn)
367+
}
323368
}
324369
}
325370

@@ -342,6 +387,42 @@ func formatStats(cs *telegram.ChatSession) string {
342387
)
343388
}
344389

390+
// ── Progress Callback Helpers ──────────────────────────────────────────
391+
392+
// sortedToolKeys returns the keys of a map sorted alphabetically.
393+
func sortedToolKeys(m map[string]int) []string {
394+
keys := make([]string, 0, len(m))
395+
for k := range m {
396+
keys = append(keys, k)
397+
}
398+
sort.Strings(keys)
399+
return keys
400+
}
401+
402+
// formatTelegramStats formats the final agent run statistics for Telegram.
403+
// Returns a compact Markdown code-formatted line.
404+
func formatTelegramStats(info loop.IterationInfo, toolList []string) string {
405+
toolStr := "none"
406+
if len(toolList) > 0 {
407+
toolStr = strings.Join(toolList, ", ")
408+
}
409+
410+
latency := info.TotalLatency.Truncate(time.Second)
411+
iters := fmt.Sprintf("%d turn", info.Turn)
412+
if info.Turn != 1 {
413+
iters += "s"
414+
}
415+
416+
// Always include cache stats so the user can see them even when zero.
417+
cacheStr := fmt.Sprintf(" · cache: %dcr+%drd+%dct",
418+
info.CacheCreationTokens, info.CacheReadTokens, info.CachedTokens)
419+
420+
return fmt.Sprintf(
421+
"```\n✅ Done · %s · %d in / %d out%s · %s — tools: %s\n```",
422+
iters, info.InputTokens, info.OutputTokens, cacheStr, latency.String(), toolStr,
423+
)
424+
}
425+
345426
// reportError sends an error message to the given chat and logs to stderr.
346427
func reportError(bot *telegram.Bot, chatID int64, msg string) {
347428
fmt.Fprintf(os.Stderr, "odek telegram: %s\n", msg)

internal/loop/loop.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ type SkillLoader func(userInput string) string
2323
// each tool invocation. Used by the WebUI for live streaming of tool events.
2424
type ToolEventHandler func(event string, name string, data string)
2525

26+
// IterationInfo holds data about a single agent loop iteration, passed to
27+
// the IterationCallback after each turn. Used for progress reporting.
28+
type IterationInfo struct {
29+
Turn int // current iteration (1-indexed)
30+
MaxTurns int // max iterations configured
31+
ToolNames []string // tools called this turn (duplicates possible)
32+
InputTokens int // cumulative input tokens
33+
OutputTokens int // cumulative output tokens
34+
CacheCreationTokens int // cumulative cache creation tokens
35+
CacheReadTokens int // cumulative cache read tokens
36+
CachedTokens int // cumulative cached tokens (OpenAI)
37+
TotalLatency time.Duration // cumulative wall time
38+
HasFinalAnswer bool // true when the agent reached a final answer
39+
}
40+
41+
// IterationCallback is an optional callback invoked after each iteration
42+
// of the agent loop. Used by Telegram/WebUI for progress reporting.
43+
type IterationCallback func(info IterationInfo)
44+
2645
// Engine runs the agent loop: observe → think → act → repeat.
2746
type Engine struct {
2847
client *llm.Client
@@ -36,6 +55,9 @@ type Engine struct {
3655

3756
toolEventHandler ToolEventHandler // optional: fires during tool execution
3857

58+
// iterationCallback is an optional callback fired after each iteration.
59+
iterationCallback IterationCallback
60+
3961
// PromptCaching enables Anthropic/OpenAI/DeepSeek prompt caching markers.
4062
// When enabled, the system prompt and first user message are annotated
4163
// with cache_control markers, and the system prompt is moved to the
@@ -73,6 +95,10 @@ func (e *Engine) SetSkillLoader(sl SkillLoader) { e.skillLoader = sl }
7395
// SetToolEventHandler sets the optional tool event callback for live streaming.
7496
func (e *Engine) SetToolEventHandler(cb ToolEventHandler) { e.toolEventHandler = cb }
7597

98+
// SetIterationCallback sets the iteration progress callback.
99+
// If nil, no callback is fired.
100+
func (e *Engine) SetIterationCallback(cb IterationCallback) { e.iterationCallback = cb }
101+
76102
// ── Token Estimation ─────────────────────────────────────────────────
77103
//
78104
// Zero-dependency heuristic: 1 token ≈ 4 chars for English text.
@@ -230,6 +256,7 @@ func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (s
230256
// answer plus the complete updated message history.
231257
func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, []llm.Message, error) {
232258
tools := e.buildToolDefs()
259+
startTime := time.Now()
233260

234261
for i := 0; i < e.maxIter; i++ {
235262
select {
@@ -323,6 +350,22 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
323350
e.TotalCachedTokens,
324351
)
325352
}
353+
354+
// Fire iteration callback with final answer signal
355+
if e.iterationCallback != nil {
356+
e.iterationCallback(IterationInfo{
357+
Turn: i + 1,
358+
MaxTurns: e.maxIter,
359+
ToolNames: nil,
360+
InputTokens: e.TotalInputTokens,
361+
OutputTokens: e.TotalOutputTokens,
362+
CacheCreationTokens: e.TotalCacheCreationTokens,
363+
CacheReadTokens: e.TotalCacheReadTokens,
364+
CachedTokens: e.TotalCachedTokens,
365+
TotalLatency: time.Since(startTime),
366+
HasFinalAnswer: true,
367+
})
368+
}
326369
// Append final assistant message so callers (e.g. WebUI) get
327370
// the final text in the messages slice and can stream it.
328371
messages = append(messages, llm.Message{
@@ -348,7 +391,9 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
348391
messages = append(messages, assistantMsg)
349392

350393
// ACT: execute each tool call
394+
toolNames := make([]string, 0, len(result.ToolCalls))
351395
for _, tc := range result.ToolCalls {
396+
toolNames = append(toolNames, tc.Function.Name)
352397
if e.renderer != nil {
353398
e.renderer.ToolCall(tc.Function.Name, tc.Function.Arguments)
354399
}
@@ -392,6 +437,22 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
392437
ToolCallID: tc.ID,
393438
})
394439
}
440+
441+
// Fire iteration callback with tool call results
442+
if e.iterationCallback != nil {
443+
e.iterationCallback(IterationInfo{
444+
Turn: i + 1,
445+
MaxTurns: e.maxIter,
446+
ToolNames: toolNames,
447+
InputTokens: e.TotalInputTokens,
448+
OutputTokens: e.TotalOutputTokens,
449+
CacheCreationTokens: e.TotalCacheCreationTokens,
450+
CacheReadTokens: e.TotalCacheReadTokens,
451+
CachedTokens: e.TotalCachedTokens,
452+
TotalLatency: time.Since(startTime),
453+
HasFinalAnswer: false,
454+
})
455+
}
395456
}
396457

397458
return "", messages, fmt.Errorf("reached max iterations (%d) without final answer", e.maxIter)

internal/telegram/bot.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,22 @@ func (b *Bot) SendMessage(chatID int64, text string, opts *SendOpts) (*Message,
220220
return &msg, nil
221221
}
222222

223+
// EditMessageText edits a previously sent message in the given chat.
224+
// The messageID must identify an existing message sent by the bot.
225+
// Supports SendOpts for parse_mode. Returns an error if the message
226+
// hasn't changed (Telegram "Bad Request: message is not modified").
227+
func (b *Bot) EditMessageText(chatID int64, messageID int, text string, opts *SendOpts) error {
228+
params := map[string]any{
229+
"chat_id": chatID,
230+
"message_id": messageID,
231+
"text": text,
232+
}
233+
if opts != nil && opts.ParseMode != "" {
234+
params["parse_mode"] = opts.ParseMode
235+
}
236+
return b.doJSON("editMessageText", params, nil)
237+
}
238+
223239
// SendPhoto sends a photo from a file path to the specified chat.
224240
func (b *Bot) SendPhoto(chatID int64, path string, caption string) (*Message, error) {
225241
params := map[string]any{

odek.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ type Config struct {
9696
// after each tool invocation. Used by the WebUI for live streaming.
9797
ToolEventHandler func(event string, name string, data string)
9898

99+
// IterationCallback, if set, is invoked after each iteration of the
100+
// agent loop with progress info (turn number, tokens, tools called).
101+
// Used by the Telegram handler for periodic progress updates.
102+
IterationCallback loop.IterationCallback
103+
99104
// Skills configures the skill system. When nil, skills are disabled.
100105
Skills *skills.SkillsConfig
101106

@@ -404,6 +409,11 @@ func New(cfg Config) (*Agent, error) {
404409
engine.SetToolEventHandler(cfg.ToolEventHandler)
405410
}
406411

412+
// Wire iteration callback for progress reporting
413+
if cfg.IterationCallback != nil {
414+
engine.SetIterationCallback(cfg.IterationCallback)
415+
}
416+
407417
agent.engine = engine
408418
agent.registry = registry
409419
agent.sandboxCleanup = cfg.SandboxCleanup

0 commit comments

Comments
 (0)