From b3c467a33f52c111d912fd8128990b84d3b97cb7 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Wed, 11 Feb 2026 02:17:13 +0800 Subject: [PATCH] feat: update role-tier structure to include prefixes for models and enhance related configurations --- lib/cli.ts | 20 +++--- lib/dispatch.ts | 6 +- lib/model-selector.ts | 19 +++--- lib/onboarding.ts | 13 ++-- lib/setup/config.ts | 6 +- lib/setup/index.ts | 4 +- lib/tiers.ts | 104 ++++++++++++++++++++----------- lib/tools/setup.ts | 10 +-- lib/tools/work-heartbeat.test.ts | 32 +++++----- 9 files changed, 123 insertions(+), 91 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index 5ab49e4..d9c4746 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -21,18 +21,18 @@ export function registerCli(program: Command): void { .option("--new-agent ", "Create a new agent with this name") .option("--agent ", "Use an existing agent by ID") .option("--workspace ", "Direct workspace path") - .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.junior})`) - .option("--medior ", `Medior dev model (default: ${DEFAULT_MODELS.medior})`) - .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.senior})`) - .option("--reviewer ", `Reviewer model (default: ${DEFAULT_MODELS.reviewer})`) - .option("--tester ", `Tester model (default: ${DEFAULT_MODELS.tester})`) + .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`) + .option("--medior ", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`) + .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`) + .option("--reviewer ", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`) + .option("--tester ", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`) .action(async (opts) => { const models: Partial> = {}; - 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, diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 90678b7..6d2d2b1 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -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}`; } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index 012f1e2..905a58a 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -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", }; } diff --git a/lib/onboarding.ts b/lib/onboarding.ts index e8baa47..1ce28b4 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -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 { (pluginConfig as { models?: Record })?.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. diff --git a/lib/setup/config.ts b/lib/setup/config.ts index 668e579..0cc7949 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -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): { dev: Record = {}; 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 }; diff --git a/lib/setup/index.ts b/lib/setup/index.ts index a174ede..9fac6d5 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -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 { - 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)) { diff --git a/lib/tiers.ts b/lib/tiers.ts index 32613b7..2e2eb81 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -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 = { - 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 = { - 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(map: Record>, 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 of all default models. */ +export function allDefaultModels(): Record { + const result = {} as Record; + 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..` nested structure (user overrides) - * 2. Plugin config `models.` 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..` + * 2. DEFAULT_MODELS[role][name] + * 3. Passthrough (treat as raw model ID) */ export function resolveTierToModel( tier: string, pluginConfig?: Record, - role?: "dev" | "qa", ): string { const models = (pluginConfig as { models?: Record })?.models; - // Try nested role-tier structure first - if (role && models && typeof models === "object") { - const roleModels = models[role] as Record | 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)[tier]; - if (flatModel) return flatModel; + const role = tierRole(tier); + const name = tierName(tier); + if (role) { + const roleModels = models[role] as Record | undefined; + if (roleModels?.[name]) return roleModels[name]; + } } - return DEFAULT_MODELS[tier as Tier] ?? tier; + return defaultModel(tier) ?? tier; } diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index eab0d4d..e5287be 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -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}`, }, }, }, diff --git a/lib/tools/work-heartbeat.test.ts b/lib/tools/work-heartbeat.test.ts index 17e9820..01b7776 100644 --- a/lib/tools/work-heartbeat.test.ts +++ b/lib/tools/work-heartbeat.test.ts @@ -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 { @@ -67,10 +67,10 @@ let tmpDir: string; async function setupWorkspace(projects: Record): Promise { 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"); }); });