mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
fix(agents): keep state.messages intact across z.ai-style provider turns in embedded runs
This commit is contained in:
@@ -75,7 +75,11 @@ import {
|
||||
setCompactionSafeguardCancelReason,
|
||||
} from "../pi-hooks/compaction-safeguard-runtime.js";
|
||||
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
|
||||
import { applyPiCompactionSettingsFromConfig } from "../pi-settings.js";
|
||||
import {
|
||||
applyPiAutoCompactionGuard,
|
||||
applyPiCompactionSettingsFromConfig,
|
||||
isSilentOverflowProneModel,
|
||||
} from "../pi-settings.js";
|
||||
import { createOpenClawCodingTools } from "../pi-tools.js";
|
||||
import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js";
|
||||
import { registerProviderStreamForModel } from "../provider-stream.js";
|
||||
@@ -960,12 +964,23 @@ async function compactEmbeddedPiSessionDirectOnce(
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
// DefaultResourceLoader.reload() rehydrates settings from disk and can drop OpenClaw
|
||||
// compaction overrides applied in createPreparedEmbeddedPiSettingsManager.
|
||||
// compaction overrides applied in createPreparedEmbeddedPiSettingsManager — same
|
||||
// rehydration also restores Pi's auto-compaction (openclaw#75799), so re-apply
|
||||
// both guards. effectiveModel.baseUrl matches the surrounding scope so
|
||||
// auth-profile-injected baseUrls reach the endpoint-class detector.
|
||||
applyPiCompactionSettingsFromConfig({
|
||||
settingsManager,
|
||||
cfg: params.config,
|
||||
contextTokenBudget: ctxInfo.tokens,
|
||||
});
|
||||
applyPiAutoCompactionGuard({
|
||||
settingsManager,
|
||||
silentOverflowProneProvider: isSilentOverflowProneModel({
|
||||
provider,
|
||||
modelId,
|
||||
baseUrl: effectiveModel.baseUrl ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const { customTools } = splitSdkTools({
|
||||
tools: effectiveTools,
|
||||
|
||||
@@ -321,6 +321,7 @@ vi.mock("../../pi-settings.js", () => ({
|
||||
keepRecentTokens: 40_000,
|
||||
},
|
||||
}),
|
||||
isSilentOverflowProneModel: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../extensions.js", () => ({
|
||||
|
||||
@@ -107,6 +107,7 @@ import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settin
|
||||
import {
|
||||
applyPiAutoCompactionGuard,
|
||||
applyPiCompactionSettingsFromConfig,
|
||||
isSilentOverflowProneModel,
|
||||
} from "../../pi-settings.js";
|
||||
import {
|
||||
createClientToolNameConflictError,
|
||||
@@ -1474,10 +1475,16 @@ export async function runEmbeddedAttempt(
|
||||
cfg: params.config,
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
});
|
||||
applyPiAutoCompactionGuard({
|
||||
const piAutoCompactionGuardArgs = {
|
||||
settingsManager,
|
||||
contextEngineInfo: activeContextEngine?.info,
|
||||
});
|
||||
silentOverflowProneProvider: isSilentOverflowProneModel({
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
baseUrl: params.model.baseUrl ?? undefined,
|
||||
}),
|
||||
};
|
||||
applyPiAutoCompactionGuard(piAutoCompactionGuardArgs);
|
||||
|
||||
// Sets compaction/pruning runtime state and returns extension factories
|
||||
// that must be passed to the resource loader for the safeguard to be active.
|
||||
@@ -1496,12 +1503,15 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
// DefaultResourceLoader.reload() rehydrates settings from disk and can drop OpenClaw
|
||||
// compaction overrides applied in createPreparedEmbeddedPiSettingsManager.
|
||||
// compaction overrides applied in createPreparedEmbeddedPiSettingsManager — same
|
||||
// rehydration also restores Pi's auto-compaction (openclaw#75799), so re-apply
|
||||
// both guards.
|
||||
applyPiCompactionSettingsFromConfig({
|
||||
settingsManager,
|
||||
cfg: params.config,
|
||||
contextTokenBudget: params.contextTokenBudget,
|
||||
});
|
||||
applyPiAutoCompactionGuard(piAutoCompactionGuardArgs);
|
||||
prepStages.mark("session-resource-loader");
|
||||
|
||||
// Get hook runner early so it's available when creating tools
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./pi-compaction-constants.js";
|
||||
import {
|
||||
applyPiAutoCompactionGuard,
|
||||
applyPiCompactionSettingsFromConfig,
|
||||
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR,
|
||||
isSilentOverflowProneModel,
|
||||
resolveCompactionReserveTokensFloor,
|
||||
} from "./pi-settings.js";
|
||||
|
||||
@@ -345,3 +347,163 @@ describe("resolveCompactionReserveTokensFloor", () => {
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSilentOverflowProneModel", () => {
|
||||
// Reporter's repro shape: openrouter routing to z-ai/glm. Both the bare
|
||||
// `z-ai/...` form and the `openrouter/z-ai/...` qualified form must hit.
|
||||
it("flags z-ai-prefixed model ids regardless of qualifier", () => {
|
||||
expect(isSilentOverflowProneModel({ provider: "openrouter", modelId: "z-ai/glm-5.1" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isSilentOverflowProneModel({ provider: "openrouter", modelId: "openrouter/z-ai/glm-5" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a config-set z.ai provider regardless of model id", () => {
|
||||
expect(isSilentOverflowProneModel({ provider: "z.ai", modelId: "glm-5.1" })).toBe(true);
|
||||
expect(isSilentOverflowProneModel({ provider: "z-ai", modelId: "glm-5.1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a direct api.z.ai baseUrl via endpointClass", () => {
|
||||
expect(
|
||||
isSilentOverflowProneModel({
|
||||
provider: "openai",
|
||||
modelId: "glm-5.1",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// openclaw#75799 reporter's setup: an OpenAI-compatible in-house gateway
|
||||
// exposing Zhipu's GLM family directly (model id `glm-5.1`, no `z-ai/`
|
||||
// qualifier, custom baseUrl that is not api.z.ai). Catch the GLM family
|
||||
// name with or without a path namespace so deploys that proxy it under
|
||||
// their own provider name still hit the guard.
|
||||
it("flags glm- model ids regardless of path namespace", () => {
|
||||
expect(isSilentOverflowProneModel({ provider: "custom", modelId: "glm-5.1" })).toBe(true);
|
||||
expect(isSilentOverflowProneModel({ provider: "custom", modelId: "glm-4.7" })).toBe(true);
|
||||
expect(
|
||||
isSilentOverflowProneModel({ provider: "ollama", modelId: "ollama/glm-5.1:cloud" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// pi-ai's overflow.ts only documents z.ai as the silent-overflow style. We
|
||||
// intentionally do NOT extend the guard to anthropic/openai/google/openrouter-
|
||||
// anthropic routes — adding them without a reproducible repro would broaden
|
||||
// the kill surface and regress baseline behavior for those providers.
|
||||
it("does not flag anthropic, openai, google or other routes", () => {
|
||||
expect(
|
||||
isSilentOverflowProneModel({ provider: "anthropic", modelId: "claude-sonnet-4.6" }),
|
||||
).toBe(false);
|
||||
expect(isSilentOverflowProneModel({ provider: "openai", modelId: "gpt-5.5" })).toBe(false);
|
||||
expect(
|
||||
isSilentOverflowProneModel({
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isSilentOverflowProneModel({ provider: "google", modelId: "gemini-2.5-pro" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats missing fields as not silent-overflow-prone", () => {
|
||||
expect(isSilentOverflowProneModel({})).toBe(false);
|
||||
expect(
|
||||
isSilentOverflowProneModel({ provider: undefined, modelId: undefined, baseUrl: null }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPiAutoCompactionGuard", () => {
|
||||
// Direct repro of openclaw#75799: pi-ai's silent-overflow detection misfires
|
||||
// on a successful turn against z.ai-style providers, triggering Pi's
|
||||
// _runAutoCompaction from inside Session.prompt() and reassigning
|
||||
// agent.state.messages between the runner's prompt.submitted trajectory
|
||||
// event and the provider request. Disabling Pi auto-compaction here keeps
|
||||
// state.messages intact; OpenClaw's preemptive compaction continues to
|
||||
// handle real overflow on its own path.
|
||||
it("disables Pi auto-compaction for silent-overflow-prone providers", () => {
|
||||
const setCompactionEnabled = vi.fn();
|
||||
const settingsManager = {
|
||||
getCompactionReserveTokens: () => 20_000,
|
||||
getCompactionKeepRecentTokens: () => 4_000,
|
||||
applyOverrides: () => {},
|
||||
setCompactionEnabled,
|
||||
};
|
||||
|
||||
const result = applyPiAutoCompactionGuard({
|
||||
settingsManager,
|
||||
silentOverflowProneProvider: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ supported: true, disabled: true });
|
||||
expect(setCompactionEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("disables Pi auto-compaction when a context engine plugin owns compaction", () => {
|
||||
const setCompactionEnabled = vi.fn();
|
||||
const settingsManager = {
|
||||
getCompactionReserveTokens: () => 20_000,
|
||||
getCompactionKeepRecentTokens: () => 4_000,
|
||||
applyOverrides: () => {},
|
||||
setCompactionEnabled,
|
||||
};
|
||||
|
||||
const result = applyPiAutoCompactionGuard({
|
||||
settingsManager,
|
||||
contextEngineInfo: {
|
||||
id: "third-party",
|
||||
name: "Third-party Context Engine",
|
||||
version: "0.1.0",
|
||||
ownsCompaction: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ supported: true, disabled: true });
|
||||
expect(setCompactionEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// Default-mode runs against ordinary providers must keep Pi's auto-compaction
|
||||
// enabled. Disabling it across the board would silently remove Pi's
|
||||
// overflow-recovery path inside Session.prompt() for users who are not
|
||||
// affected by z.ai's silent-overflow accounting.
|
||||
it("leaves Pi auto-compaction alone for non-z.ai providers without engine ownership", () => {
|
||||
const setCompactionEnabled = vi.fn();
|
||||
const settingsManager = {
|
||||
getCompactionReserveTokens: () => 20_000,
|
||||
getCompactionKeepRecentTokens: () => 4_000,
|
||||
applyOverrides: () => {},
|
||||
setCompactionEnabled,
|
||||
};
|
||||
|
||||
const result = applyPiAutoCompactionGuard({
|
||||
settingsManager,
|
||||
contextEngineInfo: {
|
||||
id: "legacy",
|
||||
name: "Legacy Context Engine",
|
||||
version: "1.0.0",
|
||||
},
|
||||
silentOverflowProneProvider: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ supported: true, disabled: false });
|
||||
expect(setCompactionEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unsupported when the settings manager has no setCompactionEnabled hook", () => {
|
||||
const settingsManager = {
|
||||
getCompactionReserveTokens: () => 20_000,
|
||||
getCompactionKeepRecentTokens: () => 4_000,
|
||||
applyOverrides: () => {},
|
||||
};
|
||||
|
||||
const result = applyPiAutoCompactionGuard({
|
||||
settingsManager,
|
||||
silentOverflowProneProvider: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ supported: false, disabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ContextEngineInfo } from "../context-engine/types.js";
|
||||
import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./pi-compaction-constants.js";
|
||||
import { resolveProviderEndpoint } from "./provider-attribution.js";
|
||||
import { normalizeProviderId } from "./provider-id.js";
|
||||
|
||||
export const DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000;
|
||||
|
||||
@@ -122,18 +124,80 @@ export function applyPiCompactionSettingsFromConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Decide whether Pi's internal auto-compaction should be disabled for this run. */
|
||||
function shouldDisablePiAutoCompaction(params: { contextEngineInfo?: ContextEngineInfo }): boolean {
|
||||
return params.contextEngineInfo?.ownsCompaction === true;
|
||||
/**
|
||||
* Detect providers whose pi-ai `isContextOverflow` Case 2 (silent overflow)
|
||||
* fires on a successful turn and triggers Pi's `_runAutoCompaction` from
|
||||
* inside `Session.prompt()`, collapsing `agent.state.messages` before the
|
||||
* provider call (openclaw#75799).
|
||||
*
|
||||
* True on any of: `zai-native` endpoint class, normalized provider id `zai`,
|
||||
* a `z-ai/` / `openrouter/z-ai/` model-id namespace prefix, or a `glm-` model
|
||||
* name (with or without a path namespace) — covering in-house gateways and
|
||||
* ollama-style deploys that expose Zhipu's GLM family directly without a
|
||||
* `z-ai/` qualifier. Intentionally narrow to z.ai-style accounting; other
|
||||
* providers documented as silently truncating are not added without a
|
||||
* reproducible repro.
|
||||
*/
|
||||
export function isSilentOverflowProneModel(model: {
|
||||
provider?: string | null;
|
||||
modelId?: string | null;
|
||||
baseUrl?: string | null;
|
||||
}): boolean {
|
||||
const provider = normalizeProviderId(typeof model.provider === "string" ? model.provider : "");
|
||||
if (provider === "zai") {
|
||||
return true;
|
||||
}
|
||||
if (typeof model.baseUrl === "string" && model.baseUrl.length > 0) {
|
||||
if (resolveProviderEndpoint(model.baseUrl).endpointClass === "zai-native") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (typeof model.modelId === "string" && model.modelId.length > 0) {
|
||||
const normalized = model.modelId.toLowerCase();
|
||||
if (
|
||||
normalized.startsWith("z-ai/") ||
|
||||
normalized.startsWith("openrouter/z-ai/") ||
|
||||
normalized.startsWith("glm-") ||
|
||||
normalized.includes("/glm-")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Disable Pi auto-compaction via settings when a context engine owns compaction. */
|
||||
/**
|
||||
* Disable Pi's `_checkCompaction → _runAutoCompaction` (which would otherwise
|
||||
* fire from inside `Session.prompt()` and reassign `agent.state.messages`
|
||||
* before the provider call) when OpenClaw or a plugin owns compaction:
|
||||
* `contextEngineInfo.ownsCompaction === true`, or the active model is
|
||||
* silent-overflow-prone (openclaw#75799). Default-mode runs against ordinary
|
||||
* providers keep Pi's auto-compaction as the existing baseline.
|
||||
*/
|
||||
function shouldDisablePiAutoCompaction(params: {
|
||||
contextEngineInfo?: ContextEngineInfo;
|
||||
silentOverflowProneProvider?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
params.contextEngineInfo?.ownsCompaction === true || params.silentOverflowProneProvider === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the auto-compaction guard. Callers that reload a `DefaultResourceLoader`
|
||||
* MUST call this AGAIN after each `reload()` — `settingsManager.reload()`
|
||||
* rehydrates `compaction.enabled` from disk and silently restores Pi's
|
||||
* default-on behavior, undoing the guard. Mirrors the existing
|
||||
* `applyPiCompactionSettingsFromConfig` re-call pattern at the same sites.
|
||||
*/
|
||||
export function applyPiAutoCompactionGuard(params: {
|
||||
settingsManager: PiSettingsManagerLike;
|
||||
contextEngineInfo?: ContextEngineInfo;
|
||||
silentOverflowProneProvider?: boolean;
|
||||
}): { supported: boolean; disabled: boolean } {
|
||||
const disable = shouldDisablePiAutoCompaction({
|
||||
contextEngineInfo: params.contextEngineInfo,
|
||||
silentOverflowProneProvider: params.silentOverflowProneProvider,
|
||||
});
|
||||
const hasMethod = typeof params.settingsManager.setCompactionEnabled === "function";
|
||||
if (!disable || !hasMethod) {
|
||||
|
||||
Reference in New Issue
Block a user