Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/agents/runtime-reflection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import {
loadRecentRuntimeReflections,
writeRuntimeReflection,
type RuntimeReflectionRecord,
} from "./runtime-reflection.js";

async function withTempDir(run: (dir: string) => Promise<void>): Promise<void> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-reflection-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}

describe("runtime reflections", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("writes a reflection artifact into the dated surface directory", async () => {
await withTempDir(async (dir) => {
vi.stubEnv("OPENCLAW_RUNTIME_REFLECTIONS_ROOT", dir);

const written = await writeRuntimeReflection({
surface: "cron",
entityId: "daily-knowledge-archiver",
sessionKey: "agent:ops:main",
taskShape: "debug_runtime",
claimedOutcome: "core_success",
observedOutcome: "partial_delivery_failure",
claimMismatch: true,
sourceRefs: ["cron:Daily Knowledge Archiver", "runs/daily.jsonl"],
benchmarkTags: ["cron-triage", "honest-outcome"],
operatorActionable: "Check delivery target and rerun only after confirming announce path.",
createdAt: "2026-03-24T10:00:00.000Z",
});

expect(written.record.id).toContain("cron-daily-knowledge-archiver");
expect(written.path).toContain(path.join("2026-03-24", "cron"));

const raw = JSON.parse(await fs.readFile(written.path, "utf8")) as RuntimeReflectionRecord;
expect(raw.surface).toBe("cron");
expect(raw.claimMismatch).toBe(true);
expect(raw.benchmarkTags).toContain("cron-triage");
});
});

it("loads recent reflections, filters by age, and sorts newest first", async () => {
await withTempDir(async (dir) => {
vi.stubEnv("OPENCLAW_RUNTIME_REFLECTIONS_ROOT", dir);
const nowMs = Date.parse("2026-03-24T12:00:00.000Z");

await writeRuntimeReflection({
surface: "startup_advisory",
entityId: "agent-main",
sessionKey: "agent:main:main",
taskShape: "skill_routing_policy",
claimedOutcome: "fresh_ready",
observedOutcome: "cached_advisory",
sourceRefs: ["agent:main:main"],
benchmarkTags: ["startup-advisory"],
operatorActionable: "Wait for background refresh before trusting auto-attach.",
createdAt: "2026-03-24T11:30:00.000Z",
});
await writeRuntimeReflection({
surface: "timeline_intel",
entityId: "home_timeline",
claimedOutcome: "top_item_candidate",
observedOutcome: "rejected_hype_noise",
sourceRefs: ["x_timeline_20260324_113000.json"],
benchmarkTags: ["001-timeline-intel"],
operatorActionable: "Keep freshness-first lane and reject hype-only tweets.",
createdAt: "2026-03-24T11:50:00.000Z",
});
await writeRuntimeReflection({
surface: "memory_operator",
entityId: "mem-legacy",
claimedOutcome: "edited",
observedOutcome: "edited",
sourceRefs: ["op-old"],
benchmarkTags: ["memory-operator"],
operatorActionable: "Legacy sample.",
createdAt: "2026-03-23T00:00:00.000Z",
});

const recent = await loadRecentRuntimeReflections({ nowMs, windowMs: 6 * 60 * 60_000 });
expect(recent.map((entry) => entry.record.surface)).toEqual([
"timeline_intel",
"startup_advisory",
]);
expect(recent[0]?.record.id).toContain("timeline-intel-home-timeline");
});
});
});
245 changes: 245 additions & 0 deletions src/agents/runtime-reflection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import fs from "node:fs/promises";
import path from "node:path";

export const DEFAULT_RUNTIME_REFLECTIONS_ROOT =
"/Users/lixun/.openclaw/workspace/artifacts/self-improve";
Comment on lines +4 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

硬编码的本地路径会导致其他环境构建/运行失败。

DEFAULT_RUNTIME_REFLECTIONS_ROOT 包含用户特定的本地路径 /Users/lixun/.openclaw/workspace/artifacts/self-improve,这在其他开发者机器或 CI 环境中将无法正常工作。

建议使用相对于项目根目录或通过配置解析的路径,例如:

🛠️ 建议修复
-export const DEFAULT_RUNTIME_REFLECTIONS_ROOT =
-  "/Users/lixun/.openclaw/workspace/artifacts/self-improve";
+import { resolveStateDir } from "../config/paths.js";
+
+export const DEFAULT_RUNTIME_REFLECTIONS_ROOT =
+  path.join(resolveStateDir(), "artifacts", "self-improve");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agents/runtime-reflection.ts` around lines 4 - 5,
DEFAULT_RUNTIME_REFLECTIONS_ROOT is hard-coded to a user-specific absolute path
which breaks on other machines; change it to derive from a configurable or
project-relative location (e.g., use process.env, a config value, or
path.resolve(__dirname, '..', '..', 'artifacts', 'self-improve')) and update the
export so DEFAULT_RUNTIME_REFLECTIONS_ROOT falls back to a sensible
project-relative default when the env/config is not set; modify the constant
definition in runtime-reflection.ts to read from the chosen config/env and
construct the path with Node's path.join/path.resolve instead of the literal
"/Users/lixun/..." string.


export type RuntimeReflectionSurface =
| "cron"
| "startup_advisory"
| "memory_operator"
| "timeline_intel";

export type RuntimeReflectionRecord = {
version: 1;
id: string;
surface: RuntimeReflectionSurface;
entityId: string;
sessionKey?: string;
taskShape?: string;
claimedOutcome: string;
observedOutcome: string;
claimMismatch?: boolean;
recoveryKind?: string;
sourceRefs: string[];
benchmarkTags: string[];
operatorActionable: string;
createdAt: string;
metadata?: Record<string, unknown>;
};

export type RuntimeReflectionWriteInput = Omit<RuntimeReflectionRecord, "version" | "id"> & {
id?: string;
};

export type RuntimeReflectionArtifact = {
path: string;
record: RuntimeReflectionRecord;
};

function resolveRuntimeReflectionsRoot(): string {
return (
process.env.OPENCLAW_RUNTIME_REFLECTIONS_ROOT?.trim() || DEFAULT_RUNTIME_REFLECTIONS_ROOT
);
}

function slugify(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "artifact";
}

function normalizeRefs(values: string[] | undefined): string[] {
return Array.from(
new Set((values ?? []).map((value) => value.trim()).filter(Boolean)),
);
}

function resolveCreatedAt(value?: string): string {
const createdAt = value?.trim() || new Date().toISOString();
const parsed = Date.parse(createdAt);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid runtime reflection createdAt: ${value}`);
}
return new Date(parsed).toISOString();
}

function buildRuntimeReflectionId(
surface: RuntimeReflectionSurface,
entityId: string,
createdAt: string,
): string {
const stamp = createdAt.replace(/[-:.TZ]/g, "").slice(0, 17);
return `${surface.replace(/_/g, "-")}-${slugify(entityId)}-${stamp}`;
}

function resolveRuntimeReflectionPath(record: RuntimeReflectionRecord): string {
const datePart = record.createdAt.slice(0, 10);
return path.join(
resolveRuntimeReflectionsRoot(),
datePart,
record.surface,
`${record.id}.json`,
);
}

async function resolveUniqueRuntimeReflectionArtifact(record: RuntimeReflectionRecord): Promise<{
record: RuntimeReflectionRecord;
path: string;
}> {
let nextRecord = record;
let filePath = resolveRuntimeReflectionPath(nextRecord);
let suffix = 2;
while (true) {
try {
await fs.access(filePath);
nextRecord = { ...record, id: `${record.id}-${suffix}` };
filePath = resolveRuntimeReflectionPath(nextRecord);
suffix += 1;
} catch {
return { record: nextRecord, path: filePath };
}
}
}

export async function writeRuntimeReflection(
input: RuntimeReflectionWriteInput,
): Promise<RuntimeReflectionArtifact> {
const createdAt = resolveCreatedAt(input.createdAt);
const record: RuntimeReflectionRecord = {
version: 1,
id: input.id?.trim() || buildRuntimeReflectionId(input.surface, input.entityId, createdAt),
surface: input.surface,
entityId: input.entityId.trim(),
sessionKey: input.sessionKey?.trim() || undefined,
taskShape: input.taskShape?.trim() || undefined,
claimedOutcome: input.claimedOutcome.trim(),
observedOutcome: input.observedOutcome.trim(),
claimMismatch: input.claimMismatch === true ? true : undefined,
recoveryKind: input.recoveryKind?.trim() || undefined,
sourceRefs: normalizeRefs(input.sourceRefs),
benchmarkTags: normalizeRefs(input.benchmarkTags),
operatorActionable: input.operatorActionable.trim(),
createdAt,
metadata: input.metadata,
};
const { record: uniqueRecord, path: filePath } =
await resolveUniqueRuntimeReflectionArtifact(record);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(uniqueRecord, null, 2)}\n`, "utf8");
return { path: filePath, record: uniqueRecord };
}

function parseReflectionRecord(raw: string, filePath: string): RuntimeReflectionRecord | null {
try {
const parsed = JSON.parse(raw) as Partial<RuntimeReflectionRecord> | null;
if (!parsed || parsed.version !== 1 || typeof parsed.id !== "string") {
return null;
}
if (
typeof parsed.surface !== "string" ||
typeof parsed.entityId !== "string" ||
typeof parsed.claimedOutcome !== "string" ||
typeof parsed.observedOutcome !== "string" ||
typeof parsed.operatorActionable !== "string" ||
typeof parsed.createdAt !== "string"
) {
return null;
}
const createdAt = resolveCreatedAt(parsed.createdAt);
return {
version: 1,
id: parsed.id,
surface: parsed.surface as RuntimeReflectionSurface,
entityId: parsed.entityId,
sessionKey: typeof parsed.sessionKey === "string" ? parsed.sessionKey : undefined,
taskShape: typeof parsed.taskShape === "string" ? parsed.taskShape : undefined,
claimedOutcome: parsed.claimedOutcome,
observedOutcome: parsed.observedOutcome,
claimMismatch: parsed.claimMismatch === true ? true : undefined,
recoveryKind: typeof parsed.recoveryKind === "string" ? parsed.recoveryKind : undefined,
sourceRefs: normalizeRefs(Array.isArray(parsed.sourceRefs) ? parsed.sourceRefs : []),
benchmarkTags: normalizeRefs(Array.isArray(parsed.benchmarkTags) ? parsed.benchmarkTags : []),
operatorActionable: parsed.operatorActionable,
createdAt,
metadata:
parsed.metadata && typeof parsed.metadata === "object"
? (parsed.metadata as Record<string, unknown>)
: undefined,
};
} catch {
return null;
}
}

async function walkJsonFiles(root: string): Promise<string[]> {
const pending = [root];
const results: string[] = [];
while (pending.length > 0) {
const current = pending.pop();
if (!current) {
continue;
}
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const nextPath = path.join(current, entry.name);
if (entry.isDirectory()) {
pending.push(nextPath);
} else if (entry.isFile() && entry.name.endsWith(".json")) {
results.push(nextPath);
}
}
}
return results;
}

export async function loadRecentRuntimeReflections(params?: {
nowMs?: number;
windowMs?: number;
surfaces?: RuntimeReflectionSurface[];
limit?: number;
}): Promise<RuntimeReflectionArtifact[]> {
const nowMs = params?.nowMs ?? Date.now();
const windowMs = Math.max(1, params?.windowMs ?? 24 * 60 * 60_000);
const limit = Math.max(1, params?.limit ?? 50);
const surfaceFilter = params?.surfaces?.length ? new Set(params.surfaces) : null;
const root = resolveRuntimeReflectionsRoot();
const files = await walkJsonFiles(root);
const artifacts: RuntimeReflectionArtifact[] = [];
for (const filePath of files) {
const raw = await fs.readFile(filePath, "utf8").catch(() => null);
if (!raw) {
continue;
}
const record = parseReflectionRecord(raw, filePath);
if (!record) {
continue;
}
if (surfaceFilter && !surfaceFilter.has(record.surface)) {
continue;
}
const createdAtMs = Date.parse(record.createdAt);
if (!Number.isFinite(createdAtMs) || nowMs - createdAtMs > windowMs) {
continue;
}
artifacts.push({ path: filePath, record });
}
return artifacts
.toSorted((a, b) => {
const bMs = Date.parse(b.record.createdAt);
const aMs = Date.parse(a.record.createdAt);
if (bMs !== aMs) {
return bMs - aMs;
}
return a.record.id.localeCompare(b.record.id);
})
.slice(0, limit);
}
Loading
Loading