diff --git a/Makefile b/Makefile index c5d691c29..c462914e8 100644 --- a/Makefile +++ b/Makefile @@ -306,7 +306,7 @@ test: generate ## fmt: Format Go code fmt: - @$(GOLANGCI_LINT) fmt + @go fmt ./... ## lint-docs: Check common documentation layout and naming conventions lint-docs: diff --git a/README.md b/README.md index 5aac4bbc9..f7b2806bf 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ πŸ‘οΈ **Vision pipeline**: Send images and files directly to the Agent β€” automatic base64 encoding for multimodal LLMs. 🧠 **Smart routing**: Rule-based model routing β€” simple queries go to lightweight models, saving API costs. +🧬 **FreeRide**: Intelligent model rotation using OpenRouter's free pool β€” never pay for basic LLM traffic again. [Learn more](docs/guides/freeride.md). +❄️ **Persistent Cooldowns**: Remembers rate-limited models across restarts via `cooldowns.json`, ensuring instant failover and zero "cooldown amnesia". _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is planned. Boot speed comparison based on 0.8GHz single-core benchmarks (see table below)._ @@ -622,6 +624,7 @@ For detailed guides beyond this README: | [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage | | [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration | | [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | +| [FreeRide](docs/freeride.md) | Dynamic free model rotation and K3s secret management | | [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | | [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls | | [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | diff --git a/cmd/freeride-diag/main.go b/cmd/freeride-diag/main.go new file mode 100644 index 000000000..5bf51e5a0 --- /dev/null +++ b/cmd/freeride-diag/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "time" +) + +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + ContextLength int `json:"context_length"` + Pricing struct { + Prompt string `json:"prompt"` + Completion string `json:"completion"` + } `json:"pricing"` + Created int64 `json:"created"` + Score float64 + LastError string + IsReachable bool +} + +func main() { + apiKey := os.Getenv("OPENROUTER_API_KEY") + if apiKey == "" { + fmt.Println("❌ Error: OPENROUTER_API_KEY environment variable is not set.") + os.Exit(1) + } + + fmt.Println("πŸ” Fetching all models from OpenRouter...") + models, err := fetchModels(apiKey) + if err != nil { + fmt.Printf("❌ Failed to fetch models: %v\n", err) + os.Exit(1) + } + + var freeModels []Model + for _, m := range models { + if m.Pricing.Prompt == "0" && m.Pricing.Completion == "0" { + // Scoring logic (same as tool) + score := 0.0 + score += float64(m.ContextLength) / 128000.0 * 0.4 + if m.Created > 0 { + ageInDays := float64(time.Now().Unix()-m.Created) / 86400.0 + if ageInDays < 365 { + score += (1.0 - ageInDays/365.0) * 0.2 + } + } + m.Score = score + freeModels = append(freeModels, m) + } + } + + sort.Slice(freeModels, func(i, j int) bool { + return freeModels[i].Score > freeModels[j].Score + }) + + fmt.Printf("βœ… Found %d free models. Testing connectivity until we find 3 working ones...\n\n", len(freeModels)) + + successCount := 0 + for i := range freeModels { + if successCount >= 3 { + break + } + m := &freeModels[i] + fmt.Printf("[%d/%d] Testing %s... ", i+1, len(freeModels), m.ID) + + err := testModel(apiKey, m.ID) + if err == nil { + m.IsReachable = true + successCount++ + fmt.Println("βœ… OK") + } else { + m.LastError = err.Error() + fmt.Printf("❌ FAIL (%v)\n", err) + } + } + + fmt.Println("\n--- FINAL RECOMMENDATIONS ---") + header := fmt.Sprintf("%-50s | %-15s | %-10s", "Model ID", "Context", "Status") + fmt.Println(header) + fmt.Println(strings.Repeat("-", len(header))) + + for i, m := range freeModels { + if i >= 10 { + break + } + status := "Unknown" + if i < 5 { + if m.IsReachable { + status = "βœ… OK" + } else { + status = "❌ FAIL" + } + } + fmt.Printf("%-50s | %-15d | %-10s\n", m.ID, m.ContextLength, status) + } + + for _, m := range freeModels { + if m.IsReachable { + fmt.Printf("\nπŸš€ SUCCESS! Use this model for testing: \n go run cmd/picoclaw/main.go agent --model openrouter/%s\n", m.ID) + break + } + } +} + +func fetchModels(apiKey string) ([]Model, error) { + req, _ := http.NewRequest("GET", "https://openrouter.ai/api/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data []Model `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Data, nil +} + +func testModel(apiKey, modelID string) error { + payload := map[string]any{ + "model": modelID, + "messages": []map[string]string{ + {"role": "user", "content": "ping"}, + }, + "max_tokens": 10, + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 23227d56a..9f234bb4e 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -51,12 +51,12 @@ func agentCmd(message, sessionKey, model string, debug bool) error { msgBus := bus.NewMessageBus() defer msgBus.Close() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + agentLoop := agent.NewAgentLoop(cfg, internal.GetConfigPath(), msgBus, provider) defer agentLoop.Close() // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() - logger.InfoCF("agent", "Agent initialized", + logger.DebugCF("agent", "Agent initialized", map[string]any{ "tools_count": startupInfo["tools"].(map[string]any)["count"], "skills_total": startupInfo["skills"].(map[string]any)["total"], diff --git a/cmd/picoclaw/internal/freeride/command.go b/cmd/picoclaw/internal/freeride/command.go new file mode 100644 index 000000000..2c2bf9112 --- /dev/null +++ b/cmd/picoclaw/internal/freeride/command.go @@ -0,0 +1,84 @@ +package freeride + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// NewFreerideCommand returns a new cobra.Command for managing OpenRouter free models. +func NewFreerideCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "freeride", + Short: "Manage OpenRouter free models and fallbacks", + Long: "FreeRide automatically discovers and configures OpenRouter's best free models as fallbacks for your PicoClaw agent.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + newListCommand(), + newAutoCommand(), + newStatusCommand(), + ) + + return cmd +} + +func newListCommand() *cobra.Command { + var limit int + cmd := &cobra.Command{ + Use: "list", + Short: "List available free models from OpenRouter", + RunE: func(cmd *cobra.Command, args []string) error { + t := tools.NewFreeRideTool(internal.GetConfigPath(), nil) + result := t.Execute(context.Background(), map[string]any{ + "command": "list", + "limit": float64(limit), + }) + fmt.Println(result.ForLLM) + return nil + }, + } + cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Number of models to list") + return cmd +} + +func newAutoCommand() *cobra.Command { + var limit int + cmd := &cobra.Command{ + Use: "auto", + Short: "Automatically configure best free models as fallbacks", + RunE: func(cmd *cobra.Command, args []string) error { + t := tools.NewFreeRideTool(internal.GetConfigPath(), nil) + result := t.Execute(context.Background(), map[string]any{ + "command": "auto", + "limit": float64(limit), + }) + fmt.Println(result.ForLLM) + return nil + }, + } + cmd.Flags().IntVarP(&limit, "limit", "l", 5, "Number of fallbacks to configure") + return cmd +} + +func newStatusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Check current FreeRide configuration", + RunE: func(cmd *cobra.Command, args []string) error { + t := tools.NewFreeRideTool(internal.GetConfigPath(), nil) + result := t.Execute(context.Background(), map[string]any{ + "command": "status", + }) + fmt.Println(result.ForLLM) + return nil + }, + } +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0867203a6..0858f2fe2 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -18,6 +18,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/freeride" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" @@ -84,6 +85,7 @@ picoclaw --no-color status`, onboard.NewOnboardCommand(), agent.NewAgentCommand(), auth.NewAuthCommand(), + freeride.NewFreerideCommand(), gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 309e60ba9..c6d53d146 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -40,6 +40,7 @@ func TestNewPicoclawCommand(t *testing.T) { "agent", "auth", "cron", + "freeride", "gateway", "migrate", "model", diff --git a/docs/guides/freeride.md b/docs/guides/freeride.md new file mode 100644 index 000000000..6af18872c --- /dev/null +++ b/docs/guides/freeride.md @@ -0,0 +1,128 @@ +# FreeRide 🦞 + +FreeRide is a dynamic model rotation and failover system for PicoClaw that leverages OpenRouter's free model pool. It ensures your agent stays alive even if individual free models become rate-limited or go offline. + +## Key Features + +- **Automatic Discovery**: Scans OpenRouter for the best currently available free models. +- **Dynamic Failover**: Automatically rotates through a pool of models when errors (like 429 Rate Limiting) occur. +- **Intelligent Ranking**: Models are scored and ranked based on context length, capabilities (tools/vision), and provider trust. +- **K3s Ready**: Designed to work seamlessly in Kubernetes environments with secure API key management. +- **Visual Provenance (🦞)**: Responses generated via a fallback model are clearly marked with a "lobster" emoji and the model name, providing transparency about which model handled your request. + +## Configuration + +FreeRide is implemented as a native PicoClaw tool. + +### 1. Enable the Tool +Ensure the `skills` tool is enabled in your `config.json` (FreeRide is bundled with the skills system): + +```json +{ + "tools": { + "skills": { + "enabled": true + } + } +} +``` + +### 2. Set the API Key +FreeRide requires an OpenRouter API key. Even for free models, many providers require a key for identification and higher rate limits. + +PicoClaw supports dynamic environment variable resolution using the `env://` scheme. + +In **Local Mode** or **Docker**, set the environment variable: +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +``` + +Then in your `config.json`, use: +```json +{ + "api_keys": ["env://OPENROUTER_API_KEY"] +} +``` +*(Note: `freeride auto` will automatically configure this for you.)* + +In **K3s Mode**, add the secret to your cluster (see below). + +## Usage + +You can interact with FreeRide directly through the agent: + +### `freeride auto` +**The most important command.** This command: +1. Fetches the current list of ~28+ free models. +2. Ranks them by quality. +3. Automatically populates your `config.json`'s `model_list`. +4. Adds the top 5 models to your agent's `model_fallbacks` list. +5. Reloads the agent configuration instantly. + +### `freeride status` +Shows your current primary model and the active fallback rotation pool. + +### `freeride list [limit]` +Displays the current top-ranked free models available on OpenRouter without modifying your configuration. + +## K3s Deployment & Secrets + +When running PicoClaw on K3s, follow these steps to manage your secrets safely. + +### Adding the Secret +If you are creating the secrets for the first time: +```bash +kubectl create secret generic picoclaw-secrets \ + --namespace agi \ + --from-literal=openrouter-api-key="YOUR_KEY_HERE" +``` + +### Updating Existing Secrets (Safe Patching) +If `picoclaw-secrets` already exists and you want to add the OpenRouter key without losing your Telegram or NVIDIA keys, use **`kubectl patch`**: + +```bash +kubectl patch secret picoclaw-secrets \ + --namespace agi \ + --type='json' \ + -p='[{"op": "add", "path": "/data/openrouter-api-key", "value":"'$(echo -n "YOUR_KEY_HERE" | base64 -w0)'"}]' +``` + +### Deployment Configuration +Ensure your `deployment.yaml` maps the secret to the environment variable: + +```yaml +env: + - name: OPENROUTER_API_KEY + valueFrom: + secretKeyRef: + name: picoclaw-secrets + key: openrouter-api-key +``` + +## Cooldown Persistence & Timing ❄️ + +To prevent the agent from "hanging" or retrying known-failed models, PicoClaw uses a two-pronged approach: + +### 1. Zero-Amnesia Persistence +Model failures (e.g., 429 Rate Limits) are saved to `~/.picoclaw/cooldowns.json`. This ensures that if you restart the agent, it **remembers** which models were saturated and skips them instantly. You no longer have to wait through a series of timeouts every time you restart. + +### 2. Aggressive 30s Timeout +The default request timeout for LLM calls is **30 seconds**. If a free model is stalled or unresponsive, the agent will move to the next fallback in your pool much faster than the standard HTTP default. + +## Troubleshooting + +- **404 Errors**: Ensure the model is still available on OpenRouter using `freeride list`. If it's gone, run `freeride auto` to refresh your fallback pool. +- **429 Rate Limiting**: This is common with free models. PicoClaw will automatically try the next model in your `model_fallbacks` list and persist the cooldown to `cooldowns.json`. + +--- + +## Legal & Responsible Use πŸ›‘οΈ + +FreeRide is provided for **personal assistance, educational research, and infrastructure failover** purposes only. By using this capability, you acknowledge and agree to the following: + +1. **Terms of Service**: You are responsible for complying with [OpenRouter's Terms of Service](https://openrouter.ai/terms) and the individual "Acceptable Use Policies" of each model provider (e.g., Google, Meta, Mistral). +2. **No Guarantee of Service**: Free models are provided "as-is" by third parties. They may be withdrawn, rate-limited, or modified at any time without notice. +3. **No Reselling**: You should not use FreeRide to build commercial services that "resell" free model access in a way that violates provider licenses (check specific model licenses like Llama 3 Community or Qwen for commercial usage thresholds). +4. **Rate Limit Respect**: PicoClaw handles failover automatically, but users should not use FreeRide to intentionally overwhelm or evade the fair-use rate limits of providers. + +*PicoClaw is an independent tool and is not affiliated with OpenRouter or any specific LLM provider.* diff --git a/docs/reference/rate-limiting.md b/docs/reference/rate-limiting.md index b54c757f8..5b550f823 100644 --- a/docs/reference/rate-limiting.md +++ b/docs/reference/rate-limiting.md @@ -25,13 +25,20 @@ AgentLoop.callLLM() The rate limiter runs **after** the cooldown check and **before** the provider call, so: - Candidates already in cooldown are skipped entirely (no token consumed) - Candidates that are available get throttled to the configured RPM - -The same check applies in `ExecuteImage`. - + +## Cooldown Persistence ❄️ + +While the Rate Limiter is **proactive**, the Cooldown Tracker is **reactive** (handled *after* a 429 is actually received). + +To ensure stability across restarts, the Cooldown Tracker persists its state to disk: +- **Location**: `~/.picoclaw/cooldowns.json` (or sibling to your workspace) +- **Behavior**: If the agent is restarted, it loads the failure history and continues to enforce cooldowns. This prevents "initialization hangs" where a new agent process tries a long list of models that are already known to be rate-limited. +- **Timing**: Default LLM request timeout is now **30 seconds** to ensure faster failover. + ### Thread safety - + `RateLimiterRegistry` is safe for concurrent use. The per-limiter token bucket uses a fine-grained mutex so concurrent goroutines each acquire their own token independently. - + ## Configuration Set `rpm` on any model in `model_list`: diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index fa33f0bb4..d5b1232ed 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -36,6 +36,41 @@ See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full |--------|------|---------|-------------| | `filter_sensitive_data` | bool | `true` | Enable/disable filtering | | `filter_min_length` | int | `8` | Minimum content length to trigger filtering | + ++## Dynamic Credential Schemes ++ ++PicoClaw supports several schemes for resolving API keys and secrets dynamically at runtime, avoiding the need to hardcode sensitive strings in your configuration file. ++ ++| Scheme | Format | Description | ++|--------|--------|-------------| ++| **Environment** | `env://NAME` | Resolves the value of the environment variable `NAME`. | ++| **File** | `file:///path/to/key.txt` | Reads the first line of the specified file. | ++| **Encrypted** | `enc://VAULT_KEY` | (Beta) Decrypts values stored in an internal secure vault. | ++ ++### Usage Example ++ ++In `config.json`: ++```json ++{ ++ "model_list": [ ++ { ++ "model_name": "gpt-5.4", ++ "api_keys": ["env://OPENAI_API_KEY"] ++ } ++ ], ++ "tools": { ++ "web": { ++ "brave": { ++ "api_keys": ["file:///run/secrets/brave_key"] ++ } ++ } ++ } ++} ++``` ++ ++### Lenient Resolution ++If an environment variable (using `env://`) is not set, PicoClaw will return an empty string and continue. This allows you to configure multiple optional keys without causing the agent to crash on startup if some are missing. ++ ## Web Tools diff --git a/examples/freeride-config.json b/examples/freeride-config.json new file mode 100644 index 000000000..77bd76743 --- /dev/null +++ b/examples/freeride-config.json @@ -0,0 +1,37 @@ +{ + "agents": { + "defaults": { + "model_name": "google/gemma-3-27b-it:free", + "model_fallbacks": [ + "google/gemma-3-27b-it:free", + "nvidia/nemotron-4-340b-instruct:free", + "qwen/qwen-2.5-72b-instruct:free", + "mistralai/mistral-small-24b-it-v1:free" + ], + "max_tokens": 4096, + "max_tool_iterations": 10 + } + }, + "model_list": [ + { + "model_name": "google/gemma-3-27b-it:free", + "model": "google/gemma-3-27b-it:free", + "protocol": "openrouter" + }, + { + "model_name": "nvidia/nemotron-4-340b-instruct:free", + "model": "nvidia/nemotron-4-340b-instruct:free", + "protocol": "openrouter" + }, + { + "model_name": "qwen/qwen-2.5-72b-instruct:free", + "model": "qwen/qwen-2.5-72b-instruct:free", + "protocol": "openrouter" + }, + { + "model_name": "mistralai/mistral-small-24b-it-v1:free", + "model": "mistralai/mistral-small-24b-it-v1:free", + "protocol": "openrouter" + } + ] +} diff --git a/pkg/agent/context_manager_test.go b/pkg/agent/context_manager_test.go index 629d11fcb..ff55cb039 100644 --- a/pkg/agent/context_manager_test.go +++ b/pkg/agent/context_manager_test.go @@ -465,7 +465,7 @@ func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) { }, } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"}) + al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "summary"}) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { @@ -617,7 +617,7 @@ func TestIngestCalledDuringTurn(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"}) + al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "done"}) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("expected default agent") @@ -763,5 +763,5 @@ func testConfig(t *testing.T) *config.Config { func newCMTestAgentLoop(cfg *config.Config) *AgentLoop { msgBus := bus.NewMessageBus() - return NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "test"}) + return NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "test"}) } diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go index e405ef944..b3e950527 100644 --- a/pkg/agent/context_seahorse_test.go +++ b/pkg/agent/context_seahorse_test.go @@ -534,7 +534,7 @@ func TestSeahorseRealLoopNoDuplicateMessages(t *testing.T) { msgBus := bus.NewMessageBus() mockProvider := &simpleMockProvider{response: "I received your message."} - al := NewAgentLoop(cfg, msgBus, mockProvider) + al := NewAgentLoop(cfg, "", msgBus, mockProvider) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("expected default agent") @@ -885,7 +885,7 @@ func TestSeahorseSteeringMessageIngested(t *testing.T) { msgBus := bus.NewMessageBus() mockProvider := &simpleMockProvider{response: "I received your message."} - al := NewAgentLoop(cfg, msgBus, mockProvider) + al := NewAgentLoop(cfg, "", msgBus, mockProvider) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("expected default agent") @@ -992,7 +992,7 @@ func TestSeahorseSummarizeSkipsCondensedWhenBelowThreshold(t *testing.T) { msgBus := bus.NewMessageBus() provider := &seahorseTestProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("expected default agent") diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 31b996260..1ac3ae2ea 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -120,7 +120,7 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { msgBus := bus.NewMessageBus() provider := &scriptedToolProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.RegisterTool(&mockCustomTool{}) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { @@ -305,7 +305,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.RegisterTool(tool1) al.RegisterTool(tool2) @@ -406,7 +406,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { successResp: "Recovered from context error", } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("expected default agent") @@ -493,7 +493,7 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary text"}) + al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "summary text"}) defaultAgent := al.registry.GetDefaultAgent() if defaultAgent == nil { t.Fatal("expected default agent") @@ -563,7 +563,7 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) doneCh := make(chan struct{}) al.RegisterTool(&asyncFollowUpTool{ name: "async_followup", diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go index 85d8f5c11..dff3146b7 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -55,7 +55,7 @@ func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks co Hooks: hooks, } - return NewAgentLoop(cfg, bus.NewMessageBus(), provider) + return NewAgentLoop(cfg, "", bus.NewMessageBus(), provider) } func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) { diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index eb76c4da8..84da4c112 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -38,7 +38,7 @@ func newHookTestLoop( }, } - al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + al := NewAgentLoop(cfg, "", bus.NewMessageBus(), provider) agent := al.registry.GetDefaultAgent() if agent == nil { t.Fatal("expected default agent") diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fb6f95edf..c49ef5c54 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -27,7 +27,6 @@ import ( "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/state" - "github.com/sipeed/picoclaw/pkg/utils" ) type AgentLoop struct { @@ -64,6 +63,8 @@ type AgentLoop struct { turnSeq atomic.Uint64 activeRequests sync.WaitGroup + configPath string + cooldownPath string reloadFunc func() error @@ -265,6 +266,14 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } +func (al *AgentLoop) GetReloadFunc() func() error { + return al.reloadFunc +} + +func (al *AgentLoop) GetConfigPath() string { + return al.configPath +} + // Close releases resources held by agent session stores. Call after Stop. func (al *AgentLoop) Close() { mcpManager := al.mcp.takeManager() @@ -378,7 +387,7 @@ func (al *AgentLoop) ReloadProviderAndConfig( newRL.RegisterCandidates(agent.LightCandidates) } } - al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(al.cooldownPath), newRL) al.mu.Unlock() @@ -513,7 +522,12 @@ func (al *AgentLoop) runAgentLoop( } } - if opts.SendResponse && result.finalContent != "" { + finalContent := result.finalContent + if usedFallback, fallbackModel := ts.GetFallbackInfo(); usedFallback { + finalContent += fmt.Sprintf("\n\n🦞 _(FreeRide: %s)_", fallbackModel) + } + + if opts.SendResponse && finalContent != "" { agentID, sessionKey, scope := outboundTurnMetadata( agent.ID, opts.Dispatch.SessionKey, @@ -529,22 +543,11 @@ func (al *AgentLoop) runAgentLoop( AgentID: agentID, SessionKey: sessionKey, Scope: scope, - Content: result.finalContent, + Content: finalContent, }) } - if result.finalContent != "" { - responsePreview := utils.Truncate(result.finalContent, 120) - logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]any{ - "agent_id": agent.ID, - "session_key": opts.Dispatch.SessionKey, - "iterations": ts.currentIteration(), - "final_length": len(result.finalContent), - }) - } - - return result.finalContent, nil + return finalContent, nil } // selectCandidates returns the model candidates and resolved model name to use diff --git a/pkg/agent/loop_event.go b/pkg/agent/loop_event.go index 510c339c1..40fb8791a 100644 --- a/pkg/agent/loop_event.go +++ b/pkg/agent/loop_event.go @@ -160,7 +160,7 @@ func (al *AgentLoop) logEvent(evt Event) { fields["error"] = payload.Message } - logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields) + logger.DebugF("Agent event: "+evt.Kind.String(), fields) } // MountHook registers an in-process hook on the agent loop. diff --git a/pkg/agent/loop_init.go b/pkg/agent/loop_init.go index 359dc8060..5bee267b2 100644 --- a/pkg/agent/loop_init.go +++ b/pkg/agent/loop_init.go @@ -5,6 +5,7 @@ package agent import ( "context" "fmt" + "path/filepath" "time" "github.com/sipeed/picoclaw/pkg/audio/tts" @@ -21,13 +22,15 @@ import ( func NewAgentLoop( cfg *config.Config, + configPath string, msgBus *bus.MessageBus, provider providers.LLMProvider, ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) // Set up shared fallback chain with rate limiting. - cooldown := providers.NewCooldownTracker() + cooldownPath := filepath.Join(filepath.Dir(filepath.Clean(registry.GetDefaultAgent().Workspace)), "cooldowns.json") + cooldown := providers.NewCooldownTracker(cooldownPath) rl := providers.NewRateLimiterRegistry() // Register rate limiters for all agents' candidates so that RPM limits // configured in ModelConfig are enforced before each LLM call. @@ -55,15 +58,17 @@ func NewAgentLoop( } al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - eventBus: eventBus, - fallback: fallbackChain, - cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), - workerSem: make(chan struct{}, workerPoolSize), + bus: msgBus, + cfg: cfg, + configPath: configPath, + cooldownPath: cooldownPath, + registry: registry, + state: stateManager, + eventBus: eventBus, + fallback: fallbackChain, + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), + workerSem: make(chan struct{}, workerPoolSize), } al.providerFactory = providers.CreateProviderFromConfig al.hooks = NewHookManager(eventBus) @@ -229,6 +234,10 @@ func registerSharedTools( // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") + if skills_enabled { + agent.Tools.Register(tools.NewFreeRideTool(al.GetConfigPath(), al.GetReloadFunc())) + } + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") if skills_enabled && (find_skills_enable || install_skills_enable) { diff --git a/pkg/agent/loop_message.go b/pkg/agent/loop_message.go index 96b0b0817..c0509dfdd 100644 --- a/pkg/agent/loop_message.go +++ b/pkg/agent/loop_message.go @@ -112,7 +112,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } else { logContent = utils.Truncate(msg.Content, 80) } - logger.InfoCF( + logger.DebugCF( "agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), map[string]any{ @@ -156,7 +156,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } } - logger.InfoCF("agent", "Routed message", + logger.DebugCF("agent", "Routed message", map[string]any{ "agent_id": agent.ID, "scope_key": scopeKey, diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 5cdac186c..90025df3e 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -156,7 +156,7 @@ func newTestAgentLoop( } msgBus = bus.NewMessageBus() provider = &mockProvider{} - al = NewAgentLoop(cfg, msgBus, provider) + al = NewAgentLoop(cfg, "", msgBus, provider) return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } } @@ -180,7 +180,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ Channel: "discord", @@ -239,7 +239,7 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ Channel: "telegram", @@ -290,7 +290,7 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) { msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) useTestSideQuestionProvider(al, provider) defaultAgent := al.GetRegistry().GetDefaultAgent() if defaultAgent == nil { @@ -360,7 +360,7 @@ func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(t *testing.T) { msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) useTestSideQuestionProvider(al, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ @@ -419,7 +419,7 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) { msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) useTestSideQuestionProvider(al, provider) defaultAgent := al.GetRegistry().GetDefaultAgent() if defaultAgent == nil { @@ -486,7 +486,7 @@ func TestProcessMessage_BtwCommandRetriesWithoutMediaOnVisionUnsupported(t *test msgBus := bus.NewMessageBus() provider := &visionUnsupportedMediaProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) useTestSideQuestionProvider(al, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ @@ -530,7 +530,7 @@ func TestProcessMessage_BtwCommandUsesProviderFactoryModel(t *testing.T) { msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) useTestSideQuestionProvider(al, provider) response, err := al.processMessage(context.Background(), bus.InboundMessage{ @@ -572,7 +572,7 @@ func TestProcessMessage_BtwCommandHookModelBypassesFallbackCandidates(t *testing msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) useTestSideQuestionProvider(al, provider) if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "hook-model"})); err != nil { t.Fatalf("MountHook failed: %v", err) @@ -609,7 +609,7 @@ func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) agent := al.GetRegistry().GetDefaultAgent() opts := processOptions{} @@ -653,7 +653,7 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &recordingProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ Channel: "telegram", @@ -785,7 +785,7 @@ func TestRecordLastChannel(t *testing.T) { if got := al.state.GetLastChannel(); got != testChannel { t.Errorf("Expected channel '%s', got '%s'", testChannel, got) } - al2 := NewAgentLoop(cfg, msgBus, provider) + al2 := NewAgentLoop(cfg, "", msgBus, provider) if got := al2.state.GetLastChannel(); got != testChannel { t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) } @@ -802,7 +802,7 @@ func TestRecordLastChatID(t *testing.T) { if got := al.state.GetLastChatID(); got != testChatID { t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) } - al2 := NewAgentLoop(cfg, msgBus, provider) + al2 := NewAgentLoop(cfg, "", msgBus, provider) if got := al2.state.GetLastChatID(); got != testChatID { t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) } @@ -831,7 +831,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) { // Create agent loop msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Verify state manager is initialized if al.state == nil { @@ -866,7 +866,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Register a custom tool customTool := &mockCustomTool{} @@ -937,7 +937,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Register a test tool and verify it shows up in startup info testTool := &mockCustomTool{} @@ -969,7 +969,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. msgBus := bus.NewMessageBus() provider := &handledMediaProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) store := media.NewFileMediaStore() al.SetMediaStore(store) @@ -1068,7 +1068,7 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes msgBus := bus.NewMessageBus() provider := &handledMediaWithSteeringProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) store := media.NewFileMediaStore() al.SetMediaStore(store) @@ -1116,7 +1116,7 @@ func TestRunAgentLoop_ResponseHandledToolPublishesForUserWhenSendResponseDisable msgBus := bus.NewMessageBus() provider := &handledUserProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) store := media.NewFileMediaStore() al.SetMediaStore(store) @@ -1267,7 +1267,7 @@ func TestResolveMessageRoute_UsesInboundContextAccount(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"}) + al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "ok"}) route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{ Context: bus.InboundContext{ @@ -1338,7 +1338,7 @@ func TestResolveMessageRoute_UsesDispatchRulesInOrder(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"}) + al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "ok"}) route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{ Context: bus.InboundContext{ @@ -1373,7 +1373,7 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { msgBus := bus.NewMessageBus() provider := &artifactThenSendProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) store := media.NewFileMediaStore() al.SetMediaStore(store) @@ -1443,7 +1443,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) info := al.GetStartupInfo() @@ -1490,7 +1490,7 @@ func TestAgentLoop_Stop(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Note: running is only set to true when Run() is called // We can't test that without starting the event loop @@ -2153,7 +2153,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "ok"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) msg := bus.InboundMessage{ Context: bus.InboundContext{ @@ -2208,7 +2208,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} baseMsg := bus.InboundMessage{ @@ -2304,7 +2304,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -2361,7 +2361,7 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -2437,7 +2437,7 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t if err != nil { t.Fatalf("CreateProvider() error = %v", err) } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -2555,7 +2555,7 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) { if err != nil { t.Fatalf("CreateProvider() error = %v", err) } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -2636,7 +2636,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) { t.Fatalf("CreateProvider() error = %v", err) } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -2646,8 +2646,8 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) { Content: "hi", }) - if resp != "fallback reply" { - t.Fatalf("response = %q, want %q (fallback provider)", resp, "fallback reply") + if !strings.HasPrefix(resp, "fallback reply") || !strings.Contains(resp, "🦞") { + t.Fatalf("response = %q, want it to contain %q and 🦞 (fallback provider)", resp, "fallback reply") } if primaryCalls == 0 { t.Fatal("primary server was never called; expected at least one attempt") @@ -2713,7 +2713,7 @@ func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t * t.Fatalf("CreateProvider() error = %v", err) } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -2723,8 +2723,8 @@ func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t * Content: "hi", }) - if resp != "active provider reply" { - t.Fatalf("response = %q, want %q", resp, "active provider reply") + if !strings.HasPrefix(resp, "active provider reply") || !strings.Contains(resp, "🦞") { + t.Fatalf("response = %q, want it to contain %q and 🦞", resp, "active provider reply") } if callCount < 2 { t.Fatalf("primary server calls = %d, want >= 2 (one 429 + one success via activeProvider)", callCount) @@ -2752,7 +2752,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "File operation complete"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} // ReadFileTool returns SilentResult, which should not send user message @@ -2794,7 +2794,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "Command output: hello world"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) helper := testHelper{al: al} // ExecTool returns UserResult, which should send user message @@ -2873,7 +2873,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { successResp: "Recovered from context error", } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Inject some history to simulate a full context. // Session history only stores user/assistant/tool messages β€” the system @@ -2984,7 +2984,7 @@ func TestAgentLoop_VisionUnsupportedErrorStripsSessionMedia(t *testing.T) { msgBus := bus.NewMessageBus() provider := &visionUnsupportedMediaProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) sessionKey := "agent:main:telegram:direct:user1" @@ -3075,7 +3075,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: ""} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1") if err != nil { @@ -3106,7 +3106,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { msgBus := bus.NewMessageBus() provider := &toolLimitOnlyProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.RegisterTool(&toolLimitTestTool{}) response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1") @@ -3173,7 +3173,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) defer al.Close() if al.mcp.hasManager() { @@ -3215,7 +3215,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) { }, } - al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + al := NewAgentLoop(cfg, "", bus.NewMessageBus(), &mockProvider{}) chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) if err != nil { t.Fatalf("Failed to create channel manager: %v", err) @@ -3283,7 +3283,7 @@ func TestHandleReasoning(t *testing.T) { }, } msgBus := bus.NewMessageBus() - return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus + return NewAgentLoop(cfg, "", msgBus, &mockProvider{}), msgBus } t.Run("skips when any required field is empty", func(t *testing.T) { @@ -3453,7 +3453,7 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T response: "final answer", reasoningContent: "thinking trace", } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) chManager, err := channels.NewManager(&config.Config{}, msgBus, nil) if err != nil { @@ -3512,7 +3512,7 @@ func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) { response: "final answer", reasoningContent: "thinking trace", } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.processMessage(context.Background(), bus.InboundMessage{ Channel: "pico", @@ -3583,7 +3583,7 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { msgBus := bus.NewMessageBus() provider := &toolFeedbackProvider{filePath: heartbeatFile} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1") if err != nil { @@ -3629,7 +3629,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { msgBus := bus.NewMessageBus() provider := &toolFeedbackProvider{filePath: heartbeatFile} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ Channel: "telegram", @@ -3682,7 +3682,7 @@ func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing. msgBus := bus.NewMessageBus() provider := &messageToolProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ Channel: "telegram", @@ -3735,7 +3735,7 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t msgBus := bus.NewMessageBus() provider := &picoInterleavedContentProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) agent := al.GetRegistry().GetDefaultAgent() if agent == nil { @@ -3813,7 +3813,7 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) { msgBus := bus.NewMessageBus() provider := &picoInterleavedContentProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) agent := al.GetRegistry().GetDefaultAgent() if agent == nil { @@ -4377,7 +4377,7 @@ func TestParallelMessageProcessing_DifferentSessionsProcessedConcurrently(t *tes }, } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) defer al.Close() ctx, cancel := context.WithCancel(context.Background()) @@ -4468,7 +4468,7 @@ func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T msgBus := bus.NewMessageBus() defer msgBus.Close() - al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{ + al := NewAgentLoop(cfg, "", msgBus, &concurrentMockProvider{ responseFunc: func(callID int) string { wg.Done() return "ok" diff --git a/pkg/agent/loop_turn.go b/pkg/agent/loop_turn.go index 1085ddeae..7bce21b29 100644 --- a/pkg/agent/loop_turn.go +++ b/pkg/agent/loop_turn.go @@ -387,6 +387,11 @@ turnLoop: fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, ) + displayName := fbResult.Model + if strings.HasPrefix(fbResult.IdentityKey, "model_name:") { + displayName = strings.TrimPrefix(fbResult.IdentityKey, "model_name:") + } + ts.SetFallbackInfo(true, displayName) } return fbResult.Response, nil } @@ -600,10 +605,10 @@ turnLoop: reasoningContent = response.ReasoningContent } if ts.channel == "pico" { - go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + go al.publishPicoReasoning(ctx, reasoningContent, ts.chatID) } else { go al.handleReasoning( - turnCtx, + ctx, reasoningContent, ts.channel, al.targetReasoningChannelID(ts.channel), @@ -671,7 +676,7 @@ turnLoop: continue } finalContent = responseContent - logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + logger.DebugCF("agent", "LLM response without tool calls (direct answer)", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, @@ -1051,7 +1056,7 @@ turnLoop: argsJSON, _ := json.Marshal(toolArgs) argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), + logger.DebugCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), map[string]any{ "agent_id": ts.agent.ID, "tool": toolName, diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 8aa11e37b..dfa0bc8de 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -36,14 +36,14 @@ func NewAgentRegistry( } instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) registry.agents["main"] = instance - logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) + logger.DebugCF("agent", "Created implicit main agent (no agents.list configured)", nil) } else { for i := range agentConfigs { ac := &agentConfigs[i] id := routing.NormalizeAgentID(ac.ID) instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) registry.agents[id] = instance - logger.InfoCF("agent", "Registered agent", + logger.DebugCF("agent", "Registered agent", map[string]any{ "agent_id": id, "name": ac.Name, diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index bba988672..3e84cf44f 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -278,7 +278,7 @@ func TestAgentLoop_SteeringMode_ConfiguredFromConfig(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) if al.SteeringMode() != SteeringAll { t.Fatalf("expected 'all' mode from config, got %v", al.SteeringMode()) @@ -328,7 +328,7 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "continued response"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.Steer(providers.Message{Role: "user", Content: "new direction"}) @@ -594,7 +594,7 @@ func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.RegisterTool(tool1) al.RegisterTool(tool2) @@ -682,7 +682,7 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Enqueue a steering message before processing starts al.Steer(providers.Message{Role: "user", Content: "pre-enqueued steering"}) @@ -740,7 +740,7 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { firstCallStarted: make(chan struct{}), releaseFirstCall: make(chan struct{}), } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) runCtx, cancelRun := context.WithCancel(context.Background()) defer cancelRun() @@ -866,7 +866,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing. } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) resultCh := make(chan struct { resp string @@ -943,7 +943,7 @@ func TestAgentLoop_AgentForSession_UsesStoredScopeMetadata(t *testing.T) { }, } - al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + al := NewAgentLoop(cfg, "", bus.NewMessageBus(), &mockProvider{}) support, ok := al.registry.GetAgent("support") if !ok || support == nil { t.Fatal("expected support agent") @@ -1026,7 +1026,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.SetMediaStore(store) if err = al.Steer(providers.Message{ @@ -1129,7 +1129,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) al.RegisterTool(tool1) al.RegisterTool(tool2) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) @@ -1283,7 +1283,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { finalResp: "should not happen", } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) started := make(chan struct{}) al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) @@ -1475,7 +1475,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { } msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, wrappedProvider) + al := NewAgentLoop(cfg, "", msgBus, wrappedProvider) al.RegisterTool(tool1) al.RegisterTool(tool2) diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a2ba835d..1e57010d7 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -850,7 +850,7 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) { }, }, } - al := NewAgentLoop(cfg, bus.NewMessageBus(), panicProvider) + al := NewAgentLoop(cfg, "", bus.NewMessageBus(), panicProvider) parent := &turnState{ ctx: context.Background(), @@ -943,7 +943,7 @@ func TestGetActiveTurn(t *testing.T) { }, }, } - al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + al := NewAgentLoop(cfg, "", nil, &simpleMockProviderAPI{response: "ok"}) // Create a root turn state rootCtx := context.Background() @@ -1001,7 +1001,7 @@ func TestGetActiveTurn_WithChildren(t *testing.T) { }, }, } - al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + al := NewAgentLoop(cfg, "", nil, &simpleMockProviderAPI{response: "ok"}) rootCtx := context.Background() rootTS := &turnState{ @@ -1083,7 +1083,7 @@ func TestInjectFollowUp(t *testing.T) { }, } - al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + al := NewAgentLoop(cfg, "", nil, &simpleMockProviderAPI{response: "ok"}) msg := providers.Message{ Role: "user", @@ -1112,7 +1112,7 @@ func TestAPIAliases(t *testing.T) { }, } - al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + al := NewAgentLoop(cfg, "", nil, &simpleMockProviderAPI{response: "ok"}) msg := providers.Message{ Role: "user", @@ -1150,7 +1150,7 @@ func TestInterruptHard_Alias(t *testing.T) { }, }, } - al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + al := NewAgentLoop(cfg, "", nil, &simpleMockProviderAPI{response: "ok"}) rootCtx := context.Background() rootTS := &turnState{ @@ -1327,7 +1327,7 @@ func TestConcurrencySemaphore_Timeout(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &simpleMockProviderAPI{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ @@ -1427,7 +1427,7 @@ func TestContextWrapping_SingleLayer(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &simpleMockProviderAPI{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ @@ -1473,7 +1473,7 @@ func TestSyncSubTurn_NoChannelDelivery(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &simpleMockProviderAPI{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ @@ -1530,7 +1530,7 @@ func TestAsyncSubTurn_ChannelDelivery(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &simpleMockProviderAPI{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ @@ -1662,7 +1662,7 @@ func TestSpawnDuringAbort_RaceCondition(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &simpleMockProviderAPI{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ @@ -1761,7 +1761,7 @@ func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &slowMockProvider{delay: 5 * time.Second} // SubTurn takes 5 seconds - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) // Capture events via real EventBus var mu sync.Mutex @@ -1847,7 +1847,7 @@ func TestAsyncSubTurn_ParentWaitsForChild(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &slowMockProvider{delay: 200 * time.Millisecond} // SubTurn takes 200ms - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ @@ -2014,7 +2014,7 @@ func TestSubTurn_IndependentContext(t *testing.T) { } msgBus := bus.NewMessageBus() provider := &slowMockProvider{delay: 500 * time.Millisecond} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, "", msgBus, provider) ctx := context.Background() parentTS := &turnState{ diff --git a/pkg/agent/turn.go b/pkg/agent/turn.go index cc67ec926..836ada49b 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn.go @@ -104,6 +104,8 @@ type turnState struct { tokenBudget *atomic.Int64 // Shared token budget counter lastFinishReason string // Last LLM finish_reason lastUsage *providers.UsageInfo // Last LLM usage info + usedFallback bool // Whether a fallback/FreeRide model was used + fallbackModel string // The name of the fallback model used // Back-reference to the owning AgentLoop (set for SubTurns only, used for hard abort cascade) al *AgentLoop @@ -493,6 +495,25 @@ func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) { ts.lastUsage = usage } +/** + * pico: freeride support + */ + +// SetFallbackInfo sets fallback model info +func (ts *turnState) SetFallbackInfo(used bool, model string) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.usedFallback = used + ts.fallbackModel = model +} + +// GetFallbackInfo returns fallback model info +func (ts *turnState) GetFallbackInfo() (bool, string) { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.usedFallback, ts.fallbackModel +} + // Context helper functions for SubTurn type turnStateKeyType struct{} diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 1cab1a4a6..56ebd342b 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -1376,7 +1376,7 @@ type PlaceholderRecorder interface { // 1. Create core components msgBus := bus.NewMessageBus() provider := providers.CreateProvider(cfg) -agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) +agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider) // 2. Create media store (with TTL cleanup) mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig) diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index c44859c20..37f56fe1a 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -1374,7 +1374,7 @@ type PlaceholderRecorder interface { // 1. εˆ›ε»Ίζ ΈεΏƒη»„δ»Ά msgBus := bus.NewMessageBus() provider := providers.CreateProvider(cfg) -agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) +agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider) // 2. εˆ›ε»Ίεͺ’δ½“ε­˜ε‚¨οΌˆεΈ¦ TTL 清理) mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5bc96fb12..ca367647c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -530,8 +530,9 @@ type VoiceConfig struct { // Default protocol is "openai" if no prefix is specified. type ModelConfig struct { // Required fields - ModelName string `json:"model_name"` // User-facing alias for the model - Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") + ModelName string `json:"model_name"` // User-facing alias for the model + Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") + Protocol string `json:"protocol,omitempty"` // Explicit protocol (e.g., "openai", "openrouter", "anthropic") // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go index 6eaf32bc1..8271e3746 100644 --- a/pkg/config/config_struct.go +++ b/pkg/config/config_struct.go @@ -245,8 +245,10 @@ func (s *SecureString) UnmarshalJSON(value []byte) error { } func (s SecureString) MarshalYAML() (any, error) { - // Preserve raw value if it is already a reference (enc:// or file://) - if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { + // Preserve raw value if it is already a reference (enc://, file://, or env://) + if strings.HasPrefix(s.raw, credential.EncScheme) || + strings.HasPrefix(s.raw, credential.FileScheme) || + strings.HasPrefix(s.raw, credential.EnvScheme) { return s.raw, nil } // If resolved is a reference format (e.g. set via Set), copy back to raw @@ -300,10 +302,11 @@ func resolveKey(v string) (string, error) { if resolver == nil { resolver = credential.NewResolver("") } - if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") { + if strings.HasPrefix(v, credential.EncScheme) || + strings.HasPrefix(v, credential.FileScheme) || + strings.HasPrefix(v, credential.EnvScheme) { decrypted, err := resolver.Resolve(v) if err != nil { - logger.Errorf("Resolve error: %v", err) return "", err } return decrypted, nil diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go index 8ecd6783b..634ca8b2c 100644 --- a/pkg/credential/credential.go +++ b/pkg/credential/credential.go @@ -77,6 +77,7 @@ const picoclawHome = "PICOCLAW_HOME" const ( FileScheme = "file://" EncScheme = "enc://" + EnvScheme = "env://" hkdfInfo = "picoclaw-credential-v1" saltLen = 16 @@ -149,6 +150,17 @@ func (r *Resolver) Resolve(raw string) (string, error) { return resolveEncrypted(raw) } + if strings.HasPrefix(raw, EnvScheme) { + envVar := strings.TrimPrefix(raw, EnvScheme) + val := os.Getenv(envVar) + if val == "" { + // Do not return an error here, just return empty string. + // This prevents the whole agent from failing to start if an optional key is missing. + return "", nil + } + return strings.TrimSpace(val), nil + } + // Plaintext credential β€” return unchanged. return raw, nil } diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 039f45075..c005eef2a 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -197,7 +197,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr } msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider) fmt.Println("\nπŸ“¦ Agent Status:") startupInfo := agentLoop.GetStartupInfo() diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index 90142fb8b..2e137e6b1 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -36,7 +36,7 @@ type ( ReasoningDetail = protocoltypes.ReasoningDetail ) -const DefaultRequestTimeout = 120 * time.Second +const DefaultRequestTimeout = 30 * time.Second // NewHTTPClient creates an *http.Client with an optional proxy and the default timeout. func NewHTTPClient(proxy string) *http.Client { diff --git a/pkg/providers/cooldown.go b/pkg/providers/cooldown.go index b0d8608dc..0fa02c8f3 100644 --- a/pkg/providers/cooldown.go +++ b/pkg/providers/cooldown.go @@ -1,7 +1,10 @@ package providers import ( + "encoding/json" "math" + "os" + "path/filepath" "sync" "time" ) @@ -16,6 +19,7 @@ type CooldownTracker struct { mu sync.RWMutex entries map[string]*cooldownEntry failureWindow time.Duration + storagePath string nowFunc func() time.Time // for testing } @@ -29,20 +33,23 @@ type cooldownEntry struct { } // NewCooldownTracker creates a tracker with default 24h failure window. -func NewCooldownTracker() *CooldownTracker { - return &CooldownTracker{ +func NewCooldownTracker(storagePath string) *CooldownTracker { + ct := &CooldownTracker{ entries: make(map[string]*cooldownEntry), failureWindow: defaultFailureWindow, + storagePath: storagePath, nowFunc: time.Now, } + if storagePath != "" { + ct.Load() + } + return ct } // MarkFailure records a failure for a provider and sets appropriate cooldown. // Resets error counts if last failure was more than failureWindow ago. func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) { ct.mu.Lock() - defer ct.mu.Unlock() - now := ct.nowFunc() entry := ct.getOrCreate(provider) @@ -53,6 +60,9 @@ func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) { } entry.ErrorCount++ + if entry.FailureCounts == nil { + entry.FailureCounts = make(map[FailoverReason]int) + } entry.FailureCounts[reason]++ entry.LastFailure = now @@ -63,15 +73,20 @@ func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) { } else { entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount)) } + + // Capture state for saving outside the lock + toSave := ct.copyEntriesLocked() + ct.mu.Unlock() + + ct.persist(toSave) } // MarkSuccess resets all counters and cooldowns for a provider. func (ct *CooldownTracker) MarkSuccess(provider string) { ct.mu.Lock() - defer ct.mu.Unlock() - entry := ct.entries[provider] if entry == nil { + ct.mu.Unlock() return } @@ -80,6 +95,12 @@ func (ct *CooldownTracker) MarkSuccess(provider string) { entry.CooldownEnd = time.Time{} entry.DisabledUntil = time.Time{} entry.DisabledReason = "" + + // Capture state for saving outside the lock + toSave := ct.copyEntriesLocked() + ct.mu.Unlock() + + ct.persist(toSave) } // IsAvailable returns true if the provider is not in cooldown or disabled. @@ -162,6 +183,93 @@ func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) return entry.FailureCounts[reason] } +// Load reads cooldown state from disk. +func (ct *CooldownTracker) Load() { + if ct.storagePath == "" { + return + } + + data, err := os.ReadFile(ct.storagePath) + if err != nil { + return // ignore missing file + } + + var loaded map[string]*cooldownEntry + if err := json.Unmarshal(data, &loaded); err != nil { + return + } + + ct.mu.Lock() + defer ct.mu.Unlock() + + now := ct.nowFunc() + for k, v := range loaded { + // Only load entries that aren't fully expired yet + if (!v.CooldownEnd.IsZero() && now.Before(v.CooldownEnd)) || + (!v.DisabledUntil.IsZero() && now.Before(v.DisabledUntil)) || + (!v.LastFailure.IsZero() && now.Sub(v.LastFailure) < ct.failureWindow) { + if v.FailureCounts == nil { + v.FailureCounts = make(map[FailoverReason]int) + } + ct.entries[k] = v + } + } +} + +// Save writes cooldown state to disk. +func (ct *CooldownTracker) Save() { + ct.mu.RLock() + toSave := ct.copyEntriesLocked() + ct.mu.RUnlock() + + ct.persist(toSave) +} + +func (ct *CooldownTracker) copyEntriesLocked() map[string]*cooldownEntry { + toSave := make(map[string]*cooldownEntry) + now := ct.nowFunc() + for k, v := range ct.entries { + if v == nil { + continue + } + // Only save entries that are still relevant + if (!v.CooldownEnd.IsZero() && now.Before(v.CooldownEnd)) || + (!v.DisabledUntil.IsZero() && now.Before(v.DisabledUntil)) || + (!v.LastFailure.IsZero() && now.Sub(v.LastFailure) < ct.failureWindow) { + + // Deep copy the entry to avoid data races when serializing outside the lock + copy := *v + if v.FailureCounts != nil { + copy.FailureCounts = make(map[FailoverReason]int) + for r, c := range v.FailureCounts { + copy.FailureCounts[r] = c + } + } + toSave[k] = © + } + } + return toSave +} + +func (ct *CooldownTracker) persist(toSave map[string]*cooldownEntry) { + if ct.storagePath == "" { + return + } + + if len(toSave) == 0 { + os.Remove(ct.storagePath) // cleanup if empty + return + } + + data, err := json.MarshalIndent(toSave, "", " ") + if err != nil { + return + } + + os.MkdirAll(filepath.Dir(ct.storagePath), 0755) + os.WriteFile(ct.storagePath, data, 0644) +} + func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry { entry := ct.entries[provider] if entry == nil { diff --git a/pkg/providers/cooldown_test.go b/pkg/providers/cooldown_test.go index b517e7feb..22e6f6240 100644 --- a/pkg/providers/cooldown_test.go +++ b/pkg/providers/cooldown_test.go @@ -8,13 +8,13 @@ import ( func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) { current := now - ct := NewCooldownTracker() + ct := NewCooldownTracker("") ct.nowFunc = func() time.Time { return current } return ct, ¤t } func TestCooldown_InitiallyAvailable(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") if !ct.IsAvailable("openai") { t.Error("new provider should be available") } @@ -110,7 +110,7 @@ func TestCooldown_BillingCap(t *testing.T) { } func TestCooldown_SuccessReset(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("openai", FailoverBilling) @@ -157,7 +157,7 @@ func TestCooldown_FailureWindowReset(t *testing.T) { } func TestCooldown_PerReasonTracking(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("openai", FailoverRateLimit) @@ -218,7 +218,7 @@ func TestCooldown_CooldownRemaining(t *testing.T) { } func TestCooldown_SuccessOnUnknownProvider(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") // Should not panic ct.MarkSuccess("nonexistent") if !ct.IsAvailable("nonexistent") { @@ -227,7 +227,7 @@ func TestCooldown_SuccessOnUnknownProvider(t *testing.T) { } func TestCooldown_ConcurrentAccess(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") var wg sync.WaitGroup for range 100 { @@ -251,7 +251,7 @@ func TestCooldown_ConcurrentAccess(t *testing.T) { } func TestCooldown_MultipleProviders(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("anthropic", FailoverBilling) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index ab68b326a..408fca170 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -109,6 +109,9 @@ func ResolveAPIBase(cfg *config.ModelConfig) string { return strings.TrimRight(apiBase, "/") } protocol, _ := ExtractProtocol(cfg.Model) + if cfg.Protocol != "" { + protocol = cfg.Protocol + } return strings.TrimRight(getDefaultAPIBase(protocol), "/") } @@ -128,6 +131,10 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } protocol, modelID := ExtractProtocol(cfg.Model) + if cfg.Protocol != "" { + protocol = cfg.Protocol + modelID = cfg.Model + } userAgent := cfg.UserAgent if userAgent == "" { @@ -224,13 +231,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding", "mimo": // All other OpenAI-compatible HTTP providers - if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { - return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) - } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } + if cfg.APIKey() == "" && apiBase == "" && !isEmptyAPIKeyAllowed(protocol) { + return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) + } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 36092105b..0e46594ba 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -32,10 +32,11 @@ func (c FallbackCandidate) StableKey() string { // FallbackResult contains the successful response and metadata about all attempts. type FallbackResult struct { - Response *LLMResponse - Provider string - Model string - Attempts []FallbackAttempt + Response *LLMResponse + Provider string + Model string + IdentityKey string + Attempts []FallbackAttempt } // FallbackAttempt records one attempt in the fallback chain. @@ -187,6 +188,7 @@ func (fc *FallbackChain) Execute( result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model + result.IdentityKey = candidate.IdentityKey return result, nil } @@ -305,6 +307,7 @@ func (fc *FallbackChain) ExecuteImage( result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model + result.IdentityKey = candidate.IdentityKey return result, nil } diff --git a/pkg/providers/fallback_multikey_test.go b/pkg/providers/fallback_multikey_test.go index 10481ec61..0efae6d3c 100644 --- a/pkg/providers/fallback_multikey_test.go +++ b/pkg/providers/fallback_multikey_test.go @@ -24,7 +24,7 @@ func TestMultiKeyFailover(t *testing.T) { } // Create fallback chain - cooldown := NewCooldownTracker() + cooldown := NewCooldownTracker("") chain := NewFallbackChain(cooldown, nil) // Mock run function: first call fails with 429, second succeeds @@ -81,7 +81,7 @@ func TestMultiKeyFailoverAllFail(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") - cooldown := NewCooldownTracker() + cooldown := NewCooldownTracker("") chain := NewFallbackChain(cooldown, nil) // Mock run function: all calls fail with rate limit @@ -126,7 +126,7 @@ func TestMultiKeyFailoverCooldown(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") - cooldown := NewCooldownTracker() + cooldown := NewCooldownTracker("") chain := NewFallbackChain(cooldown, nil) // Put the first model in cooldown (using ModelKey now, not just provider) @@ -182,7 +182,7 @@ func TestMultiKeyFailoverWithFormatError(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") - cooldown := NewCooldownTracker() + cooldown := NewCooldownTracker("") chain := NewFallbackChain(cooldown, nil) // Mock run function: first call fails with format error (bad request) @@ -262,7 +262,7 @@ func TestMultiKeyWithModelFallback(t *testing.T) { ) } - cooldown := NewCooldownTracker() + cooldown := NewCooldownTracker("") chain := NewFallbackChain(cooldown, nil) // Mock run function: first two fail, third succeeds (model fallback) @@ -336,7 +336,7 @@ func TestMultiKeyFailoverMixedErrors(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") - cooldown := NewCooldownTracker() + cooldown := NewCooldownTracker("") chain := NewFallbackChain(cooldown, nil) // Mock run function: different errors for each key diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 07cc01baa..16b35f65d 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -18,7 +18,7 @@ func successRun(content string) func(ctx context.Context, provider, model string } func TestFallback_SingleCandidate_Success(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} @@ -35,7 +35,7 @@ func TestFallback_SingleCandidate_Success(t *testing.T) { } func TestFallback_SecondCandidateSuccess(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -68,7 +68,7 @@ func TestFallback_SecondCandidateSuccess(t *testing.T) { } func TestFallback_AllFail(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -95,7 +95,7 @@ func TestFallback_AllFail(t *testing.T) { } func TestFallback_ContextCanceled(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) ctx, cancel := context.WithCancel(context.Background()) @@ -122,7 +122,7 @@ func TestFallback_ContextCanceled(t *testing.T) { } func TestFallback_NonRetriableError(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -192,7 +192,7 @@ func TestFallback_CooldownSkip(t *testing.T) { } func TestFallback_AllInCooldown(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) // Put all models in cooldown (using ModelKey now) @@ -220,7 +220,7 @@ func TestFallback_AllInCooldown(t *testing.T) { } func TestFallback_NoCandidates(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) _, err := fc.Execute(context.Background(), nil, successRun("ok")) @@ -231,7 +231,7 @@ func TestFallback_NoCandidates(t *testing.T) { func TestFallback_EmptyFallbacks(t *testing.T) { // Single primary, no fallbacks: should work like direct call - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} @@ -245,7 +245,7 @@ func TestFallback_EmptyFallbacks(t *testing.T) { } func TestFallback_UnclassifiedError(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -278,7 +278,7 @@ func assertFallbackErrorFallsBack( ) { t.Helper() - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -338,7 +338,7 @@ func TestFallback_TimeoutErrorFallsBack(t *testing.T) { } func TestFallback_SuccessResetsCooldown(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} @@ -375,7 +375,7 @@ func assertLocalRateLimitSkipsToHealthyFallback( ) { t.Helper() - ct := NewCooldownTracker() + ct := NewCooldownTracker("") rl := NewRateLimiterRegistry() rl.Register(primaryKey, 1) if err := rl.Wait(context.Background(), primaryKey); err != nil { @@ -432,7 +432,7 @@ func TestFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) { // --- Image Fallback Tests --- func TestImageFallback_Success(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")} @@ -446,7 +446,7 @@ func TestImageFallback_Success(t *testing.T) { } func TestImageFallback_DimensionError(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -470,7 +470,7 @@ func TestImageFallback_DimensionError(t *testing.T) { } func TestImageFallback_SizeError(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -494,7 +494,7 @@ func TestImageFallback_SizeError(t *testing.T) { } func TestImageFallback_RetryOnOtherErrors(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ @@ -540,7 +540,7 @@ func TestImageFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) { } func TestImageFallback_NoCandidates(t *testing.T) { - ct := NewCooldownTracker() + ct := NewCooldownTracker("") fc := NewFallbackChain(ct, nil) _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok")) diff --git a/pkg/tools/freeride.go b/pkg/tools/freeride.go new file mode 100644 index 000000000..20114f816 --- /dev/null +++ b/pkg/tools/freeride.go @@ -0,0 +1,308 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// FreeRideTool adapts the FreeRide logic (from clawhub/free-ride) for PicoClaw. +// It manages OpenRouter's free models and configures them as fallbacks. +type FreeRideTool struct { + configPath string + reloadFunc func() error +} + +func NewFreeRideTool(configPath string, reloadFunc func() error) *FreeRideTool { + return &FreeRideTool{ + configPath: configPath, + reloadFunc: reloadFunc, + } +} + +func (t *FreeRideTool) Name() string { + return "freeride" +} + +func (t *FreeRideTool) Description() string { + return "FreeRide gives you unlimited free AI in PicoClaw by automatically managing OpenRouter's free models. " + + "Use 'auto' to configure best model + fallbacks, or 'list' to see available free models." +} + +func (t *FreeRideTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "enum": []string{"auto", "list", "status"}, + "description": "The command to run: 'auto' (configures models), 'list' (shows free models), 'status' (checks current setup)", + }, + "limit": map[string]any{ + "type": "integer", + "description": "For 'list', how many models to show. For 'auto', how many fallbacks to configure.", + "default": 5, + }, + }, + "required": []string{"command"}, + } +} + +type openRouterModel struct { + ID string `json:"id"` + Name string `json:"name"` + ContextLength int `json:"context_length"` + Pricing struct { + Prompt string `json:"prompt"` + Completion string `json:"completion"` + } `json:"pricing"` + SupportedParameters []string `json:"supported_parameters"` + Created int64 `json:"created"` +} + +func (t *FreeRideTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + cmd, _ := args["command"].(string) + limit := 5 + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + + switch cmd { + case "list": + return t.handleList(ctx, limit) + case "auto": + return t.handleAuto(ctx, limit) + case "status": + return t.handleStatus() + default: + return ErrorResult(fmt.Sprintf("unknown command: %s", cmd)) + } +} + +func (t *FreeRideTool) fetchFreeModels(ctx context.Context) ([]openRouterModel, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/models", nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode) + } + + var wrapper struct { + Data []openRouterModel `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil { + return nil, err + } + + var freeModels []openRouterModel + for _, m := range wrapper.Data { + if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" || m.Pricing.Prompt == "0.00" { + freeModels = append(freeModels, m) + } + } + + // Rank models + sort.Slice(freeModels, func(i, j int) bool { + return scoreModel(freeModels[i]) > scoreModel(freeModels[j]) + }) + + return freeModels, nil +} + +func scoreModel(m openRouterModel) float64 { + score := 0.0 + + // Context length (40%) - normalize against 128k + ctxScore := float64(m.ContextLength) / 128000.0 + if ctxScore > 1.0 { + ctxScore = 1.0 + } + score += ctxScore * 0.4 + + // Capabilities (30%) - tools, vision, prompt caching, etc. + capabilityScore := 0.0 + for _, p := range m.SupportedParameters { + if p == "tools" { + capabilityScore += 0.5 + } + if p == "response_format" { + capabilityScore += 0.5 + } + } + if capabilityScore > 1.0 { + capabilityScore = 1.0 + } + score += capabilityScore * 0.3 + + // Recency (20%) - newer is better + // Normalize against 2 years ago + twoYearsAgo := time.Now().AddDate(-2, 0, 0).Unix() + now := time.Now().Unix() + if m.Created > twoYearsAgo { + recencyScore := float64(m.Created-twoYearsAgo) / float64(now-twoYearsAgo) + score += recencyScore * 0.2 + } + + // Provider Trust (10%) - hardcoded list of trusted names + trustNames := []string{"google", "meta", "nvidia", "mistral", "anthropic", "openai", "microsoft", "qwen", "deepseek"} + for _, name := range trustNames { + if strings.Contains(strings.ToLower(m.ID), name) { + score += 0.1 + break + } + } + + return score +} + +func (t *FreeRideTool) handleList(ctx context.Context, limit int) *ToolResult { + models, err := t.fetchFreeModels(ctx) + if err != nil { + return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error()) + } + + if len(models) == 0 { + return SilentResult("No free models found on OpenRouter.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d free models on OpenRouter (ranked by quality):\n\n", len(models))) + for i, m := range models { + if i >= limit { + break + } + sb.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1, m.Name, m.ID)) + sb.WriteString(fmt.Sprintf(" Context: %d tokens | Score: %.2f\n", m.ContextLength, scoreModel(m))) + sb.WriteString(fmt.Sprintf(" Parameters: %s\n\n", strings.Join(m.SupportedParameters, ", "))) + } + + return SilentResult(sb.String()) +} + +func (t *FreeRideTool) handleAuto(ctx context.Context, limit int) *ToolResult { + models, err := t.fetchFreeModels(ctx) + if err != nil { + return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error()) + } + + if len(models) == 0 { + return ErrorResult("No free models found on OpenRouter.") + } + + cfgObj, err := config.LoadConfig(t.configPath) + if err != nil { + return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error()) + } + + // 1. Add models to ModelList if not present + var addedModels []string + for i, m := range models { + if i >= limit { + break + } + modelName := strings.ReplaceAll(m.ID, "/", "-") + if !modelExists(cfgObj, modelName) { + mc := &config.ModelConfig{ + ModelName: modelName, + Model: m.ID, + Protocol: "openrouter", + Enabled: true, + } + mc.SetAPIKey("env://OPENROUTER_API_KEY") + cfgObj.ModelList = append(cfgObj.ModelList, mc) + addedModels = append(addedModels, modelName) + } + } + + // 2. Set fallbacks for the default agent + if len(addedModels) > 0 { + // Update AgentDefaults fallbacks + cfgObj.Agents.Defaults.ModelFallbacks = append(cfgObj.Agents.Defaults.ModelFallbacks, addedModels...) + // Deduplicate fallbacks + cfgObj.Agents.Defaults.ModelFallbacks = uniqueStrings(cfgObj.Agents.Defaults.ModelFallbacks) + + if err := config.SaveConfig(t.configPath, cfgObj); err != nil { + return ErrorResult(fmt.Errorf("failed to save config: %w", err).Error()) + } + + msg := fmt.Sprintf("Success! Added %d free models as fallbacks: %s.\n", len(addedModels), strings.Join(addedModels, ", ")) + msg += "Re-loading configuration to apply changes..." + + if t.reloadFunc != nil { + if err := t.reloadFunc(); err != nil { + return ErrorResult(fmt.Sprintf("%s\nFailed to reload: %v", msg, err)) + } + } + + return SilentResult(msg) + } + + return SilentResult("No new free models to add. Your configuration is up to date.") +} + +func (t *FreeRideTool) handleStatus() *ToolResult { + cfgObj, err := config.LoadConfig(t.configPath) + if err != nil { + return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error()) + } + + var sb strings.Builder + sb.WriteString("FreeRide Status:\n") + sb.WriteString(fmt.Sprintf("- Primary Model: %s\n", cfgObj.Agents.Defaults.GetModelName())) + sb.WriteString(fmt.Sprintf("- Fallback Models: %s\n", strings.Join(cfgObj.Agents.Defaults.ModelFallbacks, ", "))) + + // Check for OpenRouter models in fallbacks + openRouterCount := 0 + for _, fb := range cfgObj.Agents.Defaults.ModelFallbacks { + if strings.Contains(strings.ToLower(fb), "openrouter") || isKnownOpenRouterAlias(cfgObj, fb) { + openRouterCount++ + } + } + sb.WriteString(fmt.Sprintf("- Managed Free Models: %d\n", openRouterCount)) + + return SilentResult(sb.String()) +} + +func modelExists(cfg *config.Config, modelName string) bool { + for _, m := range cfg.ModelList { + if m.ModelName == modelName { + return true + } + } + return false +} + +func isKnownOpenRouterAlias(cfg *config.Config, modelName string) bool { + for _, m := range cfg.ModelList { + if m.ModelName == modelName && strings.HasPrefix(m.Model, "openrouter/") { + return true + } + } + return false +} + +func uniqueStrings(input []string) []string { + keys := make(map[string]bool) + list := []string{} + for _, entry := range input { + if _, value := keys[entry]; !value { + keys[entry] = true + list = append(list, entry) + } + } + return list +} diff --git a/pkg/tools/freeride_test.go b/pkg/tools/freeride_test.go new file mode 100644 index 000000000..6ff24ad4d --- /dev/null +++ b/pkg/tools/freeride_test.go @@ -0,0 +1,167 @@ +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestFreeRideTool_List(t *testing.T) { + // Mock OpenRouter API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + { + "id": "google/gemini-pro-1.5", + "name": "Gemini Pro 1.5", + "context_length": 128000, + "pricing": map[string]string{ + "prompt": "0", + "completion": "0", + }, + "created": 1700000000, + }, + { + "id": "meta-llama/llama-3-8b", + "name": "Llama 3 8B", + "context_length": 8000, + "pricing": map[string]string{ + "prompt": "0.0001", + "completion": "0.0001", + }, + "created": 1700000000, + }, + }, + }) + })) + defer server.Close() + + // Override default transport to use mock server + oldTransport := http.DefaultClient.Transport + http.DefaultClient.Transport = &mockTransport{server.URL} + defer func() { http.DefaultClient.Transport = oldTransport }() + + tool := NewFreeRideTool("config.json", nil) + result := tool.Execute(context.Background(), map[string]any{ + "command": "list", + }) + + if result.IsError { + t.Fatalf("Expected no error, got %s", result.ForLLM) + } + + if !result.Silent { + t.Errorf("Expected silent result") + } + + output := result.ForLLM + if !contains(output, "Gemini Pro 1.5") { + t.Errorf("Expected Gemini Pro 1.5 in output, got %s", output) + } + if contains(output, "Llama 3 8B") { + t.Errorf("Did not expect paid model Llama 3 8B in output, got %s", output) + } +} + +func TestFreeRideTool_Auto(t *testing.T) { + os.Setenv("OPENROUTER_API_KEY", "sk-test-key") + defer os.Unsetenv("OPENROUTER_API_KEY") + + tempDir, err := os.MkdirTemp("", "freeride-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "config.json") + initialCfg := &config.Config{ + ModelList: []*config.ModelConfig{}, + } + initialCfg.Agents.Defaults.ModelName = "existing-model" + + if err := config.SaveConfig(configPath, initialCfg); err != nil { + t.Fatalf("failed to save initial config: %v", err) + } + + // Mock OpenRouter API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + { + "id": "google/gemini-pro-1.5", + "name": "Gemini Pro 1.5", + "context_length": 128000, + "pricing": map[string]string{ + "prompt": "0", + "completion": "0", + }, + "created": 1700000000, + }, + }, + }) + })) + defer server.Close() + + oldTransport := http.DefaultClient.Transport + http.DefaultClient.Transport = &mockTransport{server.URL} + defer func() { http.DefaultClient.Transport = oldTransport }() + + var reloadCalled bool + reloadFunc := func() error { + reloadCalled = true + return nil + } + + tool := NewFreeRideTool(configPath, reloadFunc) + result := tool.Execute(context.Background(), map[string]any{ + "command": "auto", + }) + + if result.IsError { + t.Fatalf("Expected no error, got %s", result.ForLLM) + } + + if !reloadCalled { + t.Errorf("Expected reloadFunc to be called") + } + + // Verify config + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("failed to load updated config: %v", err) + } + + if len(cfg.ModelList) != 1 { + t.Errorf("Expected 1 model in ModelList, got %d", len(cfg.ModelList)) + } + + if cfg.ModelList[0].ModelName != "google-gemini-pro-1.5" { + t.Errorf("Expected model name google-gemini-pro-1.5, got %s", cfg.ModelList[0].ModelName) + } + + if len(cfg.Agents.Defaults.ModelFallbacks) != 1 { + t.Errorf("Expected 1 fallback, got %d", len(cfg.Agents.Defaults.ModelFallbacks)) + } +} + +type mockTransport struct { + url string +} + +func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + newReq, _ := http.NewRequest(req.Method, m.url, req.Body) + return http.DefaultTransport.RoundTrip(newReq) +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e51dff71a..b567478bf 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -191,7 +191,7 @@ func (r *ToolRegistry) ExecuteWithContext( channel, chatID string, asyncCallback AsyncCallback, ) *ToolResult { - logger.InfoCF("tool", "Tool execution started", + logger.DebugCF("tool", "Tool execution started", map[string]any{ "tool": name, "args": args, @@ -284,7 +284,7 @@ func (r *ToolRegistry) ExecuteWithContext( "duration": duration.Milliseconds(), }) } else { - logger.InfoCF("tool", "Tool execution completed", + logger.DebugCF("tool", "Tool execution completed", map[string]any{ "tool": name, "duration_ms": duration.Milliseconds(), diff --git a/scratch/check_paths.go b/scratch/check_paths.go new file mode 100644 index 000000000..f6eb96d75 --- /dev/null +++ b/scratch/check_paths.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/config" +) + +func main() { + cfg, err := config.LoadConfig(os.ExpandEnv("$HOME/.picoclaw/config.json")) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + registry := agent.NewAgentRegistry(cfg, nil) + defaultAgent := registry.GetDefaultAgent() + if defaultAgent == nil { + fmt.Println("No default agent") + return + } + + cooldownPath := filepath.Join(filepath.Dir(filepath.Clean(defaultAgent.Workspace)), "cooldowns.json") + fmt.Printf("Workspace: %s\n", defaultAgent.Workspace) + fmt.Printf("Cooldown Path: %s\n", cooldownPath) +} diff --git a/web/Makefile b/web/Makefile index 4dca810e7..fbe42db9f 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,5 +1,5 @@ .PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \ - build-android-arm64 build-android-bundle + build-android-arm64 build-android-bundle frontend-install # Go variables GO?=CGO_ENABLED=0 go @@ -105,7 +105,7 @@ build-android-bundle: build-frontend GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ @echo "All Android launcher builds complete" -build-frontend: +frontend-install: @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \ @@ -115,6 +115,8 @@ build-frontend: (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \ fi + +build-frontend: frontend-install @echo "Building frontend..." @cd $(FRONTEND_DIR) && pnpm build:backend @@ -124,12 +126,12 @@ build-dev-picoclaw: @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw # Run all tests -test: +test: frontend-install cd $(BACKEND_DIR) && ${WEB_GO} test ./... cd $(FRONTEND_DIR) && pnpm lint # Lint and format -lint: +lint: frontend-install cd $(BACKEND_DIR) && ${WEB_GO} vet ./... cd $(FRONTEND_DIR) && pnpm check diff --git a/workspace/skills/freeride/SKILL.md b/workspace/skills/freeride/SKILL.md new file mode 100644 index 000000000..d95c292bc --- /dev/null +++ b/workspace/skills/freeride/SKILL.md @@ -0,0 +1,17 @@ +# FreeRide Skill + +FreeRide gives you unlimited free AI in PicoClaw by automatically managing OpenRouter's free models. + +## Usage + +- `/freeride auto`: Auto-configure best model + fallbacks. +- `/freeride list`: See all 30+ free models ranked. +- `/freeride status`: Check your current setup. + +## How it works + +The skill uses the `freeride` tool to fetch free models from OpenRouter, ranks them by context length, capabilities, recency, and provider trust, and then updates your PicoClaw configuration with the best models as fallbacks. + +## Setup + +Ensure you have your OpenRouter API key set in your K3s secrets or environment variables as `OPENROUTER_API_KEY`.