From cdc312726b3f2054fca11f70851cd16590a1a08b Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:41:27 +0100 Subject: [PATCH] feat(observability): add ToolObserver hook for structured tool tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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":{...}} --- cmd/picoclaw/cmd_agent.go | 24 ++++++++++++++++++++++++ pkg/agent/loop.go | 10 ++++++++++ pkg/tools/registry.go | 25 +++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 8658c9d320..178b82e04f 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -6,6 +6,7 @@ package main import ( "bufio" "context" + "encoding/json" "fmt" "io" "os" @@ -18,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" ) func agentCmd() { @@ -72,6 +74,28 @@ func agentCmd() { msgBus := bus.NewMessageBus() 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) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b36f4a0c40..c13bf0d356 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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) { al.channelManager = cm } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 6ecb8ae7cc..bbdfea4344 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -10,9 +10,15 @@ import ( "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 { - tools map[string]Tool - mu sync.RWMutex + tools map[string]Tool + mu sync.RWMutex + observer ToolObserver } 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) { r.mu.Lock() defer r.mu.Unlock() @@ -81,6 +94,14 @@ func (r *ToolRegistry) ExecuteWithContext( result := tool.Execute(ctx, args) 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 if result.IsError { logger.ErrorCF("tool", "Tool execution failed",