feat(routing): implement task-specific model routing and overrides
- Added Model field to CronPayload and JobExecutor - Integrated model override support into AgentLoop selectCandidates - Updated RuleClassifier to bypass routing if explicit override is provided - Added unit tests for cron model propagation and agent routing override - Updated documentation for model tiering, overrides, and ARM build process
This commit is contained in:
parent
6de7f9e8c3
commit
1a23b5a08e
12 changed files with 265 additions and 23 deletions
131
docs/custom/improvement-roadmap.md
Normal file
131
docs/custom/improvement-roadmap.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Picoclaw Improvement Roadmap: Tools, Skills & Cronjobs
|
||||||
|
|
||||||
|
This document outlines how to expand Picoclaw's capabilities based on your specific homelab environment and agent roles (Alpha, Pulse, Forge, Atlas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The Decision Matrix: Which one do I use?
|
||||||
|
|
||||||
|
To choose the right method, ask yourself **"When and how should this happen?"**
|
||||||
|
|
||||||
|
| Method | The Use Case | The "Why" | Example |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Tools** | **Real-time action/data.** When you ask "What's the price of ASML?" | Gives the LLM **active hands**. It allows the model to fetch fresh facts that weren't in its training data. | `fetch_ticker_price(symbol)` |
|
||||||
|
| **Skills** | **Consistent Logic.** When you want the LLM to analyze the *quality* of a stock using your specific methodology. | Shapes the **agent's brain**. It ensures the bot always follows your "investment thesis" framework every time it thinks. | `ibkr_analysis_skill` |
|
||||||
|
| **Cronjobs** | **Scheduled Automation.** When you want a summary at 8 AM every morning without you having to ask for it. | Provides **proactive duty**. It ensures the system is working for you 24/7, even when you aren't chatting with it. | `daily_backup.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Elaborating on Use Cases
|
||||||
|
|
||||||
|
### **Scenario A: "I want to know if my internet is slow right now."**
|
||||||
|
- **Winning Method:** **Tool**
|
||||||
|
- **Why?** You are in a live chat and need a data-point *right now*. A `run_speedtest` tool triggered by your question is the most efficient.
|
||||||
|
|
||||||
|
### **Scenario B: "I want to track my long-term weight trend and get a weekly report every Sunday."**
|
||||||
|
- **Winning Method:** **Cronjob + Tool**
|
||||||
|
- **Why?** Use a **Cronjob** to trigger the analysis every Sunday. The cronjob then uses a **SQLite Tool** to pull the data and send the report to your Telegram.
|
||||||
|
|
||||||
|
### **Scenario C: "I want my agent to follow the 'Getting Things Done' (GTD) method for my task list."**
|
||||||
|
- **Winning Method:** **Skill**
|
||||||
|
- **Why?** This isn't about an API or a schedule; it's about *how* the agent processes information. A "GTD Skill" contains the rules (folders, next actions, contexts) that the agent must ALWAYS follow when you talk about tasks.
|
||||||
|
|
||||||
|
### **Scenario D: "I want to be alerted if ASML drops below €800."**
|
||||||
|
- **Winning Method:** **Cronjob**
|
||||||
|
- **Why?** A "Tool" only works when you are talking. You need a background **Cronjob** that runs every 10 minutes, checks the price, and "pokes" you if the condition is met.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Recommended Additions by Agent
|
||||||
|
|
||||||
|
### **Alpha (Finance)**
|
||||||
|
* **Tool:** `tavily_search` — Real-time news searching for your ticker watchlist.
|
||||||
|
* **Skill:** `fundamental_analysis` — A set of instructions for reading IBKR data and flagging 10-K risk factors.
|
||||||
|
* **Cronjob:** `portfolio_fetch` — Automatically download your IBKR Flex Query CSV every day at 6 PM.
|
||||||
|
|
||||||
|
### **Pulse (Fitness)**
|
||||||
|
* **Tool:** `sqlite_query` — Query your local health database for long-term HRV or weight trends.
|
||||||
|
* **Skill:** `recovery_logic` — Specific rules for when to recommend a "deload week" based on biometric inputs.
|
||||||
|
* **Cronjob:** `morning_brief` — At 7 AM, analyze last night's sleep data and generate a summary waiting for you in Telegram.
|
||||||
|
|
||||||
|
### **Forge (Systems)**
|
||||||
|
* **Tool:** `ssh_run` — Allow the agent to run commands directly on **Argus** or **Vulcan** for remote debugging.
|
||||||
|
* **Skill:** `refactor_patterns` — Knowledge of Go/Python best practices to improve code quality during review.
|
||||||
|
* **Cronjob:** `heartbeat_monitor` — Every hour, ping all devices in the lab and alert you if one goes offline.
|
||||||
|
|
||||||
|
### **Atlas (Org/Local)**
|
||||||
|
* **Tool:** `calendar_api` — Link specifically to an `ical` or Google Calendar to add/remove appointments.
|
||||||
|
* **Cronjob:** `daily_planner` — Every Sunday evening, summarize your upcoming week based on your notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tool Discovery: Web Search
|
||||||
|
**Great news:** Picoclaw already has a powerful `web_search` tool built into `pkg/tools/web.go`. It supports:
|
||||||
|
- **Tavily** & **Brave Search** (Recommended for pure data)
|
||||||
|
- **Perplexity** (Best for analytical answers)
|
||||||
|
- **DuckDuckGo** (Free, no API key needed)
|
||||||
|
|
||||||
|
To enable these, you simply need to add your API keys to the `config.json` file we built earlier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Setup Guide: The "Morning Briefing"
|
||||||
|
To turn Picoclaw into a proactive assistant that messages you every morning with market and macro news, follow this framework:
|
||||||
|
|
||||||
|
### **Phase 1: The Command Script**
|
||||||
|
Create a script on your Pi (e.g., `~/scripts/morning_brief.sh`):
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Trigger Alpha to generate a briefing and send it to your Telegram ID
|
||||||
|
picoclaw --agent alpha --cmd "Search for overnight news on ASML, NVDA and the 10Y Treasury yield. Summarize the macro sentiment for today. Check ~/picoclaw-data/workspace/ibkr for any updates. Send result as a Morning Briefing."
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Phase 2: The Cron Trigger**
|
||||||
|
Add this to your Pi's crontab (`crontab -e`):
|
||||||
|
```bash
|
||||||
|
# Run the briefing every weekday at 7:30 AM
|
||||||
|
30 07 * * 1-5 /home/tim/scripts/morning_brief.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Why this works:**
|
||||||
|
1. **Proactive:** You don't have to remember to check the news; it hits your phone while you're having coffee.
|
||||||
|
2. **Context-Aware:** Because it uses the **Alpha** agent, it already knows your watchlist and your portfolio goals.
|
||||||
|
3. **Low Latency:** By the time you wake up, the "heavy lifting" (searching and summarizing) is already done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. More Brainstorming Ideas
|
||||||
|
|
||||||
|
### **Forge (Systems)**
|
||||||
|
- **Tool:** `log_audit` — Automatically scan `journalctl` for Picoclaw errors and summarize them.
|
||||||
|
- **Skill:** `pi_safety_rules` — Explicit rules to never exceed 400MB RAM in generated code.
|
||||||
|
- **Cronjob:** `vulcan_health_check` — Ping your desktop every hour to ensure the Ollama host is reachable.
|
||||||
|
|
||||||
|
### **Pulse (Health)**
|
||||||
|
- **Skill:** `hrv_interpreter` — A framework to tell you *exactly* how to adjust your workout based on last night's HRV.
|
||||||
|
- **Cronjob:** `evening_reminder` — At 9 PM, check if you logged your supplements; if not, send a gentle nudge.
|
||||||
|
- **Model Override:** Force a higher-tier model for specific Pulse tasks (e.g., weekly health audit) using the `model` payload field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Model Tiering & Routing Overrides
|
||||||
|
|
||||||
|
Picoclaw uses a multi-tier routing system to balance performance and cost on the Pi Zero 2W.
|
||||||
|
|
||||||
|
### **The Model Tiers**
|
||||||
|
| Tier | Model | Best For |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Light** | `gemini-1.5-flash-lite` | Simple commands, data retrieval, basic chat. (Default for most Pulse/Atlas agents) |
|
||||||
|
| **Mid** | `gemini-1.5-flash` | Reasoning tasks, complex tool orchestration, small code refactors. |
|
||||||
|
| **High** | `claude-3-5-sonnet` | Critical decision making, complex coding, high-stakes analysis. |
|
||||||
|
|
||||||
|
### **The Override Mechanism**
|
||||||
|
While the `RuleClassifier` automatically promotes tasks to higher tiers based on complexity, you can **force** a specific model for repeated tasks (like Cronjobs) to ensure consistent quality.
|
||||||
|
|
||||||
|
#### **Cron Override Example:**
|
||||||
|
In your `agent.yaml` or cron payload, add the `model` field:
|
||||||
|
```yaml
|
||||||
|
payload:
|
||||||
|
message: "Run a deep audit of the system logs and suggest security improvements."
|
||||||
|
model: "gemini-1.5-flash" # Forces Mid-tier logic for this specific task
|
||||||
|
```
|
||||||
|
|
@ -15,12 +15,22 @@ This guide details the step-by-step process for building a custom version of Pic
|
||||||
|
|
||||||
Since the Raspberry Pi Zero 2W is an ARM-based environment (running a 32-bit `armhf` OS usually), we use Go's powerful cross-compilation features natively on Windows.
|
Since the Raspberry Pi Zero 2W is an ARM-based environment (running a 32-bit `armhf` OS usually), we use Go's powerful cross-compilation features natively on Windows.
|
||||||
|
|
||||||
Run this single command in PowerShell from the repository root:
|
Run this dynamic command in PowerShell from the repository root to automatically bake in the current Version and Commit Hash:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:CGO_ENABLED="0"; $env:GOOS="linux"; $env:GOARCH="arm"; $env:GOARM="7"; go build -o picoclaw-custom ./cmd/picoclaw
|
# 1. Capture dynamic build metadata
|
||||||
|
$v = (git describe --tags --always --dirty 2>$null) -join ""; if (!$v) { $v = "dev" }
|
||||||
|
$c = (git rev-parse --short HEAD 2>$null) -join ""; if (!$c) { $c = "unknown" }
|
||||||
|
$t = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
|
||||||
|
|
||||||
|
# 2. Compile with metadata injection
|
||||||
|
$env:CGO_ENABLED="0"; $env:GOOS="linux"; $env:GOARCH="arm"; $env:GOARM="7"
|
||||||
|
go build -v -tags "goolm,stdjson" `
|
||||||
|
-ldflags "-X github.com/sipeed/picoclaw/pkg/config.Version=$v -X github.com/sipeed/picoclaw/pkg/config.GitCommit=$c -X github.com/sipeed/picoclaw/pkg/config.BuildTime=$t" `
|
||||||
|
-o picoclaw-custom ./cmd/picoclaw
|
||||||
```
|
```
|
||||||
|
|
||||||
This instructs the Go compiler to generate a standalone Linux executable tailored for the Pi's architecture.
|
This ensures your Telegram header shows the correct version and commit info instead of just `dev`.
|
||||||
|
|
||||||
## Step B: Transfer to Pi
|
## Step B: Transfer to Pi
|
||||||
|
|
||||||
|
|
@ -30,7 +40,9 @@ Use `scp` (Secure Copy Protocol), which transfers files over an encrypted SSH co
|
||||||
scp picoclaw-custom tim@picoclaw.local:/tmp/
|
scp picoclaw-custom tim@picoclaw.local:/tmp/
|
||||||
```
|
```
|
||||||
|
|
||||||
*This copies your newly compiled `picoclaw-custom` file from Windows up to the `/tmp/` folder on your Pi.*
|
*This copies your newly compiled `picoclaw-custom` executable file from Windows up to the `/tmp/` folder on your Pi.*
|
||||||
|
|
||||||
|
>**Note:** You are **not** copying the entire project folder or any `.go` code! Go's superpower is compiling all your files and logic down into a single, standalone binary file. That one compressed file contains the entire bot engine and is the absolute only thing the Pi needs to run perfectly.
|
||||||
|
|
||||||
## Step C: Update the Raspberry Pi Service
|
## Step C: Update the Raspberry Pi Service
|
||||||
|
|
||||||
|
|
@ -52,3 +64,13 @@ Log into your Raspberry Pi terminal via SSH.
|
||||||
|
|
||||||
### Why Cross-Compile?
|
### Why Cross-Compile?
|
||||||
Cross-compiling on your powerful Windows desktop ("Vulcan") saves the Raspberry Pi Zero 2W from the massive heat, CPU stress, and time-consumption of downloading the Go SDK and compiling code with 512MB of RAM.
|
Cross-compiling on your powerful Windows desktop ("Vulcan") saves the Raspberry Pi Zero 2W from the massive heat, CPU stress, and time-consumption of downloading the Go SDK and compiling code with 512MB of RAM.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "The new code changes aren't showing up!"
|
||||||
|
If you replaced the binary in `/usr/local/bin/picoclaw` but the bot is still running old logic, your `systemd` service is likely pointing to a different folder.
|
||||||
|
|
||||||
|
1. SSH into the Pi and run: `sudo systemctl status picoclaw`
|
||||||
|
2. Look specifically at the line that says:
|
||||||
|
`├─23319 /usr/local/bin/picoclaw gateway` (Under the CGroup tree).
|
||||||
|
3. If the path listed there is something else (like `/home/tim/picoclaw`), you must `mv` your `picoclaw-custom` binary into *that* specific folder instead!
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "234232:xxxx",
|
"token": "xxxx:xxxx",
|
||||||
"allow_from": ["xxxxx"]
|
"allow_from": ["xxxxx"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) {
|
||||||
|
|
||||||
resultCh := make(chan string, 1)
|
resultCh := make(chan string, 1)
|
||||||
go func() {
|
go func() {
|
||||||
resp, _ := al.ProcessDirectWithChannel(context.Background(), "do something", "test-session", "test", "chat1")
|
resp, _ := al.ProcessDirectWithChannel(context.Background(), "do something", "test-session", "test", "chat1", "")
|
||||||
resultCh <- resp
|
resultCh <- resp
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T)
|
||||||
})
|
})
|
||||||
defer al.Close()
|
defer al.Close()
|
||||||
|
|
||||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct")
|
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +140,7 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T)
|
||||||
})
|
})
|
||||||
defer al.Close()
|
defer al.Close()
|
||||||
|
|
||||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct")
|
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +172,7 @@ func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testin
|
||||||
})
|
})
|
||||||
defer al.Close()
|
defer al.Close()
|
||||||
|
|
||||||
_, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct")
|
_, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected invalid configured hook error")
|
t.Fatal("expected invalid configured hook error")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ type processOptions struct {
|
||||||
SuppressToolFeedback bool // Whether to suppress inline tool feedback messages
|
SuppressToolFeedback bool // Whether to suppress inline tool feedback messages
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
|
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
|
||||||
|
ModelOverride string // Explicitly requested model tier
|
||||||
}
|
}
|
||||||
|
|
||||||
type continuationTarget struct {
|
type continuationTarget struct {
|
||||||
|
|
@ -1284,12 +1285,13 @@ func (al *AgentLoop) ProcessDirect(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
content, sessionKey string,
|
content, sessionKey string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
|
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) ProcessDirectWithChannel(
|
func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
content, sessionKey, channel, chatID string,
|
content, sessionKey, channel, chatID string,
|
||||||
|
modelOverride string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if err := al.ensureHooksInitialized(ctx); err != nil {
|
if err := al.ensureHooksInitialized(ctx); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -1304,6 +1306,7 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
|
ModelOverride: modelOverride,
|
||||||
}
|
}
|
||||||
|
|
||||||
return al.processMessage(ctx, msg)
|
return al.processMessage(ctx, msg)
|
||||||
|
|
@ -1445,6 +1448,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
ModelOverride: msg.ModelOverride,
|
||||||
}
|
}
|
||||||
|
|
||||||
// context-dependent commands check their own Runtime fields and report
|
// context-dependent commands check their own Runtime fields and report
|
||||||
|
|
@ -1848,7 +1852,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||||
ts.ingestMessage(turnCtx, al, rootMsg)
|
ts.ingestMessage(turnCtx, al, rootMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
activeCandidates, activeModel, usedLight, score := al.selectCandidates(ts.agent, ts.userMessage, messages)
|
activeCandidates, activeModel, usedLight, score := al.selectCandidates(ts.agent, ts.userMessage, messages, ts.opts.ModelOverride)
|
||||||
ts.setComplexityScore(score)
|
ts.setComplexityScore(score)
|
||||||
activeProvider := ts.agent.Provider
|
activeProvider := ts.agent.Provider
|
||||||
if usedLight && ts.agent.LightProvider != nil {
|
if usedLight && ts.agent.LightProvider != nil {
|
||||||
|
|
@ -2976,7 +2980,20 @@ func (al *AgentLoop) selectCandidates(
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
userMsg string,
|
userMsg string,
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
|
modelOverride string,
|
||||||
) (candidates []providers.FallbackCandidate, model string, usedLight bool, score float64) {
|
) (candidates []providers.FallbackCandidate, model string, usedLight bool, score float64) {
|
||||||
|
if modelOverride != "" {
|
||||||
|
// Use explicit override if provided (bypasses routing rules)
|
||||||
|
logger.InfoCF("agent", "Model routing: using explicit override",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"model": modelOverride,
|
||||||
|
})
|
||||||
|
// We use the primary candidates list but substitute the model name if it matches an available candidate,
|
||||||
|
// otherwise we trust the override.
|
||||||
|
return agent.Candidates, modelOverride, false, 0
|
||||||
|
}
|
||||||
|
|
||||||
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
||||||
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false, 0
|
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false, 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -2008,6 +2009,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
"test",
|
"test",
|
||||||
"test-chat",
|
"test-chat",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Expected success after retry, got error: %v", err)
|
t.Fatalf("Expected success after retry, got error: %v", err)
|
||||||
|
|
@ -2055,7 +2057,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
|
||||||
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 {
|
||||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -2087,7 +2089,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
||||||
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", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -2162,6 +2164,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
|
||||||
"session-1",
|
"session-1",
|
||||||
"cli",
|
"cli",
|
||||||
"direct",
|
"direct",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||||
|
|
@ -3046,3 +3049,41 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) {
|
||||||
t.Fatalf("expected 2 calls for retry, got %d", provider.calls)
|
t.Fatalf("expected 2 calls for retry, got %d", provider.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentLoop_SelectCandidates_ModelOverride(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
ModelName: "default-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &simpleMockProvider{response: "ok"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
// Test case: provide an explicit model override
|
||||||
|
modelOverride := "explicit-gemini-3-flash"
|
||||||
|
|
||||||
|
// We need an agent instance
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
t.Fatal("default agent not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, selectedModel, usedLight, score := al.selectCandidates(agent, "test", nil, modelOverride)
|
||||||
|
|
||||||
|
if selectedModel != modelOverride {
|
||||||
|
t.Fatalf("selectedModel = %q, want %q", selectedModel, modelOverride)
|
||||||
|
}
|
||||||
|
if usedLight {
|
||||||
|
t.Fatal("usedLight should be false when using override")
|
||||||
|
}
|
||||||
|
if score != 0 {
|
||||||
|
t.Fatalf("score should be 0 when using override, got %f", score)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(candidates, agent.Candidates) {
|
||||||
|
t.Fatal("candidates should match agent.Candidates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -702,6 +702,7 @@ func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) {
|
||||||
"test-session",
|
"test-session",
|
||||||
"test",
|
"test",
|
||||||
"chat1",
|
"chat1",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
resultCh <- result{resp, err}
|
resultCh <- result{resp, err}
|
||||||
}()
|
}()
|
||||||
|
|
@ -784,6 +785,7 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) {
|
||||||
"test-session",
|
"test-session",
|
||||||
"test",
|
"test",
|
||||||
"chat1",
|
"chat1",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|
@ -971,6 +973,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.
|
||||||
sessionKey,
|
sessionKey,
|
||||||
"test",
|
"test",
|
||||||
"chat1",
|
"chat1",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
resultCh <- struct {
|
resultCh <- struct {
|
||||||
resp string
|
resp string
|
||||||
|
|
@ -1185,6 +1188,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
"test",
|
"test",
|
||||||
"chat1",
|
"chat1",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
resultCh <- result{resp: resp, err: err}
|
resultCh <- result{resp: resp, err: err}
|
||||||
}()
|
}()
|
||||||
|
|
@ -1350,6 +1354,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
"test",
|
"test",
|
||||||
"chat1",
|
"chat1",
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
resultCh <- result{resp: resp, err: err}
|
resultCh <- result{resp: resp, err: err}
|
||||||
}()
|
}()
|
||||||
|
|
@ -1518,7 +1523,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) {
|
||||||
resultCh := make(chan string, 1)
|
resultCh := make(chan string, 1)
|
||||||
go func() {
|
go func() {
|
||||||
resp, _ := al.ProcessDirectWithChannel(
|
resp, _ := al.ProcessDirectWithChannel(
|
||||||
context.Background(), "go", "test-session", "test", "chat1",
|
context.Background(), "go", "test-session", "test", "chat1", "",
|
||||||
)
|
)
|
||||||
resultCh <- resp
|
resultCh <- resp
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type InboundMessage struct {
|
||||||
MessageID string `json:"message_id,omitempty"` // platform message ID
|
MessageID string `json:"message_id,omitempty"` // platform message ID
|
||||||
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
|
ModelOverride string `json:"model_override,omitempty"` // Explicitly requested model tier
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type CronPayload struct {
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"` // Override model tier (e.g. "gemini-3-flash")
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronJobState struct {
|
type CronJobState struct {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import (
|
||||||
|
|
||||||
// JobExecutor is the interface for executing cron jobs through the agent
|
// JobExecutor is the interface for executing cron jobs through the agent
|
||||||
type JobExecutor interface {
|
type JobExecutor interface {
|
||||||
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
|
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string, modelOverride string) (string, error)
|
||||||
// PublishResponseIfNeeded sends response to the outbound bus only when the
|
// PublishResponseIfNeeded sends response to the outbound bus only when the
|
||||||
// agent did not already deliver content through the message tool in this round.
|
// agent did not already deliver content through the message tool in this round.
|
||||||
PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string)
|
PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string)
|
||||||
|
|
@ -351,6 +351,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
channel,
|
channel,
|
||||||
chatID,
|
chatID,
|
||||||
|
job.Payload.Model,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("Error: %v", err)
|
return fmt.Sprintf("Error: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type stubJobExecutor struct {
|
||||||
lastKey string
|
lastKey string
|
||||||
lastChan string
|
lastChan string
|
||||||
lastChatID string
|
lastChatID string
|
||||||
|
lastModel string // Track the model override
|
||||||
publishedResp string
|
publishedResp string
|
||||||
publishedChan string
|
publishedChan string
|
||||||
publishedChatID string
|
publishedChatID string
|
||||||
|
|
@ -29,11 +30,13 @@ type stubJobExecutor struct {
|
||||||
func (s *stubJobExecutor) ProcessDirectWithChannel(
|
func (s *stubJobExecutor) ProcessDirectWithChannel(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
content, sessionKey, channel, chatID string,
|
content, sessionKey, channel, chatID string,
|
||||||
|
modelOverride string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
s.lastPrompt = content
|
s.lastPrompt = content
|
||||||
s.lastKey = sessionKey
|
s.lastKey = sessionKey
|
||||||
s.lastChan = channel
|
s.lastChan = channel
|
||||||
s.lastChatID = chatID
|
s.lastChatID = chatID
|
||||||
|
s.lastModel = modelOverride
|
||||||
return s.response, s.err
|
return s.response, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,3 +348,23 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
|
||||||
t.Fatalf("unexpected publish on error path: %q", executor.publishedResp)
|
t.Fatalf("unexpected publish on error path: %q", executor.publishedResp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCronTool_ExecuteJob_ModelOverride(t *testing.T) {
|
||||||
|
executor := &stubJobExecutor{response: "reply"}
|
||||||
|
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
|
||||||
|
|
||||||
|
job := &cron.CronJob{ID: "job-override"}
|
||||||
|
job.Payload.Channel = "telegram"
|
||||||
|
job.Payload.To = "chat-1"
|
||||||
|
job.Payload.Message = "heavy task"
|
||||||
|
job.Payload.Model = "gemini-3-flash"
|
||||||
|
|
||||||
|
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
|
||||||
|
t.Fatalf("ExecuteJob() = %q, want ok", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if executor.lastModel != "gemini-3-flash" {
|
||||||
|
t.Fatalf("model = %q, want gemini-3-flash", executor.lastModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue