feat: integrate FreeRide model failover and modernize provider architecture
This commit is contained in:
parent
6126ede963
commit
4758b44315
45 changed files with 1344 additions and 194 deletions
2
Makefile
2
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:
|
||||
|
|
|
|||
|
|
@ -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
159
cmd/freeride-diag/main.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
84
cmd/picoclaw/internal/freeride/command.go
Normal file
84
cmd/picoclaw/internal/freeride/command.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
"agent",
|
||||
"auth",
|
||||
"cron",
|
||||
"freeride",
|
||||
"gateway",
|
||||
"migrate",
|
||||
"model",
|
||||
|
|
|
|||
128
docs/guides/freeride.md
Normal file
128
docs/guides/freeride.md
Normal 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.*
|
||||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
37
examples/freeride-config.json
Normal file
37
examples/freeride-config.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -16,6 +19,7 @@ type CooldownTracker struct {
|
|||
mu sync.RWMutex
|
||||
entries map[string]*cooldownEntry
|
||||
failureWindow time.Duration
|
||||
storagePath string
|
||||
nowFunc func() time.Time // for testing
|
||||
}
|
||||
|
||||
|
|
@ -29,20 +33,23 @@ type cooldownEntry struct {
|
|||
}
|
||||
|
||||
// NewCooldownTracker creates a tracker with default 24h failure window.
|
||||
func NewCooldownTracker() *CooldownTracker {
|
||||
return &CooldownTracker{
|
||||
func NewCooldownTracker(storagePath string) *CooldownTracker {
|
||||
ct := &CooldownTracker{
|
||||
entries: make(map[string]*cooldownEntry),
|
||||
failureWindow: defaultFailureWindow,
|
||||
storagePath: storagePath,
|
||||
nowFunc: time.Now,
|
||||
}
|
||||
if storagePath != "" {
|
||||
ct.Load()
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
// MarkFailure records a failure for a provider and sets appropriate cooldown.
|
||||
// Resets error counts if last failure was more than failureWindow ago.
|
||||
func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
now := ct.nowFunc()
|
||||
entry := ct.getOrCreate(provider)
|
||||
|
||||
|
|
@ -53,6 +60,9 @@ func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {
|
|||
}
|
||||
|
||||
entry.ErrorCount++
|
||||
if entry.FailureCounts == nil {
|
||||
entry.FailureCounts = make(map[FailoverReason]int)
|
||||
}
|
||||
entry.FailureCounts[reason]++
|
||||
entry.LastFailure = now
|
||||
|
||||
|
|
@ -63,15 +73,20 @@ func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {
|
|||
} else {
|
||||
entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount))
|
||||
}
|
||||
|
||||
// Capture state for saving outside the lock
|
||||
toSave := ct.copyEntriesLocked()
|
||||
ct.mu.Unlock()
|
||||
|
||||
ct.persist(toSave)
|
||||
}
|
||||
|
||||
// MarkSuccess resets all counters and cooldowns for a provider.
|
||||
func (ct *CooldownTracker) MarkSuccess(provider string) {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
entry := ct.entries[provider]
|
||||
if entry == nil {
|
||||
ct.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +95,12 @@ func (ct *CooldownTracker) MarkSuccess(provider string) {
|
|||
entry.CooldownEnd = time.Time{}
|
||||
entry.DisabledUntil = time.Time{}
|
||||
entry.DisabledReason = ""
|
||||
|
||||
// Capture state for saving outside the lock
|
||||
toSave := ct.copyEntriesLocked()
|
||||
ct.mu.Unlock()
|
||||
|
||||
ct.persist(toSave)
|
||||
}
|
||||
|
||||
// IsAvailable returns true if the provider is not in cooldown or disabled.
|
||||
|
|
@ -162,6 +183,93 @@ func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason)
|
|||
return entry.FailureCounts[reason]
|
||||
}
|
||||
|
||||
// Load reads cooldown state from disk.
|
||||
func (ct *CooldownTracker) Load() {
|
||||
if ct.storagePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(ct.storagePath)
|
||||
if err != nil {
|
||||
return // ignore missing file
|
||||
}
|
||||
|
||||
var loaded map[string]*cooldownEntry
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
now := ct.nowFunc()
|
||||
for k, v := range loaded {
|
||||
// Only load entries that aren't fully expired yet
|
||||
if (!v.CooldownEnd.IsZero() && now.Before(v.CooldownEnd)) ||
|
||||
(!v.DisabledUntil.IsZero() && now.Before(v.DisabledUntil)) ||
|
||||
(!v.LastFailure.IsZero() && now.Sub(v.LastFailure) < ct.failureWindow) {
|
||||
if v.FailureCounts == nil {
|
||||
v.FailureCounts = make(map[FailoverReason]int)
|
||||
}
|
||||
ct.entries[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save writes cooldown state to disk.
|
||||
func (ct *CooldownTracker) Save() {
|
||||
ct.mu.RLock()
|
||||
toSave := ct.copyEntriesLocked()
|
||||
ct.mu.RUnlock()
|
||||
|
||||
ct.persist(toSave)
|
||||
}
|
||||
|
||||
func (ct *CooldownTracker) copyEntriesLocked() map[string]*cooldownEntry {
|
||||
toSave := make(map[string]*cooldownEntry)
|
||||
now := ct.nowFunc()
|
||||
for k, v := range ct.entries {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
// Only save entries that are still relevant
|
||||
if (!v.CooldownEnd.IsZero() && now.Before(v.CooldownEnd)) ||
|
||||
(!v.DisabledUntil.IsZero() && now.Before(v.DisabledUntil)) ||
|
||||
(!v.LastFailure.IsZero() && now.Sub(v.LastFailure) < ct.failureWindow) {
|
||||
|
||||
// Deep copy the entry to avoid data races when serializing outside the lock
|
||||
copy := *v
|
||||
if v.FailureCounts != nil {
|
||||
copy.FailureCounts = make(map[FailoverReason]int)
|
||||
for r, c := range v.FailureCounts {
|
||||
copy.FailureCounts[r] = c
|
||||
}
|
||||
}
|
||||
toSave[k] = ©
|
||||
}
|
||||
}
|
||||
return toSave
|
||||
}
|
||||
|
||||
func (ct *CooldownTracker) persist(toSave map[string]*cooldownEntry) {
|
||||
if ct.storagePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if len(toSave) == 0 {
|
||||
os.Remove(ct.storagePath) // cleanup if empty
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(toSave, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(ct.storagePath), 0755)
|
||||
os.WriteFile(ct.storagePath, data, 0644)
|
||||
}
|
||||
|
||||
func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry {
|
||||
entry := ct.entries[provider]
|
||||
if entry == nil {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import (
|
|||
|
||||
func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) {
|
||||
current := now
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
ct.nowFunc = func() time.Time { return current }
|
||||
return ct, ¤t
|
||||
}
|
||||
|
||||
func TestCooldown_InitiallyAvailable(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
if !ct.IsAvailable("openai") {
|
||||
t.Error("new provider should be available")
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@ func TestCooldown_BillingCap(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCooldown_SuccessReset(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
|
||||
ct.MarkFailure("openai", FailoverRateLimit)
|
||||
ct.MarkFailure("openai", FailoverBilling)
|
||||
|
|
@ -157,7 +157,7 @@ func TestCooldown_FailureWindowReset(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCooldown_PerReasonTracking(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
|
||||
ct.MarkFailure("openai", FailoverRateLimit)
|
||||
ct.MarkFailure("openai", FailoverRateLimit)
|
||||
|
|
@ -218,7 +218,7 @@ func TestCooldown_CooldownRemaining(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCooldown_SuccessOnUnknownProvider(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
// Should not panic
|
||||
ct.MarkSuccess("nonexistent")
|
||||
if !ct.IsAvailable("nonexistent") {
|
||||
|
|
@ -227,7 +227,7 @@ func TestCooldown_SuccessOnUnknownProvider(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCooldown_ConcurrentAccess(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range 100 {
|
||||
|
|
@ -251,7 +251,7 @@ func TestCooldown_ConcurrentAccess(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCooldown_MultipleProviders(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
ct := NewCooldownTracker("")
|
||||
|
||||
ct.MarkFailure("openai", FailoverRateLimit)
|
||||
ct.MarkFailure("anthropic", FailoverBilling)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
308
pkg/tools/freeride.go
Normal 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
167
pkg/tools/freeride_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
28
scratch/check_paths.go
Normal 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)
|
||||
}
|
||||
10
web/Makefile
10
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
|
||||
|
||||
|
|
|
|||
17
workspace/skills/freeride/SKILL.md
Normal file
17
workspace/skills/freeride/SKILL.md
Normal 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`.
|
||||
Loading…
Add table
Reference in a new issue