Skip to content

Commit c1ef5b7

Browse files
Alexey Panfilovclaude
andcommitted
feat: add per-phase request tracing to agent Process/ProcessStream
Logs a single structured INFO request_trace line at the end of every request with millisecond durations for: embed, tools_select, cross_session, cache, and per-iteration history/llm/tools phases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 53c2e3a commit c1ef5b7

2 files changed

Lines changed: 99 additions & 2 deletions

File tree

internal/agent/agent.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,19 +108,25 @@ func (a *Agent) TTSEnabled() bool {
108108

109109
// Process runs the agentic loop. onToolCall is called before each tool execution (may be nil).
110110
func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(toolName string)) (string, error) {
111+
tr := newRequestTrace()
111112
queryText := messageText(userMsg)
112113

113114
// Store user message; embed it if semantic store + MCP embeddings are both available.
115+
endEmbed := tr.begin("embed")
114116
queryEmb := a.storeUserMessage(ctx, chatID, userMsg, queryText)
117+
endEmbed()
115118

116119
// Auto-compact if needed
117120
if a.compacter != nil && NeedsCompaction(a.store, chatID) {
118121
a.logger.Info("auto-compacting conversation", "chat_id", chatID)
122+
endCompact := tr.begin("compact")
119123
if err := a.compacter.Compact(ctx, chatID, a.store); err != nil {
120124
a.logger.Warn("auto compaction failed", "err", err)
121125
}
126+
endCompact()
122127
}
123128

129+
endToolsSel := tr.begin("tools_select")
124130
var tools []llm.Tool
125131
if a.mcp != nil {
126132
tools = a.mcp.LLMToolsForQuery(ctx, queryText)
@@ -131,25 +137,38 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
131137
if a.filesystem != nil {
132138
tools = append(tools, filesystemTools()...)
133139
}
140+
endToolsSel()
134141

142+
endCross := tr.begin("cross_session")
135143
crossSessionCtx := a.buildCrossSessionContext(ctx, chatID, queryEmb)
144+
endCross()
136145

137146
// Check response cache before calling the LLM.
138-
if cached, ok := a.cache.Get(chatID, queryEmb); ok {
147+
endCache := tr.begin("cache")
148+
cached, cacheHit := a.cache.Get(chatID, queryEmb)
149+
endCache()
150+
if cacheHit {
139151
a.logger.Info("cache hit", "chat_id", chatID)
140152
a.store.AddMessage(chatID, llm.Message{Role: "assistant", Content: cached})
153+
tr.log(a.logger, chatID, "cache_hit")
141154
return cached, nil
142155
}
143156

144157
for i := 0; i < maxToolIterations; i++ {
158+
endHist := tr.begin(fmt.Sprintf("iter%d_history", i))
145159
history := a.getHistory(chatID, queryEmb)
160+
endHist()
146161

147162
sysPrompt := "Current date and time: " + time.Now().Format("Monday, 2 January 2006, 15:04 MST") + "\n\n" + a.sysPrompt
148163
if crossSessionCtx != "" {
149164
sysPrompt += "\n\n" + crossSessionCtx
150165
}
166+
167+
endLLM := tr.begin(fmt.Sprintf("iter%d_llm", i))
151168
resp, err := a.router.Chat(ctx, history, sysPrompt, tools)
169+
endLLM()
152170
if err != nil {
171+
tr.log(a.logger, chatID, "llm_error")
153172
return "", fmt.Errorf("llm: %w", err)
154173
}
155174

@@ -162,6 +181,7 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
162181
if i == 0 {
163182
a.cache.Set(chatID, queryEmb, resp.Content)
164183
}
184+
tr.log(a.logger, chatID, "ok")
165185
return resp.Content, nil
166186
}
167187

@@ -171,25 +191,35 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
171191
ToolCalls: resp.ToolCalls,
172192
})
173193

194+
endTools := tr.begin(fmt.Sprintf("iter%d_tools", i))
174195
a.executeToolCalls(ctx, chatID, resp.ToolCalls, onToolCall)
196+
endTools()
175197
}
176198

199+
tr.log(a.logger, chatID, "max_iterations")
177200
return "", fmt.Errorf("exceeded maximum tool iterations (%d)", maxToolIterations)
178201
}
179202

180203
// ProcessStream is like Process but streams the final text response via onChunk.
181204
// Tool-calling iterations remain synchronous. onChunk receives the accumulated text so far.
182205
func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(string), onChunk func(accumulated string)) (string, error) {
206+
tr := newRequestTrace()
183207
queryText := messageText(userMsg)
208+
209+
endEmbed := tr.begin("embed")
184210
queryEmb := a.storeUserMessage(ctx, chatID, userMsg, queryText)
211+
endEmbed()
185212

186213
if a.compacter != nil && NeedsCompaction(a.store, chatID) {
187214
a.logger.Info("auto-compacting conversation", "chat_id", chatID)
215+
endCompact := tr.begin("compact")
188216
if err := a.compacter.Compact(ctx, chatID, a.store); err != nil {
189217
a.logger.Warn("auto compaction failed", "err", err)
190218
}
219+
endCompact()
191220
}
192221

222+
endToolsSel := tr.begin("tools_select")
193223
var tools []llm.Tool
194224
if a.mcp != nil {
195225
tools = a.mcp.LLMToolsForQuery(ctx, queryText)
@@ -200,24 +230,37 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
200230
if a.filesystem != nil {
201231
tools = append(tools, filesystemTools()...)
202232
}
233+
endToolsSel()
203234

235+
endCross := tr.begin("cross_session")
204236
crossSessionCtx := a.buildCrossSessionContext(ctx, chatID, queryEmb)
237+
endCross()
205238

206-
if cached, ok := a.cache.Get(chatID, queryEmb); ok {
239+
endCache := tr.begin("cache")
240+
cached, cacheHit := a.cache.Get(chatID, queryEmb)
241+
endCache()
242+
if cacheHit {
207243
a.logger.Info("cache hit", "chat_id", chatID)
208244
a.store.AddMessage(chatID, llm.Message{Role: "assistant", Content: cached})
245+
tr.log(a.logger, chatID, "cache_hit")
209246
return cached, nil
210247
}
211248

212249
for i := 0; i < maxToolIterations; i++ {
250+
endHist := tr.begin(fmt.Sprintf("iter%d_history", i))
213251
history := a.getHistory(chatID, queryEmb)
252+
endHist()
253+
214254
sysPrompt := "Current date and time: " + time.Now().Format("Monday, 2 January 2006, 15:04 MST") + "\n\n" + a.sysPrompt
215255
if crossSessionCtx != "" {
216256
sysPrompt += "\n\n" + crossSessionCtx
217257
}
218258

259+
endLLM := tr.begin(fmt.Sprintf("iter%d_llm", i))
219260
ch, err := a.router.ChatStream(ctx, history, sysPrompt, tools)
220261
if err != nil {
262+
endLLM()
263+
tr.log(a.logger, chatID, "llm_error")
221264
return "", fmt.Errorf("llm: %w", err)
222265
}
223266

@@ -227,6 +270,8 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
227270

228271
for chunk := range ch {
229272
if chunk.Err != nil {
273+
endLLM()
274+
tr.log(a.logger, chatID, "stream_error")
230275
return "", fmt.Errorf("llm stream: %w", chunk.Err)
231276
}
232277
if chunk.Delta != "" {
@@ -242,12 +287,14 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
242287
}
243288
}
244289
}
290+
endLLM()
245291

246292
if len(toolCalls) == 0 {
247293
a.store.AddMessage(chatID, llm.Message{Role: "assistant", Content: accumulated})
248294
if i == 0 {
249295
a.cache.Set(chatID, queryEmb, accumulated)
250296
}
297+
tr.log(a.logger, chatID, "ok")
251298
return accumulated, nil
252299
}
253300

@@ -258,9 +305,12 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
258305
ToolCalls: toolCalls,
259306
})
260307

308+
endTools := tr.begin(fmt.Sprintf("iter%d_tools", i))
261309
a.executeToolCalls(ctx, chatID, toolCalls, onToolCall)
310+
endTools()
262311
}
263312

313+
tr.log(a.logger, chatID, "max_iterations")
264314
return "", fmt.Errorf("exceeded maximum tool iterations (%d)", maxToolIterations)
265315
}
266316

internal/agent/trace.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package agent
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"time"
7+
)
8+
9+
// requestTrace records durations for named phases of a single agent request.
10+
// Usage:
11+
//
12+
// tr := newRequestTrace()
13+
// done := tr.begin("embed")
14+
// ... work ...
15+
// done()
16+
// tr.log(logger, chatID, "ok")
17+
type requestTrace struct {
18+
spans []traceSpan
19+
start time.Time
20+
}
21+
22+
type traceSpan struct {
23+
name string
24+
dur time.Duration
25+
}
26+
27+
func newRequestTrace() *requestTrace {
28+
return &requestTrace{start: time.Now()}
29+
}
30+
31+
// begin starts a named span and returns a function that records its duration.
32+
func (t *requestTrace) begin(name string) func() {
33+
s := time.Now()
34+
return func() {
35+
t.spans = append(t.spans, traceSpan{name: name, dur: time.Since(s)})
36+
}
37+
}
38+
39+
// log emits a single structured log line with all span durations in milliseconds.
40+
func (t *requestTrace) log(logger *slog.Logger, chatID int64, status string) {
41+
args := make([]any, 0, 3+len(t.spans)*2)
42+
args = append(args, "chat_id", chatID, "status", status, "total_ms", time.Since(t.start).Milliseconds())
43+
for _, s := range t.spans {
44+
args = append(args, fmt.Sprintf("%s_ms", s.name), s.dur.Milliseconds())
45+
}
46+
logger.Info("request_trace", args...)
47+
}

0 commit comments

Comments
 (0)