From 6e7149509a3a4a25661604a6def08a3f532b0b87 Mon Sep 17 00:00:00 2001
From: Leandro Barbosa
Date: Fri, 13 Feb 2026 12:12:12 -0300
Subject: [PATCH 001/130] feat: add model fallback chain with error
classification
Add 2-layer fallback system (text + image) with automatic candidate
resolution. Includes error classifier (~40 patterns), per-provider
cooldown (exponential backoff), and model reference parsing.
- FailoverError/FailoverReason types for structured error handling
- ErrorClassifier with rate_limit, billing, auth, timeout patterns
- FallbackChain with cooldown management and candidate rotation
- ModelRef parser for provider/model string format
- 128 tests, 95%+ coverage
---
pkg/providers/cooldown.go | 207 +++++++++++
pkg/providers/cooldown_test.go | 269 ++++++++++++++
pkg/providers/error_classifier.go | 253 +++++++++++++
pkg/providers/error_classifier_test.go | 337 ++++++++++++++++++
pkg/providers/fallback.go | 283 +++++++++++++++
pkg/providers/fallback_test.go | 473 +++++++++++++++++++++++++
pkg/providers/model_ref.go | 64 ++++
pkg/providers/model_ref_test.go | 125 +++++++
pkg/providers/types.go | 48 ++-
9 files changed, 2058 insertions(+), 1 deletion(-)
create mode 100644 pkg/providers/cooldown.go
create mode 100644 pkg/providers/cooldown_test.go
create mode 100644 pkg/providers/error_classifier.go
create mode 100644 pkg/providers/error_classifier_test.go
create mode 100644 pkg/providers/fallback.go
create mode 100644 pkg/providers/fallback_test.go
create mode 100644 pkg/providers/model_ref.go
create mode 100644 pkg/providers/model_ref_test.go
diff --git a/pkg/providers/cooldown.go b/pkg/providers/cooldown.go
new file mode 100644
index 000000000..6811297f0
--- /dev/null
+++ b/pkg/providers/cooldown.go
@@ -0,0 +1,207 @@
+package providers
+
+import (
+ "math"
+ "sync"
+ "time"
+)
+
+const (
+ defaultFailureWindow = 24 * time.Hour
+)
+
+// CooldownTracker manages per-provider cooldown state for the fallback chain.
+// Thread-safe via sync.RWMutex. In-memory only (resets on restart).
+type CooldownTracker struct {
+ mu sync.RWMutex
+ entries map[string]*cooldownEntry
+ failureWindow time.Duration
+ nowFunc func() time.Time // for testing
+}
+
+type cooldownEntry struct {
+ ErrorCount int
+ FailureCounts map[FailoverReason]int
+ CooldownEnd time.Time // standard cooldown expiry
+ DisabledUntil time.Time // billing-specific disable expiry
+ DisabledReason FailoverReason // reason for disable (billing)
+ LastFailure time.Time
+}
+
+// NewCooldownTracker creates a tracker with default 24h failure window.
+func NewCooldownTracker() *CooldownTracker {
+ return &CooldownTracker{
+ entries: make(map[string]*cooldownEntry),
+ failureWindow: defaultFailureWindow,
+ nowFunc: time.Now,
+ }
+}
+
+// MarkFailure records a failure for a provider and sets appropriate cooldown.
+// Resets error counts if last failure was more than failureWindow ago.
+func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {
+ ct.mu.Lock()
+ defer ct.mu.Unlock()
+
+ now := ct.nowFunc()
+ entry := ct.getOrCreate(provider)
+
+ // 24h failure window reset: if no failure in failureWindow, reset counters.
+ if !entry.LastFailure.IsZero() && now.Sub(entry.LastFailure) > ct.failureWindow {
+ entry.ErrorCount = 0
+ entry.FailureCounts = make(map[FailoverReason]int)
+ }
+
+ entry.ErrorCount++
+ entry.FailureCounts[reason]++
+ entry.LastFailure = now
+
+ if reason == FailoverBilling {
+ billingCount := entry.FailureCounts[FailoverBilling]
+ entry.DisabledUntil = now.Add(calculateBillingCooldown(billingCount))
+ entry.DisabledReason = FailoverBilling
+ } else {
+ entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount))
+ }
+}
+
+// MarkSuccess resets all counters and cooldowns for a provider.
+func (ct *CooldownTracker) MarkSuccess(provider string) {
+ ct.mu.Lock()
+ defer ct.mu.Unlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return
+ }
+
+ entry.ErrorCount = 0
+ entry.FailureCounts = make(map[FailoverReason]int)
+ entry.CooldownEnd = time.Time{}
+ entry.DisabledUntil = time.Time{}
+ entry.DisabledReason = ""
+}
+
+// IsAvailable returns true if the provider is not in cooldown or disabled.
+func (ct *CooldownTracker) IsAvailable(provider string) bool {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return true
+ }
+
+ now := ct.nowFunc()
+
+ // Billing disable takes precedence (longer cooldown).
+ if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {
+ return false
+ }
+
+ // Standard cooldown.
+ if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {
+ return false
+ }
+
+ return true
+}
+
+// CooldownRemaining returns how long until the provider becomes available.
+// Returns 0 if already available.
+func (ct *CooldownTracker) CooldownRemaining(provider string) time.Duration {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return 0
+ }
+
+ now := ct.nowFunc()
+ var remaining time.Duration
+
+ if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {
+ d := entry.DisabledUntil.Sub(now)
+ if d > remaining {
+ remaining = d
+ }
+ }
+
+ if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {
+ d := entry.CooldownEnd.Sub(now)
+ if d > remaining {
+ remaining = d
+ }
+ }
+
+ return remaining
+}
+
+// ErrorCount returns the current error count for a provider.
+func (ct *CooldownTracker) ErrorCount(provider string) int {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return 0
+ }
+ return entry.ErrorCount
+}
+
+// FailureCount returns the failure count for a specific reason.
+func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) int {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return 0
+ }
+ return entry.FailureCounts[reason]
+}
+
+func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry {
+ entry := ct.entries[provider]
+ if entry == nil {
+ entry = &cooldownEntry{
+ FailureCounts: make(map[FailoverReason]int),
+ }
+ ct.entries[provider] = entry
+ }
+ return entry
+}
+
+// calculateStandardCooldown computes standard exponential backoff.
+// Formula from OpenClaw: min(1h, 1min * 5^min(n-1, 3))
+//
+// 1 error → 1 min
+// 2 errors → 5 min
+// 3 errors → 25 min
+// 4+ errors → 1 hour (cap)
+func calculateStandardCooldown(errorCount int) time.Duration {
+ n := max(1, errorCount)
+ exp := min(n-1, 3)
+ ms := 60_000 * int(math.Pow(5, float64(exp)))
+ ms = min(3_600_000, ms) // cap at 1 hour
+ return time.Duration(ms) * time.Millisecond
+}
+
+// calculateBillingCooldown computes billing-specific exponential backoff.
+// Formula from OpenClaw: min(24h, 5h * 2^min(n-1, 10))
+//
+// 1 error → 5 hours
+// 2 errors → 10 hours
+// 3 errors → 20 hours
+// 4+ errors → 24 hours (cap)
+func calculateBillingCooldown(billingErrorCount int) time.Duration {
+ const baseMs = 5 * 60 * 60 * 1000 // 5 hours
+ const maxMs = 24 * 60 * 60 * 1000 // 24 hours
+
+ n := max(1, billingErrorCount)
+ exp := min(n-1, 10)
+ raw := float64(baseMs) * math.Pow(2, float64(exp))
+ ms := int(math.Min(float64(maxMs), raw))
+ return time.Duration(ms) * time.Millisecond
+}
diff --git a/pkg/providers/cooldown_test.go b/pkg/providers/cooldown_test.go
new file mode 100644
index 000000000..e51ff40e5
--- /dev/null
+++ b/pkg/providers/cooldown_test.go
@@ -0,0 +1,269 @@
+package providers
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) {
+ current := now
+ ct := NewCooldownTracker()
+ ct.nowFunc = func() time.Time { return current }
+ return ct, ¤t
+}
+
+func TestCooldown_InitiallyAvailable(t *testing.T) {
+ ct := NewCooldownTracker()
+ if !ct.IsAvailable("openai") {
+ t.Error("new provider should be available")
+ }
+ if ct.ErrorCount("openai") != 0 {
+ t.Error("new provider should have 0 errors")
+ }
+}
+
+func TestCooldown_StandardEscalation(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // 1st error → 1 min cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+ if ct.IsAvailable("openai") {
+ t.Error("should be in cooldown after 1st error")
+ }
+
+ // Advance 61 seconds → available
+ *current = now.Add(61 * time.Second)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after 1 min cooldown")
+ }
+
+ // 2nd error → 5 min cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+ *current = now.Add(61*time.Second + 4*time.Minute)
+ if ct.IsAvailable("openai") {
+ t.Error("should be in cooldown (5 min) after 2nd error")
+ }
+ *current = now.Add(61*time.Second + 6*time.Minute)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after 5 min cooldown")
+ }
+}
+
+func TestCooldown_StandardCap(t *testing.T) {
+ // Verify formula: 1m, 5m, 25m, 1h, 1h, 1h...
+ expected := []time.Duration{
+ 1 * time.Minute,
+ 5 * time.Minute,
+ 25 * time.Minute,
+ 1 * time.Hour,
+ 1 * time.Hour,
+ }
+
+ for i, want := range expected {
+ got := calculateStandardCooldown(i + 1)
+ if got != want {
+ t.Errorf("calculateStandardCooldown(%d) = %v, want %v", i+1, got, want)
+ }
+ }
+}
+
+func TestCooldown_BillingEscalation(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // 1st billing error → 5h cooldown
+ ct.MarkFailure("openai", FailoverBilling)
+ if ct.IsAvailable("openai") {
+ t.Error("should be disabled after billing error")
+ }
+
+ // Advance 4h → still disabled
+ *current = now.Add(4 * time.Hour)
+ if ct.IsAvailable("openai") {
+ t.Error("should still be disabled (5h cooldown)")
+ }
+
+ // Advance 5h + 1s → available
+ *current = now.Add(5*time.Hour + 1*time.Second)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after 5h billing cooldown")
+ }
+}
+
+func TestCooldown_BillingCap(t *testing.T) {
+ expected := []time.Duration{
+ 5 * time.Hour,
+ 10 * time.Hour,
+ 20 * time.Hour,
+ 24 * time.Hour,
+ 24 * time.Hour,
+ }
+
+ for i, want := range expected {
+ got := calculateBillingCooldown(i + 1)
+ if got != want {
+ t.Errorf("calculateBillingCooldown(%d) = %v, want %v", i+1, got, want)
+ }
+ }
+}
+
+func TestCooldown_SuccessReset(t *testing.T) {
+ ct := NewCooldownTracker()
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("openai", FailoverBilling)
+ if ct.ErrorCount("openai") != 2 {
+ t.Errorf("error count = %d, want 2", ct.ErrorCount("openai"))
+ }
+
+ ct.MarkSuccess("openai")
+ if ct.ErrorCount("openai") != 0 {
+ t.Errorf("error count after success = %d, want 0", ct.ErrorCount("openai"))
+ }
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after success")
+ }
+ if ct.FailureCount("openai", FailoverRateLimit) != 0 {
+ t.Error("failure counts should be reset after success")
+ }
+ if ct.FailureCount("openai", FailoverBilling) != 0 {
+ t.Error("billing failure count should be reset after success")
+ }
+}
+
+func TestCooldown_FailureWindowReset(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // 4 errors → 1h cooldown
+ for i := 0; i < 4; i++ {
+ ct.MarkFailure("openai", FailoverRateLimit)
+ *current = current.Add(2 * time.Second) // small advance between errors
+ }
+ if ct.ErrorCount("openai") != 4 {
+ t.Errorf("error count = %d, want 4", ct.ErrorCount("openai"))
+ }
+
+ // Advance 25 hours (past 24h failure window)
+ *current = now.Add(25 * time.Hour)
+
+ // Next error should reset counters first, then increment to 1
+ ct.MarkFailure("openai", FailoverRateLimit)
+ if ct.ErrorCount("openai") != 1 {
+ t.Errorf("error count after window reset = %d, want 1 (reset + 1)", ct.ErrorCount("openai"))
+ }
+}
+
+func TestCooldown_PerReasonTracking(t *testing.T) {
+ ct := NewCooldownTracker()
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("openai", FailoverBilling)
+ ct.MarkFailure("openai", FailoverAuth)
+
+ if ct.FailureCount("openai", FailoverRateLimit) != 2 {
+ t.Errorf("rate_limit count = %d, want 2", ct.FailureCount("openai", FailoverRateLimit))
+ }
+ if ct.FailureCount("openai", FailoverBilling) != 1 {
+ t.Errorf("billing count = %d, want 1", ct.FailureCount("openai", FailoverBilling))
+ }
+ if ct.FailureCount("openai", FailoverAuth) != 1 {
+ t.Errorf("auth count = %d, want 1", ct.FailureCount("openai", FailoverAuth))
+ }
+ if ct.ErrorCount("openai") != 4 {
+ t.Errorf("total error count = %d, want 4", ct.ErrorCount("openai"))
+ }
+}
+
+func TestCooldown_BillingTakesPrecedence(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // Standard cooldown (1 min) + billing disable (5h)
+ ct.MarkFailure("openai", FailoverRateLimit) // 1 min cooldown
+ ct.MarkFailure("openai", FailoverBilling) // 5h disable
+
+ // After 2 min: standard cooldown expired but billing still active
+ *current = now.Add(2 * time.Minute)
+ if ct.IsAvailable("openai") {
+ t.Error("billing disable should take precedence over standard cooldown")
+ }
+
+ // After 5h + 1s: both expired
+ *current = now.Add(5*time.Hour + 1*time.Second)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after all cooldowns expire")
+ }
+}
+
+func TestCooldown_CooldownRemaining(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // No failures → 0 remaining
+ if ct.CooldownRemaining("openai") != 0 {
+ t.Error("expected 0 remaining for new provider")
+ }
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+
+ *current = now.Add(30 * time.Second)
+ remaining := ct.CooldownRemaining("openai")
+ if remaining <= 0 || remaining > 1*time.Minute {
+ t.Errorf("remaining = %v, expected ~30s", remaining)
+ }
+}
+
+func TestCooldown_SuccessOnUnknownProvider(t *testing.T) {
+ ct := NewCooldownTracker()
+ // Should not panic
+ ct.MarkSuccess("nonexistent")
+ if !ct.IsAvailable("nonexistent") {
+ t.Error("nonexistent provider should be available")
+ }
+}
+
+func TestCooldown_ConcurrentAccess(t *testing.T) {
+ ct := NewCooldownTracker()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 100; i++ {
+ wg.Add(3)
+ go func() {
+ defer wg.Done()
+ ct.MarkFailure("openai", FailoverRateLimit)
+ }()
+ go func() {
+ defer wg.Done()
+ ct.IsAvailable("openai")
+ }()
+ go func() {
+ defer wg.Done()
+ ct.MarkSuccess("openai")
+ }()
+ }
+
+ wg.Wait()
+ // If we got here without panic, concurrent access is safe
+}
+
+func TestCooldown_MultipleProviders(t *testing.T) {
+ ct := NewCooldownTracker()
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("anthropic", FailoverBilling)
+
+ if ct.IsAvailable("openai") {
+ t.Error("openai should be in cooldown")
+ }
+ if ct.IsAvailable("anthropic") {
+ t.Error("anthropic should be in cooldown")
+ }
+ // groq was never touched
+ if !ct.IsAvailable("groq") {
+ t.Error("groq should be available")
+ }
+}
diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go
new file mode 100644
index 000000000..a0f003006
--- /dev/null
+++ b/pkg/providers/error_classifier.go
@@ -0,0 +1,253 @@
+package providers
+
+import (
+ "context"
+ "regexp"
+ "strings"
+)
+
+// errorPattern defines a single pattern (string or regex) for error classification.
+type errorPattern struct {
+ substring string
+ regex *regexp.Regexp
+}
+
+func substr(s string) errorPattern { return errorPattern{substring: s} }
+func rxp(r string) errorPattern { return errorPattern{regex: regexp.MustCompile("(?i)" + r)} }
+
+// Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns).
+var (
+ rateLimitPatterns = []errorPattern{
+ rxp(`rate[_ ]limit`),
+ substr("too many requests"),
+ substr("429"),
+ substr("exceeded your current quota"),
+ rxp(`exceeded.*quota`),
+ rxp(`resource has been exhausted`),
+ rxp(`resource.*exhausted`),
+ substr("resource_exhausted"),
+ substr("quota exceeded"),
+ substr("usage limit"),
+ }
+
+ overloadedPatterns = []errorPattern{
+ rxp(`overloaded_error`),
+ rxp(`"type"\s*:\s*"overloaded_error"`),
+ substr("overloaded"),
+ }
+
+ timeoutPatterns = []errorPattern{
+ substr("timeout"),
+ substr("timed out"),
+ substr("deadline exceeded"),
+ substr("context deadline exceeded"),
+ }
+
+ billingPatterns = []errorPattern{
+ rxp(`\b402\b`),
+ substr("payment required"),
+ substr("insufficient credits"),
+ substr("credit balance"),
+ substr("plans & billing"),
+ substr("insufficient balance"),
+ }
+
+ authPatterns = []errorPattern{
+ rxp(`invalid[_ ]?api[_ ]?key`),
+ substr("incorrect api key"),
+ substr("invalid token"),
+ substr("authentication"),
+ substr("re-authenticate"),
+ substr("oauth token refresh failed"),
+ substr("unauthorized"),
+ substr("forbidden"),
+ substr("access denied"),
+ substr("expired"),
+ substr("token has expired"),
+ rxp(`\b401\b`),
+ rxp(`\b403\b`),
+ substr("no credentials found"),
+ substr("no api key found"),
+ }
+
+ formatPatterns = []errorPattern{
+ substr("string should match pattern"),
+ substr("tool_use.id"),
+ substr("tool_use_id"),
+ substr("messages.1.content.1.tool_use.id"),
+ substr("invalid request format"),
+ }
+
+ imageDimensionPatterns = []errorPattern{
+ rxp(`image dimensions exceed max`),
+ }
+
+ imageSizePatterns = []errorPattern{
+ rxp(`image exceeds.*mb`),
+ }
+
+ // Transient HTTP status codes that map to timeout (server-side failures).
+ transientStatusCodes = map[int]bool{
+ 500: true, 502: true, 503: true,
+ 521: true, 522: true, 523: true, 524: true,
+ 529: true,
+ }
+)
+
+// ClassifyError classifies an error into a FailoverError with reason.
+// Returns nil if the error is not classifiable (unknown errors should not trigger fallback).
+func ClassifyError(err error, provider, model string) *FailoverError {
+ if err == nil {
+ return nil
+ }
+
+ // Context cancellation: user abort, never fallback.
+ if err == context.Canceled {
+ return nil
+ }
+
+ // Context deadline exceeded: treat as timeout, always fallback.
+ if err == context.DeadlineExceeded {
+ return &FailoverError{
+ Reason: FailoverTimeout,
+ Provider: provider,
+ Model: model,
+ Wrapped: err,
+ }
+ }
+
+ msg := strings.ToLower(err.Error())
+
+ // Image dimension/size errors: non-retriable, non-fallback.
+ if IsImageDimensionError(msg) || IsImageSizeError(msg) {
+ return &FailoverError{
+ Reason: FailoverFormat,
+ Provider: provider,
+ Model: model,
+ Wrapped: err,
+ }
+ }
+
+ // Try HTTP status code extraction first.
+ if status := extractHTTPStatus(msg); status > 0 {
+ if reason := classifyByStatus(status); reason != "" {
+ return &FailoverError{
+ Reason: reason,
+ Provider: provider,
+ Model: model,
+ Status: status,
+ Wrapped: err,
+ }
+ }
+ }
+
+ // Message pattern matching (priority order from OpenClaw).
+ if reason := classifyByMessage(msg); reason != "" {
+ return &FailoverError{
+ Reason: reason,
+ Provider: provider,
+ Model: model,
+ Wrapped: err,
+ }
+ }
+
+ return nil
+}
+
+// classifyByStatus maps HTTP status codes to FailoverReason.
+func classifyByStatus(status int) FailoverReason {
+ switch {
+ case status == 401 || status == 403:
+ return FailoverAuth
+ case status == 402:
+ return FailoverBilling
+ case status == 408:
+ return FailoverTimeout
+ case status == 429:
+ return FailoverRateLimit
+ case status == 400:
+ return FailoverFormat
+ case transientStatusCodes[status]:
+ return FailoverTimeout
+ }
+ return ""
+}
+
+// classifyByMessage matches error messages against patterns.
+// Priority order matters (from OpenClaw classifyFailoverReason).
+func classifyByMessage(msg string) FailoverReason {
+ if matchesAny(msg, rateLimitPatterns) {
+ return FailoverRateLimit
+ }
+ if matchesAny(msg, overloadedPatterns) {
+ return FailoverRateLimit // Overloaded treated as rate_limit
+ }
+ if matchesAny(msg, billingPatterns) {
+ return FailoverBilling
+ }
+ if matchesAny(msg, timeoutPatterns) {
+ return FailoverTimeout
+ }
+ if matchesAny(msg, authPatterns) {
+ return FailoverAuth
+ }
+ if matchesAny(msg, formatPatterns) {
+ return FailoverFormat
+ }
+ return ""
+}
+
+// extractHTTPStatus extracts an HTTP status code from an error message.
+// Looks for patterns like "status: 429", "status 429", "HTTP 429", or standalone "429".
+func extractHTTPStatus(msg string) int {
+ // Common patterns in Go HTTP error messages
+ patterns := []*regexp.Regexp{
+ regexp.MustCompile(`status[:\s]+(\d{3})`),
+ regexp.MustCompile(`HTTP[/\s]+\d*\.?\d*\s+(\d{3})`),
+ }
+
+ for _, p := range patterns {
+ if m := p.FindStringSubmatch(msg); len(m) > 1 {
+ return parseDigits(m[1])
+ }
+ }
+
+ return 0
+}
+
+// IsImageDimensionError returns true if the message indicates an image dimension error.
+func IsImageDimensionError(msg string) bool {
+ return matchesAny(msg, imageDimensionPatterns)
+}
+
+// IsImageSizeError returns true if the message indicates an image file size error.
+func IsImageSizeError(msg string) bool {
+ return matchesAny(msg, imageSizePatterns)
+}
+
+// matchesAny checks if msg matches any of the patterns.
+func matchesAny(msg string, patterns []errorPattern) bool {
+ for _, p := range patterns {
+ if p.regex != nil {
+ if p.regex.MatchString(msg) {
+ return true
+ }
+ } else if p.substring != "" {
+ if strings.Contains(msg, p.substring) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// parseDigits converts a string of digits to an int.
+func parseDigits(s string) int {
+ n := 0
+ for _, c := range s {
+ if c >= '0' && c <= '9' {
+ n = n*10 + int(c-'0')
+ }
+ }
+ return n
+}
diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go
new file mode 100644
index 000000000..865aea57a
--- /dev/null
+++ b/pkg/providers/error_classifier_test.go
@@ -0,0 +1,337 @@
+package providers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+)
+
+func TestClassifyError_Nil(t *testing.T) {
+ result := ClassifyError(nil, "openai", "gpt-4")
+ if result != nil {
+ t.Errorf("expected nil for nil error, got %+v", result)
+ }
+}
+
+func TestClassifyError_ContextCanceled(t *testing.T) {
+ result := ClassifyError(context.Canceled, "openai", "gpt-4")
+ if result != nil {
+ t.Errorf("expected nil for context.Canceled (user abort), got %+v", result)
+ }
+}
+
+func TestClassifyError_ContextDeadlineExceeded(t *testing.T) {
+ result := ClassifyError(context.DeadlineExceeded, "openai", "gpt-4")
+ if result == nil {
+ t.Fatal("expected non-nil for deadline exceeded")
+ }
+ if result.Reason != FailoverTimeout {
+ t.Errorf("reason = %q, want timeout", result.Reason)
+ }
+}
+
+func TestClassifyError_StatusCodes(t *testing.T) {
+ tests := []struct {
+ status int
+ reason FailoverReason
+ }{
+ {401, FailoverAuth},
+ {403, FailoverAuth},
+ {402, FailoverBilling},
+ {408, FailoverTimeout},
+ {429, FailoverRateLimit},
+ {400, FailoverFormat},
+ {500, FailoverTimeout},
+ {502, FailoverTimeout},
+ {503, FailoverTimeout},
+ {521, FailoverTimeout},
+ {522, FailoverTimeout},
+ {523, FailoverTimeout},
+ {524, FailoverTimeout},
+ {529, FailoverTimeout},
+ }
+
+ for _, tt := range tests {
+ err := fmt.Errorf("API error: status: %d something went wrong", tt.status)
+ result := ClassifyError(err, "test", "model")
+ if result == nil {
+ t.Errorf("status %d: expected non-nil", tt.status)
+ continue
+ }
+ if result.Reason != tt.reason {
+ t.Errorf("status %d: reason = %q, want %q", tt.status, result.Reason, tt.reason)
+ }
+ }
+}
+
+func TestClassifyError_RateLimitPatterns(t *testing.T) {
+ patterns := []string{
+ "rate limit exceeded",
+ "rate_limit reached",
+ "too many requests",
+ "exceeded your current quota",
+ "resource has been exhausted",
+ "resource_exhausted",
+ "quota exceeded",
+ "usage limit reached",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverRateLimit {
+ t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_OverloadedPatterns(t *testing.T) {
+ patterns := []string{
+ "overloaded_error",
+ `{"type": "overloaded_error"}`,
+ "server is overloaded",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "anthropic", "claude")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ // Overloaded is treated as rate_limit
+ if result.Reason != FailoverRateLimit {
+ t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_BillingPatterns(t *testing.T) {
+ patterns := []string{
+ "payment required",
+ "insufficient credits",
+ "credit balance too low",
+ "plans & billing page",
+ "insufficient balance",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverBilling {
+ t.Errorf("pattern %q: reason = %q, want billing", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_TimeoutPatterns(t *testing.T) {
+ patterns := []string{
+ "request timeout",
+ "connection timed out",
+ "deadline exceeded",
+ "context deadline exceeded",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverTimeout {
+ t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_AuthPatterns(t *testing.T) {
+ patterns := []string{
+ "invalid api key",
+ "invalid_api_key",
+ "incorrect api key",
+ "invalid token",
+ "authentication failed",
+ "re-authenticate",
+ "oauth token refresh failed",
+ "unauthorized access",
+ "forbidden",
+ "access denied",
+ "expired",
+ "token has expired",
+ "no credentials found",
+ "no api key found",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverAuth {
+ t.Errorf("pattern %q: reason = %q, want auth", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_FormatPatterns(t *testing.T) {
+ patterns := []string{
+ "string should match pattern",
+ "tool_use.id is required",
+ "invalid tool_use_id",
+ "messages.1.content.1.tool_use.id must be valid",
+ "invalid request format",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "anthropic", "claude")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverFormat {
+ t.Errorf("pattern %q: reason = %q, want format", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_ImageDimensionError(t *testing.T) {
+ err := errors.New("image dimensions exceed max allowed 2048x2048")
+ result := ClassifyError(err, "openai", "gpt-4o")
+ if result == nil {
+ t.Fatal("expected non-nil for image dimension error")
+ }
+ if result.Reason != FailoverFormat {
+ t.Errorf("reason = %q, want format", result.Reason)
+ }
+ if result.IsRetriable() {
+ t.Error("image dimension error should not be retriable")
+ }
+}
+
+func TestClassifyError_ImageSizeError(t *testing.T) {
+ err := errors.New("image exceeds 20 mb limit")
+ result := ClassifyError(err, "openai", "gpt-4o")
+ if result == nil {
+ t.Fatal("expected non-nil for image size error")
+ }
+ if result.Reason != FailoverFormat {
+ t.Errorf("reason = %q, want format", result.Reason)
+ }
+}
+
+func TestClassifyError_UnknownError(t *testing.T) {
+ err := errors.New("some completely random error")
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result != nil {
+ t.Errorf("expected nil for unknown error, got %+v", result)
+ }
+}
+
+func TestClassifyError_ProviderModelPropagation(t *testing.T) {
+ err := errors.New("rate limit exceeded")
+ result := ClassifyError(err, "my-provider", "my-model")
+ if result == nil {
+ t.Fatal("expected non-nil")
+ }
+ if result.Provider != "my-provider" {
+ t.Errorf("provider = %q, want my-provider", result.Provider)
+ }
+ if result.Model != "my-model" {
+ t.Errorf("model = %q, want my-model", result.Model)
+ }
+}
+
+func TestFailoverError_IsRetriable(t *testing.T) {
+ tests := []struct {
+ reason FailoverReason
+ retriable bool
+ }{
+ {FailoverAuth, true},
+ {FailoverRateLimit, true},
+ {FailoverBilling, true},
+ {FailoverTimeout, true},
+ {FailoverOverloaded, true},
+ {FailoverFormat, false},
+ {FailoverUnknown, true},
+ }
+
+ for _, tt := range tests {
+ fe := &FailoverError{Reason: tt.reason}
+ if fe.IsRetriable() != tt.retriable {
+ t.Errorf("IsRetriable(%q) = %v, want %v", tt.reason, fe.IsRetriable(), tt.retriable)
+ }
+ }
+}
+
+func TestFailoverError_ErrorString(t *testing.T) {
+ fe := &FailoverError{
+ Reason: FailoverRateLimit,
+ Provider: "openai",
+ Model: "gpt-4",
+ Status: 429,
+ Wrapped: errors.New("too many requests"),
+ }
+ s := fe.Error()
+ if s == "" {
+ t.Error("expected non-empty error string")
+ }
+}
+
+func TestFailoverError_Unwrap(t *testing.T) {
+ inner := errors.New("inner error")
+ fe := &FailoverError{Reason: FailoverTimeout, Wrapped: inner}
+ if fe.Unwrap() != inner {
+ t.Error("Unwrap should return wrapped error")
+ }
+}
+
+func TestExtractHTTPStatus(t *testing.T) {
+ tests := []struct {
+ msg string
+ want int
+ }{
+ {"status: 429 rate limited", 429},
+ {"status 401 unauthorized", 401},
+ {"HTTP/1.1 502 Bad Gateway", 502},
+ {"no status code here", 0},
+ {"random number 12345", 0},
+ }
+
+ for _, tt := range tests {
+ got := extractHTTPStatus(tt.msg)
+ if got != tt.want {
+ t.Errorf("extractHTTPStatus(%q) = %d, want %d", tt.msg, got, tt.want)
+ }
+ }
+}
+
+func TestIsImageDimensionError(t *testing.T) {
+ if !IsImageDimensionError("image dimensions exceed max 4096x4096") {
+ t.Error("should match image dimensions exceed max")
+ }
+ if IsImageDimensionError("normal error message") {
+ t.Error("should not match normal error")
+ }
+}
+
+func TestIsImageSizeError(t *testing.T) {
+ if !IsImageSizeError("image exceeds 20 mb") {
+ t.Error("should match image exceeds mb")
+ }
+ if IsImageSizeError("normal error message") {
+ t.Error("should not match normal error")
+ }
+}
diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go
new file mode 100644
index 000000000..9b07f9153
--- /dev/null
+++ b/pkg/providers/fallback.go
@@ -0,0 +1,283 @@
+package providers
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// FallbackChain orchestrates model fallback across multiple candidates.
+type FallbackChain struct {
+ cooldown *CooldownTracker
+}
+
+// FallbackCandidate represents one model/provider to try.
+type FallbackCandidate struct {
+ Provider string
+ Model string
+}
+
+// FallbackResult contains the successful response and metadata about all attempts.
+type FallbackResult struct {
+ Response *LLMResponse
+ Provider string
+ Model string
+ Attempts []FallbackAttempt
+}
+
+// FallbackAttempt records one attempt in the fallback chain.
+type FallbackAttempt struct {
+ Provider string
+ Model string
+ Error error
+ Reason FailoverReason
+ Duration time.Duration
+ Skipped bool // true if skipped due to cooldown
+}
+
+// NewFallbackChain creates a new fallback chain with the given cooldown tracker.
+func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
+ return &FallbackChain{cooldown: cooldown}
+}
+
+// ResolveCandidates parses model config into a deduplicated candidate list.
+func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
+ seen := make(map[string]bool)
+ var candidates []FallbackCandidate
+
+ addCandidate := func(raw string) {
+ ref := ParseModelRef(raw, defaultProvider)
+ if ref == nil {
+ return
+ }
+ key := ModelKey(ref.Provider, ref.Model)
+ if seen[key] {
+ return
+ }
+ seen[key] = true
+ candidates = append(candidates, FallbackCandidate{
+ Provider: ref.Provider,
+ Model: ref.Model,
+ })
+ }
+
+ // Primary first.
+ addCandidate(cfg.Primary)
+
+ // Then fallbacks.
+ for _, fb := range cfg.Fallbacks {
+ addCandidate(fb)
+ }
+
+ return candidates
+}
+
+// Execute runs the fallback chain for text/chat requests.
+// It tries each candidate in order, respecting cooldowns and error classification.
+//
+// Behavior:
+// - Candidates in cooldown are skipped (logged as skipped attempt).
+// - context.Canceled aborts immediately (user abort, no fallback).
+// - Non-retriable errors (format) abort immediately.
+// - Retriable errors trigger fallback to next candidate.
+// - Success marks provider as good (resets cooldown).
+// - If all fail, returns aggregate error with all attempts.
+func (fc *FallbackChain) Execute(
+ ctx context.Context,
+ candidates []FallbackCandidate,
+ run func(ctx context.Context, provider, model string) (*LLMResponse, error),
+) (*FallbackResult, error) {
+ if len(candidates) == 0 {
+ return nil, fmt.Errorf("fallback: no candidates configured")
+ }
+
+ result := &FallbackResult{
+ Attempts: make([]FallbackAttempt, 0, len(candidates)),
+ }
+
+ for i, candidate := range candidates {
+ // Check context before each attempt.
+ if ctx.Err() == context.Canceled {
+ return nil, context.Canceled
+ }
+
+ // Check cooldown.
+ if !fc.cooldown.IsAvailable(candidate.Provider) {
+ remaining := fc.cooldown.CooldownRemaining(candidate.Provider)
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Skipped: true,
+ Reason: FailoverRateLimit,
+ Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)),
+ })
+ continue
+ }
+
+ // Execute the run function.
+ start := time.Now()
+ resp, err := run(ctx, candidate.Provider, candidate.Model)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ // Success.
+ fc.cooldown.MarkSuccess(candidate.Provider)
+ result.Response = resp
+ result.Provider = candidate.Provider
+ result.Model = candidate.Model
+ return result, nil
+ }
+
+ // Context cancellation: abort immediately, no fallback.
+ if ctx.Err() == context.Canceled {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+ return nil, context.Canceled
+ }
+
+ // Classify the error.
+ failErr := ClassifyError(err, candidate.Provider, candidate.Model)
+
+ if failErr == nil {
+ // Unclassifiable error: do not fallback, return immediately.
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+ return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w",
+ candidate.Provider, candidate.Model, err)
+ }
+
+ // Non-retriable error: abort immediately.
+ if !failErr.IsRetriable() {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: failErr,
+ Reason: failErr.Reason,
+ Duration: elapsed,
+ })
+ return nil, failErr
+ }
+
+ // Retriable error: mark failure and continue to next candidate.
+ fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason)
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: failErr,
+ Reason: failErr.Reason,
+ Duration: elapsed,
+ })
+
+ // If this was the last candidate, return aggregate error.
+ if i == len(candidates)-1 {
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+ }
+ }
+
+ // All candidates were skipped (all in cooldown).
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+}
+
+// ExecuteImage runs the fallback chain for image/vision requests.
+// Simpler than Execute: no cooldown checks (image endpoints have different rate limits).
+// Image dimension/size errors abort immediately (non-retriable).
+func (fc *FallbackChain) ExecuteImage(
+ ctx context.Context,
+ candidates []FallbackCandidate,
+ run func(ctx context.Context, provider, model string) (*LLMResponse, error),
+) (*FallbackResult, error) {
+ if len(candidates) == 0 {
+ return nil, fmt.Errorf("image fallback: no candidates configured")
+ }
+
+ result := &FallbackResult{
+ Attempts: make([]FallbackAttempt, 0, len(candidates)),
+ }
+
+ for i, candidate := range candidates {
+ if ctx.Err() == context.Canceled {
+ return nil, context.Canceled
+ }
+
+ start := time.Now()
+ resp, err := run(ctx, candidate.Provider, candidate.Model)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ result.Response = resp
+ result.Provider = candidate.Provider
+ result.Model = candidate.Model
+ return result, nil
+ }
+
+ if ctx.Err() == context.Canceled {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+ return nil, context.Canceled
+ }
+
+ // Image dimension/size errors are non-retriable.
+ errMsg := strings.ToLower(err.Error())
+ if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Reason: FailoverFormat,
+ Duration: elapsed,
+ })
+ return nil, &FailoverError{
+ Reason: FailoverFormat,
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Wrapped: err,
+ }
+ }
+
+ // Any other error: record and try next.
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+
+ if i == len(candidates)-1 {
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+ }
+ }
+
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+}
+
+// FallbackExhaustedError indicates all fallback candidates were tried and failed.
+type FallbackExhaustedError struct {
+ Attempts []FallbackAttempt
+}
+
+func (e *FallbackExhaustedError) Error() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts)))
+ for i, a := range e.Attempts {
+ if a.Skipped {
+ sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model))
+ } else {
+ sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)",
+ i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond)))
+ }
+ }
+ return sb.String()
+}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
new file mode 100644
index 000000000..ea81e0d48
--- /dev/null
+++ b/pkg/providers/fallback_test.go
@@ -0,0 +1,473 @@
+package providers
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func makeCandidate(provider, model string) FallbackCandidate {
+ return FallbackCandidate{Provider: provider, Model: model}
+}
+
+func successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return &LLMResponse{Content: content, FinishReason: "stop"}, nil
+ }
+}
+
+func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return nil, err
+ }
+}
+
+func TestFallback_SingleCandidate_Success(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
+ result, err := fc.Execute(context.Background(), candidates, successRun("hello"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Response.Content != "hello" {
+ t.Errorf("content = %q, want hello", result.Response.Content)
+ }
+ if result.Provider != "openai" || result.Model != "gpt-4" {
+ t.Errorf("provider/model = %s/%s, want openai/gpt-4", result.Provider, result.Model)
+ }
+}
+
+func TestFallback_SecondCandidateSuccess(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude-opus"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ return nil, errors.New("rate limit exceeded")
+ }
+ return &LLMResponse{Content: "from claude", FinishReason: "stop"}, nil
+ }
+
+ result, err := fc.Execute(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", result.Provider)
+ }
+ if result.Response.Content != "from claude" {
+ t.Errorf("content = %q, want 'from claude'", result.Response.Content)
+ }
+ if len(result.Attempts) != 1 {
+ t.Errorf("attempts = %d, want 1 (failed attempt recorded)", len(result.Attempts))
+ }
+}
+
+func TestFallback_AllFail(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ makeCandidate("groq", "llama"),
+ }
+
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return nil, errors.New("rate limit exceeded")
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error when all candidates fail")
+ }
+ var exhausted *FallbackExhaustedError
+ if !errors.As(err, &exhausted) {
+ t.Errorf("expected FallbackExhaustedError, got %T: %v", err, err)
+ }
+ if len(exhausted.Attempts) != 3 {
+ t.Errorf("attempts = %d, want 3", len(exhausted.Attempts))
+ }
+}
+
+func TestFallback_ContextCanceled(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ cancel() // cancel context
+ return nil, context.Canceled
+ }
+ t.Error("should not reach second candidate after cancel")
+ return nil, nil
+ }
+
+ _, err := fc.Execute(ctx, candidates, run)
+ if err != context.Canceled {
+ t.Errorf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestFallback_NonRetriableError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("string should match pattern")
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for non-retriable")
+ }
+ var fe *FailoverError
+ if !errors.As(err, &fe) {
+ t.Fatalf("expected FailoverError, got %T", err)
+ }
+ if fe.Reason != FailoverFormat {
+ t.Errorf("reason = %q, want format", fe.Reason)
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (non-retriable should not try next)", attempt)
+ }
+}
+
+func TestFallback_CooldownSkip(t *testing.T) {
+ now := time.Now()
+ ct, _ := newTestTracker(now)
+ fc := NewFallbackChain(ct)
+
+ // Put openai in cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ if provider == "openai" {
+ t.Error("should not call openai (in cooldown)")
+ }
+ return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil
+ }
+
+ result, err := fc.Execute(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", result.Provider)
+ }
+ // Should have 1 skipped attempt
+ skipped := 0
+ for _, a := range result.Attempts {
+ if a.Skipped {
+ skipped++
+ }
+ }
+ if skipped != 1 {
+ t.Errorf("skipped = %d, want 1", skipped)
+ }
+}
+
+func TestFallback_AllInCooldown(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ // Put all providers in cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("anthropic", FailoverBilling)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ _, err := fc.Execute(context.Background(), candidates,
+ func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ t.Error("should not call any provider (all in cooldown)")
+ return nil, nil
+ })
+
+ if err == nil {
+ t.Fatal("expected error when all in cooldown")
+ }
+ var exhausted *FallbackExhaustedError
+ if !errors.As(err, &exhausted) {
+ t.Fatalf("expected FallbackExhaustedError, got %T", err)
+ }
+}
+
+func TestFallback_NoCandidates(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ _, err := fc.Execute(context.Background(), nil, successRun("ok"))
+ if err == nil {
+ t.Error("expected error for empty candidates")
+ }
+}
+
+func TestFallback_EmptyFallbacks(t *testing.T) {
+ // Single primary, no fallbacks: should work like direct call
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
+ result, err := fc.Execute(context.Background(), candidates, successRun("ok"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Response.Content != "ok" {
+ t.Error("expected success with single candidate")
+ }
+}
+
+func TestFallback_UnclassifiedError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("completely unknown internal error")
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for unclassified error")
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt)
+ }
+}
+
+func TestFallback_SuccessResetsCooldown(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere
+ }
+ return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !ct.IsAvailable("openai") {
+ t.Error("success should reset cooldown")
+ }
+}
+
+// --- Image Fallback Tests ---
+
+func TestImageFallback_Success(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")}
+ result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Response.Content != "image result" {
+ t.Error("expected image result")
+ }
+}
+
+func TestImageFallback_DimensionError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4o"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("image dimensions exceed max 4096x4096")
+ }
+
+ _, err := fc.ExecuteImage(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for image dimension error")
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (image dimension error should not retry)", attempt)
+ }
+}
+
+func TestImageFallback_SizeError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4o"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("image exceeds 20 mb")
+ }
+
+ _, err := fc.ExecuteImage(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for image size error")
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (image size error should not retry)", attempt)
+ }
+}
+
+func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4o"),
+ makeCandidate("anthropic", "claude-sonnet"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ return nil, errors.New("rate limit exceeded")
+ }
+ return &LLMResponse{Content: "image ok", FinishReason: "stop"}, nil
+ }
+
+ result, err := fc.ExecuteImage(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", result.Provider)
+ }
+}
+
+func TestImageFallback_NoCandidates(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok"))
+ if err == nil {
+ t.Error("expected error for empty candidates")
+ }
+}
+
+// --- ResolveCandidates Tests ---
+
+func TestResolveCandidates_Simple(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "gpt-4",
+ Fallbacks: []string{"anthropic/claude-opus", "groq/llama-3"},
+ }
+
+ candidates := ResolveCandidates(cfg, "openai")
+ if len(candidates) != 3 {
+ t.Fatalf("candidates = %d, want 3", len(candidates))
+ }
+
+ if candidates[0].Provider != "openai" || candidates[0].Model != "gpt-4" {
+ t.Errorf("candidate[0] = %s/%s, want openai/gpt-4", candidates[0].Provider, candidates[0].Model)
+ }
+ if candidates[1].Provider != "anthropic" || candidates[1].Model != "claude-opus" {
+ t.Errorf("candidate[1] = %s/%s, want anthropic/claude-opus", candidates[1].Provider, candidates[1].Model)
+ }
+ if candidates[2].Provider != "groq" || candidates[2].Model != "llama-3" {
+ t.Errorf("candidate[2] = %s/%s, want groq/llama-3", candidates[2].Provider, candidates[2].Model)
+ }
+}
+
+func TestResolveCandidates_Deduplication(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "openai/gpt-4",
+ Fallbacks: []string{"openai/gpt-4", "anthropic/claude"},
+ }
+
+ candidates := ResolveCandidates(cfg, "default")
+ if len(candidates) != 2 {
+ t.Errorf("candidates = %d, want 2 (duplicate removed)", len(candidates))
+ }
+}
+
+func TestResolveCandidates_EmptyFallbacks(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "gpt-4",
+ Fallbacks: nil,
+ }
+
+ candidates := ResolveCandidates(cfg, "openai")
+ if len(candidates) != 1 {
+ t.Errorf("candidates = %d, want 1", len(candidates))
+ }
+}
+
+func TestResolveCandidates_EmptyPrimary(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "",
+ Fallbacks: []string{"anthropic/claude"},
+ }
+
+ candidates := ResolveCandidates(cfg, "openai")
+ if len(candidates) != 1 {
+ t.Errorf("candidates = %d, want 1", len(candidates))
+ }
+}
+
+func TestFallbackExhaustedError_Message(t *testing.T) {
+ e := &FallbackExhaustedError{
+ Attempts: []FallbackAttempt{
+ {Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond},
+ {Provider: "anthropic", Model: "claude", Skipped: true},
+ },
+ }
+ msg := e.Error()
+ if msg == "" {
+ t.Error("expected non-empty error message")
+ }
+}
diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go
new file mode 100644
index 000000000..0d1b02d16
--- /dev/null
+++ b/pkg/providers/model_ref.go
@@ -0,0 +1,64 @@
+package providers
+
+import "strings"
+
+// ModelRef represents a parsed model reference with provider and model name.
+type ModelRef struct {
+ Provider string
+ Model string
+}
+
+// ParseModelRef parses "anthropic/claude-opus" into {Provider: "anthropic", Model: "claude-opus"}.
+// If no slash present, uses defaultProvider.
+// Returns nil for empty input.
+func ParseModelRef(raw string, defaultProvider string) *ModelRef {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+
+ if idx := strings.Index(raw, "/"); idx > 0 {
+ provider := NormalizeProvider(raw[:idx])
+ model := strings.TrimSpace(raw[idx+1:])
+ if model == "" {
+ return nil
+ }
+ return &ModelRef{Provider: provider, Model: model}
+ }
+
+ return &ModelRef{
+ Provider: NormalizeProvider(defaultProvider),
+ Model: raw,
+ }
+}
+
+// NormalizeProvider normalizes provider identifiers to canonical form.
+func NormalizeProvider(provider string) string {
+ p := strings.ToLower(strings.TrimSpace(provider))
+
+ switch p {
+ case "z.ai", "z-ai":
+ return "zai"
+ case "opencode-zen":
+ return "opencode"
+ case "qwen":
+ return "qwen-portal"
+ case "kimi-code":
+ return "kimi-coding"
+ case "gpt":
+ return "openai"
+ case "claude":
+ return "anthropic"
+ case "glm":
+ return "zhipu"
+ case "google":
+ return "gemini"
+ }
+
+ return p
+}
+
+// ModelKey returns a canonical "provider/model" key for deduplication.
+func ModelKey(provider, model string) string {
+ return NormalizeProvider(provider) + "/" + strings.ToLower(strings.TrimSpace(model))
+}
diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go
new file mode 100644
index 000000000..6dd25167f
--- /dev/null
+++ b/pkg/providers/model_ref_test.go
@@ -0,0 +1,125 @@
+package providers
+
+import "testing"
+
+func TestParseModelRef_WithSlash(t *testing.T) {
+ ref := ParseModelRef("anthropic/claude-opus", "openai")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", ref.Provider)
+ }
+ if ref.Model != "claude-opus" {
+ t.Errorf("model = %q, want claude-opus", ref.Model)
+ }
+}
+
+func TestParseModelRef_WithoutSlash(t *testing.T) {
+ ref := ParseModelRef("gpt-4", "openai")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "openai" {
+ t.Errorf("provider = %q, want openai", ref.Provider)
+ }
+ if ref.Model != "gpt-4" {
+ t.Errorf("model = %q, want gpt-4", ref.Model)
+ }
+}
+
+func TestParseModelRef_Empty(t *testing.T) {
+ ref := ParseModelRef("", "openai")
+ if ref != nil {
+ t.Errorf("expected nil for empty string, got %+v", ref)
+ }
+}
+
+func TestParseModelRef_EmptyModelAfterSlash(t *testing.T) {
+ ref := ParseModelRef("openai/", "default")
+ if ref != nil {
+ t.Errorf("expected nil for empty model, got %+v", ref)
+ }
+}
+
+func TestParseModelRef_WhitespaceHandling(t *testing.T) {
+ ref := ParseModelRef(" anthropic / claude-opus ", "openai")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", ref.Provider)
+ }
+ if ref.Model != "claude-opus" {
+ t.Errorf("model = %q, want claude-opus", ref.Model)
+ }
+}
+
+func TestNormalizeProvider(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"OpenAI", "openai"},
+ {"ANTHROPIC", "anthropic"},
+ {"z.ai", "zai"},
+ {"z-ai", "zai"},
+ {"Z.AI", "zai"},
+ {"opencode-zen", "opencode"},
+ {"qwen", "qwen-portal"},
+ {"kimi-code", "kimi-coding"},
+ {"gpt", "openai"},
+ {"claude", "anthropic"},
+ {"glm", "zhipu"},
+ {"google", "gemini"},
+ {"groq", "groq"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ got := NormalizeProvider(tt.input)
+ if got != tt.want {
+ t.Errorf("NormalizeProvider(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestModelKey(t *testing.T) {
+ tests := []struct {
+ provider string
+ model string
+ want string
+ }{
+ {"openai", "gpt-4", "openai/gpt-4"},
+ {"Anthropic", "Claude-Opus", "anthropic/claude-opus"},
+ {"claude", "sonnet", "anthropic/sonnet"},
+ {"z.ai", "Model-X", "zai/model-x"},
+ }
+
+ for _, tt := range tests {
+ got := ModelKey(tt.provider, tt.model)
+ if got != tt.want {
+ t.Errorf("ModelKey(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
+ }
+ }
+}
+
+func TestParseModelRef_ProviderNormalization(t *testing.T) {
+ ref := ParseModelRef("Z.AI/model-x", "default")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "zai" {
+ t.Errorf("provider = %q, want zai", ref.Provider)
+ }
+}
+
+func TestParseModelRef_DefaultProviderNormalization(t *testing.T) {
+ ref := ParseModelRef("gpt-4o", "GPT")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "openai" {
+ t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider)
+ }
+}
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index 88b62e975..aa30a1a46 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -1,6 +1,9 @@
package providers
-import "context"
+import (
+ "context"
+ "fmt"
+)
type ToolCall struct {
ID string `json:"id"`
@@ -40,6 +43,49 @@ type LLMProvider interface {
GetDefaultModel() string
}
+// FailoverReason classifies why an LLM request failed for fallback decisions.
+type FailoverReason string
+
+const (
+ FailoverAuth FailoverReason = "auth"
+ FailoverRateLimit FailoverReason = "rate_limit"
+ FailoverBilling FailoverReason = "billing"
+ FailoverTimeout FailoverReason = "timeout"
+ FailoverFormat FailoverReason = "format"
+ FailoverOverloaded FailoverReason = "overloaded"
+ FailoverUnknown FailoverReason = "unknown"
+)
+
+// FailoverError wraps an LLM provider error with classification metadata.
+type FailoverError struct {
+ Reason FailoverReason
+ Provider string
+ Model string
+ Status int
+ Wrapped error
+}
+
+func (e *FailoverError) Error() string {
+ return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v",
+ e.Reason, e.Provider, e.Model, e.Status, e.Wrapped)
+}
+
+func (e *FailoverError) Unwrap() error {
+ return e.Wrapped
+}
+
+// IsRetriable returns true if this error should trigger fallback to next candidate.
+// Non-retriable: Format errors (bad request structure, image dimension/size).
+func (e *FailoverError) IsRetriable() bool {
+ return e.Reason != FailoverFormat
+}
+
+// ModelConfig holds primary model and fallback list.
+type ModelConfig struct {
+ Primary string
+ Fallbacks []string
+}
+
type ToolDefinition struct {
Type string `json:"type"`
Function ToolFunctionDefinition `json:"function"`
From 272536a11a96eb0f1c9deaf7c58dd2bc88411133 Mon Sep 17 00:00:00 2001
From: Leandro Barbosa
Date: Fri, 13 Feb 2026 12:12:33 -0300
Subject: [PATCH 002/130] feat: add multi-agent routing with declarative
bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
---
pkg/agent/instance.go | 144 +++++++++++++
pkg/agent/loop.go | 353 ++++++++++++++++++++------------
pkg/agent/registry.go | 114 +++++++++++
pkg/agent/registry_test.go | 199 ++++++++++++++++++
pkg/channels/base.go | 17 +-
pkg/channels/discord.go | 9 +
pkg/channels/slack.go | 25 +++
pkg/channels/telegram.go | 9 +
pkg/config/config.go | 123 ++++++++++-
pkg/config/config_test.go | 186 +++++++++++++++++
pkg/routing/agent_id.go | 66 ++++++
pkg/routing/agent_id_test.go | 86 ++++++++
pkg/routing/route.go | 252 +++++++++++++++++++++++
pkg/routing/route_test.go | 297 +++++++++++++++++++++++++++
pkg/routing/session_key.go | 183 +++++++++++++++++
pkg/routing/session_key_test.go | 162 +++++++++++++++
pkg/tools/spawn.go | 25 ++-
pkg/tools/subagent.go | 4 +-
18 files changed, 2098 insertions(+), 156 deletions(-)
create mode 100644 pkg/agent/instance.go
create mode 100644 pkg/agent/registry.go
create mode 100644 pkg/agent/registry_test.go
create mode 100644 pkg/config/config_test.go
create mode 100644 pkg/routing/agent_id.go
create mode 100644 pkg/routing/agent_id_test.go
create mode 100644 pkg/routing/route.go
create mode 100644 pkg/routing/route_test.go
create mode 100644 pkg/routing/session_key.go
create mode 100644 pkg/routing/session_key_test.go
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
new file mode 100644
index 000000000..5eb0630b5
--- /dev/null
+++ b/pkg/agent/instance.go
@@ -0,0 +1,144 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
+ "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// AgentInstance represents a fully configured agent with its own workspace,
+// session manager, context builder, and tool registry.
+type AgentInstance struct {
+ ID string
+ Name string
+ Model string
+ Fallbacks []string
+ Workspace string
+ MaxIterations int
+ ContextWindow int
+ Provider providers.LLMProvider
+ Sessions *session.SessionManager
+ ContextBuilder *ContextBuilder
+ Tools *tools.ToolRegistry
+ Subagents *config.SubagentsConfig
+ SkillsFilter []string
+ Candidates []providers.FallbackCandidate
+}
+
+// NewAgentInstance creates an agent instance from config.
+func NewAgentInstance(
+ agentCfg *config.AgentConfig,
+ defaults *config.AgentDefaults,
+ provider providers.LLMProvider,
+) *AgentInstance {
+ workspace := resolveAgentWorkspace(agentCfg, defaults)
+ os.MkdirAll(workspace, 0755)
+
+ model := resolveAgentModel(agentCfg, defaults)
+ fallbacks := resolveAgentFallbacks(agentCfg, defaults)
+
+ restrict := defaults.RestrictToWorkspace
+ toolsRegistry := tools.NewToolRegistry()
+ toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewExecTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
+
+ sessionsDir := filepath.Join(workspace, "sessions")
+ sessionsManager := session.NewSessionManager(sessionsDir)
+
+ contextBuilder := NewContextBuilder(workspace)
+ contextBuilder.SetToolsRegistry(toolsRegistry)
+
+ agentID := routing.DefaultAgentID
+ agentName := ""
+ var subagents *config.SubagentsConfig
+ var skillsFilter []string
+
+ if agentCfg != nil {
+ agentID = routing.NormalizeAgentID(agentCfg.ID)
+ agentName = agentCfg.Name
+ subagents = agentCfg.Subagents
+ skillsFilter = agentCfg.Skills
+ }
+
+ maxIter := defaults.MaxToolIterations
+ if maxIter == 0 {
+ maxIter = 20
+ }
+
+ // Resolve fallback candidates
+ modelCfg := providers.ModelConfig{
+ Primary: model,
+ Fallbacks: fallbacks,
+ }
+ candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
+
+ return &AgentInstance{
+ ID: agentID,
+ Name: agentName,
+ Model: model,
+ Fallbacks: fallbacks,
+ Workspace: workspace,
+ MaxIterations: maxIter,
+ ContextWindow: defaults.MaxTokens,
+ Provider: provider,
+ Sessions: sessionsManager,
+ ContextBuilder: contextBuilder,
+ Tools: toolsRegistry,
+ Subagents: subagents,
+ SkillsFilter: skillsFilter,
+ Candidates: candidates,
+ }
+}
+
+// resolveAgentWorkspace determines the workspace directory for an agent.
+func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
+ if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
+ return expandHome(strings.TrimSpace(agentCfg.Workspace))
+ }
+ if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
+ return expandHome(defaults.Workspace)
+ }
+ home, _ := os.UserHomeDir()
+ id := routing.NormalizeAgentID(agentCfg.ID)
+ return filepath.Join(home, ".picoclaw", "workspace-"+id)
+}
+
+// resolveAgentModel resolves the primary model for an agent.
+func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
+ if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
+ return strings.TrimSpace(agentCfg.Model.Primary)
+ }
+ return defaults.Model
+}
+
+// resolveAgentFallbacks resolves the fallback models for an agent.
+func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
+ if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
+ return agentCfg.Model.Fallbacks
+ }
+ return defaults.ModelFallbacks
+}
+
+func expandHome(path string) string {
+ if path == "" {
+ return path
+ }
+ if path[0] == '~' {
+ home, _ := os.UserHomeDir()
+ if len(path) > 1 && path[1] == '/' {
+ return home + path[1:]
+ }
+ return home
+ }
+ return path
+}
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index fac2856e9..ffc2191e3 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -10,8 +10,6 @@ import (
"context"
"encoding/json"
"fmt"
- "os"
- "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -21,23 +19,18 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
- "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
type AgentLoop struct {
- bus *bus.MessageBus
- provider providers.LLMProvider
- workspace string
- model string
- contextWindow int // Maximum context window size in tokens
- maxIterations int
- sessions *session.SessionManager
- contextBuilder *ContextBuilder
- tools *tools.ToolRegistry
- running atomic.Bool
- summarizing sync.Map // Tracks which sessions are currently being summarized
+ bus *bus.MessageBus
+ cfg *config.Config
+ registry *AgentRegistry
+ running atomic.Bool
+ summarizing sync.Map
+ fallback *providers.FallbackChain
}
// processOptions configures how a message is processed
@@ -52,60 +45,61 @@ type processOptions struct {
}
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
- workspace := cfg.WorkspacePath()
- os.MkdirAll(workspace, 0755)
+ registry := NewAgentRegistry(cfg, provider)
- restrict := cfg.Agents.Defaults.RestrictToWorkspace
+ // Register shared tools to all agents
+ registerSharedTools(cfg, msgBus, registry, provider)
- toolsRegistry := tools.NewToolRegistry()
- toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
- toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
- toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
- toolsRegistry.Register(tools.NewExecTool(workspace, restrict))
-
- braveAPIKey := cfg.Tools.Web.Search.APIKey
- toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
- toolsRegistry.Register(tools.NewWebFetchTool(50000))
-
- // Register message tool
- messageTool := tools.NewMessageTool()
- messageTool.SetSendCallback(func(channel, chatID, content string) error {
- msgBus.PublishOutbound(bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: content,
- })
- return nil
- })
- toolsRegistry.Register(messageTool)
-
- // Register spawn tool
- subagentManager := tools.NewSubagentManager(provider, workspace, msgBus)
- spawnTool := tools.NewSpawnTool(subagentManager)
- toolsRegistry.Register(spawnTool)
-
- // Register edit file tool
- editFileTool := tools.NewEditFileTool(workspace, restrict)
- toolsRegistry.Register(editFileTool)
- toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
-
- sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
-
- // Create context builder and set tools registry
- contextBuilder := NewContextBuilder(workspace)
- contextBuilder.SetToolsRegistry(toolsRegistry)
+ // Set up shared fallback chain
+ cooldown := providers.NewCooldownTracker()
+ fallbackChain := providers.NewFallbackChain(cooldown)
return &AgentLoop{
- bus: msgBus,
- provider: provider,
- workspace: workspace,
- model: cfg.Agents.Defaults.Model,
- contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
- maxIterations: cfg.Agents.Defaults.MaxToolIterations,
- sessions: sessionsManager,
- contextBuilder: contextBuilder,
- tools: toolsRegistry,
- summarizing: sync.Map{},
+ bus: msgBus,
+ cfg: cfg,
+ registry: registry,
+ summarizing: sync.Map{},
+ fallback: fallbackChain,
+ }
+}
+
+// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
+func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
+ braveAPIKey := cfg.Tools.Web.Search.APIKey
+
+ for _, agentID := range registry.ListAgentIDs() {
+ agent, ok := registry.GetAgent(agentID)
+ if !ok {
+ continue
+ }
+
+ // Web tools
+ agent.Tools.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
+ agent.Tools.Register(tools.NewWebFetchTool(50000))
+
+ // Message tool
+ messageTool := tools.NewMessageTool()
+ messageTool.SetSendCallback(func(channel, chatID, content string) error {
+ msgBus.PublishOutbound(bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
+ Content: content,
+ })
+ return nil
+ })
+ agent.Tools.Register(messageTool)
+
+ // Spawn tool with allowlist checker
+ subagentManager := tools.NewSubagentManager(provider, agent.Workspace, msgBus)
+ spawnTool := tools.NewSpawnTool(subagentManager)
+ currentAgentID := agentID
+ spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
+ })
+ agent.Tools.Register(spawnTool)
+
+ // Update context builder with the complete tools registry
+ agent.ContextBuilder.SetToolsRegistry(agent.Tools)
}
}
@@ -145,7 +139,11 @@ func (al *AgentLoop) Stop() {
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
- al.tools.Register(tool)
+ for _, agentID := range al.registry.ListAgentIDs() {
+ if agent, ok := al.registry.GetAgent(agentID); ok {
+ agent.Tools.Register(tool)
+ }
+ }
}
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
@@ -165,7 +163,6 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
- // Add message preview to log
preview := utils.Truncate(msg.Content, 80)
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview),
map[string]interface{}{
@@ -180,9 +177,36 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
- // Process as user message
- return al.runAgentLoop(ctx, processOptions{
- SessionKey: msg.SessionKey,
+ // Route to determine agent and session key
+ route := al.registry.ResolveRoute(routing.RouteInput{
+ Channel: msg.Channel,
+ AccountID: msg.Metadata["account_id"],
+ Peer: extractPeer(msg),
+ ParentPeer: extractParentPeer(msg),
+ GuildID: msg.Metadata["guild_id"],
+ TeamID: msg.Metadata["team_id"],
+ })
+
+ agent, ok := al.registry.GetAgent(route.AgentID)
+ if !ok {
+ agent = al.registry.GetDefaultAgent()
+ }
+
+ // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
+ sessionKey := route.SessionKey
+ if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
+ sessionKey = msg.SessionKey
+ }
+
+ logger.InfoCF("agent", "Routed message",
+ map[string]interface{}{
+ "agent_id": agent.ID,
+ "session_key": sessionKey,
+ "matched_by": route.MatchedBy,
+ })
+
+ return al.runAgentLoop(ctx, agent, processOptions{
+ SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
@@ -193,7 +217,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
- // Verify this is a system message
if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
}
@@ -210,36 +233,36 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
originChannel = msg.ChatID[:idx]
originChatID = msg.ChatID[idx+1:]
} else {
- // Fallback
originChannel = "cli"
originChatID = msg.ChatID
}
- // Use the origin session for context
- sessionKey := fmt.Sprintf("%s:%s", originChannel, originChatID)
+ // Use default agent for system messages
+ agent := al.registry.GetDefaultAgent()
- // Process as system message with routing back to origin
- return al.runAgentLoop(ctx, processOptions{
+ // Use the origin session for context
+ sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
+
+ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey,
Channel: originChannel,
ChatID: originChatID,
UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
DefaultResponse: "Background task completed.",
EnableSummary: false,
- SendResponse: true, // Send response back to original channel
+ SendResponse: true,
})
}
// runAgentLoop is the core message processing logic.
-// It handles context building, LLM calls, tool execution, and response handling.
-func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
+func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) {
// 1. Update tool contexts
- al.updateToolContexts(opts.Channel, opts.ChatID)
+ al.updateToolContexts(agent, opts.Channel, opts.ChatID)
// 2. Build messages
- history := al.sessions.GetHistory(opts.SessionKey)
- summary := al.sessions.GetSummary(opts.SessionKey)
- messages := al.contextBuilder.BuildMessages(
+ history := agent.Sessions.GetHistory(opts.SessionKey)
+ summary := agent.Sessions.GetSummary(opts.SessionKey)
+ messages := agent.ContextBuilder.BuildMessages(
history,
summary,
opts.UserMessage,
@@ -249,10 +272,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
)
// 3. Save user message to session
- al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
+ agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Run LLM iteration loop
- finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
+ finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil {
return "", err
}
@@ -263,12 +286,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
// 6. Save final assistant message to session
- al.sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
- al.sessions.Save(al.sessions.GetOrCreate(opts.SessionKey))
+ agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
+ agent.Sessions.Save(agent.Sessions.GetOrCreate(opts.SessionKey))
// 7. Optional: summarization
if opts.EnableSummary {
- al.maybeSummarize(opts.SessionKey)
+ al.maybeSummarize(agent, opts.SessionKey)
}
// 8. Optional: send response via bus
@@ -284,6 +307,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
map[string]interface{}{
+ "agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
"final_length": len(finalContent),
@@ -293,22 +317,22 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
// runLLMIteration executes the LLM call loop with tool handling.
-// Returns the final content, iteration count, and any error.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) {
+func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
iteration := 0
var finalContent string
- for iteration < al.maxIterations {
+ for iteration < agent.MaxIterations {
iteration++
logger.DebugCF("agent", "LLM iteration",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
- "max": al.maxIterations,
+ "max": agent.MaxIterations,
})
// Build tool definitions
- toolDefs := al.tools.GetDefinitions()
+ toolDefs := agent.Tools.GetDefinitions()
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
for _, td := range toolDefs {
providerToolDefs = append(providerToolDefs, providers.ToolDefinition{
@@ -324,8 +348,9 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// Log LLM request details
logger.DebugCF("agent", "LLM request",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
- "model": al.model,
+ "model": agent.Model,
"messages_count": len(messages),
"tools_count": len(providerToolDefs),
"max_tokens": 8192,
@@ -341,15 +366,40 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
"tools_json": formatToolsForLog(providerToolDefs),
})
- // Call LLM
- response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
- "max_tokens": 8192,
- "temperature": 0.7,
- })
+ // Call LLM with fallback chain if candidates are configured.
+ var response *providers.LLMResponse
+ var err error
+
+ if len(agent.Candidates) > 1 && al.fallback != nil {
+ fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
+ func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ })
+ },
+ )
+ if fbErr != nil {
+ err = fbErr
+ } else {
+ response = fbResult.Response
+ if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
+ logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
+ fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
+ map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
+ }
+ }
+ } else {
+ response, err = agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ })
+ }
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
})
@@ -361,6 +411,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
})
@@ -374,6 +425,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
}
logger.InfoCF("agent", "LLM requested tool calls",
map[string]interface{}{
+ "agent_id": agent.ID,
"tools": toolNames,
"count": len(toolNames),
"iteration": iteration,
@@ -398,20 +450,20 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
messages = append(messages, assistantMsg)
// Save assistant message with tool calls to session
- al.sessions.AddFullMessage(opts.SessionKey, assistantMsg)
+ agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls
for _, tc := range response.ToolCalls {
- // Log tool call with arguments preview
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
map[string]interface{}{
+ "agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
})
- result, err := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID)
+ result, err := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID)
if err != nil {
result = fmt.Sprintf("Error: %v", err)
}
@@ -424,7 +476,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
messages = append(messages, toolResultMsg)
// Save tool result message to session
- al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
+ agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
}
}
@@ -432,13 +484,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
}
// updateToolContexts updates the context for tools that need channel/chatID info.
-func (al *AgentLoop) updateToolContexts(channel, chatID string) {
- if tool, ok := al.tools.Get("message"); ok {
+func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
+ if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
mt.SetContext(channel, chatID)
}
}
- if tool, ok := al.tools.Get("spawn"); ok {
+ if tool, ok := agent.Tools.Get("spawn"); ok {
if st, ok := tool.(*tools.SpawnTool); ok {
st.SetContext(channel, chatID)
}
@@ -446,16 +498,17 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
-func (al *AgentLoop) maybeSummarize(sessionKey string) {
- newHistory := al.sessions.GetHistory(sessionKey)
+func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string) {
+ newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
- threshold := al.contextWindow * 75 / 100
+ threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
- if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
+ summarizeKey := agent.ID + ":" + sessionKey
+ if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
- defer al.summarizing.Delete(sessionKey)
- al.summarizeSession(sessionKey)
+ defer al.summarizing.Delete(summarizeKey)
+ al.summarizeSession(agent, sessionKey)
}()
}
}
@@ -465,15 +518,26 @@ func (al *AgentLoop) maybeSummarize(sessionKey string) {
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info := make(map[string]interface{})
+ agent := al.registry.GetDefaultAgent()
+ if agent == nil {
+ return info
+ }
+
// Tools info
- tools := al.tools.List()
+ toolsList := agent.Tools.List()
info["tools"] = map[string]interface{}{
- "count": len(tools),
- "names": tools,
+ "count": len(toolsList),
+ "names": toolsList,
}
// Skills info
- info["skills"] = al.contextBuilder.GetSkillsInfo()
+ info["skills"] = agent.ContextBuilder.GetSkillsInfo()
+
+ // Agents info
+ info["agents"] = map[string]interface{}{
+ "count": len(al.registry.ListAgentIDs()),
+ "ids": al.registry.ListAgentIDs(),
+ }
return info
}
@@ -530,12 +594,12 @@ func formatToolsForLog(tools []providers.ToolDefinition) string {
}
// summarizeSession summarizes the conversation history for a session.
-func (al *AgentLoop) summarizeSession(sessionKey string) {
+func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
- history := al.sessions.GetHistory(sessionKey)
- summary := al.sessions.GetSummary(sessionKey)
+ history := agent.Sessions.GetHistory(sessionKey)
+ summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
@@ -545,8 +609,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
toSummarize := history[:len(history)-4]
// Oversized Message Guard
- // Skip messages larger than 50% of context window to prevent summarizer overflow
- maxMessageTokens := al.contextWindow / 2
+ maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
@@ -554,7 +617,6 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
if m.Role != "user" && m.Role != "assistant" {
continue
}
- // Estimate tokens for this message
msgTokens := len(m.Content) / 4
if msgTokens > maxMessageTokens {
omitted = true
@@ -568,19 +630,17 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
}
// Multi-Part Summarization
- // Split into two parts if history is significant
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
- s1, _ := al.summarizeBatch(ctx, part1, "")
- s2, _ := al.summarizeBatch(ctx, part2, "")
+ s1, _ := al.summarizeBatch(ctx, agent, part1, "")
+ s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- // Merge them
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
+ resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
"max_tokens": 1024,
"temperature": 0.3,
})
@@ -590,7 +650,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
finalSummary = s1 + " " + s2
}
} else {
- finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary)
+ finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
@@ -598,14 +658,14 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
}
if finalSummary != "" {
- al.sessions.SetSummary(sessionKey, finalSummary)
- al.sessions.TruncateHistory(sessionKey, 4)
- al.sessions.Save(al.sessions.GetOrCreate(sessionKey))
+ agent.Sessions.SetSummary(sessionKey, finalSummary)
+ agent.Sessions.TruncateHistory(sessionKey, 4)
+ agent.Sessions.Save(agent.Sessions.GetOrCreate(sessionKey))
}
}
// summarizeBatch summarizes a batch of messages.
-func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) {
+func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
if existingSummary != "" {
prompt += "Existing context: " + existingSummary + "\n"
@@ -615,7 +675,7 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
}
- response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
+ response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
"max_tokens": 1024,
"temperature": 0.3,
})
@@ -629,7 +689,34 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
total := 0
for _, m := range messages {
- total += len(m.Content) / 4 // Simple heuristic: 4 chars per token
+ total += len(m.Content) / 4
}
return total
}
+
+// extractPeer extracts the routing peer from inbound message metadata.
+func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
+ peerKind := msg.Metadata["peer_kind"]
+ if peerKind == "" {
+ return nil
+ }
+ peerID := msg.Metadata["peer_id"]
+ if peerID == "" {
+ if peerKind == "direct" {
+ peerID = msg.SenderID
+ } else {
+ peerID = msg.ChatID
+ }
+ }
+ return &routing.RoutePeer{Kind: peerKind, ID: peerID}
+}
+
+// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
+func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
+ parentKind := msg.Metadata["parent_peer_kind"]
+ parentID := msg.Metadata["parent_peer_id"]
+ if parentKind == "" || parentID == "" {
+ return nil
+ }
+ return &routing.RoutePeer{Kind: parentKind, ID: parentID}
+}
diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go
new file mode 100644
index 000000000..e37149c31
--- /dev/null
+++ b/pkg/agent/registry.go
@@ -0,0 +1,114 @@
+package agent
+
+import (
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// AgentRegistry manages multiple agent instances and routes messages to them.
+type AgentRegistry struct {
+ agents map[string]*AgentInstance
+ resolver *routing.RouteResolver
+ mu sync.RWMutex
+}
+
+// NewAgentRegistry creates a registry from config, instantiating all agents.
+func NewAgentRegistry(
+ cfg *config.Config,
+ provider providers.LLMProvider,
+) *AgentRegistry {
+ registry := &AgentRegistry{
+ agents: make(map[string]*AgentInstance),
+ resolver: routing.NewRouteResolver(cfg),
+ }
+
+ agentConfigs := cfg.Agents.List
+ if len(agentConfigs) == 0 {
+ implicitAgent := &config.AgentConfig{
+ ID: "main",
+ Default: true,
+ }
+ instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, provider)
+ registry.agents["main"] = instance
+ logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
+ } else {
+ for i := range agentConfigs {
+ ac := &agentConfigs[i]
+ id := routing.NormalizeAgentID(ac.ID)
+ instance := NewAgentInstance(ac, &cfg.Agents.Defaults, provider)
+ registry.agents[id] = instance
+ logger.InfoCF("agent", "Registered agent",
+ map[string]interface{}{
+ "agent_id": id,
+ "name": ac.Name,
+ "workspace": instance.Workspace,
+ "model": instance.Model,
+ })
+ }
+ }
+
+ return registry
+}
+
+// GetAgent returns the agent instance for a given ID.
+func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ id := routing.NormalizeAgentID(agentID)
+ agent, ok := r.agents[id]
+ return agent, ok
+}
+
+// ResolveRoute determines which agent handles the message.
+func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
+ return r.resolver.ResolveRoute(input)
+}
+
+// ListAgentIDs returns all registered agent IDs.
+func (r *AgentRegistry) ListAgentIDs() []string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ ids := make([]string, 0, len(r.agents))
+ for id := range r.agents {
+ ids = append(ids, id)
+ }
+ return ids
+}
+
+// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
+func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
+ parent, ok := r.GetAgent(parentAgentID)
+ if !ok {
+ return false
+ }
+ if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
+ return false
+ }
+ targetNorm := routing.NormalizeAgentID(targetAgentID)
+ for _, allowed := range parent.Subagents.AllowAgents {
+ if allowed == "*" {
+ return true
+ }
+ if routing.NormalizeAgentID(allowed) == targetNorm {
+ return true
+ }
+ }
+ return false
+}
+
+// GetDefaultAgent returns the default agent instance.
+func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ if agent, ok := r.agents["main"]; ok {
+ return agent
+ }
+ for _, agent := range r.agents {
+ return agent
+ }
+ return nil
+}
diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go
new file mode 100644
index 000000000..d4ccc064d
--- /dev/null
+++ b/pkg/agent/registry_test.go
@@ -0,0 +1,199 @@
+package agent
+
+import (
+ "context"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+type mockProvider struct{}
+
+func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+ return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
+}
+
+func (m *mockProvider) GetDefaultModel() string {
+ return "mock-model"
+}
+
+func testCfg(agents []config.AgentConfig) *config.Config {
+ return &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: "/tmp/picoclaw-test-registry",
+ Model: "gpt-4",
+ MaxTokens: 8192,
+ MaxToolIterations: 10,
+ },
+ List: agents,
+ },
+ }
+}
+
+func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
+ cfg := testCfg(nil)
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ ids := registry.ListAgentIDs()
+ if len(ids) != 1 || ids[0] != "main" {
+ t.Errorf("expected implicit main agent, got %v", ids)
+ }
+
+ agent, ok := registry.GetAgent("main")
+ if !ok || agent == nil {
+ t.Fatal("expected to find 'main' agent")
+ }
+ if agent.ID != "main" {
+ t.Errorf("agent.ID = %q, want 'main'", agent.ID)
+ }
+}
+
+func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "sales", Default: true, Name: "Sales Bot"},
+ {ID: "support", Name: "Support Bot"},
+ })
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ ids := registry.ListAgentIDs()
+ if len(ids) != 2 {
+ t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
+ }
+
+ sales, ok := registry.GetAgent("sales")
+ if !ok || sales == nil {
+ t.Fatal("expected to find 'sales' agent")
+ }
+ if sales.Name != "Sales Bot" {
+ t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
+ }
+
+ support, ok := registry.GetAgent("support")
+ if !ok || support == nil {
+ t.Fatal("expected to find 'support' agent")
+ }
+}
+
+func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "my-agent", Default: true},
+ })
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ agent, ok := registry.GetAgent("My-Agent")
+ if !ok || agent == nil {
+ t.Fatal("expected to find agent with normalized ID")
+ }
+ if agent.ID != "my-agent" {
+ t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
+ }
+}
+
+func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "alpha"},
+ {ID: "beta", Default: true},
+ })
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ // GetDefaultAgent first checks for "main", then returns any
+ agent := registry.GetDefaultAgent()
+ if agent == nil {
+ t.Fatal("expected a default agent")
+ }
+}
+
+func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {
+ ID: "parent",
+ Default: true,
+ Subagents: &config.SubagentsConfig{
+ AllowAgents: []string{"child1", "child2"},
+ },
+ },
+ {ID: "child1"},
+ {ID: "child2"},
+ {ID: "restricted"},
+ })
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ if !registry.CanSpawnSubagent("parent", "child1") {
+ t.Error("expected parent to be allowed to spawn child1")
+ }
+ if !registry.CanSpawnSubagent("parent", "child2") {
+ t.Error("expected parent to be allowed to spawn child2")
+ }
+ if registry.CanSpawnSubagent("parent", "restricted") {
+ t.Error("expected parent to NOT be allowed to spawn restricted")
+ }
+ if registry.CanSpawnSubagent("child1", "child2") {
+ t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
+ }
+}
+
+func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {
+ ID: "admin",
+ Default: true,
+ Subagents: &config.SubagentsConfig{
+ AllowAgents: []string{"*"},
+ },
+ },
+ {ID: "any-agent"},
+ })
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ if !registry.CanSpawnSubagent("admin", "any-agent") {
+ t.Error("expected wildcard to allow spawning any agent")
+ }
+ if !registry.CanSpawnSubagent("admin", "nonexistent") {
+ t.Error("expected wildcard to allow spawning even nonexistent agents")
+ }
+}
+
+func TestAgentInstance_Model(t *testing.T) {
+ model := &config.AgentModelConfig{Primary: "claude-opus"}
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "custom", Default: true, Model: model},
+ })
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ agent, _ := registry.GetAgent("custom")
+ if agent.Model != "claude-opus" {
+ t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
+ }
+}
+
+func TestAgentInstance_FallbackInheritance(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "inherit", Default: true},
+ })
+ cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ agent, _ := registry.GetAgent("inherit")
+ if len(agent.Fallbacks) != 2 {
+ t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
+ }
+}
+
+func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
+ model := &config.AgentModelConfig{
+ Primary: "gpt-4",
+ Fallbacks: []string{}, // explicitly empty = disable
+ }
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "no-fallback", Default: true, Model: model},
+ })
+ cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
+ registry := NewAgentRegistry(cfg, &mockProvider{})
+
+ agent, _ := registry.GetAgent("no-fallback")
+ if len(agent.Fallbacks) != 0 {
+ t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
+ }
+}
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index fabec1a86..c1d3085ec 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -2,7 +2,6 @@ package channels
import (
"context"
- "fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -72,17 +71,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
return
}
- // Build session key: channel:chatID
- sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
-
msg := bus.InboundMessage{
- Channel: c.name,
- SenderID: senderID,
- ChatID: chatID,
- Content: content,
- Media: media,
- SessionKey: sessionKey,
- Metadata: metadata,
+ Channel: c.name,
+ SenderID: senderID,
+ ChatID: chatID,
+ Content: content,
+ Media: media,
+ Metadata: metadata,
}
c.bus.PublishInbound(msg)
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index e65c99eec..af4a01b35 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -228,6 +228,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"preview": utils.Truncate(content, 50),
})
+ peerKind := "channel"
+ peerID := m.ChannelID
+ if m.GuildID == "" {
+ peerKind = "direct"
+ peerID = senderID
+ }
+
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
@@ -236,6 +243,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
}
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index b3ac12e01..58dc7824c 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -25,6 +25,7 @@ type SlackChannel struct {
api *slack.Client
socketClient *socketmode.Client
botUserID string
+ teamID string
transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
@@ -72,6 +73,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
return fmt.Errorf("slack auth test failed: %w", err)
}
c.botUserID = authResp.UserID
+ c.teamID = authResp.TeamID
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
"bot_user_id": c.botUserID,
@@ -274,11 +276,21 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
+ peerKind := "channel"
+ peerID := channelID
+ if strings.HasPrefix(channelID, "D") {
+ peerKind = "direct"
+ peerID = senderID
+ }
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
+ "peer_kind": peerKind,
+ "peer_id": peerID,
+ "team_id": c.teamID,
}
logger.DebugCF("slack", "Received message", map[string]interface{}{
@@ -324,12 +336,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
+ mentionPeerKind := "channel"
+ mentionPeerID := channelID
+ if strings.HasPrefix(channelID, "D") {
+ mentionPeerKind = "direct"
+ mentionPeerID = senderID
+ }
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"is_mention": "true",
+ "peer_kind": mentionPeerKind,
+ "peer_id": mentionPeerID,
+ "team_id": c.teamID,
}
c.HandleMessage(senderID, chatID, content, nil, metadata)
@@ -359,6 +381,9 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"platform": "slack",
"is_command": "true",
"trigger_id": cmd.TriggerID,
+ "peer_kind": "channel",
+ "peer_id": channelID,
+ "team_id": c.teamID,
}
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 3ad4818c3..32924206f 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -351,12 +351,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
}(chatID, pID)
}
+ peerKind := "direct"
+ peerID := fmt.Sprintf("%d", user.ID)
+ if message.Chat.Type != "private" {
+ peerKind = "group"
+ peerID = fmt.Sprintf("%d", chatID)
+ }
+
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.Username,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
}
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 56f1e1958..accccc583 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -45,6 +45,8 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
type Config struct {
Agents AgentsConfig `json:"agents"`
+ Bindings []AgentBinding `json:"bindings,omitempty"`
+ Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers"`
Gateway GatewayConfig `json:"gateway"`
@@ -54,16 +56,97 @@ type Config struct {
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
+ List []AgentConfig `json:"list,omitempty"`
+}
+
+// AgentModelConfig supports both string and structured model config.
+// String format: "gpt-4" (just primary, no fallbacks)
+// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
+type AgentModelConfig struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+}
+
+func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ m.Primary = s
+ m.Fallbacks = nil
+ return nil
+ }
+ type raw struct {
+ Primary string `json:"primary"`
+ Fallbacks []string `json:"fallbacks"`
+ }
+ var r raw
+ if err := json.Unmarshal(data, &r); err != nil {
+ return err
+ }
+ m.Primary = r.Primary
+ m.Fallbacks = r.Fallbacks
+ return nil
+}
+
+func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
+ if len(m.Fallbacks) == 0 && m.Primary != "" {
+ return json.Marshal(m.Primary)
+ }
+ type raw struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+ }
+ return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
+}
+
+type AgentConfig struct {
+ ID string `json:"id"`
+ Default bool `json:"default,omitempty"`
+ Name string `json:"name,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+ Skills []string `json:"skills,omitempty"`
+ Subagents *SubagentsConfig `json:"subagents,omitempty"`
+}
+
+type SubagentsConfig struct {
+ AllowAgents []string `json:"allow_agents,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+}
+
+type PeerMatch struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+}
+
+type BindingMatch struct {
+ Channel string `json:"channel"`
+ AccountID string `json:"account_id,omitempty"`
+ Peer *PeerMatch `json:"peer,omitempty"`
+ GuildID string `json:"guild_id,omitempty"`
+ TeamID string `json:"team_id,omitempty"`
+}
+
+type AgentBinding struct {
+ AgentID string `json:"agent_id"`
+ Match BindingMatch `json:"match"`
+}
+
+type SessionConfig struct {
+ DMScope string `json:"dm_scope,omitempty"`
+ IdentityLinks map[string][]string `json:"identity_links,omitempty"`
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
@@ -348,6 +431,32 @@ func (c *Config) GetAPIBase() string {
return ""
}
+// ModelConfig holds primary model and fallback list.
+type ModelConfig struct {
+ Primary string
+ Fallbacks []string
+}
+
+// GetModelConfig returns the text model configuration with fallbacks.
+func (c *Config) GetModelConfig() ModelConfig {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return ModelConfig{
+ Primary: c.Agents.Defaults.Model,
+ Fallbacks: c.Agents.Defaults.ModelFallbacks,
+ }
+}
+
+// GetImageModelConfig returns the image model configuration with fallbacks.
+func (c *Config) GetImageModelConfig() ModelConfig {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return ModelConfig{
+ Primary: c.Agents.Defaults.ImageModel,
+ Fallbacks: c.Agents.Defaults.ImageModelFallbacks,
+ }
+}
+
func expandHome(path string) string {
if path == "" {
return path
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
new file mode 100644
index 000000000..e99c4f0aa
--- /dev/null
+++ b/pkg/config/config_test.go
@@ -0,0 +1,186 @@
+package config
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestAgentModelConfig_UnmarshalString(t *testing.T) {
+ var m AgentModelConfig
+ if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
+ t.Fatalf("unmarshal string: %v", err)
+ }
+ if m.Primary != "gpt-4" {
+ t.Errorf("Primary = %q, want 'gpt-4'", m.Primary)
+ }
+ if m.Fallbacks != nil {
+ t.Errorf("Fallbacks = %v, want nil", m.Fallbacks)
+ }
+}
+
+func TestAgentModelConfig_UnmarshalObject(t *testing.T) {
+ var m AgentModelConfig
+ data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}`
+ if err := json.Unmarshal([]byte(data), &m); err != nil {
+ t.Fatalf("unmarshal object: %v", err)
+ }
+ if m.Primary != "claude-opus" {
+ t.Errorf("Primary = %q, want 'claude-opus'", m.Primary)
+ }
+ if len(m.Fallbacks) != 2 {
+ t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks))
+ }
+ if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" {
+ t.Errorf("Fallbacks = %v", m.Fallbacks)
+ }
+}
+
+func TestAgentModelConfig_MarshalString(t *testing.T) {
+ m := AgentModelConfig{Primary: "gpt-4"}
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(data) != `"gpt-4"` {
+ t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data))
+ }
+}
+
+func TestAgentModelConfig_MarshalObject(t *testing.T) {
+ m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}}
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var result map[string]interface{}
+ json.Unmarshal(data, &result)
+ if result["primary"] != "claude-opus" {
+ t.Errorf("primary = %v", result["primary"])
+ }
+}
+
+func TestAgentConfig_FullParse(t *testing.T) {
+ jsonData := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "max_tool_iterations": 20
+ },
+ "list": [
+ {
+ "id": "sales",
+ "default": true,
+ "name": "Sales Bot",
+ "model": "gpt-4"
+ },
+ {
+ "id": "support",
+ "name": "Support Bot",
+ "model": {
+ "primary": "claude-opus",
+ "fallbacks": ["haiku"]
+ },
+ "subagents": {
+ "allow_agents": ["sales"]
+ }
+ }
+ ]
+ },
+ "bindings": [
+ {
+ "agent_id": "support",
+ "match": {
+ "channel": "telegram",
+ "account_id": "*",
+ "peer": {"kind": "direct", "id": "user123"}
+ }
+ }
+ ],
+ "session": {
+ "dm_scope": "per-peer",
+ "identity_links": {
+ "john": ["telegram:123", "discord:john#1234"]
+ }
+ }
+ }`
+
+ cfg := DefaultConfig()
+ if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if len(cfg.Agents.List) != 2 {
+ t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
+ }
+
+ sales := cfg.Agents.List[0]
+ if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" {
+ t.Errorf("sales = %+v", sales)
+ }
+ if sales.Model == nil || sales.Model.Primary != "gpt-4" {
+ t.Errorf("sales.Model = %+v", sales.Model)
+ }
+
+ support := cfg.Agents.List[1]
+ if support.ID != "support" || support.Name != "Support Bot" {
+ t.Errorf("support = %+v", support)
+ }
+ if support.Model == nil || support.Model.Primary != "claude-opus" {
+ t.Errorf("support.Model = %+v", support.Model)
+ }
+ if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
+ t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks)
+ }
+ if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
+ t.Errorf("support.Subagents = %+v", support.Subagents)
+ }
+
+ if len(cfg.Bindings) != 1 {
+ t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings))
+ }
+ binding := cfg.Bindings[0]
+ if binding.AgentID != "support" || binding.Match.Channel != "telegram" {
+ t.Errorf("binding = %+v", binding)
+ }
+ if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" {
+ t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer)
+ }
+
+ if cfg.Session.DMScope != "per-peer" {
+ t.Errorf("Session.DMScope = %q", cfg.Session.DMScope)
+ }
+ if len(cfg.Session.IdentityLinks) != 1 {
+ t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks)
+ }
+ links := cfg.Session.IdentityLinks["john"]
+ if len(links) != 2 {
+ t.Errorf("john links = %v", links)
+ }
+}
+
+func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
+ jsonData := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "max_tool_iterations": 20
+ }
+ }
+ }`
+
+ cfg := DefaultConfig()
+ if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if len(cfg.Agents.List) != 0 {
+ t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
+ }
+ if len(cfg.Bindings) != 0 {
+ t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
+ }
+}
diff --git a/pkg/routing/agent_id.go b/pkg/routing/agent_id.go
new file mode 100644
index 000000000..bcf2f0dc0
--- /dev/null
+++ b/pkg/routing/agent_id.go
@@ -0,0 +1,66 @@
+package routing
+
+import (
+ "regexp"
+ "strings"
+)
+
+const (
+ DefaultAgentID = "main"
+ DefaultMainKey = "main"
+ DefaultAccountID = "default"
+ MaxAgentIDLength = 64
+)
+
+var (
+ validIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
+ invalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`)
+ leadingDashRe = regexp.MustCompile(`^-+`)
+ trailingDashRe = regexp.MustCompile(`-+$`)
+)
+
+// NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}.
+// Invalid characters are collapsed to "-". Leading/trailing dashes stripped.
+// Empty input returns DefaultAgentID ("main").
+func NormalizeAgentID(id string) string {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return DefaultAgentID
+ }
+ lower := strings.ToLower(trimmed)
+ if validIDRe.MatchString(lower) {
+ return lower
+ }
+ result := invalidCharsRe.ReplaceAllString(lower, "-")
+ result = leadingDashRe.ReplaceAllString(result, "")
+ result = trailingDashRe.ReplaceAllString(result, "")
+ if len(result) > MaxAgentIDLength {
+ result = result[:MaxAgentIDLength]
+ }
+ if result == "" {
+ return DefaultAgentID
+ }
+ return result
+}
+
+// NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID.
+func NormalizeAccountID(id string) string {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return DefaultAccountID
+ }
+ lower := strings.ToLower(trimmed)
+ if validIDRe.MatchString(lower) {
+ return lower
+ }
+ result := invalidCharsRe.ReplaceAllString(lower, "-")
+ result = leadingDashRe.ReplaceAllString(result, "")
+ result = trailingDashRe.ReplaceAllString(result, "")
+ if len(result) > MaxAgentIDLength {
+ result = result[:MaxAgentIDLength]
+ }
+ if result == "" {
+ return DefaultAccountID
+ }
+ return result
+}
diff --git a/pkg/routing/agent_id_test.go b/pkg/routing/agent_id_test.go
new file mode 100644
index 000000000..050fe0645
--- /dev/null
+++ b/pkg/routing/agent_id_test.go
@@ -0,0 +1,86 @@
+package routing
+
+import "testing"
+
+func TestNormalizeAgentID_Empty(t *testing.T) {
+ if got := NormalizeAgentID(""); got != DefaultAgentID {
+ t.Errorf("NormalizeAgentID('') = %q, want %q", got, DefaultAgentID)
+ }
+}
+
+func TestNormalizeAgentID_Whitespace(t *testing.T) {
+ if got := NormalizeAgentID(" "); got != DefaultAgentID {
+ t.Errorf("NormalizeAgentID(' ') = %q, want %q", got, DefaultAgentID)
+ }
+}
+
+func TestNormalizeAgentID_Valid(t *testing.T) {
+ tests := []struct {
+ input, want string
+ }{
+ {"main", "main"},
+ {"Main", "main"},
+ {"SALES", "sales"},
+ {"support-bot", "support-bot"},
+ {"agent_1", "agent_1"},
+ {"a", "a"},
+ {"0test", "0test"},
+ }
+ for _, tt := range tests {
+ if got := NormalizeAgentID(tt.input); got != tt.want {
+ t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeAgentID_InvalidChars(t *testing.T) {
+ tests := []struct {
+ input, want string
+ }{
+ {"Hello World", "hello-world"},
+ {"agent@123", "agent-123"},
+ {"foo.bar.baz", "foo-bar-baz"},
+ {"--leading", "leading"},
+ {"--both--", "both"},
+ }
+ for _, tt := range tests {
+ if got := NormalizeAgentID(tt.input); got != tt.want {
+ t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeAgentID_AllInvalid(t *testing.T) {
+ if got := NormalizeAgentID("@@@"); got != DefaultAgentID {
+ t.Errorf("NormalizeAgentID('@@@') = %q, want %q", got, DefaultAgentID)
+ }
+}
+
+func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
+ long := ""
+ for i := 0; i < 100; i++ {
+ long += "a"
+ }
+ got := NormalizeAgentID(long)
+ if len(got) > MaxAgentIDLength {
+ t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
+ }
+}
+
+func TestNormalizeAccountID_Empty(t *testing.T) {
+ if got := NormalizeAccountID(""); got != DefaultAccountID {
+ t.Errorf("NormalizeAccountID('') = %q, want %q", got, DefaultAccountID)
+ }
+}
+
+func TestNormalizeAccountID_Valid(t *testing.T) {
+ if got := NormalizeAccountID("MyBot"); got != "mybot" {
+ t.Errorf("NormalizeAccountID('MyBot') = %q, want 'mybot'", got)
+ }
+}
+
+func TestNormalizeAccountID_InvalidChars(t *testing.T) {
+ if got := NormalizeAccountID("bot@home"); got != "bot-home" {
+ t.Errorf("NormalizeAccountID('bot@home') = %q, want 'bot-home'", got)
+ }
+}
diff --git a/pkg/routing/route.go b/pkg/routing/route.go
new file mode 100644
index 000000000..9eb060c53
--- /dev/null
+++ b/pkg/routing/route.go
@@ -0,0 +1,252 @@
+package routing
+
+import (
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// RouteInput contains the routing context from an inbound message.
+type RouteInput struct {
+ Channel string
+ AccountID string
+ Peer *RoutePeer
+ ParentPeer *RoutePeer
+ GuildID string
+ TeamID string
+}
+
+// ResolvedRoute is the result of agent routing.
+type ResolvedRoute struct {
+ AgentID string
+ Channel string
+ AccountID string
+ SessionKey string
+ MainSessionKey string
+ MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default"
+}
+
+// RouteResolver determines which agent handles a message based on config bindings.
+type RouteResolver struct {
+ cfg *config.Config
+}
+
+// NewRouteResolver creates a new route resolver.
+func NewRouteResolver(cfg *config.Config) *RouteResolver {
+ return &RouteResolver{cfg: cfg}
+}
+
+// ResolveRoute determines which agent handles the message and constructs session keys.
+// Implements the 7-level priority cascade:
+// peer > parent_peer > guild > team > account > channel_wildcard > default
+func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
+ channel := strings.ToLower(strings.TrimSpace(input.Channel))
+ accountID := NormalizeAccountID(input.AccountID)
+ peer := input.Peer
+
+ dmScope := DMScope(r.cfg.Session.DMScope)
+ if dmScope == "" {
+ dmScope = DMScopeMain
+ }
+ identityLinks := r.cfg.Session.IdentityLinks
+
+ bindings := r.filterBindings(channel, accountID)
+
+ choose := func(agentID string, matchedBy string) ResolvedRoute {
+ resolvedAgentID := r.pickAgentID(agentID)
+ sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: resolvedAgentID,
+ Channel: channel,
+ AccountID: accountID,
+ Peer: peer,
+ DMScope: dmScope,
+ IdentityLinks: identityLinks,
+ }))
+ mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID))
+ return ResolvedRoute{
+ AgentID: resolvedAgentID,
+ Channel: channel,
+ AccountID: accountID,
+ SessionKey: sessionKey,
+ MainSessionKey: mainSessionKey,
+ MatchedBy: matchedBy,
+ }
+ }
+
+ // Priority 1: Peer binding
+ if peer != nil && strings.TrimSpace(peer.ID) != "" {
+ if match := r.findPeerMatch(bindings, peer); match != nil {
+ return choose(match.AgentID, "binding.peer")
+ }
+ }
+
+ // Priority 2: Parent peer binding
+ parentPeer := input.ParentPeer
+ if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
+ if match := r.findPeerMatch(bindings, parentPeer); match != nil {
+ return choose(match.AgentID, "binding.peer.parent")
+ }
+ }
+
+ // Priority 3: Guild binding
+ guildID := strings.TrimSpace(input.GuildID)
+ if guildID != "" {
+ if match := r.findGuildMatch(bindings, guildID); match != nil {
+ return choose(match.AgentID, "binding.guild")
+ }
+ }
+
+ // Priority 4: Team binding
+ teamID := strings.TrimSpace(input.TeamID)
+ if teamID != "" {
+ if match := r.findTeamMatch(bindings, teamID); match != nil {
+ return choose(match.AgentID, "binding.team")
+ }
+ }
+
+ // Priority 5: Account binding
+ if match := r.findAccountMatch(bindings); match != nil {
+ return choose(match.AgentID, "binding.account")
+ }
+
+ // Priority 6: Channel wildcard binding
+ if match := r.findChannelWildcardMatch(bindings); match != nil {
+ return choose(match.AgentID, "binding.channel")
+ }
+
+ // Priority 7: Default agent
+ return choose(r.resolveDefaultAgentID(), "default")
+}
+
+func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding {
+ var filtered []config.AgentBinding
+ for _, b := range r.cfg.Bindings {
+ matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel))
+ if matchChannel == "" || matchChannel != channel {
+ continue
+ }
+ if !matchesAccountID(b.Match.AccountID, accountID) {
+ continue
+ }
+ filtered = append(filtered, b)
+ }
+ return filtered
+}
+
+func matchesAccountID(matchAccountID, actual string) bool {
+ trimmed := strings.TrimSpace(matchAccountID)
+ if trimmed == "" {
+ return actual == DefaultAccountID
+ }
+ if trimmed == "*" {
+ return true
+ }
+ return strings.ToLower(trimmed) == strings.ToLower(actual)
+}
+
+func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ if b.Match.Peer == nil {
+ continue
+ }
+ peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind))
+ peerID := strings.TrimSpace(b.Match.Peer.ID)
+ if peerKind == "" || peerID == "" {
+ continue
+ }
+ if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID {
+ return b
+ }
+ }
+ return nil
+}
+
+func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ matchGuild := strings.TrimSpace(b.Match.GuildID)
+ if matchGuild != "" && matchGuild == guildID {
+ return &bindings[i]
+ }
+ }
+ return nil
+}
+
+func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ matchTeam := strings.TrimSpace(b.Match.TeamID)
+ if matchTeam != "" && matchTeam == teamID {
+ return &bindings[i]
+ }
+ }
+ return nil
+}
+
+func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ accountID := strings.TrimSpace(b.Match.AccountID)
+ if accountID == "*" {
+ continue
+ }
+ if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
+ continue
+ }
+ return &bindings[i]
+ }
+ return nil
+}
+
+func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ accountID := strings.TrimSpace(b.Match.AccountID)
+ if accountID != "*" {
+ continue
+ }
+ if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
+ continue
+ }
+ return &bindings[i]
+ }
+ return nil
+}
+
+func (r *RouteResolver) pickAgentID(agentID string) string {
+ trimmed := strings.TrimSpace(agentID)
+ if trimmed == "" {
+ return NormalizeAgentID(r.resolveDefaultAgentID())
+ }
+ normalized := NormalizeAgentID(trimmed)
+ agents := r.cfg.Agents.List
+ if len(agents) == 0 {
+ return normalized
+ }
+ for _, a := range agents {
+ if NormalizeAgentID(a.ID) == normalized {
+ return normalized
+ }
+ }
+ return NormalizeAgentID(r.resolveDefaultAgentID())
+}
+
+func (r *RouteResolver) resolveDefaultAgentID() string {
+ agents := r.cfg.Agents.List
+ if len(agents) == 0 {
+ return DefaultAgentID
+ }
+ for _, a := range agents {
+ if a.Default {
+ id := strings.TrimSpace(a.ID)
+ if id != "" {
+ return NormalizeAgentID(id)
+ }
+ }
+ }
+ if id := strings.TrimSpace(agents[0].ID); id != "" {
+ return NormalizeAgentID(id)
+ }
+ return DefaultAgentID
+}
diff --git a/pkg/routing/route_test.go b/pkg/routing/route_test.go
new file mode 100644
index 000000000..8255db5f9
--- /dev/null
+++ b/pkg/routing/route_test.go
@@ -0,0 +1,297 @@
+package routing
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config {
+ return &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: "/tmp/picoclaw-test",
+ Model: "gpt-4",
+ },
+ List: agents,
+ },
+ Bindings: bindings,
+ Session: config.SessionConfig{
+ DMScope: "per-peer",
+ },
+ }
+}
+
+func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {
+ cfg := testConfig(nil, nil)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user1"},
+ })
+
+ if route.AgentID != DefaultAgentID {
+ t.Errorf("AgentID = %q, want %q", route.AgentID, DefaultAgentID)
+ }
+ if route.MatchedBy != "default" {
+ t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_PeerBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "sales", Default: true},
+ {ID: "support"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "support",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "*",
+ Peer: &config.PeerMatch{Kind: "direct", ID: "user123"},
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ })
+
+ if route.AgentID != "support" {
+ t.Errorf("AgentID = %q, want 'support'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.peer" {
+ t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_GuildBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "general", Default: true},
+ {ID: "gaming"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "gaming",
+ Match: config.BindingMatch{
+ Channel: "discord",
+ AccountID: "*",
+ GuildID: "guild-abc",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "discord",
+ GuildID: "guild-abc",
+ Peer: &RoutePeer{Kind: "channel", ID: "ch1"},
+ })
+
+ if route.AgentID != "gaming" {
+ t.Errorf("AgentID = %q, want 'gaming'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.guild" {
+ t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_TeamBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "general", Default: true},
+ {ID: "work"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "work",
+ Match: config.BindingMatch{
+ Channel: "slack",
+ AccountID: "*",
+ TeamID: "T12345",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "slack",
+ TeamID: "T12345",
+ Peer: &RoutePeer{Kind: "channel", ID: "C001"},
+ })
+
+ if route.AgentID != "work" {
+ t.Errorf("AgentID = %q, want 'work'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.team" {
+ t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_AccountBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "default-agent", Default: true},
+ {ID: "premium"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "premium",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "bot2",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ AccountID: "bot2",
+ Peer: &RoutePeer{Kind: "direct", ID: "user1"},
+ })
+
+ if route.AgentID != "premium" {
+ t.Errorf("AgentID = %q, want 'premium'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.account" {
+ t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_ChannelWildcard(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "main", Default: true},
+ {ID: "telegram-bot"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "telegram-bot",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "*",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user1"},
+ })
+
+ if route.AgentID != "telegram-bot" {
+ t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.channel" {
+ t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "general", Default: true},
+ {ID: "vip"},
+ {ID: "gaming"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "vip",
+ Match: config.BindingMatch{
+ Channel: "discord",
+ AccountID: "*",
+ Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"},
+ },
+ },
+ {
+ AgentID: "gaming",
+ Match: config.BindingMatch{
+ Channel: "discord",
+ AccountID: "*",
+ GuildID: "guild-1",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "discord",
+ GuildID: "guild-1",
+ Peer: &RoutePeer{Kind: "direct", ID: "user-vip"},
+ })
+
+ if route.AgentID != "vip" {
+ t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID)
+ }
+ if route.MatchedBy != "binding.peer" {
+ t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "main", Default: true},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "nonexistent",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "*",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ })
+
+ if route.AgentID != "main" {
+ t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
+ }
+}
+
+func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "alpha"},
+ {ID: "beta", Default: true},
+ {ID: "gamma"},
+ }
+ cfg := testConfig(agents, nil)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "cli",
+ })
+
+ if route.AgentID != "beta" {
+ t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
+ }
+}
+
+func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "alpha"},
+ {ID: "beta"},
+ }
+ cfg := testConfig(agents, nil)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "cli",
+ })
+
+ if route.AgentID != "alpha" {
+ t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)
+ }
+}
diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go
new file mode 100644
index 000000000..e12f0d1d8
--- /dev/null
+++ b/pkg/routing/session_key.go
@@ -0,0 +1,183 @@
+package routing
+
+import (
+ "fmt"
+ "strings"
+)
+
+// DMScope controls DM session isolation granularity.
+type DMScope string
+
+const (
+ DMScopeMain DMScope = "main"
+ DMScopePerPeer DMScope = "per-peer"
+ DMScopePerChannelPeer DMScope = "per-channel-peer"
+ DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
+)
+
+// RoutePeer represents a chat peer with kind and ID.
+type RoutePeer struct {
+ Kind string // "direct", "group", "channel"
+ ID string
+}
+
+// SessionKeyParams holds all inputs for session key construction.
+type SessionKeyParams struct {
+ AgentID string
+ Channel string
+ AccountID string
+ Peer *RoutePeer
+ DMScope DMScope
+ IdentityLinks map[string][]string
+}
+
+// ParsedSessionKey is the result of parsing an agent-scoped session key.
+type ParsedSessionKey struct {
+ AgentID string
+ Rest string
+}
+
+// BuildAgentMainSessionKey returns "agent::main".
+func BuildAgentMainSessionKey(agentID string) string {
+ return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
+}
+
+// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
+func BuildAgentPeerSessionKey(params SessionKeyParams) string {
+ agentID := NormalizeAgentID(params.AgentID)
+
+ peer := params.Peer
+ if peer == nil {
+ peer = &RoutePeer{Kind: "direct"}
+ }
+ peerKind := strings.TrimSpace(peer.Kind)
+ if peerKind == "" {
+ peerKind = "direct"
+ }
+
+ if peerKind == "direct" {
+ dmScope := params.DMScope
+ if dmScope == "" {
+ dmScope = DMScopeMain
+ }
+ peerID := strings.TrimSpace(peer.ID)
+
+ // Resolve identity links (cross-platform collapse)
+ if dmScope != DMScopeMain && peerID != "" {
+ if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" {
+ peerID = linked
+ }
+ }
+ peerID = strings.ToLower(peerID)
+
+ switch dmScope {
+ case DMScopePerAccountChannelPeer:
+ if peerID != "" {
+ channel := normalizeChannel(params.Channel)
+ accountID := NormalizeAccountID(params.AccountID)
+ return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID)
+ }
+ case DMScopePerChannelPeer:
+ if peerID != "" {
+ channel := normalizeChannel(params.Channel)
+ return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID)
+ }
+ case DMScopePerPeer:
+ if peerID != "" {
+ return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID)
+ }
+ }
+ return BuildAgentMainSessionKey(agentID)
+ }
+
+ // Group/channel peers always get per-peer sessions
+ channel := normalizeChannel(params.Channel)
+ peerID := strings.ToLower(strings.TrimSpace(peer.ID))
+ if peerID == "" {
+ peerID = "unknown"
+ }
+ return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
+}
+
+// ParseAgentSessionKey extracts agentId and rest from "agent::".
+func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {
+ raw := strings.TrimSpace(sessionKey)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.SplitN(raw, ":", 3)
+ if len(parts) < 3 {
+ return nil
+ }
+ if parts[0] != "agent" {
+ return nil
+ }
+ agentID := strings.TrimSpace(parts[1])
+ rest := parts[2]
+ if agentID == "" || rest == "" {
+ return nil
+ }
+ return &ParsedSessionKey{AgentID: agentID, Rest: rest}
+}
+
+// IsSubagentSessionKey returns true if the session key represents a subagent.
+func IsSubagentSessionKey(sessionKey string) bool {
+ raw := strings.TrimSpace(sessionKey)
+ if raw == "" {
+ return false
+ }
+ if strings.HasPrefix(strings.ToLower(raw), "subagent:") {
+ return true
+ }
+ parsed := ParseAgentSessionKey(raw)
+ if parsed == nil {
+ return false
+ }
+ return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:")
+}
+
+func normalizeChannel(channel string) string {
+ c := strings.TrimSpace(strings.ToLower(channel))
+ if c == "" {
+ return "unknown"
+ }
+ return c
+}
+
+func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
+ if len(identityLinks) == 0 {
+ return ""
+ }
+ peerID = strings.TrimSpace(peerID)
+ if peerID == "" {
+ return ""
+ }
+
+ candidates := make(map[string]bool)
+ rawCandidate := strings.ToLower(peerID)
+ if rawCandidate != "" {
+ candidates[rawCandidate] = true
+ }
+ channel = strings.ToLower(strings.TrimSpace(channel))
+ if channel != "" {
+ scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
+ candidates[scopedCandidate] = true
+ }
+ if len(candidates) == 0 {
+ return ""
+ }
+
+ for canonical, ids := range identityLinks {
+ canonicalName := strings.TrimSpace(canonical)
+ if canonicalName == "" {
+ continue
+ }
+ for _, id := range ids {
+ normalized := strings.ToLower(strings.TrimSpace(id))
+ if normalized != "" && candidates[normalized] {
+ return canonicalName
+ }
+ }
+ }
+ return ""
+}
diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go
new file mode 100644
index 000000000..81e4ce018
--- /dev/null
+++ b/pkg/routing/session_key_test.go
@@ -0,0 +1,162 @@
+package routing
+
+import "testing"
+
+func TestBuildAgentMainSessionKey(t *testing.T) {
+ got := BuildAgentMainSessionKey("sales")
+ want := "agent:sales:main"
+ if got != want {
+ t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
+ got := BuildAgentMainSessionKey("Sales Bot")
+ want := "agent:sales-bot:main"
+ if got != want {
+ t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopeMain,
+ })
+ want := "agent:main:main"
+ if got != want {
+ t.Errorf("DMScopeMain = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopePerPeer,
+ })
+ want := "agent:main:direct:user123"
+ if got != want {
+ t.Errorf("DMScopePerPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopePerChannelPeer,
+ })
+ want := "agent:main:telegram:direct:user123"
+ if got != want {
+ t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ AccountID: "bot1",
+ Peer: &RoutePeer{Kind: "direct", ID: "User123"},
+ DMScope: DMScopePerAccountChannelPeer,
+ })
+ want := "agent:main:telegram:bot1:direct:user123"
+ if got != want {
+ t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "group", ID: "chat456"},
+ DMScope: DMScopePerPeer,
+ })
+ want := "agent:main:telegram:group:chat456"
+ if got != want {
+ t.Errorf("GroupPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: nil,
+ DMScope: DMScopePerPeer,
+ })
+ // nil peer defaults to direct with empty ID, falls to main
+ want := "agent:main:main"
+ if got != want {
+ t.Errorf("NilPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
+ links := map[string][]string{
+ "john": {"telegram:user123", "discord:john#1234"},
+ }
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopePerPeer,
+ IdentityLinks: links,
+ })
+ want := "agent:main:direct:john"
+ if got != want {
+ t.Errorf("IdentityLink = %q, want %q", got, want)
+ }
+}
+
+func TestParseAgentSessionKey_Valid(t *testing.T) {
+ parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
+ if parsed == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if parsed.AgentID != "sales" {
+ t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID)
+ }
+ if parsed.Rest != "telegram:direct:user123" {
+ t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest)
+ }
+}
+
+func TestParseAgentSessionKey_Invalid(t *testing.T) {
+ tests := []string{
+ "",
+ "foo:bar",
+ "notprefix:sales:main",
+ "agent::main",
+ "agent:sales:",
+ }
+ for _, input := range tests {
+ if got := ParseAgentSessionKey(input); got != nil {
+ t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got)
+ }
+ }
+}
+
+func TestIsSubagentSessionKey(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"subagent:task-1", true},
+ {"agent:main:subagent:task-1", true},
+ {"agent:main:main", false},
+ {"agent:main:telegram:direct:user123", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ if got := IsSubagentSessionKey(tt.input); got != tt.want {
+ t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index 1bd7ac432..c449769de 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -6,9 +6,10 @@ import (
)
type SpawnTool struct {
- manager *SubagentManager
- originChannel string
- originChatID string
+ manager *SubagentManager
+ originChannel string
+ originChatID string
+ allowlistCheck func(targetAgentID string) bool
}
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
@@ -39,6 +40,10 @@ func (t *SpawnTool) Parameters() map[string]interface{} {
"type": "string",
"description": "Optional short label for the task (for display)",
},
+ "agent_id": map[string]interface{}{
+ "type": "string",
+ "description": "Optional target agent ID to delegate the task to",
+ },
},
"required": []string{"task"},
}
@@ -49,6 +54,10 @@ func (t *SpawnTool) SetContext(channel, chatID string) {
t.originChatID = chatID
}
+func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
+ t.allowlistCheck = check
+}
+
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
task, ok := args["task"].(string)
if !ok {
@@ -56,12 +65,20 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s
}
label, _ := args["label"].(string)
+ agentID, _ := args["agent_id"].(string)
+
+ // Check allowlist if targeting a specific agent
+ if agentID != "" && t.allowlistCheck != nil {
+ if !t.allowlistCheck(agentID) {
+ return fmt.Sprintf("Error: not allowed to spawn agent '%s'", agentID), nil
+ }
+ }
if t.manager == nil {
return "Error: Subagent manager not configured", nil
}
- result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID)
+ result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID)
if err != nil {
return "", fmt.Errorf("failed to spawn subagent: %w", err)
}
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 0c05097f0..d45ab3433 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -14,6 +14,7 @@ type SubagentTask struct {
ID string
Task string
Label string
+ AgentID string
OriginChannel string
OriginChatID string
Status string
@@ -40,7 +41,7 @@ func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *b
}
}
-func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string) (string, error) {
+func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string) (string, error) {
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -51,6 +52,7 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel
ID: taskID,
Task: task,
Label: label,
+ AgentID: agentID,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
From 0f5b2f67bbe443b63b7d975025661fc702cc0892 Mon Sep 17 00:00:00 2001
From: Leandro Barbosa
Date: Fri, 13 Feb 2026 12:26:44 -0300
Subject: [PATCH 003/130] style: fix gofmt formatting in cooldown files
Remove extra spaces in comment alignment to pass fmt-check CI.
---
pkg/providers/cooldown.go | 4 ++--
pkg/providers/cooldown_test.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/pkg/providers/cooldown.go b/pkg/providers/cooldown.go
index 6811297f0..b0d8608dc 100644
--- a/pkg/providers/cooldown.go
+++ b/pkg/providers/cooldown.go
@@ -196,8 +196,8 @@ func calculateStandardCooldown(errorCount int) time.Duration {
// 3 errors → 20 hours
// 4+ errors → 24 hours (cap)
func calculateBillingCooldown(billingErrorCount int) time.Duration {
- const baseMs = 5 * 60 * 60 * 1000 // 5 hours
- const maxMs = 24 * 60 * 60 * 1000 // 24 hours
+ const baseMs = 5 * 60 * 60 * 1000 // 5 hours
+ const maxMs = 24 * 60 * 60 * 1000 // 24 hours
n := max(1, billingErrorCount)
exp := min(n-1, 10)
diff --git a/pkg/providers/cooldown_test.go b/pkg/providers/cooldown_test.go
index e51ff40e5..47f43ad5c 100644
--- a/pkg/providers/cooldown_test.go
+++ b/pkg/providers/cooldown_test.go
@@ -184,7 +184,7 @@ func TestCooldown_BillingTakesPrecedence(t *testing.T) {
// Standard cooldown (1 min) + billing disable (5h)
ct.MarkFailure("openai", FailoverRateLimit) // 1 min cooldown
- ct.MarkFailure("openai", FailoverBilling) // 5h disable
+ ct.MarkFailure("openai", FailoverBilling) // 5h disable
// After 2 min: standard cooldown expired but billing still active
*current = now.Add(2 * time.Minute)
From 82856bc57aa0af52dc8c3f9b563b90af2d030126 Mon Sep 17 00:00:00 2001
From: yinwm
Date: Sun, 15 Feb 2026 18:41:39 +0800
Subject: [PATCH 004/130] feat(cron): add configurable execution timeout for
cron jobs
Add a new configuration option `exec_timeout_minutes` under the `tools.cron`
section to control the maximum execution time for cron jobs. The default
timeout is set to 5 minutes, which is appropriate for LLM operations.
The configuration can be set in the config file or via the
`PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES` environment variable. A value of
0 disables the timeout entirely.
This change improves system reliability by preventing cron jobs from running
indefinitely in case of unexpected failures or hanging processes.
---
README.ja.md | 6 ++++++
README.md | 3 +++
README.zh.md | 6 ++++++
cmd/picoclaw/main.go | 6 +++---
config/config.example.json | 3 +++
pkg/config/config.go | 10 +++++++++-
pkg/tools/cron.go | 8 ++++++--
7 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index 48105ce2f..5e4e49411 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -195,6 +195,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -646,6 +649,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
"search": {
"apiKey": "BSA..."
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/README.md b/README.md
index 2ba70881b..1b7537fc9 100644
--- a/README.md
+++ b/README.md
@@ -697,6 +697,9 @@ picoclaw agent -m "Hello"
"search": {
"api_key": "BSA..."
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/README.zh.md b/README.zh.md
index f2c9bf780..877cb0f5d 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -217,6 +217,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
}
}
@@ -625,6 +628,9 @@ picoclaw agent -m "你好"
"search": {
"api_key": "BSA..."
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 21246cf41..8225931c8 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -669,7 +669,7 @@ func gatewayCmd() {
})
// Setup cron tool and service
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
+ cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes)*time.Minute)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
@@ -1069,14 +1069,14 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
+func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, execTimeout time.Duration) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool
- cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace)
+ cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, execTimeout)
agentLoop.RegisterTool(cronTool)
// Set the onJob handler
diff --git a/config/config.example.json b/config/config.example.json
index c71587a04..d56596f24 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -98,6 +98,9 @@
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 391120e2d..9acbcce8c 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -173,8 +173,13 @@ type WebToolsConfig struct {
Search WebSearchConfig `json:"search"`
}
+type CronToolsConfig struct {
+ ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
+}
+
type ToolsConfig struct {
- Web WebToolsConfig `json:"web"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
}
func DefaultConfig() *Config {
@@ -262,6 +267,9 @@ func DefaultConfig() *Config {
MaxResults: 5,
},
},
+ Cron: CronToolsConfig{
+ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
+ },
},
Heartbeat: HeartbeatConfig{
Enabled: true,
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 0ef745e2b..8632b07b9 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -28,12 +28,16 @@ type CronTool struct {
}
// NewCronTool creates a new CronTool
-func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string) *CronTool {
+func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, execTimeout time.Duration) *CronTool {
+ execTool := NewExecTool(workspace, false)
+ if execTimeout > 0 {
+ execTool.SetTimeout(execTimeout)
+ }
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
- execTool: NewExecTool(workspace, false),
+ execTool: execTool,
}
}
From a6e885bb473a20d671ed1dab5e8e8ea9bb8cd399 Mon Sep 17 00:00:00 2001
From: Jared Mahotiere
Date: Sun, 15 Feb 2026 08:04:07 -0500
Subject: [PATCH 005/130] refactor(providers): extract protocol factory and
openai-compat transport
---
pkg/providers/factory.go | 291 ++++++++++++
pkg/providers/factory_test.go | 150 ++++++
pkg/providers/http_provider.go | 473 ++++---------------
pkg/providers/openai_compat/provider.go | 230 +++++++++
pkg/providers/openai_compat/provider_test.go | 149 ++++++
5 files changed, 905 insertions(+), 388 deletions(-)
create mode 100644 pkg/providers/factory.go
create mode 100644 pkg/providers/factory_test.go
create mode 100644 pkg/providers/openai_compat/provider.go
create mode 100644 pkg/providers/openai_compat/provider_test.go
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
new file mode 100644
index 000000000..84dcd9aaa
--- /dev/null
+++ b/pkg/providers/factory.go
@@ -0,0 +1,291 @@
+package providers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type providerType int
+
+const (
+ providerTypeHTTPCompat providerType = iota
+ providerTypeClaudeAuth
+ providerTypeCodexAuth
+ providerTypeClaudeCLI
+ providerTypeGitHubCopilot
+)
+
+type providerSelection struct {
+ providerType providerType
+ apiKey string
+ apiBase string
+ proxy string
+ model string
+ workspace string
+ connectMode string
+}
+
+func createClaudeAuthProvider() (LLMProvider, error) {
+ cred, err := auth.GetCredential("anthropic")
+ if err != nil {
+ return nil, fmt.Errorf("loading auth credentials: %w", err)
+ }
+ if cred == nil {
+ return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
+ }
+ return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
+}
+
+func createCodexAuthProvider() (LLMProvider, error) {
+ cred, err := auth.GetCredential("openai")
+ if err != nil {
+ return nil, fmt.Errorf("loading auth credentials: %w", err)
+ }
+ if cred == nil {
+ return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
+ }
+ return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
+}
+
+func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
+ model := cfg.Agents.Defaults.Model
+ providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
+ lowerModel := strings.ToLower(model)
+
+ sel := providerSelection{
+ providerType: providerTypeHTTPCompat,
+ model: model,
+ }
+
+ // First, prefer explicit provider configuration.
+ if providerName != "" {
+ switch providerName {
+ case "groq":
+ if cfg.Providers.Groq.APIKey != "" {
+ sel.apiKey = cfg.Providers.Groq.APIKey
+ sel.apiBase = cfg.Providers.Groq.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.groq.com/openai/v1"
+ }
+ }
+ case "openai", "gpt":
+ if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
+ if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
+ sel.providerType = providerTypeCodexAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.OpenAI.APIKey
+ sel.apiBase = cfg.Providers.OpenAI.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.openai.com/v1"
+ }
+ }
+ case "anthropic", "claude":
+ if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
+ if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
+ sel.providerType = providerTypeClaudeAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.Anthropic.APIKey
+ sel.apiBase = cfg.Providers.Anthropic.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.anthropic.com/v1"
+ }
+ }
+ case "openrouter":
+ if cfg.Providers.OpenRouter.APIKey != "" {
+ sel.apiKey = cfg.Providers.OpenRouter.APIKey
+ if cfg.Providers.OpenRouter.APIBase != "" {
+ sel.apiBase = cfg.Providers.OpenRouter.APIBase
+ } else {
+ sel.apiBase = "https://openrouter.ai/api/v1"
+ }
+ }
+ case "zhipu", "glm":
+ if cfg.Providers.Zhipu.APIKey != "" {
+ sel.apiKey = cfg.Providers.Zhipu.APIKey
+ sel.apiBase = cfg.Providers.Zhipu.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+ case "gemini", "google":
+ if cfg.Providers.Gemini.APIKey != "" {
+ sel.apiKey = cfg.Providers.Gemini.APIKey
+ sel.apiBase = cfg.Providers.Gemini.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
+ }
+ }
+ case "vllm":
+ if cfg.Providers.VLLM.APIBase != "" {
+ sel.apiKey = cfg.Providers.VLLM.APIKey
+ sel.apiBase = cfg.Providers.VLLM.APIBase
+ }
+ case "shengsuanyun":
+ if cfg.Providers.ShengSuanYun.APIKey != "" {
+ sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
+ sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://router.shengsuanyun.com/api/v1"
+ }
+ }
+ case "claude-cli", "claude-code", "claudecode":
+ workspace := cfg.Agents.Defaults.Workspace
+ if workspace == "" {
+ workspace = "."
+ }
+ sel.providerType = providerTypeClaudeCLI
+ sel.workspace = workspace
+ return sel, nil
+ case "deepseek":
+ if cfg.Providers.DeepSeek.APIKey != "" {
+ sel.apiKey = cfg.Providers.DeepSeek.APIKey
+ sel.apiBase = cfg.Providers.DeepSeek.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.deepseek.com/v1"
+ }
+ if model != "deepseek-chat" && model != "deepseek-reasoner" {
+ sel.model = "deepseek-chat"
+ }
+ }
+ case "github_copilot", "copilot":
+ sel.providerType = providerTypeGitHubCopilot
+ if cfg.Providers.GitHubCopilot.APIBase != "" {
+ sel.apiBase = cfg.Providers.GitHubCopilot.APIBase
+ } else {
+ sel.apiBase = "localhost:4321"
+ }
+ sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
+ return sel, nil
+ }
+ }
+
+ // Fallback: infer provider from model and configured keys.
+ if sel.apiKey == "" && sel.apiBase == "" {
+ switch {
+ case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
+ sel.apiKey = cfg.Providers.Moonshot.APIKey
+ sel.apiBase = cfg.Providers.Moonshot.APIBase
+ sel.proxy = cfg.Providers.Moonshot.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.moonshot.cn/v1"
+ }
+ case strings.HasPrefix(model, "openrouter/") ||
+ strings.HasPrefix(model, "anthropic/") ||
+ strings.HasPrefix(model, "openai/") ||
+ strings.HasPrefix(model, "meta-llama/") ||
+ strings.HasPrefix(model, "deepseek/") ||
+ strings.HasPrefix(model, "google/"):
+ sel.apiKey = cfg.Providers.OpenRouter.APIKey
+ sel.proxy = cfg.Providers.OpenRouter.Proxy
+ if cfg.Providers.OpenRouter.APIBase != "" {
+ sel.apiBase = cfg.Providers.OpenRouter.APIBase
+ } else {
+ sel.apiBase = "https://openrouter.ai/api/v1"
+ }
+ case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
+ (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
+ if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
+ sel.providerType = providerTypeClaudeAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.Anthropic.APIKey
+ sel.apiBase = cfg.Providers.Anthropic.APIBase
+ sel.proxy = cfg.Providers.Anthropic.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.anthropic.com/v1"
+ }
+ case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
+ (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
+ if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
+ sel.providerType = providerTypeCodexAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.OpenAI.APIKey
+ sel.apiBase = cfg.Providers.OpenAI.APIBase
+ sel.proxy = cfg.Providers.OpenAI.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.openai.com/v1"
+ }
+ case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
+ sel.apiKey = cfg.Providers.Gemini.APIKey
+ sel.apiBase = cfg.Providers.Gemini.APIBase
+ sel.proxy = cfg.Providers.Gemini.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
+ }
+ case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
+ sel.apiKey = cfg.Providers.Zhipu.APIKey
+ sel.apiBase = cfg.Providers.Zhipu.APIBase
+ sel.proxy = cfg.Providers.Zhipu.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
+ }
+ case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
+ sel.apiKey = cfg.Providers.Groq.APIKey
+ sel.apiBase = cfg.Providers.Groq.APIBase
+ sel.proxy = cfg.Providers.Groq.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.groq.com/openai/v1"
+ }
+ case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
+ sel.apiKey = cfg.Providers.Nvidia.APIKey
+ sel.apiBase = cfg.Providers.Nvidia.APIBase
+ sel.proxy = cfg.Providers.Nvidia.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://integrate.api.nvidia.com/v1"
+ }
+ case cfg.Providers.VLLM.APIBase != "":
+ sel.apiKey = cfg.Providers.VLLM.APIKey
+ sel.apiBase = cfg.Providers.VLLM.APIBase
+ sel.proxy = cfg.Providers.VLLM.Proxy
+ default:
+ if cfg.Providers.OpenRouter.APIKey != "" {
+ sel.apiKey = cfg.Providers.OpenRouter.APIKey
+ sel.proxy = cfg.Providers.OpenRouter.Proxy
+ if cfg.Providers.OpenRouter.APIBase != "" {
+ sel.apiBase = cfg.Providers.OpenRouter.APIBase
+ } else {
+ sel.apiBase = "https://openrouter.ai/api/v1"
+ }
+ } else {
+ return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
+ }
+ }
+ }
+
+ if sel.providerType == providerTypeHTTPCompat {
+ if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
+ return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model)
+ }
+ if sel.apiBase == "" {
+ return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model)
+ }
+ }
+
+ return sel, nil
+}
+
+func CreateProvider(cfg *config.Config) (LLMProvider, error) {
+ sel, err := resolveProviderSelection(cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ switch sel.providerType {
+ case providerTypeClaudeAuth:
+ return createClaudeAuthProvider()
+ case providerTypeCodexAuth:
+ return createCodexAuthProvider()
+ case providerTypeClaudeCLI:
+ return NewClaudeCliProvider(sel.workspace), nil
+ case providerTypeGitHubCopilot:
+ return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model)
+ default:
+ return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+ }
+}
diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go
new file mode 100644
index 000000000..f894b292a
--- /dev/null
+++ b/pkg/providers/factory_test.go
@@ -0,0 +1,150 @@
+package providers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestResolveProviderSelection(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(*config.Config)
+ wantType providerType
+ wantAPIBase string
+ wantProxy string
+ wantErrSubstr string
+ }{
+ {
+ name: "explicit claude-cli provider routes to cli provider type",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "claude-cli"
+ cfg.Agents.Defaults.Workspace = "/tmp/ws"
+ },
+ wantType: providerTypeClaudeCLI,
+ },
+ {
+ name: "explicit copilot provider routes to github copilot type",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "copilot"
+ },
+ wantType: providerTypeGitHubCopilot,
+ wantAPIBase: "localhost:4321",
+ },
+ {
+ name: "openrouter model uses openrouter defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "openrouter/auto"
+ cfg.Providers.OpenRouter.APIKey = "sk-or-test"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://openrouter.ai/api/v1",
+ },
+ {
+ name: "anthropic oauth routes to claude auth provider",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "claude-sonnet-4-5-20250929"
+ cfg.Providers.Anthropic.AuthMethod = "oauth"
+ },
+ wantType: providerTypeClaudeAuth,
+ },
+ {
+ name: "openai oauth routes to codex auth provider",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "gpt-4o"
+ cfg.Providers.OpenAI.AuthMethod = "oauth"
+ },
+ wantType: providerTypeCodexAuth,
+ },
+ {
+ name: "zhipu model uses zhipu base default",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "glm-4.7"
+ cfg.Providers.Zhipu.APIKey = "zhipu-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://open.bigmodel.cn/api/paas/v4",
+ },
+ {
+ name: "groq model uses groq base default",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "groq/llama-3.3-70b"
+ cfg.Providers.Groq.APIKey = "gsk-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.groq.com/openai/v1",
+ },
+ {
+ name: "moonshot model keeps proxy and default base",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5"
+ cfg.Providers.Moonshot.APIKey = "moonshot-key"
+ cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.moonshot.cn/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "missing keys returns model config error",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "custom-model"
+ },
+ wantErrSubstr: "no API key configured for model",
+ },
+ {
+ name: "openrouter prefix without key returns provider key error",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "openrouter/auto"
+ },
+ wantErrSubstr: "no API key configured for provider",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tt.setup(cfg)
+
+ got, err := resolveProviderSelection(cfg)
+ if tt.wantErrSubstr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErrSubstr) {
+ t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("resolveProviderSelection() error = %v", err)
+ }
+ if got.providerType != tt.wantType {
+ t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType)
+ }
+ if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase {
+ t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase)
+ }
+ if tt.wantProxy != "" && got.proxy != tt.wantProxy {
+ t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy)
+ }
+ })
+ }
+}
+
+func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.Model = "openrouter/auto"
+ cfg.Providers.OpenRouter.APIKey = "sk-or-test"
+
+ provider, err := CreateProvider(cfg)
+ if err != nil {
+ t.Fatalf("CreateProvider() error = %v", err)
+ }
+
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("provider type = %T, want *HTTPProvider", provider)
+ }
+}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 17eb6214c..0f7f646d8 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -7,427 +7,124 @@
package providers
import (
- "bytes"
"context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/auth"
- "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers/openai_compat"
)
type HTTPProvider struct {
- apiKey string
- apiBase string
- httpClient *http.Client
+ delegate *openai_compat.Provider
}
-func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
- client := &http.Client{
- Timeout: 120 * time.Second,
+func NewHTTPProvider(apiKey, apiBase string, proxy ...string) *HTTPProvider {
+ proxyURL := ""
+ if len(proxy) > 0 {
+ proxyURL = proxy[0]
}
-
- if proxy != "" {
- proxyURL, err := url.Parse(proxy)
- if err == nil {
- client.Transport = &http.Transport{
- Proxy: http.ProxyURL(proxyURL),
- }
- }
- }
-
return &HTTPProvider{
- apiKey: apiKey,
- apiBase: strings.TrimRight(apiBase, "/"),
- httpClient: client,
+ delegate: openai_compat.NewProvider(apiKey, apiBase, proxyURL),
}
}
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
- if p.apiBase == "" {
- return nil, fmt.Errorf("API base not configured")
- }
-
- // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5)
- if idx := strings.Index(model, "/"); idx != -1 {
- prefix := model[:idx]
- if prefix == "moonshot" || prefix == "nvidia" {
- model = model[idx+1:]
- }
- }
-
- requestBody := map[string]interface{}{
- "model": model,
- "messages": messages,
- }
-
- if len(tools) > 0 {
- requestBody["tools"] = tools
- requestBody["tool_choice"] = "auto"
- }
-
- if maxTokens, ok := options["max_tokens"].(int); ok {
- lowerModel := strings.ToLower(model)
- if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
- requestBody["max_completion_tokens"] = maxTokens
- } else {
- requestBody["max_tokens"] = maxTokens
- }
- }
-
- if temperature, ok := options["temperature"].(float64); ok {
- lowerModel := strings.ToLower(model)
- // Kimi k2 models only support temperature=1
- if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
- requestBody["temperature"] = 1.0
- } else {
- requestBody["temperature"] = temperature
- }
- }
-
- jsonData, err := json.Marshal(requestBody)
+ compatResp, err := p.delegate.Chat(ctx, toOpenAICompatMessages(messages), toOpenAICompatTools(tools), model, options)
if err != nil {
- return nil, fmt.Errorf("failed to marshal request: %w", err)
+ return nil, err
}
-
- req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- if p.apiKey != "" {
- req.Header.Set("Authorization", "Bearer "+p.apiKey)
- }
-
- resp, err := p.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
- }
-
- return p.parseResponse(body)
-}
-
-func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
- var apiResponse struct {
- Choices []struct {
- Message struct {
- Content string `json:"content"`
- ToolCalls []struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Function *struct {
- Name string `json:"name"`
- Arguments string `json:"arguments"`
- } `json:"function"`
- } `json:"tool_calls"`
- } `json:"message"`
- FinishReason string `json:"finish_reason"`
- } `json:"choices"`
- Usage *UsageInfo `json:"usage"`
- }
-
- if err := json.Unmarshal(body, &apiResponse); err != nil {
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- if len(apiResponse.Choices) == 0 {
- return &LLMResponse{
- Content: "",
- FinishReason: "stop",
- }, nil
- }
-
- choice := apiResponse.Choices[0]
-
- toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
- for _, tc := range choice.Message.ToolCalls {
- arguments := make(map[string]interface{})
- name := ""
-
- // Handle OpenAI format with nested function object
- if tc.Type == "function" && tc.Function != nil {
- name = tc.Function.Name
- if tc.Function.Arguments != "" {
- if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
- arguments["raw"] = tc.Function.Arguments
- }
- }
- } else if tc.Function != nil {
- // Legacy format without type field
- name = tc.Function.Name
- if tc.Function.Arguments != "" {
- if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
- arguments["raw"] = tc.Function.Arguments
- }
- }
- }
-
- toolCalls = append(toolCalls, ToolCall{
- ID: tc.ID,
- Name: name,
- Arguments: arguments,
- })
- }
-
- return &LLMResponse{
- Content: choice.Message.Content,
- ToolCalls: toolCalls,
- FinishReason: choice.FinishReason,
- Usage: apiResponse.Usage,
- }, nil
+ return fromOpenAICompatResponse(compatResp), nil
}
func (p *HTTPProvider) GetDefaultModel() string {
return ""
}
-func createClaudeAuthProvider() (LLMProvider, error) {
- cred, err := auth.GetCredential("anthropic")
- if err != nil {
- return nil, fmt.Errorf("loading auth credentials: %w", err)
+func toOpenAICompatMessages(messages []Message) []openai_compat.Message {
+ out := make([]openai_compat.Message, 0, len(messages))
+ for _, msg := range messages {
+ out = append(out, openai_compat.Message{
+ Role: msg.Role,
+ Content: msg.Content,
+ ToolCalls: toOpenAICompatToolCalls(msg.ToolCalls),
+ ToolCallID: msg.ToolCallID,
+ })
}
- if cred == nil {
- return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
- }
- return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
+ return out
}
-func createCodexAuthProvider() (LLMProvider, error) {
- cred, err := auth.GetCredential("openai")
- if err != nil {
- return nil, fmt.Errorf("loading auth credentials: %w", err)
+func toOpenAICompatTools(tools []ToolDefinition) []openai_compat.ToolDefinition {
+ out := make([]openai_compat.ToolDefinition, 0, len(tools))
+ for _, t := range tools {
+ out = append(out, openai_compat.ToolDefinition{
+ Type: t.Type,
+ Function: openai_compat.ToolFunctionDefinition{
+ Name: t.Function.Name,
+ Description: t.Function.Description,
+ Parameters: t.Function.Parameters,
+ },
+ })
}
- if cred == nil {
- return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
- }
- return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
+ return out
}
-func CreateProvider(cfg *config.Config) (LLMProvider, error) {
- model := cfg.Agents.Defaults.Model
- providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
-
- var apiKey, apiBase, proxy string
-
- lowerModel := strings.ToLower(model)
-
- // First, try to use explicitly configured provider
- if providerName != "" {
- switch providerName {
- case "groq":
- if cfg.Providers.Groq.APIKey != "" {
- apiKey = cfg.Providers.Groq.APIKey
- apiBase = cfg.Providers.Groq.APIBase
- if apiBase == "" {
- apiBase = "https://api.groq.com/openai/v1"
- }
+func toOpenAICompatToolCalls(toolCalls []ToolCall) []openai_compat.ToolCall {
+ out := make([]openai_compat.ToolCall, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ var fn *openai_compat.FunctionCall
+ if tc.Function != nil {
+ fn = &openai_compat.FunctionCall{
+ Name: tc.Function.Name,
+ Arguments: tc.Function.Arguments,
}
- case "openai", "gpt":
- if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
- if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
- return createCodexAuthProvider()
- }
- apiKey = cfg.Providers.OpenAI.APIKey
- apiBase = cfg.Providers.OpenAI.APIBase
- if apiBase == "" {
- apiBase = "https://api.openai.com/v1"
- }
- }
- case "anthropic", "claude":
- if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
- if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
- return createClaudeAuthProvider()
- }
- apiKey = cfg.Providers.Anthropic.APIKey
- apiBase = cfg.Providers.Anthropic.APIBase
- if apiBase == "" {
- apiBase = "https://api.anthropic.com/v1"
- }
- }
- case "openrouter":
- if cfg.Providers.OpenRouter.APIKey != "" {
- apiKey = cfg.Providers.OpenRouter.APIKey
- if cfg.Providers.OpenRouter.APIBase != "" {
- apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- apiBase = "https://openrouter.ai/api/v1"
- }
- }
- case "zhipu", "glm":
- if cfg.Providers.Zhipu.APIKey != "" {
- apiKey = cfg.Providers.Zhipu.APIKey
- apiBase = cfg.Providers.Zhipu.APIBase
- if apiBase == "" {
- apiBase = "https://open.bigmodel.cn/api/paas/v4"
- }
- }
- case "gemini", "google":
- if cfg.Providers.Gemini.APIKey != "" {
- apiKey = cfg.Providers.Gemini.APIKey
- apiBase = cfg.Providers.Gemini.APIBase
- if apiBase == "" {
- apiBase = "https://generativelanguage.googleapis.com/v1beta"
- }
- }
- case "vllm":
- if cfg.Providers.VLLM.APIBase != "" {
- apiKey = cfg.Providers.VLLM.APIKey
- apiBase = cfg.Providers.VLLM.APIBase
- }
- case "shengsuanyun":
- if cfg.Providers.ShengSuanYun.APIKey != "" {
- apiKey = cfg.Providers.ShengSuanYun.APIKey
- apiBase = cfg.Providers.ShengSuanYun.APIBase
- if apiBase == "" {
- apiBase = "https://router.shengsuanyun.com/api/v1"
- }
- }
- case "claude-cli", "claudecode", "claude-code":
- workspace := cfg.Agents.Defaults.Workspace
- if workspace == "" {
- workspace = "."
- }
- return NewClaudeCliProvider(workspace), nil
- case "deepseek":
- if cfg.Providers.DeepSeek.APIKey != "" {
- apiKey = cfg.Providers.DeepSeek.APIKey
- apiBase = cfg.Providers.DeepSeek.APIBase
- if apiBase == "" {
- apiBase = "https://api.deepseek.com/v1"
- }
- if model != "deepseek-chat" && model != "deepseek-reasoner" {
- model = "deepseek-chat"
- }
- }
- case "github_copilot", "copilot":
- if cfg.Providers.GitHubCopilot.APIBase != "" {
- apiBase = cfg.Providers.GitHubCopilot.APIBase
- } else {
- apiBase = "localhost:4321"
- }
- return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
-
}
+ out = append(out, openai_compat.ToolCall{
+ ID: tc.ID,
+ Type: tc.Type,
+ Function: fn,
+ Name: tc.Name,
+ Arguments: tc.Arguments,
+ })
+ }
+ return out
+}
+func fromOpenAICompatResponse(resp *openai_compat.LLMResponse) *LLMResponse {
+ if resp == nil {
+ return &LLMResponse{}
}
- // Fallback: detect provider from model name
- if apiKey == "" && apiBase == "" {
- switch {
- case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
- apiKey = cfg.Providers.Moonshot.APIKey
- apiBase = cfg.Providers.Moonshot.APIBase
- proxy = cfg.Providers.Moonshot.Proxy
- if apiBase == "" {
- apiBase = "https://api.moonshot.cn/v1"
- }
-
- case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
- apiKey = cfg.Providers.OpenRouter.APIKey
- proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- apiBase = "https://openrouter.ai/api/v1"
- }
-
- case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
- if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
- return createClaudeAuthProvider()
- }
- apiKey = cfg.Providers.Anthropic.APIKey
- apiBase = cfg.Providers.Anthropic.APIBase
- proxy = cfg.Providers.Anthropic.Proxy
- if apiBase == "" {
- apiBase = "https://api.anthropic.com/v1"
- }
-
- case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
- if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
- return createCodexAuthProvider()
- }
- apiKey = cfg.Providers.OpenAI.APIKey
- apiBase = cfg.Providers.OpenAI.APIBase
- proxy = cfg.Providers.OpenAI.Proxy
- if apiBase == "" {
- apiBase = "https://api.openai.com/v1"
- }
-
- case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
- apiKey = cfg.Providers.Gemini.APIKey
- apiBase = cfg.Providers.Gemini.APIBase
- proxy = cfg.Providers.Gemini.Proxy
- if apiBase == "" {
- apiBase = "https://generativelanguage.googleapis.com/v1beta"
- }
-
- case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
- apiKey = cfg.Providers.Zhipu.APIKey
- apiBase = cfg.Providers.Zhipu.APIBase
- proxy = cfg.Providers.Zhipu.Proxy
- if apiBase == "" {
- apiBase = "https://open.bigmodel.cn/api/paas/v4"
- }
-
- case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
- apiKey = cfg.Providers.Groq.APIKey
- apiBase = cfg.Providers.Groq.APIBase
- proxy = cfg.Providers.Groq.Proxy
- if apiBase == "" {
- apiBase = "https://api.groq.com/openai/v1"
- }
-
- case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
- apiKey = cfg.Providers.Nvidia.APIKey
- apiBase = cfg.Providers.Nvidia.APIBase
- proxy = cfg.Providers.Nvidia.Proxy
- if apiBase == "" {
- apiBase = "https://integrate.api.nvidia.com/v1"
- }
-
- case cfg.Providers.VLLM.APIBase != "":
- apiKey = cfg.Providers.VLLM.APIKey
- apiBase = cfg.Providers.VLLM.APIBase
- proxy = cfg.Providers.VLLM.Proxy
-
- default:
- if cfg.Providers.OpenRouter.APIKey != "" {
- apiKey = cfg.Providers.OpenRouter.APIKey
- proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- apiBase = "https://openrouter.ai/api/v1"
- }
- } else {
- return nil, fmt.Errorf("no API key configured for model: %s", model)
- }
+ var usage *UsageInfo
+ if resp.Usage != nil {
+ usage = &UsageInfo{
+ PromptTokens: resp.Usage.PromptTokens,
+ CompletionTokens: resp.Usage.CompletionTokens,
+ TotalTokens: resp.Usage.TotalTokens,
}
}
- if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
- return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
+ return &LLMResponse{
+ Content: resp.Content,
+ ToolCalls: fromOpenAICompatToolCalls(resp.ToolCalls),
+ FinishReason: resp.FinishReason,
+ Usage: usage,
}
-
- if apiBase == "" {
- return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
- }
-
- return NewHTTPProvider(apiKey, apiBase, proxy), nil
+}
+
+func fromOpenAICompatToolCalls(toolCalls []openai_compat.ToolCall) []ToolCall {
+ out := make([]ToolCall, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ var fn *FunctionCall
+ if tc.Function != nil {
+ fn = &FunctionCall{
+ Name: tc.Function.Name,
+ Arguments: tc.Function.Arguments,
+ }
+ }
+ out = append(out, ToolCall{
+ ID: tc.ID,
+ Type: tc.Type,
+ Function: fn,
+ Name: tc.Name,
+ Arguments: tc.Arguments,
+ })
+ }
+ return out
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
new file mode 100644
index 000000000..4aef1389a
--- /dev/null
+++ b/pkg/providers/openai_compat/provider.go
@@ -0,0 +1,230 @@
+package openai_compat
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type ToolCall struct {
+ ID string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]interface{} `json:"arguments,omitempty"`
+}
+
+type FunctionCall struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+type LLMResponse struct {
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ FinishReason string `json:"finish_reason"`
+ Usage *UsageInfo `json:"usage,omitempty"`
+}
+
+type UsageInfo struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+}
+
+type ToolDefinition struct {
+ Type string `json:"type"`
+ Function ToolFunctionDefinition `json:"function"`
+}
+
+type ToolFunctionDefinition struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]interface{} `json:"parameters"`
+}
+
+type Provider struct {
+ apiKey string
+ apiBase string
+ httpClient *http.Client
+}
+
+func NewProvider(apiKey, apiBase string, proxy ...string) *Provider {
+ proxyURL := ""
+ if len(proxy) > 0 {
+ proxyURL = proxy[0]
+ }
+ client := &http.Client{
+ Timeout: 120 * time.Second,
+ }
+
+ if proxyURL != "" {
+ parsed, err := url.Parse(proxyURL)
+ if err == nil {
+ client.Transport = &http.Transport{
+ Proxy: http.ProxyURL(parsed),
+ }
+ }
+ }
+
+ return &Provider{
+ apiKey: apiKey,
+ apiBase: strings.TrimRight(apiBase, "/"),
+ httpClient: client,
+ }
+}
+
+func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ // Strip provider prefix (moonshot/kimi-*, nvidia/*) for OpenAI-compatible backends.
+ if idx := strings.Index(model, "/"); idx != -1 {
+ prefix := model[:idx]
+ if prefix == "moonshot" || prefix == "nvidia" {
+ model = model[idx+1:]
+ }
+ }
+
+ requestBody := map[string]interface{}{
+ "model": model,
+ "messages": messages,
+ }
+
+ if len(tools) > 0 {
+ requestBody["tools"] = tools
+ requestBody["tool_choice"] = "auto"
+ }
+
+ if maxTokens, ok := options["max_tokens"].(int); ok {
+ lowerModel := strings.ToLower(model)
+ if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
+ requestBody["max_completion_tokens"] = maxTokens
+ } else {
+ requestBody["max_tokens"] = maxTokens
+ }
+ }
+
+ if temperature, ok := options["temperature"].(float64); ok {
+ lowerModel := strings.ToLower(model)
+ // Kimi k2 models only support temperature=1.
+ if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
+ requestBody["temperature"] = 1.0
+ } else {
+ requestBody["temperature"] = temperature
+ }
+ }
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ }
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
+ }
+
+ return parseResponse(body)
+}
+
+func parseResponse(body []byte) (*LLMResponse, error) {
+ var apiResponse struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ ToolCalls []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function *struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage *UsageInfo `json:"usage"`
+ }
+
+ if err := json.Unmarshal(body, &apiResponse); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ if len(apiResponse.Choices) == 0 {
+ return &LLMResponse{
+ Content: "",
+ FinishReason: "stop",
+ }, nil
+ }
+
+ choice := apiResponse.Choices[0]
+ toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
+ for _, tc := range choice.Message.ToolCalls {
+ arguments := make(map[string]interface{})
+ name := ""
+
+ if tc.Type == "function" && tc.Function != nil {
+ name = tc.Function.Name
+ if tc.Function.Arguments != "" {
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
+ arguments["raw"] = tc.Function.Arguments
+ }
+ }
+ } else if tc.Function != nil {
+ name = tc.Function.Name
+ if tc.Function.Arguments != "" {
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
+ arguments["raw"] = tc.Function.Arguments
+ }
+ }
+ }
+
+ toolCalls = append(toolCalls, ToolCall{
+ ID: tc.ID,
+ Name: name,
+ Arguments: arguments,
+ })
+ }
+
+ return &LLMResponse{
+ Content: choice.Message.Content,
+ ToolCalls: toolCalls,
+ FinishReason: choice.FinishReason,
+ Usage: apiResponse.Usage,
+ }, nil
+}
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
new file mode 100644
index 000000000..7c5f1c63c
--- /dev/null
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -0,0 +1,149 @@
+package openai_compat
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
+ var requestBody map[string]interface{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/chat/completions" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL)
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234})
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if _, ok := requestBody["max_completion_tokens"]; !ok {
+ t.Fatalf("expected max_completion_tokens in request body")
+ }
+ if _, ok := requestBody["max_tokens"]; ok {
+ t.Fatalf("did not expect max_tokens key for glm model")
+ }
+}
+
+func TestProviderChat_ParsesToolCalls(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{
+ "content": "",
+ "tool_calls": []map[string]interface{}{
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "get_weather",
+ "arguments": "{\"city\":\"SF\"}",
+ },
+ },
+ },
+ },
+ "finish_reason": "tool_calls",
+ },
+ },
+ "usage": map[string]interface{}{
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "total_tokens": 15,
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL)
+ out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if len(out.ToolCalls) != 1 {
+ t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
+ }
+ if out.ToolCalls[0].Name != "get_weather" {
+ t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
+ }
+ if out.ToolCalls[0].Arguments["city"] != "SF" {
+ t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
+ }
+}
+
+func TestProviderChat_HTTPError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL)
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+}
+
+func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
+ var requestBody map[string]interface{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL)
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "moonshot/kimi-k2.5",
+ map[string]interface{}{"temperature": 0.3},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if requestBody["model"] != "kimi-k2.5" {
+ t.Fatalf("model = %v, want kimi-k2.5", requestBody["model"])
+ }
+ if requestBody["temperature"] != 1.0 {
+ t.Fatalf("temperature = %v, want 1.0", requestBody["temperature"])
+ }
+}
From 762565b0d4406aee7fb617d0b5c46d85014ab04e Mon Sep 17 00:00:00 2001
From: Jared Mahotiere
Date: Sun, 15 Feb 2026 08:04:12 -0500
Subject: [PATCH 006/130] refactor(providers): move anthropic logic to protocol
package
---
pkg/providers/anthropic/provider.go | 241 +++++++++++++++++++
pkg/providers/anthropic/provider_test.go | 208 +++++++++++++++++
pkg/providers/claude_provider.go | 281 +++++++++--------------
pkg/providers/claude_provider_test.go | 137 +----------
4 files changed, 565 insertions(+), 302 deletions(-)
create mode 100644 pkg/providers/anthropic/provider.go
create mode 100644 pkg/providers/anthropic/provider_test.go
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
new file mode 100644
index 000000000..ca72f0180
--- /dev/null
+++ b/pkg/providers/anthropic/provider.go
@@ -0,0 +1,241 @@
+package anthropicprovider
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/anthropics/anthropic-sdk-go"
+ "github.com/anthropics/anthropic-sdk-go/option"
+)
+
+type ToolCall struct {
+ ID string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]interface{} `json:"arguments,omitempty"`
+}
+
+type FunctionCall struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+type LLMResponse struct {
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ FinishReason string `json:"finish_reason"`
+ Usage *UsageInfo `json:"usage,omitempty"`
+}
+
+type UsageInfo struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+}
+
+type ToolDefinition struct {
+ Type string `json:"type"`
+ Function ToolFunctionDefinition `json:"function"`
+}
+
+type ToolFunctionDefinition struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]interface{} `json:"parameters"`
+}
+
+type Provider struct {
+ client *anthropic.Client
+ tokenSource func() (string, error)
+}
+
+func NewProvider(token string) *Provider {
+ client := anthropic.NewClient(
+ option.WithAuthToken(token),
+ option.WithBaseURL("https://api.anthropic.com"),
+ )
+ return &Provider{client: &client}
+}
+
+func NewProviderWithClient(client *anthropic.Client) *Provider {
+ return &Provider{client: client}
+}
+
+func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider {
+ p := NewProvider(token)
+ p.tokenSource = tokenSource
+ return p
+}
+
+func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ var opts []option.RequestOption
+ if p.tokenSource != nil {
+ tok, err := p.tokenSource()
+ if err != nil {
+ return nil, fmt.Errorf("refreshing token: %w", err)
+ }
+ opts = append(opts, option.WithAuthToken(tok))
+ }
+
+ params, err := buildParams(messages, tools, model, options)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := p.client.Messages.New(ctx, params, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("claude API call: %w", err)
+ }
+
+ return parseResponse(resp), nil
+}
+
+func (p *Provider) GetDefaultModel() string {
+ return "claude-sonnet-4-5-20250929"
+}
+
+func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
+ var system []anthropic.TextBlockParam
+ var anthropicMessages []anthropic.MessageParam
+
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ system = append(system, anthropic.TextBlockParam{Text: msg.Content})
+ case "user":
+ if msg.ToolCallID != "" {
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
+ )
+ } else {
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
+ )
+ }
+ case "assistant":
+ if len(msg.ToolCalls) > 0 {
+ var blocks []anthropic.ContentBlockParamUnion
+ if msg.Content != "" {
+ blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
+ }
+ for _, tc := range msg.ToolCalls {
+ blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
+ }
+ anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
+ } else {
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
+ )
+ }
+ case "tool":
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
+ )
+ }
+ }
+
+ maxTokens := int64(4096)
+ if mt, ok := options["max_tokens"].(int); ok {
+ maxTokens = int64(mt)
+ }
+
+ params := anthropic.MessageNewParams{
+ Model: anthropic.Model(model),
+ Messages: anthropicMessages,
+ MaxTokens: maxTokens,
+ }
+
+ if len(system) > 0 {
+ params.System = system
+ }
+
+ if temp, ok := options["temperature"].(float64); ok {
+ params.Temperature = anthropic.Float(temp)
+ }
+
+ if len(tools) > 0 {
+ params.Tools = translateTools(tools)
+ }
+
+ return params, nil
+}
+
+func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
+ result := make([]anthropic.ToolUnionParam, 0, len(tools))
+ for _, t := range tools {
+ tool := anthropic.ToolParam{
+ Name: t.Function.Name,
+ InputSchema: anthropic.ToolInputSchemaParam{
+ Properties: t.Function.Parameters["properties"],
+ },
+ }
+ if desc := t.Function.Description; desc != "" {
+ tool.Description = anthropic.String(desc)
+ }
+ if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
+ required := make([]string, 0, len(req))
+ for _, r := range req {
+ if s, ok := r.(string); ok {
+ required = append(required, s)
+ }
+ }
+ tool.InputSchema.Required = required
+ }
+ result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
+ }
+ return result
+}
+
+func parseResponse(resp *anthropic.Message) *LLMResponse {
+ var content string
+ var toolCalls []ToolCall
+
+ for _, block := range resp.Content {
+ switch block.Type {
+ case "text":
+ tb := block.AsText()
+ content += tb.Text
+ case "tool_use":
+ tu := block.AsToolUse()
+ var args map[string]interface{}
+ if err := json.Unmarshal(tu.Input, &args); err != nil {
+ args = map[string]interface{}{"raw": string(tu.Input)}
+ }
+ toolCalls = append(toolCalls, ToolCall{
+ ID: tu.ID,
+ Name: tu.Name,
+ Arguments: args,
+ })
+ }
+ }
+
+ finishReason := "stop"
+ switch resp.StopReason {
+ case anthropic.StopReasonToolUse:
+ finishReason = "tool_calls"
+ case anthropic.StopReasonMaxTokens:
+ finishReason = "length"
+ case anthropic.StopReasonEndTurn:
+ finishReason = "stop"
+ }
+
+ return &LLMResponse{
+ Content: content,
+ ToolCalls: toolCalls,
+ FinishReason: finishReason,
+ Usage: &UsageInfo{
+ PromptTokens: int(resp.Usage.InputTokens),
+ CompletionTokens: int(resp.Usage.OutputTokens),
+ TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
+ },
+ }
+}
diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go
new file mode 100644
index 000000000..01b4fe663
--- /dev/null
+++ b/pkg/providers/anthropic/provider_test.go
@@ -0,0 +1,208 @@
+package anthropicprovider
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/anthropics/anthropic-sdk-go"
+ anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+)
+
+func TestBuildParams_BasicMessage(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "Hello"},
+ }
+ params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
+ "max_tokens": 1024,
+ })
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if string(params.Model) != "claude-sonnet-4-5-20250929" {
+ t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
+ }
+ if params.MaxTokens != 1024 {
+ t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
+ }
+ if len(params.Messages) != 1 {
+ t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
+ }
+}
+
+func TestBuildParams_SystemMessage(t *testing.T) {
+ messages := []Message{
+ {Role: "system", Content: "You are helpful"},
+ {Role: "user", Content: "Hi"},
+ }
+ params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if len(params.System) != 1 {
+ t.Fatalf("len(System) = %d, want 1", len(params.System))
+ }
+ if params.System[0].Text != "You are helpful" {
+ t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
+ }
+ if len(params.Messages) != 1 {
+ t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
+ }
+}
+
+func TestBuildParams_ToolCallMessage(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "What's the weather?"},
+ {
+ Role: "assistant",
+ Content: "",
+ ToolCalls: []ToolCall{
+ {
+ ID: "call_1",
+ Name: "get_weather",
+ Arguments: map[string]interface{}{"city": "SF"},
+ },
+ },
+ },
+ {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
+ }
+ params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if len(params.Messages) != 3 {
+ t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
+ }
+}
+
+func TestBuildParams_WithTools(t *testing.T) {
+ tools := []ToolDefinition{
+ {
+ Type: "function",
+ Function: ToolFunctionDefinition{
+ Name: "get_weather",
+ Description: "Get weather for a city",
+ Parameters: map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "city": map[string]interface{}{"type": "string"},
+ },
+ "required": []interface{}{"city"},
+ },
+ },
+ },
+ }
+ params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if len(params.Tools) != 1 {
+ t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
+ }
+}
+
+func TestParseResponse_TextOnly(t *testing.T) {
+ resp := &anthropic.Message{
+ Content: []anthropic.ContentBlockUnion{},
+ Usage: anthropic.Usage{
+ InputTokens: 10,
+ OutputTokens: 20,
+ },
+ }
+ result := parseResponse(resp)
+ if result.Usage.PromptTokens != 10 {
+ t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
+ }
+ if result.Usage.CompletionTokens != 20 {
+ t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
+ }
+ if result.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
+ }
+}
+
+func TestParseResponse_StopReasons(t *testing.T) {
+ tests := []struct {
+ stopReason anthropic.StopReason
+ want string
+ }{
+ {anthropic.StopReasonEndTurn, "stop"},
+ {anthropic.StopReasonMaxTokens, "length"},
+ {anthropic.StopReasonToolUse, "tool_calls"},
+ }
+ for _, tt := range tests {
+ resp := &anthropic.Message{
+ StopReason: tt.stopReason,
+ }
+ result := parseResponse(resp)
+ if result.FinishReason != tt.want {
+ t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
+ }
+ }
+}
+
+func TestProvider_ChatRoundTrip(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/messages" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer test-token" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var reqBody map[string]interface{}
+ json.NewDecoder(r.Body).Decode(&reqBody)
+
+ resp := map[string]interface{}{
+ "id": "msg_test",
+ "type": "message",
+ "role": "assistant",
+ "model": reqBody["model"],
+ "stop_reason": "end_turn",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": "Hello! How can I help you?"},
+ },
+ "usage": map[string]interface{}{
+ "input_tokens": 15,
+ "output_tokens": 8,
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Hello! How can I help you?" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
+ }
+ if resp.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage.PromptTokens != 15 {
+ t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
+ }
+}
+
+func TestProvider_GetDefaultModel(t *testing.T) {
+ p := NewProvider("test-token")
+ if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
+ t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
+ }
+}
+
+func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
+ c := anthropic.NewClient(
+ anthropicoption.WithAuthToken(token),
+ anthropicoption.WithBaseURL(baseURL),
+ )
+ return &c
+}
diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go
index ae6aca96d..16f1884c5 100644
--- a/pkg/providers/claude_provider.go
+++ b/pkg/providers/claude_provider.go
@@ -2,195 +2,48 @@ package providers
import (
"context"
- "encoding/json"
"fmt"
- "github.com/anthropics/anthropic-sdk-go"
- "github.com/anthropics/anthropic-sdk-go/option"
"github.com/sipeed/picoclaw/pkg/auth"
+ anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
type ClaudeProvider struct {
- client *anthropic.Client
- tokenSource func() (string, error)
+ delegate *anthropicprovider.Provider
}
func NewClaudeProvider(token string) *ClaudeProvider {
- client := anthropic.NewClient(
- option.WithAuthToken(token),
- option.WithBaseURL("https://api.anthropic.com"),
- )
- return &ClaudeProvider{client: &client}
+ return &ClaudeProvider{
+ delegate: anthropicprovider.NewProvider(token),
+ }
}
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
- p := NewClaudeProvider(token)
- p.tokenSource = tokenSource
- return p
+ return &ClaudeProvider{
+ delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource),
+ }
+}
+
+func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
+ return &ClaudeProvider{delegate: delegate}
}
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
- var opts []option.RequestOption
- if p.tokenSource != nil {
- tok, err := p.tokenSource()
- if err != nil {
- return nil, fmt.Errorf("refreshing token: %w", err)
- }
- opts = append(opts, option.WithAuthToken(tok))
- }
-
- params, err := buildClaudeParams(messages, tools, model, options)
+ resp, err := p.delegate.Chat(
+ ctx,
+ toAnthropicProviderMessages(messages),
+ toAnthropicProviderTools(tools),
+ model,
+ options,
+ )
if err != nil {
return nil, err
}
-
- resp, err := p.client.Messages.New(ctx, params, opts...)
- if err != nil {
- return nil, fmt.Errorf("claude API call: %w", err)
- }
-
- return parseClaudeResponse(resp), nil
+ return fromAnthropicProviderResponse(resp), nil
}
func (p *ClaudeProvider) GetDefaultModel() string {
- return "claude-sonnet-4-5-20250929"
-}
-
-func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
- var system []anthropic.TextBlockParam
- var anthropicMessages []anthropic.MessageParam
-
- for _, msg := range messages {
- switch msg.Role {
- case "system":
- system = append(system, anthropic.TextBlockParam{Text: msg.Content})
- case "user":
- if msg.ToolCallID != "" {
- anthropicMessages = append(anthropicMessages,
- anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
- )
- } else {
- anthropicMessages = append(anthropicMessages,
- anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
- )
- }
- case "assistant":
- if len(msg.ToolCalls) > 0 {
- var blocks []anthropic.ContentBlockParamUnion
- if msg.Content != "" {
- blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
- }
- for _, tc := range msg.ToolCalls {
- blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
- }
- anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
- } else {
- anthropicMessages = append(anthropicMessages,
- anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
- )
- }
- case "tool":
- anthropicMessages = append(anthropicMessages,
- anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
- )
- }
- }
-
- maxTokens := int64(4096)
- if mt, ok := options["max_tokens"].(int); ok {
- maxTokens = int64(mt)
- }
-
- params := anthropic.MessageNewParams{
- Model: anthropic.Model(model),
- Messages: anthropicMessages,
- MaxTokens: maxTokens,
- }
-
- if len(system) > 0 {
- params.System = system
- }
-
- if temp, ok := options["temperature"].(float64); ok {
- params.Temperature = anthropic.Float(temp)
- }
-
- if len(tools) > 0 {
- params.Tools = translateToolsForClaude(tools)
- }
-
- return params, nil
-}
-
-func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam {
- result := make([]anthropic.ToolUnionParam, 0, len(tools))
- for _, t := range tools {
- tool := anthropic.ToolParam{
- Name: t.Function.Name,
- InputSchema: anthropic.ToolInputSchemaParam{
- Properties: t.Function.Parameters["properties"],
- },
- }
- if desc := t.Function.Description; desc != "" {
- tool.Description = anthropic.String(desc)
- }
- if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
- required := make([]string, 0, len(req))
- for _, r := range req {
- if s, ok := r.(string); ok {
- required = append(required, s)
- }
- }
- tool.InputSchema.Required = required
- }
- result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
- }
- return result
-}
-
-func parseClaudeResponse(resp *anthropic.Message) *LLMResponse {
- var content string
- var toolCalls []ToolCall
-
- for _, block := range resp.Content {
- switch block.Type {
- case "text":
- tb := block.AsText()
- content += tb.Text
- case "tool_use":
- tu := block.AsToolUse()
- var args map[string]interface{}
- if err := json.Unmarshal(tu.Input, &args); err != nil {
- args = map[string]interface{}{"raw": string(tu.Input)}
- }
- toolCalls = append(toolCalls, ToolCall{
- ID: tu.ID,
- Name: tu.Name,
- Arguments: args,
- })
- }
- }
-
- finishReason := "stop"
- switch resp.StopReason {
- case anthropic.StopReasonToolUse:
- finishReason = "tool_calls"
- case anthropic.StopReasonMaxTokens:
- finishReason = "length"
- case anthropic.StopReasonEndTurn:
- finishReason = "stop"
- }
-
- return &LLMResponse{
- Content: content,
- ToolCalls: toolCalls,
- FinishReason: finishReason,
- Usage: &UsageInfo{
- PromptTokens: int(resp.Usage.InputTokens),
- CompletionTokens: int(resp.Usage.OutputTokens),
- TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
- },
- }
+ return p.delegate.GetDefaultModel()
}
func createClaudeTokenSource() func() (string, error) {
@@ -205,3 +58,95 @@ func createClaudeTokenSource() func() (string, error) {
return cred.AccessToken, nil
}
}
+
+func toAnthropicProviderMessages(messages []Message) []anthropicprovider.Message {
+ out := make([]anthropicprovider.Message, 0, len(messages))
+ for _, msg := range messages {
+ out = append(out, anthropicprovider.Message{
+ Role: msg.Role,
+ Content: msg.Content,
+ ToolCalls: toAnthropicProviderToolCalls(msg.ToolCalls),
+ ToolCallID: msg.ToolCallID,
+ })
+ }
+ return out
+}
+
+func toAnthropicProviderTools(tools []ToolDefinition) []anthropicprovider.ToolDefinition {
+ out := make([]anthropicprovider.ToolDefinition, 0, len(tools))
+ for _, t := range tools {
+ out = append(out, anthropicprovider.ToolDefinition{
+ Type: t.Type,
+ Function: anthropicprovider.ToolFunctionDefinition{
+ Name: t.Function.Name,
+ Description: t.Function.Description,
+ Parameters: t.Function.Parameters,
+ },
+ })
+ }
+ return out
+}
+
+func toAnthropicProviderToolCalls(toolCalls []ToolCall) []anthropicprovider.ToolCall {
+ out := make([]anthropicprovider.ToolCall, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ var fn *anthropicprovider.FunctionCall
+ if tc.Function != nil {
+ fn = &anthropicprovider.FunctionCall{
+ Name: tc.Function.Name,
+ Arguments: tc.Function.Arguments,
+ }
+ }
+ out = append(out, anthropicprovider.ToolCall{
+ ID: tc.ID,
+ Type: tc.Type,
+ Function: fn,
+ Name: tc.Name,
+ Arguments: tc.Arguments,
+ })
+ }
+ return out
+}
+
+func fromAnthropicProviderResponse(resp *anthropicprovider.LLMResponse) *LLMResponse {
+ if resp == nil {
+ return &LLMResponse{}
+ }
+
+ var usage *UsageInfo
+ if resp.Usage != nil {
+ usage = &UsageInfo{
+ PromptTokens: resp.Usage.PromptTokens,
+ CompletionTokens: resp.Usage.CompletionTokens,
+ TotalTokens: resp.Usage.TotalTokens,
+ }
+ }
+
+ return &LLMResponse{
+ Content: resp.Content,
+ ToolCalls: fromAnthropicProviderToolCalls(resp.ToolCalls),
+ FinishReason: resp.FinishReason,
+ Usage: usage,
+ }
+}
+
+func fromAnthropicProviderToolCalls(toolCalls []anthropicprovider.ToolCall) []ToolCall {
+ out := make([]ToolCall, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ var fn *FunctionCall
+ if tc.Function != nil {
+ fn = &FunctionCall{
+ Name: tc.Function.Name,
+ Arguments: tc.Function.Arguments,
+ }
+ }
+ out = append(out, ToolCall{
+ ID: tc.ID,
+ Type: tc.Type,
+ Function: fn,
+ Name: tc.Name,
+ Arguments: tc.Arguments,
+ })
+ }
+ return out
+}
diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go
index bbad2d269..13bbde1fc 100644
--- a/pkg/providers/claude_provider_test.go
+++ b/pkg/providers/claude_provider_test.go
@@ -8,140 +8,9 @@ import (
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+ anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
-func TestBuildClaudeParams_BasicMessage(t *testing.T) {
- messages := []Message{
- {Role: "user", Content: "Hello"},
- }
- params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
- "max_tokens": 1024,
- })
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if string(params.Model) != "claude-sonnet-4-5-20250929" {
- t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
- }
- if params.MaxTokens != 1024 {
- t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
- }
- if len(params.Messages) != 1 {
- t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
- }
-}
-
-func TestBuildClaudeParams_SystemMessage(t *testing.T) {
- messages := []Message{
- {Role: "system", Content: "You are helpful"},
- {Role: "user", Content: "Hi"},
- }
- params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if len(params.System) != 1 {
- t.Fatalf("len(System) = %d, want 1", len(params.System))
- }
- if params.System[0].Text != "You are helpful" {
- t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
- }
- if len(params.Messages) != 1 {
- t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
- }
-}
-
-func TestBuildClaudeParams_ToolCallMessage(t *testing.T) {
- messages := []Message{
- {Role: "user", Content: "What's the weather?"},
- {
- Role: "assistant",
- Content: "",
- ToolCalls: []ToolCall{
- {
- ID: "call_1",
- Name: "get_weather",
- Arguments: map[string]interface{}{"city": "SF"},
- },
- },
- },
- {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
- }
- params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if len(params.Messages) != 3 {
- t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
- }
-}
-
-func TestBuildClaudeParams_WithTools(t *testing.T) {
- tools := []ToolDefinition{
- {
- Type: "function",
- Function: ToolFunctionDefinition{
- Name: "get_weather",
- Description: "Get weather for a city",
- Parameters: map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
- },
- "required": []interface{}{"city"},
- },
- },
- },
- }
- params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if len(params.Tools) != 1 {
- t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
- }
-}
-
-func TestParseClaudeResponse_TextOnly(t *testing.T) {
- resp := &anthropic.Message{
- Content: []anthropic.ContentBlockUnion{},
- Usage: anthropic.Usage{
- InputTokens: 10,
- OutputTokens: 20,
- },
- }
- result := parseClaudeResponse(resp)
- if result.Usage.PromptTokens != 10 {
- t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
- }
- if result.Usage.CompletionTokens != 20 {
- t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
- }
- if result.FinishReason != "stop" {
- t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
- }
-}
-
-func TestParseClaudeResponse_StopReasons(t *testing.T) {
- tests := []struct {
- stopReason anthropic.StopReason
- want string
- }{
- {anthropic.StopReasonEndTurn, "stop"},
- {anthropic.StopReasonMaxTokens, "length"},
- {anthropic.StopReasonToolUse, "tool_calls"},
- }
- for _, tt := range tests {
- resp := &anthropic.Message{
- StopReason: tt.stopReason,
- }
- result := parseClaudeResponse(resp)
- if result.FinishReason != tt.want {
- t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
- }
- }
-}
-
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" {
@@ -175,8 +44,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
}))
defer server.Close()
- provider := NewClaudeProvider("test-token")
- provider.client = createAnthropicTestClient(server.URL, "test-token")
+ delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
+ provider := newClaudeProviderWithDelegate(delegate)
messages := []Message{{Role: "user", Content: "Hello"}}
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
From 362c49a69d0465b711153e1ab14eeaaeb779eee6 Mon Sep 17 00:00:00 2001
From: Jared Mahotiere
Date: Sun, 15 Feb 2026 08:04:16 -0500
Subject: [PATCH 007/130] docs(test): document protocol architecture and
migration compatibility
---
README.md | 10 ++++++++++
pkg/migrate/migrate_test.go | 18 ++++++++++++++++++
2 files changed, 28 insertions(+)
diff --git a/README.md b/README.md
index 091af2811..25c6d9863 100644
--- a/README.md
+++ b/README.md
@@ -662,6 +662,16 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+### Provider Architecture
+
+PicoClaw routes providers by protocol family:
+
+- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
+- Anthropic protocol: Claude-native API behavior.
+- Codex/OAuth path: OpenAI OAuth/token authentication route.
+
+This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
+
Zhipu
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index be2360aac..e930d45f4 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -299,6 +299,24 @@ func TestConvertConfig(t *testing.T) {
})
}
+func TestSupportedProvidersCompatibility(t *testing.T) {
+ expected := []string{
+ "anthropic",
+ "openai",
+ "openrouter",
+ "groq",
+ "zhipu",
+ "vllm",
+ "gemini",
+ }
+
+ for _, provider := range expected {
+ if !supportedProviders[provider] {
+ t.Fatalf("supportedProviders missing expected key %q", provider)
+ }
+ }
+}
+
func TestMergeConfig(t *testing.T) {
t.Run("fills empty fields", func(t *testing.T) {
existing := config.DefaultConfig()
From 97bf4ff3fddd99c5a6a1d9a74a4e7637f34d7063 Mon Sep 17 00:00:00 2001
From: Yasuhiro Matsumoto
Date: Sun, 15 Feb 2026 23:56:13 +0900
Subject: [PATCH 008/130] Fix Japanese translation
---
README.ja.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index e33b312f9..706af2c75 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -3,7 +3,7 @@
PicoClaw: Go で書かれた超効率 AI アシスタント
-$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!
+$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!
@@ -39,7 +39,7 @@
## 📢 ニュース
-2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走!
+2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
## ✨ 特徴
@@ -729,7 +729,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
## 🐛 トラブルシューティング
-### Web 検索で「API 配置问题」と表示される
+### Web 検索で「API 設定の問題」と表示される
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
From 7ce5b75178356d4c81044faa6d2ea06cd69ec507 Mon Sep 17 00:00:00 2001
From: Yasuhiro Matsumoto
Date: Mon, 16 Feb 2026 00:47:17 +0900
Subject: [PATCH 009/130] Fix shadowing field runnnig
---
pkg/channels/maixcam.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go
index 5fc19adbe..01e570b25 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -18,7 +18,6 @@ type MaixCamChannel struct {
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
- running bool
}
type MaixCamMessage struct {
@@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
- running: false,
}, nil
}
From 35670d5a583737215bf165a445a4b5f4f1fb4cc3 Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Mon, 16 Feb 2026 13:45:36 +0200
Subject: [PATCH 010/130] feat(linters): Added golangci-lint config & CI job
---
.github/workflows/build.yml | 4 +-
.github/workflows/docker-build.yml | 2 +-
.github/workflows/pr.yml | 40 +++++++--
.github/workflows/release.yml | 6 +-
.golangci.yaml | 133 +++++++++++++++++++++++++++++
5 files changed, 172 insertions(+), 13 deletions(-)
create mode 100644 .golangci.yaml
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0f075b0bb..499613625 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 2d1aa9ffc..dadbed212 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -25,7 +25,7 @@ jobs:
steps:
# ── Checkout ──────────────────────────────
- name: 📥 Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index fac7597ea..4d7ac74ba 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -4,14 +4,40 @@ on:
pull_request:
jobs:
+ lint:
+ name: Linter
+ runs-on: ubuntu-latest
+ # TODO: Remove continue-on-error once linter issues are fixed
+ continue-on-error: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+
+ - name: Gofmt check
+ run: diff -u <(echo -n) <(gofmt -d .)
+
+ - name: Run go generate
+ run: go generate ./...
+
+ - name: Golangci Lint
+ uses: golangci/golangci-lint-action@v9
+ with:
+ version: latest
+
+ # TODO: Remove once linter job is required
fmt-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -20,15 +46,16 @@ jobs:
make fmt
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
+ # TODO: Remove once linter job is required
vet:
runs-on: ubuntu-latest
needs: fmt-check
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -43,10 +70,10 @@ jobs:
needs: fmt-check
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -55,4 +82,3 @@ jobs:
- name: Run go test
run: go test ./...
-
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f9987b35f..06ee55a7d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,7 +26,7 @@ jobs:
contents: write
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -47,13 +47,13 @@ jobs:
packages: write
steps:
- name: Checkout tag
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.tag }}
- name: Setup Go from go.mod
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 000000000..4d8435fff
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,133 @@
+version: "2"
+
+linters:
+ default: all
+ disable:
+ # TODO: Tweak for current project needs
+ - containedctx
+ - cyclop
+ - depguard
+ - dupl
+ - dupword
+ - err113
+ - exhaustruct
+ - funcorder
+ - gochecknoglobals
+ - godot
+ - intrange
+ - ireturn
+ - nlreturn
+ - noctx
+ - noinlineerr
+ - nonamedreturns
+ - tagliatelle
+ - testpackage
+ - varnamelen
+ - wrapcheck
+ - wsl
+ - wsl_v5
+ settings:
+ errcheck:
+ check-type-assertions: true
+ check-blank: true
+ exhaustive:
+ default-signifies-exhaustive: true
+ funlen:
+ lines: 120
+ statements: 40
+ gocognit:
+ min-complexity: 25
+ gocyclo:
+ min-complexity: 20
+ govet:
+ enable-all: true
+ disable:
+ - fieldalignment
+ lll:
+ line-length: 120
+ tab-width: 4
+ misspell:
+ locale: US
+ mnd:
+ checks:
+ - argument
+ - assign
+ - case
+ - condition
+ - operation
+ - return
+ nakedret:
+ max-func-lines: 3
+ revive:
+ enable-all-rules: true
+ rules:
+ - name: add-constant
+ disabled: true
+ - name: argument-limit
+ arguments:
+ - 7
+ severity: warning
+ - name: banned-characters
+ disabled: true
+ - name: cognitive-complexity
+ disabled: true
+ - name: comment-spacings
+ arguments:
+ - nolint
+ severity: warning
+ - name: cyclomatic
+ disabled: true
+ - name: file-header
+ disabled: true
+ - name: function-result-limit
+ arguments:
+ - 3
+ severity: warning
+ - name: function-length
+ disabled: true
+ - name: line-length-limit
+ disabled: true
+ - name: max-public-structs
+ disabled: true
+ - name: modifies-value-receiver
+ disabled: true
+ - name: package-comments
+ disabled: true
+ - name: unused-receiver
+ disabled: true
+ exclusions:
+ generated: lax
+ rules:
+ - linters:
+ - lll
+ source: '^//go:generate '
+ - linters:
+ - funlen
+ - maintidx
+ - gocognit
+ - gocyclo
+ path: _test\.go$
+
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+
+formatters:
+ enable:
+ - gci
+ - gofmt
+ - gofumpt
+ - goimports
+ settings:
+ gci:
+ sections:
+ - standard
+ - default
+ - localmodule
+ custom-order: true
+ gofmt:
+ rewrite-rules:
+ - pattern: "interface{}"
+ replacement: "any"
+ - pattern: "a[b:len(a)]"
+ replacement: "a[b:]"
From d69ef653df4f23dd42cff04724c3b54c7add1b6e Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Mon, 16 Feb 2026 13:51:10 +0200
Subject: [PATCH 011/130] feat(linters): Added job names
---
.github/workflows/pr.yml | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 4d7ac74ba..e1a2397d1 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -1,7 +1,7 @@
-name: pr-check
+name: PR
on:
- pull_request:
+ pull_request: { }
jobs:
lint:
@@ -31,6 +31,7 @@ jobs:
# TODO: Remove once linter job is required
fmt-check:
+ name: Formatting
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -48,6 +49,7 @@ jobs:
# TODO: Remove once linter job is required
vet:
+ name: Vet
runs-on: ubuntu-latest
needs: fmt-check
steps:
@@ -66,6 +68,7 @@ jobs:
run: go vet ./...
test:
+ name: Tests
runs-on: ubuntu-latest
needs: fmt-check
steps:
From d9b5f64777416502a67e1d556de10d241d77d38b Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Mon, 16 Feb 2026 17:13:35 +0200
Subject: [PATCH 012/130] feat(linters): Temporarily disable most linters
---
.github/workflows/pr.yml | 6 ++---
.golangci.yaml | 57 +++++++++++++++++++++++++++++++++++++---
2 files changed, 56 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index e1a2397d1..1394aa053 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -7,8 +7,6 @@ jobs:
lint:
name: Linter
runs-on: ubuntu-latest
- # TODO: Remove continue-on-error once linter issues are fixed
- continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -29,7 +27,7 @@ jobs:
with:
version: latest
- # TODO: Remove once linter job is required
+ # TODO: Remove once linter is properly configured
fmt-check:
name: Formatting
runs-on: ubuntu-latest
@@ -47,7 +45,7 @@ jobs:
make fmt
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
- # TODO: Remove once linter job is required
+ # TODO: Remove once linter is properly configured
vet:
name: Vet
runs-on: ubuntu-latest
diff --git a/.golangci.yaml b/.golangci.yaml
index 4d8435fff..80e54ac1c 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -26,6 +26,52 @@ linters:
- wrapcheck
- wsl
- wsl_v5
+
+ # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
+ - bodyclose
+ - contextcheck
+ - dogsled
+ - embeddedstructfieldcheck
+ - errcheck
+ - errchkjson
+ - errorlint
+ - exhaustive
+ - forbidigo
+ - forcetypeassert
+ - funlen
+ - gochecknoinits
+ - gocognit
+ - goconst
+ - gocritic
+ - gocyclo
+ - godox
+ - goprintffuncname
+ - gosec
+ - govet
+ - ineffassign
+ - lll
+ - maintidx
+ - misspell
+ - mnd
+ - modernize
+ - nakedret
+ - nestif
+ - nilnil
+ - paralleltest
+ - perfsprint
+ - prealloc
+ - predeclared
+ - revive
+ - staticcheck
+ - tagalign
+ - testifylint
+ - thelper
+ - unparam
+ - unused
+ - usestdlibvars
+ - usetesting
+ - wastedassign
+ - whitespace
settings:
errcheck:
check-type-assertions: true
@@ -114,10 +160,12 @@ issues:
formatters:
enable:
- - gci
- - gofmt
- - gofumpt
- goimports
+ # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
+ # - gci
+ # - gofmt
+ # - gofumpt
+ # - golines
settings:
gci:
sections:
@@ -126,8 +174,11 @@ formatters:
- localmodule
custom-order: true
gofmt:
+ simplify: true
rewrite-rules:
- pattern: "interface{}"
replacement: "any"
- pattern: "a[b:len(a)]"
replacement: "a[b:]"
+ golines:
+ max-len: 120
From 67d07109a99411ba4a791a287bb3d143fb6f1a0d Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Mon, 16 Feb 2026 17:15:02 +0200
Subject: [PATCH 013/130] feat(linters): Removed fmt check (present in linters)
---
.github/workflows/pr.yml | 3 ---
1 file changed, 3 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 1394aa053..df267aae8 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -16,9 +16,6 @@ jobs:
with:
go-version-file: go.mod
- - name: Gofmt check
- run: diff -u <(echo -n) <(gofmt -d .)
-
- name: Run go generate
run: go generate ./...
From ff3c875b3fad1116a7ea7e22a10034a741b38f18 Mon Sep 17 00:00:00 2001
From: Humaid Koreshi
Date: Tue, 17 Feb 2026 02:15:59 +0600
Subject: [PATCH 014/130] docs: add missing Chinese language link to Japanese
README
---
README.ja.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.ja.md b/README.ja.md
index e33b312f9..c6babf510 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -12,7 +12,7 @@
-**日本語** | [English](README.md)
+[中文](README.zh.md) | **日本語** | [English](README.md)
From 852d361eb0d54c042228a9c56ff9a20e0d59f9f8 Mon Sep 17 00:00:00 2001
From: Yasuhiro Matsumoto
Date: Tue, 17 Feb 2026 08:23:44 +0900
Subject: [PATCH 015/130] Add new provider cerebras
---
.env.example | 1 +
README.ja.md | 17 +++++++++++++++++
README.md | 2 ++
README.zh.md | 4 +++-
config/config.example.json | 4 ++++
pkg/config/config.go | 5 +++++
pkg/providers/http_provider.go | 18 +++++++++++++++++-
7 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/.env.example b/.env.example
index 66539b634..06d43070c 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
+# CEREBRAS_API_KEY=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
diff --git a/README.ja.md b/README.ja.md
index e33b312f9..a8aa993c3 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -618,6 +618,22 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
+### プロバイダー
+
+> [!NOTE]
+> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
+
+| プロバイダー | 用途 | API キー取得先 |
+| --- | --- | --- |
+| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
+| `openrouter`(未テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic`(未テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai`(未テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek`(未テスト) | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
+
### 基本設定
1. **設定ファイルの作成:**
@@ -767,3 +783,4 @@ Web 検索を有効にするには:
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
+| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
diff --git a/README.md b/README.md
index 0a9dacce6..d46b62641 100644
--- a/README.md
+++ b/README.md
@@ -664,6 +664,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
Zhipu
@@ -856,3 +857,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
| **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | 2000 queries/month | Web search functionality |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
+| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
diff --git a/README.zh.md b/README.zh.md
index 2ca2987bb..7f6ea8eeb 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -535,6 +535,7 @@ Agent 读取 HEARTBEAT.md
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
智谱 (Zhipu) 配置示例
@@ -718,4 +719,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
-| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
\ No newline at end of file
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
+| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
\ No newline at end of file
diff --git a/config/config.example.json b/config/config.example.json
index 3c9158e9c..96a31bbd2 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -111,6 +111,10 @@
"ollama": {
"api_key": "",
"api_base": "http://localhost:11434/v1"
+ },
+ "cerebras": {
+ "api_key": "",
+ "api_base": ""
}
},
"tools": {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index d189ff00b..cfc40e6e3 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -179,6 +179,7 @@ type ProvidersConfig struct {
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
+ Cerebras ProviderConfig `json:"cerebras"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
}
@@ -305,6 +306,7 @@ func DefaultConfig() *Config {
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
+ Cerebras: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
@@ -406,6 +408,9 @@ func (c *Config) GetAPIKey() string {
if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey
}
+ if c.Providers.Cerebras.APIKey != "" {
+ return c.Providers.Cerebras.APIKey
+ }
return ""
}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 4cf2c6db2..00d4d6fa7 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -56,7 +56,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b)
if idx := strings.Index(model, "/"); idx != -1 {
prefix := model[:idx]
- if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" {
+ if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" || prefix == "cerebras" {
model = model[idx+1:]
}
}
@@ -313,6 +313,14 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
workspace = "."
}
return NewCodexCliProvider(workspace), nil
+ case "cerebras":
+ if cfg.Providers.Cerebras.APIKey != "" {
+ apiKey = cfg.Providers.Cerebras.APIKey
+ apiBase = cfg.Providers.Cerebras.APIBase
+ if apiBase == "" {
+ apiBase = "https://api.cerebras.ai/v1"
+ }
+ }
case "deepseek":
if cfg.Providers.DeepSeek.APIKey != "" {
apiKey = cfg.Providers.DeepSeek.APIKey
@@ -409,6 +417,14 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
if apiBase == "" {
apiBase = "https://integrate.api.nvidia.com/v1"
}
+ case (strings.Contains(lowerModel, "cerebras") || strings.HasPrefix(model, "cerebras/")) && cfg.Providers.Cerebras.APIKey != "":
+ apiKey = cfg.Providers.Cerebras.APIKey
+ apiBase = cfg.Providers.Cerebras.APIBase
+ proxy = cfg.Providers.Cerebras.Proxy
+ if apiBase == "" {
+ apiBase = "https://api.cerebras.ai/v1"
+ }
+
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
fmt.Println("Ollama provider selected based on model name prefix")
apiKey = cfg.Providers.Ollama.APIKey
From 5772b9241bd767afee67527a8506c3503785eef5 Mon Sep 17 00:00:00 2001
From: Yasuhiro Matsumoto
Date: Tue, 17 Feb 2026 08:25:21 +0900
Subject: [PATCH 016/130] Better nuance
---
README.ja.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index a8aa993c3..355eef7de 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -627,10 +627,10 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| --- | --- | --- |
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
-| `openrouter`(未テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic`(未テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai`(未テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek`(未テスト) | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek`(要テスト) | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
From 75fb728a1161a6a92e17f3470f9b28508c0daada Mon Sep 17 00:00:00 2001
From: AlbertBui010
Date: Tue, 17 Feb 2026 09:17:03 +0700
Subject: [PATCH 017/130] docs: add Vietnamese README (README.vi.md)
- Add full Vietnamese translation of README.md
- Update language selector links in README.md, README.zh.md, README.ja.md
---
README.ja.md | 2 +-
README.md | 2 +-
README.vi.md | 859 +++++++++++++++++++++++++++++++++++++++++++++++++++
README.zh.md | 2 +-
4 files changed, 862 insertions(+), 3 deletions(-)
create mode 100644 README.vi.md
diff --git a/README.ja.md b/README.ja.md
index e33b312f9..fa4eae69a 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -12,7 +12,7 @@
-**日本語** | [English](README.md)
+**日本語** | [Tiếng Việt](README.vi.md) | [English](README.md)
diff --git a/README.md b/README.md
index 0a9dacce6..6ec28a315 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
- [中文](README.zh.md) | [日本語](README.ja.md) | **English**
+ [中文](README.zh.md) | [日本語](README.ja.md) | [Tiếng Việt](README.vi.md) | **English**
---
diff --git a/README.vi.md b/README.vi.md
new file mode 100644
index 000000000..533ef7607
--- /dev/null
+++ b/README.vi.md
@@ -0,0 +1,859 @@
+
+

+
+
PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go
+
+
Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+ [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Tiếng Việt**
+
+
+---
+
+🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
+
+⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
+
+
+
+|
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**
+>
+> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.
+> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**.
+> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi.
+> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.
+> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.
+
+
+## 📢 Tin tức
+
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](doc/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
+
+2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
+🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
+
+2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
+
+## ✨ Tính năng nổi bật
+
+🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi).
+
+💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
+
+⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
+
+🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
+
+🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Ngôn ngữ** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Thời gian khởi động**(CPU 0.8GHz) | >500s | >30s | **<1s** |
+| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux****Chỉ từ $10** |
+
+
+
+## 🦾 Demo
+
+### 🛠️ Quy trình trợ lý tiêu chuẩn
+
+
+
+🧩 Lập trình Full-Stack |
+🗂️ Quản lý Nhật ký & Kế hoạch |
+🔎 Tìm kiếm Web & Học hỏi |
+
+
+
|
+
|
+
|
+
+
+| Phát triển • Triển khai • Mở rộng |
+Lên lịch • Tự động hóa • Ghi nhớ |
+Khám phá • Phân tích • Xu hướng |
+
+
+
+### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu
+
+PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
+
+* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E (Ethernet) hoặc W (WiFi6), dùng làm Trợ lý Gia đình tối giản.
+* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), dùng cho quản trị Server tự động.
+* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), dùng cho Giám sát thông minh.
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!
+
+## 📦 Cài đặt
+
+### Cài đặt bằng binary biên dịch sẵn
+
+Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases).
+
+### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build (không cần cài đặt)
+make build
+
+# Build cho nhiều nền tảng
+make build-all
+
+# Build và cài đặt
+make install
+```
+
+## 🐳 Docker Compose
+
+Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy.
+
+```bash
+# 1. Clone repo
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Thiết lập API Key
+cp config/config.example.json config/config.json
+vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
+
+# 3. Build & Khởi động
+docker compose --profile gateway up -d
+
+# 4. Xem logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Dừng
+docker compose --profile gateway down
+```
+
+### Chế độ Agent (chạy một lần)
+
+```bash
+# Đặt câu hỏi
+docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
+
+# Chế độ tương tác
+docker compose run --rm picoclaw-agent
+```
+
+### Build lại
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 Bắt đầu nhanh
+
+> [!TIP]
+> Thiết lập API key trong `~/.picoclaw/config.json`.
+> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
+
+**1. Khởi tạo**
+
+```bash
+picoclaw onboard
+```
+
+**2. Cấu hình** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Lấy API Key**
+
+* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng)
+
+> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ.
+
+**4. Trò chuyện**
+
+```bash
+picoclaw agent -m "Xin chào, bạn là ai?"
+```
+
+Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút.
+
+---
+
+## 💬 Tích hợp ứng dụng Chat
+
+Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
+
+| Kênh | Mức độ thiết lập |
+| --- | --- |
+| **Telegram** | Dễ (chỉ cần token) |
+| **Discord** | Dễ (bot token + intents) |
+| **QQ** | Dễ (AppID + AppSecret) |
+| **DingTalk** | Trung bình (app credentials) |
+| **LINE** | Trung bình (credentials + webhook URL) |
+
+
+Telegram (Khuyên dùng)
+
+**1. Tạo bot**
+
+* Mở Telegram, tìm `@BotFather`
+* Gửi `/newbot`, làm theo hướng dẫn
+* Sao chép token
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Lấy User ID từ `@userinfobot` trên Telegram.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Tạo bot**
+
+* Truy cập
+* Create an application → Bot → Add Bot
+* Sao chép bot token
+
+**2. Bật Intents**
+
+* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT**
+* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên
+
+**3. Lấy User ID**
+
+* Discord Settings → Advanced → bật **Developer Mode**
+* Click chuột phải vào avatar → **Copy User ID**
+
+**4. Cấu hình**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Mời bot vào server**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Mở URL mời được tạo và thêm bot vào server của bạn
+
+**6. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Tạo bot**
+
+* Truy cập [QQ Open Platform](https://q.qq.com/#)
+* Tạo ứng dụng → Lấy **AppID** và **AppSecret**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Tạo bot**
+
+* Truy cập [Open Platform](https://open.dingtalk.com/)
+* Tạo ứng dụng nội bộ
+* Sao chép Client ID và Client Secret
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Tạo tài khoản LINE Official**
+
+- Truy cập [LINE Developers Console](https://developers.line.biz/)
+- Tạo provider → Tạo Messaging API channel
+- Sao chép **Channel Secret** và **Channel Access Token**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Thiết lập Webhook URL**
+
+LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
+
+```bash
+# Ví dụ với ngrok
+ngrok http 18791
+```
+
+Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
+
+**4. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
+
+> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
+
+
+
+##
Tham gia Mạng xã hội Agent
+
+Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
+
+**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Cấu hình chi tiết
+
+File cấu hình: `~/.picoclaw/config.json`
+
+### Cấu trúc Workspace
+
+PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Phiên hội thoại và lịch sử
+├── memory/ # Bộ nhớ dài hạn (MEMORY.md)
+├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.)
+├── cron/ # Cơ sở dữ liệu tác vụ định kỳ
+├── skills/ # Kỹ năng tùy chỉnh
+├── AGENTS.md # Hướng dẫn hành vi Agent
+├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
+├── IDENTITY.md # Danh tính Agent
+├── SOUL.md # Tâm hồn/Tính cách Agent
+├── TOOLS.md # Mô tả công cụ
+└── USER.md # Tùy chọn người dùng
+```
+
+### 🔒 Hộp cát bảo mật (Security Sandbox)
+
+PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace.
+
+#### Cấu hình mặc định
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+|----------|---------|-------|
+| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent |
+| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace |
+
+#### Công cụ được bảo vệ
+
+Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox:
+
+| Công cụ | Chức năng | Giới hạn |
+|---------|----------|---------|
+| `read_file` | Đọc file | Chỉ file trong workspace |
+| `write_file` | Ghi file | Chỉ file trong workspace |
+| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace |
+| `edit_file` | Sửa file | Chỉ file trong workspace |
+| `append_file` | Thêm vào file | Chỉ file trong workspace |
+| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace |
+
+#### Bảo vệ bổ sung cho Exec
+
+Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau:
+
+* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt
+* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa
+* `dd if=` — Tạo ảnh đĩa
+* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa
+* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống
+* Fork bomb `:(){ :|:& };:`
+
+#### Ví dụ lỗi
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Tắt giới hạn (Rủi ro bảo mật)
+
+Nếu bạn cần agent truy cập đường dẫn ngoài workspace:
+
+**Cách 1: File cấu hình**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Cách 2: Biến môi trường**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát.
+
+#### Tính nhất quán của ranh giới bảo mật
+
+Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi:
+
+| Đường thực thi | Ranh giới bảo mật |
+|----------------|-------------------|
+| Agent chính | `restrict_to_workspace` ✅ |
+| Subagent / Spawn | Kế thừa cùng giới hạn ✅ |
+| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ |
+
+Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ.
+
+### Heartbeat (Tác vụ định kỳ)
+
+PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace:
+
+```markdown
+# Tác vụ định kỳ
+
+- Kiểm tra email xem có tin nhắn quan trọng không
+- Xem lại lịch cho các sự kiện sắp tới
+- Kiểm tra dự báo thời tiết
+```
+
+Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn.
+
+#### Tác vụ bất đồng bộ với Spawn
+
+Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**:
+
+```markdown
+# Tác vụ định kỳ
+
+## Tác vụ nhanh (trả lời trực tiếp)
+- Báo cáo thời gian hiện tại
+
+## Tác vụ lâu (dùng spawn cho async)
+- Tìm kiếm tin tức AI trên web và tóm tắt
+- Kiểm tra email và báo cáo tin nhắn quan trọng
+```
+
+**Hành vi chính:**
+
+| Tính năng | Mô tả |
+|-----------|-------|
+| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat |
+| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên |
+| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message |
+| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
+
+#### Cách Subagent giao tiếp
+
+```
+Heartbeat kích hoạt
+ ↓
+Agent đọc HEARTBEAT.md
+ ↓
+Tác vụ lâu: spawn subagent
+ ↓ ↓
+Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập
+ ↓ ↓
+Tất cả tác vụ hoàn thành Subagent dùng công cụ "message"
+ ↓ ↓
+Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
+```
+
+Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính.
+
+**Cấu hình:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+|----------|---------|-------|
+| `enabled` | `true` | Bật/tắt heartbeat |
+| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) |
+
+**Biến môi trường:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian
+
+### Nhà cung cấp (Providers)
+
+> [!NOTE]
+> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
+
+| Nhà cung cấp | Mục đích | Lấy API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Cấu hình Zhipu
+
+**1. Lấy API key**
+
+* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Cấu hình**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Your API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Chạy**
+
+```bash
+picoclaw agent -m "Xin chào"
+```
+
+
+
+
+Ví dụ cấu hình đầy đủ
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+## Tham chiếu CLI
+
+| Lệnh | Mô tả |
+| --- | --- |
+| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
+| `picoclaw agent -m "..."` | Trò chuyện với agent |
+| `picoclaw agent` | Chế độ chat tương tác |
+| `picoclaw gateway` | Khởi động gateway (cho bot chat) |
+| `picoclaw status` | Hiển thị trạng thái |
+| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
+| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
+
+### Tác vụ định kỳ / Nhắc nhở
+
+PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
+
+* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút
+* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ
+* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron
+
+Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động.
+
+## 🤝 Đóng góp & Lộ trình
+
+Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗
+
+Lộ trình sắp được công bố...
+
+Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge.
+
+Nhóm người dùng:
+
+Discord:
+
+
+
+## 🐛 Xử lý sự cố
+
+### Tìm kiếm web hiện "API 配置问题"
+
+Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công.
+
+Để bật tìm kiếm web:
+
+1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất.
+2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key).
+
+Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Gặp lỗi lọc nội dung (Content Filtering)
+
+Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác.
+
+### Telegram bot báo "Conflict: terminated by other getUpdates"
+
+Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm.
+
+---
+
+## 📝 So sánh API Key
+
+| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
+| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
+| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
+| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
diff --git a/README.zh.md b/README.zh.md
index 2ca2987bb..ceddb170c 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,7 @@
- **中文** | [日本語](README.ja.md) | [English](README.md)
+ **中文** | [日本語](README.ja.md) | [Tiếng Việt](README.vi.md) | [English](README.md)
---
From a961a2df878342af8522aead61361534663fc73f Mon Sep 17 00:00:00 2001
From: Guoguo <16666742+imguoguo@users.noreply.github.com>
Date: Tue, 17 Feb 2026 14:32:51 +0800
Subject: [PATCH 018/130] fix(ci): use env var for release tag (#342)
Signed-off-by: Guoguo
---
.github/workflows/release.yml | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f9987b35f..9fe3a684e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,11 +32,13 @@ jobs:
- name: Create and push tag
shell: bash
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}"
- git push origin "${{ inputs.tag }}"
+ git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
+ git push origin "$RELEASE_TAG"
release:
name: GoReleaser Release
From 0fadbcd340dfa7dc9b5fde7dfba413ba1d5831d0 Mon Sep 17 00:00:00 2001
From: zepan
Date: Tue, 17 Feb 2026 16:03:07 +0800
Subject: [PATCH 019/130] 1. add roadmap.md
---
ROADMAP.md | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..8c5c0e252
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,116 @@
+
+# 🦐 PicoClaw Roadmap
+
+> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
+
+---
+
+## 🚀 1. Core Optimization: Extreme Lightweight
+
+*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
+
+* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
+ * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
+ * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
+ * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
+
+
+## 🛡️ 2. Security Hardening: Defense in Depth
+
+*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
+
+* **Input Defense & Permission Control**
+ * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
+ * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
+ * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
+
+
+* **Sandboxing & Isolation**
+ * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
+ * **Context Isolation**: Prevent data leakage between different user sessions or channels.
+ * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
+
+
+* **Authentication & Secrets**
+ * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
+ * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
+
+
+
+## 🔌 3. Connectivity: Protocol-First Architecture
+
+*Connect every model, reach every platform.*
+
+* **Provider**
+ * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
+ * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
+ * **Online Models**: Continued support for frontier closed-source models.
+
+
+* **Channel**
+ * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
+ * **Standards**: Support for the **OneBot** protocol.
+ * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
+
+
+* **Skill Marketplace**
+ * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
+
+
+
+## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
+
+*Beyond conversation—focusing on action and collaboration.*
+
+* **Operations**
+ * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
+ * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
+ * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
+
+
+* **Multi-Agent Collaboration**
+ * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
+ * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
+ * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
+ * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
+
+
+
+## 📚 5. Developer Experience (DevEx) & Documentation
+
+*Lowering the barrier to entry so anyone can deploy in minutes.*
+
+* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
+ * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
+
+
+* **Comprehensive Documentation**
+ * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
+ * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
+ * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
+
+
+
+## 🤖 6. Engineering: AI-Powered Open Source
+
+*Born from Vibe Coding, we continue to use AI to accelerate development.*
+
+* **AI-Enhanced CI/CD**
+ * Integrate AI for automated Code Review, Linting, and PR Labeling.
+ * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
+ * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
+
+
+
+## 🎨 7. Brand & Community
+
+* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
+ * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
+
+
+
+---
+
+### 🤝 Call for Contributions
+
+We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!
\ No newline at end of file
From ac4b16dfb4bc961507b0385d32b089ee955ca7a6 Mon Sep 17 00:00:00 2001
From: zepan
Date: Tue, 17 Feb 2026 16:51:38 +0800
Subject: [PATCH 020/130] 1. rename doc to docs
---
README.md | 2 +-
README.zh.md | 2 +-
{doc => docs}/picoclaw_community_roadmap_260216.md | 0
3 files changed, 2 insertions(+), 2 deletions(-)
rename {doc => docs}/picoclaw_community_roadmap_260216.md (100%)
diff --git a/README.md b/README.md
index 0a9dacce6..29fddb7e3 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@
## 📢 News
-2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](doc/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
+2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
diff --git a/README.zh.md b/README.zh.md
index 2ca2987bb..8b59effa3 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -50,7 +50,7 @@
## 📢 新闻 (News)
-2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](doc/picoclaw_community_roadmap_260216.md), 期待你的参与!
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
diff --git a/doc/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
similarity index 100%
rename from doc/picoclaw_community_roadmap_260216.md
rename to docs/picoclaw_community_roadmap_260216.md
From 951b05d2550202f8ebbdf89eb39e582991fffb97 Mon Sep 17 00:00:00 2001
From: zepan
Date: Tue, 17 Feb 2026 17:15:40 +0800
Subject: [PATCH 021/130] 1. add AI Code Generation selection in pr template
---
.github/pull_request_template.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index d2773e27d..7910cb1e2 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -5,6 +5,11 @@
- [ ] 📖 Documentation update
- [ ] ⚡ Code refactoring (no functional changes, no api changes)
+## 🤖 AI Code Generation
+- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)
+- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
+- [ ] 👨💻 Mostly Human-written (Human lead, AI assisted or none)
+
## 🔗 Linked Issue
## 📚 Technical Context (Skip for Docs)
From 5fb2721d22d3e8d45d5969d5219e76dd34ff8ec6 Mon Sep 17 00:00:00 2001
From: zepan
Date: Tue, 17 Feb 2026 18:01:39 +0800
Subject: [PATCH 022/130] 1. add android phone termux quick guide
---
README.md | 14 ++++++++++++++
README.zh.md | 17 +++++++++++++++++
assets/termux.jpg | Bin 0 -> 99784 bytes
3 files changed, 31 insertions(+)
create mode 100644 assets/termux.jpg
diff --git a/README.md b/README.md
index 29fddb7e3..a6f421e9d 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,20 @@
+### 📱 Run on old Android Phones
+Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+1. **Install Termux** (Available on F-Droid or Google Play).
+2. **Execute cmds**
+```bash
+# Note: Replace v0.1.1 with the latest version from the Releases page
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+And then follow the instructions in the "Quick Start" section to complete the configuration!
+
+
### 🐜 Innovative Low-Footprint Deploy
PicoClaw can be deployed on almost any Linux device!
diff --git a/README.zh.md b/README.zh.md
index 8b59effa3..b09adf74a 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -100,6 +100,23 @@
+### 📱 在手机上轻松运行
+picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+1. 先去应用商店下载安装Termux
+2. 打开后执行指令
+```bash
+# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+
+
+
+
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
diff --git a/assets/termux.jpg b/assets/termux.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..30c724a2054885569cca76286d7d5c81e9f88a5d
GIT binary patch
literal 99784
zcmcG#byQr>wl3Oe;}TpNNU$VWaA-UP3&Ab8ySsbPKw|-dySsaU0NuE|y9RfE{Id5s
zXTSIEAMd^~?(8wws_s>5Rn4+*)i-B9&pxjLa6XGmi38x^-~f^@AHee(0j-#uxd{Lu
zCG{SF0ssKu0c>#a0Q{E}-HV!sAOhfC&fpgneo?j$Y+T?EY+zPyDpqbVCp(yx_2t)p
z1Ob2m_piLjFW=zc|CP3QNyGm;UGvXFU*!2e>+zEHp8}p20HOe7L?mP+L}X+nWFQb3
z1q}xc4HXp)9}61;hX|jTmhJ2cLOM2>WTEyYX3Cc
zzXc8+;ibMn6jU_ymkf0{0C+eA1b9RQBqT({m)zbj*8zyQNUvVAiXh`D=mX!_Sjb0g!h561a#-ke!A7l<1yzO|M#KvNEk=D%@Mh4kI6_?Skm||607ZEuqM&8y
z*6Qaf0t!vzt_Cz^$GAPsgv3`8M8^;Bt|Cg2jL$0VYXOe6fvpd7$1Ux|08NE_DaPKX
zx9uUe39nQ!L!PLkQK94Ba=e_P3Xs110QPm%qc^TGG!BqXqM_B*bP-CS8Ax_r_gS#r
z6V^F8iVGakUEMhuKp)(Ier^=o1{GufoN($TU0%J*b&b~soh!?a5e~U-LIJ~jbp-m4
z;zHa2!Ile>7oO5)oCAfwM#wWoiA>BJ$^1yjWH&B4pcd#QOavyeBIPp9uT|09m0AW2
z;0&$4sDz1`18@iE-7R)o;iRQkZH*ekv{Zj`5OIymkn*S{Y($4)>!E@-BbHQ^}Zs7-(
z_U0B
z!Yt@OKlK{(@wg#OzY`u6@SCmNuP}UFDQA?Coe}V=CrAQqjFZ)w=qDCDI9;Q
z+ocBrR@2=(pe2Bzxqxpg{}t
zPqVxTju#uLk*zT^F)gHgBJFQ;yDI0@$B-FXEfOeqFdpUWU657;Dmc29$u<
z?9a;aFZvYuFwT8IXwF}|G^HGWM8B6n`58PrYaJ3iZ#TVCT{xis~YTNV~!7>ya4)A9_E#JwpVllpL*a`klkFtbUX8pH?DB>zB;pln4wx6Y(t
zG#yLwrpf-Q?(Dj^F*@<1BnhpuS3%mNy~1NoS4o`UcaN-Z;^c&sZ(SDUhR$uYb0Qp2
zh!12YmW6I4FLfEjVm(cM8cb>;o2iY+B?4)svC;kv&RfR-O
zR`+s!L>`OqL%X1^1g(u_w^|EM<(&%Tm73TlPx2eV?z_(6(po7~7791fWMs)SBYD@O
zx@JyBu6*bS;e4EVP7B3ovA>J6N#cIjWn5LM1a_GP%t;M
zWbak4P&`0F3|}7Zf1$_?K^w`|+KMYk5sF7jByk?@)=eKHUVYxglAtd<&^C$vMa%Vv
z$%b(&gs`JDi58g9g3OwZr_VFY8q>?OS@&9xV}P)hau19B!_ykM;pu?}HT#|;86W5-dw{ZPCz=wCyPS>jGjDfPT`kej^
z^|i@M->kke@^87M0}%ovA}~_@zpA+;7o&&KC5#L}Jgb;~n;zF1eaR$uK?6HG?QEBR
z=Wz;|aMfOvO!QIiesA=!VVi(G5J8!ebvL1bt~i}@mtTg00_ZNYK`am91>X~jC~
zlAX)*rr#PnhaU~o5zlqTE(B+A0u!9yLW>eQyVFY|GlZ%Ou%sdZSLndGx6RVBS(ZibGxd9yTQ
z@|6E%9jbnVa9#mKQHQ5vc#3~@!;iI7;4QplD{IMM#EC{2t_o<5_9G7OYgh8qw1SF!
zi?pYB&w?QC=+FimsJ4Sk{RG6@7nbMj)-X|fYh(SO#6MFR*+(ig@cOdcWdvEEu;8>f6qUYYWZ?dP_tb+~4%!{XKq4+;I2O;QJtUd-G=m8-s5qsQ7Ok*&As
zM-bc_UMRQGPQK$rp7qLJI!U-VWI!Q(_eZRlFROwbwL#{QysOPD>k54VK3t!I<{d>`
z-aer{=XMn+vNLULYQhfp8%EirvsH!e06B~~-qgBuZf;SRlJ4u+RphJMfeDqgK|5VH@L6H_linb7GEC()1bTnr3vSQ6U7-x!HO
zHzYj7G=_rhOfu$~=0r@*)*j0}ToOTEpB)S^o&io#>-|wP(|)`b4Bi}R%o5HrU$Gf8
z7ZvR34q?=cto~J!I~cN(2%PMD8sv0Fq+N^c{W18FhT1)GG+WKqCKp?BUmW0u6tt0R
z)-I~5tC|A-$TH7cy$M>S@kbsfKBA|(Aon;AE3d`mBC(mOix0~(pE8VPZTvZl1jK}{
zN?oq{VFDaER1Phsr$>~M(a}fh7wEX;YD^yd+9twKD%Tm
zpvUdS3J+H3(zjxk#J;cYv_H#8q4#e%Hag+jF1wM1f1<4o7x=h5TIr?L8{QjY
z=#1?@@+CcK#NyD4e)V2@CD4cdbU<6Hr^M`RCc?d^T6Urx_q6w-XZj4@B`qw>5X6XvRN2|f(69Y}ZV6krH3vfjkgw;KYoAdXXM6Os;
z(V^U^$^=OabSyxD_;5{y79z%;NnSkGIc!o4g#Pwn)bp*?zNlST-RG0raq?$2%H
6vL6hYc2SGNpg(f+bE$Bf*~DC?(520H8o^qJ-=WTAy+;-`%V)6
z`(!5Pw2#x{mS34SHQnR-o#?4;`7tgiS@#ZvK?F%s>-QRN*7S8|v{W>HUY$P^c}QM%
z806l|;M9sVTXNaRwOq7UcHjBK%kvCEt85Tn0Zcld0qLnu<)&YzSezPMCXS36OoCOWz;je!9R{hIChVgP1Li#JnZ7Ohcx>MRP_KXzJ3&1qcT5oA~IsDE{g+qXSr-C
zACU~Ntna%Cc+AvI|6w|Qr8?eyivKBrmmITGpY=p|gC;>pWKUfKQs7!7Ij%FX%?Ii}
zL`XHPdkEHOBZuGwxsEa!6z7yS}2d`4Y=*RgZ6&*;j6{HZC2eL-US|^?h!D
zvZ;SG@QeV24^R;Mt8ga2hduA}8tR14Dor}|a?tXA#=h*cQTr~+%ox2yKq0eKv1^RB
z=zPM7RAO|)<@;nd$b-Q)f?9z+@}qU?7uvFX2g3QvYQn^o#7=MB6;alrr~)-adlEBD
zq)qDUV;4!#C0@sLg$%3F_9>j3Zc)X;(L`ec^knclIbuGy7>U2{8iio8Ww3|Q#R`WJ
zn=s{5AO*^zmwYH{Zqmtmbe;%jk4e
z9+7KQ+S!qyt(k-_zT52k)X!9a5NV5bwuPE1bRAua0w-P126^q==~9ULg!fkJP^I>u
z&*|~w^wo-On)Um^lwk2Ks)(mI^@&i@9I;nR9Ig((GetR}JF#3yC#y$W?QR~McCGfp
z1VmjLo$(vif@X7Mh9q|31p{n)HG0_5gm;eWY_B2W;?OM`avX_m@QeZwa@ly~HIrlv
zmlRK2{cSNe3r!^ui_S)51brAlFgWr$*`1aeX|@Uw7m=YT_0Y!wXZU%-meaDQZlY{?
zeJ%dy8|MxZ`ZEIg08~Uh$dsXMt0#IE|L4+9ZM&rp{mM_lCGzn8asC<&f}p9g)@K0M
zP_KXZ=idJP2!hn2_mYV3Sz)?Arsa94AX8I1ZpUiPP3=E#8Ll?tnTd9yKg6Ilte8-f
z^meZLM|}qTo^mQ#qO-@!Lio7Dv-yFB95-6BmnwjdREAx;b&BFsrQhIbp^3aOe>8xE
z|7AE~$yz|$%SOuNwq-DISdwUyOs283$RO)&4#u43g`=}_NS7TBmj%C|^QUoW;k8y&)bEa)|ZrRL+eN=1y
z`T=mgTa+XRwHtZn8ALj*xT2hOsiVdCvE0~UjUowM7?ew|Z(TMaj&aJ)88{Xi-3nsf
z4#)V$KgF@|Yxr0Vz^2TL_oGPGL~_=esBKZm@di?KOO@>87>9^wc3{`C0~Q1e
zwHWjB9?rtrU-u#bYposMKB5}vpV!%
zE92_$ZR6S>M{c5^QnwvSzlV@KfYU|?>G#KizP8GE>*MyCxtSp};}ueJJYr&Gm4g6b
z1GpahsVu^pA~6PO&|`n^ILUWZEa?PQrpv`p$Nik~;w7yiSG)7Y-to`R0QZ!l4SYD?
zkE6DAwUau(KMWXJTsxWy*?Vjtl(YFFC@!15(onZxAM!M#fNojq;BFJS=yem^a&4e2
zN$ySLJ6cyTTZ=OlB)o1jz9o)Ia;$w6-+ma9@h)CjDbgHh%2%q5K1_`t9`}}9TuI7A
z<8+mrV7~OMu`cM@9;9~vyonu;Uqj*TU#wg_Snys_Fmn`fCLwN4R=+s{_7)J&iG(kY
zRK_)QeALrwMG(%nd2y@Ua8X=m`(@IWqol5RqTIN?NnTkB&sqvL8ZcStNSbjK)yf<4|iQ43r#5pVXdO
zQn{t~Vy+1s+qd}jaJM$^*)B&2Hcsj4
z$VyAtE8Y5PujbFH|EC8yy`L0~h<%D_m?`Ts%T@B0PLj0$g0;*TkeC
z5G5rg9?=_`HxxAF6qFQy>B*N{fyh8C6cj89d|Z5r|84n8Pr|Pv@*u#yFmwM!Pl8@J
z)ECC2c|Ixz#AMab@ESNcI!(}us;W7Ei~CVk&7~9=6dT{!Jw8cC
z&mobMSGPuDX!Mp-T*Wv$ckJY)D6*F-!2f4G{^5fE5~VK}Y5tb`LJ0p)j(;xxB}tL-
zUTQ%={rrF9?y_#=l5G}kZJWNnx0)oOV{x7{j?C{x)0$OYZUS7pSrh0Fc_I%)=jfG!Z
zqcV?Psd`f%dh7UV?zqaukAj_d?k*`y_EsuY%}`MIGba|M`N;>?es6Tk6&gCUIw(0*
za!Hs^u?DOgB0flH&Yi*i5%k+-7gHv{k9Sm@>Y?GpX@xN=a?}mlftjeT5plC|G#4|5
zDIu+E5of0Buu5;leQr-+!|gBMnn|$j8h`!8rL)tcQkiXgHuk`^