refactor: implement dynamic role and level handling with migration support
This commit is contained in:
55
index.ts
55
index.ts
@@ -15,6 +15,28 @@ import { registerCli } from "./lib/cli.js";
|
|||||||
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
||||||
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
||||||
import { initRunCommand } from "./lib/run-command.js";
|
import { initRunCommand } from "./lib/run-command.js";
|
||||||
|
import { ROLE_REGISTRY } from "./lib/roles/index.js";
|
||||||
|
|
||||||
|
/** Build the models config schema dynamically from the role registry. */
|
||||||
|
function buildModelsSchema(): Record<string, unknown> {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
const levelProps: Record<string, unknown> = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
levelProps[level] = { type: "string" };
|
||||||
|
}
|
||||||
|
properties[roleId] = {
|
||||||
|
type: "object",
|
||||||
|
description: `${config.displayName} level models`,
|
||||||
|
properties: levelProps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
description: "Model mapping per role and level",
|
||||||
|
properties,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "devclaw",
|
id: "devclaw",
|
||||||
@@ -24,38 +46,7 @@ const plugin = {
|
|||||||
configSchema: {
|
configSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
models: {
|
models: buildModelsSchema(),
|
||||||
type: "object",
|
|
||||||
description: "Model mapping per role and level",
|
|
||||||
properties: {
|
|
||||||
dev: {
|
|
||||||
type: "object",
|
|
||||||
description: "Developer level models",
|
|
||||||
properties: {
|
|
||||||
junior: { type: "string" },
|
|
||||||
mid: { type: "string" },
|
|
||||||
senior: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
qa: {
|
|
||||||
type: "object",
|
|
||||||
description: "QA level models",
|
|
||||||
properties: {
|
|
||||||
junior: { type: "string" },
|
|
||||||
mid: { type: "string" },
|
|
||||||
senior: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
architect: {
|
|
||||||
type: "object",
|
|
||||||
description: "Architect level models",
|
|
||||||
properties: {
|
|
||||||
junior: { type: "string" },
|
|
||||||
senior: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projectExecution: {
|
projectExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: ["parallel", "sequential"],
|
||||||
|
|||||||
141
lib/migrations.ts
Normal file
141
lib/migrations.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* migrations.ts — Backward-compatibility aliases and migration logic.
|
||||||
|
*
|
||||||
|
* Contains all role/level renaming aliases and projects.json format migration.
|
||||||
|
* This file can be removed once all users have migrated to the new format.
|
||||||
|
*
|
||||||
|
* Migrations handled:
|
||||||
|
* - Role renames: dev → developer, qa → tester
|
||||||
|
* - Level renames: mid → medior, reviewer → medior, tester → junior, opus → senior, sonnet → junior
|
||||||
|
* - projects.json format: old hardcoded dev/qa/architect fields → workers map
|
||||||
|
* - projects.json format: old role keys in workers map → canonical role keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WorkerState, Project } from "./projects.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role aliases — old role IDs → canonical IDs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Maps old role IDs to canonical IDs. */
|
||||||
|
export const ROLE_ALIASES: Record<string, string> = {
|
||||||
|
dev: "developer",
|
||||||
|
qa: "tester",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a role ID, applying aliases for backward compatibility. */
|
||||||
|
export function canonicalRole(role: string): string {
|
||||||
|
return ROLE_ALIASES[role] ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Level aliases — old level names → canonical names, per role
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Maps old level names to canonical names, per role. */
|
||||||
|
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
|
||||||
|
developer: { mid: "medior", medior: "medior" },
|
||||||
|
dev: { mid: "medior", medior: "medior" },
|
||||||
|
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
|
||||||
|
qa: { mid: "medior", reviewer: "medior", tester: "junior" },
|
||||||
|
architect: { opus: "senior", sonnet: "junior" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a level name, applying aliases for backward compatibility. */
|
||||||
|
export function canonicalLevel(role: string, level: string): string {
|
||||||
|
return LEVEL_ALIASES[role]?.[level] ?? level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// projects.json migration helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function migrateLevel(level: string | null, role: string): string | null {
|
||||||
|
if (!level) return null;
|
||||||
|
return LEVEL_ALIASES[role]?.[level] ?? level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateSessions(
|
||||||
|
sessions: Record<string, string | null>,
|
||||||
|
role: string,
|
||||||
|
): Record<string, string | null> {
|
||||||
|
const aliases = LEVEL_ALIASES[role];
|
||||||
|
if (!aliases) return sessions;
|
||||||
|
|
||||||
|
const migrated: Record<string, string | null> = {};
|
||||||
|
for (const [key, value] of Object.entries(sessions)) {
|
||||||
|
const newKey = aliases[key] ?? key;
|
||||||
|
migrated[newKey] = value;
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkerState(worker: Record<string, unknown>, role: string): WorkerState {
|
||||||
|
const level = (worker.level ?? worker.tier ?? null) as string | null;
|
||||||
|
const sessions = (worker.sessions as Record<string, string | null>) ?? {};
|
||||||
|
return {
|
||||||
|
active: worker.active as boolean,
|
||||||
|
issueId: worker.issueId as string | null,
|
||||||
|
startTime: worker.startTime as string | null,
|
||||||
|
level: migrateLevel(level, role),
|
||||||
|
sessions: migrateSessions(sessions, role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty worker state with null sessions for given levels. */
|
||||||
|
function emptyWorkerState(levels: string[]): WorkerState {
|
||||||
|
const sessions: Record<string, string | null> = {};
|
||||||
|
for (const l of levels) {
|
||||||
|
sessions[l] = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
issueId: null,
|
||||||
|
startTime: null,
|
||||||
|
level: null,
|
||||||
|
sessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a raw project object from old format to current format.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* 1. Old format: hardcoded dev/qa/architect fields → workers map
|
||||||
|
* 2. Old role keys in workers map (dev → developer, qa → tester)
|
||||||
|
* 3. Old level names in worker state
|
||||||
|
* 4. Missing channel field defaults to "telegram"
|
||||||
|
*/
|
||||||
|
export function migrateProject(project: Project): void {
|
||||||
|
const raw = project as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
|
||||||
|
// Old format: hardcoded dev/qa/architect fields → workers map
|
||||||
|
project.workers = {};
|
||||||
|
for (const role of ["dev", "qa", "architect"]) {
|
||||||
|
const canonical = ROLE_ALIASES[role] ?? role;
|
||||||
|
project.workers[canonical] = raw[role]
|
||||||
|
? parseWorkerState(raw[role] as Record<string, unknown>, role)
|
||||||
|
: emptyWorkerState([]);
|
||||||
|
}
|
||||||
|
// Clean up old fields from the in-memory object
|
||||||
|
delete raw.dev;
|
||||||
|
delete raw.qa;
|
||||||
|
delete raw.architect;
|
||||||
|
} else if (raw.workers) {
|
||||||
|
// New format: parse each worker with role-aware migration
|
||||||
|
const workers = raw.workers as Record<string, Record<string, unknown>>;
|
||||||
|
project.workers = {};
|
||||||
|
for (const [role, worker] of Object.entries(workers)) {
|
||||||
|
// Migrate old role keys (dev→developer, qa→tester)
|
||||||
|
const canonical = ROLE_ALIASES[role] ?? role;
|
||||||
|
project.workers[canonical] = parseWorkerState(worker, role);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
project.workers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.channel) {
|
||||||
|
project.channel = "telegram";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js";
|
import { migrateProject } from "./migrations.js";
|
||||||
|
|
||||||
export type WorkerState = {
|
export type WorkerState = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
@@ -37,38 +38,6 @@ export type ProjectsData = {
|
|||||||
projects: Record<string, Project>;
|
projects: Record<string, Project>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function migrateLevel(level: string | null, role: string): string | null {
|
|
||||||
if (!level) return null;
|
|
||||||
return LEVEL_ALIASES[role]?.[level] ?? level;
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateSessions(
|
|
||||||
sessions: Record<string, string | null>,
|
|
||||||
role: string,
|
|
||||||
): Record<string, string | null> {
|
|
||||||
const aliases = LEVEL_ALIASES[role];
|
|
||||||
if (!aliases) return sessions;
|
|
||||||
|
|
||||||
const migrated: Record<string, string | null> = {};
|
|
||||||
for (const [key, value] of Object.entries(sessions)) {
|
|
||||||
const newKey = aliases[key] ?? key;
|
|
||||||
migrated[newKey] = value;
|
|
||||||
}
|
|
||||||
return migrated;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorkerState(worker: Record<string, unknown>, role: string): WorkerState {
|
|
||||||
const level = (worker.level ?? worker.tier ?? null) as string | null;
|
|
||||||
const sessions = (worker.sessions as Record<string, string | null>) ?? {};
|
|
||||||
return {
|
|
||||||
active: worker.active as boolean,
|
|
||||||
issueId: worker.issueId as string | null,
|
|
||||||
startTime: worker.startTime as string | null,
|
|
||||||
level: migrateLevel(level, role),
|
|
||||||
sessions: migrateSessions(sessions, role),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a blank WorkerState with null sessions for given level names.
|
* Create a blank WorkerState with null sessions for given level names.
|
||||||
*/
|
*/
|
||||||
@@ -105,36 +74,7 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
|
|||||||
const data = JSON.parse(raw) as ProjectsData;
|
const data = JSON.parse(raw) as ProjectsData;
|
||||||
|
|
||||||
for (const project of Object.values(data.projects)) {
|
for (const project of Object.values(data.projects)) {
|
||||||
// Migrate old format: hardcoded dev/qa/architect fields → workers map
|
migrateProject(project);
|
||||||
const raw = project as unknown as Record<string, unknown>;
|
|
||||||
if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
|
|
||||||
project.workers = {};
|
|
||||||
for (const role of ["dev", "qa", "architect"]) {
|
|
||||||
const canonical = ROLE_ALIASES[role] ?? role;
|
|
||||||
project.workers[canonical] = raw[role]
|
|
||||||
? parseWorkerState(raw[role] as Record<string, unknown>, role)
|
|
||||||
: emptyWorkerState([]);
|
|
||||||
}
|
|
||||||
// Clean up old fields from the in-memory object
|
|
||||||
delete raw.dev;
|
|
||||||
delete raw.qa;
|
|
||||||
delete raw.architect;
|
|
||||||
} else if (raw.workers) {
|
|
||||||
// New format: parse each worker with role-aware migration
|
|
||||||
const workers = raw.workers as Record<string, Record<string, unknown>>;
|
|
||||||
project.workers = {};
|
|
||||||
for (const [role, worker] of Object.entries(workers)) {
|
|
||||||
// Migrate old role keys (dev→developer, qa→tester)
|
|
||||||
const canonical = ROLE_ALIASES[role] ?? role;
|
|
||||||
project.workers[canonical] = parseWorkerState(worker, role);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
project.workers = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project.channel) {
|
|
||||||
project.channel = "telegram";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe("models", () => {
|
|||||||
assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5");
|
assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5");
|
||||||
assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5");
|
assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5");
|
||||||
assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5");
|
assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5");
|
||||||
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
|
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return all default models", () => {
|
it("should return all default models", () => {
|
||||||
@@ -150,7 +150,7 @@ describe("models", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should pass through unknown level as model ID", () => {
|
it("should pass through unknown level as model ID", () => {
|
||||||
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
|
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve old config keys via aliases", () => {
|
it("should resolve old config keys via aliases", () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
|||||||
models: {
|
models: {
|
||||||
junior: "anthropic/claude-haiku-4-5",
|
junior: "anthropic/claude-haiku-4-5",
|
||||||
medior: "anthropic/claude-sonnet-4-5",
|
medior: "anthropic/claude-sonnet-4-5",
|
||||||
senior: "anthropic/claude-opus-4-5",
|
senior: "anthropic/claude-opus-4-6",
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
junior: "⚡",
|
junior: "⚡",
|
||||||
@@ -43,7 +43,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
|||||||
models: {
|
models: {
|
||||||
junior: "anthropic/claude-haiku-4-5",
|
junior: "anthropic/claude-haiku-4-5",
|
||||||
medior: "anthropic/claude-sonnet-4-5",
|
medior: "anthropic/claude-sonnet-4-5",
|
||||||
senior: "anthropic/claude-opus-4-5",
|
senior: "anthropic/claude-opus-4-6",
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
junior: "⚡",
|
junior: "⚡",
|
||||||
@@ -63,7 +63,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
|||||||
defaultLevel: "junior",
|
defaultLevel: "junior",
|
||||||
models: {
|
models: {
|
||||||
junior: "anthropic/claude-sonnet-4-5",
|
junior: "anthropic/claude-sonnet-4-5",
|
||||||
senior: "anthropic/claude-opus-4-5",
|
senior: "anthropic/claude-opus-4-6",
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
junior: "📐",
|
junior: "📐",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { ROLE_REGISTRY } from "./registry.js";
|
import { ROLE_REGISTRY } from "./registry.js";
|
||||||
import type { RoleConfig } from "./types.js";
|
import type { RoleConfig } from "./types.js";
|
||||||
import type { ResolvedRoleConfig } from "../config/types.js";
|
import type { ResolvedRoleConfig } from "../config/types.js";
|
||||||
|
import { ROLE_ALIASES as _ROLE_ALIASES, canonicalLevel as _canonicalLevel } from "../migrations.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Role IDs
|
// Role IDs
|
||||||
@@ -38,37 +39,10 @@ export function requireRole(role: string): RoleConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Role aliases — maps old role IDs to new canonical IDs
|
// Migration aliases — re-exported from lib/migrations.ts for backward compat
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Maps old role IDs to canonical IDs. Used for backward compatibility. */
|
export { ROLE_ALIASES, canonicalRole, LEVEL_ALIASES, canonicalLevel } from "../migrations.js";
|
||||||
export const ROLE_ALIASES: Record<string, string> = {
|
|
||||||
dev: "developer",
|
|
||||||
qa: "tester",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Resolve a role ID, applying aliases for backward compatibility. */
|
|
||||||
export function canonicalRole(role: string): string {
|
|
||||||
return ROLE_ALIASES[role] ?? role;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Level aliases — maps old level names to new canonical names
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Maps old level names to canonical names, per role. Used for backward compatibility. */
|
|
||||||
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
|
|
||||||
developer: { mid: "medior", medior: "medior" },
|
|
||||||
dev: { mid: "medior", medior: "medior" },
|
|
||||||
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
|
|
||||||
qa: { mid: "medior", reviewer: "medior", tester: "junior" },
|
|
||||||
architect: { opus: "senior", sonnet: "junior" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Resolve a level name, applying aliases for backward compatibility. */
|
|
||||||
export function canonicalLevel(role: string, level: string): string {
|
|
||||||
return LEVEL_ALIASES[role]?.[level] ?? level;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Levels
|
// Levels
|
||||||
@@ -135,13 +109,13 @@ export function resolveModel(
|
|||||||
pluginConfig?: Record<string, unknown>,
|
pluginConfig?: Record<string, unknown>,
|
||||||
resolvedRole?: ResolvedRoleConfig,
|
resolvedRole?: ResolvedRoleConfig,
|
||||||
): string {
|
): string {
|
||||||
const canonical = canonicalLevel(role, level);
|
const canonical = _canonicalLevel(role, level);
|
||||||
|
|
||||||
// 1. Plugin config override (openclaw.json) — check canonical role + old aliases
|
// 1. Plugin config override (openclaw.json) — check canonical role + old aliases
|
||||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
||||||
if (models && typeof models === "object") {
|
if (models && typeof models === "object") {
|
||||||
// Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer")
|
// Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer")
|
||||||
const roleModels = (models[role] ?? models[Object.entries(ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record<string, string> | undefined;
|
const roleModels = (models[role] ?? models[Object.entries(_ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record<string, string> | undefined;
|
||||||
if (roleModels?.[canonical]) return roleModels[canonical];
|
if (roleModels?.[canonical]) return roleModels[canonical];
|
||||||
if (roleModels?.[level]) return roleModels[level];
|
if (roleModels?.[level]) return roleModels[level];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,63 @@
|
|||||||
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
|
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
|
||||||
*/
|
*/
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { ROLE_REGISTRY } from "../roles/index.js";
|
||||||
|
import type { ModelAssignment } from "./smart-model-selector.js";
|
||||||
|
|
||||||
export type ModelAssignment = {
|
/**
|
||||||
developer: {
|
* Build a ModelAssignment where every role/level maps to the same model.
|
||||||
junior: string;
|
*/
|
||||||
medior: string;
|
function singleModelAssignment(model: string): ModelAssignment {
|
||||||
senior: string;
|
const result: ModelAssignment = {};
|
||||||
};
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
tester: {
|
result[roleId] = {};
|
||||||
junior: string;
|
for (const level of config.levels) {
|
||||||
medior: string;
|
result[roleId][level] = model;
|
||||||
senior: string;
|
}
|
||||||
};
|
}
|
||||||
architect: {
|
return result;
|
||||||
junior: string;
|
}
|
||||||
senior: string;
|
|
||||||
};
|
/**
|
||||||
};
|
* Build the JSON format example for the LLM prompt, derived from registry.
|
||||||
|
*/
|
||||||
|
function buildJsonExample(): string {
|
||||||
|
const obj: Record<string, Record<string, string>> = {};
|
||||||
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
obj[roleId] = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
obj[roleId][level] = "provider/model-name";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a parsed assignment has all required roles and levels.
|
||||||
|
*/
|
||||||
|
function validateAssignment(assignment: Record<string, unknown>, fallbackModel: string): ModelAssignment | null {
|
||||||
|
const result: ModelAssignment = {};
|
||||||
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
const roleData = assignment[roleId] as Record<string, string> | undefined;
|
||||||
|
if (!roleData) {
|
||||||
|
// Backfill missing roles from the first available role or fallback
|
||||||
|
result[roleId] = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
result[roleId][level] = fallbackModel;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[roleId] = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
if (!roleData[level]) {
|
||||||
|
console.error(`Missing ${roleId}.${level} in LLM assignment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
result[roleId][level] = roleData[level];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use an LLM to intelligently select and assign models to DevClaw roles.
|
* Use an LLM to intelligently select and assign models to DevClaw roles.
|
||||||
@@ -35,16 +75,12 @@ export async function selectModelsWithLLM(
|
|||||||
|
|
||||||
// If only one model, assign it to all roles
|
// If only one model, assign it to all roles
|
||||||
if (availableModels.length === 1) {
|
if (availableModels.length === 1) {
|
||||||
const model = availableModels[0].model;
|
return singleModelAssignment(availableModels[0].model);
|
||||||
return {
|
|
||||||
developer: { junior: model, medior: model, senior: model },
|
|
||||||
tester: { junior: model, medior: model, senior: model },
|
|
||||||
architect: { junior: model, senior: model },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a prompt for the LLM
|
// Create a prompt for the LLM
|
||||||
const modelList = availableModels.map((m) => m.model).join("\n");
|
const modelList = availableModels.map((m) => m.model).join("\n");
|
||||||
|
const jsonExample = buildJsonExample();
|
||||||
|
|
||||||
const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities.
|
const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities.
|
||||||
|
|
||||||
@@ -65,22 +101,7 @@ Rules:
|
|||||||
6. Stable versions (no date) > snapshot versions (with date like 20250514)
|
6. Stable versions (no date) > snapshot versions (with date like 20250514)
|
||||||
|
|
||||||
Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
||||||
{
|
${jsonExample}`;
|
||||||
"developer": {
|
|
||||||
"junior": "provider/model-name",
|
|
||||||
"medior": "provider/model-name",
|
|
||||||
"senior": "provider/model-name"
|
|
||||||
},
|
|
||||||
"tester": {
|
|
||||||
"junior": "provider/model-name",
|
|
||||||
"medior": "provider/model-name",
|
|
||||||
"senior": "provider/model-name"
|
|
||||||
},
|
|
||||||
"architect": {
|
|
||||||
"junior": "provider/model-name",
|
|
||||||
"senior": "provider/model-name"
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionId = sessionKey ?? "devclaw-model-selection";
|
const sessionId = sessionKey ?? "devclaw-model-selection";
|
||||||
@@ -127,28 +148,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
|||||||
// Log what we got for debugging
|
// Log what we got for debugging
|
||||||
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
|
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
|
||||||
|
|
||||||
// Validate the structure
|
// Validate and backfill
|
||||||
// Backfill architect if LLM didn't return it (graceful upgrade)
|
const validated = validateAssignment(assignment, availableModels[0].model);
|
||||||
if (!assignment.architect) {
|
if (!validated) {
|
||||||
assignment.architect = {
|
|
||||||
senior: assignment.developer?.senior ?? availableModels[0].model,
|
|
||||||
junior: assignment.developer?.medior ?? availableModels[0].model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!assignment.developer?.junior ||
|
|
||||||
!assignment.developer?.medior ||
|
|
||||||
!assignment.developer?.senior ||
|
|
||||||
!assignment.tester?.junior ||
|
|
||||||
!assignment.tester?.medior ||
|
|
||||||
!assignment.tester?.senior
|
|
||||||
) {
|
|
||||||
console.error("Invalid assignment structure. Got:", assignment);
|
console.error("Invalid assignment structure. Got:", assignment);
|
||||||
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);
|
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignment as ModelAssignment;
|
return validated;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("LLM model selection failed:", (err as Error).message);
|
console.error("LLM model selection failed:", (err as Error).message);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,23 +3,25 @@
|
|||||||
*
|
*
|
||||||
* Uses an LLM to intelligently analyze and assign models to DevClaw roles.
|
* Uses an LLM to intelligently analyze and assign models to DevClaw roles.
|
||||||
*/
|
*/
|
||||||
|
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
|
import { ROLE_REGISTRY } from "../roles/index.js";
|
||||||
|
|
||||||
export type ModelAssignment = {
|
/** Model assignment: role → level → model ID. Derived from registry structure. */
|
||||||
developer: {
|
export type ModelAssignment = Record<string, Record<string, string>>;
|
||||||
junior: string;
|
|
||||||
medior: string;
|
/**
|
||||||
senior: string;
|
* Build a ModelAssignment where every role/level maps to the same model.
|
||||||
};
|
*/
|
||||||
tester: {
|
function singleModelAssignment(model: string): ModelAssignment {
|
||||||
junior: string;
|
const result: ModelAssignment = {};
|
||||||
medior: string;
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
senior: string;
|
result[roleId] = {};
|
||||||
};
|
for (const level of config.levels) {
|
||||||
architect: {
|
result[roleId][level] = model;
|
||||||
junior: string;
|
}
|
||||||
senior: string;
|
}
|
||||||
};
|
return result;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intelligently assign available models to DevClaw roles using an LLM.
|
* Intelligently assign available models to DevClaw roles using an LLM.
|
||||||
@@ -42,12 +44,7 @@ export async function assignModels(
|
|||||||
|
|
||||||
// If only one model, use it for everything
|
// If only one model, use it for everything
|
||||||
if (authenticated.length === 1) {
|
if (authenticated.length === 1) {
|
||||||
const model = authenticated[0].model;
|
return singleModelAssignment(authenticated[0].model);
|
||||||
return {
|
|
||||||
developer: { junior: model, medior: model, senior: model },
|
|
||||||
tester: { junior: model, medior: model, senior: model },
|
|
||||||
architect: { junior: model, senior: model },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple models: use LLM-based selection
|
// Multiple models: use LLM-based selection
|
||||||
@@ -68,15 +65,16 @@ export function formatAssignment(assignment: ModelAssignment): string {
|
|||||||
const lines = [
|
const lines = [
|
||||||
"| Role | Level | Model |",
|
"| Role | Level | Model |",
|
||||||
"|-----------|----------|--------------------------|",
|
"|-----------|----------|--------------------------|",
|
||||||
`| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`,
|
|
||||||
`| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`,
|
|
||||||
`| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`,
|
|
||||||
`| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`,
|
|
||||||
`| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`,
|
|
||||||
`| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`,
|
|
||||||
`| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`,
|
|
||||||
`| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`,
|
|
||||||
];
|
];
|
||||||
|
for (const roleId of getAllRoleIds()) {
|
||||||
|
const roleModels = assignment[roleId];
|
||||||
|
if (!roleModels) continue;
|
||||||
|
const displayName = ROLE_REGISTRY[roleId]?.displayName ?? roleId.toUpperCase();
|
||||||
|
for (const level of getLevelsForRole(roleId)) {
|
||||||
|
const model = roleModels[level] ?? "";
|
||||||
|
lines.push(`| ${displayName.padEnd(9)} | ${level.padEnd(8)} | ${model.padEnd(24)} |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("architect tiers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve default architect models", () => {
|
it("should resolve default architect models", () => {
|
||||||
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
|
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||||
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
|||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||||
|
import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js";
|
||||||
|
|
||||||
/** Valid author roles for attribution */
|
/** Valid author roles for attribution — all registry roles + orchestrator */
|
||||||
const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const;
|
const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"];
|
||||||
type AuthorRole = (typeof AUTHOR_ROLES)[number];
|
type AuthorRole = string;
|
||||||
|
|
||||||
export function createTaskCommentTool(api: OpenClawPluginApi) {
|
export function createTaskCommentTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -73,7 +74,7 @@ Examples:
|
|||||||
const issue = await provider.getIssue(issueId);
|
const issue = await provider.getIssue(issueId);
|
||||||
|
|
||||||
const commentBody = authorRole
|
const commentBody = authorRole
|
||||||
? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}`
|
? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}`
|
||||||
: body;
|
: body;
|
||||||
|
|
||||||
await provider.addComment(issueId, commentBody);
|
await provider.addComment(issueId, commentBody);
|
||||||
@@ -99,8 +100,7 @@ Examples:
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const ROLE_EMOJI: Record<AuthorRole, string> = {
|
function getRoleEmoji(role: string): string {
|
||||||
developer: "👨💻",
|
if (role === "orchestrator") return "🎛️";
|
||||||
tester: "🔍",
|
return getFallbackEmoji(role);
|
||||||
orchestrator: "🎛️",
|
}
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user