first commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// This file implements structured telemetry collection for a loop run
|
||||
// (observability -- structured telemetry collection). A lightweight accumulator observes the AgentEvents the loop
|
||||
// already emits and folds them into a compact summary — per-tool wall-clock
|
||||
// durations, turn count, truncation count, compaction count, and the latest
|
||||
// context-utilization ratio — that is surfaced once at run end as a
|
||||
// TelemetryEvent (just before agent_end).
|
||||
//
|
||||
// The design is deliberately additive: telemetry rides the existing AgentEvent
|
||||
// family and the existing stream-json output path, so no new dependency
|
||||
// (Prometheus/OTLP) is introduced and existing stream-json consumers that do
|
||||
// not know the "telemetry" event type keep working unchanged. Collection is
|
||||
// passive — observing an event never changes loop behavior.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// telemetry is the per-run accumulator. Most fields are folded from events on
|
||||
// the single runLoop goroutine, but tool_execution_* events fire from the
|
||||
// parallel tool-batch goroutines (ExecuteToolCalls), so all mutation goes
|
||||
// through mu.
|
||||
type telemetry struct {
|
||||
mu sync.Mutex
|
||||
turns int
|
||||
truncationCount int
|
||||
compactionCount int
|
||||
|
||||
// toolStarts maps an in-flight tool call id to the wall-clock time its
|
||||
// execution began, so the matching end event can compute a duration. Keying
|
||||
// by call id (not tool name) keeps parallel tool batches correct.
|
||||
toolStarts map[string]time.Time
|
||||
// toolTimings aggregates finished tool durations by tool name.
|
||||
toolTimings map[string]agentcore.ToolTiming
|
||||
|
||||
// contextTokens / contextWindow capture the most recent context accounting so
|
||||
// the summary can report the latest utilization ratio. contextWindow == 0
|
||||
// means the window is unknown (utilization is then reported as 0).
|
||||
contextTokens int
|
||||
contextWindow int
|
||||
|
||||
// now is the clock, injectable for deterministic tests. Defaults to
|
||||
// time.Now.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newTelemetry constructs an empty accumulator using the wall clock.
|
||||
func newTelemetry() *telemetry {
|
||||
return &telemetry{
|
||||
toolStarts: make(map[string]time.Time),
|
||||
toolTimings: make(map[string]agentcore.ToolTiming),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// observe folds a single emitted event into the accumulator. It is a no-op for
|
||||
// event types that carry no telemetry signal, and it never mutates the event.
|
||||
func (t *telemetry) observe(ev agentcore.AgentEvent) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
switch e := ev.(type) {
|
||||
case agentcore.TurnStartEvent:
|
||||
t.turns++
|
||||
case agentcore.ToolExecutionStartEvent:
|
||||
t.toolStarts[e.ToolCallID] = t.now()
|
||||
case agentcore.ToolExecutionEndEvent:
|
||||
start, ok := t.toolStarts[e.ToolCallID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(t.toolStarts, e.ToolCallID)
|
||||
elapsed := t.now().Sub(start).Milliseconds()
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
agg := t.toolTimings[e.ToolName]
|
||||
agg.Count++
|
||||
agg.TotalMs += elapsed
|
||||
t.toolTimings[e.ToolName] = agg
|
||||
case agentcore.TurnEndEvent:
|
||||
if e.Message.StopReason == agentcore.StopReasonLength {
|
||||
t.truncationCount++
|
||||
}
|
||||
case agentcore.CompactionEvent:
|
||||
// Count only successful compactions; a failed one (ErrorMessage set) left
|
||||
// the context unchanged.
|
||||
if e.ErrorMessage == "" {
|
||||
t.compactionCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordContext captures the latest context-token usage and window so the
|
||||
// summary can report the current utilization ratio. A non-positive window is
|
||||
// treated as unknown.
|
||||
func (t *telemetry) recordContext(tokens, window int) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.contextTokens = tokens
|
||||
if window > 0 {
|
||||
t.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// summary materializes the accumulated metrics into a TelemetryEvent. The
|
||||
// per-tool map is copied so the emitted event does not alias the accumulator's
|
||||
// live state.
|
||||
func (t *telemetry) summary() agentcore.TelemetryEvent {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
timings := make(map[string]agentcore.ToolTiming, len(t.toolTimings))
|
||||
for name, v := range t.toolTimings {
|
||||
timings[name] = v
|
||||
}
|
||||
var utilization float64
|
||||
if t.contextWindow > 0 {
|
||||
utilization = float64(t.contextTokens) / float64(t.contextWindow)
|
||||
}
|
||||
return agentcore.TelemetryEvent{
|
||||
Turns: t.turns,
|
||||
ToolDurationsMs: timings,
|
||||
TruncationCount: t.truncationCount,
|
||||
CompactionCount: t.compactionCount,
|
||||
ContextUtilization: utilization,
|
||||
ContextTokens: t.contextTokens,
|
||||
ContextWindow: t.contextWindow,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user