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:
parent
f9a986f735
commit
9b8163caf9
19 changed files with 2040 additions and 178 deletions
19
ROADMAP.md
19
ROADMAP.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
223
internal/fantasy/react_fsm_test.go
Normal file
223
internal/fantasy/react_fsm_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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
80
pkg/agent/kv_delegate.go
Normal 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
|
||||
}
|
||||
129
pkg/agent/kv_delegate_test.go
Normal file
129
pkg/agent/kv_delegate_test.go
Normal 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")
|
||||
}
|
||||
289
pkg/agent/offloading_runtime_test.go
Normal file
289
pkg/agent/offloading_runtime_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -175,6 +175,7 @@ func (s *CheckpointStore) ListCheckpoints(ctx context.Context, conversationID id
|
|||
}
|
||||
return s.q.ListAgentCheckpointsByConversationID(ctx, sqlc.ListAgentCheckpointsByConversationIDParams{
|
||||
ConversationID: conversationID,
|
||||
Lim: 1000,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
228
pkg/agent/state_store_test.go
Normal file
228
pkg/agent/state_store_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -168,6 +168,7 @@ func loadToolResultRows(ctx context.Context, q *sqlc.Queries, input ToolResultSe
|
|||
}
|
||||
return q.ListAgentToolResultsByRunID(ctx, sqlc.ListAgentToolResultsByRunIDParams{
|
||||
RunID: runID,
|
||||
Lim: 1000,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
271
pkg/agent/tool_result_search_test.go
Normal file
271
pkg/agent/tool_result_search_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
219
pkg/worker/worker_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue