feat: integrate FreeRide model failover and modernize provider architecture

This commit is contained in:
stevef 2026-04-19 22:02:14 +02:00
parent 6126ede963
commit 4758b44315
45 changed files with 1344 additions and 194 deletions

View file

@ -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:

View file

@ -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 |

159
cmd/freeride-diag/main.go Normal file
View file

@ -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
}

View file

@ -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"],

View file

@ -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
},
}
}

View file

@ -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(),

View file

@ -40,6 +40,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"agent",
"auth",
"cron",
"freeride",
"gateway",
"migrate",
"model",

128
docs/guides/freeride.md Normal file
View file

@ -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.*

View file

@ -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`:

View file

@ -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

View file

@ -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"
}
]
}

View file

@ -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"})
}

View file

@ -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")

View file

@ -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",

View file

@ -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) {

View file

@ -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")

View file

@ -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

View file

@ -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.

View file

@ -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) {

View file

@ -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,

View file

@ -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"

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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{

View file

@ -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{}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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()

View file

@ -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 {

View file

@ -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] = &copy
}
}
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 {

View file

@ -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, &current
}
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)

View file

@ -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,

View file

@ -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
}

View file

@ -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

View file

@ -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"))

308
pkg/tools/freeride.go Normal file
View file

@ -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
}

167
pkg/tools/freeride_test.go Normal file
View file

@ -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)
}

View file

@ -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(),

28
scratch/check_paths.go Normal file
View file

@ -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)
}

View file

@ -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

View file

@ -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`.