diff --git a/lib/public/js/components/general/agent-defaults.js b/lib/public/js/components/general/agent-defaults.js new file mode 100644 index 00000000..1c3816b3 --- /dev/null +++ b/lib/public/js/components/general/agent-defaults.js @@ -0,0 +1,121 @@ +import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import htm from "htm"; +import { fetchAgentDefaults, updateAgentDefaults } from "../../lib/api.js"; +import { setCached } from "../../lib/api-cache.js"; +import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; +import { ChevronDownIcon } from "../icons.js"; +import { ToggleSwitch } from "../toggle-switch.js"; +import { showToast } from "../toast.js"; + +const html = htm.bind(h); + +const kAgentDefaultsCacheKey = "/api/agent-defaults"; +const kThinkingOptions = [ + { value: "off", label: "Off" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, +]; + +export const AgentDefaults = () => { + const { data, loading } = useCachedFetch( + kAgentDefaultsCacheKey, + fetchAgentDefaults, + { maxAgeMs: 30000 }, + ); + const serverDefaults = data?.agentDefaults || null; + const [savingKey, setSavingKey] = useState(""); + const [thinking, setThinking] = useState("medium"); + const [dreaming, setDreaming] = useState(false); + + useEffect(() => { + if (!serverDefaults) return; + if (typeof serverDefaults.thinking === "string") { + setThinking(serverDefaults.thinking); + } + if (typeof serverDefaults.dreaming === "boolean") { + setDreaming(serverDefaults.dreaming); + } + }, [serverDefaults?.thinking, serverDefaults?.dreaming]); + + const save = async (key, payload, optimistic) => { + const previous = key === "thinking" ? thinking : dreaming; + optimistic(); + setSavingKey(key); + try { + const response = await updateAgentDefaults(payload); + if (!response.ok) { + throw new Error(response.error || "Could not save agent defaults"); + } + const next = response.agentDefaults || {}; + if (typeof next.thinking === "string") setThinking(next.thinking); + if (typeof next.dreaming === "boolean") setDreaming(next.dreaming); + setCached(kAgentDefaultsCacheKey, { ...(data || {}), ...response }); + showToast("Agent defaults saved", "success"); + } catch (err) { + if (key === "thinking") setThinking(previous); + else setDreaming(previous); + showToast(err.message || "Could not save agent defaults", "error"); + } finally { + setSavingKey(""); + } + }; + + const handleThinkingChange = (event) => { + const nextThinking = String(event.target.value || ""); + save("thinking", { thinking: nextThinking }, () => setThinking(nextThinking)); + }; + + const handleDreamingChange = (nextDreaming) => { + save("dreaming", { dreaming: nextDreaming }, () => setDreaming(nextDreaming)); + }; + + const disabled = loading && !serverDefaults; + + return html` +
+

Agent Defaults

+
+
+
+

Thinking default

+

Default reasoning depth for agent calls.

+
+
+ + <${ChevronDownIcon} + className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted" + /> +
+
+
+
+

Dreaming

+

Allow the agent to run background ideation between turns.

+
+ <${ToggleSwitch} + checked=${dreaming} + disabled=${disabled || savingKey === "dreaming"} + onChange=${handleDreamingChange} + label=${dreaming ? "On" : "Off"} + /> +
+
+
+ `; +}; diff --git a/lib/public/js/components/general/index.js b/lib/public/js/components/general/index.js index b508c92d..76283e28 100644 --- a/lib/public/js/components/general/index.js +++ b/lib/public/js/components/general/index.js @@ -11,6 +11,7 @@ import { Features } from "../features.js"; import { GeneralDoctorWarning } from "../doctor/general-warning.js"; import { ChevronDownIcon } from "../icons.js"; import { UpdateActionButton } from "../update-action-button.js"; +import { AgentDefaults } from "./agent-defaults.js"; import { useGeneralTab } from "./use-general-tab.js"; const html = htm.bind(h); @@ -131,6 +132,7 @@ export const GeneralTab = ({ `} /> <${Features} onSwitchTab=${onSwitchTab} /> + <${AgentDefaults} /> <${Google} gatewayStatus=${state.gatewayStatus} onRestartRequired=${onRestartRequired} diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 324851b4..a32c7778 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -516,6 +516,40 @@ export async function updateAlphaclaw() { return res.json(); } +export async function fetchAgentDefaults() { + const res = await authFetch("/api/agent-defaults"); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + throw new Error(text || "Could not parse agent defaults response"); + } + if (!res.ok) { + throw new Error(data.error || text || `HTTP ${res.status}`); + } + return data; +} + +export async function updateAgentDefaults(payload) { + const res = await authFetch("/api/agent-defaults", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + throw new Error(text || "Could not parse agent defaults response"); + } + if (!res.ok) { + throw new Error(data.error || text || `HTTP ${res.status}`); + } + return data; +} + export async function fetchSyncCron() { const res = await authFetch("/api/sync-cron"); const text = await res.text(); diff --git a/lib/server/agent-defaults-config.js b/lib/server/agent-defaults-config.js new file mode 100644 index 00000000..8586837e --- /dev/null +++ b/lib/server/agent-defaults-config.js @@ -0,0 +1,107 @@ +const fs = require("fs"); +const { + readOpenclawConfig, + resolveOpenclawConfigPath, + writeOpenclawConfig, +} = require("./openclaw-config"); + +class MalformedOpenclawConfigError extends Error { + constructor(configPath) { + super( + `Refusing to overwrite ${configPath}: file exists but could not be parsed as JSON. Inspect or restore it before changing agent defaults.`, + ); + this.code = "MALFORMED_OPENCLAW_CONFIG"; + } +} + +const kThinkingLevels = Object.freeze(["off", "low", "medium", "high"]); +const kDefaultThinkingLevel = "medium"; +const kDefaultDreamingEnabled = false; + +const isThinkingLevel = (value) => + typeof value === "string" && kThinkingLevels.includes(value); + +const normalizeAgentDefaults = (rawConfig = {}) => { + const config = + rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig) + ? rawConfig + : {}; + const raw = + config.agentDefaults && + typeof config.agentDefaults === "object" && + !Array.isArray(config.agentDefaults) + ? config.agentDefaults + : {}; + return { + thinking: isThinkingLevel(raw.thinking) ? raw.thinking : kDefaultThinkingLevel, + dreaming: typeof raw.dreaming === "boolean" ? raw.dreaming : kDefaultDreamingEnabled, + }; +}; + +const readAgentDefaults = ({ fsModule = fs, openclawDir } = {}) => { + const config = readOpenclawConfig({ + fsModule, + openclawDir, + fallback: {}, + }); + return normalizeAgentDefaults(config); +}; + +const writeAgentDefaults = ({ + fsModule = fs, + openclawDir, + thinking, + dreaming, +} = {}) => { + const configPath = resolveOpenclawConfigPath({ openclawDir }); + const exists = + typeof fsModule.existsSync === "function" + ? fsModule.existsSync(configPath) + : null; + const config = readOpenclawConfig({ + fsModule, + openclawDir, + fallback: exists === true ? null : {}, + }); + if (exists === true && config === null) { + throw new MalformedOpenclawConfigError(configPath); + } + const safeConfig = + config && typeof config === "object" && !Array.isArray(config) ? config : {}; + const before = JSON.stringify(safeConfig.agentDefaults || null); + const next = normalizeAgentDefaults(safeConfig); + if (typeof thinking === "string") { + if (!isThinkingLevel(thinking)) { + throw new Error( + `thinking must be one of: ${kThinkingLevels.join(", ")}`, + ); + } + next.thinking = thinking; + } + if (typeof dreaming === "boolean") { + next.dreaming = dreaming; + } + safeConfig.agentDefaults = next; + const after = JSON.stringify(safeConfig.agentDefaults); + const changed = before !== after; + if (changed) { + writeOpenclawConfig({ + fsModule, + openclawDir, + config: safeConfig, + spacing: 2, + }); + } + return { changed, agentDefaults: next }; +}; + +module.exports = { + MalformedOpenclawConfigError, + kThinkingLevels, + kDefaultThinkingLevel, + kDefaultDreamingEnabled, + isThinkingLevel, + normalizeAgentDefaults, + readAgentDefaults, + writeAgentDefaults, +}; diff --git a/lib/server/constants.js b/lib/server/constants.js index 242dd61b..32802652 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -408,6 +408,7 @@ const SETUP_API_PREFIXES = [ "/api/channels", "/api/operations", "/api/nodes", + "/api/agent-defaults", ]; module.exports = { diff --git a/lib/server/doctor/service.js b/lib/server/doctor/service.js index 678004e2..2c5915bf 100644 --- a/lib/server/doctor/service.js +++ b/lib/server/doctor/service.js @@ -15,6 +15,7 @@ const { kDoctorRunTimeoutMs, kDoctorStaleThresholdMs, } = require("./constants"); +const { readAgentDefaults } = require("../agent-defaults-config"); const kMaxSnippetLines = 20; @@ -214,12 +215,13 @@ const createDoctorService = ({ promptVersion: kDoctorPromptVersion, }); const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000; + const { thinking } = readAgentDefaults({ openclawDir: managedRoot }); const gatewayParams = { agentId: "main", idempotencyKey: buildDoctorIdempotencyKey(runId), message: prompt, sessionKey: buildDoctorSessionKey(runId), - thinking: "medium", + thinking, timeout: Math.round(kDoctorRunTimeoutMs / 1000), }; const result = await clawCmd( diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index 28817a91..e16020af 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -1,5 +1,12 @@ const { buildManagedPaths } = require("../internal-files-migration"); const { readOpenclawConfig } = require("../openclaw-config"); +const { + MalformedOpenclawConfigError, + kThinkingLevels, + isThinkingLevel, + readAgentDefaults, + writeAgentDefaults, +} = require("../agent-defaults-config"); const https = require("https"); const registerSystemRoutes = ({ @@ -576,6 +583,57 @@ const registerSystemRoutes = ({ res.json({ ok: true, syncCron: status }); }); + app.get("/api/agent-defaults", (req, res) => { + try { + const agentDefaults = readAgentDefaults({ + fsModule: fs, + openclawDir: OPENCLAW_DIR, + }); + res.json({ ok: true, agentDefaults, thinkingLevels: kThinkingLevels }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } + }); + + app.put("/api/agent-defaults", (req, res) => { + const { thinking, dreaming } = req.body || {}; + if (thinking !== undefined && !isThinkingLevel(thinking)) { + return res.status(400).json({ + ok: false, + error: `thinking must be one of: ${kThinkingLevels.join(", ")}`, + }); + } + if (dreaming !== undefined && typeof dreaming !== "boolean") { + return res + .status(400) + .json({ ok: false, error: "dreaming must be a boolean" }); + } + if (thinking === undefined && dreaming === undefined) { + return res.status(400).json({ + ok: false, + error: "At least one of thinking or dreaming must be provided", + }); + } + try { + const result = writeAgentDefaults({ + fsModule: fs, + openclawDir: OPENCLAW_DIR, + thinking, + dreaming, + }); + res.json({ + ok: true, + changed: result.changed, + agentDefaults: result.agentDefaults, + }); + } catch (err) { + if (err instanceof MalformedOpenclawConfigError) { + return res.status(409).json({ ok: false, error: err.message }); + } + res.status(500).json({ ok: false, error: err.message }); + } + }); + app.get("/api/alphaclaw/version", async (req, res) => { const refresh = String(req.query.refresh || "") === "1"; const status = await alphaclawVersionService.getVersionStatus(refresh); diff --git a/tests/server/agent-defaults-config.test.js b/tests/server/agent-defaults-config.test.js new file mode 100644 index 00000000..4eb70316 --- /dev/null +++ b/tests/server/agent-defaults-config.test.js @@ -0,0 +1,138 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + MalformedOpenclawConfigError, + kThinkingLevels, + kDefaultThinkingLevel, + kDefaultDreamingEnabled, + normalizeAgentDefaults, + readAgentDefaults, + writeAgentDefaults, +} = require("../../lib/server/agent-defaults-config"); + +const createTempOpenclawDir = () => + fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-agent-defaults-test-")); + +describe("server/agent-defaults-config", () => { + it("falls back to safe defaults when openclaw.json is missing or empty", () => { + const openclawDir = createTempOpenclawDir(); + + expect(readAgentDefaults({ fsModule: fs, openclawDir })).toEqual({ + thinking: kDefaultThinkingLevel, + dreaming: kDefaultDreamingEnabled, + }); + }); + + it("normalizes invalid stored values to safe defaults", () => { + expect( + normalizeAgentDefaults({ + agentDefaults: { thinking: "ultra", dreaming: "yes" }, + }), + ).toEqual({ + thinking: kDefaultThinkingLevel, + dreaming: kDefaultDreamingEnabled, + }); + }); + + it("reads stored values verbatim when valid", () => { + const openclawDir = createTempOpenclawDir(); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify({ + agentDefaults: { thinking: "high", dreaming: true }, + }), + ); + + expect(readAgentDefaults({ fsModule: fs, openclawDir })).toEqual({ + thinking: "high", + dreaming: true, + }); + }); + + it("writes only the keys provided and preserves existing siblings", () => { + const openclawDir = createTempOpenclawDir(); + const configPath = path.join(openclawDir, "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + tools: { profile: "full" }, + agentDefaults: { thinking: "low", dreaming: true }, + }), + ); + + const result = writeAgentDefaults({ + fsModule: fs, + openclawDir, + thinking: "high", + }); + + expect(result.changed).toBe(true); + expect(result.agentDefaults).toEqual({ thinking: "high", dreaming: true }); + const stored = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(stored).toEqual({ + tools: { profile: "full" }, + agentDefaults: { thinking: "high", dreaming: true }, + }); + }); + + it("reports changed=false when the write is a no-op", () => { + const openclawDir = createTempOpenclawDir(); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify({ + agentDefaults: { thinking: "medium", dreaming: false }, + }), + ); + + const result = writeAgentDefaults({ + fsModule: fs, + openclawDir, + thinking: "medium", + dreaming: false, + }); + + expect(result.changed).toBe(false); + }); + + it("rejects invalid thinking values", () => { + const openclawDir = createTempOpenclawDir(); + expect(() => + writeAgentDefaults({ fsModule: fs, openclawDir, thinking: "max" }), + ).toThrow(/thinking must be one of/); + }); + + it("exposes the canonical thinking level list", () => { + expect(kThinkingLevels).toEqual(["off", "low", "medium", "high"]); + }); + + it("refuses to overwrite when openclaw.json exists but is malformed", () => { + const openclawDir = createTempOpenclawDir(); + const configPath = path.join(openclawDir, "openclaw.json"); + fs.writeFileSync(configPath, "{ malformed:"); + + expect(() => + writeAgentDefaults({ fsModule: fs, openclawDir, thinking: "high" }), + ).toThrow(MalformedOpenclawConfigError); + + expect(fs.readFileSync(configPath, "utf8")).toBe("{ malformed:"); + }); + + it("creates openclaw.json from scratch when absent", () => { + const openclawDir = createTempOpenclawDir(); + const configPath = path.join(openclawDir, "openclaw.json"); + + const result = writeAgentDefaults({ + fsModule: fs, + openclawDir, + thinking: "high", + dreaming: true, + }); + + expect(result.changed).toBe(true); + expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual({ + agentDefaults: { thinking: "high", dreaming: true }, + }); + }); +}); diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index 2cb044f5..b2cf5d05 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -78,6 +78,7 @@ const createSystemDeps = () => { restartInProgress: false, gatewayRunning: true, })), + markRequired: vi.fn(), markRestartInProgress: vi.fn(), clearRequired: vi.fn(), markRestartComplete: vi.fn(), @@ -666,4 +667,143 @@ describe("server/routes/system", () => { }, ]); }); + + describe("/api/agent-defaults", () => { + const buildAgentDefaultsDeps = (storedConfig) => { + const deps = createSystemDeps(); + const stored = { value: storedConfig ? { ...storedConfig } : null }; + deps.fs.readFileSync = vi.fn((filePath) => { + if ( + typeof filePath === "string" && + filePath.endsWith("openclaw.json") && + stored.value + ) { + return JSON.stringify(stored.value); + } + throw new Error("ENOENT"); + }); + deps.fs.writeFileSync = vi.fn((filePath, content) => { + if (typeof filePath === "string" && filePath.endsWith("openclaw.json")) { + stored.value = JSON.parse(content); + } + }); + deps.fs.mkdirSync = vi.fn(); + return { deps, stored }; + }; + + it("returns defaults when openclaw.json is missing", async () => { + const { deps } = buildAgentDefaultsDeps(null); + const app = createApp(deps); + + const res = await request(app).get("/api/agent-defaults"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + agentDefaults: { thinking: "medium", dreaming: false }, + thinkingLevels: ["off", "low", "medium", "high"], + }); + }); + + it("returns stored agent defaults", async () => { + const { deps } = buildAgentDefaultsDeps({ + agentDefaults: { thinking: "high", dreaming: true }, + }); + const app = createApp(deps); + + const res = await request(app).get("/api/agent-defaults"); + + expect(res.status).toBe(200); + expect(res.body.agentDefaults).toEqual({ thinking: "high", dreaming: true }); + }); + + it("rejects invalid thinking values on PUT", async () => { + const { deps } = buildAgentDefaultsDeps(null); + const app = createApp(deps); + + const res = await request(app) + .put("/api/agent-defaults") + .send({ thinking: "max" }); + + expect(res.status).toBe(400); + expect(res.body.ok).toBe(false); + expect(res.body.error).toContain("thinking must be one of"); + }); + + it("rejects non-boolean dreaming values on PUT", async () => { + const { deps } = buildAgentDefaultsDeps(null); + const app = createApp(deps); + + const res = await request(app) + .put("/api/agent-defaults") + .send({ dreaming: "yes" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("dreaming must be a boolean"); + }); + + it("rejects empty payloads on PUT", async () => { + const { deps } = buildAgentDefaultsDeps(null); + const app = createApp(deps); + + const res = await request(app).put("/api/agent-defaults").send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("At least one of thinking or dreaming"); + }); + + it("persists changes and preserves unrelated config keys", async () => { + const { deps, stored } = buildAgentDefaultsDeps({ + tools: { profile: "full" }, + }); + const app = createApp(deps); + + const res = await request(app) + .put("/api/agent-defaults") + .send({ thinking: "high", dreaming: true }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + changed: true, + agentDefaults: { thinking: "high", dreaming: true }, + }); + expect(stored.value.agentDefaults).toEqual({ + thinking: "high", + dreaming: true, + }); + expect(stored.value.tools).toEqual({ profile: "full" }); + }); + + it("reports no change for no-op writes", async () => { + const { deps } = buildAgentDefaultsDeps({ + agentDefaults: { thinking: "medium", dreaming: false }, + }); + const app = createApp(deps); + + const res = await request(app) + .put("/api/agent-defaults") + .send({ thinking: "medium", dreaming: false }); + + expect(res.status).toBe(200); + expect(res.body.changed).toBe(false); + }); + + it("refuses to overwrite when openclaw.json exists but cannot be parsed", async () => { + const { deps } = buildAgentDefaultsDeps(null); + deps.fs.existsSync = vi.fn(() => true); + deps.fs.readFileSync = vi.fn(() => "{ this is not json"); + const writeSpy = vi.spyOn(deps.fs, "writeFileSync"); + const app = createApp(deps); + + const res = await request(app) + .put("/api/agent-defaults") + .send({ thinking: "high" }); + + expect(res.status).toBe(409); + expect(res.body.ok).toBe(false); + expect(res.body.error).toMatch(/could not be parsed/i); + expect(writeSpy).not.toHaveBeenCalled(); + }); + }); });