diff --git a/qa/scenarios/config/crestodian-ring-zero-setup.md b/qa/scenarios/config/crestodian-ring-zero-setup.md index 4f230f93b1b..26023884aaa 100644 --- a/qa/scenarios/config/crestodian-ring-zero-setup.md +++ b/qa/scenarios/config/crestodian-ring-zero-setup.md @@ -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: diff --git a/scripts/e2e/crestodian-first-run-docker-client.ts b/scripts/e2e/crestodian-first-run-docker-client.ts index ef502773301..1dd6a3f4f04 100644 --- a/scripts/e2e/crestodian-first-run-docker-client.ts +++ b/scripts/e2e/crestodian-first-run-docker-client.ts @@ -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 { + 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 { + 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"); } diff --git a/scripts/e2e/crestodian-first-run-spec.json b/scripts/e2e/crestodian-first-run-spec.json new file mode 100644 index 00000000000..3cec5392544 --- /dev/null +++ b/scripts/e2e/crestodian-first-run-spec.json @@ -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" + ] +} diff --git a/src/crestodian/assistant-backends.ts b/src/crestodian/assistant-backends.ts new file mode 100644 index 00000000000..9d6de6251c9 --- /dev/null +++ b/src/crestodian/assistant-backends.ts @@ -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 }, + }, + }, + }; +} diff --git a/src/crestodian/assistant-prompts.ts b/src/crestodian/assistant-prompts.ts new file mode 100644 index 00000000000..6a586dddd76 --- /dev/null +++ b/src/crestodian/assistant-prompts.ts @@ -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 ", + "- config set ", + "- config set-ref env ", + "- create agent workspace model ", + "- talk to 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; + 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); +} diff --git a/src/crestodian/assistant.test.ts b/src/crestodian/assistant.test.ts index 85d749f2b39..91ff243b7fa 100644 --- a/src/crestodian/assistant.test.ts +++ b/src/crestodian/assistant.test.ts @@ -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"); diff --git a/src/crestodian/assistant.ts b/src/crestodian/assistant.ts index 7c06d1fc544..74d96b631f5 100644 --- a/src/crestodian/assistant.ts +++ b/src/crestodian/assistant.ts @@ -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 ", - "- config set ", - "- config set-ref env ", - "- create agent workspace model ", - "- talk to 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; }; -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; - 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 = { - "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[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 { return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-crestodian-planner-")); }