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..15f489567 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,8 @@
π§ **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/freeride.md).
+
_*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/docs/guides/freeride.md b/docs/guides/freeride.md
new file mode 100644
index 000000000..98b7af927
--- /dev/null
+++ b/docs/guides/freeride.md
@@ -0,0 +1,126 @@
+# 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 `freeride` tool is enabled and whitelisted in your `config.json`:
+
+```json
+{
+ "tools": {
+ "whitelist": ["freeride", ...],
+ "whitelist_enabled": true,
+ "security_policy": {
+ "enabled": true,
+ "config": {
+ "allowed_tools": {
+ "freeride": 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
+```
+
+## 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.
+- **Security Blocks**: Ensure `freeride` is added to your `security_policy` allowed tools map.
+
+---
+
+## 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/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/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..10297c901 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,7 @@ type AgentLoop struct {
turnSeq atomic.Uint64
activeRequests sync.WaitGroup
+ configPath string
reloadFunc func() error
@@ -265,6 +265,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()
@@ -519,6 +527,10 @@ func (al *AgentLoop) runAgentLoop(
opts.Dispatch.SessionKey,
opts.Dispatch.SessionScope,
)
+ finalContent := result.finalContent
+ if usedFallback, fallbackModel := ts.GetFallbackInfo(); usedFallback {
+ finalContent += fmt.Sprintf("\n\nπ¦ _(FreeRide: %s)_", fallbackModel)
+ }
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Context: outboundContextFromInbound(
opts.Dispatch.InboundContext,
@@ -529,21 +541,10 @@ 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
}
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..234b8890e 100644
--- a/pkg/agent/loop_init.go
+++ b/pkg/agent/loop_init.go
@@ -21,6 +21,7 @@ import (
func NewAgentLoop(
cfg *config.Config,
+ configPath string,
msgBus *bus.MessageBus,
provider providers.LLMProvider,
) *AgentLoop {
@@ -57,6 +58,7 @@ func NewAgentLoop(
al := &AgentLoop{
bus: msgBus,
cfg: cfg,
+ configPath: configPath,
registry: registry,
state: stateManager,
eventBus: eventBus,
@@ -229,6 +231,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..bc16d109e 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{
@@ -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{
@@ -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..b82bad9d4 100644
--- a/pkg/agent/loop_turn.go
+++ b/pkg/agent/loop_turn.go
@@ -387,6 +387,7 @@ turnLoop:
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
map[string]any{"agent_id": ts.agent.ID, "iteration": iteration},
)
+ ts.SetFallbackInfo(true, fbResult.Model)
}
return fbResult.Response, nil
}
@@ -600,10 +601,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 +672,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 +1052,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_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/tools/freeride.go b/pkg/tools/freeride.go
new file mode 100644
index 000000000..09a6f3ba2
--- /dev/null
+++ b/pkg/tools/freeride.go
@@ -0,0 +1,307 @@
+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: "openrouter/" + m.ID,
+ 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/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`.