fix(agents): keep state.messages intact across z.ai-style provider turns in embedded runs

This commit is contained in:
openperf
2026-05-02 19:33:07 +08:00
parent 3aaf30ffa6
commit 0d4d304e0f
5 changed files with 261 additions and 9 deletions

View File

@@ -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,

View File

@@ -321,6 +321,7 @@ vi.mock("../../pi-settings.js", () => ({
keepRecentTokens: 40_000,
},
}),
isSilentOverflowProneModel: () => false,
}));
vi.mock("../extensions.js", () => ({

View File

@@ -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

View File

@@ -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 });
});
});

View File

@@ -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) {