feat(system-agent): agentic cached caretaker greeting with quick actions (#111615)

* feat(system-agent): agentic cached caretaker greeting with quick actions

* test(gateway): pin caretaker greeting in session-ownership tests

* fix(system-agent): offer agent handoff chip only when a model is available

* fix(ci): typecheck narrowing, lint, and knip gates for greeting module

* fix(system-agent): reject structured greeting output on every retained line

* test(gateway): split caretaker welcome tests under the max-lines gate
This commit is contained in:
Peter Steinberger
2026-07-20 01:21:09 -07:00
committed by GitHub
parent 6eee1dccfc
commit 629de998f9
16 changed files with 2121 additions and 58 deletions
+3
View File
@@ -361,6 +361,9 @@ const config = {
// GatewayBoardProvider and boardExists are constructed/asserted by the
// focused Control UI provider tests, not by a separate production module.
"ui/src/lib/board/provider.ts": ["exports"],
// Greeting cache/fact contracts (hash, alert text, store shapes) are
// asserted by the focused greeting unit tests, not by another prod module.
"src/system-agent/greeting.ts": ["exports", "types"],
},
workspaces: {
".": {
@@ -134,6 +134,8 @@ type GatewaySystemAgentSession = {
};
welcome: string;
welcomeQuestion?: SystemAgentChatQuestion;
/** Audit cursor captured with the pending caretaker welcome; cleared after delivery. */
welcomeAuditSequence?: number;
lastUsedAt: number;
ownerKey: string;
pendingApproval?: { id: string; proposalHash: string };
@@ -0,0 +1,269 @@
// Focused welcome-delivery tests for openclaw.chat: caretaker greeting wiring,
// audit-cursor acknowledgement, and the onboarding template path.
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js";
import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js";
import type { GatewayClient, GatewayRequestContext } from "./types.js";
const setupInferenceMocks = vi.hoisted(() => ({ verifySetupInference: vi.fn() }));
const transcriptStoreMocks = vi.hoisted(() => ({
appendTranscriptReset: vi.fn(),
appendTranscriptTurn: vi.fn(),
readTranscriptTail: vi.fn(() => []),
}));
const greetingMocks = vi.hoisted(() => ({
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
buildSystemAgentGreetingQuestion: vi.fn(),
loadSystemAgentGreetingFacts: vi.fn(),
resolveSystemAgentGreeting: vi.fn(),
}));
const onboardingWelcomeMocks = vi.hoisted(() => ({ buildOnboardingWelcome: vi.fn() }));
vi.mock("../../system-agent/setup-inference.js", () => ({
verifySetupInference: setupInferenceMocks.verifySetupInference,
}));
vi.mock("../../system-agent/transcript-store.js", () => ({
appendTranscriptReset: transcriptStoreMocks.appendTranscriptReset,
appendTranscriptTurn: transcriptStoreMocks.appendTranscriptTurn,
readTranscriptTail: transcriptStoreMocks.readTranscriptTail,
}));
vi.mock("../../system-agent/greeting.js", () => ({
acknowledgeSystemAgentGreetingDelivery: greetingMocks.acknowledgeSystemAgentGreetingDelivery,
buildSystemAgentGreetingQuestion: greetingMocks.buildSystemAgentGreetingQuestion,
loadSystemAgentGreetingFacts: greetingMocks.loadSystemAgentGreetingFacts,
resolveSystemAgentGreeting: greetingMocks.resolveSystemAgentGreeting,
}));
vi.mock("../../system-agent/onboarding-welcome.js", () => ({
buildOnboardingWelcome: onboardingWelcomeMocks.buildOnboardingWelcome,
}));
type FakeEngine = {
handle: ReturnType<typeof vi.fn>;
seedHistory: ReturnType<typeof vi.fn>;
historyLength: ReturnType<typeof vi.fn>;
historySince: ReturnType<typeof vi.fn>;
getPendingOperatorProposal: ReturnType<typeof vi.fn>;
resolveOperatorApproval: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
loadOverview: ReturnType<typeof vi.fn>;
noteAssistantMessage: ReturnType<typeof vi.fn>;
planGreeting: ReturnType<typeof vi.fn>;
};
function makeEngine(): FakeEngine {
// Mirrors persistEngineHistory's contract: noted assistant messages appear
// in historySince so the welcome is persisted before acknowledgement.
const history: Array<{ role: "user" | "assistant"; text: string }> = [];
return {
handle: vi.fn(async () => ({ text: "did the thing", action: "none" })),
seedHistory: vi.fn(),
historyLength: vi.fn(() => history.length),
historySince: vi.fn((index: number) => history.slice(index)),
getPendingOperatorProposal: vi.fn(() => null),
resolveOperatorApproval: vi.fn(async () => null),
dispose: vi.fn(async () => undefined),
loadOverview: vi.fn(async () => ({})),
noteAssistantMessage: vi.fn((text: string) => {
history.push({ role: "assistant", text });
}),
planGreeting: vi.fn(),
};
}
const createdEngines = vi.hoisted(() => [] as FakeEngine[]);
vi.mock("../../system-agent/chat-engine.js", () => ({
SystemAgentChatEngine: function FakeSystemAgentChatEngine(this: FakeEngine) {
const engine = makeEngine();
createdEngines.push(engine);
Object.assign(this, engine);
},
}));
type RespondCall = { ok: boolean; payload?: unknown; error?: unknown };
const defaultClient = {
connId: "conn-test",
connect: { device: { id: "device-test" } },
} as GatewayClient;
function makeContext(sessions: Map<string, SystemAgentChatSession>): GatewayRequestContext {
return { systemAgentSessions: sessions } as unknown as GatewayRequestContext;
}
async function callChat(
context: GatewayRequestContext,
params: Record<string, unknown>,
): Promise<RespondCall> {
const calls: RespondCall[] = [];
const respond = (ok: boolean, payload?: unknown, error?: unknown) => {
calls.push({ ok, payload, error });
};
await expectDefined(
systemAgentHandlers["openclaw.chat"],
'systemAgentHandlers["openclaw.chat"] test invariant',
)({ params, respond, context, client: defaultClient } as never);
return expectDefined(calls[0], "system-agent response");
}
const quickActions = {
id: "system-agent-quick-actions",
header: "Quick actions",
question: "What would you like me to do?",
options: [
{ label: "Show update", reply: "status" },
{ label: "Talk to my agent", reply: "talk to agent" },
{ label: "Review recent changes", reply: "audit" },
],
};
beforeEach(() => {
createdEngines.length = 0;
setupInferenceMocks.verifySetupInference.mockResolvedValue({ ok: true, binding: {} });
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValue({
updateAvailable: null,
channelHealth: { available: true, degraded: [] },
recentExternalEdit: false,
auditSequence: 0,
});
greetingMocks.resolveSystemAgentGreeting.mockResolvedValue({
text: "I'm OpenClaw. All systems nominal.",
source: "model",
});
greetingMocks.buildSystemAgentGreetingQuestion.mockReturnValue(quickActions);
onboardingWelcomeMocks.buildOnboardingWelcome.mockResolvedValue({
text: "Inference is ready. Let's finish setup.",
});
});
afterEach(() => {
vi.clearAllMocks();
transcriptStoreMocks.readTranscriptTail.mockReturnValue([]);
resetCommandQueueStateForTest();
});
describe("openclaw.chat caretaker welcome", () => {
it("returns caretaker quick actions and persists the resolved greeting", async () => {
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValueOnce({
updateAvailable: "2026.7.20",
channelHealth: { available: true, degraded: [] },
recentExternalEdit: true,
auditSequence: 42,
});
greetingMocks.resolveSystemAgentGreeting.mockResolvedValueOnce({
text: "I'm healthy. An update is ready, and I noticed a manual config edit.",
source: "model",
});
const call = await callChat(makeContext(new Map()), { sessionId: "caretaker-welcome" });
expect(call.payload).toMatchObject({
reply: "I'm healthy. An update is ready, and I noticed a manual config edit.",
question: {
header: "Quick actions",
options: [
{ label: "Show update", reply: "status" },
{ label: "Talk to my agent", reply: "talk to agent" },
{ label: "Review recent changes", reply: "audit" },
],
},
});
expect(greetingMocks.resolveSystemAgentGreeting).toHaveBeenCalledWith(
expect.objectContaining({ allowInference: true }),
);
expect(transcriptStoreMocks.appendTranscriptTurn).toHaveBeenCalledWith(
expect.objectContaining({
role: "assistant",
text: "I'm healthy. An update is ready, and I noticed a manual config edit.",
}),
);
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).toHaveBeenCalledWith({
auditSequence: 42,
});
expect(transcriptStoreMocks.appendTranscriptTurn.mock.invocationCallOrder[0]).toBeLessThan(
greetingMocks.acknowledgeSystemAgentGreetingDelivery.mock.invocationCallOrder[0] ?? 0,
);
});
it("does not plan a greeting when a fresh session is created with a message", async () => {
const sessions = new Map<string, SystemAgentChatSession>();
greetingMocks.resolveSystemAgentGreeting.mockResolvedValueOnce({
text: "Hi, I'm OpenClaw — caretaker of this gateway, config, channels, and agents.",
source: "template",
});
const context = makeContext(sessions);
const call = await callChat(context, {
sessionId: "fresh-with-message",
message: "status",
});
expect(call.payload).toMatchObject({ reply: "did the thing", action: "none" });
expect(greetingMocks.resolveSystemAgentGreeting).toHaveBeenCalledWith(
expect.objectContaining({ allowInference: false }),
);
expect(createdEngines[0]?.planGreeting).not.toHaveBeenCalled();
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).not.toHaveBeenCalled();
expect(sessions.get("fresh-with-message")?.welcomeAuditSequence).toBe(0);
const welcome = await callChat(context, { sessionId: "fresh-with-message" });
expect(welcome.payload).toMatchObject({
reply: "Hi, I'm OpenClaw — caretaker of this gateway, config, channels, and agents.",
});
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).toHaveBeenCalledWith({
auditSequence: 0,
});
expect(sessions.get("fresh-with-message")?.welcomeAuditSequence).toBeUndefined();
});
it("does not acknowledge audit entries when greeting delivery fails", async () => {
const sessions = new Map<string, SystemAgentChatSession>();
const context = makeContext(sessions);
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValueOnce({
updateAvailable: null,
channelHealth: { available: true, degraded: [] },
recentExternalEdit: true,
auditSequence: 42,
});
await expect(
expectDefined(
systemAgentHandlers["openclaw.chat"],
'systemAgentHandlers["openclaw.chat"] test invariant',
)({
params: { sessionId: "failed-delivery" },
respond: () => {
throw new Error("socket closed");
},
context,
client: defaultClient,
} as never),
).rejects.toThrow("socket closed");
expect(transcriptStoreMocks.appendTranscriptTurn).toHaveBeenCalled();
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).not.toHaveBeenCalled();
expect(sessions.get("failed-delivery")?.welcomeAuditSequence).toBe(42);
const retry = await callChat(context, { sessionId: "failed-delivery" });
expect(retry.payload).toMatchObject({ reply: "I'm OpenClaw. All systems nominal." });
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).toHaveBeenCalledWith({
auditSequence: 42,
});
expect(sessions.get("failed-delivery")?.welcomeAuditSequence).toBeUndefined();
});
it("keeps onboarding on its dedicated template path", async () => {
const call = await callChat(makeContext(new Map()), {
sessionId: "onboarding-welcome",
welcomeVariant: "onboarding",
});
expect(call.payload).toMatchObject({ reply: "Inference is ready. Let's finish setup." });
expect(onboardingWelcomeMocks.buildOnboardingWelcome).toHaveBeenCalledOnce();
expect(greetingMocks.loadSystemAgentGreetingFacts).not.toHaveBeenCalled();
expect(greetingMocks.resolveSystemAgentGreeting).not.toHaveBeenCalled();
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).not.toHaveBeenCalled();
});
});
@@ -23,6 +23,19 @@ vi.mock("../../system-agent/transcript-store.js", () => ({
appendTranscriptTurn: vi.fn(),
readTranscriptTail: vi.fn(() => []),
}));
// Ownership tests exercise fresh-session creation; keep the caretaker greeting
// deterministic so identity behavior is the only variable under test.
vi.mock("../../system-agent/greeting.js", () => ({
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
buildSystemAgentGreetingQuestion: vi.fn(() => undefined),
loadSystemAgentGreetingFacts: vi.fn(() => ({
updateAvailable: null,
channelHealth: { available: true, degraded: [] },
recentExternalEdit: false,
auditSequence: 0,
})),
resolveSystemAgentGreeting: vi.fn(async () => ({ text: "welcome text", source: "template" })),
}));
type FakeEngine = {
handle: ReturnType<typeof vi.fn>;
@@ -47,6 +47,14 @@ const transcriptStoreMocks = vi.hoisted(() => ({
(limit: number) => Array<{ role: "user" | "assistant"; text: string; at: number }>
>(() => []),
}));
const greetingMocks = vi.hoisted(() => ({
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
loadSystemAgentGreetingFacts: vi.fn(),
resolveSystemAgentGreeting: vi.fn(),
}));
const onboardingWelcomeMocks = vi.hoisted(() => ({
buildOnboardingWelcome: vi.fn(),
}));
vi.mock("../../system-agent/setup-inference.js", () => ({
activateSetupInference: setupInferenceMocks.activateSetupInference,
@@ -68,6 +76,18 @@ vi.mock("../../system-agent/transcript-store.js", () => ({
appendTranscriptTurn: transcriptStoreMocks.appendTranscriptTurn,
readTranscriptTail: transcriptStoreMocks.readTranscriptTail,
}));
vi.mock("../../system-agent/greeting.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../system-agent/greeting.js")>();
return {
...actual,
acknowledgeSystemAgentGreetingDelivery: greetingMocks.acknowledgeSystemAgentGreetingDelivery,
loadSystemAgentGreetingFacts: greetingMocks.loadSystemAgentGreetingFacts,
resolveSystemAgentGreeting: greetingMocks.resolveSystemAgentGreeting,
};
});
vi.mock("../../system-agent/onboarding-welcome.js", () => ({
buildOnboardingWelcome: onboardingWelcomeMocks.buildOnboardingWelcome,
}));
type RespondCall = {
ok: boolean;
@@ -190,6 +210,20 @@ beforeEach(async () => {
transcriptStoreMocks.appendTranscriptTurn.mockReset();
transcriptStoreMocks.appendTranscriptReset.mockReset();
transcriptStoreMocks.readTranscriptTail.mockReset().mockReturnValue([]);
greetingMocks.acknowledgeSystemAgentGreetingDelivery.mockReset();
greetingMocks.loadSystemAgentGreetingFacts.mockReset().mockReturnValue({
updateAvailable: null,
channelHealth: { available: true, degraded: [] },
recentExternalEdit: false,
auditSequence: 0,
});
greetingMocks.resolveSystemAgentGreeting.mockReset().mockResolvedValue({
text: "I'm OpenClaw. All systems nominal.",
source: "model",
});
onboardingWelcomeMocks.buildOnboardingWelcome.mockReset().mockResolvedValue({
text: "Inference is ready. Let's finish setup.",
});
});
afterEach(() => {
@@ -201,6 +235,10 @@ afterEach(() => {
providerAuthChoiceMocks.applyAuthChoiceLoadedPluginProvider.mockReset();
setupSharedMocks.readSetupConfigFileSnapshot.mockReset();
setupSharedMocks.writeWizardConfigFile.mockReset();
greetingMocks.loadSystemAgentGreetingFacts.mockReset();
greetingMocks.resolveSystemAgentGreeting.mockReset();
greetingMocks.acknowledgeSystemAgentGreetingDelivery.mockReset();
onboardingWelcomeMocks.buildOnboardingWelcome.mockReset();
verifiedInference = undefined;
verifiedInferenceDeps = undefined;
resetCommandQueueStateForTest();
+36 -3
View File
@@ -23,11 +23,16 @@ import { CommandLane } from "../../process/lanes.js";
import { defaultRuntime } from "../../runtime.js";
import { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
import { resolveSystemAgentDelegationKey } from "../../system-agent/delegation-session.js";
import {
acknowledgeSystemAgentGreetingDelivery,
buildSystemAgentGreetingQuestion,
loadSystemAgentGreetingFacts,
resolveSystemAgentGreeting,
} from "../../system-agent/greeting.js";
import { isSystemAgentInferenceUnavailableError } from "../../system-agent/inference-error.js";
import { buildNewAgentWelcome } from "../../system-agent/new-agent-welcome.js";
import { buildOnboardingWelcome } from "../../system-agent/onboarding-welcome.js";
import { describeSystemAgentPersistentOperation } from "../../system-agent/operations.js";
import { formatSystemAgentStartupMessage } from "../../system-agent/overview.js";
import {
appendTranscriptReset,
appendTranscriptTurn,
@@ -79,6 +84,15 @@ function getSystemAgentSessionQueue(
return queue;
}
function acknowledgeDeliveredSystemAgentWelcome(session: SystemAgentChatSession): void {
const auditSequence = session.welcomeAuditSequence;
if (auditSequence === undefined) {
return;
}
acknowledgeSystemAgentGreetingDelivery({ auditSequence });
delete session.welcomeAuditSequence;
}
async function runSystemAgentGatewayTask<T>(task: () => Promise<T>): Promise<T> {
// Track every accepted RPC as active, never queued: restart draining snapshots
// active ids, so a queued OpenClaw request could otherwise outlive its socket.
@@ -521,6 +535,8 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
await existing?.engine.dispose();
}
let session = sessions.get(sessionId);
let greetingAuditSequence: number | undefined;
const welcomeOnly = params.message === undefined || !params.message.trim();
if (!session) {
const inference = params.delegation
? await import("../../system-agent/inference-fallback.js").then(
@@ -572,7 +588,18 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
} else if (params.welcomeVariant === "new-agent") {
welcome = buildNewAgentWelcome({ engine });
} else {
welcome = formatSystemAgentStartupMessage(await engine.loadOverview());
const overview = await engine.loadOverview();
const facts = loadSystemAgentGreetingFacts();
greetingAuditSequence = facts.auditSequence;
welcome = (
await resolveSystemAgentGreeting({
overview,
facts,
planner: (plannerParams) => engine.planGreeting(plannerParams),
allowInference: welcomeOnly,
})
).text;
welcomeQuestion = buildSystemAgentGreetingQuestion(overview, facts);
engine.noteAssistantMessage(welcome);
}
} catch (error) {
@@ -592,11 +619,14 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
engine,
welcome,
...(welcomeQuestion ? { welcomeQuestion } : {}),
...(greetingAuditSequence !== undefined
? { welcomeAuditSequence: greetingAuditSequence }
: {}),
lastUsedAt: Date.now(),
ownerKey,
};
sessions.set(sessionId, session);
if (params.message === undefined || !params.message.trim()) {
if (welcomeOnly) {
respond(
true,
{
@@ -607,10 +637,12 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
},
undefined,
);
acknowledgeDeliveredSystemAgentWelcome(session);
return;
}
}
session.lastUsedAt = Date.now();
// Inline check (not `welcomeOnly`) so TS narrows params.message below.
if (params.message === undefined || !params.message.trim()) {
respond(
true,
@@ -622,6 +654,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
},
undefined,
);
acknowledgeDeliveredSystemAgentWelcome(session);
return;
}
const historyStart = session.engine.historyLength();
+27 -1
View File
@@ -33,6 +33,14 @@ const isRestartEnabledMock = vi.fn(() => true);
const readPackageVersionMock = vi.fn(async () => "1.0.0");
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null);
const normalizeUpdateChannelMock = vi.fn((): UpdateChannel | null => null);
const getUpdateAvailableMock = vi.fn(
() =>
null as {
currentVersion: string;
latestVersion: string;
channel: string;
} | null,
);
const readConfigFileSnapshotMock = vi.fn<() => Promise<ConfigFileSnapshot>>();
type ManagedServiceUpdateHandoffResult = Awaited<
ReturnType<
@@ -141,6 +149,10 @@ vi.mock("../../infra/update-channels.js", () => ({
normalizeUpdateChannel: normalizeUpdateChannelMock,
}));
vi.mock("../../infra/update-startup.js", () => ({
getUpdateAvailable: getUpdateAvailableMock,
}));
vi.mock("../../infra/update-runner.js", () => ({
resolveUpdateInstallSurface: resolveUpdateInstallSurfaceMock,
runGatewayUpdate: runGatewayUpdateMock,
@@ -210,6 +222,8 @@ beforeEach(() => {
readPackageVersionMock.mockResolvedValue("1.0.0");
normalizeUpdateChannelMock.mockReset();
normalizeUpdateChannelMock.mockReturnValue(null);
getUpdateAvailableMock.mockReset();
getUpdateAvailableMock.mockReturnValue(null);
readConfigFileSnapshotMock.mockReset();
readConfigFileSnapshotMock.mockResolvedValue({
path: "/tmp/openclaw.json",
@@ -1006,6 +1020,11 @@ describe("update.run post-core plugin finalize", () => {
describe("update.status", () => {
it("refreshes the latest update sentinel before responding", async () => {
getUpdateAvailableMock.mockReturnValueOnce({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "latest",
});
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
kind: "update",
status: "skipped",
@@ -1036,12 +1055,19 @@ describe("update.status", () => {
expect(respond).toHaveBeenCalledTimes(1);
const [ok, response] = firstMockCall(respond, "update status response") as [
boolean,
{ sentinel?: { kind?: string; status?: string } } | undefined,
(
| {
sentinel?: { kind?: string; status?: string };
updateAvailable?: { latestVersion?: string } | null;
}
| undefined
),
];
expect(ok).toBe(true);
expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1);
expect(response?.sentinel?.kind).toBe("update");
expect(response?.sentinel?.status).toBe("ok");
expect(response?.updateAvailable?.latestVersion).toBe("2.0.0");
});
it("falls back to the cached update sentinel when refresh fails", async () => {
+2
View File
@@ -41,6 +41,7 @@ import {
type UpdateRestartSentinelMeta,
} from "../../infra/update-restart-sentinel-payload.js";
import { resolveUpdateInstallSurface, runGatewayUpdate } from "../../infra/update-runner.js";
import { getUpdateAvailable } from "../../infra/update-startup.js";
import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js";
import {
getLatestUpdateRestartSentinel,
@@ -141,6 +142,7 @@ export const updateHandlers: GatewayRequestHandlers = {
}
respond(true, {
sentinel,
updateAvailable: getUpdateAvailable(),
});
},
"update.run": async ({ params, respond, client, context }) => {
+40
View File
@@ -1,5 +1,6 @@
// System-agent prompts drive the OpenClaw conversation with typed-command output.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { SystemAgentGreetingFacts } from "./greeting.js";
import type { SystemAgentOverview } from "./overview.js";
/**
@@ -14,6 +15,45 @@ export const SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS = 30_000;
/** Local startup stages can consume nearly 30s before dispatch; leave inference a real budget. */
export const SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS = 120_000;
/** Identity used only for the bounded, cached caretaker greeting turn. */
export const SYSTEM_AGENT_GREETING_SYSTEM_PROMPT = [
"You are OpenClaw, the system itself — caretaker of this machine's gateway, config, channels, and agents.",
"Speak in first person, brief and warm, no corporate filler. Report status honestly; nominal systems get one calm line.",
"Return only the greeting as markdown: 2-5 short lines, no heading, no JSON, and no inline command suggestions.",
"If an update is available, mention the version and offer an upgrade. If channelHealthAvailable is false, say channel health is unavailable. If channels are degraded, name them.",
"Do not invent causes, activity, fixes, or state beyond the supplied facts.",
].join("\n");
/** Compact, deterministic facts payload for the metered greeting turn. */
export function buildSystemAgentGreetingUserPrompt(params: {
overview: SystemAgentOverview;
facts: SystemAgentGreetingFacts;
}): string {
return JSON.stringify({
config: {
exists: params.overview.config.exists,
valid: params.overview.config.valid,
},
defaultAgentId: params.overview.defaultAgentId,
defaultModel: params.overview.defaultModel ?? null,
agents: params.overview.agents.map((agent) => ({
id: agent.id,
name: agent.name ?? null,
isDefault: agent.isDefault,
model: agent.model ?? null,
})),
gateway: {
reachable: params.overview.gateway.reachable,
url: params.overview.gateway.url,
},
updateAvailable: params.facts.updateAvailable,
channelHealthAvailable: params.facts.channelHealth.available,
degradedChannels: params.facts.channelHealth.degraded,
// recentExternalEdit stays out of the model payload: its alert line is
// host-appended at delivery.
});
}
/** System prompt: persona plus the closed command vocabulary. */
export const SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT = [
"You are OpenClaw, the system agent: a small, tidy hermit crab that lives in the config shell.",
+64 -23
View File
@@ -5,12 +5,15 @@ import os from "node:os";
import path from "node:path";
import {
SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
SYSTEM_AGENT_GREETING_SYSTEM_PROMPT,
buildSystemAgentAssistantUserPrompt,
buildSystemAgentGreetingUserPrompt,
parseSystemAgentAssistantPlanText,
type SystemAgentAssistantPlan,
type SystemAgentAssistantTurn,
} from "./assistant-prompts.js";
import { resolveSystemAgentAssistantTimeoutMs } from "./assistant-timeout.js";
import type { SystemAgentGreetingFacts, SystemAgentGreetingPlan } from "./greeting.js";
import { SystemAgentInferenceUnavailableError } from "./inference-error.js";
import type { SystemAgentOverview } from "./overview.js";
import {
@@ -66,11 +69,55 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
deps?: SystemAgentConfiguredModelPlannerDeps;
}): Promise<SystemAgentAssistantPlan | null> {
const route = await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
const input = params.input.trim();
if (!input) {
return null;
}
const prompt = buildSystemAgentAssistantUserPrompt({
input,
overview: params.overview,
...(params.history ? { history: params.history } : {}),
...(params.pendingOperation ? { pendingOperation: params.pendingOperation } : {}),
});
const result = await runConfiguredSystemAgentText({
prompt,
systemPrompt: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
runIdPrefix: "openclaw-planner",
verifiedInference: params.verifiedInference,
deps: params.deps,
});
const parsed = parseSystemAgentAssistantPlanText(result?.text);
return parsed && result ? { ...parsed, modelLabel: result.modelLabel } : null;
}
/** One tool-free, verified inference turn for the cached caretaker greeting. */
export async function planSystemAgentGreetingWithConfiguredModel(params: {
overview: SystemAgentOverview;
facts: SystemAgentGreetingFacts;
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
deps?: SystemAgentConfiguredModelPlannerDeps;
timeoutMs: number;
}): Promise<SystemAgentGreetingPlan | null> {
const result = await runConfiguredSystemAgentText({
prompt: buildSystemAgentGreetingUserPrompt(params),
systemPrompt: SYSTEM_AGENT_GREETING_SYSTEM_PROMPT,
runIdPrefix: "openclaw-greeting",
verifiedInference: params.verifiedInference,
deps: params.deps,
timeoutMs: params.timeoutMs,
});
return result ? { text: result.text, modelRef: result.modelLabel } : null;
}
async function runConfiguredSystemAgentText(params: {
prompt: string;
systemPrompt: string;
runIdPrefix: string;
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
deps?: SystemAgentConfiguredModelPlannerDeps;
timeoutMs?: number;
}): Promise<{ text: string; modelLabel: string } | null> {
const route = await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
let expectedAgentHarnessRuntimeArtifact: ReturnType<
typeof resolveSystemAgentExpectedAgentHarnessRuntimeArtifact
>;
@@ -81,19 +128,13 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
} catch (error) {
throw new SystemAgentInferenceUnavailableError("planner", [error]);
}
const prompt = buildSystemAgentAssistantUserPrompt({
input,
overview: params.overview,
...(params.history ? { history: params.history } : {}),
...(params.pendingOperation ? { pendingOperation: params.pendingOperation } : {}),
});
const tempDir = await (params.deps?.createTempDir ?? createTempPlannerDir)();
let plan: SystemAgentAssistantPlan | null;
let text: string | undefined;
try {
const runId = `openclaw-planner-${randomUUID()}`;
const timeoutMs = (
params.deps?.resolveAssistantTimeoutMs ?? resolveSystemAgentAssistantTimeoutMs
)(route);
const runId = `${params.runIdPrefix}-${randomUUID()}`;
const timeoutMs =
params.timeoutMs ??
(params.deps?.resolveAssistantTimeoutMs ?? resolveSystemAgentAssistantTimeoutMs)(route);
const shared = {
sessionId: `${runId}-session`,
agentId: "openclaw",
@@ -103,14 +144,14 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
cwd: tempDir,
agentDir: route.agentDir,
config: route.runConfig,
prompt,
prompt: params.prompt,
provider: route.provider,
model: route.model,
timeoutMs,
thinkLevel: "off" as const,
runId,
extraSystemPrompt: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
extraSystemPromptStatic: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
extraSystemPrompt: params.systemPrompt,
extraSystemPromptStatic: params.systemPrompt,
messageChannel: "openclaw",
messageProvider: "openclaw",
disableTools: true,
@@ -137,22 +178,22 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
cleanupBundleMcpOnRunEnd: true,
...(route.authProfileId ? { authProfileIdSource: "user" as const } : {}),
});
const parsed = parseSystemAgentAssistantPlanText(extractPlannerResultText(result));
plan = parsed ? { ...parsed, modelLabel: route.modelLabel } : null;
text = extractPlannerResultText(result)?.trim();
} catch (error) {
if (error instanceof SystemAgentInferenceUnavailableError) {
throw error;
}
plan = null;
text = undefined;
} finally {
await (params.deps?.removeTempDir ?? removeTempPlannerDir)(tempDir);
}
// Cleanup is the final suspension before callers can display or execute the
// model result, so authority must still match after cleanup completes.
if (plan) {
await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
if (!text) {
return null;
}
return plan;
// Cleanup is the final suspension before callers can display model text, so
// authority must still match after cleanup completes.
await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
return { text, modelLabel: route.modelLabel };
}
async function requireVerifiedPlannerRoute(
+29
View File
@@ -20,6 +20,11 @@ import {
} from "./approval-intent.js";
import type { SystemAgentAssistantPlanner, SystemAgentAssistantTurn } from "./assistant.js";
import { approvalQuestion } from "./dialogue.js";
import type {
SystemAgentGreetingFacts,
SystemAgentGreetingPlan,
SystemAgentGreetingPlanner,
} from "./greeting.js";
import {
SystemAgentInferenceUnavailableError,
isSystemAgentInferenceUnavailableError,
@@ -59,6 +64,8 @@ export type SystemAgentChatEngineOptions = {
yes?: boolean;
deps?: SystemAgentCommandDeps;
planWithAssistant?: SystemAgentAssistantPlanner;
/** Test seam for the one-shot cached caretaker greeting. */
planGreeting?: SystemAgentGreetingPlanner;
/** Test seam for the embedded agent-loop turn runner. */
runAgentTurn?: SystemAgentTurnRunner;
/** Test seam for the approval-intent classifier. */
@@ -992,6 +999,28 @@ export class SystemAgentChatEngine {
return { ...overview, defaultModel: verifiedRoute.modelLabel };
}
async planGreeting(params: {
overview: SystemAgentOverview;
facts: SystemAgentGreetingFacts;
timeoutMs: number;
}): Promise<SystemAgentGreetingPlan | null> {
const planner = this.opts.planGreeting;
const plan = planner
? await planner(params)
: await import("./assistant.js").then(({ planSystemAgentGreetingWithConfiguredModel }) =>
planSystemAgentGreetingWithConfiguredModel({
...params,
verifiedInference: this.verifiedInference,
deps: this.opts.deps,
}),
);
if (plan) {
// Custom planners do not inherit the configured planner's cleanup guard.
await this.requireVerifiedInference();
}
return plan;
}
private async requireVerifiedInference() {
const binding = this.verifiedInference;
if (this.agentSession.verifiedInference !== binding) {
+895
View File
@@ -0,0 +1,895 @@
import { describe, expect, it, vi } from "vitest";
import type { ConfigAuditRecord } from "../config/io.audit.js";
import type { SequencedSqliteAuditRecordEntry } from "../infra/sqlite-audit-record-store.js";
import { SYSTEM_AGENT_GREETING_SYSTEM_PROMPT } from "./assistant-prompts.js";
import {
acknowledgeSystemAgentGreetingDelivery,
buildSystemAgentGreetingQuestion,
loadSystemAgentGreetingFacts,
resolveSystemAgentGreeting,
SYSTEM_AGENT_EXTERNAL_EDIT_ALERT,
systemAgentGreetingChannelHealth,
systemAgentGreetingFactsHash,
type SystemAgentGreetingCacheRecord,
type SystemAgentGreetingCacheStore,
type SystemAgentGreetingFacts,
} from "./greeting.js";
import type { SystemAgentOverview } from "./overview.js";
function createOverview(overrides: Partial<SystemAgentOverview> = {}): SystemAgentOverview {
return {
config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: "hash" },
agents: [{ id: "main", name: "Main", isDefault: true, model: "openai/gpt-5.5" }],
defaultAgentId: "main",
defaultModel: "openai/gpt-5.5",
tools: {
codex: { command: "codex", found: false },
claude: { command: "claude", found: false },
gemini: { command: "gemini", found: false },
apiKeys: { openai: false, anthropic: false },
},
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: true },
references: {
docsUrl: "https://docs.openclaw.ai",
sourceUrl: "https://github.com/openclaw/openclaw",
},
...overrides,
};
}
const healthyFacts = (): SystemAgentGreetingFacts => ({
updateAvailable: null,
channelHealth: { available: true, degraded: [] },
recentExternalEdit: false,
auditSequence: 0,
});
function createCache(
initial?: Omit<SystemAgentGreetingCacheRecord, "lastSeenAuditSequence"> & {
lastSeenAuditSequence?: number;
},
) {
let record: SystemAgentGreetingCacheRecord | undefined = initial
? { lastSeenAuditSequence: 0, ...initial }
: undefined;
const store: SystemAgentGreetingCacheStore = {
latest: vi.fn(() =>
record ? [{ key: "latest", value: record, createdAt: record.at ?? 0, sequence: 1 }] : [],
),
compareAndSet: vi.fn((_key, expectedValue, value) => {
if (JSON.stringify(record ?? null) !== JSON.stringify(expectedValue)) {
return false;
}
record = value ?? undefined;
return true;
}),
};
return { store, read: () => record };
}
function configAuditEntry(
sequence: number,
event: ConfigAuditRecord["event"],
): SequencedSqliteAuditRecordEntry<ConfigAuditRecord> {
return {
key: `${event}-${sequence}`,
value: { event, ts: new Date(sequence).toISOString() } as ConfigAuditRecord,
createdAt: sequence,
sequence,
};
}
function createConfigAudit(
initial: Array<SequencedSqliteAuditRecordEntry<ConfigAuditRecord>> = [],
) {
const records = [...initial];
const latest = vi.fn(({ limit, beforeSequence }: { limit: number; beforeSequence?: number }) =>
records
.filter((entry) => beforeSequence === undefined || entry.sequence < beforeSequence)
.toSorted((left, right) => right.sequence - left.sequence)
.slice(0, limit),
);
return {
store: { latest },
add: (entry: SequencedSqliteAuditRecordEntry<ConfigAuditRecord>) => records.push(entry),
};
}
describe("system agent greeting cache", () => {
it("returns a matching model greeting without calling the planner", async () => {
const overview = createOverview();
const facts = healthyFacts();
const cached: SystemAgentGreetingCacheRecord = {
lastSeenAuditSequence: 4,
factsHash: systemAgentGreetingFactsHash(overview, facts),
text: "All systems nominal.",
modelRef: "openai/gpt-5.5",
at: 100,
};
const cache = createCache(cached);
const planner = vi.fn();
await expect(
resolveSystemAgentGreeting({
overview,
facts,
planner,
allowInference: false,
cacheStore: cache.store,
}),
).resolves.toEqual({ text: cached.text, source: "cache" });
acknowledgeSystemAgentGreetingDelivery({ auditSequence: 5, cacheStore: cache.store });
expect(planner).not.toHaveBeenCalled();
expect(cache.read()?.lastSeenAuditSequence).toBe(5);
});
it("uses the template without calling the planner when inference is disabled", async () => {
const cache = createCache();
const planner = vi.fn();
await expect(
resolveSystemAgentGreeting({
overview: createOverview(),
facts: healthyFacts(),
planner,
allowInference: false,
cacheStore: cache.store,
}),
).resolves.toMatchObject({ source: "template" });
expect(planner).not.toHaveBeenCalled();
expect(cache.read()).toBeUndefined();
});
it("calls the planner once after a state change and replaces the cache", async () => {
const overview = createOverview();
const priorFacts = healthyFacts();
const facts = { ...priorFacts, updateAvailable: "2026.7.20", auditSequence: 6 };
const cache = createCache({
factsHash: systemAgentGreetingFactsHash(overview, priorFacts),
text: "All systems nominal.",
modelRef: "openai/gpt-5.5",
at: 100,
});
const planner = vi.fn(async () => ({
text: "I'm steady.\nAn upgrade to 2026.7.20 is ready when you are.",
modelRef: "openai/gpt-5.5",
}));
const result = await resolveSystemAgentGreeting({
overview,
facts,
planner,
cacheStore: cache.store,
now: () => 200,
});
expect(result.source).toBe("model");
expect(planner).toHaveBeenCalledOnce();
expect(planner).toHaveBeenCalledWith(
expect.objectContaining({ overview, facts, timeoutMs: 20_000 }),
);
acknowledgeSystemAgentGreetingDelivery({
auditSequence: facts.auditSequence,
cacheStore: cache.store,
});
expect(cache.read()).toMatchObject({
lastSeenAuditSequence: 6,
factsHash: systemAgentGreetingFactsHash(overview, facts),
modelRef: "openai/gpt-5.5",
at: 200,
});
});
it("appends the host-owned edit alert at delivery without caching it", async () => {
const overview = createOverview();
const facts = { ...healthyFacts(), recentExternalEdit: true, auditSequence: 9 };
const planner = vi.fn(async () => ({
text: "All systems nominal.",
modelRef: "openai/gpt-5.5",
}));
const cache = createCache();
const result = await resolveSystemAgentGreeting({
overview,
facts,
planner,
cacheStore: cache.store,
now: () => 200,
});
expect(result.text).toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
// The cache stores only the model text; the alert is delivery-scoped.
expect(cache.read()?.text).toBe("All systems nominal.");
const calm = await resolveSystemAgentGreeting({
overview,
facts: { ...facts, recentExternalEdit: false },
planner,
cacheStore: cache.store,
now: () => 300,
});
expect(calm.source).toBe("cache");
expect(calm.text).not.toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
});
it("reports an edit that arrives between the facts read and delivery", () => {
const cache = createCache({ lastSeenAuditSequence: 10 });
const audit = createConfigAudit([configAuditEntry(10, "config.write")]);
const facts = loadSystemAgentGreetingFacts({
cacheStore: cache.store,
configAuditStore: audit.store,
});
audit.add(configAuditEntry(11, "config.external"));
acknowledgeSystemAgentGreetingDelivery({
auditSequence: facts.auditSequence,
cacheStore: cache.store,
});
expect(
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
).toMatchObject({ auditSequence: 11, recentExternalEdit: true });
});
it("keeps a template delivery as cursor-only state, then reports a later edit", async () => {
const overview = createOverview();
const cache = createCache();
const audit = createConfigAudit([configAuditEntry(3, "config.write")]);
const facts = loadSystemAgentGreetingFacts({
cacheStore: cache.store,
configAuditStore: audit.store,
});
await expect(
resolveSystemAgentGreeting({
overview,
facts,
planner: async () => {
throw new Error("offline");
},
cacheStore: cache.store,
}),
).resolves.toMatchObject({ source: "template" });
acknowledgeSystemAgentGreetingDelivery({
auditSequence: facts.auditSequence,
cacheStore: cache.store,
});
expect(cache.read()).toEqual({ lastSeenAuditSequence: 3 });
audit.add(configAuditEntry(4, "config.external"));
expect(
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
).toMatchObject({ auditSequence: 4, recentExternalEdit: true });
});
it("pages past six internal writes to find an external edit above the watermark", () => {
const cache = createCache({ lastSeenAuditSequence: 1 });
const audit = createConfigAudit([
configAuditEntry(2, "config.external"),
...Array.from({ length: 7 }, (_, index) => configAuditEntry(index + 3, "config.write")),
]);
const facts = loadSystemAgentGreetingFacts({
cacheStore: cache.store,
configAuditStore: audit.store,
});
expect(facts).toMatchObject({ auditSequence: 9, recentExternalEdit: true });
expect(audit.store.latest).toHaveBeenCalledTimes(2);
expect(audit.store.latest).toHaveBeenNthCalledWith(2, { limit: 5, beforeSequence: 5 });
});
it("acknowledges a delivered edit so the next facts load is clear", () => {
const cache = createCache({ lastSeenAuditSequence: 1 });
const audit = createConfigAudit([
configAuditEntry(2, "config.external"),
configAuditEntry(3, "config.write"),
]);
const facts = loadSystemAgentGreetingFacts({
cacheStore: cache.store,
configAuditStore: audit.store,
});
expect(facts.recentExternalEdit).toBe(true);
acknowledgeSystemAgentGreetingDelivery({
auditSequence: facts.auditSequence,
cacheStore: cache.store,
});
expect(
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
).toMatchObject({ auditSequence: 3, recentExternalEdit: false });
});
it("coalesces concurrent planner calls for the same facts", async () => {
const overview = createOverview();
const facts = healthyFacts();
const cache = createCache();
let finishPlan: ((plan: { text: string; modelRef: string }) => void) | undefined;
const planner = vi.fn(
() =>
new Promise<{ text: string; modelRef: string }>((resolve) => {
finishPlan = resolve;
}),
);
const first = resolveSystemAgentGreeting({ overview, facts, planner, cacheStore: cache.store });
const second = resolveSystemAgentGreeting({
overview,
facts,
planner,
cacheStore: cache.store,
});
expect(planner).toHaveBeenCalledOnce();
finishPlan?.({ text: "All systems nominal.", modelRef: "openai/gpt-5.5" });
await expect(Promise.all([first, second])).resolves.toEqual([
{ text: "All systems nominal.", source: "model" },
{ text: "All systems nominal.", source: "model" },
]);
});
it("does not let an older in-flight greeting replace newer facts", async () => {
const overview = createOverview();
const oldFacts = healthyFacts();
const newFacts = { ...oldFacts, updateAvailable: "2026.7.20" };
const cache = createCache();
let finishOld: ((plan: { text: string; modelRef: string }) => void) | undefined;
let finishNew: ((plan: { text: string; modelRef: string }) => void) | undefined;
const oldGreeting = resolveSystemAgentGreeting({
overview,
facts: oldFacts,
planner: () =>
new Promise((resolve) => {
finishOld = resolve;
}),
cacheStore: cache.store,
now: () => 100,
});
const newGreeting = resolveSystemAgentGreeting({
overview,
facts: newFacts,
planner: () =>
new Promise((resolve) => {
finishNew = resolve;
}),
cacheStore: cache.store,
now: () => 200,
});
finishNew?.({ text: "Update 2026.7.20 is ready.", modelRef: "openai/gpt-5.5" });
await newGreeting;
finishOld?.({ text: "All systems nominal.", modelRef: "openai/gpt-5.5" });
await oldGreeting;
expect(cache.read()).toMatchObject({
factsHash: systemAgentGreetingFactsHash(overview, newFacts),
text: "Update 2026.7.20 is ready.",
at: 200,
});
});
it("timestamps the greeting snapshot before inference begins", async () => {
const events: string[] = [];
const cache = createCache();
await resolveSystemAgentGreeting({
overview: createOverview(),
facts: healthyFacts(),
planner: async () => {
events.push("planner");
return { text: "All systems nominal.", modelRef: "openai/gpt-5.5" };
},
cacheStore: cache.store,
now: () => {
events.push("snapshot");
return 100;
},
});
expect(events).toEqual(["snapshot", "planner"]);
expect(cache.read()?.at).toBe(100);
});
it("uses a complete uncached template and backs off unchanged model failures", async () => {
const cache = createCache();
const overview = createOverview();
const planner = vi.fn(async () => {
throw new Error("offline");
});
const facts: SystemAgentGreetingFacts = {
updateAvailable: "2026.7.20",
channelHealth: { available: true, degraded: ["Telegram"] },
recentExternalEdit: true,
auditSequence: 7,
};
const result = await resolveSystemAgentGreeting({
overview,
facts,
planner,
cacheStore: cache.store,
now: () => 100,
});
const repeated = await resolveSystemAgentGreeting({
overview,
facts,
planner,
cacheStore: cache.store,
now: () => 200,
});
expect(result).toMatchObject({ source: "template" });
expect(result.text).toContain("caretaker of this gateway");
expect(result.text).toContain("Update 2026.7.20 is available");
expect(result.text).toContain("Channels needing attention: Telegram");
expect(result.text).toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
expect(repeated).toEqual(result);
expect(planner).toHaveBeenCalledOnce();
acknowledgeSystemAgentGreetingDelivery({
auditSequence: facts.auditSequence,
cacheStore: cache.store,
});
expect(cache.read()).toEqual({ lastSeenAuditSequence: 7 });
});
it("rejects a model greeting that omits mild facts", async () => {
const cache = createCache();
const facts: SystemAgentGreetingFacts = {
updateAvailable: "2026.7.20",
channelHealth: { available: true, degraded: ["Telegram"] },
recentExternalEdit: true,
auditSequence: 7,
};
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts,
planner: async () => ({
text: "All systems nominal.",
modelRef: "openai/gpt-5.5",
}),
cacheStore: cache.store,
now: () => 100,
});
expect(result).toMatchObject({ source: "template" });
expect(result.text).toContain("Update 2026.7.20 is available");
expect(result.text).toContain("Telegram");
expect(result.text).toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
expect(cache.read()).toBeUndefined();
});
it("rejects structured output smuggled behind a preamble line", async () => {
const cache = createCache();
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts: healthyFacts(),
planner: async () => ({
text: 'Sure:\n{"status":"healthy"}',
modelRef: "openai/gpt-5.5",
}),
cacheStore: cache.store,
now: () => 100,
});
expect(result).toMatchObject({ source: "template" });
expect(cache.read()?.text).toBeUndefined();
});
it("requires an available update's version before caching model text", async () => {
const cache = createCache();
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts: { ...healthyFacts(), updateAvailable: "2026.7.20" },
planner: async () => ({
text: "An update is ready when you are.",
modelRef: "openai/gpt-5.5",
}),
cacheStore: cache.store,
now: () => 100,
});
expect(result).toMatchObject({ source: "template" });
expect(cache.read()).toBeUndefined();
});
it("requires every degraded channel label before caching model text", async () => {
const cache = createCache();
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts: {
...healthyFacts(),
channelHealth: { available: true, degraded: ["Telegram", "Discord"] },
},
planner: async () => ({
text: "Telegram needs attention.",
modelRef: "openai/gpt-5.5",
}),
cacheStore: cache.store,
now: () => 100,
});
expect(result).toMatchObject({ source: "template" });
expect(cache.read()).toBeUndefined();
});
it("accepts model text that names every degraded channel", async () => {
const cache = createCache();
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts: {
...healthyFacts(),
channelHealth: { available: true, degraded: ["Telegram", "Discord"] },
},
planner: async () => ({
text: "Telegram and Discord need attention.",
modelRef: "openai/gpt-5.5",
}),
cacheStore: cache.store,
now: () => 100,
});
expect(result).toEqual({
text: "Telegram and Discord need attention.",
source: "model",
});
expect(cache.read()?.text).toBe("Telegram and Discord need attention.");
});
it("requires channel health unavailability before caching model text", async () => {
const cache = createCache();
const facts: SystemAgentGreetingFacts = {
...healthyFacts(),
channelHealth: { available: false, degraded: [] },
};
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts,
planner: async () => ({ text: "All systems nominal.", modelRef: "openai/gpt-5.5" }),
cacheStore: cache.store,
now: () => 100,
});
expect(result).toMatchObject({ source: "template" });
expect(result.text).toContain("Channel health is not available yet");
expect(cache.read()).toBeUndefined();
});
it.each([
{
name: "missing config",
overview: createOverview({
config: {
path: "/tmp/openclaw.json",
exists: false,
valid: false,
issues: [],
hash: null,
},
}),
expected: "Config: missing",
},
{
name: "invalid config",
overview: createOverview({
config: {
path: "/tmp/openclaw.json",
exists: true,
valid: false,
issues: ["invalid"],
hash: null,
},
}),
expected: "Config: invalid",
},
{
name: "unreachable gateway",
overview: createOverview({
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: false },
}),
expected: "Gateway: not reachable",
},
{
name: "missing default model",
overview: createOverview({ defaultModel: undefined }),
expected: "Inference is unavailable",
},
])(
"uses the deterministic template for $name without opening the cache",
async ({ overview, expected }) => {
const cache = createCache();
const planner = vi.fn(async () => ({
text: "Config setup is complete.",
modelRef: "openai/gpt-5.5",
}));
const result = await resolveSystemAgentGreeting({
overview,
facts: healthyFacts(),
planner,
cacheStore: cache.store,
now: () => 100,
});
expect(result).toMatchObject({ source: "template" });
expect(result.text).toContain(expected);
expect(planner).not.toHaveBeenCalled();
expect(cache.store.latest).not.toHaveBeenCalled();
expect(cache.read()).toBeUndefined();
},
);
it("uses the model-free fallback when the optional greeting cache cannot open", async () => {
const planner = vi.fn();
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts: healthyFacts(),
planner,
openCache: () => {
throw new Error("sqlite unavailable");
},
});
expect(result).toMatchObject({ source: "template" });
expect(result.text).toContain("caretaker of this gateway");
expect(planner).not.toHaveBeenCalled();
});
it("uses the model-free fallback when the greeting cache cannot be read", async () => {
const planner = vi.fn();
const result = await resolveSystemAgentGreeting({
overview: createOverview(),
facts: healthyFacts(),
planner,
cacheStore: {
latest: () => {
throw new Error("sqlite corrupt");
},
compareAndSet: vi.fn(),
},
});
expect(result).toMatchObject({ source: "template" });
expect(planner).not.toHaveBeenCalled();
});
it("hashes decision fields stably while ignoring diagnostic-only details", () => {
const facts = {
...healthyFacts(),
channelHealth: { available: true, degraded: ["Telegram", "Discord"] },
};
const left = createOverview();
const right = createOverview({
config: {
...left.config,
path: "/another/config.json",
hash: "another-hash",
issues: ["diagnostic detail"],
},
agents: [...left.agents].toReversed(),
gateway: { ...left.gateway, source: "another source", error: "diagnostic detail" },
});
expect(systemAgentGreetingFactsHash(left, facts)).toBe(
systemAgentGreetingFactsHash(right, {
...facts,
auditSequence: 999,
channelHealth: { available: true, degraded: ["Discord", "Telegram"] },
}),
);
expect(systemAgentGreetingFactsHash(left, facts)).not.toBe(
systemAgentGreetingFactsHash(left, {
...facts,
channelHealth: { available: false, degraded: [] },
}),
);
});
});
describe("system agent greeting identity", () => {
it("identifies OpenClaw as the machine caretaker and describes every mild fact", () => {
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain(
"You are OpenClaw, the system itself — caretaker of this machine's gateway, config, channels, and agents.",
);
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("nominal systems get one calm line");
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("If an update is available");
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("If channelHealthAvailable is false");
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("If channels are degraded");
// The external-edit alert is host-appended at delivery; the model prompt
// must not mention it (double phrasing).
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).not.toContain("recentExternalEdit");
});
});
describe("system agent greeting facts", () => {
it("treats a missing greeting slot as audit watermark zero", () => {
const cache = createCache();
const audit = createConfigAudit([configAuditEntry(1, "config.external")]);
expect(
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
).toMatchObject({ auditSequence: 1, recentExternalEdit: true });
});
it("uses cached update and health state without probing", () => {
const facts = loadSystemAgentGreetingFacts({
cacheStore: createCache({
factsHash: "old",
text: "old",
modelRef: "model",
at: 100,
}).store,
configAuditStore: {
latest: () => [
{
key: "external",
value: {
event: "config.external",
ts: new Date(200).toISOString(),
} as ConfigAuditRecord,
createdAt: 200,
sequence: 2,
},
],
},
getUpdateAvailable: () => ({
currentVersion: "2026.7.19",
latestVersion: "2026.7.20",
channel: "latest",
}),
getHealthCache: () =>
({
channels: {
telegram: {
accountId: "default",
configured: true,
healthState: "stale-socket",
},
},
channelLabels: { telegram: "Telegram" },
}) as never,
});
expect(facts).toEqual({
updateAvailable: "2026.7.20",
channelHealth: { available: true, degraded: ["Telegram"] },
recentExternalEdit: true,
auditSequence: 2,
});
});
it("deduplicates and sorts degraded channel labels", () => {
const health = systemAgentGreetingChannelHealth({
channels: {
telegram: {
accountId: "primary",
accounts: {
primary: { accountId: "primary", configured: true, probe: { ok: false } },
quiet: { accountId: "quiet", configured: false, healthState: "not-running" },
},
},
discord: { accountId: "default", running: true, connected: false },
slack: { accountId: "default", configured: true, running: false },
whatsapp: { accountId: "default", configured: true, running: true, linked: false },
},
channelLabels: {
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
whatsapp: "WhatsApp",
},
} as never);
expect(health).toEqual({
available: true,
degraded: ["Discord", "Slack", "Telegram", "WhatsApp"],
});
});
});
describe("system agent quick actions", () => {
it.each([
{
name: "healthy",
overview: createOverview(),
facts: healthyFacts(),
replies: ["talk to agent", "audit"],
},
{
name: "gateway unreachable",
overview: createOverview({
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: false },
}),
facts: healthyFacts(),
replies: ["gateway status", "restart gateway", "talk to agent", "audit"],
},
{
name: "channel health unavailable",
overview: createOverview(),
facts: { ...healthyFacts(), channelHealth: { available: false, degraded: [] } },
replies: ["health", "talk to agent", "audit"],
},
{
name: "missing config",
overview: createOverview({
config: {
path: "/tmp/openclaw.json",
exists: false,
valid: true,
issues: [],
hash: null,
},
}),
facts: healthyFacts(),
replies: ["setup", "talk to agent", "audit"],
},
{
name: "invalid config",
overview: createOverview({
config: {
path: "/tmp/openclaw.json",
exists: true,
valid: false,
issues: ["invalid"],
hash: null,
},
}),
facts: healthyFacts(),
replies: ["doctor", "talk to agent", "audit"],
},
{
name: "missing model",
overview: createOverview({ defaultModel: undefined }),
facts: healthyFacts(),
replies: ["setup", "audit"],
},
{
name: "update and manual edit",
overview: createOverview(),
facts: {
updateAvailable: "2026.7.20",
channelHealth: { available: true, degraded: [] },
recentExternalEdit: true,
auditSequence: 0,
},
replies: ["status", "talk to agent", "audit"],
},
{
name: "degraded channel",
overview: createOverview(),
facts: {
updateAvailable: null,
channelHealth: { available: true, degraded: ["Telegram"] },
recentExternalEdit: false,
auditSequence: 0,
},
replies: ["health", "talk to agent", "audit"],
},
{
name: "several exceptional facts",
overview: createOverview({
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: false },
}),
facts: {
updateAvailable: "2026.7.20",
channelHealth: { available: true, degraded: ["Telegram"] },
recentExternalEdit: true,
auditSequence: 0,
},
replies: ["gateway status", "restart gateway", "talk to agent", "audit"],
},
])("builds canonical replies for $name facts", ({ overview, facts, replies }) => {
const question = buildSystemAgentGreetingQuestion(overview, facts);
expect(question.header).toBe("Quick actions");
expect(question.options.map((option) => option.reply)).toEqual(replies);
expect(question.options.length).toBeGreaterThanOrEqual(2);
expect(question.options.length).toBeLessThanOrEqual(4);
});
it("does not recommend agent handoff for a recent external edit", () => {
const question = buildSystemAgentGreetingQuestion(createOverview(), {
...healthyFacts(),
recentExternalEdit: true,
});
expect(question.options.find((option) => option.reply === "talk to agent")?.recommended).toBe(
false,
);
});
});
+650
View File
@@ -0,0 +1,650 @@
// Cached, model-phrased caretaker greetings over deterministic gateway facts.
import { createHash } from "node:crypto";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { SystemAgentChatQuestion } from "../../packages/gateway-protocol/src/index.js";
import type { HealthSummary } from "../commands/health.types.js";
import {
CONFIG_AUDIT_MAX_ENTRIES,
CONFIG_AUDIT_SCOPE,
type ConfigAuditRecord,
} from "../config/io.audit.js";
import { getHealthCache } from "../gateway/server/health-state.js";
import { createSqliteAuditRecordStore } from "../infra/sqlite-audit-record-store.js";
import { getUpdateAvailable, type UpdateAvailable } from "../infra/update-startup.js";
import { formatSystemAgentStartupMessage, type SystemAgentOverview } from "./overview.js";
const SYSTEM_AGENT_GREETING_SCOPE = "system-agent-greeting";
const SYSTEM_AGENT_GREETING_KEY = "latest";
const SYSTEM_AGENT_GREETING_TIMEOUT_MS = 20_000;
const SYSTEM_AGENT_GREETING_FAILURE_RETRY_MS = 60_000;
const SYSTEM_AGENT_GREETING_MAX_CHARS = 700;
const SYSTEM_AGENT_GREETING_MAX_LINES = 5;
const CONFIG_AUDIT_PAGE_SIZE = 5;
const GREETING_STATE_CAS_ATTEMPTS = 4;
export type SystemAgentGreetingFacts = {
updateAvailable: string | null;
channelHealth: { available: boolean; degraded: string[] };
recentExternalEdit: boolean;
/** Newest config-audit sequence observed while these facts were built. */
auditSequence: number;
};
export type SystemAgentGreetingCacheRecord = {
lastSeenAuditSequence: number;
factsHash?: string;
text?: string;
modelRef?: string;
at?: number;
};
export type SystemAgentGreetingPlan = {
text: string;
modelRef: string;
};
export type SystemAgentGreetingPlanner = (params: {
overview: SystemAgentOverview;
facts: SystemAgentGreetingFacts;
timeoutMs: number;
}) => Promise<SystemAgentGreetingPlan | null>;
export type SystemAgentGreetingCacheStore = Pick<
ReturnType<typeof createSqliteAuditRecordStore<SystemAgentGreetingCacheRecord>>,
"compareAndSet" | "latest"
>;
type SystemAgentGreetingConfigAuditStore = Pick<
ReturnType<typeof createSqliteAuditRecordStore<ConfigAuditRecord>>,
"latest"
>;
type SystemAgentGreetingResolution = {
text: string;
source: "cache" | "model" | "template";
};
const greetingFlights = new WeakMap<
SystemAgentGreetingCacheStore,
Map<string, Promise<SystemAgentGreetingResolution>>
>();
const greetingFailures = new WeakMap<
SystemAgentGreetingCacheStore,
{ factsHash: string; retryAfter: number }
>();
let defaultGreetingCache: SystemAgentGreetingCacheStore | undefined;
function openGreetingCache(env?: NodeJS.ProcessEnv): SystemAgentGreetingCacheStore {
return createSqliteAuditRecordStore<SystemAgentGreetingCacheRecord>({
scope: SYSTEM_AGENT_GREETING_SCOPE,
maxEntries: 1,
...(env ? { env } : {}),
});
}
function getDefaultGreetingCache(): SystemAgentGreetingCacheStore {
defaultGreetingCache ??= openGreetingCache();
return defaultGreetingCache;
}
function openConfigAuditStore(env?: NodeJS.ProcessEnv): SystemAgentGreetingConfigAuditStore {
return createSqliteAuditRecordStore<ConfigAuditRecord>({
scope: CONFIG_AUDIT_SCOPE,
maxEntries: CONFIG_AUDIT_MAX_ENTRIES,
...(env ? { env } : {}),
});
}
function tryOr<T>(fallback: T, read: () => T): T {
try {
return read();
} catch {
return fallback;
}
}
function readGreetingCache(
store: SystemAgentGreetingCacheStore,
): SystemAgentGreetingCacheRecord | null {
return store.latest({ limit: 1 })[0]?.value ?? null;
}
function mutateGreetingState(
store: SystemAgentGreetingCacheStore,
mutate: (current: SystemAgentGreetingCacheRecord | null) => SystemAgentGreetingCacheRecord | null,
createdAt = Date.now(),
): void {
for (let attempt = 0; attempt < GREETING_STATE_CAS_ATTEMPTS; attempt += 1) {
const current = readGreetingCache(store);
const next = mutate(current);
if (
next === current ||
store.compareAndSet(SYSTEM_AGENT_GREETING_KEY, current, next, createdAt)
) {
return;
}
}
throw new Error("system-agent greeting state changed too often");
}
function accountLooksDegraded(account: Record<string, unknown>): boolean {
if (account.configured === false || account.enabled === false) {
return false;
}
const healthState =
typeof account.healthState === "string" ? account.healthState.trim().toLowerCase() : "";
const probe =
account.probe && typeof account.probe === "object"
? (account.probe as Record<string, unknown>)
: null;
return (
(healthState !== "" && healthState !== "healthy") ||
probe?.ok === false ||
account.linked === false ||
account.running === false ||
(account.running === true && account.connected === false) ||
(account.connected !== true &&
typeof account.lastError === "string" &&
account.lastError.trim().length > 0)
);
}
/** Extract only degraded channel labels from the gateway's existing cached health aggregate. */
export function systemAgentGreetingChannelHealth(
health: HealthSummary | null,
): SystemAgentGreetingFacts["channelHealth"] {
if (!health) {
return { available: false, degraded: [] };
}
const degraded = new Set<string>();
for (const [channelId, channel] of Object.entries(health.channels)) {
const accounts = channel.accounts ? Object.values(channel.accounts) : [channel];
if (accounts.some((account) => accountLooksDegraded(account))) {
degraded.add(health.channelLabels[channelId] ?? channelId);
}
}
return { available: true, degraded: [...degraded].toSorted((a, b) => a.localeCompare(b)) };
}
function readConfigAuditFacts(
store: SystemAgentGreetingConfigAuditStore,
lastSeenAuditSequence: number,
): Pick<SystemAgentGreetingFacts, "auditSequence" | "recentExternalEdit"> {
let auditSequence = 0;
let beforeSequence: number | undefined;
let recentExternalEdit = false;
while (true) {
const page = store.latest({
limit: CONFIG_AUDIT_PAGE_SIZE,
...(beforeSequence === undefined ? {} : { beforeSequence }),
});
if (beforeSequence === undefined) {
auditSequence = page[0]?.sequence ?? 0;
}
if (page.length === 0) {
break;
}
let reachedWatermark = false;
for (const entry of page) {
if (entry.sequence <= lastSeenAuditSequence) {
reachedWatermark = true;
break;
}
if (entry.value.event === "config.external") {
recentExternalEdit = true;
}
}
if (reachedWatermark || page.length < CONFIG_AUDIT_PAGE_SIZE) {
break;
}
const nextBeforeSequence = page.at(-1)?.sequence;
if (nextBeforeSequence === undefined || nextBeforeSequence === beforeSequence) {
break;
}
beforeSequence = nextBeforeSequence;
}
return { auditSequence, recentExternalEdit };
}
/** Read free facts from process/SQLite snapshots; this function never starts a probe. */
export function loadSystemAgentGreetingFacts(
opts: {
env?: NodeJS.ProcessEnv;
cacheStore?: SystemAgentGreetingCacheStore;
openCache?: () => SystemAgentGreetingCacheStore;
configAuditStore?: SystemAgentGreetingConfigAuditStore;
getUpdateAvailable?: () => UpdateAvailable | null;
getHealthCache?: () => HealthSummary | null;
} = {},
): SystemAgentGreetingFacts {
// Facts stay best-effort: a broken snapshot source degrades that fact
// instead of blocking the welcome.
const cache = tryOr<SystemAgentGreetingCacheRecord | null>(null, () =>
readGreetingCache(opts.cacheStore ?? opts.openCache?.() ?? openGreetingCache(opts.env)),
);
const auditFacts = tryOr({ auditSequence: 0, recentExternalEdit: false }, () =>
readConfigAuditFacts(
opts.configAuditStore ?? openConfigAuditStore(opts.env),
cache?.lastSeenAuditSequence ?? 0,
),
);
const update = tryOr<UpdateAvailable | null>(null, () =>
(opts.getUpdateAvailable ?? getUpdateAvailable)(),
);
const health = tryOr<HealthSummary | null>(null, () => (opts.getHealthCache ?? getHealthCache)());
return {
updateAvailable: update?.latestVersion ?? null,
channelHealth: systemAgentGreetingChannelHealth(health),
...auditFacts,
};
}
/** SHA-256 over greeting decisions only; paths, errors, timestamps, and tool probes stay out. */
export function systemAgentGreetingFactsHash(
overview: SystemAgentOverview,
facts: SystemAgentGreetingFacts,
): string {
const decisionFacts = {
config: {
exists: overview.config.exists,
valid: overview.config.valid,
},
defaultAgentId: overview.defaultAgentId,
defaultModel: overview.defaultModel ?? null,
gateway: {
reachable: overview.gateway.reachable,
url: overview.gateway.url,
},
agents: overview.agents
.map((agent) => ({
id: agent.id,
name: agent.name ?? null,
isDefault: agent.isDefault,
model: agent.model ?? null,
}))
.toSorted((a, b) => a.id.localeCompare(b.id)),
updateAvailable: facts.updateAvailable,
channelHealthAvailable: facts.channelHealth.available,
degradedChannels: [...facts.channelHealth.degraded].toSorted((a, b) => a.localeCompare(b)),
// recentExternalEdit is deliberately absent: its alert is host-appended at
// delivery, so the cached model text stays valid across edit-flag flips.
};
return createHash("sha256").update(JSON.stringify(decisionFacts)).digest("hex");
}
function normalizeGreetingText(text: string): string | null {
const lines = text
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(0, SYSTEM_AGENT_GREETING_MAX_LINES);
if (lines.length === 0 || lines.some((line) => /^#{1,6}\s/.test(line))) {
return null;
}
// The prompt demands plain markdown lines; structured output must never be
// cached (a single bad slot would replay on every welcome until facts change).
// Every retained line is checked so a "Sure:" preamble cannot smuggle JSON.
if (lines.some((line) => /^[[{]/.test(line) || line.startsWith("```"))) {
return null;
}
return truncateUtf16Safe(lines.join("\n"), SYSTEM_AGENT_GREETING_MAX_CHARS).trim() || null;
}
/**
* The external-edit alert is host-owned: delivery acknowledges the audit
* cursor, so a model phrasing miss would silently lose the notification.
* Appending deterministically removes that class instead of validating it.
*/
export const SYSTEM_AGENT_EXTERNAL_EDIT_ALERT =
"Heads up: the config was edited outside OpenClaw while I was away — open History to review it.";
function withHostOwnedAlerts(text: string, facts: SystemAgentGreetingFacts): string {
if (!facts.recentExternalEdit) {
return text;
}
return `${text}\n${SYSTEM_AGENT_EXTERNAL_EDIT_ALERT}`;
}
/**
* Positive-presence grounding only: exceptional facts the model was given must
* appear in its text. Deliberately no negative-claim screening — keyword
* blacklists false-reject phrasing like "no channels are degraded", and the
* greeting is advisory chat text; chips, History, and health stay host-owned.
* A hallucinated outage is bounded by the prompt, the 5-line cap, and template
* fallback on the next facts change. Accepted tradeoff, not an oversight.
*/
function modelGreetingCoversFacts(text: string, facts: SystemAgentGreetingFacts): boolean {
const normalized = text.toLocaleLowerCase();
if (facts.updateAvailable && !normalized.includes(facts.updateAvailable.toLocaleLowerCase())) {
return false;
}
if (
facts.channelHealth.degraded.some((label) => !normalized.includes(label.toLocaleLowerCase()))
) {
return false;
}
if (
!facts.channelHealth.available &&
!(normalized.includes("channel health") && normalized.includes("unavailable"))
) {
return false;
}
return true;
}
function requiresDeterministicGreeting(overview: SystemAgentOverview): boolean {
return (
!overview.config.exists ||
!overview.config.valid ||
!overview.defaultModel ||
!overview.gateway.reachable
);
}
function formatSystemAgentGreetingFallback(
overview: SystemAgentOverview,
facts: SystemAgentGreetingFacts,
): string {
const alerts: string[] = [];
if (facts.updateAvailable) {
alerts.push(`Update ${facts.updateAvailable} is available.`);
}
if (facts.channelHealth.degraded.length > 0) {
alerts.push(`Channels needing attention: ${facts.channelHealth.degraded.join(", ")}.`);
} else if (!facts.channelHealth.available) {
alerts.push("Channel health is not available yet.");
}
// recentExternalEdit is deliberately absent: withHostOwnedAlerts appends the
// single canonical edit alert to every delivered greeting, template included.
return [formatSystemAgentStartupMessage(overview), alerts.join(" ") || undefined]
.filter((line): line is string => line !== undefined)
.join("\n");
}
async function withGreetingTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error("system-agent greeting timed out")), timeoutMs);
if (typeof timer === "object" && "unref" in timer) {
timer.unref();
}
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function resolveUncachedSystemAgentGreeting(params: {
overview: SystemAgentOverview;
facts: SystemAgentGreetingFacts;
planner: SystemAgentGreetingPlanner;
cacheStore: SystemAgentGreetingCacheStore;
factsHash: string;
at: number;
timeoutMs?: number;
}): Promise<SystemAgentGreetingResolution> {
const timeoutMs = params.timeoutMs ?? SYSTEM_AGENT_GREETING_TIMEOUT_MS;
let plan: SystemAgentGreetingPlan | null = null;
try {
// This is the only metered greeting turn. The single-slot hash keeps unchanged
// caretaker opens at zero tokens while preserving a model-free rescue path.
plan = await withGreetingTimeout(
params.planner({ overview: params.overview, facts: params.facts, timeoutMs }),
timeoutMs,
);
} catch {
plan = null;
}
const text = plan ? normalizeGreetingText(plan.text) : null;
const groundedText = text && modelGreetingCoversFacts(text, params.facts) ? text : null;
if (!groundedText || !plan?.modelRef.trim()) {
// Keep provider outages cheap without writing a template into the model-greeting cache.
greetingFailures.set(params.cacheStore, {
factsHash: params.factsHash,
retryAfter: params.at + SYSTEM_AGENT_GREETING_FAILURE_RETRY_MS,
});
return {
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
source: "template",
};
}
greetingFailures.delete(params.cacheStore);
try {
mutateGreetingState(
params.cacheStore,
(current) => {
// A slower turn for old facts must not replace a newer system-state greeting.
if (
current?.factsHash &&
current.factsHash !== params.factsHash &&
(current.at ?? Number.NEGATIVE_INFINITY) >= params.at
) {
return current;
}
return {
...current,
lastSeenAuditSequence: current?.lastSeenAuditSequence ?? 0,
factsHash: params.factsHash,
text: groundedText,
modelRef: plan.modelRef.trim(),
at: params.at,
};
},
params.at,
);
} catch {
// Cache persistence is diagnostic-only; a successful greeting still wins.
}
return { text: groundedText, source: "model" };
}
type ResolveSystemAgentGreetingParams = {
overview: SystemAgentOverview;
facts: SystemAgentGreetingFacts;
planner: SystemAgentGreetingPlanner;
/** False for internal session seeding: cache reads stay free and a miss uses the template. */
allowInference?: boolean;
cacheStore?: SystemAgentGreetingCacheStore;
openCache?: () => SystemAgentGreetingCacheStore;
now?: () => number;
timeoutMs?: number;
};
export async function resolveSystemAgentGreeting(
params: ResolveSystemAgentGreetingParams,
): Promise<SystemAgentGreetingResolution> {
const resolution = await resolveSystemAgentGreetingText(params);
// Host-owned alerts append at delivery, never into the cache: the cached
// model text must stay valid for deliveries where the fact is absent.
return { ...resolution, text: withHostOwnedAlerts(resolution.text, params.facts) };
}
async function resolveSystemAgentGreetingText(
params: ResolveSystemAgentGreetingParams,
): Promise<SystemAgentGreetingResolution> {
if (requiresDeterministicGreeting(params.overview)) {
// When the system is broken, precision beats personality: the rescue path
// must neither depend on nor spend inference, and model text is never cached.
return {
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
source: "template",
};
}
let cacheStore: SystemAgentGreetingCacheStore;
try {
cacheStore = params.cacheStore ?? params.openCache?.() ?? getDefaultGreetingCache();
} catch {
return {
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
source: "template",
};
}
const factsHash = systemAgentGreetingFactsHash(params.overview, params.facts);
let cached: SystemAgentGreetingCacheRecord | null;
try {
cached = readGreetingCache(cacheStore);
} catch {
return {
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
source: "template",
};
}
if (
typeof cached?.text === "string" &&
cached.text.trim() &&
cached.factsHash === factsHash &&
typeof cached.modelRef === "string" &&
typeof cached.at === "number"
) {
return { text: cached.text, source: "cache" };
}
if (params.allowInference === false) {
return {
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
source: "template",
};
}
const at = (params.now ?? Date.now)();
// This timestamp orders competing model-cache writes; audit acknowledgement uses
// the monotonic sequence captured with the facts instead of wall-clock time.
const failure = greetingFailures.get(cacheStore);
if (failure?.factsHash === factsHash && failure.retryAfter > at) {
return {
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
source: "template",
};
}
if (failure && failure.retryAfter <= at) {
greetingFailures.delete(cacheStore);
}
let flights = greetingFlights.get(cacheStore);
if (!flights) {
flights = new Map();
greetingFlights.set(cacheStore, flights);
}
const existingFlight = flights.get(factsHash);
if (existingFlight) {
return existingFlight;
}
const flight = resolveUncachedSystemAgentGreeting({
...params,
cacheStore,
factsHash,
at,
});
flights.set(factsHash, flight);
try {
return await flight;
} finally {
if (flights.get(factsHash) === flight) {
flights.delete(factsHash);
}
}
}
/** Persist the config-audit cursor only after the host has delivered the greeting. */
export function acknowledgeSystemAgentGreetingDelivery(params: {
auditSequence: number;
cacheStore?: SystemAgentGreetingCacheStore;
openCache?: () => SystemAgentGreetingCacheStore;
now?: () => number;
}): void {
if (!Number.isSafeInteger(params.auditSequence) || params.auditSequence < 0) {
return;
}
try {
const cacheStore = params.cacheStore ?? params.openCache?.() ?? getDefaultGreetingCache();
mutateGreetingState(
cacheStore,
(current) => {
const lastSeenAuditSequence = Math.max(
current?.lastSeenAuditSequence ?? 0,
params.auditSequence,
);
if (current?.lastSeenAuditSequence === lastSeenAuditSequence) {
return current;
}
return {
...current,
lastSeenAuditSequence,
};
},
(params.now ?? Date.now)(),
);
} catch {
// Delivery wins even when diagnostic acknowledgement cannot be persisted.
}
}
function addQuickAction(
options: SystemAgentChatQuestion["options"],
option: SystemAgentChatQuestion["options"][number],
): void {
if (options.length >= 4 || options.some((candidate) => candidate.reply === option.reply)) {
return;
}
options.push(option);
}
/** Quick actions are host-derived so model wording can never invent executable replies. */
export function buildSystemAgentGreetingQuestion(
overview: SystemAgentOverview,
facts: SystemAgentGreetingFacts,
): SystemAgentChatQuestion {
const exceptional: SystemAgentChatQuestion["options"] = [];
if (!overview.config.exists) {
addQuickAction(exceptional, { label: "Set up OpenClaw", reply: "setup" });
} else if (!overview.config.valid) {
addQuickAction(exceptional, { label: "Inspect config", reply: "doctor" });
} else if (!overview.defaultModel) {
// A valid config without verified inference cannot hand off to an agent;
// setup is the canonical path to establish a model.
addQuickAction(exceptional, { label: "Set up inference", reply: "setup" });
}
if (!overview.gateway.reachable) {
addQuickAction(exceptional, { label: "Run gateway status", reply: "gateway status" });
addQuickAction(exceptional, { label: "Restart gateway", reply: "restart gateway" });
}
if (!facts.channelHealth.available || facts.channelHealth.degraded.length > 0) {
addQuickAction(exceptional, { label: "Check channel health", reply: "health" });
}
if (facts.updateAvailable) {
addQuickAction(exceptional, { label: "Show update", reply: "status" });
}
// Keep History and agent handoff reachable even when several exceptional facts compete
// for the schema's four slots. The greeting itself still names every exceptional fact.
const options = exceptional.slice(0, 2);
// Without a model the handoff chip would advertise a dead action; the
// no-model branch above already routes users to setup instead.
if (overview.defaultModel) {
addQuickAction(options, {
label: "Talk to my agent",
reply: "talk to agent",
recommended:
exceptional.length === 0 &&
!facts.recentExternalEdit &&
facts.channelHealth.available &&
overview.config.exists &&
overview.config.valid &&
overview.gateway.reachable,
});
}
addQuickAction(options, {
label: facts.recentExternalEdit ? "Review recent changes" : "Show recent changes",
reply: "audit",
});
return {
id: "system-agent-quick-actions",
header: "Quick actions",
question: "What would you like me to do?",
options,
};
}
+6 -6
View File
@@ -101,10 +101,10 @@ describe("loadSystemAgentOverview", () => {
'Next: run "gateway status" or "restart gateway"',
);
const startup = formatSystemAgentStartupMessage(overview);
expect(startup).toContain("## Hi, I'm OpenClaw.");
expect(startup).toContain("Using: openai/gpt-5.2");
expect(startup).toContain("Hi, I'm OpenClaw — caretaker");
expect(startup).toContain("Model: openai/gpt-5.2");
expect(startup).toContain("Gateway: not reachable");
expect(startup).toContain("I can start debugging with `gateway status`");
expect(startup).not.toContain("`gateway status`");
expect(startup).not.toContain("Codex:");
expect(startup).not.toContain("Claude Code:");
expect(startup).not.toContain("API keys:");
@@ -117,9 +117,9 @@ describe("loadSystemAgentOverview", () => {
expect(formatSystemAgentOverview(overview)).toContain(
'Next: run "openclaw onboard" to establish inference',
);
expect(startup).toContain("Inference unavailable");
expect(startup).toContain("run `openclaw onboard`");
expect(startup).toContain("OpenClaw needs working inference");
expect(startup).toContain("Inference is unavailable");
expect(startup).toContain("Run `openclaw onboard`");
expect(startup.match(/`[^`]+`/g)).toEqual(["`openclaw onboard`"]);
expect(startup).not.toContain("local Claude Code/Codex/Gemini login");
expect(startup).not.toContain("typed commands as last resort");
});
+13 -25
View File
@@ -304,13 +304,6 @@ function formatStartupConfigStatus(overview: SystemAgentOverview): string {
return overview.config.valid ? "valid" : "invalid";
}
function formatStartupUse(overview: SystemAgentOverview): string {
if (overview.defaultModel) {
return `Using: ${overview.defaultModel} — just tell me what you want.`;
}
return "Inference unavailable: run `openclaw onboard` and complete a live model check first.";
}
function formatStartupGatewayStatus(overview: SystemAgentOverview): string {
if (overview.gateway.reachable) {
return `Gateway: reachable at ${overview.gateway.url}.`;
@@ -318,20 +311,14 @@ function formatStartupGatewayStatus(overview: SystemAgentOverview): string {
return `Gateway: not reachable at ${overview.gateway.url}; I already did the first probe.`;
}
function formatStartupAction(overview: SystemAgentOverview): string {
function formatStartupAction(overview: SystemAgentOverview): string | undefined {
if (!overview.config.valid) {
return "I can start debugging with `validate config` or `doctor`.";
return "Config needs attention. Run `doctor` to inspect it.";
}
if (!overview.defaultModel) {
return "OpenClaw needs working inference before it can help with the rest of setup.";
return "Inference is unavailable. Run `openclaw onboard` and complete a live model check.";
}
if (!overview.config.exists) {
return "Run `openclaw onboard` to establish inference before starting OpenClaw.";
}
if (!overview.gateway.reachable) {
return "I can start debugging with `gateway status`, or queue `restart gateway` for approval.";
}
return "Everything basic is reachable. Use `talk to agent` when you want the normal agent.";
return undefined;
}
/**
@@ -357,13 +344,14 @@ export function formatSystemAgentStartupMessage(overview: SystemAgentOverview):
? `${overview.defaultAgentId} (${agent.name})`
: overview.defaultAgentId;
return [
"## Hi, I'm OpenClaw.",
"",
"- Start me when setup, config, Gateway, model choice, or agent routing feels off.",
`- ${formatStartupUse(overview)}`,
`- Config: ${formatStartupConfigStatus(overview)}. Default agent: ${agentLabel}.`,
`- ${formatStartupGatewayStatus(overview)}`,
"",
"Hi, I'm OpenClaw — caretaker of this gateway, config, channels, and agents.",
// Inference status stays independent of the recovery action line: with an
// invalid config AND no model, both problems must be visible.
overview.defaultModel ? `Model: ${overview.defaultModel}.` : "Inference is unavailable.",
`Config: ${formatStartupConfigStatus(overview)}. Default agent: ${agentLabel}.`,
formatStartupGatewayStatus(overview),
formatStartupAction(overview),
].join("\n");
]
.filter((line): line is string => line !== undefined)
.join("\n");
}
@@ -607,6 +607,40 @@ describe("custodian page", () => {
expect(request.mock.calls[1]?.[1]).toMatchObject({ message: "status" });
});
it("renders and sends quick actions on the normal caretaker welcome", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
sessionId: "control-ui-caretaker-00000000-0000-4000-8000-000000000001",
reply: "I'm OpenClaw. All systems nominal.",
action: "none",
question: {
id: "system-agent-quick-actions",
header: "Quick actions",
question: "What would you like me to do?",
options: [
{ label: "Talk to my agent", reply: "talk to agent", recommended: true },
{ label: "Show recent changes", reply: "audit" },
],
},
})
.mockResolvedValueOnce({
sessionId: "control-ui-caretaker-00000000-0000-4000-8000-000000000001",
reply: "Here's the audit state.",
action: "none",
});
const { context } = createContext(request);
const { page } = await mountPage(context, { onboarding: false });
await waitForFast(() => expect(request).toHaveBeenCalledOnce());
await page.updateComplete;
page.querySelector<HTMLButtonElement>('[data-option-value="Show recent changes"]')!.click();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
expect(request.mock.calls[1]?.[1]).toMatchObject({ message: "audit" });
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("welcomeVariant");
});
it("starts a fresh welcome when onboarding mode changes", async () => {
const request = vi
.fn()