diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 54a5396e7..474d3401b 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -22,6 +22,7 @@ type AgentInstance struct { Workspace string MaxIterations int ContextWindow int + Temperature float64 Provider providers.LLMProvider Sessions *session.SessionManager ContextBuilder *ContextBuilder @@ -91,6 +92,7 @@ func NewAgentInstance( Workspace: workspace, MaxIterations: maxIter, ContextWindow: defaults.MaxTokens, + Temperature: defaults.Temperature, Provider: provider, Sessions: sessionsManager, ContextBuilder: contextBuilder, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed69712ff..fa833f491 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -286,8 +286,12 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) // 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 + if msg.SessionKey != "" { + // Direct CLI calls should honor explicit session keys. + // Agent-scoped keys are always honored for other channels. + if strings.HasPrefix(msg.SessionKey, "agent:") || msg.Channel == "cli" { + sessionKey = msg.SessionKey + } } logger.InfoCF("agent", "Routed message", @@ -448,6 +452,11 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) { iteration := 0 var finalContent string + llmMaxTokens := agent.ContextWindow + if llmMaxTokens <= 0 { + llmMaxTokens = 8192 + } + llmTemperature := agent.Temperature for iteration < agent.MaxIterations { iteration++ @@ -470,8 +479,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, "model": agent.Model, "messages_count": len(messages), "tools_count": len(providerToolDefs), - "max_tokens": 8192, - "temperature": 0.7, + "max_tokens": llmMaxTokens, + "temperature": llmTemperature, "system_prompt_len": len(messages[0].Content), }) @@ -492,8 +501,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, 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, + "max_tokens": llmMaxTokens, + "temperature": llmTemperature, }) }, ) @@ -508,8 +517,8 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, return fbResult.Response, nil } return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, + "max_tokens": llmMaxTokens, + "temperature": llmTemperature, }) } @@ -526,6 +535,10 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, strings.Contains(errMsg, "context") || strings.Contains(errMsg, "invalidparameter") || strings.Contains(errMsg, "length") + isTransientNetworkError := strings.Contains(errMsg, "eof") || + strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "broken pipe") || + strings.Contains(errMsg, "server closed idle connection") if isContextError && retry < maxRetries { logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{ @@ -550,6 +563,17 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, ) continue } + + if isTransientNetworkError && retry < maxRetries { + backoff := time.Duration(retry+1) * 2 * time.Second + logger.WarnCF("agent", "Transient network error, retrying LLM call", map[string]interface{}{ + "error": err.Error(), + "retry": retry, + "backoff_sec": backoff.Seconds(), + }) + time.Sleep(backoff) + continue + } break } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index f2257973c..7c8823825 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -535,10 +535,12 @@ type failFirstMockProvider struct { currentCall int failError error successResp string + lastOpts map[string]interface{} } func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { m.currentCall++ + m.lastOpts = opts if m.currentCall <= m.failures { return nil, m.failError } @@ -628,3 +630,131 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } + +// TestAgentLoop_TransientEOFRetry verifies retry on transient network EOF errors. +func TestAgentLoop_TransientEOFRetry(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 2048, + Temperature: 0.2, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &failFirstMockProvider{ + failures: 1, + failError: fmt.Errorf("failed to send request: EOF"), + successResp: "Recovered from transient error", + } + + al := NewAgentLoop(cfg, msgBus, provider) + response, err := al.ProcessDirectWithChannel(context.Background(), "Ping", "eof-retry-session", "test", "chat") + if err != nil { + t.Fatalf("Expected success after transient retry, got error: %v", err) + } + if response != "Recovered from transient error" { + t.Errorf("Expected 'Recovered from transient error', got '%s'", response) + } + if provider.currentCall != 2 { + t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall) + } +} + +// TestProcessDirectWithChannel_HonorsCLISessionKey verifies explicit CLI session keys are used. +func TestProcessDirectWithChannel_HonorsCLISessionKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1024, + MaxToolIterations: 5, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + sessionKey := "my-cli-session" + _, err = al.ProcessDirectWithChannel(context.Background(), "hello", sessionKey, "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) == 0 { + t.Fatalf("Expected history under explicit CLI session key %q, got none", sessionKey) + } +} + +// TestAgentLoop_UsesConfiguredLLMOptions verifies max_tokens and temperature are sourced from config. +func TestAgentLoop_UsesConfiguredLLMOptions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1536, + Temperature: 0.25, + MaxToolIterations: 5, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &failFirstMockProvider{ + successResp: "ok", + } + + al := NewAgentLoop(cfg, msgBus, provider) + _, err = al.ProcessDirectWithChannel(context.Background(), "hello", "opts-session", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + gotMax, ok := provider.lastOpts["max_tokens"].(int) + if !ok { + t.Fatalf("Expected max_tokens int option, got %#v", provider.lastOpts["max_tokens"]) + } + if gotMax != 1536 { + t.Fatalf("max_tokens = %d, want 1536", gotMax) + } + + gotTemp, ok := provider.lastOpts["temperature"].(float64) + if !ok { + t.Fatalf("Expected temperature float64 option, got %#v", provider.lastOpts["temperature"]) + } + if gotTemp != 0.25 { + t.Fatalf("temperature = %v, want 0.25", gotTemp) + } +}