feat(codeagent-wrapper): add reasoning effort config for codex backend

- Add --reasoning-effort CLI flag for codex model thinking intensity
- Support reasoning config in ~/.codeagent/models.json per agent
- CLI flag takes precedence over config file
- Only effective for codex backend

Closes #117

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2026-01-13 22:26:58 +08:00
parent 369a3319f9
commit b8b06257ff
6 changed files with 236 additions and 49 deletions

View File

@@ -13,6 +13,7 @@ type AgentModelConfig struct {
PromptFile string `json:"prompt_file,omitempty"`
Description string `json:"description,omitempty"`
Yolo bool `json:"yolo,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
}
type ModelsConfig struct {
@@ -25,15 +26,15 @@ var defaultModelsConfig = ModelsConfig{
DefaultBackend: "opencode",
DefaultModel: "opencode/grok-code",
Agents: map[string]AgentModelConfig{
"sisyphus": {Backend: "claude", Model: "claude-sonnet-4-20250514", PromptFile: "~/.claude/skills/omo/references/sisyphus.md", Description: "Primary orchestrator"},
"oracle": {Backend: "claude", Model: "claude-sonnet-4-20250514", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"},
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250514", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"},
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"},
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"},
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "gemini-3-pro-preview", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"},
"document-writer": {Backend: "gemini", Model: "gemini-3-flash-preview", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"},
},
}
"sisyphus": {Backend: "claude", Model: "claude-sonnet-4-20250514", PromptFile: "~/.claude/skills/omo/references/sisyphus.md", Description: "Primary orchestrator"},
"oracle": {Backend: "claude", Model: "claude-sonnet-4-20250514", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"},
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250514", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"},
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"},
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"},
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"},
"document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"},
},
}
func loadModelsConfig() *ModelsConfig {
home, err := os.UserHomeDir()
@@ -70,10 +71,10 @@ func loadModelsConfig() *ModelsConfig {
return &cfg
}
func resolveAgentConfig(agentName string) (backend, model, promptFile string, yolo bool) {
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning string, yolo bool) {
cfg := loadModelsConfig()
if agent, ok := cfg.Agents[agentName]; ok {
return agent.Backend, agent.Model, agent.PromptFile, agent.Yolo
return agent.Backend, agent.Model, agent.PromptFile, agent.Reasoning, agent.Yolo
}
return cfg.DefaultBackend, cfg.DefaultModel, "", false
return cfg.DefaultBackend, cfg.DefaultModel, "", "", false
}

View File

@@ -19,17 +19,17 @@ func TestResolveAgentConfig_Defaults(t *testing.T) {
wantModel string
wantPromptFile string
}{
{"sisyphus", "claude", "claude-sonnet-4-20250514", "~/.claude/skills/omo/references/sisyphus.md"},
{"oracle", "claude", "claude-sonnet-4-20250514", "~/.claude/skills/omo/references/oracle.md"},
{"librarian", "claude", "claude-sonnet-4-5-20250514", "~/.claude/skills/omo/references/librarian.md"},
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
{"frontend-ui-ux-engineer", "gemini", "gemini-3-pro-preview", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
{"document-writer", "gemini", "gemini-3-flash-preview", "~/.claude/skills/omo/references/document-writer.md"},
}
{"sisyphus", "claude", "claude-sonnet-4-20250514", "~/.claude/skills/omo/references/sisyphus.md"},
{"oracle", "claude", "claude-sonnet-4-20250514", "~/.claude/skills/omo/references/oracle.md"},
{"librarian", "claude", "claude-sonnet-4-5-20250514", "~/.claude/skills/omo/references/librarian.md"},
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
{"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
{"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"},
}
for _, tt := range tests {
t.Run(tt.agent, func(t *testing.T) {
backend, model, promptFile, _ := resolveAgentConfig(tt.agent)
backend, model, promptFile, _, _ := resolveAgentConfig(tt.agent)
if backend != tt.wantBackend {
t.Errorf("backend = %q, want %q", backend, tt.wantBackend)
}
@@ -48,7 +48,7 @@ func TestResolveAgentConfig_UnknownAgent(t *testing.T) {
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
backend, model, promptFile, _ := resolveAgentConfig("unknown-agent")
backend, model, promptFile, _, _ := resolveAgentConfig("unknown-agent")
if backend != "opencode" {
t.Errorf("unknown agent backend = %q, want %q", backend, "opencode")
}

View File

@@ -16,6 +16,7 @@ type Config struct {
SessionID string
WorkDir string
Model string
ReasoningEffort string
ExplicitStdin bool
Timeout int
Backend string
@@ -35,18 +36,19 @@ type ParallelConfig struct {
// TaskSpec describes an individual task entry in the parallel config
type TaskSpec struct {
ID string `json:"id"`
Task string `json:"task"`
WorkDir string `json:"workdir,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
SessionID string `json:"session_id,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
Agent string `json:"agent,omitempty"`
PromptFile string `json:"prompt_file,omitempty"`
Mode string `json:"-"`
UseStdin bool `json:"-"`
Context context.Context `json:"-"`
ID string `json:"id"`
Task string `json:"task"`
WorkDir string `json:"workdir,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
SessionID string `json:"session_id,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Agent string `json:"agent,omitempty"`
PromptFile string `json:"prompt_file,omitempty"`
Mode string `json:"-"`
UseStdin bool `json:"-"`
Context context.Context `json:"-"`
}
// TaskResult captures the execution outcome of a task
@@ -190,6 +192,8 @@ func parseParallelConfig(data []byte) (*ParallelConfig, error) {
task.Backend = value
case "model":
task.Model = value
case "reasoning_effort":
task.ReasoningEffort = value
case "agent":
agentSpecified = true
task.Agent = value
@@ -214,13 +218,16 @@ func parseParallelConfig(data []byte) (*ParallelConfig, error) {
if err := validateAgentName(task.Agent); err != nil {
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
}
backend, model, promptFile, _ := resolveAgentConfig(task.Agent)
backend, model, promptFile, reasoning, _ := resolveAgentConfig(task.Agent)
if task.Backend == "" {
task.Backend = backend
}
if task.Model == "" {
task.Model = model
}
if task.ReasoningEffort == "" {
task.ReasoningEffort = reasoning
}
task.PromptFile = promptFile
}
@@ -257,6 +264,7 @@ func parseArgs() (*Config, error) {
backendName := defaultBackendName
model := ""
reasoningEffort := ""
agentName := ""
promptFile := ""
promptFileExplicit := false
@@ -277,12 +285,15 @@ func parseArgs() (*Config, error) {
if err := validateAgentName(value); err != nil {
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
}
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedYolo := resolveAgentConfig(value)
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, resolvedYolo := resolveAgentConfig(value)
backendName = resolvedBackend
model = resolvedModel
if !promptFileExplicit {
promptFile = resolvedPromptFile
}
if reasoningEffort == "" {
reasoningEffort = resolvedReasoning
}
yolo = resolvedYolo
agentName = value
i++
@@ -295,12 +306,15 @@ func parseArgs() (*Config, error) {
if err := validateAgentName(value); err != nil {
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
}
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedYolo := resolveAgentConfig(value)
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, resolvedYolo := resolveAgentConfig(value)
backendName = resolvedBackend
model = resolvedModel
if !promptFileExplicit {
promptFile = resolvedPromptFile
}
if reasoningEffort == "" {
reasoningEffort = resolvedReasoning
}
yolo = resolvedYolo
agentName = value
continue
@@ -355,6 +369,24 @@ func parseArgs() (*Config, error) {
}
model = value
continue
case arg == "--reasoning-effort":
if i+1 >= len(args) {
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
}
value := strings.TrimSpace(args[i+1])
if value == "" {
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
}
reasoningEffort = value
i++
continue
case strings.HasPrefix(arg, "--reasoning-effort="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
if value == "" {
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
}
reasoningEffort = value
continue
case strings.HasPrefix(arg, "--skip-permissions="):
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
continue
@@ -370,7 +402,7 @@ func parseArgs() (*Config, error) {
}
args = filtered
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, Agent: agentName, PromptFile: promptFile, PromptFileExplicit: promptFileExplicit, SkipPermissions: skipPermissions, Yolo: yolo, Model: strings.TrimSpace(model)}
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, Agent: agentName, PromptFile: promptFile, PromptFileExplicit: promptFileExplicit, SkipPermissions: skipPermissions, Yolo: yolo, Model: strings.TrimSpace(model), ReasoningEffort: strings.TrimSpace(reasoningEffort)}
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
if args[0] == "resume" {

View File

@@ -764,6 +764,10 @@ func buildCodexArgs(cfg *Config, targetArg string) []string {
args = append(args, "--model", model)
}
if reasoningEffort := strings.TrimSpace(cfg.ReasoningEffort); reasoningEffort != "" {
args = append(args, "--reasoning-effort", reasoningEffort)
}
args = append(args, "--skip-git-repo-check")
if isResume {
@@ -804,12 +808,13 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
logger := injectedLogger
cfg := &Config{
Mode: taskSpec.Mode,
Task: taskSpec.Task,
SessionID: taskSpec.SessionID,
WorkDir: taskSpec.WorkDir,
Model: taskSpec.Model,
Backend: defaultBackendName,
Mode: taskSpec.Mode,
Task: taskSpec.Task,
SessionID: taskSpec.SessionID,
WorkDir: taskSpec.WorkDir,
Model: taskSpec.Model,
ReasoningEffort: taskSpec.ReasoningEffort,
Backend: defaultBackendName,
}
commandName := codexCommand
@@ -846,6 +851,12 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
}
}
// Load gemini env from ~/.gemini/.env if exists
var geminiEnv map[string]string
if cfg.Backend == "gemini" {
geminiEnv = loadGeminiEnv()
}
useStdin := taskSpec.UseStdin
targetArg := taskSpec.Task
if useStdin {
@@ -948,6 +959,9 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
if cfg.Backend == "claude" && len(claudeEnv) > 0 {
cmd.SetEnv(claudeEnv)
}
if cfg.Backend == "gemini" && len(geminiEnv) > 0 {
cmd.SetEnv(geminiEnv)
}
// For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir
// Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts

View File

@@ -434,12 +434,13 @@ func run() (exitCode int) {
logInfo(fmt.Sprintf("%s running...", cfg.Backend))
taskSpec := TaskSpec{
Task: taskText,
WorkDir: cfg.WorkDir,
Mode: cfg.Mode,
SessionID: cfg.SessionID,
Model: cfg.Model,
UseStdin: useStdin,
Task: taskText,
WorkDir: cfg.WorkDir,
Mode: cfg.Mode,
SessionID: cfg.SessionID,
Model: cfg.Model,
ReasoningEffort: cfg.ReasoningEffort,
UseStdin: useStdin,
}
result := runTaskFn(taskSpec, false, cfg.Timeout)

View File

@@ -1290,6 +1290,65 @@ func TestBackendParseArgs_ModelFlag(t *testing.T) {
}
}
func TestBackendParseArgs_ReasoningEffortFlag(t *testing.T) {
tests := []struct {
name string
args []string
want string
wantErr bool
}{
{
name: "reasoning-effort flag",
args: []string{"codeagent-wrapper", "--reasoning-effort", "low", "task"},
want: "low",
},
{
name: "reasoning-effort equals syntax",
args: []string{"codeagent-wrapper", "--reasoning-effort=medium", "task"},
want: "medium",
},
{
name: "reasoning-effort trimmed",
args: []string{"codeagent-wrapper", "--reasoning-effort", " high ", "task"},
want: "high",
},
{
name: "reasoning-effort with resume mode",
args: []string{"codeagent-wrapper", "--reasoning-effort", "low", "resume", "sid", "task"},
want: "low",
},
{
name: "missing reasoning-effort value",
args: []string{"codeagent-wrapper", "--reasoning-effort"},
wantErr: true,
},
{
name: "reasoning-effort equals missing value",
args: []string{"codeagent-wrapper", "--reasoning-effort=", "task"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Args = tt.args
cfg, err := parseArgs()
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.ReasoningEffort != tt.want {
t.Fatalf("ReasoningEffort = %q, want %q", cfg.ReasoningEffort, tt.want)
}
})
}
}
func TestBackendParseArgs_PromptFileFlag(t *testing.T) {
tests := []struct {
name string
@@ -1829,6 +1888,28 @@ func TestRun_PromptFilePrefixesTask(t *testing.T) {
})
}
func TestRun_PassesReasoningEffortToTaskSpec(t *testing.T) {
defer resetTestHooks()
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
stdinReader = strings.NewReader("")
isTerminalFn = func() bool { return true }
var got TaskSpec
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
got = task
return TaskResult{ExitCode: 0, Message: "ok"}
}
os.Args = []string{"codeagent-wrapper", "--reasoning-effort", "high", "task"}
if code := run(); code != 0 {
t.Fatalf("run exit = %d, want 0", code)
}
if got.ReasoningEffort != "high" {
t.Fatalf("ReasoningEffort = %q, want %q", got.ReasoningEffort, "high")
}
}
func TestRunBuildCodexArgs_NewMode(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Setenv(key, "false")
@@ -1852,6 +1933,64 @@ func TestRunBuildCodexArgs_NewMode(t *testing.T) {
}
}
func TestRunBuildCodexArgs_NewMode_WithReasoningEffort(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Setenv(key, "false")
cfg := &Config{Mode: "new", WorkDir: "/test/dir", ReasoningEffort: "high"}
args := buildCodexArgs(cfg, "my task")
expected := []string{
"e",
"--reasoning-effort", "high",
"--skip-git-repo-check",
"-C", "/test/dir",
"--json",
"my task",
}
if len(args) != len(expected) {
t.Fatalf("len mismatch")
}
for i := range args {
if args[i] != expected[i] {
t.Fatalf("args[%d]=%s, want %s", i, args[i], expected[i])
}
}
}
func TestRunCodexTaskWithContext_CodexReasoningEffort(t *testing.T) {
defer resetTestHooks()
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
var gotArgs []string
origRunner := newCommandRunner
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
gotArgs = append([]string(nil), args...)
return newFakeCmd(fakeCmdConfig{
PID: 123,
StdoutPlan: []fakeStdoutEvent{
{Data: "{\"type\":\"result\",\"session_id\":\"sid\",\"result\":\"ok\"}\n"},
},
})
}
t.Cleanup(func() { newCommandRunner = origRunner })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "hi", Mode: "new", WorkDir: defaultWorkdir, ReasoningEffort: "high"}, nil, nil, false, true, 5)
if res.ExitCode != 0 || res.Message != "ok" {
t.Fatalf("unexpected result: %+v", res)
}
found := false
for i := 0; i+1 < len(gotArgs); i++ {
if gotArgs[i] == "--reasoning-effort" && gotArgs[i+1] == "high" {
found = true
break
}
}
if !found {
t.Fatalf("expected --reasoning-effort high in args, got %v", gotArgs)
}
}
func TestRunBuildCodexArgs_ResumeMode(t *testing.T) {
const key = "CODEX_BYPASS_SANDBOX"
t.Setenv(key, "false")