refactor: split Crestodian planner backend selection

This commit is contained in:
Peter Steinberger
2026-04-25 17:56:40 +01:00
parent 60f9358348
commit e27e29c66e
7 changed files with 447 additions and 480 deletions

View File

@@ -0,0 +1,84 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { CrestodianOverview } from "./overview.js";
export const CRESTODIAN_CLAUDE_CLI_MODEL = "claude-opus-4-7";
export const CRESTODIAN_CODEX_MODEL = "gpt-5.5";
export type CrestodianLocalPlannerBackend = {
kind: "claude-cli" | "codex-app-server" | "codex-cli";
label: string;
runner: "cli" | "embedded";
provider: string;
model: string;
buildConfig: (workspaceDir: string) => OpenClawConfig;
};
const CLAUDE_CLI_BACKEND: CrestodianLocalPlannerBackend = {
kind: "claude-cli",
label: `claude-cli/${CRESTODIAN_CLAUDE_CLI_MODEL}`,
runner: "cli",
provider: "claude-cli",
model: CRESTODIAN_CLAUDE_CLI_MODEL,
buildConfig: (workspaceDir) =>
buildCliPlannerConfig(workspaceDir, `claude-cli/${CRESTODIAN_CLAUDE_CLI_MODEL}`),
};
const CODEX_APP_SERVER_BACKEND: CrestodianLocalPlannerBackend = {
kind: "codex-app-server",
label: `openai/${CRESTODIAN_CODEX_MODEL} via codex`,
runner: "embedded",
provider: "openai",
model: CRESTODIAN_CODEX_MODEL,
buildConfig: buildCodexAppServerPlannerConfig,
};
const CODEX_CLI_BACKEND: CrestodianLocalPlannerBackend = {
kind: "codex-cli",
label: `codex-cli/${CRESTODIAN_CODEX_MODEL}`,
runner: "cli",
provider: "codex-cli",
model: CRESTODIAN_CODEX_MODEL,
buildConfig: (workspaceDir) =>
buildCliPlannerConfig(workspaceDir, `codex-cli/${CRESTODIAN_CODEX_MODEL}`),
};
export function selectCrestodianLocalPlannerBackends(
overview: CrestodianOverview,
): CrestodianLocalPlannerBackend[] {
const backends: CrestodianLocalPlannerBackend[] = [];
if (overview.tools.claude.found) {
backends.push(CLAUDE_CLI_BACKEND);
}
if (overview.tools.codex.found) {
backends.push(CODEX_APP_SERVER_BACKEND, CODEX_CLI_BACKEND);
}
return backends;
}
function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClawConfig {
return {
agents: {
defaults: {
workspace: workspaceDir,
model: { primary: modelRef },
},
},
};
}
function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig {
return {
agents: {
defaults: {
workspace: workspaceDir,
embeddedHarness: { runtime: "codex", fallback: "none" },
model: { primary: `openai/${CRESTODIAN_CODEX_MODEL}` },
},
},
plugins: {
entries: {
codex: { enabled: true },
},
},
};
}

View File

@@ -0,0 +1,122 @@
import type { CrestodianOverview } from "./overview.js";
export const CRESTODIAN_ASSISTANT_TIMEOUT_MS = 10_000;
export const CRESTODIAN_ASSISTANT_MAX_TOKENS = 512;
export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [
"You are Crestodian, OpenClaw's ring-zero setup helper.",
"Turn the user's request into exactly one safe OpenClaw Crestodian command.",
"Return only compact JSON with keys reply and command.",
"Do not invent commands. Do not claim a write was applied.",
"Do not use tools, shell commands, file edits, or network lookups; plan only from the supplied overview.",
"Use the provided OpenClaw docs/source references when the user's request needs behavior, config, or architecture details.",
"If local source is available, prefer inspecting it. Otherwise point to GitHub and strongly recommend reviewing source when docs are not enough.",
"Allowed commands:",
"- setup",
"- status",
"- health",
"- doctor",
"- doctor fix",
"- gateway status",
"- restart gateway",
"- start gateway",
"- stop gateway",
"- agents",
"- models",
"- audit",
"- validate config",
"- set default model <provider/model>",
"- config set <path> <value>",
"- config set-ref <path> env <ENV_VAR>",
"- create agent <id> workspace <path> model <provider/model>",
"- talk to <id> agent",
"- talk to agent",
"If unsure, choose overview.",
].join("\n");
export type CrestodianAssistantPlan = {
command: string;
reply?: string;
modelLabel?: string;
};
export function buildCrestodianAssistantUserPrompt(params: {
input: string;
overview: CrestodianOverview;
}): string {
const agents = params.overview.agents
.map((agent) => {
const fields = [
`id=${agent.id}`,
agent.name ? `name=${agent.name}` : undefined,
agent.workspace ? `workspace=${agent.workspace}` : undefined,
agent.model ? `model=${agent.model}` : undefined,
agent.isDefault ? "default=true" : undefined,
].filter(Boolean);
return `- ${fields.join(", ")}`;
})
.join("\n");
return [
`User request: ${params.input}`,
"",
`Default agent: ${params.overview.defaultAgentId}`,
`Default model: ${params.overview.defaultModel ?? "not configured"}`,
`Config valid: ${params.overview.config.valid}`,
`Gateway reachable: ${params.overview.gateway.reachable}`,
`Codex CLI: ${params.overview.tools.codex.found ? "found" : "not found"}`,
`Claude Code CLI: ${params.overview.tools.claude.found ? "found" : "not found"}`,
`OpenAI API key: ${params.overview.tools.apiKeys.openai ? "found" : "not found"}`,
`Anthropic API key: ${params.overview.tools.apiKeys.anthropic ? "found" : "not found"}`,
`OpenClaw docs: ${params.overview.references.docsPath ?? params.overview.references.docsUrl}`,
`OpenClaw source: ${
params.overview.references.sourcePath ?? params.overview.references.sourceUrl
}`,
params.overview.references.sourcePath
? "Source mode: local git checkout; inspect source directly when docs are insufficient."
: "Source mode: package/install; use GitHub source when docs are insufficient.",
"",
"Agents:",
agents || "- none",
].join("\n");
}
export function parseCrestodianAssistantPlanText(
rawText: string | undefined,
): CrestodianAssistantPlan | null {
const text = rawText?.trim();
if (!text) {
return null;
}
const jsonText = extractFirstJsonObject(text);
if (!jsonText) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") {
return null;
}
const record = parsed as Record<string, unknown>;
const command = typeof record.command === "string" ? record.command.trim() : "";
if (!command) {
return null;
}
const reply = typeof record.reply === "string" ? record.reply.trim() : undefined;
return {
command,
...(reply ? { reply } : {}),
};
}
function extractFirstJsonObject(text: string): string | null {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start < 0 || end <= start) {
return null;
}
return text.slice(start, end + 1);
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { RunCliAgentParams } from "../agents/cli-runner/types.js";
import type { RunEmbeddedPiAgentParams } from "../agents/pi-embedded-runner/run/params.js";
import type { EmbeddedPiRunResult } from "../agents/pi-embedded.js";
import { selectCrestodianLocalPlannerBackends } from "./assistant-backends.js";
import {
buildCrestodianAssistantUserPrompt,
planCrestodianCommandWithLocalRuntime,
@@ -139,6 +140,41 @@ describe("Crestodian assistant", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("selects local planner backends without execution state", () => {
expect(
selectCrestodianLocalPlannerBackends(
overview({
claude: { command: "claude", found: true },
codex: { command: "codex", found: true },
}),
).map((backend) => backend.kind),
).toEqual(["claude-cli", "codex-app-server", "codex-cli"]);
const [codexAppServer, codexCli] = selectCrestodianLocalPlannerBackends(
overview({
codex: { command: "codex", found: true },
}),
);
expect(codexAppServer?.buildConfig("/tmp/workspace")).toMatchObject({
agents: {
defaults: {
workspace: "/tmp/workspace",
embeddedHarness: { runtime: "codex", fallback: "none" },
model: { primary: "openai/gpt-5.5" },
},
},
plugins: { entries: { codex: { enabled: true } } },
});
expect(codexCli?.buildConfig("/tmp/workspace")).toMatchObject({
agents: {
defaults: {
workspace: "/tmp/workspace",
model: { primary: "codex-cli/gpt-5.5" },
},
},
});
});
it("falls back to Codex app-server when Claude CLI planning fails", async () => {
const runCliAgent = vi.fn(async () => {
throw new Error("claude unavailable");

View File

@@ -9,50 +9,22 @@ import {
prepareSimpleCompletionModelForAgent,
} from "../agents/simple-completion-runtime.js";
import { readConfigFileSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { selectCrestodianLocalPlannerBackends } from "./assistant-backends.js";
import {
CRESTODIAN_ASSISTANT_MAX_TOKENS,
CRESTODIAN_ASSISTANT_SYSTEM_PROMPT,
CRESTODIAN_ASSISTANT_TIMEOUT_MS,
buildCrestodianAssistantUserPrompt,
parseCrestodianAssistantPlanText,
type CrestodianAssistantPlan,
} from "./assistant-prompts.js";
import type { CrestodianOverview } from "./overview.js";
const CRESTODIAN_ASSISTANT_TIMEOUT_MS = 10_000;
const CRESTODIAN_ASSISTANT_MAX_TOKENS = 512;
const CRESTODIAN_CLAUDE_CLI_MODEL = "claude-opus-4-7";
const CRESTODIAN_CODEX_MODEL = "gpt-5.5";
const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [
"You are Crestodian, OpenClaw's ring-zero setup helper.",
"Turn the user's request into exactly one safe OpenClaw Crestodian command.",
"Return only compact JSON with keys reply and command.",
"Do not invent commands. Do not claim a write was applied.",
"Do not use tools, shell commands, file edits, or network lookups; plan only from the supplied overview.",
"Use the provided OpenClaw docs/source references when the user's request needs behavior, config, or architecture details.",
"If local source is available, prefer inspecting it. Otherwise point to GitHub and strongly recommend reviewing source when docs are not enough.",
"Allowed commands:",
"- setup",
"- status",
"- health",
"- doctor",
"- doctor fix",
"- gateway status",
"- restart gateway",
"- start gateway",
"- stop gateway",
"- agents",
"- models",
"- audit",
"- validate config",
"- set default model <provider/model>",
"- config set <path> <value>",
"- config set-ref <path> env <ENV_VAR>",
"- create agent <id> workspace <path> model <provider/model>",
"- talk to <id> agent",
"- talk to agent",
"If unsure, choose overview.",
].join("\n");
export type CrestodianAssistantPlan = {
command: string;
reply?: string;
modelLabel?: string;
};
export {
buildCrestodianAssistantUserPrompt,
parseCrestodianAssistantPlanText,
type CrestodianAssistantPlan,
} from "./assistant-prompts.js";
export type CrestodianAssistantPlanner = (params: {
input: string;
@@ -69,8 +41,6 @@ export type CrestodianLocalRuntimePlannerDeps = {
removeTempDir?: (dir: string) => Promise<void>;
};
type LocalPlannerCandidate = "claude-cli" | "codex-app-server" | "codex-cli";
export async function planCrestodianCommand(params: {
input: string;
overview: CrestodianOverview;
@@ -154,8 +124,8 @@ export async function planCrestodianCommandWithLocalRuntime(params: {
if (!input) {
return null;
}
const candidates = listLocalRuntimePlannerCandidates(params.overview);
if (candidates.length === 0) {
const backends = selectCrestodianLocalPlannerBackends(params.overview);
if (backends.length === 0) {
return null;
}
const prompt = buildCrestodianAssistantUserPrompt({
@@ -163,9 +133,9 @@ export async function planCrestodianCommandWithLocalRuntime(params: {
overview: params.overview,
});
for (const candidate of candidates) {
for (const backend of backends) {
try {
const rawText = await runLocalRuntimePlanner(candidate, {
const rawText = await runLocalRuntimePlanner(backend, {
prompt,
deps: params.deps,
});
@@ -173,7 +143,7 @@ export async function planCrestodianCommandWithLocalRuntime(params: {
if (parsed) {
return {
...parsed,
modelLabel: localRuntimePlannerLabel(candidate),
modelLabel: backend.label,
};
}
} catch {
@@ -183,109 +153,8 @@ export async function planCrestodianCommandWithLocalRuntime(params: {
return null;
}
export function buildCrestodianAssistantUserPrompt(params: {
input: string;
overview: CrestodianOverview;
}): string {
const agents = params.overview.agents
.map((agent) => {
const fields = [
`id=${agent.id}`,
agent.name ? `name=${agent.name}` : undefined,
agent.workspace ? `workspace=${agent.workspace}` : undefined,
agent.model ? `model=${agent.model}` : undefined,
agent.isDefault ? "default=true" : undefined,
].filter(Boolean);
return `- ${fields.join(", ")}`;
})
.join("\n");
return [
`User request: ${params.input}`,
"",
`Default agent: ${params.overview.defaultAgentId}`,
`Default model: ${params.overview.defaultModel ?? "not configured"}`,
`Config valid: ${params.overview.config.valid}`,
`Gateway reachable: ${params.overview.gateway.reachable}`,
`Codex CLI: ${params.overview.tools.codex.found ? "found" : "not found"}`,
`Claude Code CLI: ${params.overview.tools.claude.found ? "found" : "not found"}`,
`OpenAI API key: ${params.overview.tools.apiKeys.openai ? "found" : "not found"}`,
`Anthropic API key: ${params.overview.tools.apiKeys.anthropic ? "found" : "not found"}`,
`OpenClaw docs: ${params.overview.references.docsPath ?? params.overview.references.docsUrl}`,
`OpenClaw source: ${
params.overview.references.sourcePath ?? params.overview.references.sourceUrl
}`,
params.overview.references.sourcePath
? "Source mode: local git checkout; inspect source directly when docs are insufficient."
: "Source mode: package/install; use GitHub source when docs are insufficient.",
"",
"Agents:",
agents || "- none",
].join("\n");
}
export function parseCrestodianAssistantPlanText(
rawText: string | undefined,
): CrestodianAssistantPlan | null {
const text = rawText?.trim();
if (!text) {
return null;
}
const jsonText = extractFirstJsonObject(text);
if (!jsonText) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") {
return null;
}
const record = parsed as Record<string, unknown>;
const command = typeof record.command === "string" ? record.command.trim() : "";
if (!command) {
return null;
}
const reply = typeof record.reply === "string" ? record.reply.trim() : undefined;
return {
command,
...(reply ? { reply } : {}),
};
}
function extractFirstJsonObject(text: string): string | null {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start < 0 || end <= start) {
return null;
}
return text.slice(start, end + 1);
}
function listLocalRuntimePlannerCandidates(overview: CrestodianOverview): LocalPlannerCandidate[] {
const candidates: LocalPlannerCandidate[] = [];
if (overview.tools.claude.found) {
candidates.push("claude-cli");
}
if (overview.tools.codex.found) {
candidates.push("codex-app-server", "codex-cli");
}
return candidates;
}
function localRuntimePlannerLabel(candidate: LocalPlannerCandidate): string {
const labels: Record<LocalPlannerCandidate, string> = {
"claude-cli": `claude-cli/${CRESTODIAN_CLAUDE_CLI_MODEL}`,
"codex-app-server": `openai/${CRESTODIAN_CODEX_MODEL} via codex`,
"codex-cli": `codex-cli/${CRESTODIAN_CODEX_MODEL}`,
};
return labels[candidate];
}
async function runLocalRuntimePlanner(
candidate: LocalPlannerCandidate,
backend: ReturnType<typeof selectCrestodianLocalPlannerBackends>[number],
params: {
prompt: string;
deps?: CrestodianLocalRuntimePlannerDeps;
@@ -297,8 +166,8 @@ async function runLocalRuntimePlanner(
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = `${runId}-session`;
const sessionKey = `temp:crestodian-planner:${runId}`;
switch (candidate) {
case "claude-cli": {
switch (backend.runner) {
case "cli": {
const runCli = params.deps?.runCliAgent ?? (await loadRunCliAgent());
const result = await runCli({
sessionId,
@@ -307,10 +176,10 @@ async function runLocalRuntimePlanner(
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
config: buildCliPlannerConfig(tempDir, `claude-cli/${CRESTODIAN_CLAUDE_CLI_MODEL}`),
config: backend.buildConfig(tempDir),
prompt: params.prompt,
provider: "claude-cli",
model: CRESTODIAN_CLAUDE_CLI_MODEL,
provider: backend.provider,
model: backend.model,
timeoutMs: CRESTODIAN_ASSISTANT_TIMEOUT_MS,
runId,
extraSystemPrompt: CRESTODIAN_ASSISTANT_SYSTEM_PROMPT,
@@ -322,7 +191,7 @@ async function runLocalRuntimePlanner(
});
return extractPlannerResultText(result);
}
case "codex-app-server": {
case "embedded": {
const runEmbedded = params.deps?.runEmbeddedPiAgent ?? (await loadRunEmbeddedPiAgent());
const result = await runEmbedded({
sessionId,
@@ -331,10 +200,10 @@ async function runLocalRuntimePlanner(
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
config: buildCodexAppServerPlannerConfig(tempDir),
config: backend.buildConfig(tempDir),
prompt: params.prompt,
provider: "openai",
model: CRESTODIAN_CODEX_MODEL,
provider: backend.provider,
model: backend.model,
agentHarnessId: "codex",
disableTools: true,
toolsAllow: [],
@@ -348,30 +217,6 @@ async function runLocalRuntimePlanner(
});
return extractPlannerResultText(result);
}
case "codex-cli": {
const runCli = params.deps?.runCliAgent ?? (await loadRunCliAgent());
const result = await runCli({
sessionId,
sessionKey,
agentId: "crestodian",
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
config: buildCliPlannerConfig(tempDir, `codex-cli/${CRESTODIAN_CODEX_MODEL}`),
prompt: params.prompt,
provider: "codex-cli",
model: CRESTODIAN_CODEX_MODEL,
timeoutMs: CRESTODIAN_ASSISTANT_TIMEOUT_MS,
runId,
extraSystemPrompt: CRESTODIAN_ASSISTANT_SYSTEM_PROMPT,
extraSystemPromptStatic: CRESTODIAN_ASSISTANT_SYSTEM_PROMPT,
messageChannel: "crestodian",
messageProvider: "crestodian",
senderIsOwner: true,
cleanupCliLiveSessionOnRunEnd: true,
});
return extractPlannerResultText(result);
}
}
return undefined;
} finally {
@@ -379,34 +224,6 @@ async function runLocalRuntimePlanner(
}
}
function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClawConfig {
return {
agents: {
defaults: {
workspace: workspaceDir,
model: { primary: modelRef },
},
},
};
}
function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig {
return {
agents: {
defaults: {
workspace: workspaceDir,
embeddedHarness: { runtime: "codex", fallback: "none" },
model: { primary: `openai/${CRESTODIAN_CODEX_MODEL}` },
},
},
plugins: {
entries: {
codex: { enabled: true },
},
},
};
}
async function createTempPlannerDir(): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-crestodian-planner-"));
}