From 1a23b5a08eb79238d7574538ca53076aca8e8a9f Mon Sep 17 00:00:00 2001 From: "Tim Z." <91899951+aimbotterz@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:48:04 +0200 Subject: [PATCH] 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 --- docs/custom/improvement-roadmap.md | 131 +++++++++++++++++++++++++++++ docs/custom/pi-build.md | 30 ++++++- local_pi/config_ref.json | 2 +- pkg/agent/eventbus_test.go | 2 +- pkg/agent/hook_mount_test.go | 6 +- pkg/agent/loop.go | 31 +++++-- pkg/agent/loop_test.go | 45 +++++++++- pkg/agent/steering_test.go | 7 +- pkg/bus/types.go | 7 +- pkg/cron/service.go | 1 + pkg/tools/cron.go | 3 +- pkg/tools/cron_test.go | 23 +++++ 12 files changed, 265 insertions(+), 23 deletions(-) create mode 100644 docs/custom/improvement-roadmap.md diff --git a/docs/custom/improvement-roadmap.md b/docs/custom/improvement-roadmap.md new file mode 100644 index 000000000..82b972286 --- /dev/null +++ b/docs/custom/improvement-roadmap.md @@ -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 +``` diff --git a/docs/custom/pi-build.md b/docs/custom/pi-build.md index 5bb17cdc9..0327c01e4 100644 --- a/docs/custom/pi-build.md +++ b/docs/custom/pi-build.md @@ -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. -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 -$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 @@ -30,7 +40,9 @@ Use `scp` (Secure Copy Protocol), which transfers files over an encrypted SSH co 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 @@ -52,3 +64,13 @@ Log into your Raspberry Pi terminal via SSH. ### 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. + +## 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! diff --git a/local_pi/config_ref.json b/local_pi/config_ref.json index 4590cf2f6..912ff9bc9 100644 --- a/local_pi/config_ref.json +++ b/local_pi/config_ref.json @@ -35,7 +35,7 @@ "channels": { "telegram": { "enabled": true, - "token": "234232:xxxx", + "token": "xxxx:xxxx", "allow_from": ["xxxxx"] } }, diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 2785d70a5..6d551193f 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -275,7 +275,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { resultCh := make(chan string, 1) 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 }() diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go index 85d8f5c11..2775252a0 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -102,7 +102,7 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) }) 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 { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -140,7 +140,7 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) }) 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 { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -172,7 +172,7 @@ func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testin }) 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 { t.Fatal("expected invalid configured hook error") } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b3fffd64d..8bee6f56c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -92,6 +92,7 @@ type processOptions struct { SuppressToolFeedback bool // Whether to suppress inline tool feedback messages 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) + ModelOverride string // Explicitly requested model tier } type continuationTarget struct { @@ -1284,12 +1285,13 @@ func (al *AgentLoop) ProcessDirect( ctx context.Context, content, sessionKey string, ) (string, error) { - return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") + return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct", "") } func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, content, sessionKey, channel, chatID string, + modelOverride string, ) (string, error) { if err := al.ensureHooksInitialized(ctx); err != nil { return "", err @@ -1299,11 +1301,12 @@ func (al *AgentLoop) ProcessDirectWithChannel( } msg := bus.InboundMessage{ - Channel: channel, - SenderID: "cron", - ChatID: chatID, - Content: content, - SessionKey: sessionKey, + Channel: channel, + SenderID: "cron", + ChatID: chatID, + Content: content, + SessionKey: sessionKey, + ModelOverride: modelOverride, } return al.processMessage(ctx, msg) @@ -1445,6 +1448,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, + ModelOverride: msg.ModelOverride, } // 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) } - 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) activeProvider := ts.agent.Provider if usedLight && ts.agent.LightProvider != nil { @@ -2976,7 +2980,20 @@ func (al *AgentLoop) selectCandidates( agent *AgentInstance, userMsg string, history []providers.Message, + modelOverride string, ) (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 { return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false, 0 } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 9513d8aca..86a5c0fc2 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -2008,6 +2009,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { sessionKey, "test", "test-chat", + "", ) if err != nil { t.Fatalf("Expected success after retry, got error: %v", err) @@ -2055,7 +2057,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { provider := &simpleMockProvider{response: ""} 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 { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -2087,7 +2089,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { al := NewAgentLoop(cfg, msgBus, provider) 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 { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -2162,6 +2164,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) { "session-1", "cli", "direct", + "", ) if err != nil { 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) } } + +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") + } +} + diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 75ba9861d..18635ac62 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -702,6 +702,7 @@ func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) { "test-session", "test", "chat1", + "", ) resultCh <- result{resp, err} }() @@ -784,6 +785,7 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) { "test-session", "test", "chat1", + "", ) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -971,6 +973,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing. sessionKey, "test", "chat1", + "", ) resultCh <- struct { resp string @@ -1185,6 +1188,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { sessionKey, "test", "chat1", + "", ) resultCh <- result{resp: resp, err: err} }() @@ -1350,6 +1354,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { sessionKey, "test", "chat1", + "", ) resultCh <- result{resp: resp, err: err} }() @@ -1518,7 +1523,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { resultCh := make(chan string, 1) go func() { resp, _ := al.ProcessDirectWithChannel( - context.Background(), "go", "test-session", "test", "chat1", + context.Background(), "go", "test-session", "test", "chat1", "", ) resultCh <- resp }() diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 27cf61b5f..ce4945499 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -24,9 +24,10 @@ type InboundMessage struct { Media []string `json:"media,omitempty"` Peer Peer `json:"peer"` // routing peer MessageID string `json:"message_id,omitempty"` // platform message ID - MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope - SessionKey string `json:"session_key"` - Metadata map[string]string `json:"metadata,omitempty"` + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope + SessionKey string `json:"session_key"` + ModelOverride string `json:"model_override,omitempty"` // Explicitly requested model tier + Metadata map[string]string `json:"metadata,omitempty"` } type OutboundMessage struct { diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 6a8728943..5becfbc33 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -29,6 +29,7 @@ type CronPayload struct { Command string `json:"command,omitempty"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` + Model string `json:"model,omitempty"` // Override model tier (e.g. "gemini-3-flash") } type CronJobState struct { diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index c6ac3a129..cc74c449d 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -15,7 +15,7 @@ import ( // JobExecutor is the interface for executing cron jobs through the agent 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 // agent did not already deliver content through the message tool in this round. PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) @@ -351,6 +351,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { sessionKey, channel, chatID, + job.Payload.Model, ) if err != nil { return fmt.Sprintf("Error: %v", err) diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index c699908cd..91d19bf52 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -21,6 +21,7 @@ type stubJobExecutor struct { lastKey string lastChan string lastChatID string + lastModel string // Track the model override publishedResp string publishedChan string publishedChatID string @@ -29,11 +30,13 @@ type stubJobExecutor struct { func (s *stubJobExecutor) ProcessDirectWithChannel( _ context.Context, content, sessionKey, channel, chatID string, + modelOverride string, ) (string, error) { s.lastPrompt = content s.lastKey = sessionKey s.lastChan = channel s.lastChatID = chatID + s.lastModel = modelOverride 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) } } + +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) + } +} +