feat: sync with FreeRide implementation from original project
This commit is contained in:
parent
6126ede963
commit
0717cedc5c
31 changed files with 986 additions and 126 deletions
2
Makefile
2
Makefile
|
|
@ -306,7 +306,7 @@ test: generate
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
@$(GOLANGCI_LINT) fmt
|
@go fmt ./...
|
||||||
|
|
||||||
## lint-docs: Check common documentation layout and naming conventions
|
## lint-docs: Check common documentation layout and naming conventions
|
||||||
lint-docs:
|
lint-docs:
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@
|
||||||
|
|
||||||
🧠 **Smart routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
|
🧠 **Smart routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
|
||||||
|
|
||||||
|
🧬 **FreeRide**: Intelligent model rotation using OpenRouter's free pool — never pay for basic LLM traffic again. [Learn more](docs/freeride.md).
|
||||||
|
|
||||||
_*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is planned. Boot speed comparison based on 0.8GHz single-core benchmarks (see table below)._
|
_*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)._
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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()
|
msgBus := bus.NewMessageBus()
|
||||||
defer msgBus.Close()
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, internal.GetConfigPath(), msgBus, provider)
|
||||||
defer agentLoop.Close()
|
defer agentLoop.Close()
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
logger.InfoCF("agent", "Agent initialized",
|
logger.DebugCF("agent", "Agent initialized",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
||||||
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
||||||
|
|
|
||||||
126
docs/guides/freeride.md
Normal file
126
docs/guides/freeride.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# FreeRide 🦞
|
||||||
|
|
||||||
|
FreeRide is a dynamic model rotation and failover system for PicoClaw that leverages OpenRouter's free model pool. It ensures your agent stays alive even if individual free models become rate-limited or go offline.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Automatic Discovery**: Scans OpenRouter for the best currently available free models.
|
||||||
|
- **Dynamic Failover**: Automatically rotates through a pool of models when errors (like 429 Rate Limiting) occur.
|
||||||
|
- **Intelligent Ranking**: Models are scored and ranked based on context length, capabilities (tools/vision), and provider trust.
|
||||||
|
- **K3s Ready**: Designed to work seamlessly in Kubernetes environments with secure API key management.
|
||||||
|
- **Visual Provenance (🦞)**: Responses generated via a fallback model are clearly marked with a "lobster" emoji and the model name, providing transparency about which model handled your request.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
FreeRide is implemented as a native PicoClaw tool.
|
||||||
|
|
||||||
|
### 1. Enable the Tool
|
||||||
|
Ensure the `freeride` tool is enabled and whitelisted in your `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"whitelist": ["freeride", ...],
|
||||||
|
"whitelist_enabled": true,
|
||||||
|
"security_policy": {
|
||||||
|
"enabled": true,
|
||||||
|
"config": {
|
||||||
|
"allowed_tools": {
|
||||||
|
"freeride": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set the API Key
|
||||||
|
FreeRide requires an OpenRouter API key. Even for free models, many providers require a key for identification and higher rate limits.
|
||||||
|
|
||||||
|
PicoClaw supports dynamic environment variable resolution using the `env://` scheme.
|
||||||
|
|
||||||
|
In **Local Mode** or **Docker**, set the environment variable:
|
||||||
|
```bash
|
||||||
|
export OPENROUTER_API_KEY="sk-or-v1-..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in your `config.json`, use:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_keys": ["env://OPENROUTER_API_KEY"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*(Note: `freeride auto` will automatically configure this for you.)*
|
||||||
|
|
||||||
|
In **K3s Mode**, add the secret to your cluster (see below).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You can interact with FreeRide directly through the agent:
|
||||||
|
|
||||||
|
### `freeride auto`
|
||||||
|
**The most important command.** This command:
|
||||||
|
1. Fetches the current list of ~28+ free models.
|
||||||
|
2. Ranks them by quality.
|
||||||
|
3. Automatically populates your `config.json`'s `model_list`.
|
||||||
|
4. Adds the top 5 models to your agent's `model_fallbacks` list.
|
||||||
|
5. Reloads the agent configuration instantly.
|
||||||
|
|
||||||
|
### `freeride status`
|
||||||
|
Shows your current primary model and the active fallback rotation pool.
|
||||||
|
|
||||||
|
### `freeride list [limit]`
|
||||||
|
Displays the current top-ranked free models available on OpenRouter without modifying your configuration.
|
||||||
|
|
||||||
|
## K3s Deployment & Secrets
|
||||||
|
|
||||||
|
When running PicoClaw on K3s, follow these steps to manage your secrets safely.
|
||||||
|
|
||||||
|
### Adding the Secret
|
||||||
|
If you are creating the secrets for the first time:
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic picoclaw-secrets \
|
||||||
|
--namespace agi \
|
||||||
|
--from-literal=openrouter-api-key="YOUR_KEY_HERE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Existing Secrets (Safe Patching)
|
||||||
|
If `picoclaw-secrets` already exists and you want to add the OpenRouter key without losing your Telegram or NVIDIA keys, use **`kubectl patch`**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl patch secret picoclaw-secrets \
|
||||||
|
--namespace agi \
|
||||||
|
--type='json' \
|
||||||
|
-p='[{"op": "add", "path": "/data/openrouter-api-key", "value":"'$(echo -n "YOUR_KEY_HERE" | base64 -w0)'"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Configuration
|
||||||
|
Ensure your `deployment.yaml` maps the secret to the environment variable:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
- name: OPENROUTER_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: picoclaw-secrets
|
||||||
|
key: openrouter-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **404 Errors**: Ensure the model is still available on OpenRouter using `freeride list`. If it's gone, run `freeride auto` to refresh your fallback pool.
|
||||||
|
- **429 Rate Limiting**: This is common with free models. PicoClaw will automatically try the next model in your `model_fallbacks` list.
|
||||||
|
- **Security Blocks**: Ensure `freeride` is added to your `security_policy` allowed tools map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legal & Responsible Use 🛡️
|
||||||
|
|
||||||
|
FreeRide is provided for **personal assistance, educational research, and infrastructure failover** purposes only. By using this capability, you acknowledge and agree to the following:
|
||||||
|
|
||||||
|
1. **Terms of Service**: You are responsible for complying with [OpenRouter's Terms of Service](https://openrouter.ai/terms) and the individual "Acceptable Use Policies" of each model provider (e.g., Google, Meta, Mistral).
|
||||||
|
2. **No Guarantee of Service**: Free models are provided "as-is" by third parties. They may be withdrawn, rate-limited, or modified at any time without notice.
|
||||||
|
3. **No Reselling**: You should not use FreeRide to build commercial services that "resell" free model access in a way that violates provider licenses (check specific model licenses like Llama 3 Community or Qwen for commercial usage thresholds).
|
||||||
|
4. **Rate Limit Respect**: PicoClaw handles failover automatically, but users should not use FreeRide to intentionally overwhelm or evade the fair-use rate limits of providers.
|
||||||
|
|
||||||
|
*PicoClaw is an independent tool and is not affiliated with OpenRouter or any specific LLM provider.*
|
||||||
|
|
@ -36,6 +36,41 @@ See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering |
|
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering |
|
||||||
| `filter_min_length` | int | `8` | Minimum content length to trigger 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
|
## Web Tools
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"})
|
al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "summary"})
|
||||||
|
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
|
|
@ -617,7 +617,7 @@ func TestIngestCalledDuringTurn(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"})
|
al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "done"})
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
@ -763,5 +763,5 @@ func testConfig(t *testing.T) *config.Config {
|
||||||
|
|
||||||
func newCMTestAgentLoop(cfg *config.Config) *AgentLoop {
|
func newCMTestAgentLoop(cfg *config.Config) *AgentLoop {
|
||||||
msgBus := bus.NewMessageBus()
|
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()
|
msgBus := bus.NewMessageBus()
|
||||||
mockProvider := &simpleMockProvider{response: "I received your message."}
|
mockProvider := &simpleMockProvider{response: "I received your message."}
|
||||||
al := NewAgentLoop(cfg, msgBus, mockProvider)
|
al := NewAgentLoop(cfg, "", msgBus, mockProvider)
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
@ -885,7 +885,7 @@ func TestSeahorseSteeringMessageIngested(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
mockProvider := &simpleMockProvider{response: "I received your message."}
|
mockProvider := &simpleMockProvider{response: "I received your message."}
|
||||||
al := NewAgentLoop(cfg, msgBus, mockProvider)
|
al := NewAgentLoop(cfg, "", msgBus, mockProvider)
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
@ -992,7 +992,7 @@ func TestSeahorseSummarizeSkipsCondensedWhenBelowThreshold(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &seahorseTestProvider{}
|
provider := &seahorseTestProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &scriptedToolProvider{}
|
provider := &scriptedToolProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
al.RegisterTool(&mockCustomTool{})
|
al.RegisterTool(&mockCustomTool{})
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
|
|
@ -305,7 +305,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
al.RegisterTool(tool1)
|
al.RegisterTool(tool1)
|
||||||
al.RegisterTool(tool2)
|
al.RegisterTool(tool2)
|
||||||
|
|
||||||
|
|
@ -406,7 +406,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) {
|
||||||
successResp: "Recovered from context error",
|
successResp: "Recovered from context error",
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
@ -493,7 +493,7 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary text"})
|
al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "summary text"})
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
@ -563,7 +563,7 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
doneCh := make(chan struct{})
|
doneCh := make(chan struct{})
|
||||||
al.RegisterTool(&asyncFollowUpTool{
|
al.RegisterTool(&asyncFollowUpTool{
|
||||||
name: "async_followup",
|
name: "async_followup",
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks co
|
||||||
Hooks: hooks,
|
Hooks: hooks,
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
return NewAgentLoop(cfg, "", bus.NewMessageBus(), provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) {
|
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()
|
agent := al.registry.GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
t.Fatal("expected default agent")
|
t.Fatal("expected default agent")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
|
|
@ -64,6 +63,7 @@ type AgentLoop struct {
|
||||||
|
|
||||||
turnSeq atomic.Uint64
|
turnSeq atomic.Uint64
|
||||||
activeRequests sync.WaitGroup
|
activeRequests sync.WaitGroup
|
||||||
|
configPath string
|
||||||
|
|
||||||
reloadFunc func() error
|
reloadFunc func() error
|
||||||
|
|
||||||
|
|
@ -265,6 +265,14 @@ func (al *AgentLoop) Stop() {
|
||||||
al.running.Store(false)
|
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.
|
// Close releases resources held by agent session stores. Call after Stop.
|
||||||
func (al *AgentLoop) Close() {
|
func (al *AgentLoop) Close() {
|
||||||
mcpManager := al.mcp.takeManager()
|
mcpManager := al.mcp.takeManager()
|
||||||
|
|
@ -519,6 +527,10 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
opts.Dispatch.SessionKey,
|
opts.Dispatch.SessionKey,
|
||||||
opts.Dispatch.SessionScope,
|
opts.Dispatch.SessionScope,
|
||||||
)
|
)
|
||||||
|
finalContent := result.finalContent
|
||||||
|
if usedFallback, fallbackModel := ts.GetFallbackInfo(); usedFallback {
|
||||||
|
finalContent += fmt.Sprintf("\n\n🦞 _(FreeRide: %s)_", fallbackModel)
|
||||||
|
}
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Context: outboundContextFromInbound(
|
Context: outboundContextFromInbound(
|
||||||
opts.Dispatch.InboundContext,
|
opts.Dispatch.InboundContext,
|
||||||
|
|
@ -529,21 +541,10 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Scope: scope,
|
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 result.finalContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ func (al *AgentLoop) logEvent(evt Event) {
|
||||||
fields["error"] = payload.Message
|
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.
|
// MountHook registers an in-process hook on the agent loop.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
|
|
||||||
func NewAgentLoop(
|
func NewAgentLoop(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
configPath string,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentLoop {
|
) *AgentLoop {
|
||||||
|
|
@ -57,6 +58,7 @@ func NewAgentLoop(
|
||||||
al := &AgentLoop{
|
al := &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
configPath: configPath,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
state: stateManager,
|
state: stateManager,
|
||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
|
|
@ -229,6 +231,10 @@ func registerSharedTools(
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
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")
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
} else {
|
} else {
|
||||||
logContent = utils.Truncate(msg.Content, 80)
|
logContent = utils.Truncate(msg.Content, 80)
|
||||||
}
|
}
|
||||||
logger.InfoCF(
|
logger.DebugCF(
|
||||||
"agent",
|
"agent",
|
||||||
fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
||||||
map[string]any{
|
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{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"scope_key": scopeKey,
|
"scope_key": scopeKey,
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ func newTestAgentLoop(
|
||||||
}
|
}
|
||||||
msgBus = bus.NewMessageBus()
|
msgBus = bus.NewMessageBus()
|
||||||
provider = &mockProvider{}
|
provider = &mockProvider{}
|
||||||
al = NewAgentLoop(cfg, msgBus, provider)
|
al = NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +180,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
Channel: "discord",
|
Channel: "discord",
|
||||||
|
|
@ -239,7 +239,7 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
Channel: "telegram",
|
Channel: "telegram",
|
||||||
|
|
@ -290,7 +290,7 @@ func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
useTestSideQuestionProvider(al, provider)
|
useTestSideQuestionProvider(al, provider)
|
||||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
|
|
@ -360,7 +360,7 @@ func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
useTestSideQuestionProvider(al, provider)
|
useTestSideQuestionProvider(al, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
|
@ -419,7 +419,7 @@ func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
useTestSideQuestionProvider(al, provider)
|
useTestSideQuestionProvider(al, provider)
|
||||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
if defaultAgent == nil {
|
if defaultAgent == nil {
|
||||||
|
|
@ -486,7 +486,7 @@ func TestProcessMessage_BtwCommandRetriesWithoutMediaOnVisionUnsupported(t *test
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &visionUnsupportedMediaProvider{}
|
provider := &visionUnsupportedMediaProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
useTestSideQuestionProvider(al, provider)
|
useTestSideQuestionProvider(al, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
|
@ -530,7 +530,7 @@ func TestProcessMessage_BtwCommandUsesProviderFactoryModel(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
useTestSideQuestionProvider(al, provider)
|
useTestSideQuestionProvider(al, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -572,7 +572,7 @@ func TestProcessMessage_BtwCommandHookModelBypassesFallbackCandidates(t *testing
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
useTestSideQuestionProvider(al, provider)
|
useTestSideQuestionProvider(al, provider)
|
||||||
if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "hook-model"})); err != nil {
|
if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "hook-model"})); err != nil {
|
||||||
t.Fatalf("MountHook failed: %v", err)
|
t.Fatalf("MountHook failed: %v", err)
|
||||||
|
|
@ -609,7 +609,7 @@ func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
|
||||||
opts := processOptions{}
|
opts := processOptions{}
|
||||||
|
|
@ -653,7 +653,7 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &recordingProvider{}
|
provider := &recordingProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
Channel: "telegram",
|
Channel: "telegram",
|
||||||
|
|
@ -785,7 +785,7 @@ func TestRecordLastChannel(t *testing.T) {
|
||||||
if got := al.state.GetLastChannel(); got != testChannel {
|
if got := al.state.GetLastChannel(); got != testChannel {
|
||||||
t.Errorf("Expected channel '%s', got '%s'", testChannel, got)
|
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 {
|
if got := al2.state.GetLastChannel(); got != testChannel {
|
||||||
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got)
|
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 {
|
if got := al.state.GetLastChatID(); got != testChatID {
|
||||||
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got)
|
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 {
|
if got := al2.state.GetLastChatID(); got != testChatID {
|
||||||
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got)
|
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got)
|
||||||
}
|
}
|
||||||
|
|
@ -831,7 +831,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||||
// Create agent loop
|
// Create agent loop
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
// Verify state manager is initialized
|
// Verify state manager is initialized
|
||||||
if al.state == nil {
|
if al.state == nil {
|
||||||
|
|
@ -866,7 +866,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
// Register a custom tool
|
// Register a custom tool
|
||||||
customTool := &mockCustomTool{}
|
customTool := &mockCustomTool{}
|
||||||
|
|
@ -937,7 +937,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
// Register a test tool and verify it shows up in startup info
|
// Register a test tool and verify it shows up in startup info
|
||||||
testTool := &mockCustomTool{}
|
testTool := &mockCustomTool{}
|
||||||
|
|
@ -969,7 +969,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &handledMediaProvider{}
|
provider := &handledMediaProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
store := media.NewFileMediaStore()
|
store := media.NewFileMediaStore()
|
||||||
al.SetMediaStore(store)
|
al.SetMediaStore(store)
|
||||||
|
|
@ -1068,7 +1068,7 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &handledMediaWithSteeringProvider{}
|
provider := &handledMediaWithSteeringProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
store := media.NewFileMediaStore()
|
store := media.NewFileMediaStore()
|
||||||
al.SetMediaStore(store)
|
al.SetMediaStore(store)
|
||||||
|
|
@ -1116,7 +1116,7 @@ func TestRunAgentLoop_ResponseHandledToolPublishesForUserWhenSendResponseDisable
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &handledUserProvider{}
|
provider := &handledUserProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
store := media.NewFileMediaStore()
|
store := media.NewFileMediaStore()
|
||||||
al.SetMediaStore(store)
|
al.SetMediaStore(store)
|
||||||
|
|
@ -1267,7 +1267,7 @@ func TestResolveMessageRoute_UsesInboundContextAccount(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"})
|
al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "ok"})
|
||||||
|
|
||||||
route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{
|
route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{
|
||||||
Context: bus.InboundContext{
|
Context: bus.InboundContext{
|
||||||
|
|
@ -1338,7 +1338,7 @@ func TestResolveMessageRoute_UsesDispatchRulesInOrder(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"})
|
al := NewAgentLoop(cfg, "", msgBus, &simpleMockProvider{response: "ok"})
|
||||||
|
|
||||||
route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{
|
route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{
|
||||||
Context: bus.InboundContext{
|
Context: bus.InboundContext{
|
||||||
|
|
@ -1373,7 +1373,7 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &artifactThenSendProvider{}
|
provider := &artifactThenSendProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
store := media.NewFileMediaStore()
|
store := media.NewFileMediaStore()
|
||||||
al.SetMediaStore(store)
|
al.SetMediaStore(store)
|
||||||
|
|
@ -1443,7 +1443,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
info := al.GetStartupInfo()
|
info := al.GetStartupInfo()
|
||||||
|
|
||||||
|
|
@ -1490,7 +1490,7 @@ func TestAgentLoop_Stop(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
// Note: running is only set to true when Run() is called
|
// Note: running is only set to true when Run() is called
|
||||||
// We can't test that without starting the event loop
|
// We can't test that without starting the event loop
|
||||||
|
|
@ -2153,7 +2153,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProvider{response: "ok"}
|
provider := &simpleMockProvider{response: "ok"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Context: bus.InboundContext{
|
Context: bus.InboundContext{
|
||||||
|
|
@ -2208,7 +2208,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &countingMockProvider{response: "LLM reply"}
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
baseMsg := bus.InboundMessage{
|
baseMsg := bus.InboundMessage{
|
||||||
|
|
@ -2304,7 +2304,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &countingMockProvider{response: "LLM reply"}
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -2361,7 +2361,7 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &countingMockProvider{response: "LLM reply"}
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -2437,7 +2437,7 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -2555,7 +2555,7 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -2636,7 +2636,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -2713,7 +2713,7 @@ func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t *
|
||||||
t.Fatalf("CreateProvider() error = %v", err)
|
t.Fatalf("CreateProvider() error = %v", err)
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
|
@ -2752,7 +2752,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProvider{response: "File operation complete"}
|
provider := &simpleMockProvider{response: "File operation complete"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
// ReadFileTool returns SilentResult, which should not send user message
|
// ReadFileTool returns SilentResult, which should not send user message
|
||||||
|
|
@ -2794,7 +2794,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProvider{response: "Command output: hello world"}
|
provider := &simpleMockProvider{response: "Command output: hello world"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
helper := testHelper{al: al}
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
// ExecTool returns UserResult, which should send user message
|
// ExecTool returns UserResult, which should send user message
|
||||||
|
|
@ -2873,7 +2873,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
successResp: "Recovered from context error",
|
successResp: "Recovered from context error",
|
||||||
}
|
}
|
||||||
|
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
// Inject some history to simulate a full context.
|
// Inject some history to simulate a full context.
|
||||||
// Session history only stores user/assistant/tool messages — the system
|
// Session history only stores user/assistant/tool messages — the system
|
||||||
|
|
@ -2984,7 +2984,7 @@ func TestAgentLoop_VisionUnsupportedErrorStripsSessionMedia(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &visionUnsupportedMediaProvider{}
|
provider := &visionUnsupportedMediaProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
sessionKey := "agent:main:telegram:direct:user1"
|
sessionKey := "agent:main:telegram:direct:user1"
|
||||||
|
|
||||||
|
|
@ -3075,7 +3075,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProvider{response: ""}
|
provider := &simpleMockProvider{response: ""}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1")
|
response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -3106,7 +3106,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &toolLimitOnlyProvider{}
|
provider := &toolLimitOnlyProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
al.RegisterTool(&toolLimitTestTool{})
|
al.RegisterTool(&toolLimitTestTool{})
|
||||||
|
|
||||||
response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1")
|
response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1")
|
||||||
|
|
@ -3173,7 +3173,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
defer al.Close()
|
defer al.Close()
|
||||||
|
|
||||||
if al.mcp.hasManager() {
|
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)
|
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create channel manager: %v", err)
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
|
@ -3283,7 +3283,7 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
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) {
|
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",
|
response: "final answer",
|
||||||
reasoningContent: "thinking trace",
|
reasoningContent: "thinking trace",
|
||||||
}
|
}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
chManager, err := channels.NewManager(&config.Config{}, msgBus, nil)
|
chManager, err := channels.NewManager(&config.Config{}, msgBus, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -3512,7 +3512,7 @@ func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
|
||||||
response: "final answer",
|
response: "final answer",
|
||||||
reasoningContent: "thinking trace",
|
reasoningContent: "thinking trace",
|
||||||
}
|
}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
Channel: "pico",
|
Channel: "pico",
|
||||||
|
|
@ -3583,7 +3583,7 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
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")
|
response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -3629,7 +3629,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
Channel: "telegram",
|
Channel: "telegram",
|
||||||
|
|
@ -3682,7 +3682,7 @@ func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &messageToolProvider{}
|
provider := &messageToolProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
Channel: "telegram",
|
Channel: "telegram",
|
||||||
|
|
@ -3735,7 +3735,7 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &picoInterleavedContentProvider{}
|
provider := &picoInterleavedContentProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
|
|
@ -3813,7 +3813,7 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &picoInterleavedContentProvider{}
|
provider := &picoInterleavedContentProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
agent := al.GetRegistry().GetDefaultAgent()
|
agent := al.GetRegistry().GetDefaultAgent()
|
||||||
if agent == nil {
|
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()
|
defer al.Close()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
@ -4468,7 +4468,7 @@ func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
defer msgBus.Close()
|
defer msgBus.Close()
|
||||||
|
|
||||||
al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{
|
al := NewAgentLoop(cfg, "", msgBus, &concurrentMockProvider{
|
||||||
responseFunc: func(callID int) string {
|
responseFunc: func(callID int) string {
|
||||||
wg.Done()
|
wg.Done()
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,7 @@ turnLoop:
|
||||||
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
||||||
map[string]any{"agent_id": ts.agent.ID, "iteration": iteration},
|
map[string]any{"agent_id": ts.agent.ID, "iteration": iteration},
|
||||||
)
|
)
|
||||||
|
ts.SetFallbackInfo(true, fbResult.Model)
|
||||||
}
|
}
|
||||||
return fbResult.Response, nil
|
return fbResult.Response, nil
|
||||||
}
|
}
|
||||||
|
|
@ -600,10 +601,10 @@ turnLoop:
|
||||||
reasoningContent = response.ReasoningContent
|
reasoningContent = response.ReasoningContent
|
||||||
}
|
}
|
||||||
if ts.channel == "pico" {
|
if ts.channel == "pico" {
|
||||||
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
go al.publishPicoReasoning(ctx, reasoningContent, ts.chatID)
|
||||||
} else {
|
} else {
|
||||||
go al.handleReasoning(
|
go al.handleReasoning(
|
||||||
turnCtx,
|
ctx,
|
||||||
reasoningContent,
|
reasoningContent,
|
||||||
ts.channel,
|
ts.channel,
|
||||||
al.targetReasoningChannelID(ts.channel),
|
al.targetReasoningChannelID(ts.channel),
|
||||||
|
|
@ -671,7 +672,7 @@ turnLoop:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
finalContent = responseContent
|
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{
|
map[string]any{
|
||||||
"agent_id": ts.agent.ID,
|
"agent_id": ts.agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
|
|
@ -1051,7 +1052,7 @@ turnLoop:
|
||||||
|
|
||||||
argsJSON, _ := json.Marshal(toolArgs)
|
argsJSON, _ := json.Marshal(toolArgs)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
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{
|
map[string]any{
|
||||||
"agent_id": ts.agent.ID,
|
"agent_id": ts.agent.ID,
|
||||||
"tool": toolName,
|
"tool": toolName,
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,14 @@ func NewAgentRegistry(
|
||||||
}
|
}
|
||||||
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
||||||
registry.agents["main"] = instance
|
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 {
|
} else {
|
||||||
for i := range agentConfigs {
|
for i := range agentConfigs {
|
||||||
ac := &agentConfigs[i]
|
ac := &agentConfigs[i]
|
||||||
id := routing.NormalizeAgentID(ac.ID)
|
id := routing.NormalizeAgentID(ac.ID)
|
||||||
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||||
registry.agents[id] = instance
|
registry.agents[id] = instance
|
||||||
logger.InfoCF("agent", "Registered agent",
|
logger.DebugCF("agent", "Registered agent",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": id,
|
"agent_id": id,
|
||||||
"name": ac.Name,
|
"name": ac.Name,
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,7 @@ func TestAgentLoop_SteeringMode_ConfiguredFromConfig(t *testing.T) {
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
if al.SteeringMode() != SteeringAll {
|
if al.SteeringMode() != SteeringAll {
|
||||||
t.Fatalf("expected 'all' mode from config, got %v", al.SteeringMode())
|
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()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProvider{response: "continued response"}
|
provider := &simpleMockProvider{response: "continued response"}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
al.Steer(providers.Message{Role: "user", Content: "new direction"})
|
al.Steer(providers.Message{Role: "user", Content: "new direction"})
|
||||||
|
|
||||||
|
|
@ -594,7 +594,7 @@ func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
al.RegisterTool(tool1)
|
al.RegisterTool(tool1)
|
||||||
al.RegisterTool(tool2)
|
al.RegisterTool(tool2)
|
||||||
|
|
||||||
|
|
@ -682,7 +682,7 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
// Enqueue a steering message before processing starts
|
// Enqueue a steering message before processing starts
|
||||||
al.Steer(providers.Message{Role: "user", Content: "pre-enqueued steering"})
|
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{}),
|
firstCallStarted: make(chan struct{}),
|
||||||
releaseFirstCall: make(chan struct{}),
|
releaseFirstCall: make(chan struct{}),
|
||||||
}
|
}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
runCtx, cancelRun := context.WithCancel(context.Background())
|
runCtx, cancelRun := context.WithCancel(context.Background())
|
||||||
defer cancelRun()
|
defer cancelRun()
|
||||||
|
|
@ -866,7 +866,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
resultCh := make(chan struct {
|
resultCh := make(chan struct {
|
||||||
resp string
|
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")
|
support, ok := al.registry.GetAgent("support")
|
||||||
if !ok || support == nil {
|
if !ok || support == nil {
|
||||||
t.Fatal("expected support agent")
|
t.Fatal("expected support agent")
|
||||||
|
|
@ -1026,7 +1026,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
|
||||||
|
|
||||||
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
al.SetMediaStore(store)
|
al.SetMediaStore(store)
|
||||||
|
|
||||||
if err = al.Steer(providers.Message{
|
if err = al.Steer(providers.Message{
|
||||||
|
|
@ -1129,7 +1129,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
al.RegisterTool(tool1)
|
al.RegisterTool(tool1)
|
||||||
al.RegisterTool(tool2)
|
al.RegisterTool(tool2)
|
||||||
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
|
|
@ -1283,7 +1283,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
|
||||||
finalResp: "should not happen",
|
finalResp: "should not happen",
|
||||||
}
|
}
|
||||||
|
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
started := make(chan struct{})
|
started := make(chan struct{})
|
||||||
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
|
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
|
||||||
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
|
||||||
|
|
@ -1475,7 +1475,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
al := NewAgentLoop(cfg, msgBus, wrappedProvider)
|
al := NewAgentLoop(cfg, "", msgBus, wrappedProvider)
|
||||||
al.RegisterTool(tool1)
|
al.RegisterTool(tool1)
|
||||||
al.RegisterTool(tool2)
|
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{
|
parent := &turnState{
|
||||||
ctx: context.Background(),
|
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
|
// Create a root turn state
|
||||||
rootCtx := context.Background()
|
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()
|
rootCtx := context.Background()
|
||||||
rootTS := &turnState{
|
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{
|
msg := providers.Message{
|
||||||
Role: "user",
|
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{
|
msg := providers.Message{
|
||||||
Role: "user",
|
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()
|
rootCtx := context.Background()
|
||||||
rootTS := &turnState{
|
rootTS := &turnState{
|
||||||
|
|
@ -1327,7 +1327,7 @@ func TestConcurrencySemaphore_Timeout(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProviderAPI{}
|
provider := &simpleMockProviderAPI{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
@ -1427,7 +1427,7 @@ func TestContextWrapping_SingleLayer(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProviderAPI{}
|
provider := &simpleMockProviderAPI{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
@ -1473,7 +1473,7 @@ func TestSyncSubTurn_NoChannelDelivery(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProviderAPI{}
|
provider := &simpleMockProviderAPI{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
@ -1530,7 +1530,7 @@ func TestAsyncSubTurn_ChannelDelivery(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProviderAPI{}
|
provider := &simpleMockProviderAPI{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
@ -1662,7 +1662,7 @@ func TestSpawnDuringAbort_RaceCondition(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &simpleMockProviderAPI{}
|
provider := &simpleMockProviderAPI{}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
@ -1761,7 +1761,7 @@ func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &slowMockProvider{delay: 5 * time.Second} // SubTurn takes 5 seconds
|
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
|
// Capture events via real EventBus
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
@ -1847,7 +1847,7 @@ func TestAsyncSubTurn_ParentWaitsForChild(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &slowMockProvider{delay: 200 * time.Millisecond} // SubTurn takes 200ms
|
provider := &slowMockProvider{delay: 200 * time.Millisecond} // SubTurn takes 200ms
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
@ -2014,7 +2014,7 @@ func TestSubTurn_IndependentContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &slowMockProvider{delay: 500 * time.Millisecond}
|
provider := &slowMockProvider{delay: 500 * time.Millisecond}
|
||||||
al := NewAgentLoop(cfg, msgBus, provider)
|
al := NewAgentLoop(cfg, "", msgBus, provider)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
parentTS := &turnState{
|
parentTS := &turnState{
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ type turnState struct {
|
||||||
tokenBudget *atomic.Int64 // Shared token budget counter
|
tokenBudget *atomic.Int64 // Shared token budget counter
|
||||||
lastFinishReason string // Last LLM finish_reason
|
lastFinishReason string // Last LLM finish_reason
|
||||||
lastUsage *providers.UsageInfo // Last LLM usage info
|
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)
|
// Back-reference to the owning AgentLoop (set for SubTurns only, used for hard abort cascade)
|
||||||
al *AgentLoop
|
al *AgentLoop
|
||||||
|
|
@ -493,6 +495,25 @@ func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) {
|
||||||
ts.lastUsage = usage
|
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
|
// Context helper functions for SubTurn
|
||||||
|
|
||||||
type turnStateKeyType struct{}
|
type turnStateKeyType struct{}
|
||||||
|
|
|
||||||
|
|
@ -1376,7 +1376,7 @@ type PlaceholderRecorder interface {
|
||||||
// 1. Create core components
|
// 1. Create core components
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := providers.CreateProvider(cfg)
|
provider := providers.CreateProvider(cfg)
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider)
|
||||||
|
|
||||||
// 2. Create media store (with TTL cleanup)
|
// 2. Create media store (with TTL cleanup)
|
||||||
mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)
|
mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)
|
||||||
|
|
|
||||||
|
|
@ -1374,7 +1374,7 @@ type PlaceholderRecorder interface {
|
||||||
// 1. 创建核心组件
|
// 1. 创建核心组件
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := providers.CreateProvider(cfg)
|
provider := providers.CreateProvider(cfg)
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider)
|
||||||
|
|
||||||
// 2. 创建媒体存储(带 TTL 清理)
|
// 2. 创建媒体存储(带 TTL 清理)
|
||||||
mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)
|
mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)
|
||||||
|
|
|
||||||
|
|
@ -245,8 +245,10 @@ func (s *SecureString) UnmarshalJSON(value []byte) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s SecureString) MarshalYAML() (any, error) {
|
func (s SecureString) MarshalYAML() (any, error) {
|
||||||
// Preserve raw value if it is already a reference (enc:// or file://)
|
// 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) {
|
if strings.HasPrefix(s.raw, credential.EncScheme) ||
|
||||||
|
strings.HasPrefix(s.raw, credential.FileScheme) ||
|
||||||
|
strings.HasPrefix(s.raw, credential.EnvScheme) {
|
||||||
return s.raw, nil
|
return s.raw, nil
|
||||||
}
|
}
|
||||||
// If resolved is a reference format (e.g. set via Set), copy back to raw
|
// 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 {
|
if resolver == nil {
|
||||||
resolver = credential.NewResolver("")
|
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)
|
decrypted, err := resolver.Resolve(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Resolve error: %v", err)
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return decrypted, nil
|
return decrypted, nil
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ const picoclawHome = "PICOCLAW_HOME"
|
||||||
const (
|
const (
|
||||||
FileScheme = "file://"
|
FileScheme = "file://"
|
||||||
EncScheme = "enc://"
|
EncScheme = "enc://"
|
||||||
|
EnvScheme = "env://"
|
||||||
|
|
||||||
hkdfInfo = "picoclaw-credential-v1"
|
hkdfInfo = "picoclaw-credential-v1"
|
||||||
saltLen = 16
|
saltLen = 16
|
||||||
|
|
@ -149,6 +150,17 @@ func (r *Resolver) Resolve(raw string) (string, error) {
|
||||||
return resolveEncrypted(raw)
|
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.
|
// Plaintext credential — return unchanged.
|
||||||
return raw, nil
|
return raw, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider)
|
||||||
|
|
||||||
fmt.Println("\n📦 Agent Status:")
|
fmt.Println("\n📦 Agent Status:")
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
|
|
||||||
307
pkg/tools/freeride.go
Normal file
307
pkg/tools/freeride.go
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeRideTool adapts the FreeRide logic (from clawhub/free-ride) for PicoClaw.
|
||||||
|
// It manages OpenRouter's free models and configures them as fallbacks.
|
||||||
|
type FreeRideTool struct {
|
||||||
|
configPath string
|
||||||
|
reloadFunc func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFreeRideTool(configPath string, reloadFunc func() error) *FreeRideTool {
|
||||||
|
return &FreeRideTool{
|
||||||
|
configPath: configPath,
|
||||||
|
reloadFunc: reloadFunc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) Name() string {
|
||||||
|
return "freeride"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) Description() string {
|
||||||
|
return "FreeRide gives you unlimited free AI in PicoClaw by automatically managing OpenRouter's free models. " +
|
||||||
|
"Use 'auto' to configure best model + fallbacks, or 'list' to see available free models."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"command": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"auto", "list", "status"},
|
||||||
|
"description": "The command to run: 'auto' (configures models), 'list' (shows free models), 'status' (checks current setup)",
|
||||||
|
},
|
||||||
|
"limit": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "For 'list', how many models to show. For 'auto', how many fallbacks to configure.",
|
||||||
|
"default": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type openRouterModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ContextLength int `json:"context_length"`
|
||||||
|
Pricing struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Completion string `json:"completion"`
|
||||||
|
} `json:"pricing"`
|
||||||
|
SupportedParameters []string `json:"supported_parameters"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
cmd, _ := args["command"].(string)
|
||||||
|
limit := 5
|
||||||
|
if l, ok := args["limit"].(float64); ok {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "list":
|
||||||
|
return t.handleList(ctx, limit)
|
||||||
|
case "auto":
|
||||||
|
return t.handleAuto(ctx, limit)
|
||||||
|
case "status":
|
||||||
|
return t.handleStatus()
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown command: %s", cmd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) fetchFreeModels(ctx context.Context) ([]openRouterModel, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper struct {
|
||||||
|
Data []openRouterModel `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var freeModels []openRouterModel
|
||||||
|
for _, m := range wrapper.Data {
|
||||||
|
if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" || m.Pricing.Prompt == "0.00" {
|
||||||
|
freeModels = append(freeModels, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank models
|
||||||
|
sort.Slice(freeModels, func(i, j int) bool {
|
||||||
|
return scoreModel(freeModels[i]) > scoreModel(freeModels[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
return freeModels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreModel(m openRouterModel) float64 {
|
||||||
|
score := 0.0
|
||||||
|
|
||||||
|
// Context length (40%) - normalize against 128k
|
||||||
|
ctxScore := float64(m.ContextLength) / 128000.0
|
||||||
|
if ctxScore > 1.0 {
|
||||||
|
ctxScore = 1.0
|
||||||
|
}
|
||||||
|
score += ctxScore * 0.4
|
||||||
|
|
||||||
|
// Capabilities (30%) - tools, vision, prompt caching, etc.
|
||||||
|
capabilityScore := 0.0
|
||||||
|
for _, p := range m.SupportedParameters {
|
||||||
|
if p == "tools" {
|
||||||
|
capabilityScore += 0.5
|
||||||
|
}
|
||||||
|
if p == "response_format" {
|
||||||
|
capabilityScore += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if capabilityScore > 1.0 {
|
||||||
|
capabilityScore = 1.0
|
||||||
|
}
|
||||||
|
score += capabilityScore * 0.3
|
||||||
|
|
||||||
|
// Recency (20%) - newer is better
|
||||||
|
// Normalize against 2 years ago
|
||||||
|
twoYearsAgo := time.Now().AddDate(-2, 0, 0).Unix()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if m.Created > twoYearsAgo {
|
||||||
|
recencyScore := float64(m.Created-twoYearsAgo) / float64(now-twoYearsAgo)
|
||||||
|
score += recencyScore * 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider Trust (10%) - hardcoded list of trusted names
|
||||||
|
trustNames := []string{"google", "meta", "nvidia", "mistral", "anthropic", "openai", "microsoft", "qwen", "deepseek"}
|
||||||
|
for _, name := range trustNames {
|
||||||
|
if strings.Contains(strings.ToLower(m.ID), name) {
|
||||||
|
score += 0.1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) handleList(ctx context.Context, limit int) *ToolResult {
|
||||||
|
models, err := t.fetchFreeModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) == 0 {
|
||||||
|
return SilentResult("No free models found on OpenRouter.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Found %d free models on OpenRouter (ranked by quality):\n\n", len(models)))
|
||||||
|
for i, m := range models {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1, m.Name, m.ID))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Context: %d tokens | Score: %.2f\n", m.ContextLength, scoreModel(m)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Parameters: %s\n\n", strings.Join(m.SupportedParameters, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return SilentResult(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) handleAuto(ctx context.Context, limit int) *ToolResult {
|
||||||
|
models, err := t.fetchFreeModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) == 0 {
|
||||||
|
return ErrorResult("No free models found on OpenRouter.")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgObj, err := config.LoadConfig(t.configPath)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Add models to ModelList if not present
|
||||||
|
var addedModels []string
|
||||||
|
for i, m := range models {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
modelName := strings.ReplaceAll(m.ID, "/", "-")
|
||||||
|
if !modelExists(cfgObj, modelName) {
|
||||||
|
mc := &config.ModelConfig{
|
||||||
|
ModelName: modelName,
|
||||||
|
Model: "openrouter/" + m.ID,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
mc.SetAPIKey("env://OPENROUTER_API_KEY")
|
||||||
|
cfgObj.ModelList = append(cfgObj.ModelList, mc)
|
||||||
|
addedModels = append(addedModels, modelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set fallbacks for the default agent
|
||||||
|
if len(addedModels) > 0 {
|
||||||
|
// Update AgentDefaults fallbacks
|
||||||
|
cfgObj.Agents.Defaults.ModelFallbacks = append(cfgObj.Agents.Defaults.ModelFallbacks, addedModels...)
|
||||||
|
// Deduplicate fallbacks
|
||||||
|
cfgObj.Agents.Defaults.ModelFallbacks = uniqueStrings(cfgObj.Agents.Defaults.ModelFallbacks)
|
||||||
|
|
||||||
|
if err := config.SaveConfig(t.configPath, cfgObj); err != nil {
|
||||||
|
return ErrorResult(fmt.Errorf("failed to save config: %w", err).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Success! Added %d free models as fallbacks: %s.\n", len(addedModels), strings.Join(addedModels, ", "))
|
||||||
|
msg += "Re-loading configuration to apply changes..."
|
||||||
|
|
||||||
|
if t.reloadFunc != nil {
|
||||||
|
if err := t.reloadFunc(); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("%s\nFailed to reload: %v", msg, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SilentResult(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SilentResult("No new free models to add. Your configuration is up to date.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FreeRideTool) handleStatus() *ToolResult {
|
||||||
|
cfgObj, err := config.LoadConfig(t.configPath)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("FreeRide Status:\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("- Primary Model: %s\n", cfgObj.Agents.Defaults.GetModelName()))
|
||||||
|
sb.WriteString(fmt.Sprintf("- Fallback Models: %s\n", strings.Join(cfgObj.Agents.Defaults.ModelFallbacks, ", ")))
|
||||||
|
|
||||||
|
// Check for OpenRouter models in fallbacks
|
||||||
|
openRouterCount := 0
|
||||||
|
for _, fb := range cfgObj.Agents.Defaults.ModelFallbacks {
|
||||||
|
if strings.Contains(strings.ToLower(fb), "openrouter") || isKnownOpenRouterAlias(cfgObj, fb) {
|
||||||
|
openRouterCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- Managed Free Models: %d\n", openRouterCount))
|
||||||
|
|
||||||
|
return SilentResult(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelExists(cfg *config.Config, modelName string) bool {
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
if m.ModelName == modelName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownOpenRouterAlias(cfg *config.Config, modelName string) bool {
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
if m.ModelName == modelName && strings.HasPrefix(m.Model, "openrouter/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(input []string) []string {
|
||||||
|
keys := make(map[string]bool)
|
||||||
|
list := []string{}
|
||||||
|
for _, entry := range input {
|
||||||
|
if _, value := keys[entry]; !value {
|
||||||
|
keys[entry] = true
|
||||||
|
list = append(list, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
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,
|
channel, chatID string,
|
||||||
asyncCallback AsyncCallback,
|
asyncCallback AsyncCallback,
|
||||||
) *ToolResult {
|
) *ToolResult {
|
||||||
logger.InfoCF("tool", "Tool execution started",
|
logger.DebugCF("tool", "Tool execution started",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
"args": args,
|
"args": args,
|
||||||
|
|
@ -284,7 +284,7 @@ func (r *ToolRegistry) ExecuteWithContext(
|
||||||
"duration": duration.Milliseconds(),
|
"duration": duration.Milliseconds(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.InfoCF("tool", "Tool execution completed",
|
logger.DebugCF("tool", "Tool execution completed",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
"duration_ms": duration.Milliseconds(),
|
"duration_ms": duration.Milliseconds(),
|
||||||
|
|
|
||||||
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 \
|
.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 variables
|
||||||
GO?=CGO_ENABLED=0 go
|
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)/
|
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"
|
@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}')"; \
|
@expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
|
||||||
if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
|
if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
|
||||||
[ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
|
[ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
|
||||||
|
|
@ -115,6 +115,8 @@ build-frontend:
|
||||||
(cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
|
(cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
|
||||||
printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
|
printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build-frontend: frontend-install
|
||||||
@echo "Building frontend..."
|
@echo "Building frontend..."
|
||||||
@cd $(FRONTEND_DIR) && pnpm build:backend
|
@cd $(FRONTEND_DIR) && pnpm build:backend
|
||||||
|
|
||||||
|
|
@ -124,12 +126,12 @@ build-dev-picoclaw:
|
||||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
|
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
test:
|
test: frontend-install
|
||||||
cd $(BACKEND_DIR) && ${WEB_GO} test ./...
|
cd $(BACKEND_DIR) && ${WEB_GO} test ./...
|
||||||
cd $(FRONTEND_DIR) && pnpm lint
|
cd $(FRONTEND_DIR) && pnpm lint
|
||||||
|
|
||||||
# Lint and format
|
# Lint and format
|
||||||
lint:
|
lint: frontend-install
|
||||||
cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
|
cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
|
||||||
cd $(FRONTEND_DIR) && pnpm check
|
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