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

@@ -24,40 +24,47 @@ docsRefs:
- docs/help/testing.md
codeRefs:
- src/crestodian/operations.ts
- scripts/e2e/crestodian-first-run-spec.json
- scripts/e2e/crestodian-first-run-docker-client.ts
- extensions/qa-lab/src/suite-runtime-agent-process.ts
execution:
kind: flow
summary: Drive the public Crestodian CLI in an isolated fresh state dir and verify setup/model/agent/Discord/audit results.
config:
stateDirName: crestodian-ring-zero-state
defaultWorkspaceName: crestodian-main-workspace
agentWorkspaceName: crestodian-reef-workspace
agentId: reef
model: openai/gpt-5.2
discordEnv: DISCORD_BOT_TOKEN
discordToken: openclaw-crestodian-qa-discord-token
specPath: scripts/e2e/crestodian-first-run-spec.json
```
```yaml qa-flow
steps:
- name: bootstraps config through Crestodian CLI
actions:
- set: setupSpec
value:
expr: "JSON.parse(await fs.readFile(path.join(env.repoRoot, config.specPath), 'utf8'))"
- set: stateDir
value:
expr: "path.join(env.gateway.tempRoot, config.stateDirName)"
expr: "path.join(env.gateway.tempRoot, setupSpec.stateDirName)"
- set: configPath
value:
expr: "path.join(stateDir, 'openclaw.json')"
- set: defaultWorkspace
value:
expr: "path.join(env.gateway.tempRoot, config.defaultWorkspaceName)"
expr: "path.join(env.gateway.tempRoot, setupSpec.defaultWorkspaceName)"
- set: agentWorkspace
value:
expr: "path.join(env.gateway.tempRoot, config.agentWorkspaceName)"
expr: "path.join(env.gateway.tempRoot, setupSpec.agentWorkspaceName)"
- set: commandVars
value:
expr: "({ defaultWorkspace, agentWorkspace, agentId: setupSpec.agentId, model: setupSpec.model, discordEnv: setupSpec.discordEnv })"
- set: renderCommand
value:
lambda:
params:
- template
expr: "String(template).replace(/\\{([A-Za-z0-9_]+)\\}/g, (match, key) => String(commandVars[key] ?? match))"
- set: crestodianEnv
value:
expr: "({ OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(env.repoRoot, 'dist', 'extensions'), [config.discordEnv]: config.discordToken })"
expr: "({ OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(env.repoRoot, 'dist', 'extensions'), [setupSpec.discordEnv]: setupSpec.discordToken })"
- call: fs.rm
args:
- ref: stateDir
@@ -85,141 +92,39 @@ steps:
expr: 'String(overviewOutput).includes(''Next: run "setup" to create a starter config'')'
message:
expr: "`fresh Crestodian overview did not recommend setup: ${overviewOutput}`"
- call: runQaCli
saveAs: setupOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- expr: "`setup workspace ${defaultWorkspace} model ${config.model}`"
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(setupOutput).includes('[crestodian] done: crestodian.setup')"
message:
expr: "`Crestodian setup did not apply: ${setupOutput}`"
- call: runQaCli
saveAs: modelOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- expr: "`set default model ${config.model}`"
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(modelOutput).includes('[crestodian] done: config.setDefaultModel')"
message:
expr: "`Crestodian model update did not apply: ${modelOutput}`"
- call: runQaCli
saveAs: agentOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- expr: "`create agent ${config.agentId} workspace ${agentWorkspace} model ${config.model}`"
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(agentOutput).includes('[crestodian] done: agents.create')"
message:
expr: "`Crestodian agent creation did not apply: ${agentOutput}`"
- call: runQaCli
saveAs: discordPluginAllowOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- config set plugins.allow ["discord"]
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(discordPluginAllowOutput).includes('[crestodian] done: config.set')"
message:
expr: "`Crestodian Discord plugin allowlist did not apply: ${discordPluginAllowOutput}`"
- call: runQaCli
saveAs: discordPluginEntryOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- config set plugins.entries.discord.enabled true
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(discordPluginEntryOutput).includes('[crestodian] done: config.set')"
message:
expr: "`Crestodian Discord plugin entry did not apply: ${discordPluginEntryOutput}`"
- call: runQaCli
saveAs: discordTokenOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- expr: "`config set-ref channels.discord.token env ${config.discordEnv}`"
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(discordTokenOutput).includes('[crestodian] done: config.setRef')"
message:
expr: "`Crestodian Discord SecretRef did not apply: ${discordTokenOutput}`"
- call: runQaCli
saveAs: discordEnabledOutput
args:
- ref: env
- - crestodian
- --yes
- -m
- config set channels.discord.enabled true
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(discordEnabledOutput).includes('[crestodian] done: config.set')"
message:
expr: "`Crestodian Discord enable did not apply: ${discordEnabledOutput}`"
- call: runQaCli
saveAs: validationOutput
args:
- ref: env
- - crestodian
- -m
- validate config
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(validationOutput).includes('Config valid:')"
message:
expr: "`Crestodian config validation did not pass: ${validationOutput}`"
- forEach:
items:
ref: setupSpec.commands
item: commandStep
actions:
- call: runQaCli
saveAs: commandOutput
args:
- ref: env
- expr: "['crestodian', ...(commandStep.approve ? ['--yes'] : []), '-m', renderCommand(commandStep.message)]"
- timeoutMs: 60000
env:
ref: crestodianEnv
- assert:
expr: "String(commandOutput).includes(commandStep.expectOutput)"
message:
expr: "`Crestodian command ${commandStep.id} did not produce ${commandStep.expectOutput}: ${commandOutput}`"
- set: writtenConfig
value:
expr: "JSON.parse(await fs.readFile(configPath, 'utf8'))"
- set: agent
value:
expr: "writtenConfig.agents?.list?.find((candidate) => candidate.id === config.agentId)"
expr: "writtenConfig.agents?.list?.find((candidate) => candidate.id === setupSpec.agentId)"
- assert:
expr: "writtenConfig.agents?.defaults?.workspace === defaultWorkspace"
message:
expr: "`default workspace mismatch: ${JSON.stringify(writtenConfig.agents?.defaults)}`"
- assert:
expr: "writtenConfig.agents?.defaults?.model?.primary === config.model"
expr: "writtenConfig.agents?.defaults?.model?.primary === setupSpec.model"
message:
expr: "`default model mismatch: ${JSON.stringify(writtenConfig.agents?.defaults?.model)}`"
- assert:
expr: "agent?.workspace === agentWorkspace && agent?.model === config.model"
expr: "agent?.workspace === agentWorkspace && agent?.model === setupSpec.model"
message:
expr: "`agent config mismatch: ${JSON.stringify(agent)}`"
- assert:
@@ -231,22 +136,18 @@ steps:
message:
expr: "`Discord was not enabled: ${JSON.stringify(writtenConfig.channels?.discord)}`"
- assert:
expr: "writtenConfig.channels?.discord?.token?.source === 'env' && writtenConfig.channels?.discord?.token?.id === config.discordEnv"
expr: "writtenConfig.channels?.discord?.token?.source === 'env' && writtenConfig.channels?.discord?.token?.id === setupSpec.discordEnv"
message:
expr: "`Discord token was not an env SecretRef: ${JSON.stringify(writtenConfig.channels?.discord?.token)}`"
- assert:
expr: "!JSON.stringify(writtenConfig.channels?.discord ?? {}).includes(config.discordToken)"
expr: "!JSON.stringify(writtenConfig.channels?.discord ?? {}).includes(setupSpec.discordToken)"
message: Crestodian persisted the raw Discord token.
- set: auditText
value:
expr: "await fs.readFile(path.join(stateDir, 'audit', 'crestodian.jsonl'), 'utf8')"
- forEach:
items:
- crestodian.setup
- config.setDefaultModel
- agents.create
- config.setRef
- config.set
ref: setupSpec.auditOperations
item: operation
actions:
- assert:

View File

@@ -7,6 +7,24 @@ import type { OpenClawConfig } from "../../src/config/types.openclaw.js";
import { runCrestodian } from "../../src/crestodian/crestodian.js";
import type { RuntimeEnv } from "../../src/runtime.js";
type CrestodianFirstRunCommand = {
id: string;
message: string;
expectOutput: string;
approve: boolean;
};
type CrestodianFirstRunSpec = {
dockerDefaultWorkspace: string;
dockerAgentWorkspace: string;
agentId: string;
model: string;
discordEnv: string;
discordToken: string;
commands: CrestodianFirstRunCommand[];
auditOperations: string[];
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
@@ -27,7 +45,21 @@ function createRuntime(): { runtime: RuntimeEnv; lines: string[] } {
};
}
async function readFirstRunSpec(): Promise<CrestodianFirstRunSpec> {
return JSON.parse(
await fs.readFile(
path.join(process.cwd(), "scripts", "e2e", "crestodian-first-run-spec.json"),
"utf8",
),
) as CrestodianFirstRunSpec;
}
function renderCommandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{([A-Za-z0-9_]+)\}/g, (match, key: string) => vars[key] ?? match);
}
async function main() {
const spec = await readFirstRunSpec();
const stateDir =
process.env.OPENCLAW_STATE_DIR ??
(await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-crestodian-first-run-")));
@@ -61,137 +93,49 @@ async function main() {
"fresh overview did not include setup recommendation",
);
process.env.DISCORD_BOT_TOKEN = "openclaw-crestodian-discord-e2e-token";
process.env[spec.discordEnv] = spec.discordToken;
const setupRuntime = createRuntime();
await runCrestodian(
{
message: "setup workspace /tmp/openclaw-first-run model openai/gpt-5.2",
yes: true,
interactive: false,
},
setupRuntime.runtime,
);
const setupOutput = setupRuntime.lines.join("\n");
assert(
setupOutput.includes("[crestodian] done: crestodian.setup"),
"Crestodian setup did not apply",
);
clearConfigCache();
const modelRuntime = createRuntime();
await runCrestodian(
{
message: "set default model openai/gpt-5.2",
yes: true,
interactive: false,
},
modelRuntime.runtime,
);
assert(
modelRuntime.lines.join("\n").includes("[crestodian] done: config.setDefaultModel"),
"Crestodian default model update did not apply",
);
clearConfigCache();
const agentRuntime = createRuntime();
await runCrestodian(
{
message: "create agent reef workspace /tmp/openclaw-reef model openai/gpt-5.2",
yes: true,
interactive: false,
},
agentRuntime.runtime,
);
assert(
agentRuntime.lines.join("\n").includes("[crestodian] done: agents.create"),
"Crestodian agent creation did not apply",
);
clearConfigCache();
const discordPluginAllowRuntime = createRuntime();
await runCrestodian(
{
message: 'config set plugins.allow ["discord"]',
yes: true,
interactive: false,
},
discordPluginAllowRuntime.runtime,
);
assert(
discordPluginAllowRuntime.lines.join("\n").includes("[crestodian] done: config.set"),
"Crestodian Discord plugin allowlist did not apply",
);
clearConfigCache();
const discordPluginEntryRuntime = createRuntime();
await runCrestodian(
{
message: "config set plugins.entries.discord.enabled true",
yes: true,
interactive: false,
},
discordPluginEntryRuntime.runtime,
);
assert(
discordPluginEntryRuntime.lines.join("\n").includes("[crestodian] done: config.set"),
"Crestodian Discord plugin entry did not apply",
);
clearConfigCache();
const discordTokenRuntime = createRuntime();
await runCrestodian(
{
message: "config set-ref channels.discord.token env DISCORD_BOT_TOKEN",
yes: true,
interactive: false,
},
discordTokenRuntime.runtime,
);
assert(
discordTokenRuntime.lines.join("\n").includes("[crestodian] done: config.setRef"),
"Crestodian Discord token SecretRef did not apply",
);
clearConfigCache();
const discordEnabledRuntime = createRuntime();
await runCrestodian(
{
message: "config set channels.discord.enabled true",
yes: true,
interactive: false,
},
discordEnabledRuntime.runtime,
);
assert(
discordEnabledRuntime.lines.join("\n").includes("[crestodian] done: config.set"),
"Crestodian Discord enabled flag did not apply",
);
clearConfigCache();
const validateRuntime = createRuntime();
await runCrestodian({ message: "validate config", interactive: false }, validateRuntime.runtime);
assert(
validateRuntime.lines.join("\n").includes("Config valid:"),
"post-setup config validation did not pass",
);
const commandVars = {
defaultWorkspace: spec.dockerDefaultWorkspace,
agentWorkspace: spec.dockerAgentWorkspace,
agentId: spec.agentId,
model: spec.model,
discordEnv: spec.discordEnv,
};
for (const command of spec.commands) {
clearConfigCache();
const commandRuntime = createRuntime();
await runCrestodian(
{
message: renderCommandTemplate(command.message, commandVars),
yes: command.approve,
interactive: false,
},
commandRuntime.runtime,
);
const output = commandRuntime.lines.join("\n");
assert(
output.includes(command.expectOutput),
`Crestodian first-run command ${command.id} did not apply: ${output}`,
);
}
const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig;
assert(
config.agents?.defaults?.workspace === "/tmp/openclaw-first-run",
config.agents?.defaults?.workspace === spec.dockerDefaultWorkspace,
"first-run setup did not write default workspace",
);
assert(
config.agents?.defaults?.model &&
typeof config.agents.defaults.model === "object" &&
"primary" in config.agents.defaults.model &&
config.agents.defaults.model.primary === "openai/gpt-5.2",
config.agents.defaults.model.primary === spec.model,
"first-run setup did not write default model",
);
const reef = config.agents?.list?.find((agent) => agent.id === "reef");
const reef = config.agents?.list?.find((agent) => agent.id === spec.agentId);
assert(reef, "Crestodian did not create reef agent");
assert(reef.workspace === "/tmp/openclaw-reef", "Crestodian did not write reef workspace");
assert(reef.model === "openai/gpt-5.2", "Crestodian did not write reef model");
assert(reef.workspace === spec.dockerAgentWorkspace, "Crestodian did not write reef workspace");
assert(reef.model === spec.model, "Crestodian did not write reef model");
assert(config.plugins?.allow?.includes("discord"), "Crestodian did not allow Discord plugin");
assert(
config.plugins?.entries?.discord?.enabled === true,
@@ -205,24 +149,19 @@ async function main() {
"source" in discordToken &&
discordToken.source === "env" &&
"id" in discordToken &&
discordToken.id === "DISCORD_BOT_TOKEN",
discordToken.id === spec.discordEnv,
"Crestodian did not write Discord token SecretRef",
);
assert(
!JSON.stringify(config.channels.discord).includes(process.env.DISCORD_BOT_TOKEN),
!JSON.stringify(config.channels.discord).includes(spec.discordToken),
"Crestodian persisted the raw Discord token",
);
const auditPath = path.join(stateDir, "audit", "crestodian.jsonl");
const audit = (await fs.readFile(auditPath, "utf8")).trim();
assert(audit.includes('"operation":"crestodian.setup"'), "setup audit entry missing");
assert(
audit.includes('"operation":"config.setDefaultModel"'),
"default model audit entry missing",
);
assert(audit.includes('"operation":"agents.create"'), "agent creation audit entry missing");
assert(audit.includes('"operation":"config.setRef"'), "Discord SecretRef audit entry missing");
assert(audit.includes('"operation":"config.set"'), "Discord enabled audit entry missing");
for (const operation of spec.auditOperations) {
assert(audit.includes(`"operation":"${operation}"`), `${operation} audit entry missing`);
}
console.log("Crestodian first-run Docker E2E passed");
}

View File

@@ -0,0 +1,68 @@
{
"stateDirName": "crestodian-ring-zero-state",
"defaultWorkspaceName": "crestodian-main-workspace",
"agentWorkspaceName": "crestodian-reef-workspace",
"dockerDefaultWorkspace": "/tmp/openclaw-first-run",
"dockerAgentWorkspace": "/tmp/openclaw-reef",
"agentId": "reef",
"model": "openai/gpt-5.2",
"discordEnv": "DISCORD_BOT_TOKEN",
"discordToken": "openclaw-crestodian-discord-e2e-token",
"commands": [
{
"id": "setup",
"message": "setup workspace {defaultWorkspace} model {model}",
"expectOutput": "[crestodian] done: crestodian.setup",
"approve": true
},
{
"id": "default-model",
"message": "set default model {model}",
"expectOutput": "[crestodian] done: config.setDefaultModel",
"approve": true
},
{
"id": "agent",
"message": "create agent {agentId} workspace {agentWorkspace} model {model}",
"expectOutput": "[crestodian] done: agents.create",
"approve": true
},
{
"id": "discord-plugin-allow",
"message": "config set plugins.allow [\"discord\"]",
"expectOutput": "[crestodian] done: config.set",
"approve": true
},
{
"id": "discord-plugin-entry",
"message": "config set plugins.entries.discord.enabled true",
"expectOutput": "[crestodian] done: config.set",
"approve": true
},
{
"id": "discord-token",
"message": "config set-ref channels.discord.token env {discordEnv}",
"expectOutput": "[crestodian] done: config.setRef",
"approve": true
},
{
"id": "discord-enabled",
"message": "config set channels.discord.enabled true",
"expectOutput": "[crestodian] done: config.set",
"approve": true
},
{
"id": "validate",
"message": "validate config",
"expectOutput": "Config valid:",
"approve": false
}
],
"auditOperations": [
"crestodian.setup",
"config.setDefaultModel",
"agents.create",
"config.setRef",
"config.set"
]
}

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-"));
}