Skip to content

Commit e4de9c5

Browse files
jamebobobjalehman
andauthored
feat: wire customInstructions from plugin config (#181)
* feat: wire customInstructions from plugin config The customInstructions parameter threads through all 4 prompt builders (buildLeafSummaryPrompt, buildD1Prompt, buildD2Prompt, buildD3PlusPrompt) but is never read from config. This means operators cannot control summarization tone or style without patching source. Fix: - Add customInstructions to LcmConfig type and resolver (config.ts) - Read in resolveSummarize() chokepoint (engine.ts) with fallback: params.customInstructions || this.config.customInstructions || undefined - Add to plugin schema and uiHints (openclaw.plugin.json) - Add config test coverage (test/custom-instructions.test.ts) Every summarization path (afterTurn, /compact, overflow recovery) gets instructions automatically through the chokepoint without caller changes. * fix: honor customInstructions overrides across summarizers --------- Co-authored-by: Josh Lehman <[email protected]>
1 parent ef4865f commit e4de9c5

5 files changed

Lines changed: 139 additions & 1 deletion

File tree

openclaw.plugin.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
"summaryMaxOverageFactor": {
5353
"label": "Summary Max Overage Factor",
5454
"help": "Maximum allowed overage factor for summaries relative to target tokens (default 3). Summaries exceeding this are deterministically truncated."
55+
},
56+
"customInstructions": {
57+
"label": "Custom Instructions",
58+
"help": "Natural language instructions injected into all summarization prompts (e.g., formatting rules, tone control)"
5559
}
5660
},
5761
"configSchema": {
@@ -127,6 +131,9 @@
127131
"summaryMaxOverageFactor": {
128132
"type": "number",
129133
"minimum": 1
134+
},
135+
"customInstructions": {
136+
"type": "string"
130137
}
131138
}
132139
}

src/db/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export type LcmConfig = {
4646
maxAssemblyTokenBudget?: number;
4747
/** Maximum allowed overage factor for summaries relative to target tokens (default 3). */
4848
summaryMaxOverageFactor: number;
49+
/** Custom instructions injected into all summarization prompts. */
50+
customInstructions: string;
4951
};
5052

5153
/** Safely coerce an unknown value to a finite number, or return undefined. */
@@ -195,5 +197,7 @@ export function resolveLcmConfig(
195197
summaryMaxOverageFactor:
196198
(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR !== undefined ? parseFloat(env.LCM_SUMMARY_MAX_OVERAGE_FACTOR) : undefined)
197199
?? toNumber(pc.summaryMaxOverageFactor) ?? 3,
200+
customInstructions:
201+
env.LCM_CUSTOM_INSTRUCTIONS?.trim() ?? toStr(pc.customInstructions) ?? "",
198202
};
199203
}

src/engine.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1238,10 +1238,14 @@ export class LcmContextEngine implements ContextEngine {
12381238
};
12391239
}
12401240
try {
1241+
const customInstructions =
1242+
params.customInstructions !== undefined
1243+
? params.customInstructions
1244+
: (this.config.customInstructions || undefined);
12411245
const runtimeSummarizer = await createLcmSummarizeFromLegacyParams({
12421246
deps: this.deps,
12431247
legacyParams: lp,
1244-
customInstructions: params.customInstructions,
1248+
customInstructions,
12451249
});
12461250
if (runtimeSummarizer) {
12471251
return { summarize: runtimeSummarizer.fn, summaryModel: runtimeSummarizer.model };
@@ -1278,6 +1282,7 @@ export class LcmContextEngine implements ContextEngine {
12781282
const result = await createLcmSummarizeFromLegacyParams({
12791283
deps: this.deps,
12801284
legacyParams: { provider, model },
1285+
customInstructions: this.config.customInstructions || undefined,
12811286
});
12821287
if (!result) {
12831288
return undefined;

test/custom-instructions.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect } from "vitest";
2+
import manifest from "../openclaw.plugin.json" with { type: "json" };
3+
import { resolveLcmConfig } from "../src/db/config.js";
4+
5+
describe("customInstructions config", () => {
6+
it("defaults customInstructions to empty string", () => {
7+
const config = resolveLcmConfig({}, {});
8+
expect(config.customInstructions).toBe("");
9+
});
10+
11+
it("reads customInstructions from plugin config", () => {
12+
const config = resolveLcmConfig({}, {
13+
customInstructions: "Write as a neutral documenter. Use third person.",
14+
});
15+
expect(config.customInstructions).toBe("Write as a neutral documenter. Use third person.");
16+
});
17+
18+
it("env var overrides plugin config for customInstructions", () => {
19+
const config = resolveLcmConfig(
20+
{ LCM_CUSTOM_INSTRUCTIONS: "env instructions" } as NodeJS.ProcessEnv,
21+
{ customInstructions: "plugin instructions" },
22+
);
23+
expect(config.customInstructions).toBe("env instructions");
24+
});
25+
26+
it("trims whitespace from env var customInstructions", () => {
27+
const config = resolveLcmConfig(
28+
{ LCM_CUSTOM_INSTRUCTIONS: " trimmed " } as NodeJS.ProcessEnv,
29+
{},
30+
);
31+
expect(config.customInstructions).toBe("trimmed");
32+
});
33+
34+
it("trims whitespace from plugin config customInstructions", () => {
35+
const config = resolveLcmConfig({}, {
36+
customInstructions: " trimmed ",
37+
});
38+
expect(config.customInstructions).toBe("trimmed");
39+
});
40+
41+
it("falls through to default when plugin config value is empty string", () => {
42+
const config = resolveLcmConfig({}, {
43+
customInstructions: " ",
44+
});
45+
expect(config.customInstructions).toBe("");
46+
});
47+
48+
it("ignores non-string plugin config values", () => {
49+
const config = resolveLcmConfig({}, {
50+
customInstructions: 42,
51+
});
52+
expect(config.customInstructions).toBe("");
53+
});
54+
55+
it("ships a manifest with customInstructions in schema", () => {
56+
expect(manifest.configSchema.properties.customInstructions).toEqual({ type: "string" });
57+
});
58+
});

test/engine.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function createTestConfig(databasePath: string): LcmConfig {
4646
timezone: "UTC",
4747
pruneHeartbeatOk: false,
4848
summaryMaxOverageFactor: 3,
49+
customInstructions: "",
4950
};
5051
}
5152

@@ -3106,6 +3107,69 @@ describe("LcmContextEngine fidelity and token budget", () => {
31063107
// ── Compact token budget plumbing ───────────────────────────────────────────
31073108

31083109
describe("LcmContextEngine.compact token budget plumbing", () => {
3110+
it("preserves explicit empty-string customInstructions overrides over config defaults", async () => {
3111+
const completeSpy = vi.fn(async () => ({
3112+
content: [{ type: "text", text: "summary output" }],
3113+
}));
3114+
const engine = createEngineWithDeps(
3115+
{ customInstructions: "Write in third person." },
3116+
{ complete: completeSpy },
3117+
);
3118+
const privateEngine = engine as unknown as {
3119+
resolveSummarize: (params: {
3120+
legacyParams?: Record<string, unknown>;
3121+
customInstructions?: string;
3122+
}) => Promise<{
3123+
summarize: (text: string, aggressive?: boolean) => Promise<string>;
3124+
summaryModel: string;
3125+
}>;
3126+
};
3127+
3128+
const { summarize } = await privateEngine.resolveSummarize({
3129+
legacyParams: { provider: "anthropic", model: "claude-opus-4-5" },
3130+
customInstructions: "",
3131+
});
3132+
3133+
await summarize("segment text");
3134+
3135+
const firstCall = completeSpy.mock.calls[0]?.[0] as
3136+
| { messages?: Array<{ content?: string }> }
3137+
| undefined;
3138+
const prompt = firstCall?.messages?.[0]?.content;
3139+
expect(typeof prompt).toBe("string");
3140+
expect(prompt).toContain("Operator instructions: (none)");
3141+
expect(prompt).not.toContain("Write in third person.");
3142+
});
3143+
3144+
it("forwards config customInstructions to large-file summarization", async () => {
3145+
const completeSpy = vi.fn(async () => ({
3146+
content: [{ type: "text", text: "summary output" }],
3147+
}));
3148+
const engine = createEngineWithDeps(
3149+
{
3150+
customInstructions: "Use terse factual prose.",
3151+
largeFileSummaryProvider: "anthropic",
3152+
largeFileSummaryModel: "claude-opus-4-5",
3153+
},
3154+
{ complete: completeSpy },
3155+
);
3156+
const privateEngine = engine as unknown as {
3157+
resolveLargeFileTextSummarizer: () => Promise<((prompt: string) => Promise<string | null>) | undefined>;
3158+
};
3159+
3160+
const summarizeText = await privateEngine.resolveLargeFileTextSummarizer();
3161+
expect(summarizeText).toBeTypeOf("function");
3162+
3163+
await summarizeText!("Large file prompt");
3164+
3165+
const firstCall = completeSpy.mock.calls[0]?.[0] as
3166+
| { messages?: Array<{ content?: string }> }
3167+
| undefined;
3168+
const prompt = firstCall?.messages?.[0]?.content;
3169+
expect(typeof prompt).toBe("string");
3170+
expect(prompt).toContain("Operator instructions:\nUse terse factual prose.");
3171+
});
3172+
31093173
it("fails when compact token budget is missing", async () => {
31103174
const engine = createEngine();
31113175
const sessionId = "session-missing-budget";

0 commit comments

Comments
 (0)