feat(observability): add ToolObserver hook for structured tool tracing

Add a lightweight observer pattern to ToolRegistry that fires after
every tool execution. This enables external observability integrations
(Weave, OpenTelemetry, custom metrics) without modifying individual tools.

Changes:
- pkg/tools/registry.go: Add ToolObserver func type, observer field to
  ToolRegistry, SetObserver() method, and fire observer in ExecuteWithContext
  after every tool call with (name, args, result, durationMs)
- pkg/agent/loop.go: Add SetToolObserver() to AgentLoop, propagating
  the observer to all agent tool registries
- cmd/picoclaw/cmd_agent.go: Wire observer in agentCmd when env var
  PICOCLAW_WEAVE_OBSERVE=1 — emits WEAVE_TOOL_EVENT: JSON lines to
  stderr for downstream consumption by observability backends

The observer is a zero-cost abstraction when not set (nil check before
call). The WEAVE_TOOL_EVENT: line format is:
  WEAVE_TOOL_EVENT:{"tool":"exec","duration_ms":42,"is_error":false,"args":{...}}
This commit is contained in:
SebastianBoehler 2026-03-15 11:41:27 +01:00
parent cb0c8703fb
commit cdc312726b
3 changed files with 57 additions and 2 deletions

View file

@ -6,6 +6,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -18,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
) )
func agentCmd() { func agentCmd() {
@ -72,6 +74,28 @@ func agentCmd() {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// If observability mode is enabled, emit structured tool events to stderr.
// Python gateway reads these lines and logs them to Weave.
if os.Getenv("PICOCLAW_WEAVE_OBSERVE") == "1" {
agentLoop.SetToolObserver(func(name string, args map[string]interface{}, result *tools.ToolResult, durationMs int64) {
evt, err := json.Marshal(struct {
Tool string `json:"tool"`
DurationMs int64 `json:"duration_ms"`
IsError bool `json:"is_error"`
Args map[string]interface{} `json:"args"`
}{
Tool: name,
DurationMs: durationMs,
IsError: result.IsError,
Args: args,
})
if err != nil {
return
}
fmt.Fprintf(os.Stderr, "WEAVE_TOOL_EVENT:%s\n", evt)
})
}
// Print agent startup info (only for interactive mode) // Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized", logger.InfoCF("agent", "Agent initialized",

View file

@ -208,6 +208,16 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
} }
} }
// SetToolObserver registers a ToolObserver on every agent's tool registry.
// The observer is called after every tool execution with name, args, result, and duration.
func (al *AgentLoop) SetToolObserver(obs tools.ToolObserver) {
for _, agentID := range al.registry.ListAgentIDs() {
if agent, ok := al.registry.GetAgent(agentID); ok {
agent.Tools.SetObserver(obs)
}
}
}
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm al.channelManager = cm
} }

View file

@ -10,9 +10,15 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// ToolObserver is called after every tool execution with structured event data.
// Implement this to add observability (tracing, metrics, logging) without
// modifying individual tools.
type ToolObserver func(name string, args map[string]interface{}, result *ToolResult, durationMs int64)
type ToolRegistry struct { type ToolRegistry struct {
tools map[string]Tool tools map[string]Tool
mu sync.RWMutex mu sync.RWMutex
observer ToolObserver
} }
func NewToolRegistry() *ToolRegistry { func NewToolRegistry() *ToolRegistry {
@ -21,6 +27,13 @@ func NewToolRegistry() *ToolRegistry {
} }
} }
// SetObserver registers a callback invoked after every tool execution.
func (r *ToolRegistry) SetObserver(obs ToolObserver) {
r.mu.Lock()
defer r.mu.Unlock()
r.observer = obs
}
func (r *ToolRegistry) Register(tool Tool) { func (r *ToolRegistry) Register(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@ -81,6 +94,14 @@ func (r *ToolRegistry) ExecuteWithContext(
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
duration := time.Since(start) duration := time.Since(start)
// Fire observer if registered
r.mu.RLock()
obs := r.observer
r.mu.RUnlock()
if obs != nil {
obs(name, args, result, duration.Milliseconds())
}
// Log based on result type // Log based on result type
if result.IsError { if result.IsError {
logger.ErrorCF("tool", "Tool execution failed", logger.ErrorCF("tool", "Tool execution failed",