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

This commit is contained in:
Lauren ten Hoor
2026-02-11 01:49:14 +08:00
parent b249217bc1
commit 2450181482
8 changed files with 174 additions and 127 deletions

View File

@@ -24,13 +24,15 @@ export function registerCli(program: Command): void {
.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("--qa <model>", `QA engineer model (default: ${DEFAULT_MODELS.qa})`)
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.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.qa) models.qa = opts.qa;
if (opts.reviewer) models.reviewer = opts.reviewer;
if (opts.tester) models.tester = opts.tester;
const result = await runSetup({
newAgentName: opts.newAgent,

View File

@@ -5,8 +5,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { homedir } from "node:os";
import { TIER_MIGRATION } from "./tiers.js";
export type WorkerState = {
active: boolean;
issueId: string | null;
@@ -36,63 +34,13 @@ export type ProjectsData = {
projects: Record<string, Project>;
};
/**
* Migrate old WorkerState schema to current format.
*
* Handles three migrations:
* 1. Old sessionId field → sessions map (pre-sessions era)
* 2. Model-alias session keys → tier-name keys (haiku→junior, sonnet→medior, etc.)
* 3. Old "model" field name → "tier" field name
*/
function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
// Read tier from either "tier" (new) or "model" (old) field
const rawTier = (worker.tier ?? worker.model) as string | null;
// Migration 1: old sessionId field → sessions map
if (!worker.sessions || typeof worker.sessions !== "object") {
const sessionId = worker.sessionId as string | null;
const sessions: Record<string, string | null> = {};
if (sessionId && rawTier) {
const tierKey = TIER_MIGRATION[rawTier] ?? rawTier;
sessions[tierKey] = sessionId;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions,
};
}
// Migration 2: model-alias session keys → tier-name keys
const oldSessions = worker.sessions as Record<string, string | null>;
const needsMigration = Object.keys(oldSessions).some((key) => key in TIER_MIGRATION);
if (needsMigration) {
const newSessions: Record<string, string | null> = {};
for (const [key, value] of Object.entries(oldSessions)) {
const newKey = TIER_MIGRATION[key] ?? key;
newSessions[newKey] = value;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions: newSessions,
};
}
// Migration 3: "model" field → "tier" field (already handled by rawTier above)
function parseWorkerState(worker: Record<string, unknown>): WorkerState {
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions: oldSessions,
tier: worker.tier as string | null,
sessions: (worker.sessions as Record<string, string | null>) ?? {},
};
}
@@ -133,10 +81,10 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
for (const project of Object.values(data.projects)) {
project.dev = project.dev
? migrateWorkerState(project.dev as unknown as Record<string, unknown>)
? parseWorkerState(project.dev as unknown as Record<string, unknown>)
: emptyWorkerState([]);
project.qa = project.qa
? migrateWorkerState(project.qa as unknown as Record<string, unknown>)
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
: emptyWorkerState([]);
if (!project.channel) {
project.channel = "telegram";

View File

@@ -10,7 +10,7 @@ import { createProvider } from "../providers/index.js";
import { selectTier } from "../model-selector.js";
import { getWorker, getSessionForTier, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { ALL_TIERS, type Tier } from "../tiers.js";
import { ALL_TIERS, isDevTier, type Tier } from "../tiers.js";
// ---------------------------------------------------------------------------
// Shared constants + helpers (used by tick, work-start, auto-pickup)
@@ -189,8 +189,10 @@ export async function projectTick(opts: {
function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string {
const labelTier = detectTierFromLabels(issue.labels);
if (labelTier) {
if (role === "qa" && labelTier !== "qa") return "qa";
if (role === "dev" && labelTier === "qa") return selectTier(issue.title, issue.description ?? "", role).tier;
// QA role but label specifies a dev tier → heuristic picks the right QA tier
if (role === "qa" && isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier;
// DEV role but label specifies a QA tier → heuristic picks the right dev tier
if (role === "dev" && !isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier;
return labelTier;
}
return selectTier(issue.title, issue.description ?? "", role).tier;

View File

@@ -6,7 +6,7 @@
*/
export const DEV_TIERS = ["junior", "medior", "senior"] as const;
export const QA_TIERS = ["qa-engineer", "manual-tester"] as const;
export const QA_TIERS = ["reviewer", "tester"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number];
@@ -17,8 +17,8 @@ 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",
"qa-engineer": "anthropic/claude-sonnet-4-5",
"manual-tester": "anthropic/claude-haiku-4-5",
reviewer: "anthropic/claude-sonnet-4-5",
tester: "anthropic/claude-haiku-4-5",
};
/** Default models by role-tier structure. */
@@ -29,8 +29,8 @@ export const DEFAULT_MODELS_BY_ROLE = {
senior: "anthropic/claude-opus-4-5",
},
qa: {
"qa-engineer": "anthropic/claude-sonnet-4-5",
"manual-tester": "anthropic/claude-haiku-4-5",
reviewer: "anthropic/claude-sonnet-4-5",
tester: "anthropic/claude-haiku-4-5",
},
};
@@ -39,8 +39,8 @@ export const TIER_EMOJI: Record<Tier, string> = {
junior: "⚡",
medior: "🔧",
senior: "🧠",
"qa-engineer": "🔍",
"manual-tester": "👀",
reviewer: "🔍",
tester: "👀",
};
/** Check if a string is a valid tier name. */
@@ -83,15 +83,3 @@ export function resolveTierToModel(
return DEFAULT_MODELS[tier as Tier] ?? tier;
}
/**
* Migration map from old model-alias session keys to tier names.
* Used by migrateWorkerState() in projects.ts.
*/
export const TIER_MIGRATION: Record<string, string> = {
haiku: "junior",
sonnet: "medior",
opus: "senior",
grok: "qa-engineer",
qa: "qa-engineer",
};

View File

@@ -18,56 +18,113 @@ export function createSetupTool(api: OpenClawPluginApi) {
parameters: {
type: "object",
properties: {
newAgentName: { type: "string", description: "Create a new agent. Omit to configure current workspace." },
channelBinding: { type: "string", enum: ["telegram", "whatsapp"], description: "Channel to bind (optional, with newAgentName only)." },
migrateFrom: { type: "string", description: "Agent ID to migrate channel binding from. Check openclaw.json bindings first." },
newAgentName: {
type: "string",
description:
"Create a new agent. Omit to configure current workspace.",
},
channelBinding: {
type: "string",
enum: ["telegram", "whatsapp"],
description: "Channel to bind (optional, with newAgentName only).",
},
migrateFrom: {
type: "string",
description:
"Agent ID to migrate channel binding from. Check openclaw.json bindings first.",
},
models: {
type: "object", description: "Model overrides per role and tier.",
type: "object",
description: "Model overrides per role and tier.",
properties: {
dev: {
type: "object", description: "Developer tier models",
type: "object",
description: "Developer tier models",
properties: {
junior: { type: "string", description: `Default: ${DEFAULT_MODELS.junior}` },
medior: { type: "string", description: `Default: ${DEFAULT_MODELS.medior}` },
senior: { type: "string", description: `Default: ${DEFAULT_MODELS.senior}` },
junior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.junior}`,
},
medior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.medior}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.senior}`,
},
},
},
qa: {
type: "object", description: "QA tier models",
type: "object",
description: "QA tier models",
properties: {
"qa-engineer": { type: "string", description: `Default: ${DEFAULT_MODELS["qa-engineer"]}` },
"manual-tester": { type: "string", description: `Default: ${DEFAULT_MODELS["manual-tester"]}` },
reviewer: {
type: "string",
description: `Default: ${DEFAULT_MODELS.reviewer}`,
},
tester: {
type: "string",
description: `Default: ${DEFAULT_MODELS.tester}`,
},
},
},
},
},
projectExecution: { type: "string", enum: ["parallel", "sequential"], description: "Project execution mode. Default: parallel." },
projectExecution: {
type: "string",
enum: ["parallel", "sequential"],
description: "Project execution mode. Default: parallel.",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const result = await runSetup({
newAgentName: params.newAgentName as string | undefined,
channelBinding: (params.channelBinding as "telegram" | "whatsapp") ?? null,
channelBinding:
(params.channelBinding as "telegram" | "whatsapp") ?? null,
migrateFrom: params.migrateFrom as string | undefined,
agentId: params.newAgentName ? undefined : ctx.agentId,
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
models: params.models as Partial<Record<Tier, string>> | undefined,
projectExecution: params.projectExecution as "parallel" | "sequential" | undefined,
projectExecution: params.projectExecution as
| "parallel"
| "sequential"
| undefined,
});
const lines = [
result.agentCreated ? `Agent "${result.agentId}" created` : `Configured "${result.agentId}"`,
result.agentCreated
? `Agent "${result.agentId}" created`
: `Configured "${result.agentId}"`,
"",
];
if (result.bindingMigrated) {
lines.push(`✅ Binding migrated: ${result.bindingMigrated.channel} (${result.bindingMigrated.from}${result.agentId})`, "");
lines.push(
`✅ Binding migrated: ${result.bindingMigrated.channel} (${result.bindingMigrated.from}${result.agentId})`,
"",
);
}
lines.push("Models:", ...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`), "", "Files:", ...result.filesWritten.map((f) => ` ${f}`));
if (result.warnings.length > 0) lines.push("", "Warnings:", ...result.warnings.map((w) => ` ${w}`));
lines.push("", "Next: register a project, then create issues and pick them up.");
lines.push(
"Models:",
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
"",
"Files:",
...result.filesWritten.map((f) => ` ${f}`),
);
if (result.warnings.length > 0)
lines.push("", "Warnings:", ...result.warnings.map((w) => ` ${w}`));
lines.push(
"",
"Next: register a project, then create issues and pick them up.",
);
return jsonResult({ success: true, ...result, summary: lines.join("\n") });
return jsonResult({
success: true,
...result,
summary: lines.join("\n"),
});
},
});
}

View File

@@ -14,6 +14,7 @@ import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { notify, getNotificationConfig } from "../notify.js";
import { findNextIssue, detectRoleFromLabel, detectTierFromLabels } from "../services/tick.js";
import { isDevTier } from "../tiers.js";
import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js";
export function createWorkStartTool(api: OpenClawPluginApi) {
@@ -83,8 +84,8 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
} else {
const labelTier = detectTierFromLabels(issue.labels);
if (labelTier) {
if (role === "qa" && labelTier !== "qa") { selectedTier = "qa"; tierReason = `QA overrides "${labelTier}"`; tierSource = "role-override"; }
else if (role === "dev" && labelTier === "qa") { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; }
if (role === "qa" && isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = `QA overrides dev tier "${labelTier}"`; tierSource = "role-override"; }
else if (role === "dev" && !isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; }
else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; }
} else {
const s = selectTier(issue.title, issue.description ?? "", role);