mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-15 06:17:28 +08:00
fix bug
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"clawgo/pkg/bus"
|
||||
"clawgo/pkg/config"
|
||||
@@ -364,7 +365,7 @@ func (al *AgentLoop) runSessionWorker(ctx context.Context, sessionKey string, wo
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
response = al.naturalizeUserFacingText(taskCtx, fmt.Sprintf("处理消息时发生错误:%v", err))
|
||||
response = al.formatProcessingErrorMessage(msg, err)
|
||||
}
|
||||
|
||||
if response != "" && shouldPublishSyntheticResponse(msg) {
|
||||
@@ -386,6 +387,49 @@ func (al *AgentLoop) clearWorkerCancel(worker *sessionWorker) {
|
||||
worker.cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func (al *AgentLoop) formatProcessingErrorMessage(msg bus.InboundMessage, err error) string {
|
||||
if al.preferChineseUserFacingText(msg.SessionKey, msg.Content) {
|
||||
return err.Error()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (al *AgentLoop) preferChineseUserFacingText(sessionKey, currentContent string) bool {
|
||||
zhCount, enCount := countLanguageSignals(currentContent)
|
||||
|
||||
if al != nil && al.sessions != nil && strings.TrimSpace(sessionKey) != "" {
|
||||
history := al.sessions.GetHistory(sessionKey)
|
||||
seenUserTurns := 0
|
||||
for i := len(history) - 1; i >= 0 && seenUserTurns < 6; i-- {
|
||||
if history[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
seenUserTurns++
|
||||
z, e := countLanguageSignals(history[i].Content)
|
||||
zhCount += z
|
||||
enCount += e
|
||||
}
|
||||
}
|
||||
|
||||
if zhCount == 0 && enCount == 0 {
|
||||
return false
|
||||
}
|
||||
return zhCount >= enCount
|
||||
}
|
||||
|
||||
func countLanguageSignals(text string) (zhCount int, enCount int) {
|
||||
for _, r := range text {
|
||||
if unicode.In(r, unicode.Han) {
|
||||
zhCount++
|
||||
continue
|
||||
}
|
||||
if r <= unicode.MaxASCII && unicode.IsLetter(r) {
|
||||
enCount++
|
||||
}
|
||||
}
|
||||
return zhCount, enCount
|
||||
}
|
||||
|
||||
func (al *AgentLoop) removeWorker(sessionKey string, worker *sessionWorker) {
|
||||
al.workersMu.Lock()
|
||||
defer al.workersMu.Unlock()
|
||||
@@ -1547,12 +1591,12 @@ func (al *AgentLoop) callLLMWithModelFallback(
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if !isQuotaOrRateLimitError(err) {
|
||||
if !shouldRetryWithFallbackModel(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if idx < len(candidates)-1 {
|
||||
logger.WarnCF("agent", "Model quota/rate-limit reached, trying fallback model", map[string]interface{}{
|
||||
logger.WarnCF("agent", "Model request failed, trying fallback model", map[string]interface{}{
|
||||
"failed_model": model,
|
||||
"next_model": candidates[idx+1],
|
||||
logger.FieldError: err.Error(),
|
||||
@@ -1610,6 +1654,35 @@ func isQuotaOrRateLimitError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isModelProviderSelectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
msg := strings.ToLower(err.Error())
|
||||
keywords := []string{
|
||||
"unknown provider",
|
||||
"unknown model",
|
||||
"unsupported model",
|
||||
"model not found",
|
||||
"no such model",
|
||||
"invalid model",
|
||||
"does not exist",
|
||||
"not available for model",
|
||||
}
|
||||
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(msg, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldRetryWithFallbackModel(err error) bool {
|
||||
return isQuotaOrRateLimitError(err) || isModelProviderSelectionError(err)
|
||||
}
|
||||
|
||||
func buildProviderToolDefs(toolDefs []map[string]interface{}) ([]providers.ToolDefinition, error) {
|
||||
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
|
||||
for i, td := range toolDefs {
|
||||
@@ -2242,11 +2315,27 @@ func (al *AgentLoop) handleSlashCommand(ctx context.Context, msg bus.InboundMess
|
||||
return true, "", err
|
||||
}
|
||||
path := al.normalizeConfigPathForAgent(fields[2])
|
||||
oldValue, oldExists := al.getMapValueByPathForAgent(cfgMap, path)
|
||||
value := al.parseConfigValueForAgent(strings.Join(fields[3:], " "))
|
||||
if err := al.setMapValueByPathForAgent(cfgMap, path, value); err != nil {
|
||||
return true, "", err
|
||||
}
|
||||
|
||||
modelSwitched := path == "agents.defaults.model" && (!oldExists || strings.TrimSpace(fmt.Sprintf("%v", oldValue)) != strings.TrimSpace(fmt.Sprintf("%v", value)))
|
||||
compactedOnSwitch := false
|
||||
if modelSwitched {
|
||||
if err := al.compactContextBeforeModelSwitch(ctx, msg.SessionKey); err != nil {
|
||||
logger.WarnCF("agent", "Pre-switch context compaction failed", map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
logger.FieldError: err.Error(),
|
||||
})
|
||||
} else {
|
||||
compactedOnSwitch = true
|
||||
}
|
||||
}
|
||||
|
||||
al.applyRuntimeModelConfig(path, value)
|
||||
|
||||
data, err := json.MarshalIndent(cfgMap, "", " ")
|
||||
if err != nil {
|
||||
return true, "", err
|
||||
@@ -2266,8 +2355,14 @@ func (al *AgentLoop) handleSlashCommand(ctx context.Context, msg bus.InboundMess
|
||||
}
|
||||
return true, "", fmt.Errorf("hot reload failed, config rolled back: %w", err)
|
||||
}
|
||||
if modelSwitched && compactedOnSwitch {
|
||||
return true, fmt.Sprintf("Updated %s = %v\nContext compacted before model switch\nHot reload not applied: %v", path, value, err), nil
|
||||
}
|
||||
return true, fmt.Sprintf("Updated %s = %v\nHot reload not applied: %v", path, value, err), nil
|
||||
}
|
||||
if modelSwitched && compactedOnSwitch {
|
||||
return true, fmt.Sprintf("Updated %s = %v\nContext compacted before model switch\nGateway hot reload signal sent", path, value), nil
|
||||
}
|
||||
return true, fmt.Sprintf("Updated %s = %v\nGateway hot reload signal sent", path, value), nil
|
||||
default:
|
||||
return true, "Usage: /config get <path> | /config set <path> <value>", nil
|
||||
@@ -2326,6 +2421,16 @@ func (al *AgentLoop) getConfigPathForCommands() string {
|
||||
if fromEnv := strings.TrimSpace(os.Getenv("CLAWGO_CONFIG")); fromEnv != "" {
|
||||
return fromEnv
|
||||
}
|
||||
args := os.Args
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--config" && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
if strings.HasPrefix(arg, "--config=") {
|
||||
return strings.TrimPrefix(arg, "--config=")
|
||||
}
|
||||
}
|
||||
return filepath.Join(config.GetConfigDir(), "config.json")
|
||||
}
|
||||
|
||||
@@ -2407,6 +2512,113 @@ func (al *AgentLoop) triggerGatewayReloadFromAgent() (bool, error) {
|
||||
return configops.TriggerGatewayReload(al.getConfigPathForCommands(), errGatewayNotRunningSlash)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) compactContextBeforeModelSwitch(ctx context.Context, sessionKey string) error {
|
||||
if strings.TrimSpace(sessionKey) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
history := al.sessions.GetHistory(sessionKey)
|
||||
if len(history) <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keepRecent := al.compactionCfg.KeepRecentMessages
|
||||
if keepRecent <= 0 {
|
||||
keepRecent = 20
|
||||
}
|
||||
if keepRecent >= len(history) {
|
||||
keepRecent = len(history) / 2
|
||||
}
|
||||
if keepRecent <= 0 || keepRecent >= len(history) {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxSummaryChars := al.compactionCfg.MaxSummaryChars
|
||||
if maxSummaryChars <= 0 {
|
||||
maxSummaryChars = 6000
|
||||
}
|
||||
maxTranscriptChars := al.compactionCfg.MaxTranscriptChars
|
||||
if maxTranscriptChars <= 0 {
|
||||
maxTranscriptChars = 24000
|
||||
}
|
||||
|
||||
compactUntil := len(history) - keepRecent
|
||||
if compactUntil <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentSummary := al.sessions.GetSummary(sessionKey)
|
||||
compactCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
newSummary, err := al.buildCompactedSummary(compactCtx, currentSummary, history[:compactUntil], maxTranscriptChars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newSummary = strings.TrimSpace(newSummary)
|
||||
if newSummary == "" {
|
||||
return nil
|
||||
}
|
||||
if len(newSummary) > maxSummaryChars {
|
||||
newSummary = truncateString(newSummary, maxSummaryChars)
|
||||
}
|
||||
|
||||
before, after, err := al.sessions.CompactHistory(sessionKey, newSummary, keepRecent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.InfoCF("agent", "Context compacted before model switch", map[string]interface{}{
|
||||
"session_key": sessionKey,
|
||||
"before_messages": before,
|
||||
"after_messages": after,
|
||||
"kept_recent": keepRecent,
|
||||
"summary_chars": len(newSummary),
|
||||
"model_after": al.model,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) applyRuntimeModelConfig(path string, value interface{}) {
|
||||
switch path {
|
||||
case "agents.defaults.model":
|
||||
newModel := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if newModel == "" {
|
||||
return
|
||||
}
|
||||
al.model = newModel
|
||||
case "agents.defaults.model_fallbacks":
|
||||
al.modelFallbacks = parseModelFallbacks(value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseModelFallbacks(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
s := strings.TrimSpace(item)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
s := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
s := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{s}
|
||||
}
|
||||
}
|
||||
|
||||
// truncateString truncates a string to max length
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
|
||||
54
pkg/agent/loop_config_path_test.go
Normal file
54
pkg/agent/loop_config_path_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"clawgo/pkg/config"
|
||||
)
|
||||
|
||||
func TestGetConfigPathForCommands_FromArgs(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
oldEnv, hadEnv := os.LookupEnv("CLAWGO_CONFIG")
|
||||
t.Cleanup(func() {
|
||||
os.Args = oldArgs
|
||||
if hadEnv {
|
||||
_ = os.Setenv("CLAWGO_CONFIG", oldEnv)
|
||||
} else {
|
||||
_ = os.Unsetenv("CLAWGO_CONFIG")
|
||||
}
|
||||
})
|
||||
|
||||
_ = os.Unsetenv("CLAWGO_CONFIG")
|
||||
os.Args = []string{"clawgo", "gateway", "run", "--config", "/tmp/custom-config.json"}
|
||||
|
||||
al := &AgentLoop{}
|
||||
got := al.getConfigPathForCommands()
|
||||
if got != "/tmp/custom-config.json" {
|
||||
t.Fatalf("expected config path from args, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigPathForCommands_Default(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
oldEnv, hadEnv := os.LookupEnv("CLAWGO_CONFIG")
|
||||
t.Cleanup(func() {
|
||||
os.Args = oldArgs
|
||||
if hadEnv {
|
||||
_ = os.Setenv("CLAWGO_CONFIG", oldEnv)
|
||||
} else {
|
||||
_ = os.Unsetenv("CLAWGO_CONFIG")
|
||||
}
|
||||
})
|
||||
|
||||
_ = os.Unsetenv("CLAWGO_CONFIG")
|
||||
os.Args = []string{"clawgo", "gateway", "run"}
|
||||
|
||||
al := &AgentLoop{}
|
||||
got := al.getConfigPathForCommands()
|
||||
want := filepath.Join(config.GetConfigDir(), "config.json")
|
||||
if got != want {
|
||||
t.Fatalf("expected default config path %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
92
pkg/agent/loop_fallback_test.go
Normal file
92
pkg/agent/loop_fallback_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"clawgo/pkg/providers"
|
||||
)
|
||||
|
||||
type fallbackTestProvider struct {
|
||||
byModel map[string]fallbackResult
|
||||
called []string
|
||||
}
|
||||
|
||||
type fallbackResult struct {
|
||||
resp *providers.LLMResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *fallbackTestProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
p.called = append(p.called, model)
|
||||
if r, ok := p.byModel[model]; ok {
|
||||
return r.resp, r.err
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected model: %s", model)
|
||||
}
|
||||
|
||||
func (p *fallbackTestProvider) GetDefaultModel() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestCallLLMWithModelFallback_RetriesOnUnknownProvider(t *testing.T) {
|
||||
p := &fallbackTestProvider{
|
||||
byModel: map[string]fallbackResult{
|
||||
"gemini-3-flash": {err: fmt.Errorf(`API error (status 502): {"error":{"message":"unknown provider for model gemini-3-flash"}}`)},
|
||||
"gpt-4o-mini": {resp: &providers.LLMResponse{Content: "ok"}},
|
||||
},
|
||||
}
|
||||
|
||||
al := &AgentLoop{
|
||||
provider: p,
|
||||
model: "gemini-3-flash",
|
||||
modelFallbacks: []string{"gpt-4o-mini"},
|
||||
}
|
||||
|
||||
resp, err := al.callLLMWithModelFallback(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Content != "ok" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if len(p.called) != 2 {
|
||||
t.Fatalf("expected 2 model attempts, got %d (%v)", len(p.called), p.called)
|
||||
}
|
||||
if p.called[0] != "gemini-3-flash" || p.called[1] != "gpt-4o-mini" {
|
||||
t.Fatalf("unexpected model order: %v", p.called)
|
||||
}
|
||||
if al.model != "gpt-4o-mini" {
|
||||
t.Fatalf("expected model switch to fallback, got %q", al.model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallLLMWithModelFallback_NoRetryOnNonRetryableError(t *testing.T) {
|
||||
p := &fallbackTestProvider{
|
||||
byModel: map[string]fallbackResult{
|
||||
"gemini-3-flash": {err: fmt.Errorf("API error (status 500): internal server error")},
|
||||
},
|
||||
}
|
||||
|
||||
al := &AgentLoop{
|
||||
provider: p,
|
||||
model: "gemini-3-flash",
|
||||
modelFallbacks: []string{"gpt-4o-mini"},
|
||||
}
|
||||
|
||||
_, err := al.callLLMWithModelFallback(context.Background(), nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if len(p.called) != 1 {
|
||||
t.Fatalf("expected single model attempt, got %d (%v)", len(p.called), p.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryWithFallbackModel_UnknownProviderError(t *testing.T) {
|
||||
err := fmt.Errorf(`API error (status 502): {"error":{"message":"unknown provider for model gemini-3-flash","type":"servererror"}}`)
|
||||
if !shouldRetryWithFallbackModel(err) {
|
||||
t.Fatalf("expected unknown provider error to trigger fallback retry")
|
||||
}
|
||||
}
|
||||
52
pkg/agent/loop_language_test.go
Normal file
52
pkg/agent/loop_language_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clawgo/pkg/bus"
|
||||
"clawgo/pkg/session"
|
||||
)
|
||||
|
||||
func TestFormatProcessingErrorMessage_ChineseCurrentMessage(t *testing.T) {
|
||||
al := &AgentLoop{}
|
||||
msg := bus.InboundMessage{
|
||||
SessionKey: "s-zh-current",
|
||||
Content: "请帮我看一下这个错误",
|
||||
}
|
||||
|
||||
out := al.formatProcessingErrorMessage(msg, errors.New("boom"))
|
||||
if !strings.HasPrefix(out, "处理消息时发生错误:") {
|
||||
t.Fatalf("expected Chinese error prefix, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProcessingErrorMessage_EnglishCurrentMessage(t *testing.T) {
|
||||
al := &AgentLoop{}
|
||||
msg := bus.InboundMessage{
|
||||
SessionKey: "s-en-current",
|
||||
Content: "Please help debug this issue",
|
||||
}
|
||||
|
||||
out := al.formatProcessingErrorMessage(msg, errors.New("boom"))
|
||||
if !strings.HasPrefix(out, "Error processing message:") {
|
||||
t.Fatalf("expected English error prefix, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProcessingErrorMessage_UsesSessionHistoryLanguage(t *testing.T) {
|
||||
sm := session.NewSessionManager(t.TempDir())
|
||||
sm.AddMessage("s-history", "user", "请继续,按这个方向修复")
|
||||
|
||||
al := &AgentLoop{sessions: sm}
|
||||
msg := bus.InboundMessage{
|
||||
SessionKey: "s-history",
|
||||
Content: "ok",
|
||||
}
|
||||
|
||||
out := al.formatProcessingErrorMessage(msg, errors.New("boom"))
|
||||
if !strings.HasPrefix(out, "处理消息时发生错误:") {
|
||||
t.Fatalf("expected Chinese error prefix from session history, got %q", out)
|
||||
}
|
||||
}
|
||||
29
pkg/agent/loop_model_switch_test.go
Normal file
29
pkg/agent/loop_model_switch_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyRuntimeModelConfig_Model(t *testing.T) {
|
||||
al := &AgentLoop{model: "old-model"}
|
||||
al.applyRuntimeModelConfig("agents.defaults.model", "new-model")
|
||||
if al.model != "new-model" {
|
||||
t.Fatalf("expected runtime model updated, got %q", al.model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRuntimeModelConfig_ModelFallbacks(t *testing.T) {
|
||||
al := &AgentLoop{modelFallbacks: []string{"old-fallback"}}
|
||||
al.applyRuntimeModelConfig("agents.defaults.model_fallbacks", []interface{}{"gpt-4o-mini", "", "claude-3-5-sonnet"})
|
||||
if len(al.modelFallbacks) != 2 {
|
||||
t.Fatalf("expected 2 fallbacks, got %d: %v", len(al.modelFallbacks), al.modelFallbacks)
|
||||
}
|
||||
if al.modelFallbacks[0] != "gpt-4o-mini" || al.modelFallbacks[1] != "claude-3-5-sonnet" {
|
||||
t.Fatalf("unexpected fallbacks: %v", al.modelFallbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelFallbacks_StringValue(t *testing.T) {
|
||||
fallbacks := parseModelFallbacks("gpt-4o-mini")
|
||||
if len(fallbacks) != 1 || fallbacks[0] != "gpt-4o-mini" {
|
||||
t.Fatalf("unexpected parse result: %v", fallbacks)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user