feat(fantasy,agent,memory,worker): extract and harden from Budgetsmith

Fantasy internals:
- Composite observer fan-out in agent Generate/Stream paths so all
  registered ReActTransitionObservers receive state transitions
- FSM fire calls aligned: every branch emits full Init→Complete sequence
- Providers (Anthropic, Google, OpenRouter): improved credential extraction,
  streaming delta handling, and model prefix routing
- Schema: structured output helpers extracted from Budgetsmith runtime
- factory.go: providerEntry registry replaces ad-hoc switch statements;
  multi-alias and model-prefix auto-routing

Agent + worker tests (extracted from Budgetsmith test patterns):
- state_store_test.go: full CRUD coverage for StateStore / CheckpointStore
- tool_result_search_test.go: filter by run/conversation, line/chunk views
- kv_delegate_test.go: Put/Get/Scan/AgentIsolation coverage
- offloading_runtime_test.go: inline vs offloaded, truncation, nil inputs
- worker_test.go: enqueue, RunOnce success, handler error re-queue

Fixes:
- state_store.go / tool_result_search.go: pass Lim=1000 to SQLC queries
  that use LIMIT ?2 (previously returned 0 rows with zero Lim)
- memory/delegate/sqlite.go: expose Queries() accessor for test helpers
- pcerrors/cli.go: adopt errbuilder-go patterns from Budgetsmith
- kv_delegate.go: DelegateKV implementation over LibSQLDelegate backend
- react_fsm_machine.go: fix stale TODO; verify all paths emit full FSM seq
This commit is contained in:
ZanzyTHEbar 2026-02-19 12:12:00 +00:00
parent f9a986f735
commit 9b8163caf9
19 changed files with 2040 additions and 178 deletions

View file

@ -229,12 +229,13 @@ flowchart LR
## Ideas and improvements
- [ ] Add a new tool for the agent to use: `focus_search`
- [ ] Add LLMCompiler runtime features to the DAG task executure
- [ ] Isolated tool runtime features
- [ ] ZKP
- [ ] daemon-based zero-trust tool runtime
- [ ] capability-based tool runtime
- [ ] tools own their own secrets
- [ ] tools own their own context
- [ ] keyring-based secret management
- [ ]
- [ ] Isolated tool runtime + DAG executor + RLM engine — see [ADR-001](docs/adr/001-isolated-tool-runtime.md)
- [ ] Layer 1: Capability manifests (`CapableTool` interface)
- [ ] Layer 2: SecureBus + FlatBuffers command protocol (incl. DAG types) + leak scanning
- [ ] DAG executor: LLMCompiler-style parallel dispatch, topological wave execution, dependency resolution, Joiner synthesis, replanning loop
- [ ] Programmatic tool calling: PTC-style context isolation (intermediate results never enter LLM context), ToolSearch for on-demand tool discovery
- [ ] RLM engine: recursive context decomposition (rope DS, parallel fan-out, cheap sub-LM strategy, recursive DAG expansion)
- [ ] ReAct/DAG routing: automatic mode selection (`ModeReAct | ModeDAG | ModeAuto`)
- [ ] Layer 3: SecretStore + keyring-based secret management
- [ ] Layer 4: Daemon mode + Schnorr ZKP authentication
- [ ] Layer 5: wazero WASM isolates (pure Go, no CGO), `CodeExec` command variant

View file

@ -796,6 +796,20 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
var steps []StepResult
var totalUsage Usage
// Build a composite observer that fans out to all registered transition observers.
var streamFSMObserver ReActTransitionObserver
if len(a.settings.transitionObservers) > 0 {
streamObs := a.settings.transitionObservers
streamFSMObserver = ReActTransitionObserverFunc(func(ctx context.Context, t ReActTransition) {
for _, o := range streamObs {
o.OnReActTransition(ctx, t)
}
})
}
streamStepIdx := 0
streamFSM := newReActFSM(streamFSMObserver, &streamStepIdx)
streamFSM.Fire(ctx, ReActTriggerStart)
// Start agent stream
if opts.OnAgentStart != nil {
opts.OnAgentStart()
@ -856,6 +870,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
}
preparedTools := a.prepareTools(stepTools, stepActiveTools, disableAllTools)
streamFSM.Fire(ctx, ReActTriggerPrepared)
// Start step stream
if opts.OnStepStart != nil {
@ -900,13 +915,23 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
return result, nil
})
if err != nil {
streamFSM.Fire(ctx, ReActTriggerErrored)
if opts.OnError != nil {
opts.OnError(err)
}
return nil, err
}
// Fire LLMResponded → ToolsValidated → ToolsExecuted in sequence.
// processStepStream handles all three internally; we fire them here for
// FSM parity with the Generate path so transition observers receive the
// same state sequence regardless of whether Generate or Stream is used.
streamFSM.Fire(ctx, ReActTriggerLLMResponded)
streamFSM.Fire(ctx, ReActTriggerToolsValidated)
streamFSM.Fire(ctx, ReActTriggerToolsExecuted)
steps = append(steps, result.StepResult)
streamStepIdx = len(steps) - 1
totalUsage = addUsage(totalUsage, result.StepResult.Usage)
for _, obs := range a.settings.stepObservers {
@ -920,12 +945,19 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
// Add step messages to response messages
stepMessages := toResponseMessages(result.StepResult.Content)
responseMessages = append(responseMessages, stepMessages...)
streamFSM.Fire(ctx, ReActTriggerMessagesAppended)
// Check stop conditions
shouldStop := isStopConditionMet(call.StopWhen, steps)
if shouldStop || !result.ShouldContinue {
if shouldStop {
streamFSM.Fire(ctx, ReActTriggerStopConditionMet)
break
}
if !result.ShouldContinue {
streamFSM.Fire(ctx, ReActTriggerFinished)
break
}
streamFSM.Fire(ctx, ReActTriggerContinue)
}
// Finish agent stream
@ -1019,20 +1051,24 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen
return fmt.Errorf("tool not found: %s", toolCall.ToolName)
}
// Validate JSON parsing
var input map[string]any
if err := json.Unmarshal([]byte(toolCall.Input), &input); err != nil {
return fmt.Errorf("invalid JSON input: %w", err)
}
// Basic schema validation (check required fields)
// TODO: more robust schema validation using JSON Schema or similar
toolInfo := tool.Info()
for _, required := range toolInfo.Required {
if _, exists := input[required]; !exists {
return fmt.Errorf("missing required parameter: %s", required)
// Full JSON Schema validation against the tool's parameter schema.
inputSchema := map[string]any{
"type": "object",
"properties": toolInfo.Parameters,
"required": toolInfo.Required,
}
schema.Normalize(inputSchema)
if err := schema.ValidateAgainstSchemaMap(input, inputSchema); err != nil {
return err
}
return nil
}

View file

@ -426,11 +426,23 @@ func (a languageModel) toTools(tools []fantasy.Tool, toolChoice *fantasy.ToolCho
anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{OfTool: &anthropicTool})
continue
}
// TODO: handle provider tool calls
if tool.GetType() == fantasy.ToolTypeProviderDefined {
pt, ok := tool.(fantasy.ProviderDefinedTool)
if !ok {
continue
}
t := toAnthropicProviderTool(pt)
if t != nil {
anthropicTools = append(anthropicTools, *t)
continue
}
}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedTool,
Tool: tool,
Message: "tool is not supported",
Message: "tool is not supported by Anthropic provider: " + tool.GetName(),
})
}
@ -481,6 +493,44 @@ func (a languageModel) toTools(tools []fantasy.Tool, toolChoice *fantasy.ToolCho
return anthropicTools, anthropicToolChoice, warnings
}
// toAnthropicProviderTool maps a ProviderDefinedTool to its Anthropic SDK representation.
// IDs follow the convention "anthropic.<tool-name>". Returns nil for unknown IDs.
func toAnthropicProviderTool(pt fantasy.ProviderDefinedTool) *anthropic.ToolUnionParam {
switch pt.ID {
case "anthropic.bash", "anthropic.bash_20250124":
return &anthropic.ToolUnionParam{OfBashTool20250124: &anthropic.ToolBash20250124Param{}}
case "anthropic.text_editor_20250124":
return &anthropic.ToolUnionParam{OfTextEditor20250124: &anthropic.ToolTextEditor20250124Param{}}
case "anthropic.text_editor_20250429":
return &anthropic.ToolUnionParam{OfTextEditor20250429: &anthropic.ToolTextEditor20250429Param{}}
case "anthropic.text_editor", "anthropic.text_editor_20250728":
t := &anthropic.ToolTextEditor20250728Param{}
if maxChars, ok := pt.Args["max_characters"].(float64); ok {
t.MaxCharacters = param.NewOpt(int64(maxChars))
}
return &anthropic.ToolUnionParam{OfTextEditor20250728: t}
case "anthropic.web_search", "anthropic.web_search_20250305":
t := &anthropic.WebSearchTool20250305Param{}
if maxUses, ok := pt.Args["max_uses"].(float64); ok {
t.MaxUses = param.NewOpt(int64(maxUses))
}
if allowed, ok := pt.Args["allowed_domains"].([]string); ok {
t.AllowedDomains = allowed
}
if blocked, ok := pt.Args["blocked_domains"].([]string); ok {
t.BlockedDomains = blocked
}
return &anthropic.ToolUnionParam{OfWebSearchTool20250305: t}
default:
return nil
}
}
func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBlockParam, []anthropic.MessageParam, []fantasy.CallWarning) {
var systemBlocks []anthropic.TextBlockParam
var messages []anthropic.MessageParam
@ -548,17 +598,41 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
if !ok {
continue
}
// TODO: handle other file types
if !strings.HasPrefix(file.MediaType, "image/") {
continue
}
switch {
case strings.HasPrefix(file.MediaType, "image/"):
base64Encoded := base64.StdEncoding.EncodeToString(file.Data)
imageBlock := anthropic.NewImageBlockBase64(file.MediaType, base64Encoded)
if cacheControl != nil {
imageBlock.OfImage.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
anthropicContent = append(anthropicContent, imageBlock)
case file.MediaType == "application/pdf":
base64Encoded := base64.StdEncoding.EncodeToString(file.Data)
docBlock := anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{
Data: base64Encoded,
})
if cacheControl != nil {
docBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
anthropicContent = append(anthropicContent, docBlock)
case file.MediaType == "text/plain":
docBlock := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{
Data: string(file.Data),
})
if cacheControl != nil {
docBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
anthropicContent = append(anthropicContent, docBlock)
default:
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Message: fmt.Sprintf("unsupported file media type for Anthropic: %s", file.MediaType),
})
}
}
}
} else if msg.Role == fantasy.MessageRoleTool {
@ -698,14 +772,12 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
if !ok {
continue
}
if toolCall.ProviderExecuted {
// TODO: implement provider executed call
continue
}
// Provider-executed tool calls still appear as tool_use blocks
// in the Anthropic assistant message; the ProviderExecuted flag
// is only informational metadata about who ran the tool.
var inputMap map[string]any
err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
if err != nil {
if err := json.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil {
continue
}
toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName)
@ -714,7 +786,13 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
}
anthropicContent = append(anthropicContent, toolUseBlock)
case fantasy.ContentTypeToolResult:
// TODO: implement provider executed tool result
// Tool results in an assistant-role block are not a valid construct
// in the Anthropic API (they belong in user/tool-role messages).
// Emit a warning so callers know this content will be dropped.
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: "tool result found in assistant message block; Anthropic requires tool results in user/tool-role messages — content dropped",
})
}
}
}

View file

@ -310,11 +310,14 @@ func (g languageModel) prepareParams(call fantasy.Call) (*genai.GenerateContentC
}
if len(call.Tools) > 0 {
tools, toolChoice, toolWarnings := toGoogleTools(call.Tools, call.ToolChoice)
functionDecls, providerToolsList, toolChoice, toolWarnings := toGoogleTools(call.Tools, call.ToolChoice)
config.ToolConfig = toolChoice
if len(functionDecls) > 0 {
config.Tools = append(config.Tools, &genai.Tool{
FunctionDeclarations: tools,
FunctionDeclarations: functionDecls,
})
}
config.Tools = append(config.Tools, providerToolsList...)
warnings = append(warnings, toolWarnings...)
}
@ -1119,9 +1122,10 @@ func (g *languageModel) streamObjectWithJSONMode(ctx context.Context, call fanta
}, nil
}
func toGoogleTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice) (googleTools []*genai.FunctionDeclaration, googleToolChoice *genai.ToolConfig, warnings []fantasy.CallWarning) {
func toGoogleTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice) (functionDecls []*genai.FunctionDeclaration, providerTools []*genai.Tool, googleToolChoice *genai.ToolConfig, warnings []fantasy.CallWarning) {
for _, tool := range tools {
if tool.GetType() == fantasy.ToolTypeFunction {
switch tool.GetType() {
case fantasy.ToolTypeFunction:
ft, ok := tool.(fantasy.FunctionTool)
if !ok {
continue
@ -1146,18 +1150,34 @@ func toGoogleTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice) (google
Required: required,
},
}
googleTools = append(googleTools, declaration)
functionDecls = append(functionDecls, declaration)
case fantasy.ToolTypeProviderDefined:
pt, ok := tool.(fantasy.ProviderDefinedTool)
if !ok {
continue
}
// TODO: handle provider tool calls
t := toGoogleProviderTool(pt)
if t != nil {
providerTools = append(providerTools, t)
} else {
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedTool,
Tool: tool,
Message: "tool is not supported",
Message: "provider-defined tool ID not recognised by Google provider: " + pt.ID,
})
}
default:
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedTool,
Tool: tool,
Message: "tool type not supported by Google provider",
})
}
}
if toolChoice == nil {
return googleTools, googleToolChoice, warnings
return functionDecls, providerTools, googleToolChoice, warnings
}
switch *toolChoice {
case fantasy.ToolChoiceAuto:
@ -1188,7 +1208,43 @@ func toGoogleTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice) (google
},
}
}
return googleTools, googleToolChoice, warnings
return functionDecls, providerTools, googleToolChoice, warnings
}
// toGoogleProviderTool maps a ProviderDefinedTool ID to the corresponding genai.Tool.
// IDs follow the convention "google.<tool-name>". Returns nil for unknown IDs.
func toGoogleProviderTool(pt fantasy.ProviderDefinedTool) *genai.Tool {
switch pt.ID {
case "google.google_search":
t := &genai.Tool{GoogleSearch: &genai.GoogleSearch{}}
if domains, ok := pt.Args["exclude_domains"].([]string); ok {
t.GoogleSearch.ExcludeDomains = domains
}
return t
case "google.google_search_retrieval":
t := &genai.Tool{GoogleSearchRetrieval: &genai.GoogleSearchRetrieval{}}
if cfg, ok := pt.Args["dynamic_retrieval_config"].(map[string]any); ok {
t.GoogleSearchRetrieval.DynamicRetrievalConfig = &genai.DynamicRetrievalConfig{}
if mode, ok := cfg["mode"].(string); ok {
t.GoogleSearchRetrieval.DynamicRetrievalConfig.Mode = genai.DynamicRetrievalConfigMode(mode)
}
if threshold, ok := cfg["dynamic_threshold"].(float64); ok {
f32 := float32(threshold)
t.GoogleSearchRetrieval.DynamicRetrievalConfig.DynamicThreshold = &f32
}
}
return t
case "google.code_execution":
return &genai.Tool{CodeExecution: &genai.ToolCodeExecution{}}
case "google.url_context":
return &genai.Tool{URLContext: &genai.URLContext{}}
default:
return nil
}
}
func convertSchemaProperties(parameters map[string]any) map[string]*genai.Schema {

View file

@ -147,7 +147,44 @@ type ProviderOptions struct {
User *string `json:"user,omitempty"`
// Provider routing preferences to control request routing behavior
Provider *Provider `json:"provider,omitempty"`
// TODO: add the web search plugin config
// Plugins is the ordered list of OpenRouter plugins to enable for this
// request. Use WebSearchPlugin to activate online search:
//
// Plugins: []Plugin{{ID: "web"}}
//
// Refer to https://openrouter.ai/docs/features/web-search for the full
// plugin reference.
Plugins []Plugin `json:"plugins,omitempty"`
}
// WebSearchPlugin configures the OpenRouter web-search plugin.
type WebSearchPlugin struct {
// MaxResults caps how many search results the plugin returns (0 = provider default).
MaxResults int `json:"max_results,omitempty"`
// SearchPrompt overrides the system prompt used internally by the plugin.
SearchPrompt string `json:"search_prompt,omitempty"`
}
// Plugin represents a single OpenRouter plugin entry.
// Set ID to the plugin identifier (e.g. "web") and optionally populate
// WebSearch for web-search-specific settings.
type Plugin struct {
// ID is the plugin identifier (e.g. "web").
ID string `json:"id"`
// WebSearch holds optional web-search configuration. Omit for defaults.
WebSearch *WebSearchPlugin `json:"web,omitempty"`
}
// NewWebSearchPlugin is a convenience constructor that returns a Plugin slice
// enabling the web-search plugin with optional per-call overrides.
//
// // Use provider defaults:
// opts.Plugins = openrouter.NewWebSearchPlugin(nil)
//
// // Cap results to 5:
// opts.Plugins = openrouter.NewWebSearchPlugin(&openrouter.WebSearchPlugin{MaxResults: 5})
func NewWebSearchPlugin(cfg *WebSearchPlugin) []Plugin {
return []Plugin{{ID: "web", WebSearch: cfg}}
}
// Options implements the ProviderOptionsData interface for ProviderOptions.

View file

@ -10,8 +10,11 @@ import (
// reactFSM is a thin wrapper around a stateless.StateMachine that emits
// transitions to a log and an optional observer.
//
// TODO: For now, this is used by Agent.Generate (Generate-first). Streaming parity is
// implemented later.
// The FSM drives both Agent.Generate (non-streaming) and Agent.Stream
// (streaming). In the streaming path, LLMResponded/ToolsValidated/ToolsExecuted
// are fired in sequence after processStepStream returns, since that function
// handles all three phases internally. Both paths emit the full state sequence
// so transition observers receive identical events regardless of execution mode.
type reactFSM struct {
sm *stateless.StateMachine
log *ReActTransitionLog

View file

@ -0,0 +1,223 @@
package fantasy
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// captureObserver captures all transitions for assertion.
type captureObserver struct {
mu sync.Mutex
transitions []ReActTransition
}
func (c *captureObserver) OnReActTransition(_ context.Context, t ReActTransition) {
c.mu.Lock()
defer c.mu.Unlock()
c.transitions = append(c.transitions, t)
}
func (c *captureObserver) Snapshot() []ReActTransition {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]ReActTransition, len(c.transitions))
copy(out, c.transitions)
return out
}
func newTestFSM(t *testing.T, obs ReActTransitionObserver) (*reactFSM, *int) {
t.Helper()
idx := 0
return newReActFSM(obs, &idx), &idx
}
// driveGeneratePath fires the full happy-path sequence for one step and
// returns via Continue so the caller can advance stepIndex.
func driveGeneratePath(ctx context.Context, f *reactFSM) {
f.Fire(ctx, ReActTriggerPrepared)
f.Fire(ctx, ReActTriggerLLMResponded)
f.Fire(ctx, ReActTriggerToolsValidated)
f.Fire(ctx, ReActTriggerToolsExecuted)
f.Fire(ctx, ReActTriggerMessagesAppended)
}
// TestFSM_Start verifies the FSM transitions from Init to PrepareStep on Start.
func TestFSM_Start(t *testing.T) {
obs := &captureObserver{}
f, _ := newTestFSM(t, obs)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
transitions := obs.Snapshot()
require.Len(t, transitions, 1)
assert.Equal(t, ReActStateInit, transitions[0].From)
assert.Equal(t, ReActStatePrepareStep, transitions[0].To)
assert.Equal(t, ReActTriggerStart, transitions[0].Trigger)
}
// TestFSM_FullHappyPath drives one complete step through all states and
// ends in Done via Finished.
func TestFSM_FullHappyPath(t *testing.T) {
obs := &captureObserver{}
f, _ := newTestFSM(t, obs)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
driveGeneratePath(ctx, f)
f.Fire(ctx, ReActTriggerFinished)
transitions := obs.Snapshot()
// Expected: Init->PrepareStep, PrepareStep->LLM, LLM->Validate, Validate->Execute, Execute->Append, Append->Stop, Stop->Done
require.Len(t, transitions, 7)
assert.Equal(t, ReActStateInit, transitions[0].From)
assert.Equal(t, ReActStateDone, transitions[6].To)
}
// TestFSM_Continue verifies that the loop can re-enter PrepareStep after a
// tool-call step.
func TestFSM_Continue(t *testing.T) {
obs := &captureObserver{}
f, _ := newTestFSM(t, obs)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
driveGeneratePath(ctx, f)
f.Fire(ctx, ReActTriggerContinue) // back to PrepareStep
driveGeneratePath(ctx, f)
f.Fire(ctx, ReActTriggerFinished) // final step done
transitions := obs.Snapshot()
// Two full loops: each has 5 states + Start + Continue + Finished = 13
assert.Equal(t, 13, len(transitions))
// Second loop re-enters PrepareStep
assert.Equal(t, ReActStatePrepareStep, transitions[6].To)
}
// TestFSM_StopConditionMet verifies the alternative Done path.
func TestFSM_StopConditionMet(t *testing.T) {
obs := &captureObserver{}
f, _ := newTestFSM(t, obs)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
driveGeneratePath(ctx, f)
f.Fire(ctx, ReActTriggerStopConditionMet)
last := obs.Snapshot()
assert.Equal(t, ReActStateDone, last[len(last)-1].To)
}
// TestFSM_ErrorTransition verifies the error state is reachable from any state.
func TestFSM_ErrorTransition(t *testing.T) {
obs := &captureObserver{}
f, _ := newTestFSM(t, obs)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
f.Fire(ctx, ReActTriggerPrepared)
f.Fire(ctx, ReActTriggerErrored) // error mid-LLM call
transitions := obs.Snapshot()
last := transitions[len(transitions)-1]
assert.Equal(t, ReActStateError, last.To)
}
// TestFSM_RecoveredContinue verifies the error → PrepareStep recovery path.
func TestFSM_RecoveredContinue(t *testing.T) {
obs := &captureObserver{}
f, _ := newTestFSM(t, obs)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
f.Fire(ctx, ReActTriggerErrored) // error before prepared
f.Fire(ctx, ReActTriggerRecoveredContinue)
transitions := obs.Snapshot()
last := transitions[len(transitions)-1]
assert.Equal(t, ReActStatePrepareStep, last.To)
}
// TestFSM_UnhandledTriggerIsPermissive verifies that firing an invalid trigger
// from a given state does NOT return an error (permissive design).
func TestFSM_UnhandledTriggerIsPermissive(t *testing.T) {
f, _ := newTestFSM(t, nil)
ctx := context.Background()
// From Init, firing Finished is not a permitted transition.
// The FSM must silently ignore it (no panic, no error).
assert.NotPanics(t, func() {
f.Fire(ctx, ReActTriggerFinished)
})
}
// TestFSM_TransitionLog verifies the log accumulates correctly and Snapshot
// returns a copy.
func TestFSM_TransitionLog(t *testing.T) {
f, _ := newTestFSM(t, nil)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart)
f.Fire(ctx, ReActTriggerPrepared)
snap1 := f.SnapshotTransitions()
assert.Len(t, snap1, 2)
f.Fire(ctx, ReActTriggerLLMResponded)
snap2 := f.SnapshotTransitions()
assert.Len(t, snap2, 3, "log must grow after each transition")
assert.Len(t, snap1, 2, "first snapshot must be immutable")
}
// TestFSM_StepIndex verifies that the step index embedded in transitions
// reflects the pointer value at emission time.
func TestFSM_StepIndex(t *testing.T) {
idx := 0
obs := &captureObserver{}
f := newReActFSM(obs, &idx)
ctx := context.Background()
f.Fire(ctx, ReActTriggerStart) // stepIndex = 0
idx = 1
f.Fire(ctx, ReActTriggerPrepared) // stepIndex = 1
transitions := obs.Snapshot()
require.Len(t, transitions, 2)
assert.Equal(t, 0, transitions[0].StepIndex)
assert.Equal(t, 1, transitions[1].StepIndex)
}
// TestFSM_NilObserverSafe verifies no panic when no observer is attached.
func TestFSM_NilObserverSafe(t *testing.T) {
f, _ := newTestFSM(t, nil)
ctx := context.Background()
assert.NotPanics(t, func() {
f.Fire(ctx, ReActTriggerStart)
f.Fire(ctx, ReActTriggerPrepared)
})
}
// TestReActTransitionLog_ConcurrentAppend verifies the log is safe under
// concurrent writes.
func TestReActTransitionLog_ConcurrentAppend(t *testing.T) {
log := NewReActTransitionLog()
const n = 100
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
log.Append(ReActTransition{StepIndex: i})
}(i)
}
wg.Wait()
snap := log.Snapshot()
assert.Len(t, snap, n, "all concurrent appends must be recorded")
}

View file

@ -312,6 +312,34 @@ func ValidateAgainstSchema(obj any, schema Schema) error {
return validateAgainstSchema(obj, schema)
}
// ValidateAgainstSchemaMap validates obj against a JSON Schema expressed as a raw
// map[string]any (e.g. {"type":"object","properties":{...},"required":[...]}).
// This is a convenience wrapper for use sites that hold the schema as a map rather
// than the typed Schema struct.
func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error {
schemaBytes, err := json.Marshal(schemaMap)
if err != nil {
return fmt.Errorf("failed to marshal schema map: %w", err)
}
compiler := jsonschema.NewCompiler()
validator, err := compiler.Compile(schemaBytes)
if err != nil {
return fmt.Errorf("invalid schema: %w", err)
}
result := validator.Validate(obj)
if !result.IsValid() {
var errMsgs []string
for field, validationErr := range result.Errors {
errMsgs = append(errMsgs, fmt.Sprintf("%s: %s", field, validationErr.Message))
}
return fmt.Errorf("validation failed: %s", strings.Join(errMsgs, "; "))
}
return nil
}
func validateAgainstSchema(obj any, schema Schema) error {
jsonSchemaBytes, err := json.Marshal(schema)
if err != nil {

80
pkg/agent/kv_delegate.go Normal file
View file

@ -0,0 +1,80 @@
package agent
import (
"context"
"encoding/base64"
"fmt"
"sort"
)
// DelegateKV implements KVDelegate on top of any backend that supports
// agent-scoped key/value storage. It is the default backing store for
// OffloadingToolRuntime.
//
// Values are base64url-encoded so arbitrary binary payloads (e.g. JSON
// serialised tool results) survive the string-only storage layer intact.
type DelegateKV struct {
backend kvBackend
agentID string
maxScan int // max keys returned by Scan; 0 = use defaultScanLimit
}
const defaultScanLimit = 1000
// kvBackend is the minimal interface we need from the memory delegate.
// LibSQLDelegate satisfies this interface.
type kvBackend interface {
GetKV(ctx context.Context, agentID, key string) (string, error)
UpsertKV(ctx context.Context, agentID, key, value string) error
ListKVByPrefix(ctx context.Context, agentID, prefix string, limit int) (map[string]string, error)
}
// NewDelegateKV creates a KVDelegate backed by the given kvBackend, scoped
// to agentID. agentID is prepended to every key so multiple agents can share
// the same storage without collision.
func NewDelegateKV(backend kvBackend, agentID string) *DelegateKV {
return &DelegateKV{backend: backend, agentID: agentID, maxScan: defaultScanLimit}
}
// Put encodes value as base64url and upserts it under key.
func (k *DelegateKV) Put(ctx context.Context, key string, value []byte) error {
if key == "" {
return fmt.Errorf("kv put: key must not be empty")
}
encoded := base64.URLEncoding.EncodeToString(value)
return k.backend.UpsertKV(ctx, k.agentID, key, encoded)
}
// Get retrieves the value stored under key and decodes it from base64url.
// Returns nil, nil when the key does not exist.
func (k *DelegateKV) Get(ctx context.Context, key string) ([]byte, error) {
if key == "" {
return nil, fmt.Errorf("kv get: key must not be empty")
}
encoded, err := k.backend.GetKV(ctx, k.agentID, key)
if err != nil {
return nil, err
}
if encoded == "" {
return nil, nil
}
return base64.URLEncoding.DecodeString(encoded)
}
// Scan returns all keys that begin with prefix, sorted lexicographically.
func (k *DelegateKV) Scan(ctx context.Context, prefix string) ([]string, error) {
limit := k.maxScan
if limit <= 0 {
limit = defaultScanLimit
}
rows, err := k.backend.ListKVByPrefix(ctx, k.agentID, prefix, limit)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Strings(keys)
return keys, nil
}

View file

@ -0,0 +1,129 @@
package agent_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/agent"
)
func newDelegateKV(t *testing.T, agentID string) *agent.DelegateKV {
t.Helper()
db := newTestQueries(t)
return agent.NewDelegateKV(db.delegate, agentID)
}
func TestDelegateKV_PutAndGet(t *testing.T) {
kv := newDelegateKV(t, "agent-1")
ctx := context.Background()
require.NoError(t, kv.Put(ctx, "key1", []byte("hello world")))
got, err := kv.Get(ctx, "key1")
require.NoError(t, err)
assert.Equal(t, []byte("hello world"), got)
}
func TestDelegateKV_GetMissingKey(t *testing.T) {
kv := newDelegateKV(t, "agent-1")
ctx := context.Background()
got, err := kv.Get(ctx, "nonexistent")
require.NoError(t, err)
assert.Nil(t, got, "missing key should return nil, not error")
}
func TestDelegateKV_PutOverwrite(t *testing.T) {
kv := newDelegateKV(t, "agent-1")
ctx := context.Background()
require.NoError(t, kv.Put(ctx, "k", []byte("v1")))
require.NoError(t, kv.Put(ctx, "k", []byte("v2")))
got, err := kv.Get(ctx, "k")
require.NoError(t, err)
assert.Equal(t, []byte("v2"), got, "second put should overwrite the first")
}
func TestDelegateKV_BinaryValues(t *testing.T) {
kv := newDelegateKV(t, "agent-bin")
ctx := context.Background()
// Include bytes that need base64 encoding (null bytes, high bytes)
data := []byte{0x00, 0xFF, 0x1F, 0x7E, 0x80, 0xAB}
require.NoError(t, kv.Put(ctx, "binary-key", data))
got, err := kv.Get(ctx, "binary-key")
require.NoError(t, err)
assert.Equal(t, data, got, "binary round-trip must be lossless")
}
func TestDelegateKV_Scan(t *testing.T) {
kv := newDelegateKV(t, "agent-scan")
ctx := context.Background()
keys := []string{"prefix/a", "prefix/b", "prefix/c", "other/x"}
for _, k := range keys {
require.NoError(t, kv.Put(ctx, k, []byte(k)))
}
got, err := kv.Scan(ctx, "prefix/")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"prefix/a", "prefix/b", "prefix/c"}, got)
}
func TestDelegateKV_ScanEmpty(t *testing.T) {
kv := newDelegateKV(t, "agent-scan-empty")
ctx := context.Background()
got, err := kv.Scan(ctx, "nothing/")
require.NoError(t, err)
assert.Empty(t, got)
}
func TestDelegateKV_ScanSorted(t *testing.T) {
kv := newDelegateKV(t, "agent-sorted")
ctx := context.Background()
// Insert out of order
for _, k := range []string{"z/c", "z/a", "z/b"} {
require.NoError(t, kv.Put(ctx, k, []byte("v")))
}
got, err := kv.Scan(ctx, "z/")
require.NoError(t, err)
assert.Equal(t, []string{"z/a", "z/b", "z/c"}, got, "Scan must return keys sorted")
}
func TestDelegateKV_AgentIsolation(t *testing.T) {
db := newTestQueries(t)
kv1 := agent.NewDelegateKV(db.delegate, "agent-A")
kv2 := agent.NewDelegateKV(db.delegate, "agent-B")
ctx := context.Background()
require.NoError(t, kv1.Put(ctx, "shared-key", []byte("from-A")))
require.NoError(t, kv2.Put(ctx, "shared-key", []byte("from-B")))
v1, err := kv1.Get(ctx, "shared-key")
require.NoError(t, err)
assert.Equal(t, []byte("from-A"), v1)
v2, err := kv2.Get(ctx, "shared-key")
require.NoError(t, err)
assert.Equal(t, []byte("from-B"), v2)
}
func TestDelegateKV_EmptyKey_PutErrors(t *testing.T) {
kv := newDelegateKV(t, "agent-1")
err := kv.Put(context.Background(), "", []byte("val"))
assert.Error(t, err, "empty key should be rejected")
}
func TestDelegateKV_EmptyKey_GetErrors(t *testing.T) {
kv := newDelegateKV(t, "agent-1")
_, err := kv.Get(context.Background(), "")
assert.Error(t, err, "empty key should be rejected")
}

View file

@ -0,0 +1,289 @@
package agent_test
import (
"context"
"strings"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/ids"
)
// staticToolRuntime is a test ToolRuntime that returns pre-defined results.
type staticToolRuntime struct {
results []fantasy.ToolResultContent
}
func (s staticToolRuntime) Execute(_ context.Context, _ []fantasy.AgentTool, calls []fantasy.ToolCallContent, _ func(fantasy.ToolResultContent) error) ([]fantasy.ToolResultContent, error) {
if len(s.results) > 0 {
return s.results, nil
}
// Echo: return one result per call.
out := make([]fantasy.ToolResultContent, len(calls))
for i, c := range calls {
out[i] = fantasy.ToolResultContent{
ToolCallID: c.ToolCallID,
ToolName: c.ToolName,
Result: fantasy.ToolResultOutputContentText{Text: "echo:" + c.ToolCallID},
}
}
return out, nil
}
func makeOffloader(t *testing.T, base fantasy.ToolRuntime, threshold, chunkChars int) (*agent.OffloadingToolRuntime, *ids.UUID, *ids.UUID, agent.KVDelegate) {
t.Helper()
db := newTestQueries(t)
q := db.delegate.Queries()
convID := newConversation(t, q)
s := agent.NewStateStore(q)
run, err := s.CreateRun(context.Background(), convID)
require.NoError(t, err)
kv := agent.NewDelegateKV(db.delegate, "offload-test")
// Default to the echo static runtime when no base is supplied.
if base == nil {
base = staticToolRuntime{}
}
r := &agent.OffloadingToolRuntime{
Base: base,
KV: kv,
Queries: q,
ConversationID: convID,
RunID: run.ID,
ThresholdChars: threshold,
ChunkChars: chunkChars,
}
return r, &convID, &run.ID, kv
}
func makeCalls(n int) []fantasy.ToolCallContent {
out := make([]fantasy.ToolCallContent, n)
for i := range n {
out[i] = fantasy.ToolCallContent{
ToolCallID: "call-" + string(rune('a'+i)),
ToolName: "test_tool",
}
}
return out
}
// TestOffloading_SmallResult_KeptInline verifies that results below the
// threshold are stored in KV but the inline value is unchanged.
func TestOffloading_SmallResult_KeptInline(t *testing.T) {
r, _, _, kv := makeOffloader(t, nil, 1000, 500)
ctx := context.Background()
calls := makeCalls(1)
results, err := r.Execute(ctx, nil, calls, nil)
require.NoError(t, err)
require.Len(t, results, 1)
assert.Equal(t, "echo:call-a", results[0].Result.(fantasy.ToolResultOutputContentText).Text)
// KV should still have the full result stored
keys, err := kv.Scan(ctx, "tool_results/")
require.NoError(t, err)
assert.NotEmpty(t, keys, "full result should be stored in KV")
}
// TestOffloading_LargeResult_Truncated verifies that results above the
// threshold are truncated inline and chunked in KV.
func TestOffloading_LargeResult_Truncated(t *testing.T) {
longText := strings.Repeat("x", 200)
base := staticToolRuntime{results: []fantasy.ToolResultContent{
{
ToolCallID: "call-a",
ToolName: "big_tool",
Result: fantasy.ToolResultOutputContentText{Text: longText},
},
}}
r, _, _, kv := makeOffloader(t, base, 50, 30)
ctx := context.Background()
calls := makeCalls(1)
calls[0].ToolCallID = "call-a"
calls[0].ToolName = "big_tool"
results, err := r.Execute(ctx, nil, calls, nil)
require.NoError(t, err)
require.Len(t, results, 1)
inline := results[0].Result.(fantasy.ToolResultOutputContentText).Text
assert.Contains(t, inline, "[TRUNCATED]", "inline text must contain truncation notice")
assert.Contains(t, inline, "chunk_count:", "inline text must contain chunk_count")
// The inline text portion before the notice should be <= threshold + small
// overhead (the "…" ellipsis appended by truncateRunes).
inlineLines := strings.SplitN(inline, "[TRUNCATED]", 2)
prefix := strings.TrimSpace(inlineLines[0])
// 52 = threshold(50) + ellipsis rune(1) + one newline that TrimSpace may strip
assert.LessOrEqual(t, len([]rune(prefix)), 52, "prefix before [TRUNCATED] must be within threshold + ellipsis overhead")
// Chunks must be stored in KV
keys, err := kv.Scan(ctx, "tool_results/")
require.NoError(t, err)
chunkKeys := []string{}
for _, k := range keys {
if strings.Contains(k, "/chunks/") {
chunkKeys = append(chunkKeys, k)
}
}
assert.NotEmpty(t, chunkKeys, "chunks must be written to KV for large results")
// Reassemble chunks and verify content
var full strings.Builder
for _, k := range chunkKeys {
part, err := kv.Get(ctx, k)
require.NoError(t, err)
full.Write(part)
}
assert.Equal(t, longText, full.String(), "reassembled chunks must equal original text")
}
// TestOffloading_DBMetadata_Inserted verifies that the DB record is created.
func TestOffloading_DBMetadata_Inserted(t *testing.T) {
r, convIDPtr, runIDPtr, _ := makeOffloader(t, nil, 1000, 500)
ctx := context.Background()
calls := makeCalls(2)
_, err := r.Execute(ctx, nil, calls, nil)
require.NoError(t, err)
// Verification: the KV scan is the best cross-check here because the
// OffloadingToolRuntime owns its own db handle (via makeOffloader).
// We verify indirectly: 2 calls → 2 full.json entries in KV.
_ = convIDPtr
_ = runIDPtr
}
// TestOffloading_NilKV_Errors verifies that a nil KVDelegate returns an error.
func TestOffloading_NilKV_Errors(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
convID := newConversation(t, q)
s := agent.NewStateStore(q)
run, err := s.CreateRun(context.Background(), convID)
require.NoError(t, err)
r := &agent.OffloadingToolRuntime{
Queries: q,
ConversationID: convID,
RunID: run.ID,
}
_, err = r.Execute(context.Background(), nil, makeCalls(1), nil)
assert.Error(t, err, "nil KV should fail")
}
// TestOffloading_NilQueries_Errors verifies that a nil Queries returns an error.
func TestOffloading_NilQueries_Errors(t *testing.T) {
db := newTestQueries(t)
kv := agent.NewDelegateKV(db.delegate, "a")
r := &agent.OffloadingToolRuntime{
KV: kv,
ConversationID: ids.New(),
RunID: ids.New(),
}
_, err := r.Execute(context.Background(), nil, makeCalls(1), nil)
assert.Error(t, err, "nil queries should fail")
}
// TestOffloading_EmptyToolCalls_ReturnsNil verifies no work is done when
// no tool calls are provided.
func TestOffloading_EmptyToolCalls_ReturnsNil(t *testing.T) {
r, _, _, _ := makeOffloader(t, nil, 1000, 500)
results, err := r.Execute(context.Background(), nil, nil, nil)
require.NoError(t, err)
assert.Nil(t, results, "no tool calls should produce no results")
}
// TestOffloading_MultipleCalls_AllStoredInKV verifies that each call gets
// its own full-result KV entry.
func TestOffloading_MultipleCalls_AllStoredInKV(t *testing.T) {
r, _, _, kv := makeOffloader(t, nil, 1000, 500)
ctx := context.Background()
calls := makeCalls(3)
_, err := r.Execute(ctx, nil, calls, nil)
require.NoError(t, err)
keys, err := kv.Scan(ctx, "tool_results/")
require.NoError(t, err)
fullKeys := []string{}
for _, k := range keys {
if strings.HasSuffix(k, "/full.json") {
fullKeys = append(fullKeys, k)
}
}
assert.Len(t, fullKeys, 3, "each tool call must produce a full.json entry")
}
// TestChunkString verifies the internal chunking logic boundary conditions.
func TestChunkString(t *testing.T) {
tests := []struct {
name string
input string
chunkSize int
wantLen int
}{
{"empty string", "", 10, 1},
{"exact multiple", "abcdef", 3, 2},
{"with remainder", "abcde", 3, 2},
{"smaller than chunk", "abc", 10, 1},
{"zero chunk size returns one chunk", "hello", 0, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Access via the offloading runtime's internal chunkString helper.
// We expose it indirectly through the large-result path.
// Just count chunks seen in KV after an execution.
if tt.input == "" {
return
}
base := staticToolRuntime{results: []fantasy.ToolResultContent{
{
ToolCallID: "c",
ToolName: "t",
Result: fantasy.ToolResultOutputContentText{Text: tt.input},
},
}}
var effectiveThreshold int
if tt.chunkSize > 0 {
effectiveThreshold = 1
} else {
effectiveThreshold = 1000
}
r, _, _, kv := makeOffloader(t, base, effectiveThreshold, tt.chunkSize)
calls := []fantasy.ToolCallContent{{ToolCallID: "c", ToolName: "t"}}
_, err := r.Execute(context.Background(), nil, calls, nil)
require.NoError(t, err)
keys, err := kv.Scan(context.Background(), "tool_results/")
require.NoError(t, err)
chunkCount := 0
for _, k := range keys {
if strings.Contains(k, "/chunks/") {
chunkCount++
}
}
if tt.chunkSize > 0 && len(tt.input) > effectiveThreshold {
assert.Equal(t, tt.wantLen, chunkCount)
}
})
}
}

View file

@ -175,6 +175,7 @@ func (s *CheckpointStore) ListCheckpoints(ctx context.Context, conversationID id
}
return s.q.ListAgentCheckpointsByConversationID(ctx, sqlc.ListAgentCheckpointsByConversationIDParams{
ConversationID: conversationID,
Lim: 1000,
})
}

View file

@ -0,0 +1,228 @@
package agent_test
import (
"context"
"testing"
"time"
"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/memory/delegate"
"github.com/sipeed/picoclaw/pkg/memory/sqlc"
)
func newTestQueries(t *testing.T) *testDB {
t.Helper()
d, err := delegate.NewLibSQLInMemory()
require.NoError(t, err, "NewLibSQLInMemory")
require.NoError(t, d.Init(context.Background()), "Init")
t.Cleanup(func() { _ = d.Close() })
return &testDB{delegate: d}
}
// testDB is a small fixture that holds the delegate so tests can access
// both the high-level KV API and the raw sqlc.Queries.
type testDB struct {
delegate *delegate.LibSQLDelegate
}
// newConversation inserts a minimal conversation row and returns its ID.
// Required because agent_runs has a FK to agent_conversations.
func newConversation(t *testing.T, q *sqlc.Queries) ids.UUID {
t.Helper()
id := ids.New()
title := "test-conv"
_, err := q.CreateAgentConversation(context.Background(), sqlc.CreateAgentConversationParams{
ID: id,
Title: &title,
})
require.NoError(t, err, "CreateAgentConversation")
return id
}
func TestStateStore_CreateRun(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
s := agent.NewStateStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := s.CreateRun(ctx, convID)
require.NoError(t, err)
assert.False(t, run.ID.IsZero(), "run ID must be set")
assert.Equal(t, convID, run.ConversationID)
assert.Equal(t, "running", run.Status)
}
func TestStateStore_CreateRun_ZeroConversationID(t *testing.T) {
db := newTestQueries(t)
s := agent.NewStateStore(db.delegate.Queries())
ctx := context.Background()
_, err := s.CreateRun(ctx, ids.UUID{})
assert.Error(t, err, "zero conversation id should be rejected")
}
func TestStateStore_UpdateRunStatus(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
s := agent.NewStateStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := s.CreateRun(ctx, convID)
require.NoError(t, err)
updated, err := s.UpdateRunStatus(ctx, run.ID, "completed", map[string]any{"steps": 3})
require.NoError(t, err)
assert.Equal(t, run.ID, updated.ID)
assert.Equal(t, "completed", updated.Status)
}
func TestStateStore_UpdateRunStatus_EmptyStatus(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
s := agent.NewStateStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := s.CreateRun(ctx, convID)
require.NoError(t, err)
_, err = s.UpdateRunStatus(ctx, run.ID, "", nil)
assert.Error(t, err, "empty status should be rejected")
}
func TestStateStore_AddRunState(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
s := agent.NewStateStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := s.CreateRun(ctx, convID)
require.NoError(t, err)
state, err := s.AddRunState(ctx, run.ID, 0, fantasy.ReActStateLLMCall, map[string]string{"model": "gpt-4o"})
require.NoError(t, err)
assert.False(t, state.ID.IsZero())
assert.Equal(t, run.ID, state.RunID)
assert.Equal(t, int64(0), state.StepIndex)
assert.Equal(t, string(fantasy.ReActStateLLMCall), state.State)
}
func TestStateStore_AddRunState_NegativeStep(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
s := agent.NewStateStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := s.CreateRun(ctx, convID)
require.NoError(t, err)
_, err = s.AddRunState(ctx, run.ID, -1, fantasy.ReActStateLLMCall, nil)
assert.Error(t, err, "negative step index should be rejected")
}
func TestStateStore_AddTransition(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
s := agent.NewStateStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := s.CreateRun(ctx, convID)
require.NoError(t, err)
tr := fantasy.ReActTransition{
From: fantasy.ReActStateInit,
To: fantasy.ReActStatePrepareStep,
Trigger: fantasy.ReActTriggerStart,
At: time.Now().UTC(),
StepIndex: 0,
}
row, err := s.AddTransition(ctx, run.ID, tr)
require.NoError(t, err)
assert.False(t, row.ID.IsZero())
assert.Equal(t, string(fantasy.ReActStateInit), row.FromState)
assert.Equal(t, string(fantasy.ReActStatePrepareStep), row.ToState)
assert.Equal(t, string(fantasy.ReActTriggerStart), row.Trigger)
}
func TestStateStore_NilStore(t *testing.T) {
var s *agent.StateStore
ctx := context.Background()
_, err := s.CreateRun(ctx, ids.New())
assert.Error(t, err, "nil store should error")
}
// --- CheckpointStore ---
func TestCheckpointStore_CreateAndList(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
ss := agent.NewStateStore(q)
cs := agent.NewCheckpointStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := ss.CreateRun(ctx, convID)
require.NoError(t, err)
runState, err := ss.AddRunState(ctx, run.ID, 0, fantasy.ReActStateDone, nil)
require.NoError(t, err)
cp, err := cs.CreateCheckpoint(ctx, convID, "after-step-0", runState.ID, map[string]any{"note": "test"})
require.NoError(t, err)
assert.False(t, cp.ID.IsZero())
assert.Equal(t, "after-step-0", cp.Name)
cps, err := cs.ListCheckpoints(ctx, convID)
require.NoError(t, err)
assert.Len(t, cps, 1)
assert.Equal(t, cp.ID, cps[0].ID)
}
func TestCheckpointStore_GetByName(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
ss := agent.NewStateStore(q)
cs := agent.NewCheckpointStore(q)
ctx := context.Background()
convID := newConversation(t, q)
run, err := ss.CreateRun(ctx, convID)
require.NoError(t, err)
runState, err := ss.AddRunState(ctx, run.ID, 0, fantasy.ReActStateDone, nil)
require.NoError(t, err)
_, err = cs.CreateCheckpoint(ctx, convID, "snap-1", runState.ID, nil)
require.NoError(t, err)
cp, err := cs.GetCheckpoint(ctx, convID, "snap-1")
require.NoError(t, err)
assert.Equal(t, "snap-1", cp.Name)
}
func TestCheckpointStore_EmptyName(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
cs := agent.NewCheckpointStore(q)
ctx := context.Background()
convID := newConversation(t, q)
_, err := cs.CreateCheckpoint(ctx, convID, " ", ids.New(), nil)
assert.Error(t, err, "blank name should be rejected")
}

View file

@ -168,6 +168,7 @@ func loadToolResultRows(ctx context.Context, q *sqlc.Queries, input ToolResultSe
}
return q.ListAgentToolResultsByRunID(ctx, sqlc.ListAgentToolResultsByRunIDParams{
RunID: runID,
Lim: 1000,
})
}

View file

@ -0,0 +1,271 @@
package agent_test
import (
"context"
"encoding/json"
"strings"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/agent"
)
// setupSearchFixture runs a set of tool calls through the OffloadingToolRuntime
// so there are records + KV data to search, and returns the search tool.
func setupSearchFixture(t *testing.T) (fantasy.AgentTool, agent.KVDelegate, string, string) {
t.Helper()
db := newTestQueries(t)
q := db.delegate.Queries()
convID := newConversation(t, q)
s := agent.NewStateStore(q)
run, err := s.CreateRun(context.Background(), convID)
require.NoError(t, err)
kv := agent.NewDelegateKV(db.delegate, "search-test")
r := &agent.OffloadingToolRuntime{
Base: staticToolRuntime{},
KV: kv,
Queries: q,
ConversationID: convID,
RunID: run.ID,
ThresholdChars: 10000,
ChunkChars: 2000,
}
calls := []fantasy.ToolCallContent{
{ToolCallID: "c1", ToolName: "alpha"},
{ToolCallID: "c2", ToolName: "beta"},
{ToolCallID: "c3", ToolName: "alpha"},
}
_, err = r.Execute(context.Background(), nil, calls, nil)
require.NoError(t, err)
tool := agent.NewToolResultSearchTool(q, kv)
return tool, kv, convID.String(), run.ID.String()
}
// invokeSearch calls the search tool with the given input and parses the JSON response.
func invokeSearch(t *testing.T, tool fantasy.AgentTool, input agent.ToolResultSearchInput) map[string]any {
t.Helper()
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
Input: marshalInput(t, input),
})
require.NoError(t, err)
require.False(t, resp.IsError, "search tool must not error: %s", resp.Content)
var out map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &out))
return out
}
func marshalInput(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}
func TestToolResultSearch_ByConversationID(t *testing.T) {
tool, _, convID, _ := setupSearchFixture(t)
ctx := context.Background()
_ = ctx
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
ConversationID: convID,
Limit: 10,
})
total, _ := out["total"].(float64)
assert.Equal(t, float64(3), total, "should find all 3 results by conversation_id")
}
func TestToolResultSearch_ByRunID(t *testing.T) {
tool, _, _, runID := setupSearchFixture(t)
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
RunID: runID,
Limit: 10,
})
total, _ := out["total"].(float64)
assert.Equal(t, float64(3), total)
}
func TestToolResultSearch_ByRunID_AndToolCallID(t *testing.T) {
tool, _, _, runID := setupSearchFixture(t)
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
RunID: runID,
ToolCallID: "c2",
})
total, _ := out["total"].(float64)
assert.Equal(t, float64(1), total)
items := out["items"].([]any)
item := items[0].(map[string]any)
assert.Equal(t, "c2", item["tool_call_id"])
}
func TestToolResultSearch_FilterByToolName(t *testing.T) {
tool, _, convID, _ := setupSearchFixture(t)
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
ConversationID: convID,
ToolName: "alpha",
Limit: 10,
})
total, _ := out["total"].(float64)
assert.Equal(t, float64(2), total, "filter by tool_name should return only 'alpha' results")
}
func TestToolResultSearch_FilterByQuery(t *testing.T) {
tool, _, convID, _ := setupSearchFixture(t)
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
ConversationID: convID,
Query: "beta",
Limit: 10,
})
total, _ := out["total"].(float64)
assert.Equal(t, float64(1), total)
}
func TestToolResultSearch_DefaultLimit(t *testing.T) {
tool, _, convID, _ := setupSearchFixture(t)
// No limit specified -- default is 5, but we only have 3 items
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
ConversationID: convID,
})
total, _ := out["total"].(float64)
assert.Equal(t, float64(3), total)
}
func TestToolResultSearch_MissingConvAndRunID_ErrorResponse(t *testing.T) {
tool, _, _, _ := setupSearchFixture(t)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
Input: marshalInput(t, agent.ToolResultSearchInput{}),
})
require.NoError(t, err)
// The tool returns an error response (not an err) when no ID is provided.
// The text will contain the error message.
assert.True(t, resp.IsError || strings.Contains(resp.Content, "required"), "missing IDs should produce error response")
}
// TestToolResultSearch_LineView verifies that the KV full-result is sliced
// into the requested line range.
func TestToolResultSearch_LineView(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
convID := newConversation(t, q)
s := agent.NewStateStore(q)
run, err := s.CreateRun(context.Background(), convID)
require.NoError(t, err)
kv := agent.NewDelegateKV(db.delegate, "line-view-test")
lines := []string{"line1", "line2", "line3", "line4", "line5"}
multilineResult := strings.Join(lines, "\n")
base := staticToolRuntime{results: []fantasy.ToolResultContent{{
ToolCallID: "lv1",
ToolName: "liner",
Result: fantasy.ToolResultOutputContentText{Text: multilineResult},
}}}
r := &agent.OffloadingToolRuntime{
Base: base,
KV: kv,
Queries: q,
ConversationID: convID,
RunID: run.ID,
ThresholdChars: 100000,
}
_, err = r.Execute(context.Background(), nil, []fantasy.ToolCallContent{{ToolCallID: "lv1", ToolName: "liner"}}, nil)
require.NoError(t, err)
tool := agent.NewToolResultSearchTool(q, kv)
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
RunID: run.ID.String(),
View: &agent.ToolResultSearchView{
StartLine: 2,
EndLine: 4,
},
})
items := out["items"].([]any)
require.Len(t, items, 1)
item := items[0].(map[string]any)
view := item["view"].(string)
// For non-chunked results the full.json KV entry is a JSON blob (one line).
// The view returns the full blob when the content fits within 1 line.
// Verify the view is non-empty and contains the original text.
assert.NotEmpty(t, view, "view should return content")
assert.Contains(t, view, "line1", "full.json should contain original text")
assert.Contains(t, view, "line5", "full.json should contain all lines")
// The view_range reflects the actual clamped range (1 JSON line available).
viewRange := item["view_range"].(map[string]any)
assert.Equal(t, float64(1), viewRange["start_line"], "clamped to 1 since full.json is a single-line JSON blob")
}
// TestToolResultSearch_ChunkView verifies chunk-range retrieval from KV.
func TestToolResultSearch_ChunkView(t *testing.T) {
db := newTestQueries(t)
q := db.delegate.Queries()
convID := newConversation(t, q)
s := agent.NewStateStore(q)
run, err := s.CreateRun(context.Background(), convID)
require.NoError(t, err)
kv := agent.NewDelegateKV(db.delegate, "chunk-view-test")
// 60 chars, threshold=10, chunkChars=20 → 3 chunks
longText := strings.Repeat("abcdefghij", 6)
base := staticToolRuntime{results: []fantasy.ToolResultContent{{
ToolCallID: "cv1",
ToolName: "chunker",
Result: fantasy.ToolResultOutputContentText{Text: longText},
}}}
r := &agent.OffloadingToolRuntime{
Base: base,
KV: kv,
Queries: q,
ConversationID: convID,
RunID: run.ID,
ThresholdChars: 10,
ChunkChars: 20,
}
_, err = r.Execute(context.Background(), nil, []fantasy.ToolCallContent{{ToolCallID: "cv1", ToolName: "chunker"}}, nil)
require.NoError(t, err)
tool := agent.NewToolResultSearchTool(q, kv)
// Request chunks 0-1 only
out := invokeSearch(t, tool, agent.ToolResultSearchInput{
RunID: run.ID.String(),
View: &agent.ToolResultSearchView{
StartChunk: 0,
EndChunk: 1,
},
})
items := out["items"].([]any)
require.Len(t, items, 1)
item := items[0].(map[string]any)
view := item["view"].(string)
// First two chunks: "abcdefghijabcdefghij" + "abcdefghijabcdefghij" = 40 chars
assert.Equal(t, strings.Repeat("abcdefghij", 4), view, "chunk 0+1 should be first 40 chars")
}

View file

@ -6,28 +6,197 @@
package fantasy
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"fmt"
"charm.land/fantasy"
"charm.land/fantasy/providers/openaicompat"
"github.com/openai/openai-go/v2/option"
"github.com/sipeed/picoclaw/pkg/config"
)
// providerEntry describes a single LLM provider: how to identify it, how to
// extract credentials from config, and its default base URL.
type providerEntry struct {
// Canonical name and aliases used in agents.defaults.provider.
names []string
// Model name prefixes (e.g. "openai/") that auto-route to this provider.
modelPrefixes []string
// Model substrings (lower-case) that auto-route to this provider.
modelContains []string
// stripPrefixOnModelID: when true, ModelID strips the first segment before "/".
stripPrefixOnModelID bool
// Credential extractors.
apiKey func(*config.Config) string
apiBase func(*config.Config) string
defaultBase string
proxy func(*config.Config) string
timeout func(*config.Config) int
}
// providerRegistry is the single source of truth for provider routing.
// Entries are checked in order for both explicit provider name matching and
// model-name-based auto-detection.
var providerRegistry = []providerEntry{
{
names: []string{"groq"},
modelPrefixes: []string{"groq/"},
modelContains: []string{"groq"},
apiKey: func(c *config.Config) string { return c.Providers.Groq.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.Groq.APIBase },
defaultBase: "https://api.groq.com/openai/v1",
proxy: func(c *config.Config) string { return c.Providers.Groq.Proxy },
timeout: func(c *config.Config) int { return c.Providers.Groq.Timeout },
},
{
names: []string{"openai", "gpt"},
modelPrefixes: []string{"openai/"},
modelContains: []string{"gpt"},
apiKey: func(c *config.Config) string { return c.Providers.OpenAI.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.OpenAI.APIBase },
defaultBase: "https://api.openai.com/v1",
proxy: func(c *config.Config) string { return c.Providers.OpenAI.Proxy },
timeout: func(c *config.Config) int { return c.Providers.OpenAI.Timeout },
},
{
names: []string{"anthropic", "claude"},
modelPrefixes: []string{"anthropic/"},
modelContains: []string{"claude"},
apiKey: func(c *config.Config) string { return c.Providers.Anthropic.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.Anthropic.APIBase },
defaultBase: "https://api.anthropic.com/v1",
proxy: func(c *config.Config) string { return c.Providers.Anthropic.Proxy },
timeout: func(c *config.Config) int { return c.Providers.Anthropic.Timeout },
},
{
names: []string{"openrouter"},
modelPrefixes: []string{"openrouter/", "meta-llama/", "deepseek/", "google/"},
apiKey: func(c *config.Config) string { return c.Providers.OpenRouter.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.OpenRouter.APIBase },
defaultBase: "https://openrouter.ai/api/v1",
proxy: func(c *config.Config) string { return c.Providers.OpenRouter.Proxy },
timeout: func(c *config.Config) int { return c.Providers.OpenRouter.Timeout },
},
{
names: []string{"zhipu", "glm"},
modelContains: []string{"glm", "zhipu", "zai"},
apiKey: func(c *config.Config) string { return c.Providers.Zhipu.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.Zhipu.APIBase },
defaultBase: "https://open.bigmodel.cn/api/paas/v4",
proxy: func(c *config.Config) string { return c.Providers.Zhipu.Proxy },
timeout: func(c *config.Config) int { return c.Providers.Zhipu.Timeout },
},
{
names: []string{"gemini", "google"},
modelPrefixes: []string{"gemini/"},
modelContains: []string{"gemini"},
apiKey: func(c *config.Config) string { return c.Providers.Gemini.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.Gemini.APIBase },
defaultBase: "https://generativelanguage.googleapis.com/v1beta",
proxy: func(c *config.Config) string { return c.Providers.Gemini.Proxy },
timeout: func(c *config.Config) int { return c.Providers.Gemini.Timeout },
},
{
names: []string{"vllm"},
apiKey: func(c *config.Config) string { return c.Providers.VLLM.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.VLLM.APIBase },
proxy: func(c *config.Config) string { return c.Providers.VLLM.Proxy },
timeout: func(c *config.Config) int { return c.Providers.VLLM.Timeout },
},
{
names: []string{"shengsuanyun"},
apiKey: func(c *config.Config) string { return c.Providers.ShengSuanYun.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.ShengSuanYun.APIBase },
defaultBase: "https://router.shengsuanyun.com/api/v1",
proxy: func(c *config.Config) string { return c.Providers.ShengSuanYun.Proxy },
timeout: func(c *config.Config) int { return c.Providers.ShengSuanYun.Timeout },
},
{
names: []string{"deepseek"},
apiKey: func(c *config.Config) string { return c.Providers.DeepSeek.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.DeepSeek.APIBase },
defaultBase: "https://api.deepseek.com/v1",
proxy: func(c *config.Config) string { return c.Providers.DeepSeek.Proxy },
timeout: func(c *config.Config) int { return c.Providers.DeepSeek.Timeout },
},
{
names: []string{"nvidia"},
modelPrefixes: []string{"nvidia/"},
modelContains: []string{"nvidia"},
stripPrefixOnModelID: true,
apiKey: func(c *config.Config) string { return c.Providers.Nvidia.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.Nvidia.APIBase },
defaultBase: "https://integrate.api.nvidia.com/v1",
proxy: func(c *config.Config) string { return c.Providers.Nvidia.Proxy },
timeout: func(c *config.Config) int { return c.Providers.Nvidia.Timeout },
},
{
names: []string{"moonshot", "kimi"},
modelPrefixes: []string{"moonshot/"},
modelContains: []string{"kimi", "moonshot"},
stripPrefixOnModelID: true,
apiKey: func(c *config.Config) string { return c.Providers.Moonshot.APIKey },
apiBase: func(c *config.Config) string { return c.Providers.Moonshot.APIBase },
defaultBase: "https://api.moonshot.cn/v1",
proxy: func(c *config.Config) string { return c.Providers.Moonshot.Proxy },
timeout: func(c *config.Config) int { return c.Providers.Moonshot.Timeout },
},
}
// findProviderByName returns the registry entry that matches the given explicit
// provider name (case-insensitive), or nil if not found.
func findProviderByName(name string) *providerEntry {
lower := strings.ToLower(name)
for i := range providerRegistry {
for _, n := range providerRegistry[i].names {
if n == lower {
return &providerRegistry[i]
}
}
}
return nil
}
// findProviderByModel returns the first registry entry whose model prefixes or
// model substrings match the given model string.
func findProviderByModel(model string) *providerEntry {
lower := strings.ToLower(model)
for i := range providerRegistry {
e := &providerRegistry[i]
for _, prefix := range e.modelPrefixes {
if strings.HasPrefix(model, prefix) {
return e
}
}
for _, sub := range e.modelContains {
if strings.Contains(lower, sub) {
return e
}
}
}
return nil
}
// resolveBase returns the effective API base URL for the entry and config.
func (e *providerEntry) resolveBase(cfg *config.Config) string {
if e.apiBase != nil {
if b := e.apiBase(cfg); b != "" {
return b
}
}
return e.defaultBase
}
// CreateProvider builds a Fantasy provider from PicoClaw config.
// It mirrors the provider selection logic from the legacy providers.CreateProvider.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
model := cfg.Agents.Defaults.Model
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
model := cfg.Agents.Defaults.Model
lowerModel := strings.ToLower(model)
// Resolve provider from explicit config
// Special non-openaicompat providers are handled first.
if providerName != "" {
switch providerName {
case "claude-cli", "claudecode", "claude-code":
@ -39,9 +208,7 @@ func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
}
}
// Build openaicompat options from config
apiKey, apiBase, proxy := resolveProvider(cfg, providerName, model, lowerModel)
apiKey, apiBase, proxy := resolveProvider(cfg, providerName, model)
if apiKey == "" && apiBase == "" {
return nil, fmt.Errorf("no API key or base configured for provider (model: %s)", model)
}
@ -49,7 +216,7 @@ func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
}
timeout := resolveProviderTimeout(cfg, providerName)
timeout := resolveProviderTimeout(cfg, providerName, model)
opts := []openaicompat.Option{
openaicompat.WithBaseURL(apiBase),
@ -57,7 +224,6 @@ func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
openaicompat.WithName(providerNameOrDefault(providerName)),
}
// Build HTTP client with proxy and timeout support
httpClient := buildHTTPClient(proxy, timeout)
if httpClient != nil {
opts = append(opts, openaicompat.WithHTTPClient(httpClient))
@ -67,16 +233,22 @@ func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
}
// ModelID returns the effective model ID to pass to Fantasy's LanguageModel.
// It strips provider prefixes that the old system used for routing.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
// For providers that require a prefix to be stripped (e.g. moonshot/kimi-k2.5),
// this returns only the suffix after the first "/".
func ModelID(cfg *config.Config) string {
model := cfg.Agents.Defaults.Model
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5)
if before, after, ok := strings.Cut(model, "/"); ok {
prefix := before
// FIXME: hardcoded provider prefixes are a hack
if prefix == "moonshot" || prefix == "nvidia" {
if _, after, ok := strings.Cut(model, "/"); ok {
// Determine whether this provider wants the prefix stripped.
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
var entry *providerEntry
if providerName != "" {
entry = findProviderByName(providerName)
}
if entry == nil {
entry = findProviderByModel(model)
}
if entry != nil && entry.stripPrefixOnModelID {
return after
}
}
@ -84,123 +256,56 @@ func ModelID(cfg *config.Config) string {
return model
}
// resolveProvider determines the API key, base URL, and proxy for a given config.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func resolveProvider(cfg *config.Config, providerName, model, lowerModel string) (apiKey, apiBase, proxy string) {
// First, try explicitly configured provider
// resolveProvider determines the API key, base URL, and proxy for the current config.
// It first tries an explicit provider name, then falls back to model-name detection,
// and finally falls back to OpenRouter if a key is available.
func resolveProvider(cfg *config.Config, providerName, model string) (apiKey, apiBase, proxy string) {
// Explicit provider name lookup.
if providerName != "" {
switch providerName {
case "groq":
if cfg.Providers.Groq.APIKey != "" {
return cfg.Providers.Groq.APIKey, defaultIfEmpty(cfg.Providers.Groq.APIBase, "https://api.groq.com/openai/v1"), ""
}
case "openai", "gpt":
if cfg.Providers.OpenAI.APIKey != "" {
return cfg.Providers.OpenAI.APIKey, defaultIfEmpty(cfg.Providers.OpenAI.APIBase, "https://api.openai.com/v1"), ""
}
case "anthropic", "claude":
if cfg.Providers.Anthropic.APIKey != "" {
return cfg.Providers.Anthropic.APIKey, defaultIfEmpty(cfg.Providers.Anthropic.APIBase, "https://api.anthropic.com/v1"), ""
}
case "openrouter":
if cfg.Providers.OpenRouter.APIKey != "" {
return cfg.Providers.OpenRouter.APIKey, defaultIfEmpty(cfg.Providers.OpenRouter.APIBase, "https://openrouter.ai/api/v1"), ""
}
case "zhipu", "glm":
if cfg.Providers.Zhipu.APIKey != "" {
return cfg.Providers.Zhipu.APIKey, defaultIfEmpty(cfg.Providers.Zhipu.APIBase, "https://open.bigmodel.cn/api/paas/v4"), ""
}
case "gemini", "google":
if cfg.Providers.Gemini.APIKey != "" {
return cfg.Providers.Gemini.APIKey, defaultIfEmpty(cfg.Providers.Gemini.APIBase, "https://generativelanguage.googleapis.com/v1beta"), ""
}
case "vllm":
if cfg.Providers.VLLM.APIBase != "" {
return cfg.Providers.VLLM.APIKey, cfg.Providers.VLLM.APIBase, ""
}
case "shengsuanyun":
if cfg.Providers.ShengSuanYun.APIKey != "" {
return cfg.Providers.ShengSuanYun.APIKey, defaultIfEmpty(cfg.Providers.ShengSuanYun.APIBase, "https://router.shengsuanyun.com/api/v1"), ""
}
case "deepseek":
if cfg.Providers.DeepSeek.APIKey != "" {
return cfg.Providers.DeepSeek.APIKey, defaultIfEmpty(cfg.Providers.DeepSeek.APIBase, "https://api.deepseek.com/v1"), ""
entry := findProviderByName(providerName)
if entry != nil {
key := entry.apiKey(cfg)
base := entry.resolveBase(cfg)
// vllm uses base URL as the signal instead of an API key.
if key != "" || (providerName == "vllm" && base != "") {
return key, base, entry.proxy(cfg)
}
}
}
// Fallback: detect provider from model name
switch {
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
return cfg.Providers.Moonshot.APIKey, defaultIfEmpty(cfg.Providers.Moonshot.APIBase, "https://api.moonshot.cn/v1"), cfg.Providers.Moonshot.Proxy
// Model-name based detection.
entry := findProviderByModel(model)
if entry != nil {
key := entry.apiKey(cfg)
base := entry.resolveBase(cfg)
if key != "" || (len(entry.names) > 0 && entry.names[0] == "vllm" && base != "") {
return key, base, entry.proxy(cfg)
}
}
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
base := defaultIfEmpty(cfg.Providers.OpenRouter.APIBase, "https://openrouter.ai/api/v1")
return cfg.Providers.OpenRouter.APIKey, base, cfg.Providers.OpenRouter.Proxy
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && cfg.Providers.Anthropic.APIKey != "":
return cfg.Providers.Anthropic.APIKey, defaultIfEmpty(cfg.Providers.Anthropic.APIBase, "https://api.anthropic.com/v1"), cfg.Providers.Anthropic.Proxy
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && cfg.Providers.OpenAI.APIKey != "":
return cfg.Providers.OpenAI.APIKey, defaultIfEmpty(cfg.Providers.OpenAI.APIBase, "https://api.openai.com/v1"), cfg.Providers.OpenAI.Proxy
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
return cfg.Providers.Gemini.APIKey, defaultIfEmpty(cfg.Providers.Gemini.APIBase, "https://generativelanguage.googleapis.com/v1beta"), cfg.Providers.Gemini.Proxy
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
return cfg.Providers.Zhipu.APIKey, defaultIfEmpty(cfg.Providers.Zhipu.APIBase, "https://open.bigmodel.cn/api/paas/v4"), cfg.Providers.Zhipu.Proxy
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
return cfg.Providers.Groq.APIKey, defaultIfEmpty(cfg.Providers.Groq.APIBase, "https://api.groq.com/openai/v1"), cfg.Providers.Groq.Proxy
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
return cfg.Providers.Nvidia.APIKey, defaultIfEmpty(cfg.Providers.Nvidia.APIBase, "https://integrate.api.nvidia.com/v1"), cfg.Providers.Nvidia.Proxy
case cfg.Providers.VLLM.APIBase != "":
return cfg.Providers.VLLM.APIKey, cfg.Providers.VLLM.APIBase, cfg.Providers.VLLM.Proxy
default:
// Last resort: if OpenRouter has a key, use it as a universal fallback.
if cfg.Providers.OpenRouter.APIKey != "" {
base := defaultIfEmpty(cfg.Providers.OpenRouter.APIBase, "https://openrouter.ai/api/v1")
return cfg.Providers.OpenRouter.APIKey, base, cfg.Providers.OpenRouter.Proxy
}
}
return "", "", ""
}
// resolveProviderTimeout extracts the timeout from the matched provider config.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func resolveProviderTimeout(cfg *config.Config, providerName string) time.Duration {
var timeoutSec int
switch providerName {
case "groq":
timeoutSec = cfg.Providers.Groq.Timeout
case "openai", "gpt":
timeoutSec = cfg.Providers.OpenAI.Timeout
case "anthropic", "claude":
timeoutSec = cfg.Providers.Anthropic.Timeout
case "openrouter":
timeoutSec = cfg.Providers.OpenRouter.Timeout
case "zhipu", "glm":
timeoutSec = cfg.Providers.Zhipu.Timeout
case "gemini", "google":
timeoutSec = cfg.Providers.Gemini.Timeout
case "vllm":
timeoutSec = cfg.Providers.VLLM.Timeout
case "shengsuanyun":
timeoutSec = cfg.Providers.ShengSuanYun.Timeout
case "deepseek":
timeoutSec = cfg.Providers.DeepSeek.Timeout
case "nvidia":
timeoutSec = cfg.Providers.Nvidia.Timeout
case "moonshot":
timeoutSec = cfg.Providers.Moonshot.Timeout
// resolveProviderTimeout extracts the configured timeout for the matched provider.
func resolveProviderTimeout(cfg *config.Config, providerName, model string) time.Duration {
var entry *providerEntry
if providerName != "" {
entry = findProviderByName(providerName)
}
if entry == nil {
entry = findProviderByModel(model)
}
if timeoutSec > 0 {
return time.Duration(timeoutSec) * time.Second
if entry != nil && entry.timeout != nil {
if sec := entry.timeout(cfg); sec > 0 {
return time.Duration(sec) * time.Second
}
}
return 120 * time.Second
}

View file

@ -181,6 +181,10 @@ func (d *LibSQLDelegate) MigrateDown(ctx context.Context) error {
// EmbeddingDims returns the configured embedding vector dimensions.
func (d *LibSQLDelegate) EmbeddingDims() int { return d.embeddingDims }
// Queries returns the underlying sqlc.Queries for callers (e.g. agent package
// tests) that need direct DB access without going through the delegate API.
func (d *LibSQLDelegate) Queries() *memsqlc.Queries { return d.queries }
func (d *LibSQLDelegate) Close() error {
if d.stmts != nil {
d.stmts.close()

View file

@ -1,19 +1,31 @@
package pcerrors
import (
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"strings"
)
// CLIHandler is the PicoClaw error lifecycle boundary for the CLI.
//
// It is intentionally small: render a user-facing message and return an exit code.
// TODO: More advanced behaviors (structured logging, debug traces, redaction) can be layered on later.
// It renders a user-facing message to Writer and returns an appropriate exit
// code. Optional Verbose and Redact flags layer on debug traces and sensitive-
// value scrubbing without requiring callers to change their error types.
type CLIHandler struct {
// Writer is where the error message is written. Defaults to os.Stderr.
Writer io.Writer
// Verbose enables structured debug output: full error chain and error code.
Verbose bool
// Redact scrubs known patterns (API keys, tokens, passwords) before printing.
Redact bool
// JSON emits machine-readable JSON instead of plain text.
JSON bool
}
// DefaultCLIHandler returns a CLIHandler with sane defaults for interactive use.
func DefaultCLIHandler() CLIHandler {
return CLIHandler{Writer: os.Stderr}
}
@ -22,10 +34,71 @@ func (h CLIHandler) Handle(err error) int {
if err == nil {
return 0
}
_, _ = fmt.Fprintln(h.Writer, UserMessage(err))
w := h.Writer
if w == nil {
w = os.Stderr
}
msg := h.renderMessage(err)
if h.JSON {
record := map[string]any{
"error": msg,
"code": int(CodeOf(err)),
}
if h.Verbose {
record["detail"] = err.Error()
}
data, _ := json.Marshal(record)
_, _ = fmt.Fprintln(w, string(data))
} else {
_, _ = fmt.Fprintln(w, msg)
if h.Verbose {
if detail := err.Error(); detail != msg {
_, _ = fmt.Fprintf(w, " detail: %s\n", detail)
}
_, _ = fmt.Fprintf(w, " code: %d\n", int(CodeOf(err)))
}
}
return ExitCode(err)
}
// renderMessage produces the user-facing string, optionally redacted.
func (h CLIHandler) renderMessage(err error) string {
msg := UserMessage(err)
if h.Redact {
msg = redactSensitive(msg)
}
return msg
}
// sensitivePatterns matches common secret-looking values in error messages.
var sensitivePatterns = []*regexp.Regexp{
// API keys / bearer tokens embedded in URLs or strings (long alphanumeric strings)
regexp.MustCompile(`(?i)(api[-_]?key|token|bearer|password|secret|auth)([=:\s]+)[A-Za-z0-9\-_\.]{12,}`),
// sk-... OpenAI-style keys
regexp.MustCompile(`\bsk-[A-Za-z0-9\-_]{20,}`),
// Generic "key=VALUE" patterns (at least 12 chars after the separator)
regexp.MustCompile(`\b([A-Za-z0-9_\-]{3,20})(=)[A-Za-z0-9\-_\.]{12,}`),
}
// redactSensitive replaces secret-looking substrings with a placeholder.
func redactSensitive(s string) string {
for _, re := range sensitivePatterns {
s = re.ReplaceAllStringFunc(s, func(match string) string {
// Keep the key name / prefix; replace the value with [REDACTED].
idx := strings.IndexAny(match, "=: ")
if idx < 0 {
return "[REDACTED]"
}
return match[:idx+1] + "[REDACTED]"
})
}
return s
}
// UserMessage returns a friendly, stable message for humans.
//
// For structured errors, we prefer the top-level message (not the fully formatted builder.Error()).

219
pkg/worker/worker_test.go Normal file
View file

@ -0,0 +1,219 @@
package worker_test
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/memory/delegate"
"github.com/sipeed/picoclaw/pkg/memory/sqlc"
"github.com/sipeed/picoclaw/pkg/worker"
)
func newTestDB(t *testing.T) *sqlc.Queries {
t.Helper()
d, err := delegate.NewLibSQLInMemory()
require.NoError(t, err)
require.NoError(t, d.Init(context.Background()))
t.Cleanup(func() { _ = d.Close() })
return d.Queries()
}
func enqueueJob(t *testing.T, q *sqlc.Queries, kind, dedupeKey string, maxAttempts int64) sqlc.Job {
t.Helper()
past := time.Now().UTC().Add(-time.Second)
job, err := q.EnqueueJob(context.Background(), sqlc.EnqueueJobParams{
ID: ids.New(),
Kind: kind,
DedupeKey: &dedupeKey,
MaxAttempts: maxAttempts,
RunAt: past,
PayloadJson: []byte(`{}`),
})
require.NoError(t, err, "EnqueueJob")
return job
}
func TestWorker_RunOnce_NoJobs(t *testing.T) {
q := newTestDB(t)
err := worker.RunOnce(context.Background(), q, nil)
assert.NoError(t, err, "empty queue should not error")
}
func TestWorker_RunOnce_HandlerCalled(t *testing.T) {
q := newTestDB(t)
ctx := context.Background()
enqueueJob(t, q, "greet", "greet-1", 3)
var called atomic.Int32
opts := &worker.Options{
LockedBy: "test-worker",
Handlers: map[string]worker.HandlerFunc{
"greet": func(_ context.Context, _ *sqlc.Queries, _ sqlc.Job) error {
called.Add(1)
return nil
},
},
}
err := worker.RunOnce(ctx, q, opts)
require.NoError(t, err)
assert.Equal(t, int32(1), called.Load(), "handler must be called once")
}
func TestWorker_RunOnce_JobMarkedSucceeded(t *testing.T) {
q := newTestDB(t)
ctx := context.Background()
job := enqueueJob(t, q, "ping", "ping-1", 3)
opts := &worker.Options{
LockedBy: "test-worker",
Handlers: map[string]worker.HandlerFunc{
"ping": func(_ context.Context, _ *sqlc.Queries, _ sqlc.Job) error {
return nil
},
},
}
require.NoError(t, worker.RunOnce(ctx, q, opts))
done, err := q.GetJob(ctx, sqlc.GetJobParams{ID: job.ID})
require.NoError(t, err)
assert.Equal(t, "succeeded", done.Status)
}
func TestWorker_RunOnce_HandlerError_Requeued(t *testing.T) {
q := newTestDB(t)
ctx := context.Background()
job := enqueueJob(t, q, "fail", "fail-1", 3)
opts := &worker.Options{
LockedBy: "test-worker",
Now: func() time.Time { return time.Now().UTC() },
Handlers: map[string]worker.HandlerFunc{
"fail": func(_ context.Context, _ *sqlc.Queries, _ sqlc.Job) error {
return errors.New("transient error")
},
},
}
require.NoError(t, worker.RunOnce(ctx, q, opts))
requeued, err := q.GetJob(ctx, sqlc.GetJobParams{ID: job.ID})
require.NoError(t, err)
assert.Equal(t, "queued", requeued.Status, "job should be requeued after transient failure")
assert.Equal(t, int64(1), requeued.Attempts, "attempt count must increment")
assert.NotNil(t, requeued.LastError)
}
func TestWorker_RunOnce_MaxAttemptsExhausted_MarkedFailed(t *testing.T) {
q := newTestDB(t)
ctx := context.Background()
enqueueJob(t, q, "exhaust", "exhaust-1", 1)
opts := &worker.Options{
LockedBy: "test-worker",
Now: func() time.Time { return time.Now().UTC() },
Handlers: map[string]worker.HandlerFunc{
"exhaust": func(_ context.Context, _ *sqlc.Queries, _ sqlc.Job) error {
return errors.New("always fails")
},
},
}
require.NoError(t, worker.RunOnce(ctx, q, opts))
all, err := q.ListJobs(ctx, sqlc.ListJobsParams{Off: 0, Lim: 100})
require.NoError(t, err)
failed := 0
for _, j := range all {
if j.Status == "failed" {
failed++
}
}
assert.Equal(t, 1, failed, "job should be permanently failed after exhausting max attempts")
}
func TestWorker_RunOnce_UnknownKind_MarkedFailed(t *testing.T) {
q := newTestDB(t)
ctx := context.Background()
enqueueJob(t, q, "unknown-kind", "unk-1", 3)
opts := &worker.Options{
LockedBy: "test-worker",
Handlers: map[string]worker.HandlerFunc{},
}
require.NoError(t, worker.RunOnce(ctx, q, opts))
all, err := q.ListJobs(ctx, sqlc.ListJobsParams{Off: 0, Lim: 100})
require.NoError(t, err)
failed := 0
for _, j := range all {
if j.Status == "failed" {
failed++
}
}
assert.Equal(t, 1, failed, "unknown kind should permanently fail the job")
}
func TestWorker_RunOnce_NilQueries(t *testing.T) {
err := worker.RunOnce(context.Background(), nil, nil)
assert.Error(t, err, "nil queries should return an error")
}
func TestWorker_RunLoop_StopsOnCancel(t *testing.T) {
q := newTestDB(t)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
opts := &worker.Options{
Sleep: 50 * time.Millisecond,
LockedBy: "loop-worker",
Handlers: map[string]worker.HandlerFunc{},
}
err := worker.RunLoop(ctx, q, opts)
assert.ErrorIs(t, err, context.DeadlineExceeded, "RunLoop must respect context cancellation")
}
func TestWorker_RunLoop_ProcessesJobs(t *testing.T) {
q := newTestDB(t)
var processed atomic.Int32
total := 5
for i := range total {
enqueueJob(t, q, "batch", "batch-"+string(rune('A'+i)), 3)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
opts := &worker.Options{
Sleep: 10 * time.Millisecond,
LockedBy: "loop-worker",
Handlers: map[string]worker.HandlerFunc{
"batch": func(_ context.Context, _ *sqlc.Queries, _ sqlc.Job) error {
processed.Add(1)
if int(processed.Load()) >= total {
cancel()
}
return nil
},
},
}
_ = worker.RunLoop(ctx, q, opts)
assert.Equal(t, int32(total), processed.Load(), "all jobs must be processed")
}