From 6e7149509a3a4a25661604a6def08a3f532b0b87 Mon Sep 17 00:00:00 2001 From: Leandro Barbosa Date: Fri, 13 Feb 2026 12:12:12 -0300 Subject: [PATCH 01/56] 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 02/56] 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 03/56] 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 a6e885bb473a20d671ed1dab5e8e8ea9bb8cd399 Mon Sep 17 00:00:00 2001 From: Jared Mahotiere Date: Sun, 15 Feb 2026 08:04:07 -0500 Subject: [PATCH 04/56] 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 05/56] 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 06/56] 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 07/56] 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 08/56] 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 09/56] 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 10/56] 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 11/56] 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 12/56] 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 13/56] 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 @@ License

-**日本語** | [English](README.md) +[中文](README.zh.md) | **日本語** | [English](README.md) From 75fb728a1161a6a92e17f3470f9b28508c0daada Mon Sep 17 00:00:00 2001 From: AlbertBui010 Date: Tue, 17 Feb 2026 09:17:03 +0700 Subject: [PATCH 14/56] 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 @@ License

-**日本語** | [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 @@ Twitter

- [中文](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 + +

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 · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + [中文](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** | + +PicoClaw + +## 🦾 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ộngLê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. + +
+ +## ClawdChat 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: + +PicoClaw + +## 🐛 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 @@ Twitter

- **中文** | [日本語](README.ja.md) | [English](README.md) + **中文** | [日本語](README.ja.md) | [Tiếng Việt](README.vi.md) | [English](README.md) --- From ad747e8e8925cb9cb48cfc232f40156b0905b613 Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 14:27:03 +0100 Subject: [PATCH 15/56] fix(Makefile): update LDFLAGS and GOFLAGS for optimized build size Signed-off-by: Boris Bliznioukov --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 9786b30bb..c3f889f8f 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w" # Go variables GO?=go -GOFLAGS?=-v +GOFLAGS?=-v -tags stdjson # Installation INSTALL_PREFIX?=$(HOME)/.local From 2d758d714faf8d4cc7fe48d7886bb8f3a2971a8b Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 14:55:37 +0100 Subject: [PATCH 16/56] feat(goreleaser): add 'stdjson' tag to picoclaw build configuration Signed-off-by: Boris Bliznioukov --- .goreleaser.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 368a0f06b..0354928f3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,8 @@ builds: - id: picoclaw env: - CGO_ENABLED=0 + tags: + - stdjson goos: - linux - windows From 2d876eaa9809d3a958ffc2e73c8eed8b8d760531 Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 15:00:06 +0100 Subject: [PATCH 17/56] feat(goreleaser): enhance build flags with versioning and commit info Signed-off-by: Boris Bliznioukov --- .github/workflows/release.yml | 2 ++ .goreleaser.yaml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fe3a684e..4e9399128 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,7 @@ jobs: ref: ${{ inputs.tag }} - name: Setup Go from go.mod + id: setup-go uses: actions/setup-go@v5 with: go-version-file: go.mod @@ -89,6 +90,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} - name: Apply release flags shell: bash diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0354928f3..2c47f7d86 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,6 +13,12 @@ builds: - CGO_ENABLED=0 tags: - stdjson + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.gitCommit={{ .ShortCommit }} + - -X main.buildTime={{ .Date }} + - -X main.goVersion={{ .Env.GOVERSION }} goos: - linux - windows From 4cd3f99dd6f2ddfd3378b269d6f295d4a5ecc763 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Mon, 16 Feb 2026 12:49:11 +0900 Subject: [PATCH 18/56] fix: remove max_tokens --- pkg/providers/codex_provider.go | 4 ---- pkg/providers/codex_provider_test.go | 11 +++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 6dff3a52e..9e36217ae 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -260,10 +260,6 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } - if maxTokens, ok := options["max_tokens"].(int); ok { - params.MaxOutputTokens = openai.Opt(int64(maxTokens)) - } - if len(tools) > 0 { params.Tools = translateToolsForCodex(tools) } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 317b1a5de..c34593e7b 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -29,6 +29,9 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { if params.Instructions.Or("") != defaultCodexInstructions { t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) } + if params.MaxOutputTokens.Valid() { + t.Fatalf("MaxOutputTokens should not be set for Codex backend") + } } func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { @@ -214,6 +217,10 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "stream must be true", http.StatusBadRequest) return } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } resp := map[string]interface{}{ "id": "resp_test", @@ -293,6 +300,10 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) http.Error(w, "temperature is not supported", http.StatusBadRequest) return } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } if reqBody["stream"] != true { http.Error(w, "stream must be true", http.StatusBadRequest) return From 0d16525fab81f1010bf6ddafd5ea68d975613a88 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Mon, 16 Feb 2026 13:08:37 +0900 Subject: [PATCH 19/56] fix: codex tool call --- pkg/providers/codex_provider.go | 36 ++++++++++++++++++++++--- pkg/providers/codex_provider_test.go | 39 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 9e36217ae..7617bf716 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -217,12 +217,18 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, }) } for _, tc := range msg.ToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) + name, args, ok := resolveCodexToolCall(tc) + if !ok { + logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{ + "call_id": tc.ID, + }) + continue + } inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCall: &responses.ResponseFunctionToolCallParam{ CallID: tc.ID, - Name: tc.Name, - Arguments: string(argsJSON), + Name: name, + Arguments: args, }, }) } @@ -267,6 +273,30 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, return params } +func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) { + name = tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + if name == "" { + return "", "", false + } + + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true + } + + if tc.Function != nil && tc.Function.Arguments != "" { + return name, tc.Function.Arguments, true + } + + return name, "{}", true +} + func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { result := make([]responses.ToolUnionParam, 0, len(tools)) for _, t := range tools { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index c34593e7b..8406760c4 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -68,6 +68,45 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { } } +func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "Read a file"}, + { + Role: "assistant", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + }, + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + } + + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + if params.Input.OfInputItemList == nil { + t.Fatal("Input.OfInputItemList should not be nil") + } + if len(params.Input.OfInputItemList) != 3 { + t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) + } + + fc := params.Input.OfInputItemList[1].OfFunctionCall + if fc == nil { + t.Fatal("assistant tool call should be converted to function_call input item") + } + if fc.Name != "read_file" { + t.Errorf("Function call name = %q, want %q", fc.Name, "read_file") + } + if fc.Arguments != `{"path":"README.md"}` { + t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`) + } +} + func TestBuildCodexParams_WithTools(t *testing.T) { tools := []ToolDefinition{ { From c4cbb5fb35374d0ff917baff9196746f843b99fa Mon Sep 17 00:00:00 2001 From: Jared Mahotiere Date: Tue, 17 Feb 2026 11:13:10 -0500 Subject: [PATCH 20/56] providers: finalize PR213 review fixes Phase 1: centralize protocol message/tool/response types in protocoltypes and keep compatibility aliases in providers and protocol packages. Phase 1: preserve HTTPProvider constructor compatibility and route Anthropic api_base through factory auth/provider constructors with base URL normalization. Phase 2: expand provider routing/auth tests (deepseek/nvidia/shengsuanyun, codex/claude oauth/codex-cli) and add openai_compat + anthropic coverage for proxy transport, model normalization, numeric option coercion, token-source refresh, and base URL behavior. Phase 3: apply gofmt and validate with Dockerized tests (go test ./pkg/providers/... ./pkg/migrate and go test ./...). --- pkg/providers/anthropic/provider.go | 99 +++++++------- pkg/providers/anthropic/provider_test.go | 57 ++++++++ pkg/providers/claude_provider.go | 118 ++-------------- pkg/providers/factory.go | 47 ++++++- pkg/providers/factory_test.go | 95 +++++++++++++ pkg/providers/http_provider.go | 106 +-------------- pkg/providers/openai_compat/provider.go | 136 ++++++++++--------- pkg/providers/openai_compat/provider_test.go | 85 +++++++++++- pkg/providers/protocoltypes/types.go | 45 ++++++ pkg/providers/types.go | 54 ++------ 10 files changed, 468 insertions(+), 374 deletions(-) create mode 100644 pkg/providers/protocoltypes/types.go diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index ca72f0180..8f46aa70c 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -4,74 +4,59 @@ import ( "context" "encoding/json" "fmt" + "log" + "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -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 ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition -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"` -} +const defaultBaseURL = "https://api.anthropic.com" type Provider struct { client *anthropic.Client tokenSource func() (string, error) + baseURL string } func NewProvider(token string) *Provider { + return NewProviderWithBaseURL(token, "") +} + +func NewProviderWithBaseURL(token, apiBase string) *Provider { + baseURL := normalizeBaseURL(apiBase) client := anthropic.NewClient( option.WithAuthToken(token), - option.WithBaseURL("https://api.anthropic.com"), + option.WithBaseURL(baseURL), ) - return &Provider{client: &client} + return &Provider{ + client: &client, + baseURL: baseURL, + } } func NewProviderWithClient(client *anthropic.Client) *Provider { - return &Provider{client: client} + return &Provider{ + client: client, + baseURL: defaultBaseURL, + } } func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider { - p := NewProvider(token) + return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "") +} + +func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider { + p := NewProviderWithBaseURL(token, apiBase) p.tokenSource = tokenSource return p } @@ -103,6 +88,10 @@ func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4-5-20250929" } +func (p *Provider) BaseURL() string { + return p.baseURL +} + func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { var system []anthropic.TextBlockParam var anthropicMessages []anthropic.MessageParam @@ -208,6 +197,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { tu := block.AsToolUse() var args map[string]interface{} if err := json.Unmarshal(tu.Input, &args); err != nil { + log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err) args = map[string]interface{}{"raw": string(tu.Input)} } toolCalls = append(toolCalls, ToolCall{ @@ -239,3 +229,20 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { }, } } + +func normalizeBaseURL(apiBase string) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if strings.HasSuffix(base, "/v1") { + base = strings.TrimSuffix(base, "/v1") + } + if base == "" { + return defaultBaseURL + } + + return base +} diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 01b4fe663..6a1dabafb 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync/atomic" "testing" "github.com/anthropics/anthropic-sdk-go" @@ -199,6 +200,62 @@ func TestProvider_GetDefaultModel(t *testing.T) { } } +func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) { + p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/") + if got := p.BaseURL(); got != "https://api.anthropic.com" { + t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com") + } +} + +func TestProvider_ChatUsesTokenSource(t *testing.T) { + var requests int32 + 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 + } + atomic.AddInt32(&requests, 1) + + if got := r.Header.Get("Authorization"); got != "Bearer refreshed-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": "ok"}, + }, + "usage": map[string]interface{}{ + "input_tokens": 1, + "output_tokens": 1, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) { + return "refreshed-token", nil + }, server.URL) + + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if got := atomic.LoadInt32(&requests); got != 1 { + t.Fatalf("requests = %d, want 1", got) + } +} + func createAnthropicTestClient(baseURL, token string) *anthropic.Client { c := anthropic.NewClient( anthropicoption.WithAuthToken(token), diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index 16f1884c5..c72f5b0ef 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -3,8 +3,6 @@ package providers import ( "context" "fmt" - - "github.com/sipeed/picoclaw/pkg/auth" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -18,28 +16,34 @@ func NewClaudeProvider(token string) *ClaudeProvider { } } +func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase), + } +} + func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource), } } +func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase), + } +} + 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) { - resp, err := p.delegate.Chat( - ctx, - toAnthropicProviderMessages(messages), - toAnthropicProviderTools(tools), - model, - options, - ) + resp, err := p.delegate.Chat(ctx, messages, tools, model, options) if err != nil { return nil, err } - return fromAnthropicProviderResponse(resp), nil + return resp, nil } func (p *ClaudeProvider) GetDefaultModel() string { @@ -48,7 +52,7 @@ func (p *ClaudeProvider) GetDefaultModel() string { func createClaudeTokenSource() func() (string, error) { return func() (string, error) { - cred, err := auth.GetCredential("anthropic") + cred, err := getCredential("anthropic") if err != nil { return "", fmt.Errorf("loading auth credentials: %w", err) } @@ -58,95 +62,3 @@ 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/factory.go b/pkg/providers/factory.go index 28609c4b3..67a347721 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -8,6 +8,10 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" + +var getCredential = auth.GetCredential + type providerType int const ( @@ -30,19 +34,22 @@ type providerSelection struct { connectMode string } -func createClaudeAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("anthropic") +func createClaudeAuthProvider(apiBase string) (LLMProvider, error) { + if apiBase == "" { + apiBase = defaultAnthropicAPIBase + } + cred, err := 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 + return NewClaudeProviderWithTokenSourceAndBaseURL(cred.AccessToken, createClaudeTokenSource(), apiBase), nil } func createCodexAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("openai") + cred, err := getCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } @@ -69,6 +76,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if 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" } @@ -85,6 +93,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { } 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" } @@ -92,18 +101,24 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { case "anthropic", "claude": if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } 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" + sel.apiBase = defaultAnthropicAPIBase } } case "openrouter": 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 { @@ -114,6 +129,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if 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" } @@ -122,6 +138,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if 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" } @@ -130,15 +147,26 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.VLLM.APIBase != "" { sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase + sel.proxy = cfg.Providers.VLLM.Proxy } case "shengsuanyun": if cfg.Providers.ShengSuanYun.APIKey != "" { sel.apiKey = cfg.Providers.ShengSuanYun.APIKey sel.apiBase = cfg.Providers.ShengSuanYun.APIBase + sel.proxy = cfg.Providers.ShengSuanYun.Proxy if sel.apiBase == "" { sel.apiBase = "https://router.shengsuanyun.com/api/v1" } } + case "nvidia": + if 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 "claude-cli", "claude-code", "claudecode": workspace := cfg.WorkspacePath() if workspace == "" { @@ -159,6 +187,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.DeepSeek.APIKey != "" { sel.apiKey = cfg.Providers.DeepSeek.APIKey sel.apiBase = cfg.Providers.DeepSeek.APIBase + sel.proxy = cfg.Providers.DeepSeek.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.deepseek.com/v1" } @@ -204,6 +233,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { 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.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } sel.providerType = providerTypeClaudeAuth return sel, nil } @@ -211,7 +244,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { sel.apiBase = cfg.Providers.Anthropic.APIBase sel.proxy = cfg.Providers.Anthropic.Proxy if sel.apiBase == "" { - sel.apiBase = "https://api.anthropic.com/v1" + sel.apiBase = defaultAnthropicAPIBase } case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): @@ -303,7 +336,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { switch sel.providerType { case providerTypeClaudeAuth: - return createClaudeAuthProvider() + return createClaudeAuthProvider(sel.apiBase) case providerTypeCodexAuth: return createCodexAuthProvider() case providerTypeCodexCLIToken: diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index c1f14291d..e31737eb9 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -32,6 +33,40 @@ func TestResolveProviderSelection(t *testing.T) { wantType: providerTypeGitHubCopilot, wantAPIBase: "localhost:4321", }, + { + name: "explicit deepseek provider uses deepseek defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "deepseek" + cfg.Agents.Defaults.Model = "deepseek/deepseek-chat" + cfg.Providers.DeepSeek.APIKey = "deepseek-key" + cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.deepseek.com/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit shengsuanyun provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "shengsuanyun" + cfg.Providers.ShengSuanYun.APIKey = "ssy-key" + cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://router.shengsuanyun.com/api/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit nvidia provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "nvidia" + cfg.Providers.Nvidia.APIKey = "nvapi-test" + cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://integrate.api.nvidia.com/v1", + wantProxy: "http://127.0.0.1:7890", + }, { name: "openrouter model uses openrouter defaults", setup: func(cfg *config.Config) { @@ -202,3 +237,63 @@ func TestCreateProviderReturnsCodexProviderForCodexCliAuthMethod(t *testing.T) { t.Fatalf("provider type = %T, want *CodexProvider", provider) } } + +func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "anthropic" { + t.Fatalf("provider = %q, want anthropic", provider) + } + return &auth.AuthCredential{ + AccessToken: "anthropic-token", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "anthropic" + cfg.Providers.Anthropic.AuthMethod = "oauth" + cfg.Providers.Anthropic.APIBase = "https://proxy.example.com/v1" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + claudeProvider, ok := provider.(*ClaudeProvider) + if !ok { + t.Fatalf("provider type = %T, want *ClaudeProvider", provider) + } + if got := claudeProvider.delegate.BaseURL(); got != "https://proxy.example.com" { + t.Fatalf("anthropic baseURL = %q, want %q", got, "https://proxy.example.com") + } +} + +func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want openai", provider) + } + return &auth.AuthCredential{ + AccessToken: "openai-token", + AccountID: "acct_123", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "openai" + cfg.Providers.OpenAI.AuthMethod = "oauth" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*CodexProvider); !ok { + t.Fatalf("provider type = %T, want *CodexProvider", provider) + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 0f7f646d8..e39a19e90 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -15,116 +15,16 @@ type HTTPProvider struct { delegate *openai_compat.Provider } -func NewHTTPProvider(apiKey, apiBase string, proxy ...string) *HTTPProvider { - proxyURL := "" - if len(proxy) > 0 { - proxyURL = proxy[0] - } +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { return &HTTPProvider{ - delegate: openai_compat.NewProvider(apiKey, apiBase, proxyURL), + delegate: openai_compat.NewProvider(apiKey, apiBase, proxy), } } func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - compatResp, err := p.delegate.Chat(ctx, toOpenAICompatMessages(messages), toOpenAICompatTools(tools), model, options) - if err != nil { - return nil, err - } - return fromOpenAICompatResponse(compatResp), nil + return p.delegate.Chat(ctx, messages, tools, model, options) } func (p *HTTPProvider) GetDefaultModel() string { return "" } - -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, - }) - } - return out -} - -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, - }, - }) - } - return out -} - -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, - } - } - 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{} - } - - 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: fromOpenAICompatToolCalls(resp.ToolCalls), - FinishReason: resp.FinishReason, - Usage: usage, - } -} - -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 index 7bc8e26be..9b404dd77 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -6,55 +6,22 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -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 ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition type Provider struct { apiKey string @@ -62,21 +29,19 @@ type Provider struct { httpClient *http.Client } -func NewProvider(apiKey, apiBase string, proxy ...string) *Provider { - proxyURL := "" - if len(proxy) > 0 { - proxyURL = proxy[0] - } +func NewProvider(apiKey, apiBase, proxy string) *Provider { client := &http.Client{ Timeout: 120 * time.Second, } - if proxyURL != "" { - parsed, err := url.Parse(proxyURL) + if proxy != "" { + parsed, err := url.Parse(proxy) if err == nil { client.Transport = &http.Transport{ Proxy: http.ProxyURL(parsed), } + } else { + log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err) } } @@ -92,13 +57,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef return nil, fmt.Errorf("API base not configured") } - // Strip provider prefix for OpenAI-compatible backends. - if idx := strings.Index(model, "/"); idx != -1 { - prefix := model[:idx] - if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" { - model = model[idx+1:] - } - } + model = normalizeModel(model, p.apiBase) requestBody := map[string]interface{}{ "model": model, @@ -110,7 +69,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef requestBody["tool_choice"] = "auto" } - if maxTokens, ok := options["max_tokens"].(int); ok { + if maxTokens, ok := asInt(options["max_tokens"]); ok { lowerModel := strings.ToLower(model) if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { requestBody["max_completion_tokens"] = maxTokens @@ -119,7 +78,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef } } - if temperature, ok := options["temperature"].(float64); ok { + if temperature, ok := asFloat(options["temperature"]); ok { lowerModel := strings.ToLower(model) // Kimi k2 models only support temperature=1. if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { @@ -198,17 +157,11 @@ func parseResponse(body []byte) (*LLMResponse, error) { 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 { + if tc.Function != nil { name = tc.Function.Name if tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { + log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) arguments["raw"] = tc.Function.Arguments } } @@ -228,3 +181,52 @@ func parseResponse(body []byte) (*LLMResponse, error) { Usage: apiResponse.Usage, }, nil } + +func normalizeModel(model, apiBase string) string { + idx := strings.Index(model, "/") + if idx == -1 { + return model + } + + if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") { + return model + } + + prefix := strings.ToLower(model[:idx]) + switch prefix { + case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu": + return model[idx+1:] + default: + return model + } +} + +func asInt(v interface{}) (int, bool) { + switch val := v.(type) { + case int: + return val, true + case int64: + return int(val), true + case float64: + return int(val), true + case float32: + return int(val), true + default: + return 0, false + } +} + +func asFloat(v interface{}) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case float32: + return float64(val), true + case int: + return float64(val), true + case int64: + return float64(val), true + default: + return 0, false + } +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index e5926458b..94779b39c 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" ) @@ -32,7 +33,7 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + 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) @@ -78,7 +79,7 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + 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) @@ -100,7 +101,7 @@ func TestProviderChat_HTTPError(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + 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") @@ -128,7 +129,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, @@ -164,6 +165,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { input: "ollama/qwen2.5:14b", wantModel: "qwen2.5:14b", }, + { + name: "strips deepseek prefix", + input: "deepseek/deepseek-chat", + wantModel: "deepseek-chat", + }, } for _, tt := range tests { @@ -188,7 +194,7 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -200,3 +206,72 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { }) } } + +func TestProvider_ProxyConfigured(t *testing.T) { + proxyURL := "http://127.0.0.1:8080" + p := NewProvider("key", "https://example.com", proxyURL) + + transport, ok := p.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport) + } + + req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} + gotProxy, err := transport.Proxy(req) + if err != nil { + t.Fatalf("proxy function returned error: %v", err) + } + if gotProxy == nil || gotProxy.String() != proxyURL { + t.Fatalf("proxy = %v, want %s", gotProxy, proxyURL) + } +} + +func TestProviderChat_AcceptsNumericOptionTypes(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, + "gpt-4o", + map[string]interface{}{"max_tokens": float64(512), "temperature": 1}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["max_tokens"] != float64(512) { + t.Fatalf("max_tokens = %v, want 512", requestBody["max_tokens"]) + } + if requestBody["temperature"] != float64(1) { + t.Fatalf("temperature = %v, want 1", requestBody["temperature"]) + } +} + +func TestNormalizeModel_UsesAPIBase(t *testing.T) { + if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { + t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") + } + if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { + t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") + } +} diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go new file mode 100644 index 000000000..6b33ae734 --- /dev/null +++ b/pkg/providers/protocoltypes/types.go @@ -0,0 +1,45 @@ +package protocoltypes + +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"` +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 88b62e975..221a842fa 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -1,52 +1,20 @@ package providers -import "context" +import ( + "context" -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"` -} + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) -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 ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition type LLMProvider interface { Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) GetDefaultModel() string } - -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"` -} From b83304845ea53631e4c97cffb3d2f717d477e986 Mon Sep 17 00:00:00 2001 From: AlbertBui010 Date: Tue, 17 Feb 2026 23:39:17 +0700 Subject: [PATCH 21/56] docs: resolve conflict in README.ja.md --- README.ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.ja.md b/README.ja.md index fa4eae69a..709cee0ca 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-**日本語** | [Tiếng Việt](README.vi.md) | [English](README.md) +**日本語** | [中文](README.zh.md) | [Tiếng Việt](README.vi.md) | [English](README.md) From 8428446d69c64459018d737345f157960b56b90e Mon Sep 17 00:00:00 2001 From: AlbertBui010 Date: Tue, 17 Feb 2026 23:58:10 +0700 Subject: [PATCH 22/56] docs: fix allow_from typo in config examples --- README.ja.md | 8 ++++---- README.md | 4 ++-- README.vi.md | 8 ++++---- README.zh.md | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.ja.md b/README.ja.md index c0a2b0de0..cbfffdd8f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -253,7 +253,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -293,7 +293,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -676,7 +676,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "telegram": { "enabled": true, "token": "123456:ABC...", - "allowFrom": ["123456789"] + "allow_from": ["123456789"] }, "discord": { "enabled": true, @@ -692,7 +692,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "appSecret": "xxx", "encryptKey": "", "verificationToken": "", - "allowFrom": [] + "allow_from": [] } }, "tools": { diff --git a/README.md b/README.md index 9c95dfde8..b5ce651e0 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -326,7 +326,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } diff --git a/README.vi.md b/README.vi.md index 533ef7607..e629eaa9b 100644 --- a/README.vi.md +++ b/README.vi.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Tiếng Việt** +**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) --- @@ -50,7 +50,7 @@ ## 📢 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-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](docs/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. @@ -270,7 +270,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE. "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -313,7 +313,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } diff --git a/README.zh.md b/README.zh.md index f6c495d67..9aad5859d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -291,7 +291,7 @@ picoclaw agent -m "2+2 等于几?" "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -336,7 +336,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } From f820da42d7a63b05bef448839fd7fc5e13527d3b Mon Sep 17 00:00:00 2001 From: Leandro Barbosa Date: Tue, 17 Feb 2026 17:52:28 -0300 Subject: [PATCH 23/56] docs: add Brazilian Portuguese README (README.pt-br.md) Add complete pt-BR translation of the README and update language navigation links across all existing READMEs (English, Chinese, Japanese) to include the Portuguese option. --- README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 881 ++++++++++++++++++++++++++++++++++++++++++++++++ README.zh.md | 2 +- 4 files changed, 884 insertions(+), 3 deletions(-) create mode 100644 README.pt-br.md diff --git a/README.ja.md b/README.ja.md index b86d636ac..0da84571a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-[中文](README.zh.md) | **日本語** | [English](README.md) +[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [English](README.md) diff --git a/README.md b/README.md index e80e2213c..59b9bea7c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | **English** + [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **English** --- diff --git a/README.pt-br.md b/README.pt-br.md new file mode 100644 index 000000000..d250cc956 --- /dev/null +++ b/README.pt-br.md @@ -0,0 +1,881 @@ +
+PicoClaw + +

PicoClaw: Assistente de IA Ultra-Eficiente em Go

+ +

Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português** +
+ +--- + +🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. + +⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 DECLARACAO DE SEGURANCA & CANAIS OFICIAIS** +> +> * **SEM CRIPTOMOEDAS:** O PicoClaw **NAO** possui nenhum token/moeda oficial. Todas as alegacoes no `pump.fun` ou outras plataformas de negociacao sao **GOLPES**. +> * **DOMINIO OFICIAL:** O **UNICO** site oficial e **[picoclaw.io](https://picoclaw.io)**, e o site da empresa e **[sipeed.com](https://sipeed.com)**. +> * **Aviso:** Muitos dominios `.ai/.org/.com/.net/...` foram registrados por terceiros, nao sao nossos. +> * **Aviso:** O PicoClaw esta em fase inicial de desenvolvimento e pode ter problemas de seguranca de rede nao resolvidos. Nao implante em ambientes de producao antes da versao v1.0. +> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memoria (10-20MB) nas versoes mais recentes. Planejamos priorizar a otimizacao de recursos assim que o conjunto de funcionalidades estiver estavel. + + +## 📢 Novidades + +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw esta crescendo mais rapido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papeis de voluntarios e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter voce a bordo! + +2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado a comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. +🚀 **Chamada para Acao:** Envie suas solicitacoes de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na proxima reuniao semanal. + +2026-02-09 🎉 PicoClaw lancado oficialmente! Construido em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! + +## ✨ Funcionalidades + +🪶 **Ultra-Leve**: Consumo de memoria <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. + +💰 **Custo Minimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. + +⚡️ **Inicializacao Relampago**: Tempo de inicializacao 400X mais rapido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. + +🌍 **Portabilidade Real**: Um unico binario auto-contido para RISC-V, ARM e x86. Um clique e ja era! + +🤖 **Auto-Construido por IA**: Implementacao nativa em Go de forma autonoma — 95% do nucleo gerado pelo Agente com refinamento humano no loop. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Linguagem** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Inicializacao**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | + +PicoClaw + +## 🦾 Demonstracao + +### 🛠️ Fluxos de Trabalho Padrao do Assistente + + + + + + + + + + + + + + + + + +

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • EscalarAgendar • Automatizar • MemorizarDescobrir • Analisar • Tendencias
+ +### 📱 Rode em celulares Android antigos + +De uma segunda vida ao seu celular de dez anos atras! Transforme-o em um assistente de IA inteligente com o PicoClaw. Inicio rapido: + +1. **Instale o Termux** (Disponivel no F-Droid ou Google Play). +2. **Execute os comandos** + +```bash +# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases +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 +``` + +Depois siga as instrucoes na secao "Inicio Rapido" para completar a configuracao! + +PicoClaw + +### 🐜 Implantacao Inovadora com Baixo Consumo + +O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versao E (Ethernet) ou W (WiFi6), para Assistente Domestico Minimalista +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutencao Automatizada de Servidores +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 Mais cenarios de implantacao aguardam voce! + +## 📦 Instalacao + +### Instalar com binario pre-compilado + +Baixe o binario para sua plataforma na pagina de [releases](https://github.com/sipeed/picoclaw/releases). + +### Instalar a partir do codigo-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, sem necessidade de instalar +make build + +# Build para multiplas plataformas +make build-all + +# Build e Instalar +make install +``` + +## 🐳 Docker Compose + +Voce tambem pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. + +```bash +# 1. Clone este repositorio +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Configure suas API keys +cp config/config.example.json config/config.json +vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. + +# 3. Build & Iniciar +docker compose --profile gateway up -d + +# 4. Ver logs +docker compose logs -f picoclaw-gateway + +# 5. Parar +docker compose --profile gateway down +``` + +### Modo Agente (Execucao unica) + +```bash +# Fazer uma pergunta +docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" + +# Modo interativo +docker compose run --rm picoclaw-agent +``` + +### Rebuild + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Inicio Rapido + +> [!TIP] +> Configure sua API key em `~/.picoclaw/config.json`. +> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas gratis/mes) ou use o fallback automatico integrado. + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +**2. Configurar** (`~/.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. Obter API Keys** + +* **Provedor de 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) +* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponivel (2000 consultas/mes) + +> **Nota**: Veja `config.example.json` para um modelo de configuracao completo. + +**4. Conversar** + +```bash +picoclaw agent -m "Quanto e 2+2?" +``` + +Pronto! Voce tem um assistente de IA funcionando em 2 minutos. + +--- + +## 💬 Integracao com Apps de Chat + +Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE. + +| Canal | Nivel de Configuracao | +| --- | --- | +| **Telegram** | Facil (apenas um token) | +| **Discord** | Facil (bot token + intents) | +| **QQ** | Facil (AppID + AppSecret) | +| **DingTalk** | Medio (credenciais do app) | +| **LINE** | Medio (credenciais + webhook URL) | + +
+Telegram (Recomendado) + +**1. Criar o bot** + +* Abra o Telegram, busque `@BotFather` +* Envie `/newbot`, siga as instrucoes +* Copie o token + +**2. Configurar** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenha seu User ID pelo `@userinfobot` no Telegram. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Criar o bot** + +* Acesse +* Crie um aplicativo → Bot → Add Bot +* Copie o token do bot + +**2. Habilitar Intents** + +* Nas configuracoes do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissoes baseada em dados dos membros + +**3. Obter seu User ID** + +* Configuracoes do Discord → Avancado → habilite **Modo Desenvolvedor** +* Clique com botao direito no seu avatar → **Copiar ID do Usuario** + +**4. Configurar** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Convidar o bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Abra a URL de convite gerada e adicione o bot ao seu servidor + +**6. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Criar o bot** + +- Acesse a [QQ Open Platform](https://q.qq.com/#) +- Crie um aplicativo → Obtenha **AppID** e **AppSecret** + +**2. Configurar** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique numeros QQ para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Criar o bot** + +* Acesse a [Open Platform](https://open.dingtalk.com/) +* Crie um app interno +* Copie o Client ID e Client Secret + +**2. Configurar** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique IDs para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Criar uma Conta Oficial LINE** + +- Acesse o [LINE Developers Console](https://developers.line.biz/) +- Crie um provider → Crie um canal Messaging API +- Copie o **Channel Secret** e o **Channel Access Token** + +**2. Configurar** + +```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. Configurar URL do Webhook** + +O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel: + +```bash +# Exemplo com ngrok +ngrok http 18791 +``` + +Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. + +**4. Executar** + +```bash +picoclaw gateway +``` + +> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. + +> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao servico `picoclaw-gateway` para expor a porta do webhook. + +
+ +## ClawdChat Junte-se a Rede Social de Agentes + +Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mensagem via CLI ou qualquer App de Chat integrado. + +**Leia `https://clawdchat.ai/skill.md` e siga as instrucoes para entrar no [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Configuracao Detalhada + +Arquivo de configuracao: `~/.picoclaw/config.json` + +### Estrutura do Workspace + +O PicoClaw armazena dados no workspace configurado (padrao: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessoes de conversa e historico +├── memory/ # Memoria de longo prazo (MEMORY.md) +├── state/ # Estado persistente (ultimo canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENTS.md # Guia de comportamento do Agente +├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) +├── IDENTITY.md # Identidade do Agente +├── SOUL.md # Alma do Agente +├── TOOLS.md # Descricao das ferramentas +└── USER.md # Preferencias do usuario +``` + +### 🔒 Sandbox de Seguranca + +O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. + +#### Configuracao Padrao + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Opcao | Padrao | Descricao | +|-------|--------|-----------| +| `workspace` | `~/.picoclaw/workspace` | Diretorio de trabalho do agente | +| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace | + +#### Ferramentas Protegidas + +Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao sandbox: + +| Ferramenta | Funcao | Restricao | +|------------|--------|-----------| +| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | +| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace | +| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace | +| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | +| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace | +| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace | + +#### Protecao Adicional do Exec + +Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: + +* `rm -rf`, `del /f`, `rmdir /s` — Exclusao em massa +* `format`, `mkfs`, `diskpart` — Formatacao de disco +* `dd if=` — Criacao de imagem de disco +* Escrita em `/dev/sd[a-z]` — Escrita direta no disco +* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema +* Fork bomb `:(){ :|:& };:` + +#### Exemplos de Erro + +``` +[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)} +``` + +#### Desabilitar Restricoes (Risco de Seguranca) + +Se voce precisa que o agente acesse caminhos fora do workspace: + +**Metodo 1: Arquivo de configuracao** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Metodo 2: Variavel de ambiente** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Aviso**: Desabilitar esta restricao permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. + +#### Consistencia do Limite de Seguranca + +A configuracao `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execucao: + +| Caminho de Execucao | Limite de Seguranca | +|----------------------|---------------------| +| Agente Principal | `restrict_to_workspace` ✅ | +| Subagente / Spawn | Herda a mesma restricao ✅ | +| Tarefas Heartbeat | Herda a mesma restricao ✅ | + +Todos os caminhos compartilham a mesma restricao de workspace — nao ha como contornar o limite de seguranca por meio de subagentes ou tarefas agendadas. + +### Heartbeat (Tarefas Periodicas) + +O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: + +```markdown +# Tarefas Periodicas + +- Verificar meu email para mensagens importantes +- Revisar minha agenda para proximos eventos +- Verificar a previsao do tempo +``` + +O agente lera este arquivo a cada 30 minutos (configuravel) e executara as tarefas usando as ferramentas disponiveis. + +#### Tarefas Assincronas com Spawn + +Para tarefas de longa duracao (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: + +```markdown +# Tarefas Periodicas + +## Tarefas Rapidas (resposta direta) +- Informar hora atual + +## Tarefas Longas (usar spawn para async) +- Buscar noticias de IA na web e resumir +- Verificar email e reportar mensagens importantes +``` + +**Comportamentos principais:** + +| Funcionalidade | Descricao | +|----------------|-----------| +| **spawn** | Cria subagente assincrono, nao bloqueia o heartbeat | +| **Contexto independente** | Subagente tem seu proprio contexto, sem historico de sessao | +| **Ferramenta message** | Subagente se comunica diretamente com o usuario via ferramenta message | +| **Nao-bloqueante** | Apos o spawn, o heartbeat continua para a proxima tarefa | + +#### Como Funciona a Comunicacao do Subagente + +``` +Heartbeat dispara + ↓ +Agente le HEARTBEAT.md + ↓ +Para tarefa longa: spawn subagente + ↓ ↓ +Continua proxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas tarefas concluidas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuario recebe resultado diretamente +``` + +O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comunicar com o usuario independentemente sem passar pelo agente principal. + +**Configuracao:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Opcao | Padrao | Descricao | +|-------|--------|-----------| +| `enabled` | `true` | Habilitar/desabilitar heartbeat | +| `interval` | `30` | Intervalo de verificacao em minutos (min: 5) | + +**Variaveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo + +### Provedores + +> [!NOTE] +> O Groq fornece transcricao de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serao automaticamente transcritas. + +| Provedor | Finalidade | Obter API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Transcricao de voz** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Configuracao Zhipu + +**1. Obter API key** + +* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurar** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Sua API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw agent -m "Ola, como vai?" +``` + +
+ +
+Exemplo de configuracao completa + +```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 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Referencia CLI + +| Comando | Descricao | +| --- | --- | +| `picoclaw onboard` | Inicializar configuracao & workspace | +| `picoclaw agent -m "..."` | Conversar com o agente | +| `picoclaw agent` | Modo de chat interativo | +| `picoclaw gateway` | Iniciar o gateway (para bots de chat) | +| `picoclaw status` | Mostrar status | +| `picoclaw cron list` | Listar todas as tarefas agendadas | +| `picoclaw cron add ...` | Adicionar uma tarefa agendada | + +### Tarefas Agendadas / Lembretes + +O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: + +* **Lembretes unicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez apos 10min +* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas +* **Expressoes Cron**: "Remind me at 9am daily" (Me lembre as 9h todos os dias) → usa expressao cron + +As tarefas sao armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. + +## 🤝 Contribuir & Roadmap + +PRs sao bem-vindos! O codigo-fonte e intencionalmente pequeno e legivel. 🤗 + +Roadmap em breve... + +Grupo de desenvolvedores em formacao. Requisito de entrada: Pelo menos 1 PR com merge. + +Grupos de usuarios: + +Discord: + +PicoClaw + +## 🐛 Solucao de Problemas + +### Busca web mostra "API 配置问题" + +Isso e normal se voce ainda nao configurou uma API key de busca. O PicoClaw fornecera links uteis para busca manual. + +Para habilitar a busca web: + +1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados. +2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). + +Adicione a key em `~/.picoclaw/config.json` se usar o Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Erros de filtragem de conteudo + +Alguns provedores (como Zhipu) possuem filtragem de conteudo. Tente reformular sua pergunta ou use um modelo diferente. + +### Bot do Telegram diz "Conflict: terminated by other getUpdates" + +Isso acontece quando outra instancia do bot esta rodando. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. + +--- + +## 📝 Comparacao de API Keys + +| Servico | Plano Gratuito | Caso de Uso | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/mes | Multiplos modelos (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/mes | Melhor para usuarios chineses | +| **Brave Search** | 2000 consultas/mes | Funcionalidade de busca web | +| **Groq** | Plano gratuito disponivel | Inferencia ultra-rapida (Llama, Mixtral) | diff --git a/README.zh.md b/README.zh.md index e7dc8d769..6c87ba785 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ Twitter

- **中文** | [日本語](README.ja.md) | [English](README.md) + **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [English](README.md) --- From 01d694b9985a66c3d7119fc9f74ce8ed4f0f21b5 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:33:34 +0800 Subject: [PATCH 24/56] fix: Add comprehensive command injection and system abuse prevention patterns (#401) * Add comprehensive command injection and system abuse prevention patterns * fix: Container running as root --- Dockerfile | 9 ++++++++- docker-compose.yml | 8 ++++---- pkg/tools/shell.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd98ec0bd..0360cfda6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,14 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw -# Create picoclaw home directory +# Create non-root user and group +RUN addgroup -g 1000 picoclaw && \ + adduser -D -u 1000 -G picoclaw picoclaw + +# Switch to non-root user +USER picoclaw + +# Run onboard to create initial directories and config RUN /usr/local/bin/picoclaw onboard ENTRYPOINT ["picoclaw"] diff --git a/docker-compose.yml b/docker-compose.yml index 48769627c..32e8ee339 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: profiles: - agent volumes: - - ./config/config.json:/root/.picoclaw/config.json:ro - - picoclaw-workspace:/root/.picoclaw/workspace + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true @@ -31,9 +31,9 @@ services: - gateway volumes: # Configuration file - - ./config/config.json:/root/.picoclaw/config.json:ro + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro # Persistent workspace (sessions, memory, logs) - - picoclaw-workspace:/root/.picoclaw/workspace + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace command: ["gateway"] volumes: diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 713850f97..9c82b2748 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -31,6 +31,40 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), } return &ExecTool{ From 193fbcab11fe3c448f982f43e7837585e843acea Mon Sep 17 00:00:00 2001 From: lxowalle Date: Wed, 18 Feb 2026 16:01:41 +0800 Subject: [PATCH 25/56] docs: update PR template --- .github/pull_request_template.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7910cb1e2..c96b7da12 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,7 @@ ## 📝 Description + + + ## 🗣️ Type of Change - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) @@ -11,25 +14,28 @@ - [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none) -## 🔗 Linked Issue +## 🔗 Related Issue + + + ## 📚 Technical Context (Skip for Docs) -* **Reference:** [URL] -* **Reasoning:** ... +- **Reference URL:** +- **Reasoning:** + +## 🧪 Test Environment +- **Hardware:** +- **OS:** +- **Model/Provider:** +- **Channels:** -## 🧪 Test Environment & Hardware -- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC] -- **OS:** [e.g. Debian 12, Ubuntu 22.04] -- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3] -- **Channels:** [e.g. Discord, Telegram, Feishu, ...] - - -## 📸 Proof of Work (Optional for Docs) +## 📸 Evidence (Optional)
Click to view Logs/Screenshots -
+ +
## ☑️ Checklist - [ ] My code/docs follow the style of this project. From 3390576eeacb97bdd3da95b156e226ab72ee0929 Mon Sep 17 00:00:00 2001 From: Zenix Date: Wed, 18 Feb 2026 17:30:30 +0900 Subject: [PATCH 26/56] Feature/websearch OpenAI (#118) * feature: add web search for codex models * fix: use more elegant way to solve the issue. --- config/config.example.json | 5 +- pkg/config/config.go | 33 ++++--- pkg/config/config_test.go | 39 ++++++++ pkg/migrate/config.go | 12 ++- pkg/providers/codex_provider.go | 37 +++++--- pkg/providers/codex_provider_test.go | 129 +++++++++++++++++++++++++-- pkg/providers/http_provider.go | 14 +-- 7 files changed, 230 insertions(+), 39 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 7cd0ab8c6..37c2bcd81 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -79,7 +79,8 @@ }, "openai": { "api_key": "", - "api_base": "" + "api_base": "", + "web_search": true }, "openrouter": { "api_key": "sk-or-v1-xxx", @@ -144,4 +145,4 @@ "host": "0.0.0.0", "port": 18790 } -} \ No newline at end of file +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d34f56f3..92a4a5862 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -167,19 +167,19 @@ type DevicesConfig struct { } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Ollama ProviderConfig `json:"ollama"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` - DeepSeek ProviderConfig `json:"deepseek"` - GitHubCopilot ProviderConfig `json:"github_copilot"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI OpenAIProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Zhipu ProviderConfig `json:"zhipu"` + VLLM ProviderConfig `json:"vllm"` + Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` + Ollama ProviderConfig `json:"ollama"` + Moonshot ProviderConfig `json:"moonshot"` + ShengSuanYun ProviderConfig `json:"shengsuanyun"` + DeepSeek ProviderConfig `json:"deepseek"` + GitHubCopilot ProviderConfig `json:"github_copilot"` } type ProviderConfig struct { @@ -190,6 +190,11 @@ type ProviderConfig struct { ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` } +type OpenAIProviderConfig struct { + ProviderConfig + WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` +} + type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` @@ -308,7 +313,7 @@ func DefaultConfig() *Config { }, Providers: ProvidersConfig{ Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, + OpenAI: OpenAIProviderConfig{WebSearch: true}, OpenRouter: ProviderConfig{}, Groq: ProviderConfig{}, Zhipu: ProviderConfig{}, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index febfd0456..a1f73f0b3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -204,3 +204,42 @@ func TestConfig_Complete(t *testing.T) { t.Error("Heartbeat should be enabled by default") } } + +func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") + } +} + +func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should remain true when unset in config file") + } +} + +func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should be false when disabled in config file") + } +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 9c1e36359..57032e566 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -108,7 +108,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error case "anthropic": cfg.Providers.Anthropic = pc case "openai": - cfg.Providers.OpenAI = pc + cfg.Providers.OpenAI = config.OpenAIProviderConfig{ + ProviderConfig: pc, + WebSearch: getBoolOrDefault(pMap, "web_search", true), + } case "openrouter": cfg.Providers.OpenRouter = pc case "groq": @@ -363,6 +366,13 @@ func getBool(data map[string]interface{}, key string) (bool, bool) { return b, ok } +func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool { + if v, ok := getBool(data, key); ok { + return v + } + return defaultVal +} + func getStringSlice(data map[string]interface{}, key string) []string { v, ok := data[key] if !ok { diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 7617bf716..e3526cfb5 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -18,9 +18,10 @@ const codexDefaultModel = "gpt-5.2" const codexDefaultInstructions = "You are Codex, a coding assistant." type CodexProvider struct { - client *openai.Client - accountID string - tokenSource func() (string, string, error) + client *openai.Client + accountID string + tokenSource func() (string, string, error) + enableWebSearch bool } const defaultCodexInstructions = "You are Codex, a coding assistant." @@ -37,8 +38,9 @@ func NewCodexProvider(token, accountID string) *CodexProvider { } client := openai.NewClient(opts...) return &CodexProvider{ - client: &client, - accountID: accountID, + client: &client, + accountID: accountID, + enableWebSearch: true, } } @@ -78,7 +80,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To }) } - params := buildCodexParams(messages, tools, resolvedModel, options) + params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) stream := p.client.Responses.NewStreaming(ctx, params, opts...) defer stream.Close() @@ -182,7 +184,7 @@ func resolveCodexModel(model string) (string, string) { return codexDefaultModel, "unsupported model family" } -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams { +func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams { var inputItems responses.ResponseInputParam var instructions string @@ -266,8 +268,8 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } - if len(tools) > 0 { - params.Tools = translateToolsForCodex(tools) + if len(tools) > 0 || enableWebSearch { + params.Tools = translateToolsForCodex(tools, enableWebSearch) } return params @@ -297,9 +299,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) return name, "{}", true } -func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { - result := make([]responses.ToolUnionParam, 0, len(tools)) +func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { + capHint := len(tools) + if enableWebSearch { + capHint++ + } + result := make([]responses.ToolUnionParam, 0, capHint) for _, t := range tools { + if t.Type != "function" { + continue + } + if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } ft := responses.FunctionToolParam{ Name: t.Function.Name, Parameters: t.Function.Parameters, @@ -310,6 +322,9 @@ func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { } result = append(result, responses.ToolUnionParam{OfFunction: &ft}) } + if enableWebSearch { + result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) + } return result } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 8406760c4..92e276165 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -19,7 +19,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ "max_tokens": 2048, "temperature": 0.7, - }) + }, true) if params.Model != "gpt-4o" { t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") } @@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true) if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } @@ -59,7 +59,7 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { {Role: "tool", Content: "ok", ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -123,7 +123,7 @@ func TestBuildCodexParams_WithTools(t *testing.T) { }, }, } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } @@ -136,12 +136,61 @@ func TestBuildCodexParams_WithTools(t *testing.T) { } func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false) if !params.Store.Valid() || params.Store.Or(true) != false { t.Error("Store should be explicitly set to false") } } +func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 1 { + t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) + } + if params.Tools[0].OfWebSearch == nil { + t.Fatal("Tool should include built-in web_search") + } + if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch { + t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch) + } +} + +func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "web_search", + Description: "local web search", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "read_file", + Description: "read file", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + } + + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 2 { + t.Fatalf("len(Tools) = %d, want 2", len(params.Tools)) + } + if params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != "read_file" { + t.Fatalf("first tool should be function read_file, got %#v", params.Tools[0]) + } + if params.Tools[1].OfWebSearch == nil { + t.Fatalf("second tool should be built-in web_search, got %#v", params.Tools[1]) + } +} + func TestParseCodexResponse_TextOutput(t *testing.T) { respJSON := `{ "id": "resp_test", @@ -260,6 +309,16 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) return } + toolsAny, ok := reqBody["tools"].([]interface{}) + if !ok || len(toolsAny) != 1 { + http.Error(w, "missing default web search tool", http.StatusBadRequest) + return + } + toolObj, ok := toolsAny[0].(map[string]interface{}) + if !ok || toolObj["type"] != "web_search" { + http.Error(w, "expected web_search tool", http.StatusBadRequest) + return + } resp := map[string]interface{}{ "id": "resp_test", @@ -307,6 +366,64 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if _, ok := reqBody["tools"]; ok { + http.Error(w, "tools should be absent when web search disabled", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]interface{}{ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]interface{}{ + {"type": "output_text", "text": "Hi from Codex!"}, + }, + }, + }, + "usage": map[string]interface{}{ + "input_tokens": 4, + "output_tokens": 3, + "total_tokens": 7, + "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, + "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.enableWebSearch = false + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hi from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") + } +} + func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 4cf2c6db2..946aa29d2 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -208,7 +208,7 @@ func createClaudeAuthProvider() (LLMProvider, error) { return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } -func createCodexAuthProvider() (LLMProvider, error) { +func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) { cred, err := auth.GetCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) @@ -216,7 +216,9 @@ func createCodexAuthProvider() (LLMProvider, error) { 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 + p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()) + p.enableWebSearch = enableWebSearch + return p, nil } func CreateProvider(cfg *config.Config) (LLMProvider, error) { @@ -241,10 +243,12 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { case "openai", "gpt": if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - return NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()), nil + c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()) + c.enableWebSearch = cfg.Providers.OpenAI.WebSearch + return c, nil } if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() + return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch) } apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase @@ -369,7 +373,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { 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() + return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch) } apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase From 994ec72d917ff3cf7c3d2a4df39b8b2a66626849 Mon Sep 17 00:00:00 2001 From: harshbansal7 Date: Wed, 18 Feb 2026 16:55:20 +0530 Subject: [PATCH 27/56] Fix parsing of SKILL.md file frontmatter - regex --- pkg/skills/loader.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 0c63ae067..15e82c31b 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -9,6 +9,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/sipeed/picoclaw/pkg/logger" ) var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) @@ -251,6 +253,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string { func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { content, err := os.ReadFile(skillPath) if err != nil { + logger.WarnCF("skills", "Failed to read skill metadata", + map[string]interface{}{ + "skill_path": skillPath, + "error": err.Error(), + }) return nil } @@ -306,9 +313,9 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { } func (sl *SkillsLoader) extractFrontmatter(content string) string { - // (?s) enables DOTALL mode so . matches newlines - // Match first ---, capture everything until next --- on its own line - re := regexp.MustCompile(`(?s)^---\n(.*)\n---`) + // Support both Unix (\n) and Windows (\r\n) line endings for frontmatter blocks + // (?s) enables DOTALL so . matches newlines; ^--- at start, then ... --- at start of line + re := regexp.MustCompile(`(?s)^---\r?\n(.*?)\r?\n---`) match := re.FindStringSubmatch(content) if len(match) > 1 { return match[1] From 02b5811b95abf1b6102f1cbce2afc9cace1f3cb4 Mon Sep 17 00:00:00 2001 From: harshbansal7 Date: Wed, 18 Feb 2026 16:58:27 +0530 Subject: [PATCH 28/56] add support for \r as well --- pkg/skills/loader.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 15e82c31b..c9731b6ae 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -313,9 +313,10 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { } func (sl *SkillsLoader) extractFrontmatter(content string) string { - // Support both Unix (\n) and Windows (\r\n) line endings for frontmatter blocks - // (?s) enables DOTALL so . matches newlines; ^--- at start, then ... --- at start of line - re := regexp.MustCompile(`(?s)^---\r?\n(.*?)\r?\n---`) + // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks + // (?s) enables DOTALL so . matches newlines; + // ^--- at start, then ... --- at start of line, honoring all three line ending types + re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`) match := re.FindStringSubmatch(content) if len(match) > 1 { return match[1] From eda6e373323861fea99630013946d7aeea94ade0 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:31:15 +0800 Subject: [PATCH 29/56] feat: Support modifying the command filtering list of the exec tool (#410) --- cmd/picoclaw/main.go | 6 +- docs/tools_configuration.md | 122 ++++++++++++++++++++++++++++++++++++ pkg/agent/loop.go | 2 +- pkg/config/config.go | 9 +++ pkg/tools/cron.go | 7 ++- pkg/tools/shell.go | 119 ++++++++++++++++++++++------------- 6 files changed, 215 insertions(+), 50 deletions(-) create mode 100644 docs/tools_configuration.md diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index fd7ec484a..128f8c421 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -563,7 +563,7 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout) + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -988,14 +988,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *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, restrict, execTimeout) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md new file mode 100644 index 000000000..8777ddbd6 --- /dev/null +++ b/docs/tools_configuration.md @@ -0,0 +1,122 @@ +# Tools Configuration + +PicoClaw's tools configuration is located in the `tools` field of `config.json`. + +## Directory Structure + +```json +{ + "tools": { + "web": { ... }, + "exec": { ... }, + "approval": { ... }, + "cron": { ... } + } +} +``` + +## Web Tools + +Web tools are used for web search and fetching. + +### Brave + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `max_results` | int | 5 | Maximum number of results | + +### DuckDuckGo + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable DuckDuckGo search | +| `max_results` | int | 5 | Maximum number of results | + +### Perplexity + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `max_results` | int | 5 | Maximum number of results | + +## Exec Tool + +The exec tool is used to execute shell commands. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | +| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | + +### Functionality + +- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns +- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked + +### Default Blocked Command Patterns + +By default, PicoClaw blocks the following dangerous commands: + +- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` +- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` +- System operations: `shutdown`, `reboot`, `poweroff` +- Command substitution: `$()`, `${}`, backticks +- Pipe to shell: `| sh`, `| bash` +- Privilege escalation: `sudo`, `chmod`, `chown` +- Process control: `pkill`, `killall`, `kill -9` +- Remote operations: `curl | sh`, `wget | sh`, `ssh` +- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Containers: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Other: `eval`, `source *.sh` + +### Configuration Example + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ], + } + } +} +``` + +## Approval Tool + +The approval tool controls permissions for dangerous operations. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable approval functionality | +| `write_file` | bool | true | Require approval for file writes | +| `edit_file` | bool | true | Require approval for file edits | +| `append_file` | bool | true | Require approval for file appends | +| `exec` | bool | true | Require approval for command execution | +| `timeout_minutes` | int | 5 | Approval timeout in minutes | + +## Cron Tool + +The cron tool is used for scheduling periodic tasks. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | + +## Environment Variables + +All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: + +For example: +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` + +Note: Array-type environment variables are not currently supported and must be set via the config file. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d3afa298e..8c6c58c96 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -71,7 +71,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg registry.Register(tools.NewAppendFileTool(workspace, restrict)) // Shell execution - registry.Register(tools.NewExecTool(workspace, restrict)) + registry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKey: cfg.Tools.Web.Brave.APIKey, diff --git a/pkg/config/config.go b/pkg/config/config.go index 92a4a5862..a1cc978b6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -227,9 +227,15 @@ type CronToolsConfig struct { ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout } +type ExecConfig struct { + EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` +} + type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` } func DefaultConfig() *Config { @@ -347,6 +353,9 @@ func DefaultConfig() *Config { Cron: CronToolsConfig{ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations }, + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 21bee42ef..e2764d8ac 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -29,9 +30,9 @@ type CronTool struct { // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool { - execTool := NewExecTool(workspace, restrict) - execTool.SetTimeout(execTimeout) // 0 means no timeout +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool { + execTool := NewExecToolWithConfig(workspace, restrict, config) + execTool.SetTimeout(execTimeout) return &CronTool{ cronService: cronService, executor: executor, diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 9c82b2748..bd612d9ae 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "github.com/sipeed/picoclaw/pkg/config" "os" "os/exec" "path/filepath" @@ -21,50 +22,82 @@ type ExecTool struct { restrictToWorkspace bool } +var defaultDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), + regexp.MustCompile(`\brmdir\s+/s\b`), + regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) + regexp.MustCompile(`\bdd\s+if=`), + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), +} + func NewExecTool(workingDir string, restrict bool) *ExecTool { - denyPatterns := []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), - regexp.MustCompile(`\brmdir\s+/s\b`), - regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) - regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) - regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), - regexp.MustCompile(`\$\([^)]+\)`), - regexp.MustCompile(`\$\{[^}]+\}`), - regexp.MustCompile("`[^`]+`"), - regexp.MustCompile(`\|\s*sh\b`), - regexp.MustCompile(`\|\s*bash\b`), - regexp.MustCompile(`;\s*rm\s+-[rf]`), - regexp.MustCompile(`&&\s*rm\s+-[rf]`), - regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), - regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), - regexp.MustCompile(`<<\s*EOF`), - regexp.MustCompile(`\$\(\s*cat\s+`), - regexp.MustCompile(`\$\(\s*curl\s+`), - regexp.MustCompile(`\$\(\s*wget\s+`), - regexp.MustCompile(`\$\(\s*which\s+`), - regexp.MustCompile(`\bsudo\b`), - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), - regexp.MustCompile(`\bchown\b`), - regexp.MustCompile(`\bpkill\b`), - regexp.MustCompile(`\bkillall\b`), - regexp.MustCompile(`\bkill\s+-[9]\b`), - regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bnpm\s+install\s+-g\b`), - regexp.MustCompile(`\bpip\s+install\s+--user\b`), - regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), - regexp.MustCompile(`\byum\s+(install|remove)\b`), - regexp.MustCompile(`\bdnf\s+(install|remove)\b`), - regexp.MustCompile(`\bdocker\s+run\b`), - regexp.MustCompile(`\bdocker\s+exec\b`), - regexp.MustCompile(`\bgit\s+push\b`), - regexp.MustCompile(`\bgit\s+force\b`), - regexp.MustCompile(`\bssh\b.*@`), - regexp.MustCompile(`\beval\b`), - regexp.MustCompile(`\bsource\s+.*\.sh\b`), + return NewExecToolWithConfig(workingDir, restrict, nil) +} + +func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool { + denyPatterns := make([]*regexp.Regexp, 0) + + enableDenyPatterns := true + if config != nil { + execConfig := config.Tools.Exec + enableDenyPatterns = execConfig.EnableDenyPatterns + if enableDenyPatterns { + if len(execConfig.CustomDenyPatterns) > 0 { + fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) + for _, pattern := range execConfig.CustomDenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err) + continue + } + denyPatterns = append(denyPatterns, re) + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) + } + } else { + // If deny patterns are disabled, we won't add any patterns, allowing all commands. + fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) } return &ExecTool{ From f8bd88338701730cd638eac360631e0c8c8d2de8 Mon Sep 17 00:00:00 2001 From: Daniel Venturini Date: Wed, 18 Feb 2026 11:11:41 -0300 Subject: [PATCH 30/56] docs(readme): add brazilian accentuation on pt-br README --- README.pt-br.md | 275 ++++++++++++++++++++++++------------------------ 1 file changed, 138 insertions(+), 137 deletions(-) diff --git a/README.pt-br.md b/README.pt-br.md index d250cc956..fa73465dd 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -39,48 +39,49 @@ > [!CAUTION] -> **🚨 DECLARACAO DE SEGURANCA & CANAIS OFICIAIS** +> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS** > -> * **SEM CRIPTOMOEDAS:** O PicoClaw **NAO** possui nenhum token/moeda oficial. Todas as alegacoes no `pump.fun` ou outras plataformas de negociacao sao **GOLPES**. -> * **DOMINIO OFICIAL:** O **UNICO** site oficial e **[picoclaw.io](https://picoclaw.io)**, e o site da empresa e **[sipeed.com](https://sipeed.com)**. -> * **Aviso:** Muitos dominios `.ai/.org/.com/.net/...` foram registrados por terceiros, nao sao nossos. -> * **Aviso:** O PicoClaw esta em fase inicial de desenvolvimento e pode ter problemas de seguranca de rede nao resolvidos. Nao implante em ambientes de producao antes da versao v1.0. -> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memoria (10-20MB) nas versoes mais recentes. Planejamos priorizar a otimizacao de recursos assim que o conjunto de funcionalidades estiver estavel. +> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**. +> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**. +> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos. +> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0. +> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10-20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável. ## 📢 Novidades -2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw esta crescendo mais rapido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papeis de voluntarios e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter voce a bordo! +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo! -2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado a comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. -🚀 **Chamada para Acao:** Envie suas solicitacoes de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na proxima reuniao semanal. +2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. -2026-02-09 🎉 PicoClaw lancado oficialmente! Construido em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! +🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal. + +2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! ## ✨ Funcionalidades -🪶 **Ultra-Leve**: Consumo de memoria <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. +🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. -💰 **Custo Minimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. +💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. -⚡️ **Inicializacao Relampago**: Tempo de inicializacao 400X mais rapido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. +⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. -🌍 **Portabilidade Real**: Um unico binario auto-contido para RISC-V, ARM e x86. Um clique e ja era! +🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era! -🤖 **Auto-Construido por IA**: Implementacao nativa em Go de forma autonoma — 95% do nucleo gerado pelo Agente com refinamento humano no loop. +🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop. | | OpenClaw | NanoBot | **PicoClaw** | | ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | | **Linguagem** | TypeScript | Python | **Go** | | **RAM** | >1GB | >100MB | **< 10MB** | -| **Inicializacao**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Inicialização**
(CPU 0.8GHz) | >500s | >30s | **<1s** | | **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | PicoClaw -## 🦾 Demonstracao +## 🦾 Demonstração -### 🛠️ Fluxos de Trabalho Padrao do Assistente +### 🛠️ Fluxos de Trabalho Padrão do Assistente @@ -96,15 +97,15 @@ - +
Desenvolver • Implantar • Escalar Agendar • Automatizar • MemorizarDescobrir • Analisar • TendenciasDescobrir • Analisar • Tendências
### 📱 Rode em celulares Android antigos -De uma segunda vida ao seu celular de dez anos atras! Transforme-o em um assistente de IA inteligente com o PicoClaw. Inicio rapido: +Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido: -1. **Instale o Termux** (Disponivel no F-Droid ou Google Play). +1. **Instale o Termux** (Disponível no F-Droid ou Google Play). 2. **Execute os comandos** ```bash @@ -115,29 +116,29 @@ pkg install proot termux-chroot ./picoclaw-linux-arm64 onboard ``` -Depois siga as instrucoes na secao "Inicio Rapido" para completar a configuracao! +Depois siga as instruções na seção "Início Rápido" para completar a configuração! PicoClaw -### 🐜 Implantacao Inovadora com Baixo Consumo +### 🐜 Implantação Inovadora com Baixo Consumo O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versao E (Ethernet) ou W (WiFi6), para Assistente Domestico Minimalista -- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutencao Automatizada de Servidores +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E (Ethernet) ou W (WiFi6), para Assistente Doméstico Minimalista +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 -🌟 Mais cenarios de implantacao aguardam voce! +🌟 Mais cenários de implantação aguardam você! -## 📦 Instalacao +## 📦 Instalação -### Instalar com binario pre-compilado +### Instalar com binário pré-compilado -Baixe o binario para sua plataforma na pagina de [releases](https://github.com/sipeed/picoclaw/releases). +Baixe o binário para sua plataforma na página de [releases](https://github.com/sipeed/picoclaw/releases). -### Instalar a partir do codigo-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) +### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) ```bash git clone https://github.com/sipeed/picoclaw.git @@ -157,7 +158,7 @@ make install ## 🐳 Docker Compose -Voce tambem pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. +Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. ```bash # 1. Clone este repositorio @@ -178,7 +179,7 @@ docker compose logs -f picoclaw-gateway docker compose --profile gateway down ``` -### Modo Agente (Execucao unica) +### Modo Agente (Execução única) ```bash # Fazer uma pergunta @@ -195,12 +196,12 @@ docker compose --profile gateway build --no-cache docker compose --profile gateway up -d ``` -### 🚀 Inicio Rapido +### 🚀 Início Rápido > [!TIP] > Configure sua API key em `~/.picoclaw/config.json`. > Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas gratis/mes) ou use o fallback automatico integrado. +> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado. **1. Inicializar** @@ -246,9 +247,9 @@ picoclaw onboard **3. Obter API Keys** * **Provedor de 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) -* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponivel (2000 consultas/mes) +* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponível (2000 consultas/mês) -> **Nota**: Veja `config.example.json` para um modelo de configuracao completo. +> **Nota**: Veja `config.example.json` para um modelo de configuração completo. **4. Conversar** @@ -256,21 +257,21 @@ picoclaw onboard picoclaw agent -m "Quanto e 2+2?" ``` -Pronto! Voce tem um assistente de IA funcionando em 2 minutos. +Pronto! Você tem um assistente de IA funcionando em 2 minutos. --- -## 💬 Integracao com Apps de Chat +## 💬 Integração com Apps de Chat Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE. -| Canal | Nivel de Configuracao | +| Canal | Nível de Configuração | | --- | --- | -| **Telegram** | Facil (apenas um token) | -| **Discord** | Facil (bot token + intents) | -| **QQ** | Facil (AppID + AppSecret) | -| **DingTalk** | Medio (credenciais do app) | -| **LINE** | Medio (credenciais + webhook URL) | +| **Telegram** | Fácil (apenas um token) | +| **Discord** | Fácil (bot token + intents) | +| **QQ** | Fácil (AppID + AppSecret) | +| **DingTalk** | Médio (credenciais do app) | +| **LINE** | Médio (credenciais + webhook URL) |
Telegram (Recomendado) @@ -278,7 +279,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE. **1. Criar o bot** * Abra o Telegram, busque `@BotFather` -* Envie `/newbot`, siga as instrucoes +* Envie `/newbot`, siga as instruções * Copie o token **2. Configurar** @@ -316,13 +317,13 @@ picoclaw gateway **2. Habilitar Intents** -* Nas configuracoes do Bot, habilite **MESSAGE CONTENT INTENT** -* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissoes baseada em dados dos membros +* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros **3. Obter seu User ID** -* Configuracoes do Discord → Avancado → habilite **Modo Desenvolvedor** -* Clique com botao direito no seu avatar → **Copiar ID do Usuario** +* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor** +* Clique com botão direito no seu avatar → **Copiar ID do Usuário** **4. Configurar** @@ -376,7 +377,7 @@ picoclaw gateway } ``` -> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique numeros QQ para restringir o acesso. +> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso. **3. Executar** @@ -410,7 +411,7 @@ picoclaw gateway } ``` -> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique IDs para restringir o acesso. +> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso. **3. Executar** @@ -466,44 +467,44 @@ picoclaw gateway > Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. -> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao servico `picoclaw-gateway` para expor a porta do webhook. +> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
## ClawdChat Junte-se a Rede Social de Agentes -Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mensagem via CLI ou qualquer App de Chat integrado. +Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. -**Leia `https://clawdchat.ai/skill.md` e siga as instrucoes para entrar no [ClawdChat.ai](https://clawdchat.ai)** +**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)** -## ⚙️ Configuracao Detalhada +## ⚙️ Configuração Detalhada -Arquivo de configuracao: `~/.picoclaw/config.json` +Arquivo de configuração: `~/.picoclaw/config.json` ### Estrutura do Workspace -O PicoClaw armazena dados no workspace configurado (padrao: `~/.picoclaw/workspace`): +O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`): ``` ~/.picoclaw/workspace/ ├── sessions/ # Sessoes de conversa e historico -├── memory/ # Memoria de longo prazo (MEMORY.md) -├── state/ # Estado persistente (ultimo canal, etc.) -├── cron/ # Banco de dados de tarefas agendadas -├── skills/ # Skills personalizadas -├── AGENTS.md # Guia de comportamento do Agente -├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) -├── IDENTITY.md # Identidade do Agente -├── SOUL.md # Alma do Agente -├── TOOLS.md # Descricao das ferramentas -└── USER.md # Preferencias do usuario +├── memory/ # Memoria de longo prazo (MEMORY.md) +├── state/ # Estado persistente (ultimo canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENTS.md # Guia de comportamento do Agente +├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) +├── IDENTITY.md # Identidade do Agente +├── SOUL.md # Alma do Agente +├── TOOLS.md # Descrição das ferramentas +└── USER.md # Preferencias do usuario ``` -### 🔒 Sandbox de Seguranca +### 🔒 Sandbox de Segurança -O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. +O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. -#### Configuracao Padrao +#### Configuração Padrão ```json { @@ -516,16 +517,16 @@ O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arqu } ``` -| Opcao | Padrao | Descricao | +| Opção | Padrão | Descrição | |-------|--------|-----------| -| `workspace` | `~/.picoclaw/workspace` | Diretorio de trabalho do agente | +| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente | | `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace | #### Ferramentas Protegidas -Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao sandbox: +Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox: -| Ferramenta | Funcao | Restricao | +| Ferramenta | Função | Restrição | |------------|--------|-----------| | `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | | `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace | @@ -534,13 +535,13 @@ Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao | `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace | | `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace | -#### Protecao Adicional do Exec +#### Proteção Adicional do Exec Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: -* `rm -rf`, `del /f`, `rmdir /s` — Exclusao em massa -* `format`, `mkfs`, `diskpart` — Formatacao de disco -* `dd if=` — Criacao de imagem de disco +* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa +* `format`, `mkfs`, `diskpart` — Formatação de disco +* `dd if=` — Criação de imagem de disco * Escrita em `/dev/sd[a-z]` — Escrita direta no disco * `shutdown`, `reboot`, `poweroff` — Desligamento do sistema * Fork bomb `:(){ :|:& };:` @@ -557,11 +558,11 @@ Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes com {tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} ``` -#### Desabilitar Restricoes (Risco de Seguranca) +#### Desabilitar Restrições (Risco de Segurança) -Se voce precisa que o agente acesse caminhos fora do workspace: +Se você precisa que o agente acesse caminhos fora do workspace: -**Metodo 1: Arquivo de configuracao** +**Método 1: Arquivo de configuração** ```json { @@ -573,29 +574,29 @@ Se voce precisa que o agente acesse caminhos fora do workspace: } ``` -**Metodo 2: Variavel de ambiente** +**Método 2: Variável de ambiente** ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` -> ⚠️ **Aviso**: Desabilitar esta restricao permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. +> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. -#### Consistencia do Limite de Seguranca +#### Consistência do Limite de Segurança -A configuracao `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execucao: +A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução: -| Caminho de Execucao | Limite de Seguranca | +| Caminho de Execução | Limite de Segurança | |----------------------|---------------------| | Agente Principal | `restrict_to_workspace` ✅ | -| Subagente / Spawn | Herda a mesma restricao ✅ | -| Tarefas Heartbeat | Herda a mesma restricao ✅ | +| Subagente / Spawn | Herda a mesma restrição ✅ | +| Tarefas Heartbeat | Herda a mesma restrição ✅ | -Todos os caminhos compartilham a mesma restricao de workspace — nao ha como contornar o limite de seguranca por meio de subagentes ou tarefas agendadas. +Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas. -### Heartbeat (Tarefas Periodicas) +### Heartbeat (Tarefas Periódicas) -O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: +O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: ```markdown # Tarefas Periodicas @@ -605,51 +606,51 @@ O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HE - Verificar a previsao do tempo ``` -O agente lera este arquivo a cada 30 minutos (configuravel) e executara as tarefas usando as ferramentas disponiveis. +O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis. #### Tarefas Assincronas com Spawn -Para tarefas de longa duracao (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: +Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: ```markdown -# Tarefas Periodicas +# Tarefas Periódicas -## Tarefas Rapidas (resposta direta) +## Tarefas Rápidas (resposta direta) - Informar hora atual ## Tarefas Longas (usar spawn para async) -- Buscar noticias de IA na web e resumir +- Buscar notícias de IA na web e resumir - Verificar email e reportar mensagens importantes ``` **Comportamentos principais:** -| Funcionalidade | Descricao | +| Funcionalidade | Descrição | |----------------|-----------| -| **spawn** | Cria subagente assincrono, nao bloqueia o heartbeat | -| **Contexto independente** | Subagente tem seu proprio contexto, sem historico de sessao | -| **Ferramenta message** | Subagente se comunica diretamente com o usuario via ferramenta message | -| **Nao-bloqueante** | Apos o spawn, o heartbeat continua para a proxima tarefa | +| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat | +| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão | +| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message | +| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa | -#### Como Funciona a Comunicacao do Subagente +#### Como Funciona a Comunicação do Subagente ``` Heartbeat dispara ↓ -Agente le HEARTBEAT.md +Agente lê HEARTBEAT.md ↓ Para tarefa longa: spawn subagente ↓ ↓ -Continua proxima tarefa Subagente trabalha independentemente +Continua próxima tarefa Subagente trabalha independentemente ↓ ↓ -Todas tarefas concluidas Subagente usa ferramenta "message" +Todas tarefas concluídas Subagente usa ferramenta "message" ↓ ↓ -Responde HEARTBEAT_OK Usuario recebe resultado diretamente +Responde HEARTBEAT_OK Usuário recebe resultado diretamente ``` -O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comunicar com o usuario independentemente sem passar pelo agente principal. +O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal. -**Configuracao:** +**Configuração:** ```json { @@ -660,12 +661,12 @@ O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comu } ``` -| Opcao | Padrao | Descricao | +| Opção | Padrão | Descrição | |-------|--------|-----------| | `enabled` | `true` | Habilitar/desabilitar heartbeat | -| `interval` | `30` | Intervalo de verificacao em minutos (min: 5) | +| `interval` | `30` | Intervalo de verificação em minutos (min: 5) | -**Variaveis de ambiente:** +**Variáveis de ambiente:** * `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar * `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo @@ -673,7 +674,7 @@ O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comu ### Provedores > [!NOTE] -> O Groq fornece transcricao de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serao automaticamente transcritas. +> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas. | Provedor | Finalidade | Obter API Key | | --- | --- | --- | @@ -683,10 +684,10 @@ O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comu | `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | | `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | | `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | -| `groq` | LLM + **Transcricao de voz** (Whisper) | [console.groq.com](https://console.groq.com) | +| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
-Configuracao Zhipu +Configuração Zhipu **1. Obter API key** @@ -723,7 +724,7 @@ picoclaw agent -m "Ola, como vai?"
-Exemplo de configuracao completa +Exemplo de configuraçao completa ```json { @@ -794,11 +795,11 @@ picoclaw agent -m "Ola, como vai?"
-## Referencia CLI +## Referência CLI -| Comando | Descricao | +| Comando | Descrição | | --- | --- | -| `picoclaw onboard` | Inicializar configuracao & workspace | +| `picoclaw onboard` | Inicializar configuração & workspace | | `picoclaw agent -m "..."` | Conversar com o agente | | `picoclaw agent` | Modo de chat interativo | | `picoclaw gateway` | Iniciar o gateway (para bots de chat) | @@ -810,36 +811,36 @@ picoclaw agent -m "Ola, como vai?" O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: -* **Lembretes unicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez apos 10min +* **Lembretes únicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez após 10min * **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas -* **Expressoes Cron**: "Remind me at 9am daily" (Me lembre as 9h todos os dias) → usa expressao cron +* **Expressões Cron**: "Remind me at 9am daily" (Me lembre às 9h todos os dias) → usa expressão cron -As tarefas sao armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. +As tarefas são armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. ## 🤝 Contribuir & Roadmap -PRs sao bem-vindos! O codigo-fonte e intencionalmente pequeno e legivel. 🤗 +PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗 Roadmap em breve... -Grupo de desenvolvedores em formacao. Requisito de entrada: Pelo menos 1 PR com merge. +Grupo de desenvolvedores em formação. Requisito de entrada: Pelo menos 1 PR com merge. -Grupos de usuarios: +Grupos de usuários: Discord: PicoClaw -## 🐛 Solucao de Problemas +## 🐛 Solução de Problemas ### Busca web mostra "API 配置问题" -Isso e normal se voce ainda nao configurou uma API key de busca. O PicoClaw fornecera links uteis para busca manual. +Isso é normal se você ainda não configurou uma API key de busca. O PicoClaw fornecerá links úteis para busca manual. Para habilitar a busca web: -1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados. -2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). +1. **Opção 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas grátis/mês) para os melhores resultados. +2. **Opção 2 (Sem Cartão de Crédito)**: Se você não tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). Adicione a key em `~/.picoclaw/config.json` se usar o Brave: @@ -861,21 +862,21 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave: } ``` -### Erros de filtragem de conteudo +### Erros de filtragem de conteúdo -Alguns provedores (como Zhipu) possuem filtragem de conteudo. Tente reformular sua pergunta ou use um modelo diferente. +Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente. ### Bot do Telegram diz "Conflict: terminated by other getUpdates" -Isso acontece quando outra instancia do bot esta rodando. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. +Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. --- -## 📝 Comparacao de API Keys +## 📝 Comparação de API Keys -| Servico | Plano Gratuito | Caso de Uso | +| Serviço | Plano Gratuito | Caso de Uso | | --- | --- | --- | -| **OpenRouter** | 200K tokens/mes | Multiplos modelos (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/mes | Melhor para usuarios chineses | -| **Brave Search** | 2000 consultas/mes | Funcionalidade de busca web | -| **Groq** | Plano gratuito disponivel | Inferencia ultra-rapida (Llama, Mixtral) | +| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses | +| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web | +| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) | From d6f052f6b153a14a05fa4449756216cc069f3b87 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Wed, 18 Feb 2026 16:23:31 +0200 Subject: [PATCH 31/56] feat(linters): Fixed golangci-lint version --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index df267aae8..dfefba19c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: - name: Golangci Lint uses: golangci/golangci-lint-action@v9 with: - version: latest + version: 2.10.1 # TODO: Remove once linter is properly configured fmt-check: From 272cabc627302b36fc385a35bd5603b7a088b4e1 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Wed, 18 Feb 2026 16:24:30 +0200 Subject: [PATCH 32/56] feat(linters): Fix version --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dfefba19c..55bf77e00 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: - name: Golangci Lint uses: golangci/golangci-lint-action@v9 with: - version: 2.10.1 + version: v2.10.1 # TODO: Remove once linter is properly configured fmt-check: From b88f4c9ab567c4c523f39cd53ae72cc8fb9b5faf Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Wed, 18 Feb 2026 16:24:55 +0200 Subject: [PATCH 33/56] feat(linters): Fix linter --- pkg/tools/shell.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index bd612d9ae..d9430672f 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "github.com/sipeed/picoclaw/pkg/config" "os" "os/exec" "path/filepath" @@ -12,6 +11,8 @@ import ( "runtime" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) type ExecTool struct { From df52d4ad0129a821050d02b9e514396a5d2d0e82 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Wed, 18 Feb 2026 16:26:35 +0200 Subject: [PATCH 34/56] feat(linters): Fix linter --- pkg/providers/claude_provider.go | 1 + pkg/providers/http_provider.go | 1 + 2 files changed, 2 insertions(+) diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index c72f5b0ef..3ca54d5a3 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -3,6 +3,7 @@ package providers import ( "context" "fmt" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index e39a19e90..967d089d5 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -8,6 +8,7 @@ package providers import ( "context" + "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) From 1b3da2ca29a49b281bc57f5ed7aba36504902bae Mon Sep 17 00:00:00 2001 From: zepan Date: Wed, 18 Feb 2026 23:03:24 +0800 Subject: [PATCH 35/56] 1. update wechat group qrcode --- assets/wechat.png | Bin 145550 -> 144319 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/wechat.png b/assets/wechat.png index 6e6f5011533dd3acbd2d0ecaf2d6f562004880d2..8fc41ea7d53cfc9e0ccb7b6fe5a4fd6d079cee7c 100644 GIT binary patch literal 144319 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 ZcS`}Nmr^nhTC?^iWQVnMgke}I{a<4CA~*m5 literal 145550 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 SKV+vK)H)|gJ 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 z zD0)KBRlWlmmm9R^jp;k`F?w%GGG13Rm9#Gx6x8UlcRx^5^8GSM-V_PgwTtzq>AnIF z-KCyeUskZ*%$M>Qo40_>-M-2r3)VRffIaZ-0fI;3{W(r$Bt0Sh(w3t%bAWjjT|rbn z^Orrk55-DW5U$bf(FDwvnlX1<%D@kM+MM#T6wK`fA^mG5UHe^`S8cm+G4aO$o=W#~ zvA$+?r)1q?Qe(Yj5&emNQxcYb2qNb^6_Mr2k(C{u9QBuWxTMLxVt=VT--M7>dV^4m zJs|T(&tCPtK4n|opJzny?V1_yha0Gin0=|Lw-TuhGj#`8iUqQ(32Xt);dxSVIias# zz2?zK#<%KO<@Wi{o%q|W?u>iP4>ZnkQt0{7t1?#3WJy^%B&o(_y`;L+mt8DhcLBaJ zNV>Rob_>31ldg;Ntz-RLdf98WR#4$456LFE@G8{bk4{0$2b+LOPASTq2wo1d6X^eX zs2~62vxRdJDv9Kyxx%iRi$6purN|FM9V!!!peeGhDpP0dILQ8}wrgcuPDQm)>FcM7 zdx&z?7?=@92bgJ2dh^D%kOmNRM>EQxQPRzfaQT;!Nams0?llG?ht77vCM&DTL^UyXPAj`C2D+3)s1fJ z6{IXaJFRvuy>er;o2ZW#M@VBAkZhfwx{}9|3b6ILriC9RgIYV?2DuF&8^AR`OVFRW z2cTLzu>Et3{{vx!!OnVSgvP)?!yy~uefVMZYmZ$6{#}l)RXQ2=K^#Xd>;46Q#h3`; z4}>+0;=?w-EAFROYVTs71Q~OG0qyJB&8hwfnql=)TI72nzE1`$!luqo7w{ z0FfmXX4#M{S8Fcy(sjKLy*XdCk}1g2y)f3qs!UJx0AL{mh*tu30S)qM-#8YTK`*nJ z<5AprP4kS|8O>curJ-qqt z=LX&kD+ez*-wD$_a!M!hC8v}65|^)Ag9ta61W>aK;$}~&*?^3bq8Gq2-9IwibciR) zmN(sTxwpr%Q#Z-6G&7rfR~!cq@v)6)h4ZcK&bRXTUt0;!8~}OJ_1e=^sSJwA%KVKg zdiJrXl$BFOuK2U^ycG~Chtgd0n`?ue2{lEc&jjG<%HyD4MW%aZ zQtcyGl7!QxBcl=8elgzDN#fWLe;Z6{iSlb#Mmz;K6v#{kxBLi8-o+8V)P#^7yvuLI z^|$zPyOdYmP1ZqM`dX~v(Ry}dm^6K}RvV)E; zunXWW_2bZ|)LcBkLGYoO_{wJu8)@8#6K{8-SwGItPCk77ZQ1?Vx6gE!TjtIoq{>aw z;$rsG9AEAYUqx(E8g~dL27~Wh>A&00+iCfl{ppw)GhZ>kOFDsK9JF===6C>(9ZxtlvLuNQ@tHcd(;v2e4#D5?FD5@c-udI;8C=!$( z>IV9vfxHtTJfFri+m7B{+sZYPO@~vDzt7ZRG;+;M7?6|gbk^YER#rVxL}+_HH*C*m zD~A{tZ>;alQ;G*C+K2dWNHd$+dm?Qq)llzgTb=DY?j8 zTr0i&<2y_=_0JC{MTjt;mudY0zoelzOL z!v@>_fWH<%H5D?8_ab}|!F8T-0oTZ_xScKFa@pZ5qYps)LraTm6$F1Yh0pu&X(g#9 zX|c;X=#Q96Ji4NBjH$HF1oNlyIRq<=I9Ge-G~8rHzPo^`wA;~A<8SDM$Av{P@;-C9 z<;HDLU2zTKJqU@4ixS$IKPsNi8+eL4^mJZc3e&&L<32|Z^H@t-9@MU^dQ0^i8JEO~ zg$%B}zKR$>lxW+R|(zJ$j`Siw%5Fb(4y|a~jERQ$bA zj2@t;8vi>KPXG6s!b{MJLzFN7?*_wc>j|!6&!76I!A>mWJ-@qlrQ!wq*6#~lpuT67 z4o8Kpax)^_b3i_NIHu8duH&YX_ULB9qXmT!HM2{6|3C_Z`XN5FC(nSoT{)Tv{juT8 zTpd9W_*|Ye)f0Ee!Nd9JJtSz^oKsDHB~}QVym^3w5Hg;e4G;4S7sSt7q^xVzSkV4T zLoKNT!y1G%kCn$Fwl+^wQfdrwen3s-p^47EUcAGj>{qb7d+)`efUo$6xF`7C#ZIo-*Tb6HmKi7tGv zgH0Ey_b!^-Xhdp~-TP6ZPLGFwxst11-o>WXPA|8ZpACoe9>_s9bsT+rX+OrtY?m@} z)nO@ccUN=aoyV1yd)uF7w}P`nYQ#u(o?UoTt9u6UpK?FHad)aTQ~r)^56eIfH&sFs6KD3QTuH3^Yf zwkf9{?m1?>xn1YUwk-D~*o*gfZcbBcZk4L5jQiebN0v2j%~YH0#<>q#ifr3(@~Y2)1s zki!-^?uu9>lyMwkFuLC_0~?h(VW$Q3m*qRG7#qK<;Tu*t^X{#PtP^Fh8SoJ^Ny!5_ zv~4opV~E`|v#BorozQ(blJ52kS#g8U0gO9IiXeF40|9lh;TJJ69g=|&eE}^ma}Q*( zFG?q)thlT3;EpRQVetO%D;anvLMUdkInqEWH4h&8a>U-Lq3dS|er?6*(s--!;+EX= zuER{4s?~qf^MZUOfi*1wWR`#s*Klb=c0=KDhwFhL;DHa2RI!N~-Ll<+2>>e5Y@%@V z9m%td`={J5(Un)Vpn&(Xj5wZOna~k*zo{3WBRrX_fNvqe>CQ376gOSBn}`X{>BzG= z4qmJ-?W>k}1jcMHU8xUB6l7h6E4AkV5f~M`zO6~w*c*xexHdNSu`c`VyTlcM2w|BoP}oC@h0g6cMJKrpqrd;li*d)#}_gl;c(0tr)VqOSe-de zt<;9YBfjmUfr%!SMsA17r2@ztP4NqpZzueKW|w;6X9o7l?%jjD zEai7z;c;I8iTKsun-vsnaO$0h>B6HwK_#A}Uq!w&`lg~W{!-k_Et2OfBrV(~Is6GG zttZ#vE6BQ)XmU~v+Q*B6wS2%ZjKI(B}s$I>SeZ^BRGyq>2 zmx)?)4u*OFd{N!iR=6<4{#>YMQ%0Cs;PNwB2-V_VOUvLNsec;)AkRMo!(MBzpqw{j zxV2ZfW%)&hD!S)G)ETe8l&}o)_{~^@%&#@Oo595n0#$U3)8$@_1M&;fN7EiSd6bNd zq>*wYQ6*L+noq>wLun@e%9UR&H&+0ptn|5fB2B!HyYX#~JM7N-#Mk4fI?-R#6I$?P ztJl`c{7mNYwc&8V7$0lW9zcX!IfXx)>gVv{kh#pBsGrs3FXGR|>LF{-mBqvTOn9>C zB9RN-AdfxiobU@vXJ_A`Gf3tCz?i(b&X-79`kH64H!XADq+t%x>xPp=F-!I&ne2Q;)s(RxwL2j~l~Qh?#5e z=j5Pu^(UsJy+6+(pSo073YE^U(&xM|6~-1tH6sqL27jOYsk18x9yC!I+unKabrT+M z5E=%iJ6@*}IFa35jFL>0*A&M&64$4jW#M>`GaR>>&;1mrU2Qgb!#8+%tvHkSBn;A}Ri!c<6$= zj*5G8HA!Qq5qBRLI#$lz-f!2!#V|sTv}0gKA6|f!^My(X1!lS364Kmgm1WuasQB?+ zkrn2Bu}3CC5l5Z>v+l=M+Nbgdef5DQCe-r3DYT8y$YL68%-{t(FUIUYtu8)}W-*#1 z6NRm;DA@&&{sqg3#c_9TcEm;@)DzHbUt4^mVMe~)Cbz~EpmUUa#V~C@>BO}_IRL^K zZWf+{u+l~RLL~&T@ehRR5GQ#S3>5t>Cr)Q_uou4lXBS5iq}XF^WfR&Tr=trq3O8FD zK0d#8%Tz;fagf6;W_112GZG`GPVOsHz;oGDIe&8bd*JrgHRNtkwW~%M#N{{V=Rx}w ziXZP^KBH*Kzz+J+`>h(mh%{BeteLoDK+$&f{V?8Xl*s8OT4t9L^?hHBU6?*J9I0AI ze^S82altzvVgIV%@YI)|2A)NOU^$SEdE8i@Yvo{;$i2jUq1LJf` z33#Uf{4CM6aeH{4x*?<7axiEye%yV0@x&vcUZ}9q=z-zSon%MfZK#f1agy@9IbrrJ zRGiLlZpaVdkCD?9gyt_o*1U1ra^pL+hjYI`N!pReqn3-YIGH?!^c=yI^Z1$nYpeB* zxwE?2atjk0O@~S2=>Efmnv3EM%?b6g(*rU-Q->(&eMSZnEHhDZ7CCs#OmRkG=!fe$ z?01_xt9Ln(vGJv@N}14hZF}gwlr#}v>dAp*!P4y9zC0J#4O#RdHPJeO5^adwan8NP3jtVXcVR^qWDyz6QY#Gxa3z68&C=3Mkr`2tI#%m zvwI=kPtP^SXhWGx#v56JG~ zO@*_Uv2~~(qqvwHmVOBQkSK`;;E0(S*n75`!HkVU>|lEWT&o2ru{{(#n3-N<TfDggUN*-onKXHw zmMB;j!_76YTgjmZ!qIZ>mj!K>tK2xU)t9!O%`K@9xW&|PmR+LwU7@?Y)QlYDn4D6T z^1Y3_4F7vCT$icpT|m za;0TS_f9n{sBv?1i~A>G;Z1Utbs=!#8w@%8>%DOI%N7sr53uToy;;8m*7`$p)kG^7 z?(w*xc5-FPKvgPLjeJ?xH+6Nz$N^$HE1i`X?(g$q>ODbNm)4+?Q* zh`Fui*0nL96j1!d&t9385ZZp|%QalVX|Sgq_%0svx;*ilhvjt@k!;Qy!I&?{S0K9% zlK6LoShw8Cc8@GoODDeIc#KvTrl$ZL?L>1Xmh5gBuyXd^nXCKw ze$Hp+i=13b3&qHY?h17RP8VhHHpju6IpUypf`Xd2t_dWOjmLcsA#YaCg@7jYf9HSF z6{iWeIlwkFcwnxI8)8x8+a|)#t)vTLcr8h$+e6F)sdq2UMN!wv?rKxu?Qx68{hz!N z8`FZnBqBBvoa2vdzQ!{?)mzA15M@`yTg@{@-+=F3VLvlS=3!T`Ttax^U+;81}1H;f8TSH`e~7Z z14f$iLqX&*aupq?_Qz;O zQ_8WRRYM3F)CvM9718)uf+QxEl`z5S)~@N`PL-5T6{nC-|ELRk7;}jSzppfkB+8zq zH#k`?qyfo+UUg+@X<2>5y1wu4cn-DJs^Ay#nk@JM+np`gf*;+QA06+|excLzlG4Bd zIVO4e&``I3Aygf8qr6A4A^`?U>J0#(;<_=~Cd;>W=Bnd`^J{NJ|CtiLP@u62Rm>&b zr~B7xKq$wj;|?S2&qQq5UZ)k?-evQNdRFjkd`gOMC7d(TfzPFv3;K9ji5da$gdcr_ zG+8>|sN>f5&bNwm_WGQ3nHcp~PKn`=IjmQZ3b)U57=!Pyi2_bLqhs1oYxv;wac5fx zkH(vdTDb0Vh#X5=wxj&#U7uhxA~hixgD2$7Zy3hXt|uo|Cu=%L$#_8T-H}*Xa#d0K z;HJVAj9Zw4MK(Zjf;ynwVl13aJMLVR>ZMofi3rv(ADp7#Lr?r7(g9T_Cj~|+5rq7+ z+p)8gOU4W88>qFCKwfkLY42_2D=W%2+}!Y@Va#&*&%f}X7vR#;s#4wc5Wg^ojM2QJ z_Ec}zHhB}A*r0dXjkCI6UXLTs^R)526=?0aoo-<%1XbfF{U`^dx#qa)Iprv6@TeXa zRn8L%o$QNy`Y4~4N;v@5i)HW7EpFol;xd69vqMGH7X`<1e zcPAiE#bZpj$>POBPuIP?3wKRb%$!}b;#9bu&-qLox+8Y{urnnYDeCm%_nXQQ5oyZg z22H9p`s;C#43_32^iffPx8ZW6Zg?L8C#IPap<02X2-lhDnP`)vaw*6ce!3m?nH|~# zG1FA?C+X%}Pq|6D;1{7>J{#@M&Al;^6b@##hkWxC-E?Igy8)Bv{Un6?6|d$!$0Zps zbT-IMMtn6d&i%6y^Qf}Csxn|mcIdfq-@uhQhj|rN{tQ#FVz;k2;t|f9_yi~Q93K0s zGI01=O$B(vIU!Opa;yvvI^@oBirVkF%LZeJ&|JjxpvNNz(+|$twr5`O>n2ppF+Avb zSt{_<=N5VYH<&WW5dXlCD*kEP-N-%W*K+o z@FwYz;+2L}u#jutQqO&F{^wEnmpT_>-{cGaN?cShr`qTm_i5sFTIH&$9jL<#$V|{fNt*c2^YK>hL(n2 zvzZtx^vs=9y<((}uQkwB7p<88oDgLmwE(nKIqT`=mZv|mX7!HJbtf|8uby}~Ol4bf z;!!o+b_j*(T+G%)l`+${aVf`ZLx!oa3jVWPS_o&`_qfb{yh)l_z|C##gmJpK zVZbk0KnI)X20oFbD|)7M6t=|O1Ppf+NO&b0VlMP)%8tAx-JS06p}|5?=>i#Zx-rGu z2ZoXLTi5x$wIY-DEFPGP_TJTU8*FZ;@|Fn3+~GR z!Vtsdw=N)a&S$+n>jmwI25oGGkzIA!%+#cbrftGw4>{GBd~WXMdt6W{l07G)WGoU) zI5wa;@rfkb0qLFp8C4Tjusv0gPd8q$*@g@ADs0J2%&lA*sYVWcvckIVa|O(K_});A z|AHgWnQ#7Zc$gwERpsHT&A|0eo%$E*YQ@2en1#3re?^^!S-d}15hO~VacTds-bE&3 z2a;X*8$>!DUQKQO@DW9Z`$z(Mb5{X%(TdABy*k4SsEe<&fVyDB(1a=7fQ>6Z8e15> zp>49e__rUPL-!dOU-Z!Z>Ic|oH*_P!(~l^bjEn`Z-fzwxMttR+9N|l8$Lv?$6goF4 zJn89PD73Kkm(Si!Uh2npDk#fd49me^S>q|h55Thg1Bq&0bpQ!PRG>?Q$zOG10cZ8S zvz{gvu%kev?ue6ibjOsRNHkkz3)$^e3n+v7>3 zMoZJsA#P#u`2|pXi4VlaqQ6jV)ZFQiJYvxpC} z6DHf!(f)G1yG7`kvsa)eODn$a2#xQ+y|16|7jA#=Mg7Aq2jzQsRen+~Ms1f^m?^tR z^L3GQkbz{u`TWC_oi4jLTrjU@TF5t}_KHOD>1A22mK6MK4A6pBHZ#D*TdR~f(kbhU zX<8gu1&~T!pQ1eipE3kpJJLK8YjE4G;HE{`kp1c9j&$GpQ)0^gEHUK%AWtcoHy5C( zC!l~0CHd|K5&`3H?pS%=v8IX)gJVF8MY-bW$0t&za z1}E!I<0o8nzpE#SC)T{fhXT0CR*(iBA&4t=Ci(8B6N%sIYP~h7M7HS^5WRsE15;Mq zi4Y%c6AEC57D5yK?uPr#e20AJo;#>B;Gd>Up0*r(kE}eidmV zjMm3Euk$x0j(l`G-^%Q^-M)uL+V;DAxn-VS{+pXBD1rM@BslWa^X6Plb9Kn>z`(%x zlt@D5h$ElT?&QnGx%b5ur$3bdQ-+c`7R#AWaB{_0Ef3jway)9WC~?+o<@l&h=7JXF zeyH24T*VCBB;PhBn3BJ*AEvlxO|mC5#ee=X>n$!)!w z7vQvEOenv^$KvmzjR+RdQdgCqu@A0u8>u00`YYH`gFm zPSlAkIGPxuu4N&?)ld^sX{c|SMl4I!|9o3vMShUOoO92_XRdj?x-)4;c&MmScev2L za9hXILbG9vgnIKLOSde_ss8evJwrZxLJdk<=!tF{WQV8EVxS>`8xsiy6CbW=#7{{@tnOYkeHG3T zB1Lk{u8J@`c}1v4zHVmtG3+3nx?#w!W!;clxNPk&^6hh4O}Aj}ev$AzMRWs{8Azom zJEgZCYizp% zGa33#(d)g$3m#x3?U=SpM|8f+a=*=VnqFYB*wARRI9dO))lsM^~tCXImu+3Z~mSjhA;S z@g*#fhg&LD<{BB8c$Px1BDaLIT$h_NOGqdn&QVwEIueCGo-DT{1xx7)am zVNn;`)xz2KA-71;={eG+C%574$QKB+A*qeDw`a?pM}k^mjyG&x1-9}P$ykxy35pAv z{FX^mbAEUbz!?gLVlQ0PMb&NReeceh)>~(Go#E3I|I}pxh^DXmf~;@sl~H z@CNpU5j0U%`qS-+wl$7t-OCjP>$=74DjX~6?VPA^qA9L9e1-506E;wvi{dQGazSL2TF6^7 zNd$E`D~`rr;d$GyBCRh(FyYI|jDj$`d$ScE*3PjAu! z%U|-_Zh%T6HUgaiJpTpSsaUMx;V;jjzCJ{KaZ9P4+%A<4yay8x{FC zQEtMg%=c%bH{PD0SPUn9HF^N09kh-&nmmmchO_rAIGZiikp7ey^i4bS|JkJx9kx!A zNfQPfB5$Fb_x)TLuY$*nV(aJQ~lsyv|#F9rQigaO8L3PeH()u@Soe;c3&DE=6jaQYn(VPK96^GCTTgo za;%HLgS%T^pd4P{?(ldWKQnEba^2w$fGHVaYLb9p`^5Ei1?I4wwMOq+=IEIl*_HqMf?thpV~z z+SUTXHG-dmJlkh6>k1K`>p5#5nNhrygojpJLX-H4`*8e{QBQ6LA9v z4Rw#)&~xW>=cM>V74Edlr~_p$N5>@YyfgDxV)Yx(!d&7Pxo1d?+A=2!uhEPT&oBSL zZnUCb8%+IH+*#YkL7-nV%^I9k1-ln)*i)JsY(T`6GEK(m(u^oRn+r6yBRX zqo?^_>!0hMXy=+a35kA|+zjh0uWNju&?ct4XlO_r@7roJM}>s&3@T4n76mGZ3pch8 zINCh>46NqggL#n|go|@pJE7?iZNXPHA$Ds;T-(nt$(xBV*i#Tn7~BHToMpZ=fKqNo zHt$Q=aTD0jC4Dn2xazRM%|r~Rex+T~$Y#;&0m`5DJ#kVWZ?V@UgtJ`mWZh`Vr1@oC z+?PRkqM!*d068#v2MqZ|`>?-RaH7o^=iPgCud;jOdo7dJJHx~8e7r?6?gK+=UMIgP zMx|R2Zdl`$SYOVW)d>sSL+?hx$bN3&GLB8bDj(0{faZBIoZu-H8|sX_`<$j3lJS)W zBaQAzv1N`rl7BzEft)5koe%A|?A)%MfDFRCAbTKy;H_Vn@?QHB0AX6s7`G+{Thk0S z@bF;;`irM|qnnm8sb4f=)W!Ke?@gY*VJ{t+2~t1+|JP*?5MHoq3X+%ZLIE*`J54)C zdk7;a&YwQitM$%D>u1?y?A+E&V-~EP$V&g|uLJ|^lNBNOABX}-w58&nH$h{Z0=)`% zHc!99g@$#id<-Ag$b9>91tnSf>GR&#TrQV((-rGEt5^gO?6QDA26hzi#}&;)N-!T) z7u`&$ATAzw#=SbjU0bV_xA~n{(_H75e%jZ7^p+~gS=JVi+HvmRhr_nKi)~Yw+ozRJ zxQ@N~JbX*~uy;Ex{`UK~$MwW~BH8|=y9_1X;1U41JyH)`hBc6xetLxf&6}}z_g>w#vBF7k~o!kFN}l!;jLJP1EVul%ZCk9%cPuwbBc1o(OV|qD!oCG2|3a zWf@31T2Jo4kCvk?*^FGuAp?%(6;=uUavk0>DDEIHR3w6RZcARJp(Y5_e_$k8?qBGtFuZG=46>2*T~h!F%Ci z+xsV;%g*0CT2@+MC`^;|ZuCqelr>#aO>V{EA9lBMEcBKHr(V>$07JCK^KAA28Q8UaN>ct41@z-?ALqg zrW-;m0A{JGIB_b|o1EO9T||~{cS;6CVG4jg1WvOhiVZa;1pl&O%L%b}nqi9Wde#d~Iyf6>%o1eomft7}1wd(opR~ zv5mO(^Qt~dcg^8Ho_SE(|LdLqZ}=uYYG?rxYtVq^x~%lqEXd+hxl z|Bw6GA3XRnuC->(wbu1J&+`{DHDWK!J7}{r;w&q6Tk7R*Q_<$0Ml!|r)N-v+NmO}} zdXO0flVbII!9<4HbAZhgA5r)Qeh$td`^cE&ASJHzJ}H6u_=I-AlN3MsCu9hJs>Vi8 z8pIgleNXapx${Qf@yccyO`josS?St=>w?|2tEte!wjJHD6D8Yy>FqdMasRp53!sJ# z5JPDrvkj#vF6ksZ#Tt%FEh0EK5@cgSzw0?Y;8o_?-oBs%^>M=WdJIp}derc60}bJv zqu=hA_MfL2FhS+?G-(*O89LVE9^-?8B7kh+yXh>J*~DHI(+>MIJ6nq+1!p`rQvEI& zn);PqB^%4+AqL>zI^D)|Hx>kdg>E`E<8heBTx}csGovrmg7O`jE$mh<1c5i&Ygho~ z=ZqMsyh)x7JGySNU()~Rp=>}c5Bc)QGKG@T!hK9NBRzq(IcR$? zE9K(kZ)`$VJ57LA`3hPGAv#uKdR*6*zDTO{n!`0WMF(V#hk?vUhANe(T^S%G>)I(j zTE!33oUMatCziW7BIS}exQ@F1ty`u&z>f)Xp<1jXU7x33aj&2^%0Y7jwX4$6q<$Lhz`uG-!LrX&}Ny} zuT7x+I8kB>aV|18>@ImGr?>@yklycD$GALnQxaGV8dwF-s`Y#gl(-aY zpr;|PWbMJ8+L6Nl z9mQcZA$zV^_nE^Kj~1I@3v27C&4B2-$J-B(IdUiomN}9NmQFe?4=NK&_X;MwaL=~C zq5m7{I|67WVW!goMx`buM-*dxD~dQIym1d0K@UJ6&|Timh_r{TCPtCbwzgX1A5zw`o7@D9E46zD>0=6|xpb50Zue zCb!urkpS}9AUb5Y#%zm>zs;`^`}dn+(`hsY6GP;T<~yKXu`GwR{hWJ{NbN)ovoD7A zu|U~#s3&xNGjdU{M_>g(Z{{TLKL7zFG4_4Jc|d8_vlfMxDKI${5+Y|BI+3NyXfSwL zd`h+N;^I0NcR>m2oR+d~r&0(O)i-_!|HpXzp zc^B8Er>!MdPDquK06`O*J(K2|RG=$A6mL<-l0qg#o66|s?a^nC97Od{pImNV>xuTD zlSg-zXh*x;NuWNGwHH#yxtor5En-{qJJYu5Vl+J7PSrwRb_O-yRIFs5a{sN9N{^(v zMeUDF8Ky7lle+6?tCpxh1Je6?!e?=FyP0#*p3Nm+oLFbhR(&(`AR}EnpP@=e0Mo6< zaJQ&v-lu#>y{)0CuGZg0t}IhPk%IxtkhgDe)us$*Ji+^y^K994bEKH=P@H(_W7uvt_L+SQn#Z` zwGrY{CYer7zv;SqF;{D%*LBBKJqAO!gu9D1)E$a}}x8%@iM4#l2F+-oDAy6IwZ&ZnwN=?wo zD>gFAbV({9#bQN?1j_XU1;Dt8fjjvBKD-nfC-_dtbYcS*rdP*+;hBwPa+nlsXP?I4 zXqPK%86$1+Jp3E;HG6XCbt0`h;`3ZBz-;c38pACU!g)_MPGAQ2LvSQPLK$zW98)@ReluAmJ;tmUt8qvV)CM5C976L#s0r;*( zowtQQ`nl4rH7ydP5e;uH*-<${mW^96*P?8go7_n2HTvMOUI71x0u-{|qs1~wJbR*` zv1|GV$(r?d(_ijptslKn=32iq_1j{PpFhjcYy8h0^f~+Y4*v5MUE#Ls&I{*KnC1D7 zwUrLVPr%d705TQ2*AR}usCnlym>4%`=?e{Va^Kev6R{b36WcW4{E9X&lzEqpoA z8HO@#D2r7mNrYPPt&zjKfR z`G8UiOA0wRX+|Dk1{*-}`8>a@HOj6%)GXC=zlS+oTt(FQQ9{Gbe=B1g8z@5X8n;HT z4S`z>)Ns8=$AtIxS%2OUOkD6uE#gO1Nn7LA`)bUQ{jR-jEddH?;0*u%h!=VrT>0Lm z0etY;+#=OEih4mFC62P0c^DWd9Ss1#JNbYY(?KlnM6LWdS6YM?1BQ?2dSzdigQNhn z&R##-x$?Qu_By9#pnXLHzbwcMbaim{b$!yHZ}c|VTr&qUiUHm#a8Z*9A>@j^GDb|j z4pS~8Vg^5W>$V-byk#qJoJ}S9=una!&KiD^NvKhevi;~7@%=WBRB3J~9@om&?si@?@$O@BCtq zJ(#_w;T3KM;Gk!gE_la03luo-1mfjIEY0_a+}4Y*8511#*B0G-{LX;9w#aLK4mF*$_~InWP|D zdtdL>i^AOgf$?TQ@AAJ^ zX8fmo@sTs&CT68!0LJdtCAl(SZSduWnOXRGV7A z&0LldOGsGZ^BE4X57h*F8VXR|WJv&6ur8~9+koCw9UI|8ml%cLS{LGtDZ)3s`QL)b3+r}_Q8xQxev zGFX3)1UMR7qGZs7vw+82`jFwcC#NJ8@raN;dAUsjsy^A_)L4~2Yh z8UFpA<#()+zcU4SnQ|62Xc!~T;uwq((eSA&djxsJRy>3LT&*M6;NTj^yJ}<`szgW0 zTTVp^QrzBymlIx<&v`1{y|5P~UQ>O4%oM@-jX+kO9_-aYH^eh=H12^(3+to{)|m=o zM;b4fEz4cS%iZ*pR4O<5{WCyoNY z8=e!S)Y4A-eHbadYk2xFQU@jM6Wf&~K3QAluQS;;P&mkS&p;e1o=~`>)kunQ2!z<~ zF(29+f_|rM=^bCgkYk(p*HQd*TFQ#tse(|kSqQa|HcrH1JR|};!}Eva`EdKn(ex%!LAIWR4C zeC^lYdiYQs`+cR9CNO^aXfW*<#P`k%ZmL>g>RYj_(2@cXFED@j2jnXQlvZbcf4!cy zkeMg}WULut6ju69v@ZEdLe{&9-RE2Z>~w69_9*Tl7TirTtC;~4h(2}hloYE&P(%r+ zh|1U`Zojmel)Un=5{v!54(di^WUKxGy$1F_wgAQ3ba91ppDlx#3r-_NLBojYw71Um zirey#Sl$gwb7}DUUcQGJ#@#)KAS^@`GQ68QE0eK4Vk?a1srTyWBWO>G3+Q6heTf!* zgeWndzGR4Ld+soF6de%%dUKZ1I0T}=%*6KBYT#%<9O_Ve3%P84O&F`L&vMdH)mrz- zSl0McvIV)O%qii^X{&?}+nD9Z2%r%8YJe!OE#7Y`Pk0YI&1=^11{X0=QC4b_3(^ux ziyWBPb})pB&V`6uay7#a4L$NX7b%}q*#`EsAuRSAdLQs){AGxhY~Ixt*)x!|sIiKzx1V)T|1OCx0N{?*g2bmcez6k&&04NC8KA3RMv*s?c>8cx_9Ap zrN7?fC3J@pbzTkJ%ryfnNnB@1s(0xbK)!hE>bD=S6PcErc;t&3*zh`tRX5czr|Q(o zTovvC4*MfifI%uiOe7mYE5JJt*ZkpJ~IXML!`e{mopX})780H^H6MRbLMyKAq8u>#k>$j3~Vrg(z@Pv z;QRcG7*m6$4D)xB;m(HHs*TV^J3Mv1UY8|$IbEugBt`T)%;|j?K&)8)0~+gIyIufX z&QVKOZ>M3NX5oJPHz~tue9X%CT0VlAM0BXGK!iU-Bn)HNdxITR0Qj$jRARldUY;#D zJ!dyaf4VqE!k+r(5_Ce!W^FiQ=7-}DER{pAnntlBK6y}Gzk4cC;k63DeJWcI74iyyl1k>>)25 z&&BS0(p_27;-RMTz&g|e&}h+C@XaHnGXOGK0e-K*@u~#!nP(ZAc3hkx|F9xF`H zuR#_vJa67{GNADbDlCCL+u8&yzEWTf3-yndY_tT=0i%DkOR)L#-UAIcr6`wPXut*``PyH zw)QepzE2U}mTBwDPp(&GtmIDWSU(o zJ<@Hgjn8F2N2-ch1s1!1waGIx6jG&cQ#BBPctTEO>?CV=l3_b#S(l~7ukIBVwf z9t=F!gh&NpTKB2Y5IiGi;5k!qf7~>KmZEEP0sHKJS9pi3dP!tMPCVWa& z6$)Ib6=011PV-_4sr;a~i-&2m_38k3aOI^dZZvZWi+O*?_6vz9@!j?}Xm(G5>qvh8 z=E7fGtksbQ3?L^PX(hlGI0yfq00K4`G}tlFQau&`7?WEqnfzx}wFC9?i)Bx*Ou7m3 zFhaAw2p_s(Tmlx-!Fd3rgA^^_eWZ&=6Y;tf+7=p-ChViAQs+_uc-2AXEauuYxW(j}u= zexHk(B>odyFpO(rl$76|*Uu`8FN4+`b^4clmGe;++^90OSW+x_(Wn zJL={}z!N$r?q)_9Y-2|B?v8)rQpH_wQnJg^E$vO6o8q|`^8+1>T#Yd4fw9*&-2n6u zMf)LfN|0y0FT3gKr;nL@H}p@<+qgnsgGj&0y$=%&d%!cjhnazNK=J#0ltqljmmDqD zrCf1}?r>c#h$k|)yr-q!Q1uJ4JX~bM+#^iLuD*k91=9>|auXb;5(zB7<9gd> zD;*d0BmWV*)#F^o&@j=A^|xrA+w|I7+&*g6%1t^WWfXm;s7bMYOClHwT%*{8v~hwO zYn)|9qWEoTtvR{!uKig8RwsUvqNS#%}@|xiJZJ`?` zWS+b7Iayx{OGI*4iLEP`-ThgZ`Nw;TgHY`o z09OF;y*9uS7{9KO`}|U$^T5}7qt9J81okci^3@(pKA1Mw1JS~LnQ5&C;CfenbK-Pc zN8FEn!nLPN*~z?O*eM_jE1$|~{J21PYE*y&Ef91Fc*#pGl4!L|2XXr(nJy1WEtDHB zK0bla+WW}SeFSe!Cr_u(-+uCBA5x1qlqjq1hAf%(xzcsN4l>X&hfKe?vc^)!Uj3xE zr`D&Ud98)4+zbzP=j<|C8*wDR>Q4Ye!oDYT5FYv^n+P3e7Mm`hZ6-_0%B>S7??fhd z9|sx8b}^SLRI@Q&9v!~JoRDM*8j)n269)uVGju1tm|2K}}l7*b_FKUch@5|-Qpl5JN0kx8}l26z^k+ONKtb@rtB;^dUy zlpIhcsDx1$dI16fuomWc8+}RBgSe|MDC-XUslszgjfakuOJ&jP`$5o!$RVtH_r38C zhyhtJ*BL}KU3;|si>W56s+-0xW$aPN)c~XIgAhV0lim4Ak`JU9emc2%I*_Z->||c^_lFPBCbWF7}?iNA==yQHnsA` z&vqx?pNU{lg8fm5=;> zoTL~xw(IRw`}F)%i@&=3J_%J8i1%oHjLKN&&C(N=2*mdg_zhVfBRy;*m^--)e6*9( z^x>S>tG{*Pqo-ub4d>r^j4pr$5;9@>ur#t||8$9^mU>!VlF*FD^1#&K+xM7X!Hayr z&T)t51O258Y#k?>l(YH?R62X{G}v`IpKW14N(QOkjstB*2Gkckx57{*nCtr^fLSKc zowK916}reEP*1t3Bm09eXW2!JK=cvls(1@{=tWw5*EtoW6SfUUg!N)HdB07BvpFHb zAlFg$D%i8z9|E|oLhHTtoDtor=ZX`uV^p1N=g+J5#=~T;_%YVa;DcF!z$>TzqtqE1 z0DksuImnmmb8w0fkCl5y`Au1%N#*BpT-O%E6m8?G7~TuqqJgH?WP5AEr81u_gmV(>Oh^z>ZU7ORXyl zOPCeQef0C?0B;cxGH_q@PCIi*k{T)4x^v)o7xg#`%lCa@NMR?HT#%EB0Qu*W{WdUD z4KP8F!#q*kNG6AP`CX-2oK&$!ssV=xourVxq8)7%M4G{9E-h$W3ZJWu1!*`t^`p)2#lnjlkY% zowSo#Ii0l|B553ZrHlCDNeom*+WpJAzR{R%*q``*&&{opPi(=jBRw*ysSMv;pcKn= z!i+h zc8$=_5?J)2YT9i|;QjLaLh&ra6+ZP>fr(AB?P)cyIt zlXQibX8G;-XHEj#AG0Xh+ENOd8z;+b(sd6{$BS&FG?j7)Kb^E&b5jZoD|?pi6D_6P zQ%F$COmQE0WC_af0^3e|!rmfh;JRDAS%mPqFFA)iFFiMqZ6kzBN;v%1%$oYeP@3!4 zK9t*c^h-cJVr`3h=F?cdZZu*mlQ7*BZTjq~xAxeu&Kq-*xOHN_L(#c;L%A5cbe)tB zZ^r0Kaqd-nKG9}lU5lyzw|zg=W!z4MFDuf5E(Syglv;s68(;SY2y zC`sZhvFLMYQ4v6G*c%UE^~_o^jnX4g4nHr%UTq$<<(t?sKIK21PQCey|BMa(dw=N} zP%;5z4M<3dT~k|1ai_JvGxh>Y!af5hfJO6P$3@=%D$>uG@Of|e0VYL>n@Iy0u2ihQ>yEd+dmg1-MNP$C7z$tEZ=7^?9|wX|mjD5EUajE4}K zVoJ7<6UXE4Jg&cUsUtiVLjYl!ruvjjK)M3BRs_hD(YHykX zGH6te%UMEqW;k<_dgoVY28##(c6r$_4-(_osxJXov;ar$C=V6W*_HC|at)DiL9z+i zdl540Kd7+3VQueW(rgqSPZYnS7n=0U^fs!3PN`-(rLw=+*@ir{!m0|PU?iDdH@6gF zP@nhE zgBUgp$_Uyd$r@~}3E<9+wJ)k(?VfFMM?nvLS&V#jDZ}ez8Kq?tWuU4Oht3NzSG#jt z^5JAP=RI@-sUj(qp-jJ3=ie?{VItMBD+9D|zt)_*Lu&#T>4Gt+LmeBl?5j z_hs(;hj;GLZn3^xFFgMJ1IgUywaPSm>93qv*~b`0BfZAg#7n#rvd%7q*(UKKC@w#T zFs+T{ZbURYD!{qK!SHFlHCcHsp9#NS%+eNQ)c9VpjPZ8DM{I22fjJ}#N^;GIrWcNk zHJartH=GnAA73bWn>qcBOP}CgWe1%SHZ!G^oQ5>Mt&Xq-_Vi@QgV9OC@wq+h-dI%Y z%gy#!`kPIIMNZA#hr{uiki60#f5Fzh=HLbLo71(;+pF7UU{F47>?G zViy+jW9&;x-F^{>ckcCTE6Y7f_cJtGZXH+7*_g)5Ar zo@Iu!yK*62Sh}GV%7L3ZA^V^X`nF}%Eh%UgqP<=ferop;b3FV);CC+K>CK)x0+2O3 zaTh{;N6>?&^56~Q9}K|ObzoYHqFggB>>fgav*c>OszQ~R@Vu(o3nmqQ;H_nXJS3mp zrh0yDC$gsym252xwjJYBe%wsJqTVO$SS-^Cw({(C5!K7N8(YJINzwooN|hytsn_0G zOMy_flb7$cIm(B7LPQ4HD6fyQBu1ejJ#z}y7Tk8Gp_DE}qje*8TIs7vRjf^h;U z#WB}a#H5AfPxUdytF}M@(Rxv;38!OB;CEvT>gdX_wW%G+lRMxqp4_Cw|9VnzlgR3c z9t-Nyx-~G};4XKS$!3Ga+ zlzUg7%xvE#wlIqw3duW+$dsPa_IoQosvRe)Jk%q~=|eGvNVfd_55_>4*2fbBb0?-^ z7~IgL%Pr08TzHT|$9nM={<2>6Bb^_^=*DdOD=FI{RI3^gmF9H$w|Fq5&`s6l7$(=# zfcmY2q#l8O@7+-;_n2MKO#qpw$e1fNx&TrW?+x-;fE%jAv%G%{m2nphfYv>9?63;) z=Aj%FKALS!{rC5wX9e4uPDr060#GU%R)S{u*0tgM$j%T<(lUNPI31TuTqt14UOh5i1#@>-M!z<}5MeR+|7BnBO1W+J94qX-*|LBmY>Zo+Ui-yN>JQJC#7 z21IQd^l{E}1qW?XMsXhvux6ph?^Cv0s}WJDD{pcVS%RIce^G`mht^o3tn}vS=_hl> z#l;Hj$S1{??5-*e<GUOd)9AFl9S>z1hIY+QFAYrK_)b$IgeQ|FTmYl@7W=4Xd5O_Ee81+c}P%ojg$U zT*n1a+#j?`q?L74l(b>^L3uL6JbyqaB+`KE5XBI(C*egoD` zlIy;o-t6B2+`pHK9TMP=1dDraehqY6>Lzcp8Uuyt!>|XJL`j*k+eNO*wKlGg*FD;) z4n$MVV);=&-a8Q=4OBCG?|l$V{Zh$3V-Tmk@r#3X|B!JcPw(gKU8pf9xMn{l$nAyNFG+ zWfo0TESI)98T-jJ{T58z92f>$HIIv1=Q&Wrq8#_?r%pAGEfasXk>8fUxSS;U58-OA z97#rUPD%2^ue%FKEl9oYM@wlLXLtG!edUh1BX$tk3C){6*>I;JH^*r~tz%tz3L+F16vIpD6dtn?HD`v)$K!On*vABaeN%&Gjwr>!4=Kj;metFOa* z>Bh!LVtHxE&+Q=o)VbMuRo%s1oX>chBAB#(2($Y^(?VKfi0W=4z)KBG|HhS_=LyAt z{LdRasOzDM#%(57IcFQM&wIlWu&KxHGeI7jq@uxWjgHL_iK#8@4vbA_I9+U53!)^wj3iLD3IXnkzWE;UX zfz#JgROr{$Bj6IeA=0<=nK48zx#Mm=*T5!bqg=F(oY_FGrz=jn^Sc#SV$2P=had7{ zpaDNsglnMP`wcFqoQVE@1Mnl)qY=@?tJ#B?CF7So8c^mWL8<5HOXRJu#9A){X<&h` z4Us9o#_t3p3vN035I^0y<1$}YAfZ$?5@#oY=0l?rL_Y!|*Aj5D+1*ZX%f|NXKTFsU+V0}?v@tv6RB7jcUgH{Ldztv+#Y z2oJ;9&Ts{a;Q$Qe^EM0w-D$lmY{}mH!Sa%PYWTx%Q5;4hBERLOsDHC=N=#p8i6cno z+?B)~Vv9-5LHYvBGWx8Blnkr;Fif6IM1emWw8t!aDBuBrh zSBJd>{KXLcq9y$~i5=T{;-*g!j$iLUVNn!pl*+Tye>(gI39t4Zfk?LQ|-*IsDQ`t zl^PHSYKnof{{tG}m~21hqqvL83Xy#NuLsiwH?X{M1rnnTt9z`11`3R_FTNg-rl`qj zpx*>ah)7gnbS!mp4iDhBXe#q%JeSY^l2}1@*hYGr4RpyPSDXkwrJFS4mDp?gax8Dr zn{i3_sy~EP2GODU;A0{0iRlP_@_8q&t_};0Fk2Qmbwx9?@AVDQv$yo){8ooIk4nkR)5}!lnKiVmin=NRm=~_Wd4o1)LU$? zG($sAwC=ii0eR17_RV6H-WB^F5Y(hNc;3eBmgGLj|CUtm%iUyJy=ZVJH;J01D`yLe zF>yFLrf#kwUf;^=c{hW_JIwYw09EZj0kp8eX=wZL6g*iuPaa5vFf--lSbmUR_$V-| z$|9S&ynhQwgBe>J2%UrKCSD}%FxfeF%1L}Wdz*Nb2qcJG-j)>xkKGpD-kBlFy*INz zoS*~OIbXi=U#nvISz5WwMWtOtkatrqJysj!WYd;1RcnMmQ6Cc2*FTv>8kYDLvJ<5| z=)4=np*$GKc@zvE)-$^h-4DJV0Oqha&KpAE-{GvG{3nlSs(rU3A$uJdD+8AGw;5Ry zCBK}r{ZMOHx{X;!>upJMd`f)a-s>t3H_ErDso;C z$~n!K^oae3R6wvdo_iQi=SleTTy)UfwKsYD!_P)z9PAPo>VwP{Z5CwiH8*jgeYGH8 zLNp%>VAS2jupQjnnr(_?w<`)Dn{2^#E*^&3RRtEWBBsfFqm$}(Fi#mo;28bCeT7Ak zF9mLJQ358*W@sY#^!3z`tKh=aa7wJ;OanyQ>go}QKI9PyeulKVb-s%<0q_#WYFh@J zA@U>ue?5~=Hs++Dz9OoqA^VYjDo5WqUS-Ge3tqoDIa4B(^@_1kz_ThNyX|P!8H*Te zc}B%7?UCA6c^y>==^R4)|JRRt8Ki5w*e-X61zbn~6TG!&ds0no5r0y#=krDs73(~P zG~JFbtj{^pcGEfXgd?Y&TqxE3_jq&p{t`1DC4rwupWj6>8|F43?y@>@7&hbUZgP1O zzhoY;)y6(2$reBs+p~%RKMgHBggHotQ+GLX>{D4w$9(jm6!{tU@OmptA_jQ15pXXE z>HX3gMF@l>x-9Fi4Dn|GZ5feW7U)2Y6|YV#8zNHp-)m+4%a{1~_++q4TnSN@WcB9y+W3U=UxPnzF0is{ z4X4Z|&bWwiRgBcKyit`sZgC@PAp{QF&Px`%8*PmHb@Ac_GyJtFZ@IkKYpNp6TQQz! zldQeFfZdyPvaOyz;d;NDy1GlHtGE+ASoMM#i>RM@HbhGE?;cq6!`_S9{@b*(qa)__ zvn-uNFVQX5^vE&ONp2@{TnXKun_i>yn+*z<2|l3RHqqM})-QK#2XOH!exd`{DtY}B z%}J$(&~cI)+kN)_01WZjRS`J4ZgK#xB=`Zn;PWnCkS~4rIQAR^eH-3E8y|wRyk02A zu|_QV$vGX@jDZ!-c!7bR(;mzzWdJR4n|0AD)$6C4b0<_W-Sg?lqpmLIyAl<_&Yr(( z5?|kIpilYUC^`>WQ_)hz&!hjck}6tzz`{k$re$iw_?J)36?{e&iu->1wv4GBiU0!liM@?E+Z*r>22*qJxZ zj)&W*5s_cY?1AjQ_g*BN!!9o!cE}-$?5P~fR&G3iQvu*m>_Z5kyoG}R-z~B1$MZ*P z3%^X5aH9(9e`!}SN^3__(rCN5k4YR!ZPXvn0Xgu6aWyC28i>cOMijqtBXONMYQhVY zEc@!DsBZ-0)UijUsDrTW+Sm}Sqb;jkH4_D`a(3n|aw5;a32~3Myj6gS6axp5#5%~L zM~tLaiQ8J2Dp^p!J1L!c>9mhacXEF2@5!8w$X*QEv}+UciGG@@M9lDnS=U0A8$#7_ zK?Pp-ep(bXT0Mf@8;s{mJize9#WFBXeur&ADvQ0V8rp$U`yhq$yAFh2lO}k;BETS2>TP%{F9G#!X;}DrEDSVYSc!^NR%nX`C?w! z!|(OyA-3|e`l%u``f#3qym@5*fD9%t4n7Foq@+bE*X}eY;Ms z?TB&EJdQ}vcf#Z4RTVxk`Pc!>Xu>z@qqr_(TWwMDhl=J#{=Uz99Gq#CIpQaDP3^ONi}zXUCB)v_>op8@)@@&m7%*?9i~)3Dk4Byr*p zm)BKE!I=TS*RvO5@Ns)n7t3>DxF?dMZ^Z?N94qq}x3R^dK(ro5C(;axv$oN-%~JTt zAYjQ&FQkqRlBcVLb4VX!7l{*=+06>Y1xPt=!f+1_>5ica&Ve}5t5PkE`wHWs()56z z=vDYcfcs|8xN4er!I)&w02s<%+JLoD8DEGgyzRM{5Ln(8fYd^Xz{1dWj<7>&*?ai+dzZu`) z69MtrHB9uItMe9B-4$GBGBLwVmbZ}kFd4c|rVw$!t9nQUxVV&W!E3mGlX;f-|A4~N zeUVJ74?pVqLboVnZ$Zx|p|re-r&a`fQ>_7HW?pv#BqLIt&1#YcV4{@~YHSNHHs&3w zeGzeWY=9it^%SUy#+LvZUkE_sOVFEOz(vzwhqO>w<`@Aj@NjqWQJ&U?SRTxZfrAlG z55Th9tY;0Q6G73L{LX!4z?SuMb(d)QdKDX^lj#-~0%DR6;0b^`sdIgcKf8v!w}v(e zq*>?2H}JviXbSpBwj@!IO_SD;5iww6>m?Dg+_x_!dT`X){(h+>HaJ31$yvOTrMHY! z>$!*qldqQGY@bG>_WCvGDu?hbV5*QS4UCUhfy)ln07O&n?NpKsFA?lONi#pW+WM)| zm~)!sxiiL$L6G$pGr>xqJ$e_-e!+g_)|dtOv_|YSr7Pu~gH_IXENiVjHInB+Ai9uy zA>w*>RL_6iMIHsCL4VE5m5^F;m2iddnp+kpgk_;D)2~x~F5g2nkX8CVpP9tnVj9Uy z_>p45Gb_cN=@~0I3(QcOq=#qy4?-lW?}#07@4^jj@9y{A2^4y}-_LAT^X6BV{=5P% zC(~oa1S6(n&{b%aKn6sgdTYaNW)OAFoL2iH8QxMJ>o5}+uKVcU-cjXd;KK@?S&zx? zbqr4isj~NaJLcfWGK>kAH#+bFU#B2SBLTMR zVAtl;*uvJ`swRov^izw5&zR&3f{}PQwCLDy2y(Yox6Smnp}a7m>nC}Uc!?uNnx@i2 zH=>1CGWsxC`Q6TwhMRQIZYj|5IN?>EXAX`^e^Wg9{8OaBYDZQqs%zlH%y>WjJ+)-K zWq~1u;H+wk>G9aIUp z+T*{-b^x3KIGo7*=kfdh=`VH?+7ZcOixAVSoQ|yIN(*)*Tv$LSN`QebBaJuLLk{1u z4P$~>6sy6aeO>jTDM#x0s0ikOGucPzBnDaxJ-c+-c~^U}LyDoGJIg;p4^Gybet*pD zUQ2!G8O3vPKWap4s>NVqMfF@vMaXQD>ojk)-@GM3;r%^TQXWUMpM#Aqy zZ9t7RNi@sbXJ4)I!U9`bQD@++#!v4f(!#e4t(33g%-qB8t-&kGqpL}49wtf+LOE~g zbqtwBr=Jv*r!S}LY`?(u<#XICjly9MZFP~cId$*LccUl0P35d{COTM5xsJ(e4V$#i zSn7Mbo%Y-{G99Z)_l4BqFLKI^RWDD}50^cf?)2yS9 zI>5j9p5E4+_ilvvh`KByTqT>s#29RZh}&|d>Q&g&unS@tzjR1;%y$sAzUemE{vPtTDSC{kqZqgT zyzULYF69p8Q^M{g#HrB~fA2hjBj5Slhj|}(U968|PXR8UfJ)ilj1D=t*DbjEB>7GH zf!0`bnK*p7n3Om3D702D!IzA%i(#R6sWXVTYVNc};c4Ei?>lp4Ddy`B<~+_kRUpq9n7c3lL0bnY^kx(#rA9zANcY-&>ck_NVVzz9k5y~>{qko)dH(jux_S!nhq zwsCTChsBzzS5ec24`}U9R4WVh29x}r6p!E{qi`pwedQ1$fnBSA`{ik>RD2e#em<0k z{Y?T<6keNh27^Wt0br(mx}w+Z_Y~q%VZYe>)l!VjGhz3D$)b^&kq4U!JKIuq(({%< zAF6l}Z=}`+p0a~FU)sD@&MQH#bFKcs zvj!>l`6@H%6f{fJ6h_VIH&KHis6lORC-V}%khcFm7X0fbF&g2Jp#N;mf)D!C$ghyM#o!-D^J zYgU?p4EQj{rQWfGCvkghnR24U?4pjDju}76?+RFP$uHEE6C%$P5Bb%a^P)}nYf!=H z&cKFHV6S9n$XE_|ZXf?2Atc#L(^(FFx~F?uGpCldRrMz4ulGWvimpm7e*vz$aX=7h z+Y6jGrTPNrbc*SsL+Nd7LBvB zHoceo`91gkUY@m-@}o|vhbKGA9U6KmQ&Y0-^-w0a4W_mB)dJk4DNeYvZU^RYsUZiZ2yrJOp*R|dD zLy-BB#|kcrXVP@uXjVzU=<8dd%-@7VDYTBlRw99ZfHYhH@K9JF7yYC-;O^A<5wD85 zXus=u;j-Rts#+1OLCUgd_v;2t>Er;|)t(P*KddOX7$0##^Cdw-2|#3kr&Xl)HHUb|AbWo} zO1@vUxFUJ&wh@?hx|u5I@FWE8=FOPqonAWhFT6~{Rq^rAW{<0=Q$1@6a2f#10i&e| zfqG-A5xWiPPyAWFHVb^z&T06Mm)EWgmeY}2{|EXR;(dH2VBh?Z1;Bv%S4gtHxvx3l zaT1ZHuE^$~;FI(!%GB|BZQB(F;qT0&{Ab73(>2UK;^bDBGB~Fu5o`9-HN&3J?t!lA z)cE&Afr*#5R`TY*KvjWZ6XXVk6Mz)RZG#fT;TAi14hDnDY`>&&5YpAoJCUoM{caIMFZUlDz?rt$ved^bDrc&lmXF&Pu^-ECI67x*p z`hmmC6H<;C58#wz%jxFF;w^}Rjdjp-Ha$Mg_f+@Z+>D(6HM% z5FwoMa!_VQJr(05IGQx~* zgxK$JN1g4v?uVI=zl-A5+3EPx)^qb3T6o6@U;=JO2?O#e#M~?CDt+HI5B3MQTf<87 z{1{aGOQJ3;3^hzDyogFGe598pG)!G`8JIGT&ssOULqy73WiXCKSK6-!SE)J`dgYbw zZaltQ;9BV9A`!X$7998;di27@bSdZA8<^8|m6uWrQ(fB_EoGH~c_mU)eas+45n=7< z>_r#*&XUceu-#qqV~GB#%;M8hfkuOy!|R=jd5+UOWU4eDFO^ucdut2+uzm4&FLYqHR)Wwx z@$>O+y1=jOA4x$PY@WZQ6!;*J>src5SzlLx*wV{#uF|8=F;&-8#eR0OCiBNjHZe}~ zDdlbcxrDiLPV+tHwgSMcgIAdR*bkqZ>WZNF1k+;|8wjuQGGU&&HCF^_>4l+K6+9x=h>@E%*pXGgKA&o5U?8xx^ zoMgMBq&96FPvLhrx0jL$V1O~SHsYwH;3AM$wG~QMk8zt=35n3_I!{-);!&XM3%dh3 zDC%sU0w^CI`@8 zU_LW&@IU66{`-8>|Bv4ZQ)2@7u+VCx&llT+sVFf;2`?VBh2h32j>YJ6Pcs#=e=mAZS9W?&s2zl}Xj7>I*~Z|9!(h`n7Iku)%!%Y020Ks=w{E{03nQ=Rwo zH0UpBev`miU7>j^Vc20tDmpg3R9Bmr9a3tyv{r&;T}mm(SX-PMCTa;hwZQ8~$U@%k z!*{z=dzwW!IxAw_enjRfQd^`5!hB&J)xmX`cJ>&uGEqSw<|dJdRy&0#f zrDpQ|8x4mPEiLgr?H8`W%ieUD2%B@$Xuc7tN;@;)w$bJ*Njg70j^wd1NCs0MZu$N` za%uw}=gc0o3kdzp7LiJ$rjq4_+8zVma;CN7Y}U^so-q#!g>~c>B25P8BpH6Z6pg;KwscE`w^&a26SX>m#~sYl_Zh-x zdAD5@B%AH2GlkBp*EuQ42~uPl$rq9}m4$(}p@+JB%}3TxLAUN^#sOy5+6$UxAZVX) zf_!C0{A_c9ZTj^>Lg~(&kEQlPO!Dkgea67KyHB6;Shy{@rs=*y)A@3n&*m|y56_%S zoiUE0+0FU(^6s7S-k^eUOBa0|=4)~-I+C#0ltGtvp_U07!GwwDk)tf2>QQ|49GcmA zX6I6cZZM`3)*J^dkyL!?ZftRpBe?XF{$8h=e{YTVtfYmIQMHt_GSwx77S}O$`9gBAEJAs9IY9qfiuPkoEcRm)( z{`aHPTmw`OfVzL{5U?gWqDSF}Wx7P}y;Ylx@bcmj?M$0raVBk9o8&9+R@W8d97-AjeP4;^4>Eelu`dWq>Y~8$`(5$pxU(! z4BiSgphw60>(Z~46X}M-1qUycK0Z{`w%K(msLQod829YZY~{6^1pKyCWl1szAvSk5 zujawt4D&mDUfvEn>nQTgXlew1Th-^AY_nfr=PTC4$xzZQye^E?Yv*kpTk;pehBUlA z`RMv3nN5bEkl&&Ox*Z@yx=uyzPt7)MY7^1TRvOmNd{-EFrWqm10nVV-_*o>>ieUb# z*;d4mdttKgQ*cwKw_NAe%CF^>3lo) zxU&Gn1+o~N5N8iMACE~H1daM~Guc6L2W7!!GkE&uL-&gz8?XBWa6encBzai1Xn0q@ z9%an8ucG~<=8v7+9J1Qk=r?E5^YA5`Wps4WGWmOs$R!zunpLrzLvzfY@CiVPkhbAhzx z!F#pm7w0oV$YPGyYtVH0HSa40huK-_OhRaKSG34Fc0JoYW8C<`o!{}-3&p++j{rer z`A=!H`~gl%<7`yRV-p9<-ZGTT6;pe$xwM==EZXMVSl*+AX;$AG)q`KGK}38w@a64- zA-Ah$BaI*h9U(JeJ9(X;UI+|c&<)S<>*bF(Rhn$|(&!&l98T`kQCiD?-6Pb_G&5Tj z4P4ZMewpTY9-!1+Z9S7w2OPF2V!>vK`c?h&_7Pq@&Uq2PRA`Lu3lb~C$ps5ot{W+TbiM9`I4fd)YYmFhU+`OWo|4@k6OEB%wAo~{}XuLmkT>h@% zhd0%tVn6e_3AUtviiJd8s5Qan05O*%bxv`z$d>|NlE9Dm6|;7)0lJ&XweJ#e>c0<} z`0D?6oIVqV{s!konYFg~pBeB)d`&&qw?kcg+o`RiJ}K;1l8!u&tMPyyv;#+Io7z!8 z&jXaZ+@RK1lpq$@h&xgLVR@^|HB3rZv6|EJDa|z`o`+FfmIkAvwVIPjo%JJK#LmmQ zBI$h4MR7sQHGMT2K@1AQymQeDzHT7UO3*cws5&n>o-6DeZ>gsS@jws?t69MOa9k*k zU;_oni>XoxVZylC*)Z#$vhg?f=$gzgU3tX&&43gpcy^XQ?X;7IDY&h9fQKIk z77Ld00Db80odKr%NDjKe*D1`E14x~I=?}Ya+&Smq6|dRFMCL+-@5Uv>U;Awii8H{` z9TjI|;31D0cw%#}Vp%o6HbIfXR`oladd@4L`0x@ZU!I}?ZLk-FsiQ4$C4$2>q$xZy zw4j!045eeQU(zLXXWW@S%lSgWnV3<`-J}PwH~XA43$q+&30LiwdcxVI%Gdm+AM|T_ zu3Wg(nUTuAZv5yVu+&Mxp6@l428GQt5X=#kpd2atVC)KGw`zZ`QJugZoG(f@X1H`< zaRE_c*8P>udP5-%qNUs@6iNPx&GHv9>HZ5TDsODqx-bi{iNdT(x|XbvK^gFq}p(a7Sj}9;rd;-IMAfXy7&8NyjZ13ba>5Og2o4 zO6(vc7LR2{`g_T$1dA;VM!u_rZA@D|%b5qSiX~(rkl$~A50+{(@ilL_`>Yx>Nb^hY zJrup4D?}p-s-b3oW6+x^37H?5I^_>L>0srooU+it*}f%s+W6V{VCcf!7D)6};19=9 zpjsR@kN{OgapRV*Nce25(w%eG&z;yHqk`!YCQ~g_A?MI+C0+8Ir6;Aw$DtlU_~%=X zMAj!i-?-{p=+)MuT)Mx;0T>=R7jSp|c{{d%{q4_Ug?5o;kAm(+(Wa&f!DHEG-{K?8 zQ|&gstrp39FN>}u%WnVTmbZ@Q=&0A?oC}V-)mMX<*{FZ7FasuGM%8jUKX-i+O0>Tm z)s4IyLU$+GBqPbI$I<&ginVdr=+#M)5y2YbtHiNyk(QB43Z2#}UV4)@W16$FvfDBf zzG#vqzUQosk@P`pM1Hm85Uo+8C#vJQo3L(5nA?7>o@$Q@N=c){pdDVeU4(W(nTf5> z`@OXqO_!=rcV1OtBP$Wu>-LQ1qnU_EwZXIVdN1=8lal@YXA%@n>lVG;!2$(|xG+T| zeNwKSzfc*Wxfy#PY&ucIVR@6Yx>_N-D_-+_C4HMB3*o23uQn{Vq0wgeR8X(9dHOkmDmqMvFPmnQjmo?Mj967Oxz8}1K+Yt`d=t==dS>-Z%K@_Z zJVAjaliGe?;eE|P9@ zd8JwYj5wPy%63z>>uZne1={p$+H_Q->W_+f;x=5`2qf(*Ra?X~DQeH=Gk4q?4{2ySndaKTbKu^>p)e!-3v5!yR8ssU%oCBmi`gW zIqkn`G8~WMoJ4RfF-CVZ>IKvCz;~il;7GC`35kcXp&~mU50g!8@Ok_E67Z5v4)#-F zK_5n))Yo{tW8d#1LZ|2zX>4Zgg*W?W_%aH9kNv!Q;d|Lz{!Mw`4u8Xwb^NT5brbgW z5CM49X9m`D4LpnlIomDWQ08T#P(uiB097tE_dXuW+Mf-Azi0 zE_U?MnfF<3w*r?Wwa9jfB-sskGa6dr(1tF}PZ4%Mi1NRdH)3ZTN8uKb}4Kh$WozC}Z zDYE0j#jN?>Kzp)s=KuK;wf9-QcZGK#FodQ*F5iG8MTn~B(ulQpzbD@Mm`|XlNIh9Z z+wfyYxvoTz0xP|@lcYv33B4}823qsgn!HhTFCai5pog<{#Ws6wa}CE?EnYb4X%g?` zoKToUvms^&u!Y`&p<(0U?f3HYuVo|uY|5tPE;%*vIC<@6sSzE++{aVOf61(i+<(vR zqhaI8^DVDm@Cs0X23_frTg7B+qWXfBh|^a|*T6#5S@Yc&9>^F9=cy)ouV9FG+$9~{ zXNZqu3oLk&et_)mle?mB!b`p--Z%k3>L09%*2f`1r@5`1u&jh_v#ZrYC2u_$7XOVCYetif4k9j^)^a z0uo$_G`?%zSFJIVuWeW|{KsvlHno6nYOO)9NTM^oIj~+Z&7;c=4yDlAWH9@xDibL@Y5+Ccs(hYUo9RH-$kldpef`R}P;D(o z7Tz-~^ONh&WeD~Gu$~u)3-gzeoT_3vqo+!@5@2k_{6G)-;8TVf=XXgpFJS6)kYVFW zxBkuGdZLC>^;bT*=TAFUyiaS;`V5qUf(sjSFxA+^<=2EBdrM_MWts4ukT}pX5%`B$ z0#Yrmd)&flJPxo`~98QwQbTYWZdY1n!ES zSeIS9Ow(N}MCw>=O_v^!S#4rD*{1o7Hi7NVwY;5Hp#cowH415KsnGf59fIy<`_)0j znv^&tBB)jvTZ(>;+rK!LeH3a5^3!X2P#lOn=OgBm1_e|M-^Wr14kV5|%DLGw6vhw7rhXrT9~5TXHC1Qmw2|J8>tfdu zeiLb~P#zrZvv`~#s*{Yo5cp_c;|L!6fduGJ6usTcz%{@Kk*3<7B8$d>B)NAMKSkzj z1aSAmYCHUd&?0}Mu@X0fYw+9828Sj*z+hg%tveZ*~>Gmx2sp^{Utswb>%QOox zl0_c;fV%)qphJ@?w|jVe#Rw%+&~;xSpPctXnthSlbVp5%dk>iE+UCO_R{ivig?6=E zlUQ+|MwbN_`HJ;Z7oHkxQVmejNR+52AIA-^F!B{d7<$`a8+o`728deoepAl-N)q1h zH5kK~Cs%NzT$Iw(9=@$?ZhQ(S z|G`RQ>7Op&mC^4Hx>I!utpu%CC> zu{)x8bTxYx$ZrFJd3ZIf2by%{0Mfv06mf<%E~dMs9bZeg+UUH=TgU3nDd>*}rkxu`w5GKZ`ggT@|n?ynn6J zK`>G1Pwtiu$2Je257)tlc42wH^r|+)WpbYJlLsdrI@{-D*+!@9KzQvor!Hy=YYN1x z`M>d%O_(S7;3FJ ziOVLfEpe~p(M>!37C#0q^)2Cw=ey;_Cp)%Vw zSiE)rSsBvu4sG#;Mbu^?ugPodQ9_$y5X?1=821Q3O?G`~OD4Xlhh^{X6L|;M(_HA2 zM~qKY55gO(<@othc@E6;{jU6rxbjZ}`rq_PHG?b>0&hMYD)~@*P|{-u?2 zE;;#A8WW#D+@YfqDlRO-6n#%v^TNV~6YE=%ki+bmy?Q0?86B+6SJNre<-OkKIfVwA zh|{c(PJ5r$c2TlRA>h`U@khXk!#`WOILEBj=JFfkN@1wwyh|s@&B-E5r;H$*x=~0P zH)QzO99K>A(PV~qe7MC673T}Vn*?u`#Qp-ibOL&s+G#Dgj^M@??D-my3ZmryS>%O*>=X8Nu?e*7?$Blmd zr7ED-b$zQ0IZAVbpG1A_H1T_UsF&Pc-S#};u?#D7^qo!T9=;zvAC8sBn`a-lz(^g4)3zSY3eB`GnVXx}Q_kz?GHPVYfZ2p~fE!)RYDVsC;ts@YTi1GjD&Ihv z+XzVB%Rv9uQsJ8*qVb-Mb{jmCQ>$h&aVi5?NV?CBB<3fJi&Pbyt-77TMsR8hm#@+< zvXzV@6RAS+)>p%(mdlt&d^*$j(?S%<-!v2%L2h`%*IkhOjOd^xi;H(PMiy;Jl3fYC zw9!YOc=&Kjz#MUE;9sKBE(gO+uoFw(uaf0);oDUTZNys60)0~#NlB?@Bf?a)qSB0) zP+a3dSLjCqq))7*W1gz}B+ZiDR_9Ej1DFZ#PXiqK$u;ZoZrg3$(Gv;8Q2vvUk)DKO ztE-BoWBTDn6QXD%tn{Ak?!mCpfau4H*BxRFqCt{g{1S7~i`Z3DbOS7?@$n!Y*_zMnXbR>#Q+=1yRX7U;Ur!v#QQx^(g2=E%^@=^TTLU3RNWkI&(>7w%aktx zc{NhVw=vtd%{3)AzK`V>mp}q9^|df!>x}LGR&3#{GFv zQo0285HUBgi=QzZ5uV8plQHUtKYd`C%Ll`{|GTvK**5Zh0^d90nTAER!B20`8Ya(h zendz=xXgcDo{uvWhAm)n2S95Vn$Ug#%*HC?j!3%10+7jQG-g0M2?d>v2Q~Y@w(T!} zD6dX-cY4M-liOAH_ZDh-WBgq;a3co=Rr$lDLKAV&B zrsa6=D;j5=GY2YT)o+(}YFzL1mNe$9%7^49g$pb0&z(wDqSrMUo?rJnd#GBR5rjO+ zP$)QZUU3Iu9hIm8ztc>7ScmCIXq}nhd_N)_=vJNB_tn>wg#6^I(=VDiu=u0&N3n;G z&l~1Dp)EVYDE@V%b71fQy!-2s2xxb!Xftj~jjx)mdgZ)OK1tYlYs+3%X_oXd!j8d2yHw?Vh~AUAG?;6%gFz>}T;zHW z-DUHVcYW$8K`#zKomCndpNR`BD0m(OJG-J56IrM1J6FtL zbJAeqjU+|HjgFDupLLOx9oNKt8>~;F)u;eS+z=j}QA?mWcIpnx6 z$bE0&oyXUT(MxtJaJ47odxU#ObJvsouk>B2NNv#I`dwfcr=qm_9ozA9dd^_Qy%1z} z;pt1TWfj(6|NgC-Dr^SJ{(_G+Xws3@wye^{L_)KAq27gF2l_Bp(yuk&d>PJ0hJCYr zyvyYn;kLhVl}Ce3we4zaCMU11<3r>%%GlKA!;-i|n%2LNtOYD}Dd_Izpr_#70m^en znPkpsCXX%c0bfU~av19Lc%!hUl(en3{g~?XIU#;DdtdcNJ%UM@NqeS7?4_^$^*lGB zhi6|GeHHDN({EE#QkiB+f4}l)9Q!11?Pnc%Kl;lAF=enGkLy|VV5vdQJ9 zb9L=48lQcW4xgrxpZcTc8(>FoLHqm-zy{**$U_Dr^wNALtT*EfgmP|mvbbg4BzVQd)XBI-tye44jM%nUa})w1f9Q*ysYIAz-wW9?=E<+~Rw zfRzEczmOC=^wBxMf7J!MnQnb6VvDmFRB6j#EW{T@fH#`zHYeDcT_p;YvDKXSAQYQ{%3?@wh!M!oBQnEe$G8qEB#a?9J9Oyg$O5$M9(S} zsZv)*?Pep0zSXyu%f{g(7_UpliCEBxSAcJGfEMkqP8GO6Dx-Chosd8GIQ_!V+S*l1 zU5V#Ol;;&KP6`(AwgG=`QXC$3#@1Y?xAc&EeBtIQql4!)UZ)eBttQN)N_n6h8;e5_ zYJBhbt)sV-xf)3D*5-|qu0Y;{1NR={H$Q_8=VK$3ZW^M3O^XVvELcx`Hm|agg+iQr z`xRwCkTeq-@UPEY9Wp3?#}ICA{v&5a-FpWciLfc~mqoohB7u8Vj#`OZ+Z`cw;&q%U zVB)Pv2I6NxWZ3m3^u#FMcP;pBwsQT30Y4{Z;&tA4j)^Ur;Akh=nGC}0G&HoDX$Iv! zZakrW@Q3c%so#juhu`#Bx%I#pEe+*DGZp9VjLKTrSJ~b}d>vvja);8XYjovBV zb9}=p)ygY8)rbhFSG2H3yAXIP4| zkgq)HHbr*OY6xhsC*eHKyb+o9B!XtlOv_h^yzGn-c3v&Q1(~B%CQiLi+1uE)g^~O{Ot3| zdW6SeUrlT(u@RzT*h~e>kT;kx&icC5t{0?-Ew$W4I}`CRnW%fJ>vfXf#5#g|r*~&? z^3@*bnQ|fw+|`JfR!WTR4tAs$-8=@W>F*{}2Rx6cc65q~+VxylbDgto(=Ya~Z`hAp z6iQ+47xqrO@2(rAvk_Cme2uQcLU(*jHP%R5x5Y52-IA~ZKyrImD~KT8U&)s1`i(o_ z1d5x-V~V*exLtbi&@}3SgRnuc|L9O(58-2nW_*$HN^kplc*B<~20}zjc#Kb%D+NI) z#YU3_a$nr&nQ!)F(SLn)_DrmPU0e4FC>zy&ikbtev*7?6ymR;-Zd(50D*icQG{Lno z_fK}=H~IhTl+v*RQ_JKFf40eXQA;4{WOG4?l+qdDf|$P3NWjMXa^w9BTDXqN;y#Q^ z@452nC_fplDqPDN0_~s_w>mS+~lz3(f?{b4C7H;8rj! zaG2M$$E#zsES`Riq}OKkhS0k{7*3)LSi`BS0Tamqcc6b3VgqIg7fD{A?`f8?UrsWx z%UVg6A4+x1SvJs{82Npb^d+QRBsL9Rpi#(y?TkJ&7|$sD(9>X-u$hB~ z4}BTGJDnQ+4l+z&@ZMO&O?{A9gG57}r;KlG_u6x!A4)AIaR-h6xc$qXc>>Vu_{}poConzz(WW z*m5KkY!1zESG_0@v((C8P!)7huTL6?4yARcb$v_z-sK-e=@O!dri9A2{#gKFXj2cG zZID5BBy5C`HKtR-T7XTBLk4Z9u-QrKHyMATt%Kv6%%S<-biNyhFL`{Ge<9W`A+{gA zrdrG(NuqBZV%hH-+hRonF9oPs>LhLeaaxvUdx<4iId9NANXi~fp2*VS?sQ+lh)`$~Ks#rDBuy4mO&_`9_f41Q0aL8c~c zL)YP;i<~{(a}@X&vKs;<97Lck@UZ?K9tjfmp11(5NyUAPlbis}ijBW0`Pwi4OHZEE zf9rI0lu0aBb#|ze1Q}L6?P(9JLjz7;WE+Rak`v=j0p`~wP!-<(3_-2q)^hQe{|I=w z$Jo9Y43@UcqH6KtWJt)c-Su-tg5)ts+UM4{sd1a+lVxKtnOIpfHcwgV^1Qjfkn!-0 za+0c#mx0DkWPs9G&&)=E<5hfFIZ@|r|J@ns2XmGYb({jcc5jb9_zFz>`S;(cCbpD} z3C6eej{nXI=%bc=Xag1a&@H>OP|JAS5kym}>ZW!X5Qk!r7fT36Wrm*ok%n}|^aHI+ z`o;zcELMVqSFdQTv+pByP}$Ai&{tyl^Tcy`cG+rDUQYX6L3500%-K8^yKOsX_mhyf zDPuy);)G$u7DEr^wk@#AyhFlHn;P6N|NfnLJws-qF?=Rkj&5X|Mj*Ff1}D6_^8k1q z{HOKAzw1-ceD@WhyfMab8_Xbt8Nf~9 zLbnbeHn3YDJ8_IeK&x*Mxpsm7mCvD1=L>RzQd;CU;#_=w#gnJ3x_3A2UW0wa%8_#yU06?b77={qhr&E%{~B?|A}gCP3I)olp)X94(-BRPDb!Z zNl^{pRb?0REte4NicHv*-7%}g84<&c0%ME&Yza5Yqyh{2@Dx=85SCcpPA`?bW+Q%b8JSl?3 zHN;7rYnE-lYD^i1dP|-8?0etx7CFeYHLYu`94^HF1nFw6T>nMFRHS&R-*f2Jy1k>| zsgV8!M(FEQ%NCWMm$Q+Z8)H2bZJp0R{$}Ve{j1tu-@XG z`&DAYFK5tj0i(*vDZ7ic0Le$*jCdXH6l$wUH-F`!t%v94$4_0B!k1HCocbJ`w$~2^ht!4r# z#sk&^>*8PZ4P(-B&fUzS3U_mGg3R*L-I_*>g2klFNW55EZtnbb(#$w`E`RPfc+mwtK&~+W1@b?#Wb&p)gER@&l ztYiJuY4Vj>g}f+7##N8u;cNUD&DWB$H=TdbLlV-c0ZlFaV=XlagU)YRFluh{2Th1I3Xdd z_ZZ2N@UY=81SS(vdhfzL^*;xi%*Xd*Z@mN9=tm z44ZJyTwvPbXK%}6$JJOe6G0NeZkv}&D%b0GB=6ohmyCf%a>7J2_r+sf6Mjt3IY+}( zjxrR*!TVG*=vWJ1m0D&0A`|50VDU4)MQ8nkhfqheYBx+|s8)n1PLB4*9{GVQ|L}Hq zY9yO-CmF=Ja1`eO32>K_gjsQ=aA|XhFaG*L3_KD1Lz~R^+!-48_Ai$6T+dcFV z!QhClS_3E%O#_nlBXpxMN`w+LDexD918U`859k3xbx7fSSdZL3M}!{%sx{b&?;l&A zOk)oiP;nG6X#=eI@|L*%{ohVsCL7$G7gq%^#_QhmY;(&lJ;~0NBDZh+n2@H}5j5b(U_p3SNLbqQ8smu3IQwOweRDT8^J|6hC z9V5A@H*ieKD!V0Etm%}KRb!0hKg!clK50wddHWkZr=Q7mpV*HF&_L>!Da!E|X?hGc zml8*quP#BNH2uC;mMzP-@4t;}PbldR%!Wb>c~Js6*e=_{&Ry`IF;_r*JI+;NY$0|3 zJYHY$cmq#^r@51Vy{B=CNs)<%&)3@{_;d4?w$l;SON_3El|wMb7~(?bwu48PA;=h6 z+?o)K5_}Qj4hlejZgg)yXm0sm0!WrHsB*bsP*gd-x)@b(aFKw2R2@sX;yjPJhEX5k zPsBl`STuUBtD50h`V_IP&;iMyoW=$qwv|_rp!A-zJ!q`=wo1$*ncpiU zET$YKu${6ul2dGUb9^J~;OWrqIwi9ohs*@pYzE{6I5^7i74R0lrdlrTAZrpX#Sj@9 zXSIfhoKLk~iZ%9R5Y&9%UYduW@W+ zL9H~`I-m;=sZ)u=Hwnr$cm^-BUk=)OL{;*g-N@&0eh&&8hy7u|oV_A73h-!a{LUPL z4Y8et1n#~W$RIxffH`^cBnh&2pK3PWuxQ#oq2bEn2W6GIy|#nMUGQ4*v5@>cr|mDn zIVax3wfavPkC+&@m(I|suzb-kr_PbB$GeWp5P1FQ4%NYdg-B8$vN#@nuv1}w>C1A6 zC|w6;M-8rdTB&R=-|8ilD{)o_mhdeK(EM0M-d`O9SFcEyX{21}Z{|SA4~Ll`k4UX~oLSNg5V&SeGQ+AGm%){O7l8P6{4p_q&<=)4&tB-9 z_JXE0A7Pn6Q}4;xgU0?Abl_A&e`<`_MG-3 z^)@mkZxT`*uasoXqiEk(^SDR_ujy|Jt=FPwvIsx^LbA1R+Y%~FAhiwjr|v0uqUJa& zrn70CsxMYxagrxL{cK!(HD3nG&u+3-h%7)@Y(jspEpxOlcv-KxSe$VGr!P6o<-365 zb`Q>o{0BR5`0(8e0CDZ>LBnh^fHQR9%TflPT!+%mrijLOKJUV#UCfK^V|0{X8C3nN zLGWMq)BoQ;du=p}zw+lIC5ojTOjnmzmvXZ%bf{@@DeJWS{&s2(Bikl8z3JMzgf12e zbjvpz+Fu-UY_5t>?($T$x^iOqhp4S)H?vf5tc#?`?;L5WPSn&4G=$77hQnVO%qp}? z+t-?}E*M$y_*h8uJNhwSFtHf_r{?X$e-U+J~mETKjXQCmxLJ$`f zY}&CxjIg)CQQ$|g*KZAnzkqMT`w^=P;zx{n&@p%kB82I76MQi#Jm@Z_Wi{n!=bz6bsYxY*d8!((uJT(_ zg=Z0(bX1I(%QFNOjZlTGI2K2tLKNAS%T`82fJCMWS7BH9( zpMEzYO1_uhZ0RoP6>%a`E5pGVBk*_(_7V(iF4#FmZnP&RakY^YxhLQNlao2I#E>%1{%AcO-|Gn~YAhy{#N1k2NBIt>B z;J}rHM-q#&oPbvb8E(aup6_YZ1+>udatfyT37RHWSsZnX(jWmNdj}xYb3iW&U;T_| zUa!nN*pf2C>!M(M6m*NVUKAuZH#i0Z6x|eOp&!^b=*jv7WOV7{eqXr)4Nu=3i%p}G z&qT8drX&mys)C6p^%dG;HfW|f*Y}50(6rOeLBdEn@&kfnjQbT)#T)spo~Vcg4kOOz&uiKy z^Qw4>X$FlMytujVsB%gJE07H>2gzoCBT^fCbSXJNxq)ciJVk@jeUhj>TuRE{nAPh! zr7GGi)hiLhK{G|WNXz3Q+!N$4L_)L-_m7gSjUqSyu(3*uvBejfrBbVz)_J!4M^+0X zHG|`tx4?JLflc98KJeoKkT*C{hIYpBiIiMtHbQDoWboNak`kLLV-vsKowOe;OV1bd zH+L~CWC&Q46HwZU^;$w$13Z^}sf5V8J{(ZmnQv_vc^ZQ2&|p9}v8TW6W+B96H>~octEw(x8FAM!{GwKdgc7u ztj71QHmExpo{m1WzED!lLKJB*ldGsGC)5`6t~VEjVFcI`u02eM7t|M(or|6okZtR! zL$V>}z8v4YJ@kRx>MyhfY+uv~?5}_s4yvHfMD`Xm1m$U1(N~H--Fo(@*gjcy{mW|s zAWIQ$ptvbjKfPr%d4d#$*X7Z2g@^2*Ha?e8eU9tET!Jh@?Y?4USES-va7v2qzzYf& zj$S+O49Y8`8oON})PpGnzdWtLZOi7+6P4g$Jd_Hx#7CY*zYI*s(sn<^yF?%A?&KTyicWpK?^g1wp*JfYi z8RR}kDe~X|0Ft+4)^UXfn@IT0gu@VDNP4nMLH*Q%@f0dUbtNf#t9(R2MWXT7M zl*$`hG8@R_Q!p7&>Nfv%ihS$Yu*MqhhZX%BWG$`#8ApbdLZn#LtE8KX4zHqv?$^EKYvqvT=VL}luP)(6QYac-o|O8GL;cG%Ymxt; z80t?W4a4w56E-_}J`6)Oq zDOvKHQa%4}L1TlmB-kUPw!;P$6>=NB%(*4C_!&bbof??<~wcUgg6e zSFm9nIXOftsSWyfEGq3*LIKeHduBF=TIhCGqB?pp8m!XG)a7Jp_D1*tCvNlR>MMKY_}2eInCJ+tlu$=IVvCI`6%3etJymyliE(pEZ zquZ6fDpeZ4sU3te;cc2_O5>{=4xx+!Boyq@-_sBWjIXKY;Dl)Z9>lg1>Yq#R!}uZy zju900CnVcG!IVZoJhc=n2`J*zOoC^mB|q2d_T&l1h-`fY2=Gw~vp*fmnlg#r{$d1~ zN8po=Sw5-=@YnQhIN7Sk*05TJFkGK-az0qAS9+<*rpncxcAF;IN-*&^=APRqHUqGn zZvBOvE7k^I=_m5wf;R+6BWBiefMo&p1`Ior>`>+B(>z8WjK{4XJ=8CY(h&ZZqB;4z zjr-5qC}zKh06J}37iuF&d4D07K+!;{)u055L44|tGW|I)5aPAPp@up1x_lwAwy~~q zBLIWuwY@p4$tqYjJk(RE@>8Ah$>XVbuZHpttyxZZ`rx*1%hdi2-4NWL<97Lpe6rY~ zQtB+6kVXnDYMe)zKHBQdC}xEDM()K$%R?J;DLf;_sFro<8s?skd&&!gTY~1Ns2d>9 zr*rDLjpw<4wBQM1ez3!vV9A{!sewSZ4I(P)1yz~Z*`su$O|TSSO(7E{FONz)g#X^Q zI=oi5Bge6G_79E=a5EMb>x?Y?rK(ItG>b;hy?3=jdvWkX5+L~%cXHas)Y}OzmGp&?DBEK*lcig1S2euRFTDBXS!Yeca@oTaIE?2eM+L5P!AUq)B>}-AEQ=sNN^Ze6CX?&?| zJUczWIUJ>v3M`|}b?TK@VpdJ6HBIEof20fWyrG;MKP?N6zXZ`gRZXSy?*(-KD^M&8 zi@=?%mLPKZD8%@Sy!o^Ks3qkb=ER(ZkOD`%3zcBpO&$5PO$z)dP4VQ%)$87o1=*s#eGp`&0#3U-v~mJyQj_DkxGCQ}{z8xJHTZ|??<8D4;9 zg=p-n3eg6(%QX5b_WDy}W28fWk=4-XT+#ac{EfcbA#*e$nBJUjCl#@Fh-)u#s?3v| zWQRDU0}CwcB9@<^OOAaDJ$l6tB6gGt`~%;05(po(rsHG4Q98DJ!ToB&iBCyov2z#7 zel4e2r@6_b;x}THAw=dv`_IeD=VOgmL&w=1?WI-6d!;f>4j8wP;*=5O<`ZK|H8|#I zUY9Z_SkRgT>`_QQV%wz`I)sM1#l7T8WR+*mszmLfrug6x;+kLIjoSe~RLa+W_{ z7FuwPS%u@nG%kP-AiCl9?(d{)tEx&BGTDp1MR4yrebD%+U@fb-1Bo*NE<6&X2!cj~ zvf{9MVP2~jeWp1}=fzJS5wgr|KZIRWW|ae&8_)W}2xscknTfo$^Df=Av_>Wr9&g7JFbPZ8SUv(PWTr7!(p(mf}A zO$axl+}--NnY{qZaZ5kLUE%#v4uWRM1-!;FT7TU+$8Bt7MZt{7^ORH`h51m@B{kIWRiS|tvY8GjLWhx-WfxsL?yH3 z*oxd2RlW*v-F*@3T@w(rSMeE#-XO{u0#goME;1FtAgWr;HQ%fiZR6$UsZ;h)STJs) zBT3eLilcooS8$5tN-$_prW%@h+;^es$ADrnkIQ6R5r^twGenO{Ajo@`|LK{Gy8xVg zPAbeKGl2m9fRt!j)nA$l_xpX0K|o`HPn)+;mf8DzkC|q>42}TPB&pfV7*~*K^6ur@ zY12c;%k!E!5fU+G(%mT908Jhzj>UOo6mnBv(el4!))|{d+qi>0fFK-a)>9&O?Ou&eH(V+w1^yX*KFkbM4inkCzSqa2#lZU zzEWlk%_$pF8={>DvSaFcnoNv19 zv%kgBI+9gSHOzAMP<@BdaMW$%MmMxb&&)4@bH%9bh|HY5da068C|3EyBgyk8G~4-I zRNAM_@qn&QqJWyEAcnj;iFl6P7v?HI3j7RiH9-$rVtb6}1?=!XSRoobC_d9>u_-*28z{WLKbuHe-ZcQ;ZVPS`|wyog|Y99 zqLej>A`B@@LMvIPlF(!e*~W||yR1bdQz6MRm91=J-?xylWEo>$$37U-@6zY{z3=aH zKhMAS@A({u&yk~M-rM!QuJbxy=Xt(@KhQ(y+`AYO2^R!b9Ny8#zm`tWhdeb{y81W? z%pBrKz%db}Xh9APT`p)1l*+gA)FBL4({$|=%r2`wD$dolO^|v1FmE44sR=E#nibM9 z6S{fVMQ-WMlO|-NElJhRH4}=m4W2`!`Y+641~1Y;k52p|AAXm$k8Z^aBSsS%`rXD@ zb>h)mZhDx1w8 z$Q~dKT4X3c0rI#HHrw*E^qVtbczc&(0Izr-;W$R?9vUcpSpR9+tSS~JrTMXPY}_AL zw`sN_V@O6^=aNekzjE6FQo{24>Fc@O_gEipOVa$!e!>g!)OeRDP_|~n>`SEw zXiS0T$Vz$-UARw6>tB3Qa2L&}7i?V`Of`Q`X}e16A@^tSZltWOcN=`|EPygL{BWvs*}}y!R;nS{1C;rb*le}&xa zZ-Nn@=Y2K9b@g_)6hS;bF7r&|NN%)~z#=HFumH7gzz%3XPte=1Xgc_4=?}75P=y@% z^Ijc++~7of_`LXcGIU+BSrd8w=3Y>o-X)$>0`!#-!YlMaaiPNGZ*k$)-{JzGND6WL zY(p#iJ8%9#;uq$|LR8WPzL8I+@CRna_(LDfe!OX@-?XAM$xxC0E->ab1Oit+HP;X6A74EZ#iGU6LuNU&^i zB0fr9$!h>3%KjaD22BV`jNNA)+d1ZbaP=k=@5u_`G@)Wb)aFrlV$M|~wndZgVKd0f zx4A42g+kQ16r%|%?xHN8-1qU1)l^Oiv61P%;SU zlXC*&5fz%@v9>2(1}j1?IE z!ES{v<23SxTeYe*!610iO$^yx;Y9AL(*!9|Nx`4Z1@iKqMJYWVwH!VKxzEm0b0dgF zBHNW^5gDXiij~ zZ_tz>H`Of({oEWL_Lr`HaMZhBMg3Iu?aPegtR4a)W=04Z+V8RT6PV#`+Hf~>o+aq? z1UhZ=a8lkfv=VtL-`2_-RJWbGN4KocQ0eP&Q}SzO{MicU^D?KK!PZ#j1v-r)MZ3HN z*m*K$w)h>}=V_D+sf*29Y;@FxXa4IzejRTFsxzx=}xySh>zdz8XDEEVB-%t^UfPUO@J$q>C&r|bHHq?127V5FJ44$E65 zUrPH^RSQIPUuaaspES7!_IiyKN4E$7*Q8`(2E^d|qRQTfvZ?+$wB0|TN%p?U0o`1O zcaXt^+#CEIoxRPT<>reX^d)hnh<$}w`G9lzc$<;a}4VgW$wKb>9*Vw)x zA{bAa5J!G!b^1QsYFR^4l=jsRX5H(zy!RwJRqV{}@}*LjG&NAH5h zkI$o=l-_X{K4jdSkY<6!?!9XU=b#)<;m`RX+)JZXy8GZ&n=JQ!LmL4)Qt3Jh0QV>S zYT;X)Q9EG%hYK%hvf^K4Vn7(Ic0rNBOccjty5_Buza*ah_M8#e0;q+_34ItB<78QD z=5=c!GTJHm`Z|5+@~x)<2*DX2^Tg600x14(eDSA80XVk@Gkr7J3oTc#{4PlZ&(qeRXYXxj3THJyHRst%_Q z;@(tU#I)R7b=iL|nU5QErx}9AuS*MHp|N`h#UHTqYj{2?6Zw3ky0&K?V$iD2$76V! zuh2(-Wu!H5JNBkux^JQd0$K&7=v4dWl_iDQDIwH+kjm{JZwaNBD|M$o-)s`?^art3 z>n7YNH!^>ia&ErN&3@sfl8$e8LPrhmSe7kc*VVgpQK*LuYe2A2=4pN~_Ib4D?^BYB zhTNCit3C^`lB(1mn$=)6Tm+-aC zxMDqu+r^GguLIIXnAekJWB9$9A1(c8^8c}f-~&XMuG}qJ6@aGQ2c5L;fX8YA{`W-| zzpHr!GFxG5x2Fl^d5l0-R7B2nYCiFGeL0-FeYp54Nh7d&slnG|amR^VCb$WXA!GE3 z_r}GM&^@gviI7pc!8CgjduQJxl&60nH25-RAs3^zjUC9PU5&{H*WmMyAmlC2t%rXg zreiu;g^ii^GDk;q;?q9l?RkC9r zbGQA;XT6(wbTY#lGV4!4K41(W5BD0eTdc75?n9Li+P1ueDpyi4IT;aYyv1CvwOfd1 zo;z*S%-PhDkB<@4EZD!H7^IDyJEFELx>5=qtNq7OuSbSqzEwvxkO2Ljr)lB208IUh ze(i4;?!OJqekKIEM+l^>$<;TWdFriGH07_VZdQomD5W^0bN4d`+MM2)d7yl7PYEMb8aAtx8Lm z8KSW`jb7| z0zz^gy0NxBWt3)unVZX}Mu7xQ`A+_UheAsgisQftCtT~B^w!>v)%W&l^Y)tc7k61d z;}yl&J4fSV0_^){iV9372-F0;Aei;nftIoof@*QI+f&==vA@xuJT1W)5xKdv=7Gad zIGF?QlcQq8xFVihYj+mAwli%!`e+<#40LT52{ZZretVplX57EvEocjsg_LPYj0qRlcg*HQh$FYp2sK25DLW5On|;*~$}LyX){>j(5|mPhuJ8lf zTqSD01H&+en4u{Q#`Xh%qalxgcmw&pSKW{J*05&H`vbwWR83Of!ZP$C>YG|FL$kA9 zTi>KDkdjF6GS^_%?8Vf;i_nMI6@3Nq!%@#6>M*;kR+w=fJk{t~(zci&<^8zNWfS%s z(3gBM-j_q0nbFhiCfz0@1K2<{<@=ZKfzi?;2{zdOQoy*-1T%;$RB|6kMv(=IVz2(B zbRml6xPScXq7kUcrOxH2chmDsOrOD1#-H)_6Jlw<{y^3)Z(??rT)--68o*BzI!^?B z>z6TPCKuYt%JX%p({Cq?_z0#yofGD3r@ZuzV~(o@sd4!5?)V{mXf61)E9Y>#D`3vZ zun5oyjXVT;<8rS?!dQ&PEsH5Z-38Ht`d)t^cfYL{HN?I_D{cEUE)NzARWNUXb4)P% z_^76s{XHmW512>-lIZ>)yBI-5HU z8xE#v{ax##V)qY$JcAGWA2fwqj*Ita->hdXPJ0K<8E){c%&$Lhy4vL=ZNv!T6C^-< zg041@YxHK(b!g^;_m?y4P-8NM6e?jcRmNR%8 zy?cxI@^UFp|22PS4Yvfgm1j-%@tCDbi0xcIwh+Jd>eC)Nuc^aWUY*D}>Azld-`{5r zVc=cYl+H`$i@&c~jA6^l96yoiddzo$^eIWwiO6xN`*n1;B1-xDm!5}|DAkB4#i9g)+v^%|JvPluUeT_z@bun&QcpIh0Ba#FkCG~&7IAL~9 zm+9>Xf;|?H4F?Q?^%v-dV`sErIU9dcSjnoooXa80;WuvE;8wE; zN#iV%TZ;l67lqy}?pxeQ5t|;QHdpM0y<%X0o+~2aDY#;jaRuTE}oT)sR>W-9cxw8^8~m2psdVIM0c7&h+l)>ZP|q3cOc?C z4Z!NPyNG|2$_MGo|4k}iXP)x};>d%{6cOeaO%94um@g7WtofXA>`Y6U z&f;!~oo6oIabZF6km1ian?3y{uDX<7g1%huTnlfZi3`LDkCh;MP63-e`hp4BP91ri z(l4CiN#ybYi|$x_Q`@wYxbtu<%62H1Du5k-zuNJI9L;|N8LG0U*mrcP%k@o*@M@Y@ zBeD+fh})6ZJoT5G9vz@$bo2ELcYsv3r6| zbXy$f0pg3kRdvLKe80j;h$4M7ytr4`KW)q>OdU09v!=UfpM9aL5(U_r%a|6&RkM8* zjuvI{bx#@7;zGvndw@CrGVnSRSJ9Xti6(w5IZ4~|WC`z;UPszJBPw~1MR+xuCOD(~ zo-FSYY(&ziVh;7*BpvmEyC{5*7Nwc4q5lg}Y63)g|1YAXd6w?UV_Ke(yY|6~GbdX_ zx4rqV`wf#v8Dqz_RvYt_XXPh<)z@3Uft}Ht1}X6);?A93elzL>6EeHYbKHc?6a~Zc zb%BWvTe7mw>a$Z(Il1TDRE)x|>d~&cMBx_|!ClTsujJkW6|jUB=6#>Pb|wWJm4(b+ zo;UJ1c}OS!SDR0Q4?mQPZ~0kHb=R#GOB;HMB@s?<{o9)2ZnP$!tu1UM`9daS<7WN1 z7-b=F&bpF^ZpwG1+9YhZGvD*mh{JL+zx=~EZP(S0q`nYl1`Y506$n+4j1QPDdsF9& ziPva-o?tHN2y;*!I?AQh$}ctncw+ zTCV@XFS04nVuJ!Sdq1|&@~cAA)c%d>V8Mx;R7hza zm zw=K1xDEz=>15_}!bC^<2`#y}B1^GI;R!W?6gQ&0l-BY-%wcLBMPvf29IjTR9P zy`D-Jm}n_gZxxJHqYsg{1JCW`QI+nMd~M&GdfG4{{eLe#Te!5$k6AX}HPV(KS@{L7bhn$@MGdVBhRP>qM|Wb;}xI-S`7B zN{;O>e<56LBl43fp%5y&@g4s45$nlP6h755S5ZA_{Hc$Cz^u$?@Hs!4U=y)vpS}Q~ z8x`RB{y0;od4z0V=cHp;Rrsxtoav@yElUk+fhlpfsb0KMy!OV^Y9qi)2+-dEo|uIe!*q3m-A#Z!6?;NIW%m)(Gzf>Ada+sB{y9FcO@NVdZ<0sqI6~G|X18W%J z9Ni*<+%pEgX5rv#R0?RcraBmpIxojtr%k6LJD0!eXTKK|ys2u&EbYtLtkHmeOc(?1 z(HUwSjulAU2((v*T7Uo`rlCbJ-O^wi&0q)Q@k^Ua$T|coN|c8XDypPgG|b3RUyfCa1sG5n}Ss0 zzhnk5Cd?wBK7h=CVOV@Wvr5eHoZFjWO5GRh6^9x#shq8XnMO1#xvAb?tV6lnq4=nB z{Z?C>U{7hnIr zU@kvt_e5kodQBr2x7t*PXY(F2DJ)!NN=wvvp9`}q8Uoo7Rl@U(IZa@!SV_ zhO-JO+ShE#AN}zFSmnW&p;P~N9Q!1A(aCIbW5>p8w8VhQdsI#8kj%|mkJ$GQZoGUh z6>zvld|X7t2OC`6HAwc(zP2Y8>}T&e^C^;a-O++&41U_R=Nk#A{@Al25AXf8?|a1V zF@pCi<0B3N#8|Pf|JwKAw`Kvmw7wF7;0Gp`<+c7U>(pt(w&!QVq`e2ry1U zt%PPOl=d!Dgb{=%MV}t_Ih`zzyCRZ&U0PRQ<4~~3!*PEj@AFFlyqtK@ORE87&}<8` zSshSv5=p_}T{?={7bv3J;$Nc6p-L4KHYio%j)jkfN)ybl#NU^6OKf_5hY%bb;NHE_ zrJh|Uha5Mg3|}>3zFhhBSL*eWLPW(2jyQ70^s9ge-przIAWdKk4=_fZ)V{a&K|S#e zRSEq1Z#VzsJ7~`?kj<$NPe?Ul4tpOpH{`!y}b7_Eh9?f_i} zZza-75XyW)*h#1pIc(ULSi0J;VRuy>7?yD12qEgRaSiu9iMy%G$W?&^{iWkvK3Z$<*XkH!+z5W&=%=9thkm_@;((33Q}$<(NYxQ^8QwHj!ey`u~)*~_pQYG>} z=DwSCbU{bnIpH()=8|OWq1iA`p+rcBunVL&3Fb9IlM{y=Uh3 zPo|2GLNwd~%IIxbf8JLaG>kTT_F}5NKQ4k(#M7uDtX)mSLse9pH;X(&I{zGn$urEF zEgaE7f8EJp`+Z4CI=1plkhVmSp4HQ{bWL?*eDKN7-fgrxGwiO$Lg{`YX0pT)u~`fD zLsyg`zF`fb_Tx9b7w*K?tBGzde-$a0pZ?;=r*vwsdg+z+J#Q&d?Gi%+}7+H6n0Km$%7}-Fhh&+-EbL%(hMcx znUgUv!&5mk^z@0MKpTy^Ppo(CnS+~dSc26AEl;tW5Z(Nh z6e8E>RWdjIT0Vob@T04_tG+mw(k3t2BnCeDY!yg)R z{{#5MnNyJ<=t%VO?%U4ye#H7cqq-DHMXL_3Bn|!Bt{G(BZf_WICZytsJch{MWBbi7 z&F7n-nAtrwwoU_v0TDAZ?RWHVq;^Yog*=u2Kz@~WcQZHBjuYVa8MwzV2l7^i=lQhZ z>2Hpv{Hl}hxK9nw1u$p%HdV9f5#Nr!&BfKVac93CP@XhPQQ~Q0`!QH$oFQ1P+AKIT zK0Ob(!+~{B8b*Ts)>mo=#Hzn|Y;YVH=1kbSnBuR#dOlR_T9T=Vxd?sS|4q0PnlYr<4yOm02b#D=3 zd+^XyS~8~F$rXAdiJ2KE4!dtX6F@sbv2PrORrQ~XBJvi8q6bR7BbHv$5C7&4$fVb? z7ZH2P0!yQ>3OG>5CZWJe_62MU57e=Xx6r*+bTRfYZu>sx{IJ4(U7?`)&mp7q@WPKlnJzCS4_|0`9 zBuui|;DL7yvs4W8HE37auGF{}^$nrHWOrh0SF)Qq7dsSOtsTBrT(~S}2HO?&D>SjW-=c_F?( z_CKK*)pYW)d!8mV`AU8*N<8s}OLZ@$Z0yF}BFlS!APx!lLj#iqO>ZQYzp9N+{Na`s zNgc+0s1gHc9_zhLs}0++0DBJ@I3pd}-35Ev$SojCE*SR)efQ)j_c6H9-@_e5ckZl- zkyGstZ57Krp$Xq_w8)mb$74HFx5t<KY+3jYtw)c#$WqqGaOH7M% zN2DunTnVG5gl2#x?h5##pWto-fzNgCqck5RhH?Z;wf3Y*t-1tO=#UfqG1dt(T}Q2T z)+KN&g6n|-kV$>v7?v8rW)Z?nh(9-g(I@YN;dLL}l8n><=u+3R9!Wi)>0PLs ze07yA+N*?%qw&1ty_jeLj@x@dY)^o`3E)@O?f?8bGn#xXodcY`pU{}ZfrZx3@m8iC zE^do0JAOo)k#&uQq&&yFUb^%ZNUfQ|z_lp?8A0YdsD?Ed<&q3UCl9}rM_`(kAoOU8 zQv!)F1`4-vzYUQo>a1=Or}?{+cf{}MR=DyfrrC;p=iTW-=un@4C+AMFrcD5h@fouD zazDF*vGcQSe;jvE<1beY3W|_LNvuN)0*6A|-OVUNe>20^Cps!3CtY=QByt!ZiZ#U} zQt#{A$Xx2p*XVQ23ILkOj;>>AoA&ZV0u&oYWnmiN_0 zSh%U;@4GwiW%K3w@&+OHZ9}bhv>NrrJ(xc+_aUb+!^!(*fKM0&49gG?U6^eFtuxop z*(B66ygJ@^C)NIi&wSgA$fxBqPcI7Xo^L{Msmp-@rq%0GEEDi#1!8$B@0C?+p!V+o z4i$)58YzBW20YHteXtLlOhbVe=!+q^;x`tM%>~4eOSO_!1NRG?LtaPh68fHK8J`zX zyddh$xF@tVU>DRY~`dmoz!M?87fU zX=mPwea~o?ee>sE((Zt````Q#liRzzfIZlgFXp<0m)#yJo@-Acn@^gEpn@8LKXSxJ z^G(KtF~%yA^&+bvO%ok*ixo0CbA$)?r>y5$d$f;&w3IVS-xs1qvzeKrAKIc1!mK>7 z|AbiqGz@kn2Vqu-e>0}u_Dh@vRQWG`9_5H|3+2xI93Ll11KU?gr&=1VpGT}EurrzH zR2Lj2HbrV4f(kpr2Sx>cog2~bC>fgHPt~`L3*r~=q$aqs_XcT`1Vys{0^>*j0~n_V z1F0tvUo;$qPYI(`$k{RSas}!)GWGm4%3j83%L3<4aFDH?q+6qVj9Xv7ODK(%0QA7p$VcbA{ z%zYb`75RBOXICZ@kXhAf0!FF>mdP7epX4i7p;r9j*Kjy7VN0O@d>lFFReZQ4`K)5P zQcu;>{e1Xj(L7@FpbGP$!FB8~7>=L4&(rEuFuR~n=sYHtCQi{qPP{=PL>;^=I!k%r zs=Y0GA?!!;@=nA`f+$&ik=5_^+~eC3w8QXN*$3pVFomKQkF6RU-99CGoE3pYq+}kM zd3Qmy(WhojVJL%Um>drO4c56H?}bE)4#2~fHDg=+;1L~!T6t4?i7vq?$Yt?rj|Wx|B*v06zXH_P&}$MnP4cAFz5C$+3>8A-#cA|8ms znQ%=m8}EcILbZkHXl%X#i^QIeg5=NWB+jo1M&HN3w9W(vm&U%TlEt*#0{m4d4zuA$ zaM?QvRzPGAej%iZ7Wo*m(G@v*f+CWx@hf8$8mg>u>u!vR{PJ{7>V=^jb*zWG`55oL zt<;_*+}YDWjvaAP-q#(j7CJE+o2xE;7m=`X?p|8>K-)>k!OogBXK7kBh=YWl}-i$BVG2Qxw@AxzcW@0ukT zmjuPrebY%>J_I!1*yGf z;s@Xw8gW+pFW{5;)|dflmwiF7NBIj-!;xb}U4ca}9<7+%RR+7l!e-m^Dz3WSKSZA; z#T!5-?FBA0H`i!*)viSSHMmX+36_^u7F(|%eIpajq1YCRU-Py=*5mY>C+*jJ{;P)m zmvsd<@+Jtw{x|J8_!>jpZuP2&o)Lf)CJbzKGMR{^&dmaxh^KIphiJQg-tHesn%zW0QxdKBSosNisf5sZ|&~GXB_Rchp)Zj(?}wP3H5&V^kr(NAenZoS$F9( z?**M@$pVvS)xtQe&hSl&ePSJF*sHcLG0PLmjA`Ut@gtxK@3VsJI+`Hhtx?K8E?IYn z9aGgGZ}XoR_##aw+j9|UTg5WRp;zY?UQ)o~DOuWx*kS@J%TK5lOn!`#z2)mS916G` z37f>TitFND`ooE>xFItKjZh}$`#qI=n29 zhT4R=v${31z_712v5Oahcoq+!HPU6D=_+Jn5Dh^oC7H!j&z8SVy6ED38x1iC+~DnV zq=}KU15m;~NTeMyo8rqdnL77y@=Y>aA`07LYV`P>M~FgV-ThOsR+@YuD_ z(XC6SOYNsu^n=WN;d)8?Pbt-K?XAzd65}0RfyKa&@)627m&5ejn)vx*vg=)FNOOPhvIzYwZN^#~v_Ug~U>^WkB-qYS>|vqx&3!4F+`_ zx>MwP+ElN|bDc|T%n!Yo)RoiW@&FLkIEsB&C}KknGr13nP@)B-YviQhEZ|AyY!j6q z(%^1$NbN3|^mHx<4{}>F!5{eye5h~_j#6~4Iw(`^;b^MXe3kddLC7)cTvcJ8tW!Qp z%JIof(_8h?p!Z@}M zCeK|&b-cGi16Y#spHSiV87LMLP>IEx$CxxVAEFILp{6a8)S8%s2!(CEgZEhS8Elt^ z1!~22Y#0i_DDVy0SSs|Nt2Y4W1Z^S0C|Aa=mZ(7!C;ej?Pa$jxUjBn4>F()B^uI43 zdy2B7S$@we>U=Dn1c|F5$1k{M8u9wl7GBhaN_3!N7#XEWLzCa^(}`*qNT}fgj&j` z3-7yM73a}6@-ss86P`&)v`t<8QX}h9)qp@g@$SgnMC{XI-%HgODP@z**hy(-r}(^m9e9;&gwUiV%fxUoXjF$zhm*_tXXT_v18RX zWMH8AfK~y|tQ-aQ=&l%m5M#y~V1?c3NGKHQ8|)Be?NM^H+yTCG9L|DY6vOTwmxgSz zz#n564AmWWG2!=3?|sxO3I9y*n|9Th`HR$0rDZ)@giNScU?0AcH#j^$bekxj|NEl6 zwxF5bO@W7E@1lcaLhI+OR<&&{ESFohi^VA zf6pD$o(ROvV|$kZRQDws;DtSp`>&}B<`2iK!PKTk^)q(D+84Hf&OArhQbK|`%Rd4s zt7K6pmn&ge#`Gd_`ISVp-vhj?%*IL$V(sKCe3N*v{sY|T0La2q|5tv3wZs(nbiCgX ztCBo&Vd1{lb{hyC8by3LdjoOQtSFajr)8p@CvnDS)w^oaRVi#P6K~HVVTb1}oQ66- z3b)mdu|lcxb)BetBxc8&8j_#@yrh)+a!G<#y$XQzTyC`!o;0nn-2~#b`VQzqRg55o zp_X>e&wf=i_7F9xI{a-){7bP6X>Ey^&~HW1U>ZZWa+K381M*Y_e}6}n;I(fCU%y5N zwCn2G#y}-f4_pc0{ed!4_9n0)Dw-z>zpai2yXVc5>(T~43JDAZeO{VA>E6O17vt+Z zlSa8if(L~)`98D}Ave%~eVk^L|7&7dTLSf7Zw1RbSbbro?@>khRv{oWdcn&^D8|#G zcx$Or;PZ4C1-Lg31G`u9<4 zYue1w}|T;^9M9nJOxbg+s{$l6NWF?X`-fUTfY_3o)C2}KTs-42HH?{R(C=^oH)~j zJM6tsAMfDnp_cV_qqPG%{NRc?i=gz8i|zp?7iN)@*dZ9rP%jd{FooH$_=OwHrWtyL zfRvX^(02POWdG0shG;?|_jD*qwP?xmpU(Gx;R^DF+fo+azsS%Ny(K?yAWBwT>BphY zQ|5>YFeFL0r}t)IpXkv#(GC5eC-iqu>p5#Nj*|kH5d748;0fVK(7mI8e(EpC*fIyd z&k?(_x@d`pT4FV;wvCuWW=$3^Z6v{i$K`2_8vXi}>ZEg=nq{(bOZI!sg{9~;Gi@@o z0dd5o!pO^R+=yvLxBq1Dij7`!s`At;;dYcQu-K)sex5gT1~7%KYsDgWQI53bMD*tMJ+m zoqC}VY=DPMzVMv{h~-j5a_Sj_W8i2ISN-O$Hi8H$W!3QiqtxF3c9mTqxzr0Jm7(m( z=O_eIA;R-bQIlH+C05phADbuRAi@lt*Wz>!51rLk-_*b_?oGIa3MJ*|m{Z|DrlZn&%?G7R5IMG+V&v`zwk6$mUae^sg+CmR zPCy3;rT!R;fUOx*Zvj#@7q`#VM>k1;6`v3M@I6Vgm#Td>gO^Lsg~bfAPb|9**hkIU zN?~GbeJqrEwO4-GgWEO#Ne$Ro#>vJnclKz>vOE#FmmZ40@WTJb9xJAO3D{BKdv01{ z`195H&~@WAe8^I;s_Z2r=}(edbkz(4Zq~`qsfr&ch6X||VdrLLWlK|7-RKP!=i8f3 z#A+v)K-dFJJ9r?E3aQ$D_dm`i6Uu5{c+86X$g@WLKz-4C5zI=-JB3FkTGuMGXOG-L_`5DWA_5la&#YVuh=fiCvXZ#2xYW*M=ULIzY7=khb^r|3Ef8^Fhr!h1P}NIEEYpIL(95oi4gM z6Xbz;eg?ELzhzJKyAX7u>`H{h6H=>8y4BYjRaL2r0N|y2Zs>x4e-e>{8Muy_otEdrHv4~+ zyr&W56m-|v#<7KeE~lJ&c@Qn?1>Bx^&Ys3${QMdBS>!BE6m^@752Pw( zEeJjDwX&GMinHZSyPxhME&Mc8nm)u%J-*Lu=8iwZ-!QJz6fiU0!|az9zk`~U?&nmn z$*wjYAVb~%M20qWn!gq_#mU=Y>i+L6n(_u~K07{rUKWs0_o=n)i+FVKz=rb98^lK# z8+d2$M(q=Y_*7Zp1;res z{gZgQz7mPyt57BX%KJdLvefWvKu)wW?eQ{)Tg9>+#6IT7)`CqiLIgdGSyOj_ZLz}- zMoixZeh5=tIuufwmozkz&`M-$Xy;@hbv+wCChix29FbWoz~A}b_w6t@q;mjDH0ph6 zRDJ55k`GCX77D69mvq@#ChZyE+H-fy^lGsT4!dtlGCoos*B%|V=hBhye-N#9rbR;H z>ymW?59B|Z+|YG+7gnbWta{dSQ(~_a;nNSD7D>oq0T-x z9X&}ZG08V2kJXtlQIL0HZ+27RcJ>&dZ<(`)?HKQoBuNtBq$Dbz0U)C=0pjC%SM4Uv zY>xkwQ&@grB4-!JQRANWkTX63qja5uBm(cGQcgj}dj~#KuhuL+yPM|P3(<)|zJRjQ zbGP>Vdu4R%VU4k3DCw`Ol?};}>M|;&xx>2gUFtU=Jew>Ab}f(?|0ea@SVoi&`5W-U z{k-datF~v>x3b+huIt=mR6AP(9qYh_n|>ZREZiY>M@~~3cw}CIwGC;Cd4%1O8|4Rc zNFoS*KE*J%B8Kj!@sYzKmN!(N6wB;I>3s8%i>H^^Hc1JTbZef-N1X#!H=GzHN#jT- z;riFA6v21=`rluEl5IVb7{tTfsbs& zdjX<5q#C-yF^7+@cI*S&@9E-7(}8e#=ZT2h{5D4ttPOb^m0|`u4+ z%ns8`hVL4I!!b7?!VxSPtNqzbB%yGB18KJszX5kuagD z*%8Y{D@+t;qobzjBB4p**Jdleg|ZGbF~%qb5_s3J4RLx6aKVQM$9hu-jx}1289c|m znrkS|9px9jho1Yyu(0Gu3=5)af9}XZiiv&&?Fhskwr48EDJBM;byfSda+tzjr^%T* zfnb~MFGH0c;W7&sZD$sLnAilL;q3)dSmPh$(i*kpT(_LZqat5_RDHjg^(ydCC-^** zcfP`5ibNfnGl57wpD{GI;!5ATdir;@hoEGdyY&ek$U&F|fJU+PT|opfslKUlvQcBs z)85h)AD*jG7rk7M=1k5cF!^rd^g+0(9eC@k-rVl3VEX{tH}F`QxJceR*PJQ8UmbTT zsqrvPJ}s5K)FZ!v4|mLm)xR@cQBU`Q5I)hm(&foa-^=o21J9ZMl)s)w;4t8TN_#-R^EES) z7*`XUPF8W=(-se6nP>Co-{WQgaIU|?t!c{zvG*7xL1=vlyaM6n9jP}>Dzy6jUH_PF zf*!o~-9v2i?c#V6hskr#Y9@oPSO|uo@(#*Xw*R?8g17x^LnhK)x8RQ5?Pz8QxthPZ z%Ldtv7N%N~S7Oh}S>FD-XY|td=1N7(9K<0Jc?R<`PMc*!b;k(p4QD1F7*`HS5yu)9 z>D{y5iFj%n{es-ZQ)0=QhI`3ysPoi_1nlgEc;20P{&oT8UIyM}ETqD0yo;0abPY=H zn4ei^e+C&P6)huk;i6TXzEcfLr@@X};~_L!>@ZW+=lxK+VUlT0jfxg*4*(I^?O)cG z*GqalayxWsCeocf{NGc^#(h_0n-~U!imkF6fQdQ%3}9R|#%K|bA!}yf3Qy)$JCqGx z*Jmp^7Qe8)&A7C7T-;h))t!?J%D|f1B*)Te_K>; z0yl;td4U#U1YdJmEvTz2V^1bl%4=M_^fjol@vQm%5ZAND2HTp`MUD>t;8dL4yUL>U z{$+uglD9&qAH?g(WU%szZ!&Tzf_jjc4^U%T630}@9|5?D#k_dzokdj~cM07dbNLbk zV;!{Iad8weH#K#7ProhO&Sc`A;735%nC;zQr5;&TVZxAx|3I#O09I>oUJ+zCEuvv& z5=(tnIn>)&1%^b3&~Cdaow|ew)5r#iS$xJNvGn6aJ(x;oG#18>>`FIS(gg4~z+HL` z4|^*Lk)q0)1Ok+1?1EW&3AU5P8|-gaC2EDv*B&+Dnfs|x8b%Diawzl0{a-c1Q*^zm zjP@5+QmLD`4+U|ck}F_=8Mua}M)%RpV#vR$cz}fTdeP<-dxOQ6vn%0Iwv4ZB|fVQ2xRzx@72ekNL18={|9ICD{j*Orp z?YH*jefa1cxZP59l8&fBTr+1Rvb^q$!%99$0oQfRP&?Q=;<>w-Q$QuUfdT^@(Zn`w z$Aoqfanr(*V&8;j`gZ4ANhNt|@)Wsx^@&!-azKSsqhixFv*}mp2y_>IP4^&C#0I;k zbe$ekn@sTpQ7u_vG&oK9*TSgq&*FU^N1?_PC(n;;@H=S%)=LLSi6I=>6(SgC@8cp-;wF`(Jxn`sEZlJ%|r|^sv#2OUwFw zK&RelldAIE=33KI2Q9Y-W7)O;UQ56f#-+*J_YE`#xcPWpVwE*5$ii+`u(|(L#KuBGF;r?fo zgvM`0qs#@B)r%pHZzW^|q*gdY(%36l3Edo5lR5Ox$(5<{sFi+_5GPD|qCZglUNBr^ zrulyWWac3VB2R(OTSsfvvx37r@oGt*L||0IjKV4Xni`Sl)%fml-ygrTDMuxv9orXweus?rDI$oaoFIx71n*Cq;fxgkyq+))Mr*q+9>ikm$@12L8 zSEiybM0~#}ajQl%Gw34SZxmP}_^7D*#EB7G$(g7inHTtqblTR$@|tPVw2zSaL+66_ zrv}@SsW>gsE~*?3hUp&8!;I^IH}NR4X>3Df0Y>5YRd?h6zrWqgZd3?YDOK!(MJcOz zsM4FOH^A@Q(*}Mn^F2+JgGqRNOXk9j2B(RO-tFHb^kXK@XvzNQ6nJRo-gTq#5>>`} zyV^1$ze>^DXlc@61eb8OtuL2856+=abzs=A0FQ>rbD>mumiYi=0I> zpsr9b`Q9H$SIPL0u(0Bb;a)PbS5cc*N!D$>4C&q%HtDO(C?(NYUep6obAx^bc9te4 zdvV%jA;3>OtbauL^^y1HrQzRv;vQn=g765~TZkV%8sq4~PI0dvig_WH_z9sL-Qgnn z^V^3M9%2An7b9mnNdJ5e>K#QU;bj{`8pR<~A@lW*!msH>Xzl&RUAnN)U!_3#l!xc8 zuoB?F6fbwOEIxW5^Am47`G`^@eCUK}BP#9!=ti|{aR8#!o~Z8LG_?bAMAyiLVcz2w(-|*=K4Y#6#R<;9 z4pVZ>)s1pxg7W027&(L3*KG#(cyckd-M?O+9ysSi_r&#ZstB~3cAKu-yKZ8;7K!D4 ze70+x@mH1-2cc1*cZtyz!Pcco-6dqAyQ(zE@CbQjBc)m~7y>%_`Dlt$y_U~v5I=f~ z6jTjJNWbXy*l=aZhkf!l`f~FMj>HGClN$V*7ENQ&Qja@%qiyx$&A(QkbZ}Loz?!?6 z`z)|^h(q(q>!b5x0zC!tGY{5Ux8}_UHYIoaOG7IZKX^Yl{GPn^${w`naEW*NaJV8c zz*wyxa6d&YQ~Jje%#8;~Hy7Q~AZHynO)MB^x5rr%8DBH(_iBmJ`R$o{I}5jDlyrkf z&)J$;^V2K9`-zyYzs%1n4%FQqO)2e9m`6h+<|F33>-VtCQ%{!e)y2Gv_ME`0zvg|A z$09x+JXG~=KkN>^sKR34A}aTOnp;|n7LdiblS`{h&vSLbLp;Kg%;Z5kn3e>M4e(9~853!#5F9*jnFyL zfpvS}*ykpiF7+%uEYa-=&!U$3T;M6 z2@E~e-zjrXrEpb6_Y&i1jY46|!*8!$7++JLoSEwnkN|iL2cldeo*=f{?=0U!X_K!~ zS;fSil)OS8hAn0ot})BKtuh`Bo@R8OYebG)?8?uB0!FA(@s98d{ZivmKlhDyp#D|c ze~^=YxkO=aLV6cV>@3gpPOPyo%^^0UUPwEX)IpOATL-T4G=05!ffOUNt_#{4CG#eZ z+lMja+@dA8>Rvd{#i=EhdqohCAVke`l#IGj9F$~V!~ur+{TF-hbIyP7^X7T+oc%m6&I|Md`eDslbI)4WeSNPmwAbYntf8>P zQ`{OA37`aOsLz2`C}36y3|CV7{sKXj%nXq#22%iTBHnK<2=D*mPFUA`WsS4#Xx#P}Io@CwC9 zc<~mXQ$06ngD&5^;{)R(T?1v6&bKy$Zkj<Ctc>GumA%k3^@ z3Nm-pWq(m_+~a-q$%B{xFR^RiEKM8`vBf@v^238Gd3#jrfExI|CrahLEk$sm%Nq!4+&z4!$;daCt~Zh1uW(G)Y7S9$i#sZk+3=J_;eI&sEyHbH#rV@0dt>u?}yiKC|^ADR=`6;;ZXy5bVLKGK3gCMM+m@kUi+-o zJy<&?Frcxp0I|l9L+i4M)y+cNaci5yHWs^^pSpK%s4hFr@pf)eAm`pl(fto=(*AF~ z8#v`XG7TE>rGEq0K1HAb1?@v4(eE*?@zy$m+X>+(AeAI``;PJN~E$=SR--di)DD~U#@3U~c>rWNq zm7AvvY6QP-~z16+jI^e1qr0}cTwylJxg5Ac&>h}2KGRO zSY-5#%03G@!s#RwH`=3EK9S<5(C4S$H4n0bRhftpqrT=}vj{uvNVo@9_@f`mXpDaK za%HS*k^8Qjn4&K74Wss$*v)&B*)=Q0Usf3bHAk2y(i3zf23o{~3_>20Iyzq(#;$H= zh0{}fv7+VQA`S(=`DA@foB#oCQ@{jp6R+n6%yhuu2gFwKqn`L!ufEB=+@K2*%sWwR zBO)*4;AYxoGS7$)g3!oOz`UC-Ovt(}R~V+>t>1qjun|woh+!G?LRl!ga*T;Zu@fQ; zeG{eH&W~)fQC5~I?*aAT;XYQpCRb$XZ;qcge87oZ&#rZC;kS1?c|}P9jl|W;9>O|M z0(}_k16&wnWhnO&gHy z6Bl57Bc!8tWB3R0))&W(1WCG4Jgh;3W6VByAXE*)^8q`ubdx59p6-zLEfz5o{pXMi z)?oQq&(&&cU?0cue)j2`SM`l=SZ(dj28S-T?oebt^f__>e!bX#U7o+&b4cQOGHK-` z{=4@Du9sFy_{hgKa%HvSeG{!taO4UJf;-~$eFj#yL?%N=Dh2OXyWGC+Vrk+R%EI4b z;BEonWHc}EhCD}D{)maaEr0*ZA|!;5Jb+vgrgWRY;RztS65*%SpKCA@nmXx$@p)4b zAZECF(hs~ee`X-AQ&F-fn%Uv$=l;1r~;=%S1B zkj~wgXvS96(5W75)qC%;A6O1%+oZL7t3gsH|?xcmlz1HY2QG*V=e~ zrRbS-u8MpQ(v0<+mP6I82Q)*C6h0-8hzB~4rEGPJXW|OR#CstKTkFy}dYm<8(|@Mnsvq|BVMrj&K5a;=_to7WbR|W_}-Knv~aSh{1wAzXuzvxq0K| z8&m{rFu7^0fhh+F#gf{;x)nu;7<~Q=!gGJbLgIS!dS4wt2S~$9)hFa}e;mq&GYJ?q zNrCRI>A6xUBeJjnsx^69SXr0;@<@AZ7&x_*i{opPG$V#NtH%VtFk$_5=uZ@GYkJUbScwf^!};^_X@3J4$!y9 zN%>^)OF93`=Y*2%4>clj-t$jHq~P?9gk zBbGN1M-xcL$CGnFd0YxSQ}pXm(ulZZgk+a`iJ@U-{pYs2nfxEPzt*6Gz9I>VV=g{j zgW#_of5?tpnIhqFdWrOU8qeN5R>mSJ5|LL{1Z&%2O$4mJMlzy5o{|NuH9th(|FU^( zqzLEk89pq3qWN*JJ^}w;H=XhjmW3W`V<&ckJgB0-HTtqNb_@BSEMnqYS^be`%o>3{ z_x>XmE`Ta1Tn7f+0vzaDqrX6il0QW8kqVS%LBkvF`MBeGAxAyWgktm0YX1-Wb5+4+pjL#=(z zI{Ve=O!7I0-(Qi6CXfN)KxCV;Mk%+kD2mVbSxigL+O<(dV_tRf*|KSP?dyO!uM#+m z+9?2ez68{Loil&|oNp59icslJgtFBkOwgGN6|p5LVT=Up*u!kgpO=JqC61t(D#KmU z^eAAGldHhGsM%3<8YKCIXTF$#k=Or~bDfjbk(VGDG~N&te|I&Ufvcf%dlLd==;jo$~)A)eYT)u;-{RLewwqW_{$iZBD&bUz7Go+{ z^xV%CuH;95qqV3-+mrP%^Q}l>a)F%AyT=lcWT&&NCua?K|bi$ERlrY@otEP>rkfDDjoSR_uA zRrSkwDP?)9chYH8G*D9%`RaGp2KWkeC;Wei4z$BM2da~3<=o2;{?Pv1@H72sZK*>f z+Yl@@$4y9s&tyw~;5%!J;tv4a_S+j;C>qY)QQA@^d=}ak>P3YT{BA$Q1r&J{LWw z5C4jOmIfGk{?X$IdFGge1TZycDbPrW-vUfG1x%dW%xF8dQoIMyU9<4XTG@U(F8#r&o@F%k})rdJeYbEz*z))hH<`13Y75hwQ7;Lr;a%-7cAwek<2 zCu!ePsi}>lTzijQM1RNOL%R^Zn<^pvP*=Y~6O{ZERNdX&e^}u6S#KG$!HE)Z1|{yJ zD$txe%jhsG9t5;r_V)R^S>38!0cnnvr`6f+H%02#Gu9yne^po zeq_6BL|)`*vCF#2v!t@^PivRFig=Bzb6PjNiejrwV%$*=nAzfX)Ly?zWVj-2`FzRv zMOn)Y?*r(!R@5p(D~-)&xt3NF(jy-kwbB2?Ui|i;x5E1I6P~-5VW*q=B_Adh?fTS*f6TMq7V>!9E4f%3E)0sS!S1>~-7 zO(W7Z-#d6?@}-l&?M83KNfsYjI=2)yIjOI{KEM&bf;U8>p7`b3tq@nk+qNbM!hZ$R zG!c9Yf52c~aUz=4x-5&zM;OwxBC|H8(fU{I3??NmyTjtw3>N{vE+2q5_7XWf zRgc~Xc2d&HJ{mfH1a_TJ0=g*8;uU1PnOlvDOQ9l@9{q7@Bx5g(#tBHE0ohPo7QRQ!yJ?4A6{y5*H97JES)|#NA;)U7a6$o#~1Q~0~!rp z=L~$Z??Yc#;%-obG(q&2G&z7X9AFYzlk7D)V%4kqT|h&HGa44JFQT`V_-IOrEA;*5lEm|fYuLyh{4&WSk8+4h` zQp48o=R=M0J63P{)O<>8vE^pqfs-TE7#~QJLebJiS`JIk5o?23WXYSXH(^ovZ0!D5 zx5NR4R9h>cGnOey%K>zy{}Oq;W)!P@5`cVVC@}X$v|&LI8&l1#_tbSug6_9mrq;{Z zw$inZJ}{9Z8g~}B82Hh!s*WaKJ9t@D-6X!gZCu6cN?eEvz&&hzBs{FvQ6Xnvg`W50NRV)g| z_B@&|?p?g>tW2Im1I4gcNJm7nDxxsBE{j~<*0qqCp26tG4Z0jb^I1d)3#g~9WDORM zp}M;6!A2X;O?1@6W{j-jD)7^l@3_iG_fWjz9&vRR)b%skF~x%j{mv%23UKu9tzd^+ z)hfo#I%#3Y^~UpT?kAg;!f!SO6F0`fuNxwFYcf0SF&3_WNv3Oq45;6d%$r$+B%TC7 z3}`ro0PG-Ny0;pn=Ouz33!oa&JBMUYmHBFCY&~_uE2alH|Mw&}#wv}+aHEtl>;rR3 z$mxu)Gr(m=)CW+a1Q*Kl^HN_v5f0v1Ui6Ob#I}G-V&*ZeebC<(#%7`j<~thu^&McP zc4|2K51G=x@bsyYL?ke=pvLjW?SWwE1{Pcg+Ql`2))q+@8Bfndm<27DDwxON9YbG~ZTeARSPFjDub zv_qxV+fDac^-ECq1FAh5In0&9HP{?f~n(qMUEam1@*((HYng1EgCfU9%pRODW9%LSz5goX_g6SiJ zfR7HmuxFXCPh!@KA=sgdB&AA04H`S(Yku6lTQ$`A2b|?`5#N6!`}r5;^BD$cZplxEbWt-84e!m8|D-(aB^$AVhb>!>| zuBrySA1k4{(X!;8>js{jNqEl_ul|^+_>W(D38Bc5+HV@tA?!cd)7&Vwn}X%|K7dmI zrgbt|N2|ej4=?T2U{^UNFHN65C9qfLHl(Re`qjg4z zN4v_}Ie?HK;eJIdcFtbMmaW2z#AIJE6`}n)60D=KnfK&EufkuPld8O!ZLjq8qwTxK z$(u`+XwFRYq4s*A;sr{ifBl4y#&r&FDhkyPn*xVdsynTb5qG&$^~f38RI4#X=_Y=B zhR=G2FJFYoXa@3@ncU%vHX}g#6Ga%SF;(r4mvUewbcPPHo+lcQ^^ZmC1rU{sZ3=!B z%=d-<+4fXrJ>-6Sz_B1vr;usvtAJ4j=IgLR;qsjr_WV;CTO(oLVKCjxo`k=uu>5El_ z`c_FV9=}1Udy-J&?=7pWz)j^OCDoaXU!l4$lGKiHfQ^OO;us^6c{}Wt(v+ddNFHeO zo&MqRBWC5VH?QY4ij4Y-yfqz-Bwebe z5K#gMj24`yib=h25c5y}{KeRM1ep@nMK-~|!;gb1E&dO`Oi{^yY$W!uek&At0|}%u zX}7*=cll}^ujCYgI}x)wx(yJ`kCTA>P5wiYssArXz3JHjnkryO2kvzn2AKLUJ^(3@ z8vhK`OUoVF-|D-=CDDNM>|?+Y2km(U!#_iLkbmqQo;tVf zcaBw{8%Mc%<*pzpdwTWtcq@lIK1>w9{8KNu_wAUm=3AWFW>fN7RzFZ~j+O{ls)P%~ zhYP}TZQ?eDa-D^W9#+-g0e9)4E&X8hM5P8^Ka9#}LJj;yX7KBD><=qrp|KaIfO68v z=M8dky*}yzr$M@HLP-(@G>u*%gp>Ft3iRhR5>lIA_)*R(AbZ~1O+)KQV_QRZ#RqEy z?4t{%Pn^SOGC?d ziFforq$xwFQOTbV=1f=UHW7>d3P}LSX1#`V=Qss$5?!A6n)(+m8=a>PPFvar|B1P8 zqV?eIA!SR^8CFjxPfsw9D3=XY_;9cjL^{8@dH)d?j9F%*ieR#f^u@lkG(t--#+IxC)Oy`1WoA*EKojI(_o*l4d~0*f4MkJTQQMnqs75!6B|RG&jI| zg0sd6!%Z=CRH}2B1++i@f%fPRLjMt>ps?8PTIJ($tE2}L#8G&G+HU6&kO^7z+1$jN z75-ta-&8Qz(Tv6cZrsNy%C%I27|egIdESMW4@7{!!2XGNXwYboC$XO==fwK5?sdV( zlH_qpnh2JG$v{~ruS-~U&CJR1#TenRuPt>bM`J4L@g&BoZR(=Ws5Gis-v_R~>58GnUQy*NUQq7q>_wbltR$M3sG9$p zP8gfq2m6D58@l(hfdoPOy4iqBqH14@YJ~hq-~KyqelzCdDy;UY{YT(8zzV;UI%5QR zt#uHsn5esZt+!G7MjEsx4O>vjRvoDECHWm{sZG`%v;11LzjrV7-gfloatniG*8`Vq zVmR+MHAvS)@0#SHdh4{{Fp;CGsn4^Rgp&L?JbxU+GH?XBhWnZpMVBLWvax!>k_wY+ z@f%NJAwtkrl6M~n3M=*>XCZH#7PNK#xFA22q~|Vo_Lwu7Gvnt?$T>@4^3cJDxkeNS z8TsA&k79eY(s=H;nMUH%dZA-so`fnros#TbN84>Wjt)a4>7!R`fZmg=+|THSWMPS` zFy>t*f!>0CZJcxzdEAft{5j2(Sb{1}J=5BFivzp;Ha#I#N^84tlUx9ig+w7XL6`EkU@J{L;}_8YsF_YLofkGjUqg%|1|xawmT z*Z5|7Cssv{5x*SP!ADUt7PeSm@-fiEeq1o`uZ3ATV?JoN@;l(T)?b+tL z%tCfp*62A96B&*zdZXjEplxg&ub0sb;6l+0bu~#tn;GQ=L96~l)O;?#7{ll_UHHx; zf(h_K%pB2uaCNfoa>ebar)qWfrV=)+GFRFaDn&mk2T#cILB8AO$n(B?{zO2$`UZ&_ zSJLbdmdI0)(UC41m6^}Pmp`5LKl@KfG|*;Yp|oi7D93nIIUi8oW7FA?c++`AqsCo{ zJ&u=@wXiwWB@Mo$`5TB$_v&W`>mp?XHX|<1CiN4-r8%uu%OJ$_9xvz9pDJ2&- z1E+m<{iOR+GWLefJ6oWi1_|F*_ zXoYdB1!}<5;WApZv2+6kPsnSWcyNA!BT$L`s}B=I8yI~(VZnU+BntgU=zf_n0b5zv z$4!b%E2;vs+m*k;WQ2^tjebRs(wNYOo8&SQsCw>n`U|@VJ zp5jy;6w6G_^!Y$H%RSAT()cVU`#SdM6hRdik!sx;s-{kN0W zrbMz0+<$7|d-sm&yRj6K%r&~PRGrWp9-dVR8RE@4|A4tk7S766vtPqKisgbB(b}Rz zpT~w6H5BILTnay|UC4$PWs|^=j`7z?9cSf0dW_><76@4|*56J(1yw8<;P;#>8sYc^ z9sJQpuQl;0=Fg$o7a!QT)_nScuUXIvUEBe^MMB;g4xqU#cje$S|2ye1j#oG>S)V$0 znY6HK)Z^~Nv&bpR`rdW#72Lsr2kR^F_#^_hP8Ym(V5DI99R??hQ;?c$C0ZM+J2ZTJ zC~PuPkN7o6JDk$w{H@X)M5y&-kY>;0eD$~(dlD)$X&v=|MXHWVM5LGvBes+Cau+jD zZVBoS53~Sug5i&`!a+8TunQgDG zcjD_q(^ogAPckm^X$SKaZM1}%yrzFrZ1XnejI0Va!h0)-v~U(L>@g!ST2|teB;UW6 zI7EGiSa}E&e6U%B>@bZ2u#tuf8xM|K#dCV(o&)LYQyY59c&tD|Ovp-;R_rRxvH!jO z>$ge!)eia}LYjLphqnc%7Xifj*6;Tw(@vX6yU}fvz>cpo?h?NUqxZsz(6T-5j9wDfFqymXQ(iIzMwu` zJY37pn*SqqvNeS5!ub>2XAL;yKBN}>k#$CZ%Q^Ie#^*R)=i0?qWmOmdYce*so}w*3 zB|j_Q!hKWrDOQG*(EQu4p_x)AYp-8XN;#F`<6)!UbO^D>?xb);(r>xZ=qjsIjbkj$ z9o!F#Wr;RV;^Qc9b@@YYi0$pOA`hzSn$ ze+zQ?*I<|bH@?T)Hc@v1s>p-`+94TAI*S1VBW8b3SO5K_7kq{>-Ep{}bqkugE-iiZ z$53~R!fGUyR%xhnNCyYxZ#bbZ?k91oGF`zhPRH5M+~CfXIGC@>x|wsWEAuxeJzptO z14wI82U2e6YXG`>-#Fi^1M@=t)^P(CMyuK2qg0WLGkt=w$mBZ69L=)yp#Q@+LeMSk ze}l#zbwf~7fW+3_1m9o-)7rc2yPr}2B)ahE`DgH)O*YYdr*3pW_93R!;D6#Cis{2` zQtQMc4Dy;sNaTjMC*)ZNpSQ*6=5$=|~nUU4?y!3HkZ@_KQGa)}~pwDZX_gtG#tf zq8fht%a{g0i=eO(sBk(hropH4S)*t_Rni^j7mM%v#Nad6BpJ5=8PAN|{dB9A{RNMV z+{PX;a( ziXAJlf9ks|ZBXQI@i$J~ZYX5R6gCY3LPd-xQ68lSGUA0{fvu_OULL$9CVu|>i{!6$@Rj%J@{ zI%SF0^MtZTmkOUSe$VWNiy1AZHMUNQ9Y(G)=B3U`zUds`jLGDk%_#V_pD1SNkx`fT zcJT{qq!soxkU_1tmyH=F`zm=c{Xum=sdG*?S*kdAVPwEh#vV=m+6gO>I@y%k(-{8j zqm;tD`2H#;T0{wfX<{Hr!%=bdq1+5~eE1ZkeRS zo?>m$h0US}%MY+*RF6MirCh!e@k=|J=-WA#l{^_;x)_!21Nph%i5hjqeUb0W@Bewx zOzL#Z>KiFRTtBTdfHj9%K1&v#oqpKa&fZWLxzqSnvoTI;(`4kx${d7M%n7U;!;ahB zi`OvQPf&@;`;@99oas-`!JKES&2pKA$)+9ffKA-^4QCwh@E027b`wbd`%TSt^$SDW zDPA~2w44#E99RHl+^y4(P@VWumc+&j+;}La8Ls&*9VY9D;zP~ur4#Xe!MXf$>8n$f zygSI&ghO>RLR5%#(*vYo*xZ)eo-_V{f3y+tOr6H(^c*`moKc`d*;OO5g5?xpOZ(No z6v7!byf7O-iD28}Fk*3jlp~jh1v9^VcMw0 zx7JsqteKLJ!Te8Vfe0+y!br*JUo zeBaLNDsu2g%3ET@Tu_Kv=DYm6W02x=VC(J&XMk)Y}KaM8#4Dauo)hx2Z==X91MKqk} z2oSRizGELv9|TX*bAKsYd<$$3q*%XU9Fc&aYXNp`9FVi;?;qYEvrrE0F+lKb=|eD* z$HCD5bF<;1=4_H-0ip6i$toMj#^vyr7i_c|pOajXSrJwbu_AhXjHX#_g%44Vl??MM zO$V|2yb#>}O|M7)8#hq?Byq;%S#y|);1hsYfQp$2jphb!W3aci$diD|I51Hil>j>doA#Xi*L;PeX}n!*JL+h z3&sX0a`=d3f;#phcd~VfZhc*EOd(#C9=0zac>8KB*$;^jf{Mck5!d7&rNINJVe^HF#;zIK;XNRjsZ5VkB}2q>jB{m z;3%i}L#L$G~I-BDN9Wzo_F4;|xlz|Oj*HO)boZJZ>puZDkMsa|+6^L=hp&FNUsv&3lu_C<2qfT+ih-H~0X_uyQTl;Oc>~XQv2HQyM2Pz!ApY){96nqs&rC&VWn51l@G zIvyqEGzH1+rX#s%GP;ZSM_fSkoO2UdwFM~w&hV^t>C7KH!ZrobzTYP(wp7g|rDqD&WJD{cg=pxjIop%7%_#Z~4dL0dWC&g3P` z2QAA<<8>1Sbi2a2YV3IqqeqWRiiS8ZF*}~4!HVLrK$N7j2UU+N=@+LvIP{wCX?8H# zWBh&|%w7fhar1QP>7dmmz0lymHzJxINL-20i`4})c9 z(s{7%lM(MpoDYb%M;0iFy`^Us;Ly{NA%CUg7^)nahpBig36xV_$>M?jaslw>D^x8nWO;cqRdFO&}j|-CNmN}mdfOU8u z9yvWk2i2SXSr6!jO+2E8Q~`-Dxbp$4z$cUNpT6l(0%@cBCjqR-XajWB(4u(z%2Ud7 ztn8hzaoYc+(HY;mz62sV|CLpRiQi_Ubn}qz>tSuU`7ZYkb*j@xXO~q)RL>qf57b&_ ztkyD@{f3Egb@=~8!i(cuN~B&-;^F8liVsdls%5s8VdLEz4Xm=mw=OE7I1&2fp~TdzUzoX;0|%TVbTrvwmvM=3Xgm+qMi_e~s1dany7QJrDe_E;@{w8Ma~zsR-!RWFX&_#od=FM>XsG_A&hxTP z(#npzURCOv0d&;raihYO744xetA%er=VCZIyWv}NGsokZY*F^aK*i$oAEO|9`yw0h*D9g<4crkz9#+9%lOB4 zhp)L|cx^m{khny!CYD=FZN;fQMn#P5*HRsLW&xI;dH4N#Ny^%qc_VcW53fjr%w^SL zr*eT~n$YrAp|Gt8pc;WU{qK2SSF$f}%ie|MxQKNScV4m$hE{O%v6=P-2V-1fqs8~L zFxthkA{(w#Gko%*i53k)yK8lvV>btNNL`*-$7`z;NCOpkRn1cmcdMoaw(DDhM%;RM z0etu?>YDrVepR!A;2aD+_V8C|e1h$Qr%%{Z^QPaO+`#nxTeD^ce=KMHy?73$8Zllq zg}jZzy1Iti$ElS{gj7jY+1gq#8VSG~S$d$KxPR?2bF?0#Hz7AlZk?j7Hqb$1StIun zJ57OpW-W+*Cw0{N>5HTh?LZt5w87`*wS$>`+L1q9&5gqO_EiH(%RBE;T%aT3VPMuh ztv*I(gRuf4qMza$=a>q}W$V@2V4tzUd9YT`n&W4#`g8+94)21*HrP3j2Cle<{@TF z4<%w70w&F`2f=Cfl<%T_nw;=+v~qj zq9m?qdEE8NHwkDMOncX1U!Sz07vFg;%4VsUSlw&eF+AHAz5cLY7D5e$AV_OmbD8B^ zAyi}T&JM5o_v@B?de8l7`+Dd*8Zlv}ZfNUNde;zSC zeNu5Ede&MtveP9+9^TACemv%%Af2z5UlX_8+VLiyQL3(N0c1DK8fk%u8tVlU|6Zlg z%KgfbQ`**7UWLQtBq%`@ms{aG*R5F=quZtTG8n%LxBU~6EZ0FFQnTCZiWA!#hL$wa zE>ineX3d=0DCfEo$h2CQ3QKP2zV??tF+=gqK?x8AtG!^tIvDNBzE0=$6A@#h-t~}5 zP(`<|MVd4q^hELp6JVRiWzS84@!tDA+q^VKY`GXL4RIY+T>hv^dZYUkyXf>s<>vS} zuIp_9({H$7Xcp`*kd>Jdlmu}EAxBYrsJ`Ex%3N75=W2LNh^wpj!`EW2h-vrGEaMlf zzQamZ_N!K?KFl3Ek!!S=b3D!1Cvy8~OE|!)j#Xuo`2g>cWYij5CHOZ4Fz*h#)1(Ws zMFg5;%urhcTRiJa`n8LSq}Hj+#LAXwG@*CuqGm(t&Q_iX{)WzoE_dv&KzSMiG&ROL zU~`@Ywljp6U?e5r&@yexgWA?kD-ay8$YwoDF&t|DDgN2~$@ACbPF5)F!MrVNVz}#A zmo>R62et=p`)7DXWO2ih(}qE6_tsB$?*^B`R4+CB)TK16=5?msaR$&Bpf62NMt?LR zyDKz4a-2Ux3%^!}i#Rhf(@zZoWeE~&VLgItfmnZm;DGlp<1N7tG0Z|9tAUj+tufxcb40}rV6f~^1gwfkcqSA@d{OmU{_2Kj#`e4<++ z6^aSBUyNTL++668I6RG?fAeE!U~M!mEkO-y@cdaR``Raa0zE(^MU8Rze&~CgUAlCl zsRW&kMS_y7m^KdCk;y-vhmh_jNK6O01=4=4t4uuERGoj{-kC6=bVsBHbD%m@d5>Te z4;pt$vu;+ekmcnE|2}hcoSz{eu3Et@TmfzD)hTvAPJNN}0dx-Vt@38*&}cFgK>!?P zgb3(25`e6=f7vv6?<0s?%D(u-M%tv5d#q9VB=-lDT*Q0LdULJ$+G;~EvbgJp9VtU! zCaB|R^t&;GL4<*4@tE!nbNFaRKr0VsI7+NH>w?413^46)I38r-IoA7&-6c$N6&sm6 zrr=JLB5C+W?#rUMd!@hQ;U5hptZte@Q+k`uUauJWem=%U=IfL-St={;cTr=^1z6b! zT#y31PzMp3#?8($^L~`~r0QacOc)+q?3xENV&Y=lP3%S};4li%s~Bd|Rj}I?Msh_q z=N6dcvd5W(}ajeM*QAN%0vN0K!g8%nD%8FBx>gGY zRa?8e#>5}agvUzeMj#D1p-T(4A5SeCQjU%@_wRqv=VOGVR0sai%5I+i_RS;KPizo7Sel>N`^w4JR`;AhqyHKoE1h4RF3~II2 z6j#O6dv~43(x*T*MnIkfAMj_|sInoRM^N{P9rIlUru<(it0eMgLjD_$=B5=tKK0XBOKux5fJ1Z`M{)u+rL=U%v%JCa*!=jGuL^NrfI) z2aBHm^fxi;5$Hv9IWrE6#ZXY-zW)3Tgy*IKG@w5taDwvXD!MYNi0NtP0wER_bO6s2 zDDF7uFAueZ5Syvj*G~4`9`D&hyl*&OnUZm2%md*Lrm&>HKu*K5$wf%ZKZ6Mi_?#bq zHf)osvbi4yYRbog1j3JPKRBDyqUq}I*$kvxG+M6vreK?AS(&}&z*Dl>>tXK@;CB*y zc*9C z{qYa=+V8g^xI^FWOxGSi*30zueg9CrwajgmXZurfg34o6jvR=L5O1FWc_-k=4P;3g zqU%;YkXaL^?y%27-)R8+7^0#;X~zJMFlG-q%~e^j{R@;i8L+DS{d*wS&GWxN=L=dl zdlQ$yr(?G^>(4|{Q8t196a;mdjJ_80D+stc=Dxjf;N`(Is|D^?^1x=OJyv|nN4Rai z>cIUuFcCg!$Hnx2h*SrCbD&^RsOx`iXuN9D=V;|D{&S?fUdrO}$?iDMR{Hhor8!bG zU>!&kL8{B?V;MQHvB>gC+)Usy8mbJq5r95WYDsnCP2Oq0pmPZK%a~~>$7cBx#fe%Y zwT-GprljSXcRs>Nk933@SwFxXv_i6KC2}{-nCHC%UQZkyPyRN3BAzRuJEM;3iX3E9 z-E8OU?yxr^Y5~Wru=%$5u&Ec{^QND}e9D6h5E$5gilvb1rOuZ+Q!qiaxZRxI?4+8Lb@GraFKn2ymXbYHzIp_dP1bg& zD!H=V&^qQ0`0l-`KPeVKwtsG9KS%b)rftdED>ka>N)z)0j~Nl{`i(SXO@AP|gV}p< zkD)%&_RV?sq@s8tD`p6%l%+RE4m19qd*ZoDf4R*0a{#J0o*w5n_|(aM{oE7d9HsU; zai9o*>J3NXg{Hl`PiQkmh)L6IFUaR|o_n+y_#!3ucbxo)j7GGvI{(GXL^D zVyWc|-GhE3`%cba7a3c6dB=|1EVRTjN^PzVm|;$?)iwt&9=oc&?agJiQ6{F_Vrt%G zxfLE8FqND-6@$?p-usYJTR-<=S*`tnKCwveowbe3MG^MyHuB=89)YeY7HAFYmKQ!= zfI@?nQ}64J72^-Uvq=PeFH2|HXPr4FP!MymixD;Ak{> z-zJG(-|@j{06RYG1#1XUOG$0-6}wq(t9x%ULqT5pIDON3kjviE{LAm|WFYdC8pF!) zw#4K9j9^^KdYjlUpYTCRrI>tK+65P~%265X-!P!;6fN=-!G7~<@;CE+BF15Zz8~_% z_a)=mR9VmHDCHIL>ZOs%`cuwgeww>lGXnW_ZT0RcecAwU`? zrYJUd8Q>IHWzVE}W$_4W0U-M#7P~+6fp5?FMElaGS?i_3muspDq7`$Ix)fD;@8 zltxz>r}YgpmTRrYM;?{2`bIZhTa?O@p*UQQ1PdNWpu1FRL zz}4O?{IMdf!T~EUN0-@i6^fC!E_5_w9AWf$?T+WmpMn*!DFDnmaT|Sc)1nn_n9(=W zn)t-a-#Td7g;P5!KW*BY0?7dSGgi|bu$>gJ%3j&d>YWboFAsI+cI$mVmszf`#)Jy? zhQ3H1w+YiE#nmJOdYi}43i}P|9o=M<^Y*FL%gaNUpTcOCj_aRV1ud;n1^om;&jK7y zsXWKUERe%@UW!Vj`{rAQv@U^{1N!N&qFoDAow(~F!-lx;3$>_8<3p#1QiXXIZnv#`%mW4`dP}zTF&eN>gJzK{I5T?V=x)lbr#} zA_|u4Pl!bLb9l1|Sd_K*xg^!q^|GF59AI#fndAup6MPk^A=KgFZ&*`@wT8sGCQ`NB z&rzUb0cPD4K4Ma!`h}b?-YO(y$$c_VK3frgpE%&pV8zRghUlqIQ>%eUILQR z3;sQ`Pgy8kU;YjT%7;O&$YZhG%VM(m)aiyn?xmN~hc&EO#;&Jk1ZPs>7c(m%CBSZp z(Mahw%XKGzsNH)09AB>ASrojypg2}2&x09{5)X(B*u%&L7MRYR0p()1hwNTq9F!>E zJ52UX-HoNhp4#Y~iuGHTO}B4A3bwf81>>b-Wy_QTA@bd-c0|DUHqlne10d4fMZ77#UFXG@S}jd*XWg3{T&P% z&dkFWY>%UM{il*#Izdtg?)_xlYK(WR+{lqjNiJuWqSNT1_+N?(;H~%rPk5n{bv~?F#J$Q2I$r!HQ-G< zl^HLeO@)>t789M>!j>P`%UI_UUknZa6gr@aaESE~HN@?Rr6{jeK zz`yD9Z$;rWd6z7B@Z*dv4Vszu3@7TUhF2buig?+U{I-{bu6TKnHM@c-X6kP~)7P?$=Wub0a2{9?xa5!DN& zS5qGN9@r^4qy^l<)s0=)+BkPIo4ZU_LY|VjhAKn(X0^uF)9vp?L)>%wkM7x(q^Deb z@M}!f^~CAMc}}TzQP{Sn=Es~%$@N~a)#cIg?`$IK7uw?6CEvtHSg&cRG306VOdgN& z^d^}+u1aq3M8sUbf-K6|0zPmf0M}Rsit!ZmA96Y%Dy_}&IdZ5E0K}b>d9Z>@B!^J$ zwIy9+(#D?FH9KYmU!eeNd`B+I4n0xz7bt5u;N}|AA1LtRd6-?H#`s{8plgb`%Dxs5 zK3HCCI#4)2}ZpEgo#Z6;P{)i(7_y^v6bPh@KVqVUI+NvclaZ*5cpupc1$~uiR zZ=H7R3Y(*(X-jI{crcG?GH#m+0>&=De`d7oi{@9P)r}&y%|#x6ThqoV`Oj-qR3XS( zuRTYlLYW+BZJwRgAISB+7#KhT?7=PYM?f0za%ZOi1_3>V{tNUFIgYdNph`6f5a^fY z5{JD^2w>oJWe_FT#E@c2zR1RVq5FPu&x297v7R%BliZs%u2;v{fxOEXCueEuXA+IF z3!eKP59Ttpt~wDC_WY2}#@o!QG=|*n?nfFrXaSkIvCpC;U5|@~EPV+<4N9Obth*Ll z-7=VEt9`FgP}md3Fgj`QWFY^9uJh^lY9WFrRq2Y9IP}uu4*z)9fp?yluk^E%)wv+2 z>Xg*d0G7B~-Lmk9m7c3{+!UGwG{>a2yLb3rSwKRmjAyBRQA>GV%(k+oHtd`l;{$Ms zLs4Ey#^3pJ7YD6eejOfOWD~vD5#<#$369rsebrxVIj8nE?S-{rQ|#cdE@y%P};`Y`qWa1eG(34H7P&DtF9 z3M|I&HD)GLI{L9EoBA*7g2wR9vPpRVc6qCxc1?smc>vhsfaO%O|7XH1?Ewj&={4Uh~q+?v9rXH|+`jDrIhid*A2*E}Vuw1^cocLv~`hqfzSL$b{BK}O#b zKl2Qe;}uJlM3xw|O&Se&P>u8kmdqb#PGh+JPvm1(3zl(Y={B_0G^tilMPja9MeeCw z-SU|>waD^0g71}iCi>XD9i~X~S&jP(^b8bj7PT|RqFm@Xr%x7Pj%@|o!+9c@x*%U_ zyyW0zX>Z_6{@Y98%YnmARY4EUR`a*(PqRzrPW`W}vTq$?KWb1i8|kO7mv6|HZ)E%t zmXDLMzNE&O*!N5_j3jRGqGdz`3E#AR@DIp;QfyD+>$($t*y0abq+0WHSjiA;xaX?w zN}J3P^J&yo&khTS-~H&(u_sUj+z=MbkSa##;Dz|x+j4+4=2O*ZtJO~Pk>D{oanow5 z(Di7cov!d8ic~qlh*}Wbo#|l7dUkji-`G0!&Lg%)Ovqrq-h~$Twe*oJAt>(bY|~)+ zVv&5wV z?Oj(mTy57L5#>WjyhJaF9z+R(=m{cvL?_BJ&Yjg=rei=(YwJ2qrAG& zyC5U~&Uf@5|JQ$*qrFe|b?xif_j>kv?sczwL1GrS)ShUVl}a%%pB?DFeO?&v*v+-S zMH(b>Gm%_KDK$nzfPti4yyL*vU+iggJIfhqcBEQ4X4!t4JBvq193{4it$Dwly~>)K zcs(LGZ=_|H=%vo^^xuEID`8!Onxg(z` zrBe+PVQU|seAv@G5=>34jC6lAXUY){67BwD`ovfPv2`!;ag?-7(ujR$lo>5WkjOUp zb?S7_Rkscbjo!WOr1;F6uy0v`sm8P%QtH$lE*v#F8A>eDV;j`<1lX12={hd1e}gIv z-b?wq`P)BPxD2$`m`D_`v;>IjD)klhRjaQrkN#M!U$}+LxSd|X90+;3&hvKYCOgH( zpj7*M{x0(iW4NmsMbF|n^M1~zh6DpbhsSwGx(;3zvsY$!!JC9aH%DvJ{uRWFE6W?c z`>CJT!qaaBKWQ~BGB$Nc9&&}!z=(Ai3`tT_ShNk~oO;$YC73)(%~M?5cIfa0}eI&r6t(s*k`3J)Yx?ev(rn{KTjkF>?&9 zo;6w!tVPOMaSJbuRV^EyE<9$DU06w89-?5A(CjJ&h+2;L4!j>5o^+g8JKE{Y2$^Ye z@Y;BVOEFG~&YOcdW>Qn$^hd{$)LZz;KFZVpyu!3TxyRXqxQ#?7sO&Nm>=*)Ul1-zu zOLii$j-=?D#2NebCT9s|cAz)Y z7#^_KrVTc7QmU?++qyG`MRwCgYNO=0gzH_nNFq4$6Hfr2!~#_MSbdC+$OgNUpN(CU z&&K4+5qA~7(30@NUuK-@@Febrb~-;yr%X7{l58z`N4x%r8!;ChMJy7wnB5Wi`CUK> zq557s>&;mt{;*?N-@{kX_DCOVMBfUpqdJ3ze2#%%n|=qv69xQ&vRsI${?qmdy7N%>$uUR!C?X~$+@=v3q<$3PeSND3!?)er! zO8&fwT|LdomHOv71p^EN}4hMQ#^r~~H7Ddr>7U7g? z7|8q?K@&IKlk%IrRM#72j&pK3_tSkjz22209uF!Vlj><)69cL@#VDW($`aI7?r0#5 z#$9Milrwcdv>s&FZM0mory;0VuDT>J4w?}$_WO#8M`4-Tz&asoLt(~IAZj0a7JlvT z7RR@0ff%!{6Hk#mlC7z4P>;M88GHs3X*@|$pcQaJdclY4FhZ?3(~q^5H5ZqTyKmX* zpaXFxskmmH+u?Rh5vmN5W3K4D%*rKGbl|l8o!V$7;-_~S0fPTI&~=!U0wh@V@>fJ4 zp~#A5{___9q03cEJ?HLBhA}g66)mM)&nk#6tUVHbG4mUQkT*%`gdc-q@fUzxFJ2WO zGWrMdiLSAQJh%`AM(KofmXiXR3lUm+{BXAWS>>~#$x2kZnm9w+Ai>9j$Lj4tVORcu z7SH4A0_$Rv&^2Ph`_&vhALQxh%nGDGxJZClLVkw`c+I`=PC^XJU{Yz|E&wHPd}?uZ z$Lu%gU!WpG)>Mqc7*f`mI6h4w&G_dUKLixo^<;K_gRbrakADMJyeqxSzjZ`s;BNR0 z+MQks+ov&3)aJ>+4lM{0N9ghl-z9{Pwb}Gp3RbT6miy8)EJ<1i6H9KnkDs8CGK#k= z2i!!F7=!P_5=Zz=SG`>lamp{At_alE)XLfWo=+H5?@&s!fpd9p#Sb*M&E(bAHL0X^ zl4si9a7&Ve%-aN_V1og+XX&V{mDyyL#0PJ-sjLn#$=mY|7k-|*;l5znsj@~2qkz?) zF{tJ5=b1uhRLf1-|H<8IB?PQm{y4S#amJ=tFn%1h@XwEmMg~`8DLdO3?Z=sa$|SHI zRHu7=XBFBsLH)Nb@SVSUtX~YBzq$PNtYruoV*YIWwj6UybKbCJN>AaUQwd)KvwYOwv{?v$~vVpRw)G;Z*^T z5}n&OLJ|K#&a$li$BpcM;%4XelU`q(v0+x=C(g<6FZWQx!Tm3~e`O4e$(_|y;^Xb& z79c5$&mI#h-^b^%MxIpqYdK3=STKnBH@#{65z3s=D}67Qmr(1xcl4xNLITa3wjiiA z&U`Kd;}4wFM%DrL`ldela%HXX-lismpwQV6lXu8U!~F=wdxNx>iNrbiTWLXaKzh#s zuhYmiF#FYNf0RcV`KBgTx7OdcZ*V?Tx^!1rCl{-4{)iWS%qFE9mN1Yr?*^0!ihPQ- zLj4)`*OoTE-@`_*Q@|uk$hV>SbEx#$z)^vzrfjd>y-oV_JhDZ$hUq1hdZ_x4N7x6X zj4!*3EbfLp=K5N0@wwZ*TCU$9&$^9me>^0MYzJCsO0HaUERmdR<@|#)*R6*q448ox zkqup|fd9DoyuZ-F)O3E<1M-}Pmtu{%Qimt83Z1$+(TXX36%Ga@(YDYg=hF z+kUS!kolv7nf`@@H8U3NX5^#;q6U*KXilU{(uzt{d|E>ZnIDO6T%i1vp|S!4V@`K{ zRy&;g{93H=+YbDb$htPB771?TcCs4AYXr&a_&?r?1<;h%?{LHdU^xGCc>*}BGb)S4z-#G5vm6yKlS1F`Q;<`Qwg8k zZ3NlimyI0eUYUZ>+aam%RBTphKt2v{Ssrpw=0L=3>N%6DwPk-+#51nVTPy%(46e89 zvA&6)*I0j6LZ3_f;U@4!>SyUZa=HO+{@|F|!+iEB)YD+?HQ_|C2IXoeaO`A#9J=T% zVd#6yOewtJ^3X5tXh{;0J@QojL~PlvOQPu`18D|4g+X=)`u3W7gvlJ2vM6On9K-Sx zbSiT_BUN0V+mPO?$-1roSb{y0TO=XOKEZ*|;Jg=o06=;)-_!Dd~=Uu?L1BKgKOAVxrU>GZ=d|U z5hK6=&9MJ__Rs&zy&*pWh4snN)6(4(FNtenRsqUY&1D*QX1|S9fZVe(ClO~<$hT@- z>$tvD7s|b2mh=WWl-AZ#1u&HWF?WE}GN8xGX&2m^pHYPr&CO{FR4c`D#u>vIq~!3C zDDym0r~NOjA$IjiHt{VnK&loNV{vuck?mr?UQnQ7ceukY8hm2vPPFgX19As`ib?9g zjF5QM8~g;Iv!LlXC7lX7Sn#0HAr4F_0rH3J=Ft_bo&3z>r}nb)LYUf!uP(M;wd2KU zN-&E7@|2w{oI@U^p;1%3rzWej(voAwn$Z1)pPZB;OIhWfp2c!CU>OM;Qx)b{awEZN z%x($LiZnEXi&dlNaCHRftc;E2)F}bH(A;=lAQpTtT8}6)wHkPfSoBFEp>dL*BzwiyR0)qs1~SJxbnVV|Vyy;e1T=14H&%xzBh7 zHjKG3`%%%gLtOprt2MOa`u{;XOG8YIogezruhlT8DA4?3OPd(Z_?B$%b}(X)EnOS~ z{!oDmJqS|TEI$1e@1fh9q*BUQ_Arh}H<(B|m~3#`a|lYfPBG@;V+3B0zBsKgsbYyo zdcQU+B;`ptkdqQ4$Sif$O1M@A25tgy8 z8LBCRUQddvYrYZ$mXqD~hU|-$1 z)!(3*=O$e2Evr3yUQ8)YZx6jm4E6OuMua9!$c*n@380PEteXe?*}D#NDk2{L`Ai?auVCqspD8*b=M$ zx6^gr&Ue;ZI$_OpHON`^^q!e2#|dPR zJqT$7mSRn9@C7^j(U>bGhpl^4ti-DGLxsCAq$>+E)~kSVO?Or%8fY>Rq4OB^;>nXM zs`iitt52>%T)5KIXWJpRBFXMsvgLL8!J>NlHxrXCToZ>U#blhXi5&}BAJrO#E=4=< zf3jNT)s~wcpERe95-5AQ^>PH{-b^+UfLL%G`=^JnIF-7wlOrMMm3Q2RsSgtOS;T06 zqF*;zVPK{CL64$0glH*sv*>`YJ9>cLYAFUZ0K{O{(2Xz9JGYdgkCMMTr(JCRBFUWC zCr44_uBamOj0T91@T;gpvcp?Gm92$W)n_Ch6c}z8e}g{KrsLgdbIRtj$Nn+teazD! zZztG(e{RXY2%1+P`pkiv-q6WGxIpS#QMOS$dj1pss`A^-f|~;KFeci$68AuyJhpc9 z(%6=&VSQVc-liis)! zJ|I~PdX!vYWkG_(dT)R;<&Uh&kY{;^sdUeUl|{wW<0(~`@@=Zo=D`RwhUw+3;G|<6 z9S7&J-&r0-aZPjFiYDxh{N5w74_(G5;u=GnL51+eApb>Fl?PUR#-KTb%Tk!!i&p$A z;(n5^*8~Fcd09p3ERU?YzLYqd6ru((jasp2-JEN}OJlB8ThwRSsaUYiS@bg*Fqp{Y zj2>4ZwOQZ0qX)Ud`eq3V=cSp?Z;{?+%^N3?sxV`5d|rsVHil80tn$;7NU|0uo{VxH z;(vo?qtLF?R?T6~fO?gp{70hSkJ{SMLAFO2@pw@!B7yUF+5)10z$J;LzZ-T3q$U$e z&-pPf>~%MlmA*&3imcYK*^9UsHF}p;{f0X=t+g$OUi&y8T(wcxT-EXL;g2=m@4@c> zB>$HnivfKp*SOEdXH!c#|EbA|&*H1~E2hDqOFBQsEf;|b7iRIER?%vDqH$Jv#@K5f zZGc#1vba9(SXN+$M~2)CYKd92E*$}ak*cGh+>FW(FEf-yj5*P|W|ri7p{6nc{?ikM zxNnw5CREqf9m`spqP1cUZ-4J5LmF77LWk2)op2Fx8S;t>H| zy0>Wdv9TfCd2}+Vr5qHHplmcDzs8RMb~0Pw%mUTIV8~zCHpGJsth;T=!dw{Eq8O@ zc#2S^0w1R5-hE$H-5{a@j&BjVYPPSlq-z6E(@fecM3~Oy!7}@-(<&TFZKo>^WMZf?6p9WF}9$z-!|?R@2&Qx@}keN%+SrZ zYTY_joY!zim++ziN3Nz;E3RLgqE9bWFH}(wv3p{;Cn`m6Zu2MglT@fF(0JxZiSu)Z zC(M$JFeQ{NqTmJF`*nBZ^;z||4-tX81ygJ$izREJd|U~t@r*A|P*LmdIEA}I#n_?u z=`e0wl2@rLU2fyqM(ulX>Ji(r7wh$H^JT9-vnm{z6Dit5?qv1X}x&HqXR1dW^^c z>u>a{#UYt<)T`uM9#LZ&Z-JmpIitsr#1f`)W@_IVvofUaJ!SdGtDiPAf2>`qvg|;; zt~HgbCB)W~o~ap(TSR=PdDlzLixFcc(gEdB2+|p%cX!#5PvuN|8~GomN*!Ql#49L^ z!B5JReO>nZ2wr^PIV`nnh;XZSDDXVBCAplshcgfvtnGu`dRhD+71RUL$FQnZRS4?l z^1g}QNPniF<70N6H5w}AvN^rj<@73@>?qYWHHw9*L>z6V$kHuHv&Aa0{OO{|WKI#n zpze^&#ly+ls|m8kY0R{9wl-OULTMW$rI?zlNq%e~96F9^F+n@PIetsL7y>2A7d2I> zDHnm!HOX#8VCe0#jMAaQ&!q;W9tLv*dZGVLCHs5*yAFRb@D~GrG4K}ye=+bE1Aj5_ l7X$xC3=saFE&bdr@Mo^NM(kCDy;D#eKla5z%;m}U{{SP%`%wS@ From 287100f3030b337ac5f24d7577b0e28e26357616 Mon Sep 17 00:00:00 2001 From: harshbansal7 Date: Wed, 18 Feb 2026 23:13:47 +0530 Subject: [PATCH 36/56] Comments resolved --- pkg/skills/loader.go | 13 ++++- pkg/skills/loader_test.go | 102 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index c9731b6ae..bb0abbdcc 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -290,10 +290,15 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { // parseSimpleYAML parses simple key: value YAML format // Example: name: github\n description: "..." +// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac) func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { result := make(map[string]string) - for _, line := range strings.Split(content, "\n") { + // Normalize line endings: convert \r\n and \r to \n + normalized := strings.ReplaceAll(content, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + + for _, line := range strings.Split(normalized, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue @@ -325,7 +330,11 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string { } func (sl *SkillsLoader) stripFrontmatter(content string) string { - re := regexp.MustCompile(`^---\n.*?\n---\n`) + // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks + // (?s) enables DOTALL so . matches newlines; + // ^--- at start, then ... --- at start of line, honoring all three line ending types + // Match zero or more trailing line endings after closing --- (handles both with and without blank lines) + re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) return re.ReplaceAllString(content, "") } diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index e0e7109cf..539d24646 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -75,3 +75,105 @@ func TestSkillsInfoValidate(t *testing.T) { }) } } + +func TestExtractFrontmatter(t *testing.T) { + sl := &SkillsLoader{} + + testcases := []struct { + name string + content string + expectedName string + expectedDesc string + lineEndingType string + }{ + { + name: "unix-line-endings", + lineEndingType: "Unix (\\n)", + content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content", + expectedName: "test-skill", + expectedDesc: "A test skill", + }, + { + name: "windows-line-endings", + lineEndingType: "Windows (\\r\\n)", + content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content", + expectedName: "test-skill", + expectedDesc: "A test skill", + }, + { + name: "classic-mac-line-endings", + lineEndingType: "Classic Mac (\\r)", + content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content", + expectedName: "test-skill", + expectedDesc: "A test skill", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + // Extract frontmatter + frontmatter := sl.extractFrontmatter(tc.content) + assert.NotEmpty(t, frontmatter, "Frontmatter should be extracted for %s line endings", tc.lineEndingType) + + // Parse YAML to get name and description (parseSimpleYAML now handles all line ending types) + yamlMeta := sl.parseSimpleYAML(frontmatter) + assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType) + assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType) + }) + } +} + +func TestStripFrontmatter(t *testing.T) { + sl := &SkillsLoader{} + + testcases := []struct { + name string + content string + expectedContent string + lineEndingType string + }{ + { + name: "unix-line-endings", + lineEndingType: "Unix (\\n)", + content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content", + expectedContent: "# Skill Content", + }, + { + name: "windows-line-endings", + lineEndingType: "Windows (\\r\\n)", + content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content", + expectedContent: "# Skill Content", + }, + { + name: "classic-mac-line-endings", + lineEndingType: "Classic Mac (\\r)", + content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content", + expectedContent: "# Skill Content", + }, + { + name: "unix-line-endings-without-trailing-newline", + lineEndingType: "Unix (\\n) without trailing newline", + content: "---\nname: test-skill\ndescription: A test skill\n---\n# Skill Content", + expectedContent: "# Skill Content", + }, + { + name: "windows-line-endings-without-trailing-newline", + lineEndingType: "Windows (\\r\\n) without trailing newline", + content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n# Skill Content", + expectedContent: "# Skill Content", + }, + { + name: "no-frontmatter", + lineEndingType: "No frontmatter", + content: "# Skill Content\n\nSome content here.", + expectedContent: "# Skill Content\n\nSome content here.", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + result := sl.stripFrontmatter(tc.content) + assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType) + }) + } +} From 94a1b8664ba9637890e93f1864d19d7b78cde1c4 Mon Sep 17 00:00:00 2001 From: Hua Date: Wed, 18 Feb 2026 20:01:53 +0000 Subject: [PATCH 37/56] refactor: extract message splitting logic to shared utils - Move FindLast, findLast, and SplitMessage from discord.go to pkg/utils/message.go - Update discord.go to use utils.SplitMessage() - Makes splitting logic reusable across other channels --- pkg/channels/discord.go | 129 +-------------------------------------- pkg/utils/message.go | 131 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 128 deletions(-) create mode 100644 pkg/utils/message.go diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index f360c75ef..7dc3f3198 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "time" "github.com/bwmarrin/discordgo" @@ -106,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks + chunks := utils.SplitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks for _, chunk := range chunks { if err := c.sendChunk(ctx, channelID, chunk); err != nil { @@ -117,132 +116,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } -// splitMessage splits long messages into chunks, preserving code block integrity -// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks -func splitMessage(content string, limit int) []string { - var messages []string - - for len(content) > 0 { - if len(content) <= limit { - messages = append(messages, content) - break - } - - msgEnd := limit - - // Find natural split point within the limit - msgEnd = findLastNewline(content[:limit], 200) - if msgEnd <= 0 { - msgEnd = findLastSpace(content[:limit], 100) - } - if msgEnd <= 0 { - msgEnd = limit - } - - // Check if this would end with an incomplete code block - candidate := content[:msgEnd] - unclosedIdx := findLastUnclosedCodeBlock(candidate) - - if unclosedIdx >= 0 { - // Message would end with incomplete code block - // Try to extend to include the closing ``` (with some buffer) - extendedLimit := limit + 500 // Allow 500 char buffer for code blocks - if len(content) > extendedLimit { - closingIdx := findNextClosingCodeBlock(content, msgEnd) - if closingIdx > 0 && closingIdx <= extendedLimit { - // Extend to include the closing ``` - msgEnd = closingIdx - } else { - // Can't find closing, split before the code block - msgEnd = findLastNewline(content[:unclosedIdx], 200) - if msgEnd <= 0 { - msgEnd = findLastSpace(content[:unclosedIdx], 100) - } - if msgEnd <= 0 { - msgEnd = unclosedIdx - } - } - } else { - // Remaining content fits within extended limit - msgEnd = len(content) - } - } - - if msgEnd <= 0 { - msgEnd = limit - } - - messages = append(messages, content[:msgEnd]) - content = strings.TrimSpace(content[msgEnd:]) - } - - return messages -} - -// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` -// Returns the position of the opening ``` or -1 if all code blocks are complete -func findLastUnclosedCodeBlock(text string) int { - count := 0 - lastOpenIdx := -1 - - for i := 0; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - if count == 0 { - lastOpenIdx = i - } - count++ - i += 2 - } - } - - // If odd number of ``` markers, last one is unclosed - if count%2 == 1 { - return lastOpenIdx - } - return -1 -} - -// findNextClosingCodeBlock finds the next closing ``` starting from a position -// Returns the position after the closing ``` or -1 if not found -func findNextClosingCodeBlock(text string, startIdx int) int { - for i := startIdx; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - return i + 3 - } - } - return -1 -} - -// findLastNewline finds the last newline character within the last N characters -// Returns the position of the newline or -1 if not found -func findLastNewline(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == '\n' { - return i - } - } - return -1 -} - -// findLastSpace finds the last space character within the last N characters -// Returns the position of the space or -1 if not found -func findLastSpace(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == ' ' || s[i] == '\t' { - return i - } - } - return -1 -} - func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // 使用传入的 ctx 进行超时控制 sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/utils/message.go b/pkg/utils/message.go new file mode 100644 index 000000000..3a4cf2ad6 --- /dev/null +++ b/pkg/utils/message.go @@ -0,0 +1,131 @@ +package utils + +import ( + "strings" +) + +// SplitMessage splits long messages into chunks, preserving code block integrity +// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks +func SplitMessage(content string, limit int) []string { + var messages []string + + for len(content) > 0 { + if len(content) <= limit { + messages = append(messages, content) + break + } + + msgEnd := limit + + // Find natural split point within the limit + msgEnd = FindLastNewline(content[:limit], 200) + if msgEnd <= 0 { + msgEnd = FindLastSpace(content[:limit], 100) + } + if msgEnd <= 0 { + msgEnd = limit + } + + // Check if this would end with an incomplete code block + candidate := content[:msgEnd] + unclosedIdx := FindLastUnclosedCodeBlock(candidate) + + if unclosedIdx >= 0 { + // Message would end with incomplete code block + // Try to extend to include the closing ``` (with some buffer) + extendedLimit := limit + 500 // Allow 500 char buffer for code blocks + if len(content) > extendedLimit { + closingIdx := FindNextClosingCodeBlock(content, msgEnd) + if closingIdx > 0 && closingIdx <= extendedLimit { + // Extend to include the closing ``` + msgEnd = closingIdx + } else { + // Can't find closing, split before the code block + msgEnd = FindLastNewline(content[:unclosedIdx], 200) + if msgEnd <= 0 { + msgEnd = FindLastSpace(content[:unclosedIdx], 100) + } + if msgEnd <= 0 { + msgEnd = unclosedIdx + } + } + } else { + // Remaining content fits within extended limit + msgEnd = len(content) + } + } + + if msgEnd <= 0 { + msgEnd = limit + } + + messages = append(messages, content[:msgEnd]) + content = strings.TrimSpace(content[msgEnd:]) + } + + return messages +} + +// FindLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` +// Returns the position of the opening ``` or -1 if all code blocks are complete +func FindLastUnclosedCodeBlock(text string) int { + count := 0 + lastOpenIdx := -1 + + for i := 0; i < len(text); i++ { + if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + if count == 0 { + lastOpenIdx = i + } + count++ + i += 2 + } + } + + // If odd number of ``` markers, last one is unclosed + if count%2 == 1 { + return lastOpenIdx + } + return -1 +} + +// FindNextClosingCodeBlock finds the next closing ``` starting from a position +// Returns the position after the closing ``` or -1 if not found +func FindNextClosingCodeBlock(text string, startIdx int) int { + for i := startIdx; i < len(text); i++ { + if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + return i + 3 + } + } + return -1 +} + +// FindLastNewline finds the last newline character within the last N characters +// Returns the position of the newline or -1 if not found +func FindLastNewline(s string, searchWindow int) int { + searchStart := len(s) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(s) - 1; i >= searchStart; i-- { + if s[i] == '\n' { + return i + } + } + return -1 +} + +// FindLastSpace finds the last space character within the last N characters +// Returns the position of the space or -1 if not found +func FindLastSpace(s string, searchWindow int) int { + searchStart := len(s) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(s) - 1; i >= searchStart; i-- { + if s[i] == ' ' || s[i] == '\t' { + return i + } + } + return -1 +} From e03124dc8a695b36b28eb2798fc914efa4493906 Mon Sep 17 00:00:00 2001 From: Hua Date: Wed, 18 Feb 2026 20:21:51 +0000 Subject: [PATCH 38/56] refactor: improve SplitMessage API clarity - Accept hard upper limit (maxLen) instead of pre-subtracted value - Caller now passes actual platform limit (e.g., 2000 for Discord) - Internal buffer of 500 chars is handled within message.go - Preferred split at maxLen - 500, may extend to maxLen for code blocks - Never exceeds maxLen, no more mental math for callers --- pkg/channels/discord.go | 2 +- pkg/utils/message.go | 41 +++++++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 7dc3f3198..ba02f7598 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -105,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - chunks := utils.SplitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks + chunks := utils.SplitMessage(msg.Content, 2000) // Discord hard limit: 2000 chars (prefers split at 1500 to leave room for code blocks) for _, chunk := range chunks { if err := c.sendChunk(ctx, channelID, chunk); err != nil { diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 3a4cf2ad6..9ca49ba53 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -4,26 +4,35 @@ import ( "strings" ) -// SplitMessage splits long messages into chunks, preserving code block integrity -// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks -func SplitMessage(content string, limit int) []string { +const defaultCodeBlockBuffer = 500 + +// SplitMessage splits long messages into chunks, preserving code block integrity. +// The maxLen parameter is the hard upper limit - no message will exceed this length. +// The function prefers to split at maxLen - defaultCodeBlockBuffer to leave room for code blocks, +// but may extend up to maxLen when needed to avoid breaking incomplete code blocks. +func SplitMessage(content string, maxLen int) []string { var messages []string + codeBlockBuffer := defaultCodeBlockBuffer for len(content) > 0 { - if len(content) <= limit { + if len(content) <= maxLen { messages = append(messages, content) break } - msgEnd := limit + // Effective split point: maxLen minus buffer, to leave room for code blocks + effectiveLimit := maxLen - codeBlockBuffer + if effectiveLimit < maxLen/2 { + effectiveLimit = maxLen / 2 + } - // Find natural split point within the limit - msgEnd = FindLastNewline(content[:limit], 200) + // Find natural split point within the effective limit + msgEnd := FindLastNewline(content[:effectiveLimit], 200) if msgEnd <= 0 { - msgEnd = FindLastSpace(content[:limit], 100) + msgEnd = FindLastSpace(content[:effectiveLimit], 100) } if msgEnd <= 0 { - msgEnd = limit + msgEnd = effectiveLimit } // Check if this would end with an incomplete code block @@ -32,15 +41,14 @@ func SplitMessage(content string, limit int) []string { if unclosedIdx >= 0 { // Message would end with incomplete code block - // Try to extend to include the closing ``` (with some buffer) - extendedLimit := limit + 500 // Allow 500 char buffer for code blocks - if len(content) > extendedLimit { + // Try to extend up to maxLen (hard limit, never exceed) to include the closing ``` + if len(content) > msgEnd { closingIdx := FindNextClosingCodeBlock(content, msgEnd) - if closingIdx > 0 && closingIdx <= extendedLimit { + if closingIdx > 0 && closingIdx <= maxLen { // Extend to include the closing ``` msgEnd = closingIdx } else { - // Can't find closing, split before the code block + // Can't find closing within maxLen, split before the code block msgEnd = FindLastNewline(content[:unclosedIdx], 200) if msgEnd <= 0 { msgEnd = FindLastSpace(content[:unclosedIdx], 100) @@ -49,14 +57,11 @@ func SplitMessage(content string, limit int) []string { msgEnd = unclosedIdx } } - } else { - // Remaining content fits within extended limit - msgEnd = len(content) } } if msgEnd <= 0 { - msgEnd = limit + msgEnd = effectiveLimit } messages = append(messages, content[:msgEnd]) From e35a82762406cc09df43bbb8d72d1529f317b7fb Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Wed, 18 Feb 2026 21:44:25 +0100 Subject: [PATCH 39/56] update documents --- pkg/channels/discord.go | 2 +- pkg/utils/message.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index ba02f7598..472b51c53 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -105,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - chunks := utils.SplitMessage(msg.Content, 2000) // Discord hard limit: 2000 chars (prefers split at 1500 to leave room for code blocks) + chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars for _, chunk := range chunks { if err := c.sendChunk(ctx, channelID, chunk); err != nil { diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 9ca49ba53..ed56da95b 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -7,9 +7,9 @@ import ( const defaultCodeBlockBuffer = 500 // SplitMessage splits long messages into chunks, preserving code block integrity. -// The maxLen parameter is the hard upper limit - no message will exceed this length. // The function prefers to split at maxLen - defaultCodeBlockBuffer to leave room for code blocks, // but may extend up to maxLen when needed to avoid breaking incomplete code blocks. +// Please refer to pkg/channels/discord.go for usage. func SplitMessage(content string, maxLen int) []string { var messages []string codeBlockBuffer := defaultCodeBlockBuffer @@ -41,7 +41,7 @@ func SplitMessage(content string, maxLen int) []string { if unclosedIdx >= 0 { // Message would end with incomplete code block - // Try to extend up to maxLen (hard limit, never exceed) to include the closing ``` + // Try to extend up to maxLen to include the closing ``` if len(content) > msgEnd { closingIdx := FindNextClosingCodeBlock(content, msgEnd) if closingIdx > 0 && closingIdx <= maxLen { From b122abd30f2305631f2dc90f4d894b169ab37451 Mon Sep 17 00:00:00 2001 From: harshbansal7 Date: Thu, 19 Feb 2026 02:28:44 +0530 Subject: [PATCH 40/56] fix --- pkg/skills/loader_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index 539d24646..efadcdbf2 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -80,11 +80,11 @@ func TestExtractFrontmatter(t *testing.T) { sl := &SkillsLoader{} testcases := []struct { - name string - content string - expectedName string - expectedDesc string - lineEndingType string + name string + content string + expectedName string + expectedDesc string + lineEndingType string }{ { name: "unix-line-endings", From 4ccee8556179d42ad0c5c3d7cb1f25caed3a49b9 Mon Sep 17 00:00:00 2001 From: Hua Audio <161028864+Huaaudio@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:16:19 +0100 Subject: [PATCH 41/56] Update pkg/utils/message.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/utils/message.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/utils/message.go b/pkg/utils/message.go index ed56da95b..257f2c151 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -74,21 +74,22 @@ func SplitMessage(content string, maxLen int) []string { // FindLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` // Returns the position of the opening ``` or -1 if all code blocks are complete func FindLastUnclosedCodeBlock(text string) int { - count := 0 + inCodeBlock := false lastOpenIdx := -1 for i := 0; i < len(text); i++ { if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - if count == 0 { + // Toggle code block state on each fence + if !inCodeBlock { + // Entering a code block: record this opening fence lastOpenIdx = i } - count++ + inCodeBlock = !inCodeBlock i += 2 } } - // If odd number of ``` markers, last one is unclosed - if count%2 == 1 { + if inCodeBlock { return lastOpenIdx } return -1 From f38ce0d4ac7ce0a7f99dc8b3c9303d0d7a9a69a0 Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Wed, 18 Feb 2026 22:31:18 +0100 Subject: [PATCH 42/56] Update to support extra long code blocks --- pkg/utils/message.go | 47 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 257f2c151..6ee57bddb 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -48,13 +48,48 @@ func SplitMessage(content string, maxLen int) []string { // Extend to include the closing ``` msgEnd = closingIdx } else { - // Can't find closing within maxLen, split before the code block - msgEnd = FindLastNewline(content[:unclosedIdx], 200) - if msgEnd <= 0 { - msgEnd = FindLastSpace(content[:unclosedIdx], 100) + // Code block is too long to fit in one chunk or missing closing fence. + // Try to split inside by injecting closing and reopening fences. + headerEnd := strings.Index(content[unclosedIdx:], "\n") + if headerEnd == -1 { + headerEnd = unclosedIdx + 3 + } else { + headerEnd += unclosedIdx } - if msgEnd <= 0 { - msgEnd = unclosedIdx + header := strings.TrimSpace(content[unclosedIdx:headerEnd]) + + // If we have a reasonable amount of content after the header, split inside + if msgEnd > headerEnd+20 { + // Find a better split point closer to maxLen + innerLimit := maxLen - 5 // Leave room for "\n```" + betterEnd := FindLastNewline(content[:innerLimit], 200) + if betterEnd > headerEnd { + msgEnd = betterEnd + } else { + msgEnd = innerLimit + } + messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") + content = strings.TrimSpace(header + "\n" + content[msgEnd:]) + continue + } + + // Otherwise, try to split before the code block starts + newEnd := FindLastNewline(content[:unclosedIdx], 200) + if newEnd <= 0 { + newEnd = FindLastSpace(content[:unclosedIdx], 100) + } + if newEnd > 0 { + msgEnd = newEnd + } else { + // If we can't split before, we MUST split inside (last resort) + if unclosedIdx > 20 { + msgEnd = unclosedIdx + } else { + msgEnd = maxLen - 5 + messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") + content = strings.TrimSpace(header + "\n" + content[msgEnd:]) + continue + } } } } From 82a2faed9d54ba9caaf3f6ec764fd2f92fc6700d Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Wed, 18 Feb 2026 22:37:45 +0100 Subject: [PATCH 43/56] Privated function --- pkg/utils/message.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 6ee57bddb..66f637d3d 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -27,9 +27,9 @@ func SplitMessage(content string, maxLen int) []string { } // Find natural split point within the effective limit - msgEnd := FindLastNewline(content[:effectiveLimit], 200) + msgEnd := findLastNewline(content[:effectiveLimit], 200) if msgEnd <= 0 { - msgEnd = FindLastSpace(content[:effectiveLimit], 100) + msgEnd = findLastSpace(content[:effectiveLimit], 100) } if msgEnd <= 0 { msgEnd = effectiveLimit @@ -37,13 +37,13 @@ func SplitMessage(content string, maxLen int) []string { // Check if this would end with an incomplete code block candidate := content[:msgEnd] - unclosedIdx := FindLastUnclosedCodeBlock(candidate) + unclosedIdx := findLastUnclosedCodeBlock(candidate) if unclosedIdx >= 0 { // Message would end with incomplete code block // Try to extend up to maxLen to include the closing ``` if len(content) > msgEnd { - closingIdx := FindNextClosingCodeBlock(content, msgEnd) + closingIdx := findNextClosingCodeBlock(content, msgEnd) if closingIdx > 0 && closingIdx <= maxLen { // Extend to include the closing ``` msgEnd = closingIdx @@ -62,7 +62,7 @@ func SplitMessage(content string, maxLen int) []string { if msgEnd > headerEnd+20 { // Find a better split point closer to maxLen innerLimit := maxLen - 5 // Leave room for "\n```" - betterEnd := FindLastNewline(content[:innerLimit], 200) + betterEnd := findLastNewline(content[:innerLimit], 200) if betterEnd > headerEnd { msgEnd = betterEnd } else { @@ -74,9 +74,9 @@ func SplitMessage(content string, maxLen int) []string { } // Otherwise, try to split before the code block starts - newEnd := FindLastNewline(content[:unclosedIdx], 200) + newEnd := findLastNewline(content[:unclosedIdx], 200) if newEnd <= 0 { - newEnd = FindLastSpace(content[:unclosedIdx], 100) + newEnd = findLastSpace(content[:unclosedIdx], 100) } if newEnd > 0 { msgEnd = newEnd @@ -106,9 +106,9 @@ func SplitMessage(content string, maxLen int) []string { return messages } -// FindLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` +// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` // Returns the position of the opening ``` or -1 if all code blocks are complete -func FindLastUnclosedCodeBlock(text string) int { +func findLastUnclosedCodeBlock(text string) int { inCodeBlock := false lastOpenIdx := -1 @@ -130,9 +130,9 @@ func FindLastUnclosedCodeBlock(text string) int { return -1 } -// FindNextClosingCodeBlock finds the next closing ``` starting from a position +// findNextClosingCodeBlock finds the next closing ``` starting from a position // Returns the position after the closing ``` or -1 if not found -func FindNextClosingCodeBlock(text string, startIdx int) int { +func findNextClosingCodeBlock(text string, startIdx int) int { for i := startIdx; i < len(text); i++ { if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { return i + 3 @@ -141,9 +141,9 @@ func FindNextClosingCodeBlock(text string, startIdx int) int { return -1 } -// FindLastNewline finds the last newline character within the last N characters +// findLastNewline finds the last newline character within the last N characters // Returns the position of the newline or -1 if not found -func FindLastNewline(s string, searchWindow int) int { +func findLastNewline(s string, searchWindow int) int { searchStart := len(s) - searchWindow if searchStart < 0 { searchStart = 0 @@ -156,9 +156,9 @@ func FindLastNewline(s string, searchWindow int) int { return -1 } -// FindLastSpace finds the last space character within the last N characters +// findLastSpace finds the last space character within the last N characters // Returns the position of the space or -1 if not found -func FindLastSpace(s string, searchWindow int) int { +func findLastSpace(s string, searchWindow int) int { searchStart := len(s) - searchWindow if searchStart < 0 { searchStart = 0 From dfc3dffd0619530bff2615d48e137dfd531cf1bb Mon Sep 17 00:00:00 2001 From: Hua Audio <161028864+Huaaudio@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:43:49 +0100 Subject: [PATCH 44/56] Update pkg/utils/message.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/utils/message.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 66f637d3d..bc648f396 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -9,7 +9,8 @@ const defaultCodeBlockBuffer = 500 // SplitMessage splits long messages into chunks, preserving code block integrity. // The function prefers to split at maxLen - defaultCodeBlockBuffer to leave room for code blocks, // but may extend up to maxLen when needed to avoid breaking incomplete code blocks. -// Please refer to pkg/channels/discord.go for usage. +// Call SplitMessage with the full text content and the maximum allowed length of a single message; +// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. func SplitMessage(content string, maxLen int) []string { var messages []string codeBlockBuffer := defaultCodeBlockBuffer From 7d8894d842e874f1a0e4d413c5931ed8b8185cfa Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Wed, 18 Feb 2026 23:02:16 +0100 Subject: [PATCH 45/56] update message test, change dynamic buffer --- pkg/utils/message.go | 16 ++-- pkg/utils/message_test.go | 151 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 pkg/utils/message_test.go diff --git a/pkg/utils/message.go b/pkg/utils/message.go index bc648f396..1d05950d9 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -4,16 +4,22 @@ import ( "strings" ) -const defaultCodeBlockBuffer = 500 - // SplitMessage splits long messages into chunks, preserving code block integrity. -// The function prefers to split at maxLen - defaultCodeBlockBuffer to leave room for code blocks, -// but may extend up to maxLen when needed to avoid breaking incomplete code blocks. +// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks, +// but may extend to maxLen when needed. // Call SplitMessage with the full text content and the maximum allowed length of a single message; // it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. func SplitMessage(content string, maxLen int) []string { var messages []string - codeBlockBuffer := defaultCodeBlockBuffer + + // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible + codeBlockBuffer := maxLen / 10 + if codeBlockBuffer < 50 { + codeBlockBuffer = 50 + } + if codeBlockBuffer > maxLen/2 { + codeBlockBuffer = maxLen / 2 + } for len(content) > 0 { if len(content) <= maxLen { diff --git a/pkg/utils/message_test.go b/pkg/utils/message_test.go new file mode 100644 index 000000000..33f5e51fc --- /dev/null +++ b/pkg/utils/message_test.go @@ -0,0 +1,151 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestSplitMessage(t *testing.T) { + longText := strings.Repeat("a", 2500) + longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars + + tests := []struct { + name string + content string + maxLen int + expectChunks int // Check number of chunks + checkContent func(t *testing.T, chunks []string) // Custom validation + }{ + { + name: "Empty message", + content: "", + maxLen: 2000, + expectChunks: 0, + }, + { + name: "Short message fits in one chunk", + content: "Hello world", + maxLen: 2000, + expectChunks: 1, + }, + { + name: "Simple split regular text", + content: longText, + maxLen: 2000, + expectChunks: 2, + checkContent: func(t *testing.T, chunks []string) { + if len(chunks[0]) > 2000 { + t.Errorf("Chunk 0 too large: %d", len(chunks[0])) + } + if len(chunks[0])+len(chunks[1]) != len(longText) { + t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText)) + } + }, + }, + { + name: "Split at newline", + // 1750 chars then newline, then more chars. + // Dynamic buffer: 2000 / 10 = 200. + // Effective limit: 2000 - 200 = 1800. + // Split should happen at newline because it's at 1750 (< 1800). + // Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051. + content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300), + maxLen: 2000, + expectChunks: 2, + checkContent: func(t *testing.T, chunks []string) { + if len(chunks[0]) != 1750 { + t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0])) + } + if chunks[1] != strings.Repeat("b", 300) { + t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1])) + } + }, + }, + { + name: "Long code block split", + content: "Prefix\n" + longCode, + maxLen: 2000, + expectChunks: 2, + checkContent: func(t *testing.T, chunks []string) { + // Check that first chunk ends with closing fence + if !strings.HasSuffix(chunks[0], "\n```") { + t.Error("First chunk should end with injected closing fence") + } + // Check that second chunk starts with execution header + if !strings.HasPrefix(chunks[1], "```go") { + t.Error("Second chunk should start with injected code block header") + } + }, + }, + { + name: "Preserve Unicode characters", + content: strings.Repeat("世", 1000), // 3000 bytes + maxLen: 2000, + expectChunks: 2, + checkContent: func(t *testing.T, chunks []string) { + // Just verify we didn't panic and got valid strings. + // Go strings are UTF-8, if we split mid-rune it would be bad, + // but standard slicing might do that. + // Let's assume standard behavior is acceptable or check if it produces invalid rune? + if !strings.Contains(chunks[0], "世") { + t.Error("Chunk should contain unicode characters") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SplitMessage(tc.content, tc.maxLen) + + if tc.expectChunks == 0 { + if len(got) != 0 { + t.Errorf("Expected 0 chunks, got %d", len(got)) + } + return + } + + if len(got) != tc.expectChunks { + t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got)) + // Log sizes for debugging + for i, c := range got { + t.Logf("Chunk %d length: %d", i, len(c)) + } + return // Stop further checks if count assumes specific split + } + + if tc.checkContent != nil { + tc.checkContent(t, got) + } + }) + } +} + +func TestSplitMessage_CodeBlockIntegrity(t *testing.T) { + // Focused test for the core requirement: splitting inside a code block preserves syntax highlighting + + // 60 chars total approximately + content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```" + maxLen := 40 + + chunks := SplitMessage(content, maxLen) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + + // First chunk must end with "\n```" + if !strings.HasSuffix(chunks[0], "\n```") { + t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0]) + } + + // Second chunk must start with the header "```go" + if !strings.HasPrefix(chunks[1], "```go") { + t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1]) + } + + // First chunk should contain meaningful content + if len(chunks[0]) > 40 { + t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0])) + } +} From a46fe140a3c6e10b50d9d9437364865ac528cafb Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Wed, 18 Feb 2026 23:03:57 +0100 Subject: [PATCH 46/56] update dynamic buffer --- pkg/utils/message.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 1d05950d9..35914f399 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -9,6 +9,8 @@ import ( // but may extend to maxLen when needed. // Call SplitMessage with the full text content and the maximum allowed length of a single message; // it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. +// Call SplitMessage with the full text content and the maximum allowed length of a single message; +// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. func SplitMessage(content string, maxLen int) []string { var messages []string From 98afd39913afc07435dcf1e883cb1c447abad786 Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Wed, 18 Feb 2026 23:18:17 +0100 Subject: [PATCH 47/56] remove unicode --- pkg/utils/message_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/utils/message_test.go b/pkg/utils/message_test.go index 33f5e51fc..338509437 100644 --- a/pkg/utils/message_test.go +++ b/pkg/utils/message_test.go @@ -79,7 +79,7 @@ func TestSplitMessage(t *testing.T) { }, { name: "Preserve Unicode characters", - content: strings.Repeat("世", 1000), // 3000 bytes + content: strings.Repeat("\u4e16", 1000), // 3000 bytes maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { @@ -87,7 +87,7 @@ func TestSplitMessage(t *testing.T) { // Go strings are UTF-8, if we split mid-rune it would be bad, // but standard slicing might do that. // Let's assume standard behavior is acceptable or check if it produces invalid rune? - if !strings.Contains(chunks[0], "世") { + if !strings.Contains(chunks[0], "\u4e16") { t.Error("Chunk should contain unicode characters") } }, From 0d6b22fb3a8b90a00bc08ba015ec75a95ceb2041 Mon Sep 17 00:00:00 2001 From: Hua Audio <161028864+Huaaudio@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:26:39 +0100 Subject: [PATCH 48/56] Update pkg/utils/message.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/utils/message.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 35914f399..1d05950d9 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -9,8 +9,6 @@ import ( // but may extend to maxLen when needed. // Call SplitMessage with the full text content and the maximum allowed length of a single message; // it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. -// Call SplitMessage with the full text content and the maximum allowed length of a single message; -// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. func SplitMessage(content string, maxLen int) []string { var messages []string From bb0424e1e280c2ccc7861e6b5b431aa05bacca26 Mon Sep 17 00:00:00 2001 From: fipso Date: Thu, 19 Feb 2026 01:29:34 +0100 Subject: [PATCH 49/56] fix: also use max_completion_tokens for gpt5 era models (#445) --- pkg/providers/openai_compat/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 9b404dd77..73fac3435 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -71,7 +71,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef if maxTokens, ok := asInt(options["max_tokens"]); ok { lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { + if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") { requestBody["max_completion_tokens"] = maxTokens } else { requestBody["max_tokens"] = maxTokens From d167b4743132e2f5e6674bcb9b3d990953e936d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20Xia=28=E5=A4=8F=E6=81=BA=29?= Date: Thu, 19 Feb 2026 11:54:13 +1100 Subject: [PATCH 50/56] dead code cleanup (#210) --- pkg/skills/installer.go | 53 ----------------------------------------- 1 file changed, 53 deletions(-) diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index a3263c525..0856254e8 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "path/filepath" - "strings" "time" ) @@ -24,12 +23,6 @@ type AvailableSkill struct { Tags []string `json:"tags"` } -type BuiltinSkill struct { - Name string `json:"name"` - Path string `json:"path"` - Enabled bool `json:"enabled"` -} - func NewSkillInstaller(workspace string) *SkillInstaller { return &SkillInstaller{ workspace: workspace, @@ -123,49 +116,3 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS return skills, nil } - -func (si *SkillInstaller) ListBuiltinSkills() []BuiltinSkill { - builtinSkillsDir := filepath.Join(filepath.Dir(si.workspace), "picoclaw", "skills") - - entries, err := os.ReadDir(builtinSkillsDir) - if err != nil { - return nil - } - - var skills []BuiltinSkill - for _, entry := range entries { - if entry.IsDir() { - _ = entry - skillName := entry.Name() - skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md") - - data, err := os.ReadFile(skillFile) - description := "" - if err == nil { - content := string(data) - if idx := strings.Index(content, "\n"); idx > 0 { - firstLine := content[:idx] - if strings.Contains(firstLine, "description:") { - descLine := strings.Index(content[idx:], "\n") - if descLine > 0 { - description = strings.TrimSpace(content[idx+descLine : idx+descLine]) - } - } - } - } - - // skill := BuiltinSkill{ - // Name: skillName, - // Path: description, - // Enabled: true, - // } - - status := "✓" - fmt.Printf(" %s %s\n", status, entry.Name()) - if description != "" { - fmt.Printf(" %s\n", description) - } - } - } - return skills -} From e8afd31b28bf7d0c2e7ebddbc2320bc89f996fe0 Mon Sep 17 00:00:00 2001 From: mattn Date: Thu, 19 Feb 2026 10:02:28 +0900 Subject: [PATCH 51/56] Replace \s+ with [^\S\n]+ to preserve newlines (#299) --- pkg/tools/web.go | 6 ++-- pkg/tools/web_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 6a6d40ecf..1f5c58ea5 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -492,8 +492,10 @@ func (t *WebFetchTool) extractText(htmlContent string) string { result = strings.TrimSpace(result) - re = regexp.MustCompile(`\s+`) - result = re.ReplaceAllLiteralString(result, " ") + re = regexp.MustCompile(`[^\S\n]+`) + result = re.ReplaceAllString(result, " ") + re = regexp.MustCompile(`\n{3,}`) + result = re.ReplaceAllString(result, "\n\n") lines := strings.Split(result, "\n") var cleanLines []string diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index a526ea34a..7e6d62213 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -234,6 +234,80 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { } } +// TestWebFetchTool_extractText verifies text extraction preserves newlines +func TestWebFetchTool_extractText(t *testing.T) { + tool := &WebFetchTool{} + + tests := []struct { + name string + input string + wantFunc func(t *testing.T, got string) + }{ + { + name: "preserves newlines between block elements", + input: "

Title

\n

Paragraph 1

\n

Paragraph 2

", + wantFunc: func(t *testing.T, got string) { + lines := strings.Split(got, "\n") + if len(lines) < 2 { + t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) + } + if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") { + t.Errorf("Missing expected text: %q", got) + } + }, + }, + { + name: "removes script and style tags", + input: "

Keep this

", + wantFunc: func(t *testing.T, got string) { + if strings.Contains(got, "alert") || strings.Contains(got, "body{}") { + t.Errorf("Expected script/style content removed, got: %q", got) + } + if !strings.Contains(got, "Keep this") { + t.Errorf("Expected 'Keep this' to remain, got: %q", got) + } + }, + }, + { + name: "collapses excessive blank lines", + input: "

A

\n\n\n\n\n

B

", + wantFunc: func(t *testing.T, got string) { + if strings.Contains(got, "\n\n\n") { + t.Errorf("Expected excessive blank lines collapsed, got: %q", got) + } + }, + }, + { + name: "collapses horizontal whitespace", + input: "

hello world

", + wantFunc: func(t *testing.T, got string) { + if strings.Contains(got, " ") { + t.Errorf("Expected spaces collapsed, got: %q", got) + } + if !strings.Contains(got, "hello world") { + t.Errorf("Expected 'hello world', got: %q", got) + } + }, + }, + { + name: "empty input", + input: "", + wantFunc: func(t *testing.T, got string) { + if got != "" { + t.Errorf("Expected empty string, got: %q", got) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tool.extractText(tt.input) + tt.wantFunc(t, got) + }) + } +} + // TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain func TestWebTool_WebFetch_MissingDomain(t *testing.T) { tool := NewWebFetchTool(50000) From 56a060ff61167c03086a271340574e2f34d28721 Mon Sep 17 00:00:00 2001 From: hsohinna Date: Thu, 19 Feb 2026 14:39:35 +0800 Subject: [PATCH 52/56] feat(onebot): enhance OneBot channel (#192) * fix: change BotStatus type to json.RawMessage and add isAPIResponse function * feat(onebot): add rich media, API callback, keepalive and voice transcription Comprehensive improvements to the OneBot channel for better NapCatQQ compatibility: - Add echo-based API callback mechanism (sendAPIRequest) for request/response correlation via pending map - Add WebSocket ping/pong keepalive (30s ping, 60s read deadline) - Fetch bot self ID via get_login_info on connect/reconnect - Refactor parseMessageContentEx into parseMessageSegments supporting image, record, video, file, reply, face, forward segments - Add voice transcription via Groq transcriber (SetTranscriber) - Switch to message segment array format for sending with auto reply quote via lastMessageID tracking - Add message_sent event handling and detailed notice event processing (recall, poke, group increase/decrease, friend add, etc.) - Use sync/atomic for echoCounter, optimize listen() lock pattern - Clean up pending callbacks on Stop(), defer temp file cleanup - Mount Groq transcriber on OneBot channel in main.go gateway * feat(onebot): add user ID allowlist check for incoming messages - Currently, the agent does not respond to messages sent by users outside the allowlist. * refactor(onebot): simplify channel implementation and add emoji reaction - onebot.go from 1179 to 980 lines (~17%) --- cmd/picoclaw/main.go | 6 + pkg/channels/onebot.go | 707 +++++++++++++++++++++++++++++------------ 2 files changed, 504 insertions(+), 209 deletions(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 128f8c421..36bf2ea83 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -623,6 +623,12 @@ func gatewayCmd() { logger.InfoC("voice", "Groq transcription attached to Slack channel") } } + if onebotChannel, ok := channelManager.GetChannel("onebot"); ok { + if oc, ok := onebotChannel.(*channels.OneBotChannel); ok { + oc.SetTranscriber(transcriber) + logger.InfoC("voice", "Groq transcription attached to OneBot channel") + } + } } enabledChannels := channelManager.GetEnabledChannels() diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 5d97fab9c..53e82b44d 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "os" "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/gorilla/websocket" @@ -14,20 +16,28 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/voice" ) type OneBotChannel struct { *BaseChannel - config config.OneBotConfig - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - dedup map[string]struct{} - dedupRing []string - dedupIdx int - mu sync.Mutex - writeMu sync.Mutex - echoCounter int64 + config config.OneBotConfig + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + dedup map[string]struct{} + dedupRing []string + dedupIdx int + mu sync.Mutex + writeMu sync.Mutex + echoCounter int64 + selfID int64 + pending map[string]chan json.RawMessage + pendingMu sync.Mutex + transcriber *voice.GroqTranscriber + lastMessageID sync.Map + pendingEmojiMsg sync.Map } type oneBotRawEvent struct { @@ -43,9 +53,11 @@ type oneBotRawEvent struct { SelfID json.RawMessage `json:"self_id"` Time json.RawMessage `json:"time"` MetaEventType string `json:"meta_event_type"` + NoticeType string `json:"notice_type"` Echo string `json:"echo"` RetCode json.RawMessage `json:"retcode"` - Status BotStatus `json:"status"` + Status json.RawMessage `json:"status"` + Data json.RawMessage `json:"data"` } type BotStatus struct { @@ -53,42 +65,36 @@ type BotStatus struct { Good bool `json:"good"` } +func isAPIResponse(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s == "ok" || s == "failed" + } + var bs BotStatus + if json.Unmarshal(raw, &bs) == nil { + return bs.Online || bs.Good + } + return false +} + type oneBotSender struct { UserID json.RawMessage `json:"user_id"` Nickname string `json:"nickname"` Card string `json:"card"` } -type oneBotEvent struct { - PostType string - MessageType string - SubType string - MessageID string - UserID int64 - GroupID int64 - Content string - RawContent string - IsBotMentioned bool - Sender oneBotSender - SelfID int64 - Time int64 - MetaEventType string -} - type oneBotAPIRequest struct { Action string `json:"action"` Params interface{} `json:"params"` Echo string `json:"echo,omitempty"` } -type oneBotSendPrivateMsgParams struct { - UserID int64 `json:"user_id"` - Message string `json:"message"` -} - -type oneBotSendGroupMsgParams struct { - GroupID int64 `json:"group_id"` - Message string `json:"message"` +type oneBotMessageSegment struct { + Type string `json:"type"` + Data map[string]interface{} `json:"data"` } func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { @@ -101,9 +107,30 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One dedup: make(map[string]struct{}, dedupSize), dedupRing: make([]string, dedupSize), dedupIdx: 0, + pending: make(map[string]chan json.RawMessage), }, nil } +func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { + c.transcriber = transcriber +} + +func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { + go func() { + _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{ + "message_id": messageID, + "emoji_id": emojiID, + "set": set, + }, 5*time.Second) + if err != nil { + logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{ + "message_id": messageID, + "error": err.Error(), + }) + } + }() +} + func (c *OneBotChannel) Start(ctx context.Context) error { if c.config.WSUrl == "" { return fmt.Errorf("OneBot ws_url not configured") @@ -121,12 +148,12 @@ func (c *OneBotChannel) Start(ctx context.Context) error { }) } else { go c.listen() + c.fetchSelfID() } if c.config.ReconnectInterval > 0 { go c.reconnectLoop() } else { - // If reconnect is disabled but initial connection failed, we cannot recover if c.conn == nil { return fmt.Errorf("failed to connect to OneBot and reconnect is disabled") } @@ -152,14 +179,141 @@ func (c *OneBotChannel) connect() error { return err } + conn.SetPongHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.mu.Lock() c.conn = conn c.mu.Unlock() + go c.pinger(conn) + logger.InfoC("onebot", "WebSocket connected") return nil } +func (c *OneBotChannel) pinger(conn *websocket.Conn) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + c.writeMu.Lock() + err := conn.WriteMessage(websocket.PingMessage, nil) + c.writeMu.Unlock() + if err != nil { + logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{ + "error": err.Error(), + }) + return + } + } + } +} + +func (c *OneBotChannel) fetchSelfID() { + resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second) + if err != nil { + logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{ + "error": err.Error(), + }) + return + } + + type loginInfo struct { + UserID json.RawMessage `json:"user_id"` + Nickname string `json:"nickname"` + } + for _, extract := range []func() (*loginInfo, error){ + func() (*loginInfo, error) { + var w struct { + Data loginInfo `json:"data"` + } + err := json.Unmarshal(resp, &w) + return &w.Data, err + }, + func() (*loginInfo, error) { + var f loginInfo + err := json.Unmarshal(resp, &f) + return &f, err + }, + } { + info, err := extract() + if err != nil || len(info.UserID) == 0 { + continue + } + if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 { + atomic.StoreInt64(&c.selfID, uid) + logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{ + "self_id": uid, + "nickname": info.Nickname, + }) + return + } + } + + logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{ + "response": string(resp), + }) +} + +func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return nil, fmt.Errorf("WebSocket not connected") + } + + echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1)) + + ch := make(chan json.RawMessage, 1) + c.pendingMu.Lock() + c.pending[echo] = ch + c.pendingMu.Unlock() + + defer func() { + c.pendingMu.Lock() + delete(c.pending, echo) + c.pendingMu.Unlock() + }() + + req := oneBotAPIRequest{ + Action: action, + Params: params, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal API request: %w", err) + } + + c.writeMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.writeMu.Unlock() + + if err != nil { + return nil, fmt.Errorf("failed to write API request: %w", err) + } + + select { + case resp := <-ch: + return resp, nil + case <-time.After(timeout): + return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) + case <-c.ctx.Done(): + return nil, fmt.Errorf("context cancelled") + } +} + func (c *OneBotChannel) reconnectLoop() { interval := time.Duration(c.config.ReconnectInterval) * time.Second if interval < 5*time.Second { @@ -183,6 +337,7 @@ func (c *OneBotChannel) reconnectLoop() { }) } else { go c.listen() + c.fetchSelfID() } } } @@ -197,6 +352,13 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { c.cancel() } + c.pendingMu.Lock() + for echo, ch := range c.pending { + close(ch) + delete(c.pending, echo) + } + c.pendingMu.Unlock() + c.mu.Lock() if c.conn != nil { c.conn.Close() @@ -225,10 +387,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return err } - c.writeMu.Lock() - c.echoCounter++ - echo := fmt.Sprintf("send_%d", c.echoCounter) - c.writeMu.Unlock() + echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) req := oneBotAPIRequest{ Action: action, @@ -252,67 +411,78 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return err } + if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok { + if mid, ok := msgID.(string); ok && mid != "" { + c.setMsgEmojiLike(mid, 289, false) + } + } + return nil } +func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { + var segments []oneBotMessageSegment + + if lastMsgID, ok := c.lastMessageID.Load(chatID); ok { + if msgID, ok := lastMsgID.(string); ok && msgID != "" { + segments = append(segments, oneBotMessageSegment{ + Type: "reply", + Data: map[string]interface{}{"id": msgID}, + }) + } + } + + segments = append(segments, oneBotMessageSegment{ + Type: "text", + Data: map[string]interface{}{"text": content}, + }) + + return segments +} + func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { chatID := msg.ChatID + segments := c.buildMessageSegments(chatID, msg.Content) - if len(chatID) > 6 && chatID[:6] == "group:" { - groupID, err := strconv.ParseInt(chatID[6:], 10, 64) - if err != nil { - return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID) - } - return "send_group_msg", oneBotSendGroupMsgParams{ - GroupID: groupID, - Message: msg.Content, - }, nil + var action, idKey string + var rawID string + if rest, ok := strings.CutPrefix(chatID, "group:"); ok { + action, idKey, rawID = "send_group_msg", "group_id", rest + } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { + action, idKey, rawID = "send_private_msg", "user_id", rest + } else { + action, idKey, rawID = "send_private_msg", "user_id", chatID } - if len(chatID) > 8 && chatID[:8] == "private:" { - userID, err := strconv.ParseInt(chatID[8:], 10, 64) - if err != nil { - return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID) - } - return "send_private_msg", oneBotSendPrivateMsgParams{ - UserID: userID, - Message: msg.Content, - }, nil - } - - userID, err := strconv.ParseInt(chatID, 10, 64) + id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { - return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID) + return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID) } - - return "send_private_msg", oneBotSendPrivateMsgParams{ - UserID: userID, - Message: msg.Content, - }, nil + return action, map[string]interface{}{idKey: id, "message": segments}, nil } func (c *OneBotChannel) listen() { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") + return + } + for { select { case <-c.ctx.Done(): return default: - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") - return - } - _, message, err := conn.ReadMessage() if err != nil { logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ "error": err.Error(), }) c.mu.Lock() - if c.conn != nil { + if c.conn == conn { c.conn.Close() c.conn = nil } @@ -320,10 +490,7 @@ func (c *OneBotChannel) listen() { return } - logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{ - "length": len(message), - "payload": string(message), - }) + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) var raw oneBotRawEvent if err := json.Unmarshal(message, &raw); err != nil { @@ -334,20 +501,37 @@ func (c *OneBotChannel) listen() { continue } - if raw.Echo != "" || raw.Status.Online || raw.Status.Good { - logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{ - "echo": raw.Echo, - "status": raw.Status, - }) + logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{ + "length": len(message), + "post_type": raw.PostType, + "sub_type": raw.SubType, + }) + + if raw.Echo != "" { + c.pendingMu.Lock() + ch, ok := c.pending[raw.Echo] + c.pendingMu.Unlock() + + if ok { + select { + case ch <- message: + default: + } + } else { + logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{ + "echo": raw.Echo, + "status": string(raw.Status), + }) + } continue } - logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{ - "post_type": raw.PostType, - "message_type": raw.MessageType, - "sub_type": raw.SubType, - "meta_event_type": raw.MetaEventType, - }) + if isAPIResponse(raw.Status) { + logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{ + "status": string(raw.Status), + }) + continue + } c.handleRawEvent(&raw) } @@ -386,9 +570,12 @@ func parseJSONString(raw json.RawMessage) string { type parseMessageResult struct { Text string IsBotMentioned bool + Media []string + LocalFiles []string + ReplyTo string } -func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult { +func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult { if len(raw) == 0 { return parseMessageResult{} } @@ -408,60 +595,155 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult } var segments []map[string]interface{} - if err := json.Unmarshal(raw, &segments); err == nil { - var text string - mentioned := false - selfIDStr := strconv.FormatInt(selfID, 10) - for _, seg := range segments { - segType, _ := seg["type"].(string) - data, _ := seg["data"].(map[string]interface{}) - switch segType { - case "text": - if data != nil { - if t, ok := data["text"].(string); ok { - text += t - } + if err := json.Unmarshal(raw, &segments); err != nil { + return parseMessageResult{} + } + + var textParts []string + mentioned := false + selfIDStr := strconv.FormatInt(selfID, 10) + var media []string + var localFiles []string + var replyTo string + + for _, seg := range segments { + segType, _ := seg["type"].(string) + data, _ := seg["data"].(map[string]interface{}) + + switch segType { + case "text": + if data != nil { + if t, ok := data["text"].(string); ok { + textParts = append(textParts, t) } - case "at": - if data != nil && selfID > 0 { - qqVal := fmt.Sprintf("%v", data["qq"]) - if qqVal == selfIDStr || qqVal == "all" { - mentioned = true + } + + case "at": + if data != nil && selfID > 0 { + qqVal := fmt.Sprintf("%v", data["qq"]) + if qqVal == selfIDStr || qqVal == "all" { + mentioned = true + } + } + + case "image", "video", "file": + if data != nil { + url, _ := data["url"].(string) + if url != "" { + defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"} + filename := defaults[segType] + if f, ok := data["file"].(string); ok && f != "" { + filename = f + } else if n, ok := data["name"].(string); ok && n != "" { + filename = n + } + localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "onebot", + }) + if localPath != "" { + media = append(media, localPath) + localFiles = append(localFiles, localPath) + textParts = append(textParts, fmt.Sprintf("[%s]", segType)) } } } + + case "record": + if data != nil { + url, _ := data["url"].(string) + if url != "" { + localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{ + LoggerPrefix: "onebot", + }) + if localPath != "" { + localFiles = append(localFiles, localPath) + if c.transcriber != nil && c.transcriber.IsAvailable() { + tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) + result, err := c.transcriber.Transcribe(tctx, localPath) + tcancel() + if err != nil { + logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{ + "error": err.Error(), + }) + textParts = append(textParts, "[voice (transcription failed)]") + media = append(media, localPath) + } else { + textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text)) + } + } else { + textParts = append(textParts, "[voice]") + media = append(media, localPath) + } + } + } + } + + case "reply": + if data != nil { + if id, ok := data["id"]; ok { + replyTo = fmt.Sprintf("%v", id) + } + } + + case "face": + if data != nil { + faceID, _ := data["id"] + textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID)) + } + + case "forward": + textParts = append(textParts, "[forward message]") + + default: + } - return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned} } - return parseMessageResult{} + + return parseMessageResult{ + Text: strings.TrimSpace(strings.Join(textParts, "")), + IsBotMentioned: mentioned, + Media: media, + LocalFiles: localFiles, + ReplyTo: replyTo, + } } func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { switch raw.PostType { case "message": - evt, err := c.normalizeMessageEvent(raw) - if err != nil { - logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{ - "error": err.Error(), - }) - return + if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { + if !c.IsAllowed(strconv.FormatInt(userID, 10)) { + logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{ + "user_id": userID, + }) + return + } } - c.handleMessage(evt) + c.handleMessage(raw) + + case "message_sent": + logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{ + "message_type": raw.MessageType, + "message_id": parseJSONString(raw.MessageID), + }) + case "meta_event": c.handleMetaEvent(raw) + case "notice": - logger.DebugCF("onebot", "Notice event received", map[string]interface{}{ - "sub_type": raw.SubType, - }) + c.handleNoticeEvent(raw) + case "request": logger.DebugCF("onebot", "Request event received", map[string]interface{}{ "sub_type": raw.SubType, }) + case "": logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ "echo": raw.Echo, "status": raw.Status, }) + default: logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ "post_type": raw.PostType, @@ -469,18 +751,51 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { } } -func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) { +func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { + if raw.MetaEventType == "lifecycle" { + logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType}) + } else if raw.MetaEventType != "heartbeat" { + logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil) + } +} + +func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) { + fields := map[string]interface{}{ + "notice_type": raw.NoticeType, + "sub_type": raw.SubType, + "group_id": parseJSONString(raw.GroupID), + "user_id": parseJSONString(raw.UserID), + "message_id": parseJSONString(raw.MessageID), + } + switch raw.NoticeType { + case "group_recall", "group_increase", "group_decrease", + "friend_add", "group_admin", "group_ban": + logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields) + default: + logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields) + } +} + +func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { + // Parse fields from raw event userID, err := parseJSONInt64(raw.UserID) if err != nil { - return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID)) + logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{ + "error": err.Error(), + "raw": string(raw.UserID), + }) + return } groupID, _ := parseJSONInt64(raw.GroupID) selfID, _ := parseJSONInt64(raw.SelfID) - ts, _ := parseJSONInt64(raw.Time) messageID := parseJSONString(raw.MessageID) - parsed := parseMessageContentEx(raw.Message, selfID) + if selfID == 0 { + selfID = atomic.LoadInt64(&c.selfID) + } + + parsed := c.parseMessageSegments(raw.Message, selfID) isBotMentioned := parsed.IsBotMentioned content := raw.RawMessage @@ -495,6 +810,10 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent } } + if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") { + content = parsed.Text + } + var sender oneBotSender if len(raw.Sender) > 0 { if err := json.Unmarshal(raw.Sender, &sender); err != nil { @@ -505,137 +824,107 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent } } - logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{ - "message_type": raw.MessageType, - "user_id": userID, - "group_id": groupID, - "message_id": messageID, - "content_len": len(content), - "nickname": sender.Nickname, - }) - - return &oneBotEvent{ - PostType: raw.PostType, - MessageType: raw.MessageType, - SubType: raw.SubType, - MessageID: messageID, - UserID: userID, - GroupID: groupID, - Content: content, - RawContent: raw.RawMessage, - IsBotMentioned: isBotMentioned, - Sender: sender, - SelfID: selfID, - Time: ts, - MetaEventType: raw.MetaEventType, - }, nil -} - -func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { - switch raw.MetaEventType { - case "lifecycle": - logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{ - "sub_type": raw.SubType, - }) - case "heartbeat": - logger.DebugC("onebot", "Heartbeat received") - default: - logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{ - "meta_event_type": raw.MetaEventType, - }) + // Clean up temp files when done + if len(parsed.LocalFiles) > 0 { + defer func() { + for _, f := range parsed.LocalFiles { + if err := os.Remove(f); err != nil { + logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{ + "path": f, + "error": err.Error(), + }) + } + } + }() } -} -func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { - if c.isDuplicate(evt.MessageID) { + if c.isDuplicate(messageID) { logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ - "message_id": evt.MessageID, + "message_id": messageID, }) return } - content := evt.Content if content == "" { logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ - "message_id": evt.MessageID, + "message_id": messageID, }) return } - senderID := strconv.FormatInt(evt.UserID, 10) + senderID := strconv.FormatInt(userID, 10) var chatID string metadata := map[string]string{ - "message_id": evt.MessageID, + "message_id": messageID, } - switch evt.MessageType { + if parsed.ReplyTo != "" { + metadata["reply_to_message_id"] = parsed.ReplyTo + } + + switch raw.MessageType { case "private": chatID = "private:" + senderID - logger.InfoCF("onebot", "Received private message", map[string]interface{}{ - "sender": senderID, - "message_id": evt.MessageID, - "length": len(content), - "content": truncate(content, 100), - }) case "group": - groupIDStr := strconv.FormatInt(evt.GroupID, 10) + groupIDStr := strconv.FormatInt(groupID, 10) chatID = "group:" + groupIDStr metadata["group_id"] = groupIDStr - senderUserID, _ := parseJSONInt64(evt.Sender.UserID) + senderUserID, _ := parseJSONInt64(sender.UserID) if senderUserID > 0 { metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10) } - if evt.Sender.Card != "" { - metadata["sender_name"] = evt.Sender.Card - } else if evt.Sender.Nickname != "" { - metadata["sender_name"] = evt.Sender.Nickname + if sender.Card != "" { + metadata["sender_name"] = sender.Card + } else if sender.Nickname != "" { + metadata["sender_name"] = sender.Nickname } - triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned) + triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) if !triggered { logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ "sender": senderID, "group": groupIDStr, - "is_mentioned": evt.IsBotMentioned, + "is_mentioned": isBotMentioned, "content": truncate(content, 100), }) return } content = strippedContent - logger.InfoCF("onebot", "Received group message", map[string]interface{}{ - "sender": senderID, - "group": groupIDStr, - "message_id": evt.MessageID, - "is_mentioned": evt.IsBotMentioned, - "length": len(content), - "content": truncate(content, 100), - }) - default: logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ - "type": evt.MessageType, - "message_id": evt.MessageID, - "user_id": evt.UserID, + "type": raw.MessageType, + "message_id": messageID, + "user_id": userID, }) return } - if evt.Sender.Nickname != "" { - metadata["nickname"] = evt.Sender.Nickname - } - - logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{ - "sender_id": senderID, - "chat_id": chatID, - "content": truncate(content, 100), + logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{ + "sender": senderID, + "chat_id": chatID, + "message_id": messageID, + "length": len(content), + "content": truncate(content, 100), + "media_count": len(parsed.Media), }) - c.HandleMessage(senderID, chatID, content, []string{}, metadata) + if sender.Nickname != "" { + metadata["nickname"] = sender.Nickname + } + + c.lastMessageID.Store(chatID, messageID) + + if raw.MessageType == "group" && messageID != "" && messageID != "0" { + c.setMsgEmojiLike(messageID, 289, true) + c.pendingEmojiMsg.Store(chatID, messageID) + } + + c.HandleMessage(senderID, chatID, content, parsed.Media, metadata) } func (c *OneBotChannel) isDuplicate(messageID string) bool { From 32c5c4b3a44e959be1578a7c6d55651e89c86d37 Mon Sep 17 00:00:00 2001 From: Ruslan Semagin Date: Thu, 19 Feb 2026 13:48:17 +0300 Subject: [PATCH 53/56] refactor: replace bool map with set-style map for internal channels (#472) * refactor: replace bool map with set-style map for internal channels Use map[string]struct{} and comma-ok idiom for clearer and more idiomatic membership checks. * Update pkg/constants/channels.go Co-authored-by: Harsh Bansal <122075346+harshbansal7@users.noreply.github.com> --------- Co-authored-by: Harsh Bansal <122075346+harshbansal7@users.noreply.github.com> --- pkg/constants/channels.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/constants/channels.go b/pkg/constants/channels.go index 3e3df3839..0a46e6cd9 100644 --- a/pkg/constants/channels.go +++ b/pkg/constants/channels.go @@ -1,15 +1,16 @@ // Package constants provides shared constants across the codebase. package constants -// InternalChannels defines channels that are used for internal communication +// internalChannels defines channels that are used for internal communication // and should not be exposed to external users or recorded as last active channel. -var InternalChannels = map[string]bool{ - "cli": true, - "system": true, - "subagent": true, +var internalChannels = map[string]struct{}{ + "cli": {}, + "system": {}, + "subagent": {}, } // IsInternalChannel returns true if the channel is an internal channel. func IsInternalChannel(channel string) bool { - return InternalChannels[channel] + _, found := internalChannels[channel] + return found } From 12f0c4a6cf84b110f3bffe67a31302250d5618ed Mon Sep 17 00:00:00 2001 From: tpkeeper Date: Thu, 19 Feb 2026 19:06:09 +0800 Subject: [PATCH 54/56] fix: ensure tool name is correctly assigned in LLM iteration(missing tool call name in debug mode logs) (#454) * fix: ensure tool name is correctly assigned in LLM iteration * fix: ensure tool name is correctly included in assistant message --- pkg/agent/loop.go | 1 + pkg/tools/toolloop.go | 1 + 2 files changed, 2 insertions(+) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed69712ff..6d0a61375 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -602,6 +602,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, Name: tc.Name, Arguments: string(argumentsJSON), }, + Name: tc.Name, }) } messages = append(messages, assistantMsg) diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 1302079b4..b07b14adb 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -109,6 +109,7 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider Name: tc.Name, Arguments: string(argumentsJSON), }, + Name: tc.Name, }) } messages = append(messages, assistantMsg) From 213274002ad21fe5a41219248e74ae7f4df27664 Mon Sep 17 00:00:00 2001 From: Jex <35288649+JexLau@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:28:58 +0800 Subject: [PATCH 55/56] fix: keep Discord typing indicator alive during agent processing (#391) * fix: keep Discord typing indicator alive during agent processing Discord's ChannelTyping() expires after ~10s, but agent processing (LLM + tool execution) typically takes 30-60s+. Replace single-fire ChannelTyping() with a self-managed typing loop inside DiscordChannel. - startTyping(chatID): goroutine refreshes ChannelTyping every 8s - stopTyping(chatID): called in Send() when response is dispatched - Stop() cleans up all typing goroutines on shutdown - startTyping placed after all early returns to prevent goroutine leaks Typing lifecycle fully contained in channel layer, no interface changes. Fixes #390 Co-Authored-By: Claude Opus 4.6 * fix: add goroutine safety to Discord typing indicator - Add 5-minute timeout as safety net to prevent indefinite goroutine leaks when agent produces no outbound message (empty response, panic, etc.) - Listen on c.ctx.Done() so goroutine exits when channel context is cancelled - Log ChannelTyping() errors at debug level for diagnostics (rate limits, session closed) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 --- pkg/channels/discord.go | 69 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 472b51c53..9ddec662c 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "sync" "time" "github.com/bwmarrin/discordgo" @@ -25,6 +26,8 @@ type DiscordChannel struct { config config.DiscordConfig transcriber *voice.GroqTranscriber ctx context.Context + typingMu sync.Mutex + typingStop map[string]chan struct{} // chatID → stop signal } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { @@ -41,6 +44,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC config: cfg, transcriber: nil, ctx: context.Background(), + typingStop: make(map[string]chan struct{}), }, nil } @@ -83,6 +87,14 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { logger.InfoC("discord", "Stopping Discord bot") c.setRunning(false) + // Stop all typing goroutines before closing session + c.typingMu.Lock() + for chatID, stop := range c.typingStop { + close(stop) + delete(c.typingStop, chatID) + } + c.typingMu.Unlock() + if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) } @@ -91,6 +103,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { } func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.stopTyping(msg.ChatID) + if !c.IsRunning() { return fmt.Errorf("discord bot not running") } @@ -155,12 +169,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - if err := c.session.ChannelTyping(m.ChannelID); err != nil { - logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{ - "error": err.Error(), - }) - } - // 检查白名单,避免为被拒绝的用户下载附件和转录 if !c.IsAllowed(m.Author.ID) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ @@ -243,6 +251,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content = "[media only]" } + // Start typing after all early returns — guaranteed to have a matching Send() + c.startTyping(m.ChannelID) + logger.DebugCF("discord", "Received message", map[string]any{ "sender_name": senderName, "sender_id": senderID, @@ -271,6 +282,52 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) } +// startTyping starts a continuous typing indicator loop for the given chatID. +// It stops any existing typing loop for that chatID before starting a new one. +func (c *DiscordChannel) startTyping(chatID string) { + c.typingMu.Lock() + // Stop existing loop for this chatID if any + if stop, ok := c.typingStop[chatID]; ok { + close(stop) + } + stop := make(chan struct{}) + c.typingStop[chatID] = stop + c.typingMu.Unlock() + + go func() { + if err := c.session.ChannelTyping(chatID); err != nil { + logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + } + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + timeout := time.After(5 * time.Minute) + for { + select { + case <-stop: + return + case <-timeout: + return + case <-c.ctx.Done(): + return + case <-ticker.C: + if err := c.session.ChannelTyping(chatID); err != nil { + logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + } + } + } + }() +} + +// stopTyping stops the typing indicator loop for the given chatID. +func (c *DiscordChannel) stopTyping(chatID string) { + c.typingMu.Lock() + defer c.typingMu.Unlock() + if stop, ok := c.typingStop[chatID]; ok { + close(stop) + delete(c.typingStop, chatID) + } +} + func (c *DiscordChannel) downloadAttachment(url, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "discord", From 521359ed4f1010a853d3640bee84ae777d739eff Mon Sep 17 00:00:00 2001 From: Edouard CLAUDE Date: Thu, 19 Feb 2026 17:23:06 +0400 Subject: [PATCH 56/56] docs: add French README (README.fr.md) (#408) --- README.fr.md | 881 ++++++++++++++++++++++++++++++++++++++++++++++++ README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 2 +- README.vi.md | 2 +- README.zh.md | 2 +- 6 files changed, 886 insertions(+), 5 deletions(-) create mode 100644 README.fr.md diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 000000000..ab8faf468 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,881 @@ + + +--- + +🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [nanobot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code. + +⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **10$** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini ! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 SÉCURITÉ & CANAUX OFFICIELS** +> +> * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**. +> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**. +> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers et ne nous appartiennent pas. +> * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0. +> * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé. + + +## 📢 Actualités + +2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir ! + +2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw. +🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire. + +2026-02-09 🎉 PicoClaw est lancé ! Construit en 1 jour pour apporter les Agents IA au matériel à 10$ avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti ! + +## ✨ Fonctionnalités + +🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que Clawdbot pour les fonctionnalités essentielles. + +💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à 10$ — 98% moins cher qu'un Mac mini. + +⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz. + +🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti ! + +🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Langage** | TypeScript | Python | **Go** | +| **RAM** | >1 Go | >100 Mo | **< 10 Mo** | +| **Démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | +| **Coût** | Mac Mini 599$ | La plupart des SBC Linux
~50$ | **N'importe quelle carte Linux**
**À partir de 10$** | + +PicoClaw + +## 🦾 Démonstration + +### 🛠️ Flux de Travail Standard de l'Assistant + + + + + + + + + + + + + + + + + +

🧩 Ingénieur Full-Stack

🗂️ Gestion des Logs & Planification

🔎 Recherche Web & Apprentissage

Développer • Déployer • Mettre à l'échellePlanifier • Automatiser • MémoriserDécouvrir • Analyser • Tendances
+ +### 📱 Utiliser sur d'anciens téléphones Android + +Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide : + +1. **Installez Termux** (disponible sur F-Droid ou Google Play). +2. **Exécutez les commandes** + +```bash +# Note : Remplacez v0.1.1 par la dernière version depuis la page des Releases +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 +``` + +Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration ! + +PicoClaw + +### 🐜 Déploiement Innovant à Faible Empreinte + +PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! + +- 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste +- 30~50$ [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs +- 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente + + + +🌟 Encore plus de scénarios de déploiement vous attendent ! + +## 📦 Installation + +### Installer avec un binaire précompilé + +Téléchargez le binaire pour votre plateforme depuis la page des [releases](https://github.com/sipeed/picoclaw/releases). + +### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Compiler, pas besoin d'installer +make build + +# Compiler pour plusieurs plateformes +make build-all + +# Compiler et Installer +make install +``` + +## 🐳 Docker Compose + +Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement. + +```bash +# 1. Clonez ce dépôt +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Configurez vos clés API +cp config/config.example.json config/config.json +vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc. + +# 3. Compiler & Démarrer +docker compose --profile gateway up -d + +# 4. Voir les logs +docker compose logs -f picoclaw-gateway + +# 5. Arrêter +docker compose --profile gateway down +``` + +### Mode Agent (exécution unique) + +```bash +# Poser une question +docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?" + +# Mode interactif +docker compose run --rm picoclaw-agent +``` + +### Recompiler + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Démarrage Rapide + +> [!TIP] +> Configurez votre clé API dans `~/.picoclaw/config.json`. +> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré. + +**1. Initialiser** + +```bash +picoclaw onboard +``` + +**2. Configurer** (`~/.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": "VOTRE_CLE_API_BRAVE", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +**3. Obtenir des Clés API** + +* **Fournisseur 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) +* **Recherche Web** (optionnel) : [Brave Search](https://brave.com/search/api) - Offre gratuite disponible (2000 requêtes/mois) + +> **Note** : Consultez `config.example.json` pour un modèle de configuration complet. + +**4. Discuter** + +```bash +picoclaw agent -m "Combien font 2+2 ?" +``` + +Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes. + +--- + +## 💬 Applications de Chat + +Discutez avec votre PicoClaw via Telegram, Discord, DingTalk ou LINE + +| Canal | Configuration | +| ------------ | -------------------------------------- | +| **Telegram** | Facile (juste un token) | +| **Discord** | Facile (token bot + intents) | +| **QQ** | Facile (AppID + AppSecret) | +| **DingTalk** | Moyen (identifiants de l'application) | +| **LINE** | Moyen (identifiants + URL de webhook) | + +
+Telegram (Recommandé) + +**1. Créer un bot** + +* Ouvrez Telegram, recherchez `@BotFather` +* Envoyez `/newbot`, suivez les instructions +* Copiez le token + +**2. Configurer** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "VOTRE_TOKEN_BOT", + "allowFrom": ["VOTRE_USER_ID"] + } + } +} +``` + +> Obtenez votre User ID via `@userinfobot` sur Telegram. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Créer un bot** + +* Rendez-vous sur +* Créez une application → Bot → Add Bot +* Copiez le token du bot + +**2. Activer les intents** + +* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT** +* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous souhaitez utiliser des listes d'autorisation basées sur les données des membres + +**3. Obtenir votre User ID** + +* Paramètres Discord → Avancé → activez le **Mode Développeur** +* Clic droit sur votre avatar → **Copier l'identifiant** + +**4. Configurer** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "VOTRE_TOKEN_BOT", + "allowFrom": ["VOTRE_USER_ID"] + } + } +} +``` + +**5. Inviter le bot** + +* OAuth2 → URL Generator +* Scopes : `bot` +* Permissions du Bot : `Send Messages`, `Read Message History` +* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur + +**6. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Créer un bot** + +- Rendez-vous sur la [QQ Open Platform](https://q.qq.com/#) +- Créez une application → Obtenez l'**AppID** et l'**AppSecret** + +**2. Configurer** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "VOTRE_APP_ID", + "app_secret": "VOTRE_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Créer un bot** + +* Rendez-vous sur la [Open Platform](https://open.dingtalk.com/) +* Créez une application interne +* Copiez le Client ID et le Client Secret + +**2. Configurer** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "VOTRE_CLIENT_ID", + "client_secret": "VOTRE_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants pour restreindre l'accès. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Créer un Compte Officiel LINE** + +- Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/) +- Créez un provider → Créez un canal Messaging API +- Copiez le **Channel Secret** et le **Channel Access Token** + +**2. Configurer** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "VOTRE_CHANNEL_SECRET", + "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Configurer l'URL du Webhook** + +LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : + +```bash +# Exemple avec ngrok +ngrok http 18791 +``` + +Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**. + +**4. Lancer** + +```bash +picoclaw gateway +``` + +> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original. + +> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook. + +
+ +## ClawdChat Rejoignez le Réseau Social d'Agents + +Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée. + +**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Configuration + +Fichier de configuration : `~/.picoclaw/config.json` + +### Structure du Workspace + +PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessions de conversation et historique +├── memory/ # Mémoire à long terme (MEMORY.md) +├── state/ # État persistant (dernier canal, etc.) +├── cron/ # Base de données des tâches planifiées +├── skills/ # Compétences personnalisées +├── AGENTS.md # Guide de comportement de l'Agent +├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) +├── IDENTITY.md # Identité de l'Agent +├── SOUL.md # Âme de l'Agent +├── TOOLS.md # Description des outils +└── USER.md # Préférences utilisateur +``` + +### 🔒 Bac à Sable de Sécurité + +PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes qu'au sein du workspace configuré. + +#### Configuration par Défaut + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Par défaut | Description | +|--------|------------|-------------| +| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent | +| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace | + +#### Outils Protégés + +Lorsque `restrict_to_workspace: true`, les outils suivants sont restreints au bac à sable : + +| Outil | Fonction | Restriction | +|-------|----------|-------------| +| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace | +| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace | +| `list_dir` | Lister des répertoires | Uniquement les répertoires dans le workspace | +| `edit_file` | Éditer des fichiers | Uniquement les fichiers dans le workspace | +| `append_file` | Ajouter à des fichiers | Uniquement les fichiers dans le workspace | +| `exec` | Exécuter des commandes | Les chemins doivent être dans le workspace | + +#### Protection Supplémentaire d'Exec + +Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses : + +* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse +* `format`, `mkfs`, `diskpart` — Formatage de disque +* `dd if=` — Écriture d'image disque +* Écriture vers `/dev/sd[a-z]` — Écriture directe sur le disque +* `shutdown`, `reboot`, `poweroff` — Arrêt du système +* Fork bomb `:(){ :|:& };:` + +#### Exemples d'Erreurs + +``` +[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)} +``` + +#### Désactiver les Restrictions (Risque de Sécurité) + +Si vous avez besoin que l'agent accède à des chemins en dehors du workspace : + +**Méthode 1 : Fichier de configuration** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Méthode 2 : Variable d'environnement** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Attention** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution uniquement dans des environnements contrôlés. + +#### Cohérence du Périmètre de Sécurité + +Le paramètre `restrict_to_workspace` s'applique de manière cohérente sur tous les chemins d'exécution : + +| Chemin d'Exécution | Périmètre de Sécurité | +|--------------------|----------------------| +| Agent Principal | `restrict_to_workspace` ✅ | +| Sous-agent / Spawn | Hérite de la même restriction ✅ | +| Tâches Heartbeat | Hérite de la même restriction ✅ | + +Tous les chemins partagent la même restriction de workspace — il est impossible de contourner le périmètre de sécurité via des sous-agents ou des tâches planifiées. + +### Heartbeat (Tâches Périodiques) + +PicoClaw peut exécuter des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace : + +```markdown +# Tâches Périodiques + +- Vérifier mes e-mails pour les messages importants +- Consulter mon agenda pour les événements à venir +- Vérifier les prévisions météo +``` + +L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera les tâches à l'aide des outils disponibles. + +#### Tâches Asynchrones avec Spawn + +Pour les tâches de longue durée (recherche web, appels API), utilisez l'outil `spawn` pour créer un **sous-agent** : + +```markdown +# Tâches Périodiques + +## Tâches Rapides (réponse directe) +- Indiquer l'heure actuelle + +## Tâches Longues (utiliser spawn pour l'asynchrone) +- Rechercher les actualités IA sur le web et les résumer +- Vérifier les e-mails et signaler les messages importants +``` + +**Comportements clés :** + +| Fonctionnalité | Description | +|----------------|-------------| +| **spawn** | Crée un sous-agent asynchrone, ne bloque pas le heartbeat | +| **Contexte indépendant** | Le sous-agent a son propre contexte, sans historique de session | +| **Outil message** | Le sous-agent communique directement avec l'utilisateur via l'outil message | +| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante | + +#### Fonctionnement de la Communication du Sous-agent + +``` +Le Heartbeat se déclenche + ↓ +L'Agent lit HEARTBEAT.md + ↓ +Pour une tâche longue : spawn d'un sous-agent + ↓ ↓ +Continue la tâche suivante Le sous-agent travaille indépendamment + ↓ ↓ +Toutes les tâches terminées Le sous-agent utilise l'outil "message" + ↓ ↓ +Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement +``` + +Le sous-agent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal. + +**Configuration :** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Par défaut | Description | +|--------|------------|-------------| +| `enabled` | `true` | Activer/désactiver le heartbeat | +| `interval` | `30` | Intervalle de vérification en minutes (min : 5) | + +**Variables d'environnement :** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver +* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour modifier l'intervalle + +### Fournisseurs + +> [!NOTE] +> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits. + +| Fournisseur | Utilisation | Obtenir une Clé API | +| ------------------------ | ---------------------------------------- | ------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Configuration Zhipu + +**1. Obtenir la clé API** + +* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurer** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Votre Clé API", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw agent -m "Bonjour, comment ça va ?" +``` + +
+ +
+Exemple de configuration complète + +```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 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Référence CLI + +| Commande | Description | +| ------------------------- | ------------------------------------- | +| `picoclaw onboard` | Initialiser la configuration & le workspace | +| `picoclaw agent -m "..."` | Discuter avec l'agent | +| `picoclaw agent` | Mode de discussion interactif | +| `picoclaw gateway` | Démarrer la passerelle | +| `picoclaw status` | Afficher le statut | +| `picoclaw cron list` | Lister toutes les tâches planifiées | +| `picoclaw cron add ...` | Ajouter une tâche planifiée | + +### Tâches Planifiées / Rappels + +PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` : + +* **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min +* **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures +* **Expressions Cron** : « Rappelle-moi à 9h tous les jours » → utilise une expression cron + +Les tâches sont stockées dans `~/.picoclaw/workspace/cron/` et traitées automatiquement. + +## 🤝 Contribuer & Feuille de Route + +Les PR sont les bienvenues ! Le code source est volontairement petit et lisible. 🤗 + +Feuille de route à venir... + +Groupe de développeurs en construction. Condition d'entrée : au moins 1 PR fusionnée. + +Groupes d'utilisateurs : + +Discord : + +PicoClaw + +## 🐛 Dépannage + +### La recherche web affiche « API 配置问题 » + +C'est normal si vous n'avez pas encore configuré de clé API de recherche. PicoClaw fournira des liens utiles pour la recherche manuelle. + +Pour activer la recherche web : + +1. **Option 1 (Recommandé)** : Obtenez une clé API gratuite sur [https://brave.com/search/api](https://brave.com/search/api) (2000 requêtes gratuites/mois) pour les meilleurs résultats. +2. **Option 2 (Sans carte bancaire)** : Si vous n'avez pas de clé, le système bascule automatiquement sur **DuckDuckGo** (aucune clé requise). + +Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave : + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "VOTRE_CLE_API_BRAVE", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Erreurs de filtrage de contenu + +Certains fournisseurs (comme Zhipu) disposent d'un filtrage de contenu. Essayez de reformuler votre requête ou utilisez un modèle différent. + +### Le bot Telegram affiche « Conflict: terminated by other getUpdates » + +Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assurez-vous qu'un seul `picoclaw gateway` fonctionne à la fois. + +--- + +## 📝 Comparaison des Clés API + +| Service | Offre Gratuite | Cas d'Utilisation | +| ---------------- | -------------------- | ------------------------------------- | +| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois | +| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web | +| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) | diff --git a/README.ja.md b/README.ja.md index 7da16565f..ff1838b79 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) +[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) diff --git a/README.md b/README.md index d6a3d5696..c292bcd25 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **English** + [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English** --- diff --git a/README.pt-br.md b/README.pt-br.md index fa73465dd..a89854be7 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português** + [中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) --- diff --git a/README.vi.md b/README.vi.md index e629eaa9b..c36be9865 100644 --- a/README.vi.md +++ b/README.vi.md @@ -14,7 +14,7 @@ Twitter

-**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md) --- diff --git a/README.zh.md b/README.zh.md index 42bd20be4..b814c2fe6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ Twitter

- **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) + **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) ---
+ PicoClaw + +

PicoClaw : Assistant IA Ultra-Efficace en Go

+ +

Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+
Website + Twitter +

+ + [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** +