feat: update role-tier structure to include prefixes for models and enhance related configurations

This commit is contained in:
Lauren ten Hoor
2026-02-11 02:17:13 +08:00
parent 862813e6d3
commit b3c467a33f
9 changed files with 123 additions and 91 deletions

View File

@@ -21,18 +21,18 @@ export function registerCli(program: Command): void {
.option("--new-agent <name>", "Create a new agent with this name")
.option("--agent <id>", "Use an existing agent by ID")
.option("--workspace <path>", "Direct workspace path")
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.junior})`)
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.medior})`)
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.senior})`)
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.tester})`)
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`)
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`)
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`)
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
.action(async (opts) => {
const models: Partial<Record<Tier, string>> = {};
if (opts.junior) models.junior = opts.junior;
if (opts.medior) models.medior = opts.medior;
if (opts.senior) models.senior = opts.senior;
if (opts.reviewer) models.reviewer = opts.reviewer;
if (opts.tester) models.tester = opts.tester;
if (opts.junior) models["dev.junior"] = opts.junior;
if (opts.medior) models["dev.medior"] = opts.medior;
if (opts.senior) models["dev.senior"] = opts.senior;
if (opts.reviewer) models["qa.reviewer"] = opts.reviewer;
if (opts.tester) models["qa.tester"] = opts.tester;
const result = await runSetup({
newAgentName: opts.newAgent,

View File

@@ -15,7 +15,7 @@ import {
getSessionForTier,
getWorker,
} from "./projects.js";
import { TIER_EMOJI, isTier, resolveTierToModel } from "./tiers.js";
import { tierEmoji, resolveTierToModel } from "./tiers.js";
const execFileAsync = promisify(execFile);
@@ -129,7 +129,7 @@ export async function dispatchTask(
transitionLabel, pluginConfig,
} = opts;
const model = resolveTierToModel(tier, pluginConfig, role);
const model = resolveTierToModel(tier, pluginConfig);
const worker = getWorker(project, role);
const existingSessionKey = getSessionForTier(worker, tier);
const sessionAction = existingSessionKey ? "send" : "spawn";
@@ -276,7 +276,7 @@ function buildAnnouncement(
tier: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string,
): string {
const emoji = isTier(tier) ? TIER_EMOJI[tier] : role === "qa" ? "🔍" : "🔧";
const emoji = tierEmoji(tier) ?? (role === "qa" ? "🔍" : "🔧");
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
}

View File

@@ -1,7 +1,7 @@
/**
* Model selection for dev/qa tasks.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a tier.
* Returns tier names (junior, medior, senior, qa) instead of model aliases.
* Returns full tier names (dev.junior, dev.medior, dev.senior, qa.reviewer, qa.tester).
*/
export type TierRecommendation = {
@@ -42,10 +42,11 @@ const COMPLEX_KEYWORDS = [
* Select appropriate developer tier based on task description.
*
* Developer tiers:
* - junior: very simple (typos, single-file fixes, CSS tweaks)
* - medior: standard DEV (features, bug fixes, multi-file changes)
* - senior: deep/architectural (system-wide refactoring, novel design)
* - qa: all QA tasks (code inspection, validation, test runs)
* - dev.junior: very simple (typos, single-file fixes, CSS tweaks)
* - dev.medior: standard DEV (features, bug fixes, multi-file changes)
* - dev.senior: deep/architectural (system-wide refactoring, novel design)
* - qa.reviewer: QA code inspection and validation
* - qa.tester: QA manual testing
*/
export function selectTier(
issueTitle: string,
@@ -54,7 +55,7 @@ export function selectTier(
): TierRecommendation {
if (role === "qa") {
return {
tier: "qa",
tier: "qa.reviewer",
reason: "Default QA tier for code inspection and validation",
};
}
@@ -66,7 +67,7 @@ export function selectTier(
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
if (isSimple && wordCount < 100) {
return {
tier: "junior",
tier: "dev.junior",
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
};
}
@@ -75,14 +76,14 @@ export function selectTier(
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
if (isComplex || wordCount > 500) {
return {
tier: "senior",
tier: "dev.senior",
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
};
}
// Default: medior for standard dev work
return {
tier: "medior",
tier: "dev.medior",
reason: "Standard dev task — multi-file changes, features, bug fixes",
};
}

View File

@@ -5,7 +5,7 @@
*/
import fs from "node:fs/promises";
import path from "node:path";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
import { ALL_TIERS, defaultModel } from "./tiers.js";
// ---------------------------------------------------------------------------
// Detection
@@ -42,7 +42,7 @@ function buildModelTable(pluginConfig?: Record<string, unknown>): string {
(pluginConfig as { models?: Record<string, string> })?.models ?? {};
return ALL_TIERS.map(
(t) =>
` - **${t}**: ${models[t] || DEFAULT_MODELS[t as Tier]} (default: ${DEFAULT_MODELS[t as Tier]})`,
` - **${t}**: ${models[t] || defaultModel(t)} (default: ${defaultModel(t)})`,
).join("\n");
}
@@ -96,10 +96,11 @@ Show the default tier-to-model mapping and ask if they want to customize:
| Tier | Default Model | Purpose |
|------|---------------|---------|
| junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes |
| medior | anthropic/claude-sonnet-4-5 | Features, bug fixes |
| senior | anthropic/claude-opus-4-5 | Architecture, refactoring |
| qa | anthropic/claude-sonnet-4-5 | Code review, testing |
| dev.junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes |
| dev.medior | anthropic/claude-sonnet-4-5 | Features, bug fixes |
| dev.senior | anthropic/claude-opus-4-5 | Architecture, refactoring |
| qa.reviewer | anthropic/claude-sonnet-4-5 | Code review |
| qa.tester | anthropic/claude-haiku-4-5 | Testing |
If the defaults are fine, proceed. If customizing, ask which tiers to change.

View File

@@ -5,7 +5,7 @@
*/
import fs from "node:fs/promises";
import path from "node:path";
import { DEV_TIERS, QA_TIERS, type Tier } from "../tiers.js";
import { DEV_TIERS, QA_TIERS, tierName, type Tier } from "../tiers.js";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
function openclawConfigPath(): string {
@@ -20,10 +20,10 @@ function buildRoleTierModels(models: Record<Tier, string>): { dev: Record<string
const qa: Record<string, string> = {};
for (const tier of DEV_TIERS) {
dev[tier] = models[tier];
dev[tierName(tier)] = models[tier];
}
for (const tier of QA_TIERS) {
qa[tier] = models[tier];
qa[tierName(tier)] = models[tier];
}
return { dev, qa };

View File

@@ -4,7 +4,7 @@
* Coordinates: agent creation → model config → workspace scaffolding.
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
import { ALL_TIERS, allDefaultModels, type Tier } from "../tiers.js";
import { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js";
@@ -108,7 +108,7 @@ async function tryMigrateBinding(
}
function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> {
const models = { ...DEFAULT_MODELS };
const models = allDefaultModels();
if (overrides) {
for (const [tier, model] of Object.entries(overrides)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {

View File

@@ -1,28 +1,20 @@
/**
* Developer tier definitions and model resolution.
*
* Tasks are assigned to developer tiers (junior, medior, senior, qa)
* instead of raw model names. Each tier maps to a configurable LLM model.
* Tier names always include the role prefix: "dev.junior", "qa.reviewer", etc.
* This makes tier names globally unique and self-documenting.
*/
export const DEV_TIERS = ["junior", "medior", "senior"] as const;
export const QA_TIERS = ["reviewer", "tester"] as const;
export const DEV_TIERS = ["dev.junior", "dev.medior", "dev.senior"] as const;
export const QA_TIERS = ["qa.reviewer", "qa.tester"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number];
export type QaTier = (typeof QA_TIERS)[number];
export type Tier = (typeof ALL_TIERS)[number];
export const DEFAULT_MODELS: Record<Tier, string> = {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
reviewer: "anthropic/claude-sonnet-4-5",
tester: "anthropic/claude-haiku-4-5",
};
/** Default models by role-tier structure. */
export const DEFAULT_MODELS_BY_ROLE = {
/** Default models, nested by role. */
export const DEFAULT_MODELS = {
dev: {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
@@ -34,13 +26,17 @@ export const DEFAULT_MODELS_BY_ROLE = {
},
};
/** Emoji used in announcements per tier. */
export const TIER_EMOJI: Record<Tier, string> = {
junior: "⚡",
medior: "🔧",
senior: "🧠",
reviewer: "🔍",
tester: "👀",
/** Emoji used in announcements, nested by role. */
export const TIER_EMOJI = {
dev: {
junior: "",
medior: "🔧",
senior: "🧠",
},
qa: {
reviewer: "🔍",
tester: "👀",
},
};
/** Check if a string is a valid tier name. */
@@ -48,38 +44,72 @@ export function isTier(value: string): value is Tier {
return (ALL_TIERS as readonly string[]).includes(value);
}
/** Check if a string is a valid dev tier name. */
/** Check if a tier belongs to the dev role. */
export function isDevTier(value: string): value is DevTier {
return (DEV_TIERS as readonly string[]).includes(value);
}
/** Extract the role from a tier name (e.g. "dev.junior" → "dev"). */
export function tierRole(tier: string): "dev" | "qa" | undefined {
if (tier.startsWith("dev.")) return "dev";
if (tier.startsWith("qa.")) return "qa";
return undefined;
}
/** Extract the short name from a tier (e.g. "dev.junior" → "junior"). */
export function tierName(tier: string): string {
const dot = tier.indexOf(".");
return dot >= 0 ? tier.slice(dot + 1) : tier;
}
/** Look up a value from a nested role structure using a full tier name. */
function lookupNested<T>(map: Record<string, Record<string, T>>, tier: string): T | undefined {
const role = tierRole(tier);
if (!role) return undefined;
return map[role]?.[tierName(tier)];
}
/** Get the default model for a tier. */
export function defaultModel(tier: string): string | undefined {
return lookupNested(DEFAULT_MODELS, tier);
}
/** Get the emoji for a tier. */
export function tierEmoji(tier: string): string | undefined {
return lookupNested(TIER_EMOJI, tier);
}
/** Build a flat Record<Tier, string> of all default models. */
export function allDefaultModels(): Record<Tier, string> {
const result = {} as Record<Tier, string>;
for (const tier of ALL_TIERS) {
result[tier] = defaultModel(tier)!;
}
return result;
}
/**
* Resolve a tier name to a full model ID.
*
* Resolution order:
* 1. Plugin config `models.<role>.<tier>` nested structure (user overrides)
* 2. Plugin config `models.<tier>` flat structure (backward compatibility)
* 3. DEFAULT_MODELS (hardcoded defaults)
* 4. Treat input as raw model ID (passthrough for non-tier values)
* 1. Parse "role.name" → look up config `models.<role>.<name>`
* 2. DEFAULT_MODELS[role][name]
* 3. Passthrough (treat as raw model ID)
*/
export function resolveTierToModel(
tier: string,
pluginConfig?: Record<string, unknown>,
role?: "dev" | "qa",
): string {
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
// Try nested role-tier structure first
if (role && models && typeof models === "object") {
const roleModels = models[role] as Record<string, string> | undefined;
if (roleModels?.[tier]) return roleModels[tier];
}
// Fall back to flat structure for backward compatibility
if (models && typeof models === "object") {
const flatModel = (models as Record<string, string>)[tier];
if (flatModel) return flatModel;
const role = tierRole(tier);
const name = tierName(tier);
if (role) {
const roleModels = models[role] as Record<string, string> | undefined;
if (roleModels?.[name]) return roleModels[name];
}
}
return DEFAULT_MODELS[tier as Tier] ?? tier;
return defaultModel(tier) ?? tier;
}

View File

@@ -43,15 +43,15 @@ export function createSetupTool(api: OpenClawPluginApi) {
properties: {
junior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.junior}`,
description: `Default: ${DEFAULT_MODELS.dev.junior}`,
},
medior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.medior}`,
description: `Default: ${DEFAULT_MODELS.dev.medior}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.senior}`,
description: `Default: ${DEFAULT_MODELS.dev.senior}`,
},
},
},
@@ -61,11 +61,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
properties: {
reviewer: {
type: "string",
description: `Default: ${DEFAULT_MODELS.reviewer}`,
description: `Default: ${DEFAULT_MODELS.qa.reviewer}`,
},
tester: {
type: "string",
description: `Default: ${DEFAULT_MODELS.tester}`,
description: `Default: ${DEFAULT_MODELS.qa.tester}`,
},
},
},

View File

@@ -26,13 +26,13 @@ const INACTIVE_WORKER: WorkerState = {
};
const ACTIVE_DEV: WorkerState = {
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "medior",
sessions: { medior: "session-dev-42" },
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "dev.medior",
sessions: { "dev.medior": "session-dev-42" },
};
const ACTIVE_QA: WorkerState = {
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa",
sessions: { qa: "session-qa-42" },
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa.reviewer",
sessions: { "qa.reviewer": "session-qa-42" },
};
function makeProject(overrides: Partial<Project> = {}): Project {
@@ -67,10 +67,10 @@ let tmpDir: string;
async function setupWorkspace(projects: Record<string, Project>): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const memoryDir = path.join(tmpDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
const projectsDir = path.join(tmpDir, "projects");
await fs.mkdir(projectsDir, { recursive: true });
await fs.writeFile(
path.join(memoryDir, "projects.json"),
path.join(projectsDir, "projects.json"),
JSON.stringify({ projects }, null, 2) + "\n",
"utf-8",
);
@@ -284,13 +284,13 @@ describe("work_heartbeat: tier assignment", () => {
afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); });
it("uses label-based tier when present", async () => {
// Given: issue with "senior" label → tier should be "senior"
// Given: issue with "dev.senior" label → tier should be "dev.senior"
const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
});
const provider = mockProvider({
"To Do": [{ iid: 10, title: "Refactor auth", description: "", labels: ["To Do", "senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }],
"To Do": [{ iid: 10, title: "Refactor auth", description: "", labels: ["To Do", "dev.senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }],
});
const result = await projectTick({
@@ -299,18 +299,18 @@ describe("work_heartbeat: tier assignment", () => {
const pickup = result.pickups.find((p) => p.role === "dev");
assert.ok(pickup);
assert.strictEqual(pickup.tier, "senior", "Should use label-based tier");
assert.strictEqual(pickup.tier, "dev.senior", "Should use label-based tier");
});
it("overrides to qa tier for qa role regardless of label", async () => {
// Given: issue with "senior" label but picked up by QA
// Expected: tier = "qa" (QA always uses qa tier)
it("overrides to reviewer tier for qa role regardless of label", async () => {
// Given: issue with "dev.senior" label but picked up by QA
// Expected: tier = "qa.reviewer" (QA always uses reviewer tier)
const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
});
const provider = mockProvider({
"To Test": [{ iid: 10, title: "Review auth", description: "", labels: ["To Test", "senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }],
"To Test": [{ iid: 10, title: "Review auth", description: "", labels: ["To Test", "dev.senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }],
});
const result = await projectTick({
@@ -319,7 +319,7 @@ describe("work_heartbeat: tier assignment", () => {
const qaPickup = result.pickups.find((p) => p.role === "qa");
assert.ok(qaPickup);
assert.strictEqual(qaPickup.tier, "qa", "QA always uses qa tier regardless of issue label");
assert.strictEqual(qaPickup.tier, "qa.reviewer", "QA always uses reviewer tier regardless of issue label");
});
it("falls back to heuristic when no tier label", async () => {
@@ -339,7 +339,7 @@ describe("work_heartbeat: tier assignment", () => {
const pickup = result.pickups.find((p) => p.role === "dev");
assert.ok(pickup);
// Heuristic should select junior for a typo fix
assert.strictEqual(pickup.tier, "junior", "Heuristic should assign junior for simple typo fix");
assert.strictEqual(pickup.tier, "dev.junior", "Heuristic should assign junior for simple typo fix");
});
});