From 22f26d5710681db25954a459ff4c03fe0b8b01c5 Mon Sep 17 00:00:00 2001 From: Zi Jie Sng Date: Thu, 19 Feb 2026 11:27:55 +0800 Subject: [PATCH] feat: add reasoning_effort support for Codex provider Allow controlling OpenAI reasoning effort via the agents.defaults.reasoning_effort config field or the PICOCLAW_AGENTS_DEFAULTS_REASONING_EFFORT env var. The value is passed through the options map and applied to the Responses API ReasoningParam when using the Codex provider. Non-Codex providers ignore the option. Valid levels: none, minimal, low, medium, high, xhigh. --- pkg/agent/instance.go | 32 +++++++------ pkg/agent/loop.go | 11 +++-- pkg/config/config.go | 1 + pkg/providers/codex_provider.go | 6 +++ pkg/providers/codex_provider_test.go | 69 ++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 19 deletions(-) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 54a5396e7..acd0f793c 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -28,7 +28,8 @@ type AgentInstance struct { Tools *tools.ToolRegistry Subagents *config.SubagentsConfig SkillsFilter []string - Candidates []providers.FallbackCandidate + Candidates []providers.FallbackCandidate + ReasoningEffort string } // NewAgentInstance creates an agent instance from config. @@ -84,20 +85,21 @@ func NewAgentInstance( 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, + 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, + ReasoningEffort: defaults.ReasoningEffort, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed69712ff..80616f06f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -472,6 +472,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, "tools_count": len(providerToolDefs), "max_tokens": 8192, "temperature": 0.7, + "reasoning_effort": agent.ReasoningEffort, "system_prompt_len": len(messages[0].Content), }) @@ -492,8 +493,9 @@ 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": 8192, + "temperature": 0.7, + "reasoning_effort": agent.ReasoningEffort, }) }, ) @@ -508,8 +510,9 @@ 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": 8192, + "temperature": 0.7, + "reasoning_effort": agent.ReasoningEffort, }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 682996bd6..2652e6ddf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -149,6 +149,7 @@ type AgentDefaults struct { 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"` + ReasoningEffort string `json:"reasoning_effort,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_REASONING_EFFORT"` } type ChannelsConfig struct { diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index e3526cfb5..bb392496f 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -268,6 +268,12 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } + if re, ok := options["reasoning_effort"].(string); ok && re != "" { + params.Reasoning = responses.ReasoningParam{ + Effort: responses.ReasoningEffort(re), + } + } + if len(tools) > 0 || enableWebSearch { params.Tools = translateToolsForCodex(tools, enableWebSearch) } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 92e276165..a16566cec 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -142,6 +142,75 @@ func TestBuildCodexParams_StoreIsFalse(t *testing.T) { } } +func TestBuildCodexParams_NilOptions(t *testing.T) { + // Passing nil options should not panic. + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", nil, false) + if params.Model != "gpt-4o" { + t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") + } + if params.Reasoning.Effort != "" { + t.Errorf("Reasoning.Effort = %q, want empty", params.Reasoning.Effort) + } +} + +func TestBuildCodexParams_MissingReasoningEffortKey(t *testing.T) { + // Options map without reasoning_effort should produce zero-value Reasoning. + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "o3", map[string]interface{}{ + "max_tokens": 4096, + }, false) + if params.Reasoning.Effort != "" { + t.Errorf("Reasoning.Effort = %q, want empty", params.Reasoning.Effort) + } +} + +func TestBuildCodexParams_ReasoningEffort(t *testing.T) { + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "o3", map[string]interface{}{ + "reasoning_effort": "medium", + }, false) + if params.Reasoning.Effort != responses.ReasoningEffortMedium { + t.Errorf("Reasoning.Effort = %q, want %q", params.Reasoning.Effort, responses.ReasoningEffortMedium) + } + + // Verify the JSON serialization includes the reasoning field. + b, err := json.Marshal(params) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + reasoning, ok := raw["reasoning"].(map[string]interface{}) + if !ok { + t.Fatalf("reasoning field missing or wrong type in JSON: %s", string(b)) + } + if reasoning["effort"] != "medium" { + t.Errorf("JSON reasoning.effort = %v, want %q", reasoning["effort"], "medium") + } +} + +func TestBuildCodexParams_ReasoningEffortEmpty(t *testing.T) { + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "o3", map[string]interface{}{ + "reasoning_effort": "", + }, false) + if params.Reasoning.Effort != "" { + t.Errorf("Reasoning.Effort = %q, want empty", params.Reasoning.Effort) + } + + // When empty, reasoning should not appear in JSON. + b, err := json.Marshal(params) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if _, ok := raw["reasoning"]; ok { + t.Errorf("reasoning field should be absent in JSON when empty, got: %s", string(b)) + } +} + 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 {