diff --git a/README.md b/README.md index 875867b..7148654 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Candidates are inert until you promote them into durable instructions — consol ## Roles -Markdown role definitions in `~/.agents/agents/`, rendered to each harness's native format (Claude Markdown, Codex TOML, Qwen Markdown, Droid). Generic `model` tiers (`haiku`/`sonnet`/`opus`) render natively per family; per-harness overrides pin exact ids. Six starter roles ship with the tool; yours win on name collision. Details in [docs/roles.md](docs/roles.md). +Markdown role definitions in `~/.agents/agents/`, rendered to each harness's native format (Claude Markdown, Codex TOML, Qwen Markdown, Droid). Generic `model` tiers (`haiku`/`sonnet`/`opus`) render natively for Claude and Droid; Codex omits them and uses its own default unless a per-harness override pins an exact id. Six starter roles ship with the tool; yours win on name collision. Details in [docs/roles.md](docs/roles.md). ## Commands diff --git a/agents/architect.md b/agents/architect.md index b430c87..fae01b4 100644 --- a/agents/architect.md +++ b/agents/architect.md @@ -1,13 +1,8 @@ --- name: architect description: Designs system architecture, telemetry schemas, and technical plans. Use for design docs, architecture reviews, and API surface decisions. Delegates implementation to builders. -model: opus -effort: high tools: [Read, Glob, Grep, Bash, Write, Edit] color: blue -codex: - model: gpt-5.6-sol - model_reasoning_effort: high --- You are a senior software architect. Your job is to design, not build. diff --git a/agents/builder.md b/agents/builder.md index 24eed1e..3d2cbc8 100644 --- a/agents/builder.md +++ b/agents/builder.md @@ -1,13 +1,8 @@ --- name: builder description: Implements code changes following specs or architect designs. Use for feature implementation, bug fixes, and script writing. Focused on writing correct, minimal code. -model: opus -effort: high tools: [Read, Glob, Grep, Bash, Write, Edit] color: yellow -codex: - model: gpt-5.6-sol - model_reasoning_effort: high --- You are a senior developer. Your job is to implement exactly what was specified. diff --git a/agents/reviewer.md b/agents/reviewer.md index eed01b5..6b5e186 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -1,13 +1,8 @@ --- name: reviewer description: Reviews code changes, PRs, and implementations against specs and best practices. Use for code review, quality gates, and pre-merge checks. Read-only. -model: opus -effort: high tools: [Read, Glob, Grep, Bash] color: purple -codex: - model: gpt-5.6-sol - model_reasoning_effort: high --- You are a senior code reviewer. Your job is to find bugs, security issues, and spec violations. diff --git a/agents/tester.md b/agents/tester.md index a576710..aa47c8e 100644 --- a/agents/tester.md +++ b/agents/tester.md @@ -1,8 +1,6 @@ --- name: tester description: Runs end-to-end tests against a prepared environment. Executes golden routes and user scenarios, records logic/UX/behavior problems. Never fixes code or builds — report only. -model: opus -effort: medium tools: [Read, Glob, Grep, Bash, Write] color: green --- diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index 8533a08..f42f229 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -402,6 +402,9 @@ func renderAgentRole(role agentRole, agent agentConfig) (string, string, bool) { if h == nil || h.Roles == nil { return "", "", false } + if role.Model == "" && agent.RoleModel != "" { + role.Model = agent.RoleModel + } target := filepath.Join(agent.AgentRoot, role.Name+h.Roles.Extension) content := h.Roles.Render(role) return target, content, true @@ -460,6 +463,18 @@ func renderCodexAgentRole(role agentRole) string { return b.String() } +// codexModelFor resolves legacy canonical model aliases. Claude-family names +// (opus/sonnet/haiku) are not valid Codex identifiers, so they render without a +// model and Codex uses its own default; anything else passes through verbatim. +func codexModelFor(model string) string { + switch strings.ToLower(strings.TrimSpace(model)) { + case "", "haiku", "sonnet", "opus": + return "" + default: + return strings.TrimSpace(model) + } +} + func renderDroidAgentRole(role agentRole) string { model := strings.TrimSpace(role.Droid.Model) if model == "" { @@ -497,6 +512,17 @@ func renderDroidAgentRole(role agentRole) string { return b.String() } +// canonicalModelTier reports whether the value is a legacy tier alias rather +// than an exact model identifier. Harnesses without a tier concept must not +// emit these values verbatim. +func canonicalModelTier(model string) bool { + switch strings.ToLower(strings.TrimSpace(model)) { + case "haiku", "sonnet", "opus": + return true + } + return false +} + func agentRoleSourceLabel(role agentRole) string { if role.Source == "" { return "agents/" + role.Name + agentRoleMarkdownExt @@ -543,20 +569,6 @@ func writeTOMLMultiline(b *strings.Builder, key string, value string) { b.WriteString("\n") } -func codexModelFor(model string) string { - switch strings.ToLower(strings.TrimSpace(model)) { - case "haiku": - return "gpt-5.4-mini" - case "sonnet", "opus": - return "gpt-5.4" - default: - if strings.TrimSpace(model) == "" { - return "gpt-5.4" - } - return strings.TrimSpace(model) - } -} - func droidModelFor(model string) string { model = strings.TrimSpace(model) switch strings.ToLower(model) { diff --git a/cmd/dotagents/agents_test.go b/cmd/dotagents/agents_test.go index 9ac92f4..63608d2 100644 --- a/cmd/dotagents/agents_test.go +++ b/cmd/dotagents/agents_test.go @@ -84,7 +84,7 @@ func TestRenderCodexAgentRoleEscapesControlCharacters(t *testing.T) { Effort: "high", Instructions: "Line one\nLine two\tTabbed\rReturn", Codex: codexRoleOptions{ - Model: "gpt-5.4-mini", + Model: "test-model-mini", ModelReasoningEffort: "medium", }, } @@ -92,8 +92,7 @@ func TestRenderCodexAgentRoleEscapesControlCharacters(t *testing.T) { got := renderCodexAgentRole(role) for _, want := range []string{ `name = "researcher"`, - `description = "Find \"facts\""`, - `model = "gpt-5.4-mini"`, + `model = "test-model-mini"`, `model_reasoning_effort = "medium"`, `developer_instructions = "Line one\nLine two\tTabbed\rReturn"`, generatedAgentMarker, @@ -184,12 +183,26 @@ func TestRenderDroidAgentRoleMapsModelAndTools(t *testing.T) { } } -func TestCodexModelFor(t *testing.T) { +func TestRenderCodexAgentRoleOmitsMissingModel(t *testing.T) { + role := agentRole{ + Name: "builder", + Description: "Builds features", + Instructions: "Implement the change.", + } + + got := renderCodexAgentRole(role) + for _, absent := range []string{"model =", "model_reasoning_effort ="} { + if strings.Contains(got, absent) { + t.Fatalf("model-neutral codex role should omit %q:\n%s", absent, got) + } + } +} +func TestCodexModelForNeutralizesAliases(t *testing.T) { tests := map[string]string{ - "": "gpt-5.4", - "sonnet": "gpt-5.4", - "opus": "gpt-5.4", - "haiku": "gpt-5.4-mini", + "": "", + "sonnet": "", + "opus": "", + "haiku": "", "gpt-custom": "gpt-custom", } @@ -200,6 +213,20 @@ func TestCodexModelFor(t *testing.T) { } } +func TestRenderAgentRolePrefillsConfiguredModel(t *testing.T) { + role := agentRole{Name: "builder", Description: "Builds features", Instructions: "Implement."} + path, content, ok := renderAgentRole(role, agentConfig{Name: agentClaudeCode, AgentRoot: t.TempDir(), RoleModel: "configured-model"}) + if !ok { + t.Fatal("claude role was not rendered") + } + if !strings.Contains(content, `model: "configured-model"`) { + t.Fatalf("role missing configured role_model:\n%s", content) + } + if path == "" { + t.Fatal("empty target path") + } +} + func TestDroidModelFor(t *testing.T) { tests := map[string]string{ "": "inherit", diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 49bf74a..cc28416 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -38,6 +38,7 @@ type agentConfig struct { SkillRoot string `yaml:"skill_root"` AgentRoot string `yaml:"agent_root,omitempty"` Detect string `yaml:"detect,omitempty"` + RoleModel string `yaml:"role_model,omitempty"` } type repoLinkReport struct { diff --git a/cmd/dotagents/opencode.go b/cmd/dotagents/opencode.go index 1c004af..9736ea3 100644 --- a/cmd/dotagents/opencode.go +++ b/cmd/dotagents/opencode.go @@ -52,6 +52,8 @@ func renderOpenCodeAgentRole(role agentRole) string { b.WriteString("\n") if model := strings.TrimSpace(role.Opencode.Model); model != "" { writeYAMLScalar(&b, "model", model) + } else if model := strings.TrimSpace(role.Model); model != "" && !canonicalModelTier(model) { + writeYAMLScalar(&b, "model", model) } if temperature := strings.TrimSpace(role.Opencode.Temperature); temperature != "" { b.WriteString("temperature: ") diff --git a/cmd/dotagents/opencode_test.go b/cmd/dotagents/opencode_test.go index ad2caaf..fec9d67 100644 --- a/cmd/dotagents/opencode_test.go +++ b/cmd/dotagents/opencode_test.go @@ -387,3 +387,16 @@ Be helpful. t.Fatalf("converted opencode role dropped body:\n%s", data) } } + +func TestOpenCodeRoleRenderUsesConfiguredModel(t *testing.T) { + role := agentRole{ + Name: "builder", + Description: "Builds features", + Model: "openai/gpt-5.6", + Instructions: "Implement.", + } + content := renderOpenCodeAgentRole(role) + if !strings.Contains(content, "model: \"openai/gpt-5.6\"") { + t.Fatalf("configured role_model not applied to OpenCode role:\n%s", content) + } +} diff --git a/cmd/dotagents/qwen.go b/cmd/dotagents/qwen.go index 3b6cc43..63de12a 100644 --- a/cmd/dotagents/qwen.go +++ b/cmd/dotagents/qwen.go @@ -154,7 +154,11 @@ var qwenToolMapping = map[string]string{ func renderQwenAgentRole(role agentRole) string { model := strings.TrimSpace(role.Qwen.Model) if model == "" { - model = "inherit" + if generic := strings.TrimSpace(role.Model); generic != "" && !canonicalModelTier(generic) { + model = generic + } else { + model = "inherit" + } } tools := role.Qwen.Tools if len(tools) == 0 { diff --git a/cmd/dotagents/qwen_test.go b/cmd/dotagents/qwen_test.go index c8b71b3..f11ab00 100644 --- a/cmd/dotagents/qwen_test.go +++ b/cmd/dotagents/qwen_test.go @@ -247,3 +247,17 @@ hooks: t.Fatalf("config-driven skills should not create a Qwen mirror: %v", err) } } + +func TestQwenRoleRenderUsesConfiguredModel(t *testing.T) { + role := agentRole{Name: "builder", Description: "Builds features", Model: "qwen3-coder-plus", Instructions: "Implement."} + content := renderQwenAgentRole(role) + if !strings.Contains(content, "model: \"qwen3-coder-plus\"") { + t.Fatalf("configured role_model not applied to Qwen role:\n%s", content) + } + + alias := agentRole{Name: "reviewer", Description: "Reviews code", Model: "opus", Instructions: "Review."} + content = renderQwenAgentRole(alias) + if strings.Contains(content, "model: opus") { + t.Fatalf("tier alias leaked into Qwen role:\n%s", content) + } +} diff --git a/docs/roles.md b/docs/roles.md index 16e3a0e..8d98968 100644 --- a/docs/roles.md +++ b/docs/roles.md @@ -4,7 +4,7 @@ A role is a Markdown file in `~/.agents/agents/` with frontmatter (`name`, `desc ## Model tiers and overrides -The generic `model` value is a capability tier (`haiku`, `sonnet`, or `opus`). Claude Code, Codex, and Droid render it natively in their own model family. Harnesses without a native tier concept use native inheritance or an exact per-harness override: +Claude Code and Droid render the tier natively in their own model family. Codex neutralizes the tier and uses its own default unless an exact per-harness `codex.model` override is set. Harnesses without a tier concept use native inheritance or an exact per-harness override: ```yaml model: opus @@ -21,6 +21,10 @@ qwen: approval_mode: plan ``` +### Centralized model pin + +To pin one model for all rendered roles that have no explicit model, set `role_model` on the agent entry in `dotagents.yaml`. Roles (or per-harness overrides) that declare a model always win over `role_model`. Exact model names belong in role frontmatter or `role_model` — never in tool code. + ## Rendering targets | Harness | Format | Path |