refactor(eval): use pkg/runtime in main and eval-runner; fix eval configs

cmd/picoclaw/main.go
- bootstrapAgentRuntime() wraps picoruntime.Bootstrap with OutboundModeNone
- agentCmd and gatewayCmd use bootstrapAgentRuntime instead of inline
  provider/model/bus/agentLoop construction
- loadConfig() delegates to picoruntime.LoadResolvedConfig
- Remove unused picofantasy import

eval/cmd/eval-runner/main.go
- newEvalRuntime() uses picoruntime.Bootstrap with OutboundModeDrop and
  a WrapModel hook to inject the instrumented language model
- resolveEvalConfig() delegates to picoruntime.LoadEvalConfig
- resolvePrompt/emptyPromptTrace/parsePromptPayload/emitTrace extracted
  as named helpers for clarity
- evalRunnerTimeout() reads PICOCLAW_EVAL_TIMEOUT_MS env var with a
  180 s default
- runEval() now calls picoruntime.RunPrompt and reads duration from
  RunResult.Duration

eval/configs/default.json
- Remove tools.progressive_disclosure (always-on, field no longer exists)
- Remove memory.enabled (always-on)
- Add tools.web.duckduckgo config
- Add agents.defaults.temperature: 0.1

eval/promptfooconfig.yaml
- Pass PICOCLAW_EVAL_CONFIG=./configs/default.json via provider env block
  so the overlay is applied regardless of how the runner is invoked

eval/cases/meta_tools.yaml
- read_file and exec dispatch assertions: accept direct gateway call (0.8)
  as well as tool_call dispatch (1.0); direct call is valid since those
  tools are now in the gateway set

eval/README.md
- Update PICOCLAW_EVAL_CONFIG description to reflect overlay semantics

Makefile
- Pass PICOCLAW_EVAL_CONFIG inline in the eval target for make eval
This commit is contained in:
ZanzyTHEbar 2026-02-21 00:41:43 +00:00
parent d5fc69746e
commit 4f740dd024
7 changed files with 145 additions and 158 deletions

View file

@ -225,7 +225,7 @@ eval-build: generate
## eval: Run the eval suite against the current build ## eval: Run the eval suite against the current build
eval: eval-build eval-fixtures eval: eval-build eval-fixtures
@echo "Running eval suite..." @echo "Running eval suite..."
@cd eval && npx promptfoo eval --config promptfooconfig.yaml --no-cache --no-progress-bar @cd eval && PICOCLAW_EVAL_CONFIG="./configs/default.json" npx promptfoo eval --config promptfooconfig.yaml --no-cache --no-progress-bar
@echo "Results: eval/results/latest.json" @echo "Results: eval/results/latest.json"
@echo "View: cd eval && npx promptfoo view" @echo "View: cd eval && npx promptfoo view"

View file

@ -29,7 +29,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/devices"
picofantasy "github.com/sipeed/picoclaw/pkg/fantasy"
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/itr" "github.com/sipeed/picoclaw/pkg/itr"
@ -37,6 +36,7 @@ import (
picomemory "github.com/sipeed/picoclaw/pkg/memory" picomemory "github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/memory/delegate" "github.com/sipeed/picoclaw/pkg/memory/delegate"
"github.com/sipeed/picoclaw/pkg/migrate" "github.com/sipeed/picoclaw/pkg/migrate"
picoruntime "github.com/sipeed/picoclaw/pkg/runtime"
"github.com/sipeed/picoclaw/pkg/security" "github.com/sipeed/picoclaw/pkg/security"
"github.com/sipeed/picoclaw/pkg/security/securebus" "github.com/sipeed/picoclaw/pkg/security/securebus"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
@ -439,27 +439,16 @@ func agentCmd() {
os.Exit(1) os.Exit(1)
} }
fantasyProvider, err := picofantasy.CreateProvider(cfg)
if err != nil {
fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1)
}
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt) appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
languageModel, err := fantasyProvider.LanguageModel(appCtx, picofantasy.ModelID(cfg)) rt, err := bootstrapAgentRuntime(appCtx, cfg)
if err != nil {
fmt.Printf("Error creating language model: %v\n", err)
os.Exit(1)
}
msgBus := bus.NewMessageBus()
agentLoop, err := agent.NewAgentLoop(appCtx, cfg, msgBus, languageModel)
if err != nil { if err != nil {
fmt.Printf("Error initializing agent: %v\n", err) fmt.Printf("Error initializing agent: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer rt.Close()
agentLoop := rt.AgentLoop()
closeBus := setupSecureBus(agentLoop) closeBus := setupSecureBus(agentLoop)
defer closeBus() defer closeBus()
@ -586,27 +575,17 @@ func gatewayCmd() {
os.Exit(1) os.Exit(1)
} }
fantasyProvider, err := picofantasy.CreateProvider(cfg)
if err != nil {
fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1)
}
appCtx, cancel := context.WithCancel(context.Background()) appCtx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
languageModel, err := fantasyProvider.LanguageModel(appCtx, picofantasy.ModelID(cfg)) rt, err := bootstrapAgentRuntime(appCtx, cfg)
if err != nil {
fmt.Printf("Error creating language model: %v\n", err)
os.Exit(1)
}
msgBus := bus.NewMessageBus()
agentLoop, err := agent.NewAgentLoop(appCtx, cfg, msgBus, languageModel)
if err != nil { if err != nil {
fmt.Printf("Error initializing agent: %v\n", err) fmt.Printf("Error initializing agent: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer rt.Close()
agentLoop := rt.AgentLoop()
msgBus := rt.MessageBus()
closeBus := setupSecureBus(agentLoop) closeBus := setupSecureBus(agentLoop)
defer closeBus() defer closeBus()
@ -1555,7 +1534,15 @@ func setupCronTool(appCtx context.Context, agentLoop *agent.AgentLoop, msgBus *b
} }
func loadConfig() (*config.Config, error) { func loadConfig() (*config.Config, error) {
return config.LoadConfig(getConfigPath()) return picoruntime.LoadResolvedConfig(picoruntime.LoadConfigOptions{
BaseConfigPath: getConfigPath(),
})
}
func bootstrapAgentRuntime(appCtx context.Context, cfg *config.Config) (*picoruntime.RuntimeHandle, error) {
return picoruntime.Bootstrap(appCtx, cfg, picoruntime.BootstrapOptions{
OutboundMode: picoruntime.OutboundModeNone,
})
} }
// setupSecureBus wires the Isolated Tool Runtime into an AgentLoop. // setupSecureBus wires the Isolated Tool Runtime into an AgentLoop.

View file

@ -114,4 +114,5 @@ Create a new YAML file in `eval/cases/` following this pattern:
## Environment Variables ## Environment Variables
- `PICOCLAW_EVAL_CONFIG` - Path to picoclaw config.json for eval (defaults to `~/.picoclaw/config.json`) - `PICOCLAW_EVAL_CONFIG` - Optional overlay config path applied on top of user base config.
- Base config discovery uses XDG first (`~/.config/picoclaw/config.json`), then legacy (`~/.picoclaw/config.json`), then XDG fallback if neither exists.

View file

@ -34,7 +34,9 @@
} }
return false; return false;
}); });
return { pass: hasToolCall, score: hasToolCall ? 1.0 : 0.0, reason: hasToolCall ? 'used tool_call to dispatch read_file' : `no tool_call dispatch (tools: ${toolCalls.map(t=>t.tool).join(', ')})` }; const direct = toolCalls.some(t => t.tool === 'read_file');
const pass = hasToolCall || direct;
return { pass, score: hasToolCall ? 1.0 : (direct ? 0.8 : 0.0), reason: hasToolCall ? 'used tool_call to dispatch read_file' : (direct ? 'used direct read_file gateway' : `no read dispatch (tools: ${toolCalls.map(t=>t.tool).join(', ')})`) };
- type: javascript - type: javascript
value: | value: |
const trace = JSON.parse(output); const trace = JSON.parse(output);
@ -57,7 +59,9 @@
} }
return false; return false;
}); });
return { pass: hasToolCall, score: hasToolCall ? 1.0 : 0.0, reason: hasToolCall ? 'used tool_call for exec' : `no tool_call dispatch to exec (tools: ${toolCalls.map(t=>t.tool).join(', ')})` }; const direct = toolCalls.some(t => t.tool === 'exec');
const pass = hasToolCall || direct;
return { pass, score: hasToolCall ? 1.0 : (direct ? 0.8 : 0.0), reason: hasToolCall ? 'used tool_call for exec' : (direct ? 'used direct exec gateway' : `no exec dispatch (tools: ${toolCalls.map(t=>t.tool).join(', ')})`) };
- type: javascript - type: javascript
value: | value: |
const trace = JSON.parse(output); const trace = JSON.parse(output);

View file

@ -6,15 +6,14 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
fantasy "charm.land/fantasy" fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
picofantasy "github.com/sipeed/picoclaw/pkg/fantasy"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
picoruntime "github.com/sipeed/picoclaw/pkg/runtime"
) )
// Trace is the structured output emitted by the eval runner. // Trace is the structured output emitted by the eval runner.
@ -50,33 +49,45 @@ type Metrics struct {
func main() { func main() {
logger.SetLevel(logger.ERROR) logger.SetLevel(logger.ERROR)
prompt, err := readPrompt() prompt, err := resolvePrompt()
if err != nil { if err != nil {
emitError(fmt.Sprintf("failed to read prompt: %v", err)) emitError(fmt.Sprintf("failed to read prompt: %v", err))
return return
} }
if strings.TrimSpace(prompt) == "" { if empty := emptyPromptTrace(prompt); empty != nil {
trace := Trace{ emitTrace(*empty)
Output: "No prompt provided. Please provide a message.",
Metrics: Metrics{
TotalDurationMs: 0,
},
}
out, _ := json.Marshal(trace)
fmt.Println(string(out))
return return
} }
cfg, err := loadEvalConfig() cfg, err := resolveEvalConfig()
if err != nil { if err != nil {
emitError(fmt.Sprintf("config error: %v", err)) emitError(fmt.Sprintf("config error: %v", err))
return return
} }
trace := runEval(cfg, prompt) trace := runEval(cfg, prompt)
out, _ := json.Marshal(trace) emitTrace(trace)
fmt.Println(string(out)) }
func resolvePrompt() (string, error) {
return readPrompt()
}
func emptyPromptTrace(prompt string) *Trace {
if strings.TrimSpace(prompt) != "" {
return nil
}
return &Trace{
Output: "No prompt provided. Please provide a message.",
Metrics: Metrics{
TotalDurationMs: 0,
},
}
}
func resolveEvalConfig() (*config.Config, error) {
return picoruntime.LoadEvalConfig(evalRunnerTimeout())
} }
func readPrompt() (string, error) { func readPrompt() (string, error) {
@ -87,11 +98,8 @@ func readPrompt() (string, error) {
// promptfoo exec: provider passes the prompt as the first positional argument // promptfoo exec: provider passes the prompt as the first positional argument
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
raw := os.Args[1] raw := os.Args[1]
var promptData struct { if prompt, ok := parsePromptPayload([]byte(raw)); ok {
Prompt string `json:"prompt"` return prompt, nil
}
if json.Unmarshal([]byte(raw), &promptData) == nil && promptData.Prompt != "" {
return promptData.Prompt, nil
} }
return strings.TrimSpace(raw), nil return strings.TrimSpace(raw), nil
} }
@ -103,11 +111,8 @@ func readPrompt() (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
var promptData struct { if prompt, ok := parsePromptPayload(data); ok {
Prompt string `json:"prompt"` return prompt, nil
}
if json.Unmarshal(data, &promptData) == nil && promptData.Prompt != "" {
return promptData.Prompt, nil
} }
return strings.TrimSpace(string(data)), nil return strings.TrimSpace(string(data)), nil
} }
@ -115,121 +120,53 @@ func readPrompt() (string, error) {
return "", fmt.Errorf("no prompt provided (use positional arg, --prompt, or pipe to stdin)") return "", fmt.Errorf("no prompt provided (use positional arg, --prompt, or pipe to stdin)")
} }
// resolveBaseConfigPath returns the path to the user's config file, checking func parsePromptPayload(raw []byte) (string, bool) {
// candidate paths in order and returning the first one that exists. Falls back var payload struct {
// to the XDG-standard path even if the file is absent, matching the main Prompt string `json:"prompt"`
// picoclaw binary's behaviour.
func resolveBaseConfigPath() string {
// Prefer XDG standard path (~/.config/picoclaw/config.json).
if xdgPath, err := config.DefaultConfigPath(); err == nil {
if _, statErr := os.Stat(xdgPath); statErr == nil {
return xdgPath
} }
if err := json.Unmarshal(raw, &payload); err != nil || payload.Prompt == "" {
return "", false
} }
// Legacy path used before XDG migration. return payload.Prompt, true
home, _ := os.UserHomeDir()
legacy := home + "/.picoclaw/config.json"
if _, err := os.Stat(legacy); err == nil {
return legacy
}
// Neither exists; return the XDG path so LoadConfig returns defaults.
if xdgPath, err := config.DefaultConfigPath(); err == nil {
return xdgPath
}
return home + "/.picoclaw/config.json"
}
func loadEvalConfig() (*config.Config, error) {
// Always load the user's base config first so provider API keys,
// model selection, and other credentials are preserved.
cfg, err := config.LoadConfig(resolveBaseConfigPath())
if err != nil {
return nil, fmt.Errorf("load base config: %w", err)
}
// If an eval-specific override file is set, merge it on top of the base
// config. Only fields present in the overlay are changed; API keys etc.
// from the base config are preserved.
if overlayPath := os.Getenv("PICOCLAW_EVAL_CONFIG"); overlayPath != "" {
if err := config.OverlayConfigFile(cfg, overlayPath); err != nil {
return nil, fmt.Errorf("load eval overlay: %w", err)
}
}
return cfg, nil
} }
func runEval(cfg *config.Config, prompt string) Trace { func runEval(cfg *config.Config, prompt string) Trace {
start := time.Now() start := time.Now()
sessionKey := fmt.Sprintf("eval:%d", start.UnixNano()) sessionKey := picoruntime.NewSessionKey("eval", start)
runtime, initErr := newEvalRuntime(cfg)
fantasyProvider, err := picofantasy.CreateProvider(cfg) if initErr != nil {
if err != nil { return Trace{Error: initErr.Error()}
return Trace{Error: fmt.Sprintf("provider error: %v", err)}
} }
defer runtime.close()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) result := picoruntime.RunPrompt(runtime.handle.Context(), runtime.handle, prompt, sessionKey)
defer cancel()
languageModel, err := fantasyProvider.LanguageModel(ctx, picofantasy.ModelID(cfg)) duration := result.Duration
if err != nil { if duration <= 0 {
return Trace{Error: fmt.Sprintf("model error: %v", err)} duration = time.Since(start)
} }
instrumentedModel := &instrumentedLanguageModel{
inner: languageModel,
}
msgBus := bus.NewMessageBus()
// Drain outbound messages to prevent blocking
outDone := make(chan struct{})
go func() {
defer close(outDone)
for {
_, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
return
}
}
}()
agentLoop, err := agent.NewAgentLoop(ctx, cfg, msgBus, instrumentedModel)
if err != nil {
cancel()
<-outDone
return Trace{Error: fmt.Sprintf("agent loop init error: %v", err)}
}
defer agentLoop.Stop()
response, err := agentLoop.ProcessDirect(ctx, prompt, sessionKey)
cancel() // Signals context done, which stops outbound drain
<-outDone
duration := time.Since(start)
trace := Trace{ trace := Trace{
Output: response, Output: result.Output,
SessionKey: sessionKey, SessionKey: sessionKey,
Steps: buildSteps(instrumentedModel), Steps: buildSteps(runtime.instrumentedModel),
Metrics: Metrics{ Metrics: Metrics{
TotalDurationMs: duration.Milliseconds(), TotalDurationMs: duration.Milliseconds(),
StepCount: len(instrumentedModel.calls), StepCount: len(runtime.instrumentedModel.calls),
InputTokens: instrumentedModel.totalUsage.InputTokens, InputTokens: runtime.instrumentedModel.totalUsage.InputTokens,
OutputTokens: instrumentedModel.totalUsage.OutputTokens, OutputTokens: runtime.instrumentedModel.totalUsage.OutputTokens,
TotalTokens: instrumentedModel.totalUsage.TotalTokens, TotalTokens: runtime.instrumentedModel.totalUsage.TotalTokens,
ReasoningTokens: instrumentedModel.totalUsage.ReasoningTokens, ReasoningTokens: runtime.instrumentedModel.totalUsage.ReasoningTokens,
CacheReadTokens: instrumentedModel.totalUsage.CacheReadTokens, CacheReadTokens: runtime.instrumentedModel.totalUsage.CacheReadTokens,
}, },
} }
if err != nil { if result.Error != "" {
trace.Error = err.Error() trace.Error = result.Error
} }
for _, call := range instrumentedModel.calls { for _, call := range runtime.instrumentedModel.calls {
for range call.toolCalls { for range call.toolCalls {
trace.Metrics.ToolCallCount++ trace.Metrics.ToolCallCount++
} }
@ -238,6 +175,41 @@ func runEval(cfg *config.Config, prompt string) Trace {
return trace return trace
} }
type evalRuntime struct {
handle *picoruntime.RuntimeHandle
instrumentedModel *instrumentedLanguageModel
}
func newEvalRuntime(cfg *config.Config) (*evalRuntime, error) {
var instrumentedModel *instrumentedLanguageModel
handle, err := picoruntime.Bootstrap(context.Background(), cfg, picoruntime.BootstrapOptions{
Timeout: evalRunnerTimeout(),
OutboundMode: picoruntime.OutboundModeDrop,
WrapModel: func(inner fantasy.LanguageModel) fantasy.LanguageModel {
instrumentedModel = &instrumentedLanguageModel{inner: inner}
return instrumentedModel
},
})
if err != nil {
return nil, err
}
if instrumentedModel == nil {
handle.Close()
return nil, fmt.Errorf("instrumented model wrapper not initialized")
}
return &evalRuntime{
handle: handle,
instrumentedModel: instrumentedModel,
}, nil
}
func (r *evalRuntime) close() {
if r.handle != nil {
r.handle.Close()
}
}
func buildSteps(model *instrumentedLanguageModel) []TraceStep { func buildSteps(model *instrumentedLanguageModel) []TraceStep {
var steps []TraceStep var steps []TraceStep
idx := 0 idx := 0
@ -273,8 +245,24 @@ func truncate(s string, maxLen int) string {
return s[:maxLen] + "...[truncated]" return s[:maxLen] + "...[truncated]"
} }
func evalRunnerTimeout() time.Duration {
const defaultTimeout = 180 * time.Second
raw := strings.TrimSpace(os.Getenv("PICOCLAW_EVAL_TIMEOUT_MS"))
if raw == "" {
return defaultTimeout
}
ms, err := strconv.Atoi(raw)
if err != nil || ms <= 0 {
return defaultTimeout
}
return time.Duration(ms) * time.Millisecond
}
func emitError(msg string) { func emitError(msg string) {
trace := Trace{Error: msg} emitTrace(Trace{Error: msg})
}
func emitTrace(trace Trace) {
out, _ := json.Marshal(trace) out, _ := json.Marshal(trace)
fmt.Println(string(out)) fmt.Println(string(out))
} }

View file

@ -1,15 +1,20 @@
{ {
"tools": { "tools": {
"progressive_disclosure": false "web": {
"duckduckgo": {
"enabled": true,
"max_results": 5
}
}
}, },
"memory": { "memory": {
"enabled": true,
"db_path": ":memory:" "db_path": ":memory:"
}, },
"agents": { "agents": {
"defaults": { "defaults": {
"restrict_to_sandbox": true, "restrict_to_sandbox": true,
"max_tool_iterations": 20 "max_tool_iterations": 20,
"temperature": 0.1
} }
}, },
"heartbeat": { "heartbeat": {

View file

@ -11,6 +11,8 @@ providers:
label: "picoclaw" label: "picoclaw"
config: config:
timeout: 180000 timeout: 180000
env:
PICOCLAW_EVAL_CONFIG: "./configs/default.json"
# Default assertions applied to every test case # Default assertions applied to every test case
defaultTest: defaultTest: