From df967fe63c8b4fb6834ad66ae062bec9964890c6 Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Fri, 20 Mar 2026 12:07:00 -0400 Subject: [PATCH] fix(providers): honour request_timeout for CLI providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-cli, codex-cli, and gemini-cli ignored the request_timeout config field entirely — the factory created them without any timeout and the subprocesses ran indefinitely. This is particularly problematic for agentic tasks (e.g. cron jobs) where a runaway Claude Code session could block the agent loop forever. Each CLI provider gains a timeout field and a WithTimeout constructor. When request_timeout > 0 in the model config, Chat() wraps the incoming context with context.WithTimeout before passing it to exec.CommandContext, so the subprocess is killed and an error is returned if the deadline is exceeded. A value of 0 (the default) leaves behaviour unchanged — no timeout is applied. Co-Authored-By: Claude Sonnet 4.6 --- pkg/providers/claude_cli_provider.go | 17 +++++++++++++++++ pkg/providers/codex_cli_provider.go | 17 +++++++++++++++++ pkg/providers/factory_provider.go | 9 +++++++++ pkg/providers/gemini_cli_provider.go | 17 +++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index f514ba7f0..f63b6f00d 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -7,12 +7,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. type ClaudeCliProvider struct { command string workspace string + timeout time.Duration } // NewClaudeCliProvider creates a new Claude CLI provider. @@ -23,10 +25,25 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { } } +// NewClaudeCliProviderWithTimeout creates a new Claude CLI provider with a request timeout. +func NewClaudeCliProviderWithTimeout(workspace string, timeout time.Duration) *ClaudeCliProvider { + return &ClaudeCliProvider{ + command: "claude", + workspace: workspace, + timeout: timeout, + } +} + // Chat implements LLMProvider.Chat by executing the claude CLI. func (p *ClaudeCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildStdinPrompt(messages, tools) args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 13f53ad9e..5cfca37f8 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -8,12 +8,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. type CodexCliProvider struct { command string workspace string + timeout time.Duration } // NewCodexCliProvider creates a new Codex CLI provider. @@ -24,6 +26,15 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider { } } +// NewCodexCliProviderWithTimeout creates a new Codex CLI provider with a request timeout. +func NewCodexCliProviderWithTimeout(workspace string, timeout time.Duration) *CodexCliProvider { + return &CodexCliProvider{ + command: "codex", + workspace: workspace, + timeout: timeout, + } +} + // Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. func (p *CodexCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, @@ -32,6 +43,12 @@ func (p *CodexCliProvider) Chat( return nil, fmt.Errorf("codex command not configured") } + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildPrompt(messages, tools) args := []string{ diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a460dec99..034f8b9c2 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -180,6 +180,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewClaudeCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewClaudeCliProvider(workspace), modelID, nil case "codex-cli", "codexcli": @@ -187,6 +190,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewCodexCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewCodexCliProvider(workspace), modelID, nil case "gemini-cli", "geminicli": @@ -194,6 +200,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewGeminiCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewGeminiCliProvider(workspace), modelID, nil case "github-copilot", "copilot": diff --git a/pkg/providers/gemini_cli_provider.go b/pkg/providers/gemini_cli_provider.go index 0042535f0..2c644f378 100644 --- a/pkg/providers/gemini_cli_provider.go +++ b/pkg/providers/gemini_cli_provider.go @@ -7,12 +7,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess. type GeminiCliProvider struct { command string workspace string + timeout time.Duration } // NewGeminiCliProvider creates a new Gemini CLI provider. @@ -23,10 +25,25 @@ func NewGeminiCliProvider(workspace string) *GeminiCliProvider { } } +// NewGeminiCliProviderWithTimeout creates a new Gemini CLI provider with a request timeout. +func NewGeminiCliProviderWithTimeout(workspace string, timeout time.Duration) *GeminiCliProvider { + return &GeminiCliProvider{ + command: "gemini", + workspace: workspace, + timeout: timeout, + } +} + // Chat implements LLMProvider.Chat by executing the gemini CLI. func (p *GeminiCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildPrompt(messages, tools) // --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input.