Skip to content

Commit aa6fb18

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents ae1d76f + a743b30 commit aa6fb18

26 files changed

Lines changed: 440 additions & 94 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus.
1616
- Doctor/systemd: keep `openclaw doctor --repair` and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir `.env` values. (#66249) Thanks @tmimmanuel.
1717
- Doctor/plugins: cache external `preferOver` catalog lookups within each plugin auto-enable pass so large `agents.list` configs no longer peg CPU and repeatedly reread plugin catalogs during doctor/plugins resolution. (#66246) Thanks @yfge.
18+
- Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF.
1819

1920
## 2026.4.14-beta.1
2021

@@ -58,6 +59,8 @@ Docs: https://docs.openclaw.ai
5859
- Outbound/relay-status: suppress internal relay-status placeholder payloads (`No channel reply.`, `Replied in-thread.`, `Replied in #...`, wiki-update status variants ending in `No channel reply.`) before channel delivery so internal housekeeping text does not leak to users.
5960
- Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as `openclaw cron` no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson.
6061
- Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.
62+
- CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.
63+
- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.
6164

6265
## 2026.4.12
6366

docs/gateway/local-models.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Compatibility notes for stricter OpenAI-compatible backends:
174174

175175
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
176176
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
177+
- OpenClaw warns when the detected context window is below **32k** and blocks below **16k**. If you hit that preflight, raise the server/model context limit or choose a larger model.
177178
- Context errors? Lower `contextWindow` or raise your server limit.
178179
- OpenAI-compatible server returns `messages[].content ... expected a string`?
179180
Add `compat.requiresStringContent: true` on that model entry.

extensions/qqbot/src/utils/platform.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe("qqbot local media path remapping", () => {
9393
it("allows structured payload files inside the QQ Bot media directory", () => {
9494
const { mediaFile } = createQqbotMediaFile("allowed.png");
9595

96-
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(mediaFile);
96+
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
9797
});
9898

9999
it("blocks structured payload files inside the QQ Bot data directory", () => {
@@ -127,6 +127,6 @@ describe("qqbot local media path remapping", () => {
127127
"legacy.png",
128128
);
129129

130-
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(mediaFile);
130+
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile));
131131
});
132132
});

extensions/telegram/src/topic-name-cache.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import syncFs from "node:fs";
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5-
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import {
77
clearTopicNameCache,
88
getTopicEntry,
@@ -14,10 +14,15 @@ import {
1414

1515
describe("topic-name-cache", () => {
1616
beforeEach(() => {
17+
vi.useRealTimers();
1718
clearTopicNameCache();
1819
resetTopicNameCacheForTest();
1920
});
2021

22+
afterEach(() => {
23+
vi.useRealTimers();
24+
});
25+
2126
it("stores and retrieves a topic name", () => {
2227
updateTopicName(-100123, 42, { name: "Deployments" });
2328
expect(getTopicName(-100123, 42)).toBe("Deployments");
@@ -63,9 +68,11 @@ describe("topic-name-cache", () => {
6368
expect(topicNameCacheSize()).toBe(0);
6469
});
6570

66-
it("updates timestamps on write", () => {
71+
it("updates timestamps on write", async () => {
72+
vi.useFakeTimers();
6773
updateTopicName(-100123, 42, { name: "A" });
6874
const t1 = getTopicEntry(-100123, 42)?.updatedAt ?? 0;
75+
await vi.advanceTimersByTimeAsync(10);
6976
updateTopicName(-100123, 42, { name: "B" });
7077
const t2 = getTopicEntry(-100123, 42)?.updatedAt ?? 0;
7178
expect(t2).toBeGreaterThan(t1);
@@ -85,8 +92,10 @@ describe("topic-name-cache", () => {
8592
expect(getTopicName(-100000, 2048)).toBe("Topic 2048");
8693
});
8794

88-
it("refreshes recency on read so active topics survive eviction", () => {
95+
it("refreshes recency on read so active topics survive eviction", async () => {
96+
vi.useFakeTimers();
8997
updateTopicName(-100000, 1, { name: "Active" });
98+
await vi.advanceTimersByTimeAsync(10);
9099
for (let i = 2; i <= 2048; i++) {
91100
updateTopicName(-100000, i, { name: `Topic ${i}` });
92101
}

src/agents/context-window-guard.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
CONTEXT_WINDOW_HARD_MIN_TOKENS,
55
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
66
evaluateContextWindowGuard,
7+
formatContextWindowBlockMessage,
8+
formatContextWindowWarningMessage,
79
resolveContextWindowInfo,
810
} from "./context-window-guard.js";
911

@@ -222,4 +224,87 @@ describe("context-window-guard", () => {
222224
expect(CONTEXT_WINDOW_HARD_MIN_TOKENS).toBe(16_000);
223225
expect(CONTEXT_WINDOW_WARN_BELOW_TOKENS).toBe(32_000);
224226
});
227+
228+
it("adds a local-model hint to warning messages for localhost endpoints", () => {
229+
const guard = evaluateContextWindowGuard({
230+
info: { tokens: 24_000, source: "model" },
231+
});
232+
233+
expect(
234+
formatContextWindowWarningMessage({
235+
provider: "lmstudio",
236+
modelId: "qwen3",
237+
guard,
238+
runtimeBaseUrl: "http://127.0.0.1:1234/v1",
239+
}),
240+
).toContain("local/self-hosted runs work best at 32000+ tokens");
241+
});
242+
243+
it("does not add local-model hints for generic custom endpoints", () => {
244+
const guard = evaluateContextWindowGuard({
245+
info: { tokens: 24_000, source: "model" },
246+
});
247+
248+
expect(
249+
formatContextWindowWarningMessage({
250+
provider: "custom",
251+
modelId: "hosted-proxy-model",
252+
guard,
253+
runtimeBaseUrl: "https://models.example.com/v1",
254+
}),
255+
).toBe("low context window: custom/hosted-proxy-model ctx=24000 (warn<32000) source=model");
256+
});
257+
258+
it("adds a local-model hint to block messages for localhost endpoints", () => {
259+
const guard = evaluateContextWindowGuard({
260+
info: { tokens: 8_000, source: "model" },
261+
});
262+
263+
expect(
264+
formatContextWindowBlockMessage({
265+
guard,
266+
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
267+
}),
268+
).toContain("This looks like a local model endpoint.");
269+
});
270+
271+
it("points config-backed block remediation at agents.defaults.contextTokens", () => {
272+
const guard = evaluateContextWindowGuard({
273+
info: { tokens: 8_000, source: "agentContextTokens" },
274+
});
275+
276+
const message = formatContextWindowBlockMessage({
277+
guard,
278+
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
279+
});
280+
281+
expect(message).toContain("OpenClaw is capped by agents.defaults.contextTokens.");
282+
expect(message).not.toContain("choose a larger model");
283+
});
284+
285+
it("points model config block remediation at contextWindow/contextTokens", () => {
286+
const guard = evaluateContextWindowGuard({
287+
info: { tokens: 8_000, source: "modelsConfig" },
288+
});
289+
290+
expect(
291+
formatContextWindowBlockMessage({
292+
guard,
293+
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
294+
}),
295+
).toContain("Raise contextWindow/contextTokens or choose a larger model.");
296+
});
297+
298+
it("keeps block messages concise for public providers", () => {
299+
const guard = evaluateContextWindowGuard({
300+
info: { tokens: 8_000, source: "model" },
301+
});
302+
303+
expect(
304+
formatContextWindowBlockMessage({
305+
guard,
306+
runtimeBaseUrl: "https://api.openai.com/v1",
307+
}),
308+
).toBe(`Model context window too small (8000 tokens; source=model). Minimum is 16000.`);
309+
});
225310
});

src/agents/context-window-guard.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { resolveProviderEndpoint } from "./provider-attribution.js";
23
import { findNormalizedProviderValue } from "./provider-id.js";
34

45
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
@@ -61,6 +62,77 @@ export type ContextWindowGuardResult = ContextWindowInfo & {
6162
shouldBlock: boolean;
6263
};
6364

65+
export type ContextWindowGuardHint = {
66+
endpointClass: ReturnType<typeof resolveProviderEndpoint>["endpointClass"];
67+
likelySelfHosted: boolean;
68+
};
69+
70+
export function resolveContextWindowGuardHint(params: {
71+
runtimeBaseUrl?: string | null;
72+
}): ContextWindowGuardHint {
73+
const endpoint = resolveProviderEndpoint(params.runtimeBaseUrl ?? undefined);
74+
return {
75+
endpointClass: endpoint.endpointClass,
76+
likelySelfHosted: endpoint.endpointClass === "local",
77+
};
78+
}
79+
80+
export function formatContextWindowWarningMessage(params: {
81+
provider: string;
82+
modelId: string;
83+
guard: ContextWindowGuardResult;
84+
runtimeBaseUrl?: string | null;
85+
}): string {
86+
const base = `low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} (warn<${CONTEXT_WINDOW_WARN_BELOW_TOKENS}) source=${params.guard.source}`;
87+
const hint = resolveContextWindowGuardHint({ runtimeBaseUrl: params.runtimeBaseUrl });
88+
if (!hint.likelySelfHosted) {
89+
return base;
90+
}
91+
if (params.guard.source === "agentContextTokens") {
92+
return (
93+
`${base}; OpenClaw is capped by agents.defaults.contextTokens, so raise that cap ` +
94+
`if you want to use more of the model context window`
95+
);
96+
}
97+
if (params.guard.source === "modelsConfig") {
98+
return (
99+
`${base}; OpenClaw is using the configured model context limit for this model, ` +
100+
`so raise contextWindow/contextTokens if it is set too low`
101+
);
102+
}
103+
return (
104+
`${base}; local/self-hosted runs work best at ` +
105+
`${CONTEXT_WINDOW_WARN_BELOW_TOKENS}+ tokens and may show weaker tool use or more compaction until the server/model context limit is raised`
106+
);
107+
}
108+
109+
export function formatContextWindowBlockMessage(params: {
110+
guard: ContextWindowGuardResult;
111+
runtimeBaseUrl?: string | null;
112+
}): string {
113+
const base =
114+
`Model context window too small (${params.guard.tokens} tokens; ` +
115+
`source=${params.guard.source}). Minimum is ${CONTEXT_WINDOW_HARD_MIN_TOKENS}.`;
116+
const hint = resolveContextWindowGuardHint({ runtimeBaseUrl: params.runtimeBaseUrl });
117+
if (!hint.likelySelfHosted) {
118+
return base;
119+
}
120+
if (params.guard.source === "agentContextTokens") {
121+
return `${base} OpenClaw is capped by agents.defaults.contextTokens. Raise that cap.`;
122+
}
123+
if (params.guard.source === "modelsConfig") {
124+
return (
125+
`${base} OpenClaw is using the configured model context limit for this model. ` +
126+
`Raise contextWindow/contextTokens or choose a larger model.`
127+
);
128+
}
129+
return (
130+
`${base} This looks like a local model endpoint. ` +
131+
`Raise the server/model context limit or choose a larger model. ` +
132+
`OpenClaw local/self-hosted runs work best at ${CONTEXT_WINDOW_WARN_BELOW_TOKENS}+ tokens.`
133+
);
134+
}
135+
64136
export function evaluateContextWindowGuard(params: {
65137
info: ContextWindowInfo;
66138
warnBelowTokens?: number;

src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export const mockedResolveContextWindowInfo = vi.fn(() => ({
169169
tokens: 200000,
170170
source: "model",
171171
}));
172+
export const mockedFormatContextWindowWarningMessage = vi.fn(
173+
(params: { provider: string; modelId: string; guard: { tokens: number; source: string } }) =>
174+
`low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} source=${params.guard.source}`,
175+
);
176+
export const mockedFormatContextWindowBlockMessage = vi.fn(
177+
(params: { guard: { tokens: number; source: string } }) =>
178+
`Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`,
179+
);
172180
export const mockedGetApiKeyForModel = vi.fn(
173181
async ({ profileId }: { profileId?: string } = {}) => ({
174182
apiKey: "test-key",
@@ -300,6 +308,16 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
300308
tokens: 200000,
301309
source: "model",
302310
});
311+
mockedFormatContextWindowWarningMessage.mockReset();
312+
mockedFormatContextWindowWarningMessage.mockImplementation(
313+
(params: { provider: string; modelId: string; guard: { tokens: number; source: string } }) =>
314+
`low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} source=${params.guard.source}`,
315+
);
316+
mockedFormatContextWindowBlockMessage.mockReset();
317+
mockedFormatContextWindowBlockMessage.mockImplementation(
318+
(params: { guard: { tokens: number; source: string } }) =>
319+
`Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`,
320+
);
303321
mockedGetApiKeyForModel.mockReset();
304322
mockedGetApiKeyForModel.mockImplementation(
305323
async ({ profileId }: { profileId?: string } = {}) => ({
@@ -443,6 +461,8 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
443461
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
444462
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
445463
evaluateContextWindowGuard: mockedEvaluateContextWindowGuard,
464+
formatContextWindowBlockMessage: mockedFormatContextWindowBlockMessage,
465+
formatContextWindowWarningMessage: mockedFormatContextWindowWarningMessage,
446466
resolveContextWindowInfo: mockedResolveContextWindowInfo,
447467
}));
448468

src/agents/pi-embedded-runner/run.overflow-compaction.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
9898
...overflowBaseRunParams,
9999
runId: "run-small-context",
100100
}),
101-
).rejects.toThrow("Model context window too small (800 tokens). Minimum is 1000.");
101+
).rejects.toThrow(
102+
"Model context window too small (800 tokens; source=model). Minimum is 1000.",
103+
);
102104

103105
expect(mockedRunEmbeddedAttempt).not.toHaveBeenCalled();
104106
});

src/agents/pi-embedded-runner/run/setup.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
CONTEXT_WINDOW_HARD_MIN_TOKENS,
66
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
77
evaluateContextWindowGuard,
8+
formatContextWindowBlockMessage,
9+
formatContextWindowWarningMessage,
810
resolveContextWindowInfo,
911
type ContextWindowInfo,
1012
} from "../../context-window-guard.js";
@@ -126,19 +128,33 @@ export function resolveEffectiveRuntimeModel(params: {
126128
warnBelowTokens: CONTEXT_WINDOW_WARN_BELOW_TOKENS,
127129
hardMinTokens: CONTEXT_WINDOW_HARD_MIN_TOKENS,
128130
});
131+
const runtimeBaseUrl =
132+
typeof (params.runtimeModel as { baseUrl?: unknown }).baseUrl === "string"
133+
? (params.runtimeModel as { baseUrl: string }).baseUrl
134+
: undefined;
129135
if (ctxGuard.shouldWarn) {
130136
log.warn(
131-
`low context window: ${params.provider}/${params.modelId} ctx=${ctxGuard.tokens} (warn<${CONTEXT_WINDOW_WARN_BELOW_TOKENS}) source=${ctxGuard.source}`,
137+
formatContextWindowWarningMessage({
138+
provider: params.provider,
139+
modelId: params.modelId,
140+
guard: ctxGuard,
141+
runtimeBaseUrl,
142+
}),
132143
);
133144
}
134145
if (ctxGuard.shouldBlock) {
146+
const message = formatContextWindowBlockMessage({
147+
guard: ctxGuard,
148+
runtimeBaseUrl,
149+
});
135150
log.error(
136-
`blocked model (context window too small): ${params.provider}/${params.modelId} ctx=${ctxGuard.tokens} (min=${CONTEXT_WINDOW_HARD_MIN_TOKENS}) source=${ctxGuard.source}`,
137-
);
138-
throw new FailoverError(
139-
`Model context window too small (${ctxGuard.tokens} tokens). Minimum is ${CONTEXT_WINDOW_HARD_MIN_TOKENS}.`,
140-
{ reason: "unknown", provider: params.provider, model: params.modelId },
151+
`blocked model (context window too small): ${params.provider}/${params.modelId} ctx=${ctxGuard.tokens} (min=${CONTEXT_WINDOW_HARD_MIN_TOKENS}) source=${ctxGuard.source}; ${message}`,
141152
);
153+
throw new FailoverError(message, {
154+
reason: "unknown",
155+
provider: params.provider,
156+
model: params.modelId,
157+
});
142158
}
143159

144160
return {

0 commit comments

Comments
 (0)