diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec3997e9..23ac16719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot ### Fixes - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - UI: remove the chat stop button and keep the composer aligned to the bottom edge. +- Agents: add diagnostics cache trace config and fix cache trace logging edge cases. (#1370) — thanks @parubets. ## 2026.1.20 diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts new file mode 100644 index 000000000..bce1a1d35 --- /dev/null +++ b/src/agents/cache-trace.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveUserPath } from "../utils.js"; +import { createCacheTrace } from "./cache-trace.js"; + +describe("createCacheTrace", () => { + it("returns null when diagnostics cache tracing is disabled", () => { + const trace = createCacheTrace({ + cfg: {} as ClawdbotConfig, + env: {}, + }); + + expect(trace).toBeNull(); + }); + + it("honors diagnostics cache trace config and expands file paths", () => { + const lines: string[] = []; + const trace = createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + filePath: "~/.clawdbot/logs/cache-trace.jsonl", + }, + }, + }, + env: {}, + writer: { + filePath: "memory", + write: (line) => lines.push(line), + }, + }); + + expect(trace).not.toBeNull(); + expect(trace?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/cache-trace.jsonl")); + + trace?.recordStage("session:loaded", { + messages: [], + system: "sys", + }); + + expect(lines.length).toBe(1); + }); + + it("records empty prompt/system values when enabled", () => { + const lines: string[] = []; + const trace = createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + includePrompt: true, + includeSystem: true, + }, + }, + }, + env: {}, + writer: { + filePath: "memory", + write: (line) => lines.push(line), + }, + }); + + trace?.recordStage("prompt:before", { prompt: "", system: "" }); + + const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; + expect(event.prompt).toBe(""); + expect(event.system).toBe(""); + }); + + it("respects env overrides for enablement", () => { + const lines: string[] = []; + const trace = createCacheTrace({ + cfg: { + diagnostics: { + cacheTrace: { + enabled: true, + }, + }, + }, + env: { + CLAWDBOT_CACHE_TRACE: "0", + }, + writer: { + filePath: "memory", + write: (line) => lines.push(line), + }, + }); + + expect(trace).toBeNull(); + }); +}); diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index b86c15a13..ed561a11b 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -7,8 +7,8 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; -import { isTruthyEnvValue } from "../infra/env.js"; import { parseBooleanValue } from "../utils/boolean.js"; +import { resolveUserPath } from "../utils.js"; export type CacheTraceStage = | "session:loaded" @@ -61,6 +61,7 @@ type CacheTraceInit = { modelId?: string; modelApi?: string | null; workspaceDir?: string; + writer?: CacheTraceWriter; }; type CacheTraceConfig = { @@ -80,14 +81,18 @@ const writers = new Map(); function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig { const env = params.env ?? process.env; - const enabled = isTruthyEnvValue(env.CLAWDBOT_CACHE_TRACE); - const filePath = - env.CLAWDBOT_CACHE_TRACE_FILE?.trim() || - path.join(resolveStateDir(env), "logs", "cache-trace.jsonl"); + const config = params.cfg?.diagnostics?.cacheTrace; + const envEnabled = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE); + const enabled = envEnabled ?? config?.enabled ?? false; + const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_CACHE_TRACE_FILE?.trim(); + const filePath = fileOverride + ? resolveUserPath(fileOverride) + : path.join(resolveStateDir(env), "logs", "cache-trace.jsonl"); - const includeMessages = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_MESSAGES); - const includePrompt = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_PROMPT); - const includeSystem = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_SYSTEM); + const includeMessages = + parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_MESSAGES) ?? config?.includeMessages; + const includePrompt = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_PROMPT) ?? config?.includePrompt; + const includeSystem = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_SYSTEM) ?? config?.includeSystem; return { enabled, @@ -189,7 +194,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { const cfg = resolveCacheTraceConfig(params); if (!cfg.enabled) return null; - const writer = getWriter(cfg.filePath); + const writer = params.writer ?? getWriter(cfg.filePath); let seq = 0; const base: Omit = { @@ -210,10 +215,10 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { stage, }; - if (payload.prompt && cfg.includePrompt) { + if (payload.prompt !== undefined && cfg.includePrompt) { event.prompt = payload.prompt; } - if (payload.system && cfg.includeSystem) { + if (payload.system !== undefined && cfg.includeSystem) { event.system = payload.system; event.systemDigest = digest(payload.system); } diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 59e1f428f..33038496d 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -115,6 +115,10 @@ describe("exec approvals CLI", () => { runtimeErrors.length = 0; callGatewayFromCli.mockClear(); + const execApprovals = await import("../infra/exec-approvals.js"); + const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals); + saveExecApprovals.mockClear(); + const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js"); const program = new Command(); program.exitOverride(); @@ -122,9 +126,17 @@ describe("exec approvals CLI", () => { await program.parseAsync(["approvals", "allowlist", "add", "/usr/bin/uname"], { from: "user" }); - const setCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "exec.approvals.set"); - expect(setCall).toBeTruthy(); - const params = setCall?.[2] as { file: { agents?: Record } }; - expect(params.file.agents?.["*"]).toBeTruthy(); + expect(callGatewayFromCli).not.toHaveBeenCalledWith( + "exec.approvals.set", + expect.anything(), + {}, + ); + expect(saveExecApprovals).toHaveBeenCalledWith( + expect.objectContaining({ + agents: expect.objectContaining({ + "*": expect.anything(), + }), + }), + ); }); }); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index dd706f5ed..7aae5c0dc 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -13,6 +13,7 @@ import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { renderTable } from "../terminal/table.js"; import { callGatewayFromCli } from "./gateway-rpc.js"; +import { describeUnknownError } from "./gateway-cli/shared.js"; import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js"; import type { NodesRpcOpts } from "./nodes-cli/types.js"; @@ -96,7 +97,7 @@ async function loadSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{ } function formatCliError(err: unknown): string { - const msg = String(err ?? "unknown error"); + const msg = describeUnknownError(err); return msg.includes("\n") ? msg.split("\n")[0] : msg; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 31979f435..a0955e47f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -114,6 +114,11 @@ const FIELD_LABELS: Record = { "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", + "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", + "diagnostics.cacheTrace.filePath": "Cache Trace File Path", + "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", + "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", + "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -345,6 +350,14 @@ const FIELD_HELP: Record = { "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", "gateway.nodes.denyCommands": "Commands to block even if present in node claims or default allowlist.", + "diagnostics.cacheTrace.enabled": + "Log cache trace snapshots for embedded agent runs (default: false).", + "diagnostics.cacheTrace.filePath": + "JSONL output path for cache trace logs (default: $CLAWDBOT_STATE_DIR/logs/cache-trace.jsonl).", + "diagnostics.cacheTrace.includeMessages": + "Include full message payloads in trace output (default: true).", + "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", + "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", "tools.exec.applyPatch.enabled": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "tools.exec.applyPatch.allowModels": diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 827ec5abb..0796fa64c 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -117,9 +117,18 @@ export type DiagnosticsOtelConfig = { flushIntervalMs?: number; }; +export type DiagnosticsCacheTraceConfig = { + enabled?: boolean; + filePath?: string; + includeMessages?: boolean; + includePrompt?: boolean; + includeSystem?: boolean; +}; + export type DiagnosticsConfig = { enabled?: boolean; otel?: DiagnosticsOtelConfig; + cacheTrace?: DiagnosticsCacheTraceConfig; }; export type WebReconnectConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c0ba37a88..41fbdd57d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -63,6 +63,16 @@ export const ClawdbotSchema = z }) .strict() .optional(), + cacheTrace: z + .object({ + enabled: z.boolean().optional(), + filePath: z.string().optional(), + includeMessages: z.boolean().optional(), + includePrompt: z.boolean().optional(), + includeSystem: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict() .optional(),