fix(codex): stop quoted context from triggering explicit skill mentions (#123345)

Codex byte-scans every turn text input for $name skill mentions and
[@name](plugin://...) links (codex-rs/skills/src/mentions.rs), so historical
$skill tokens inside OpenClaw's projected <conversation_context> block and
inbound quoted-reply/room-backlog context counted as current explicit
invocations and injected skill bodies the user never requested.

Neutralize mention sigils with same-length fullwidth lookalikes (the
escapeCodexChatText technique) in projected history and inbound context;
only the raw current request stays selectable.

Fixes #122812
This commit is contained in:
Peter Steinberger
2026-08-13 15:33:23 -07:00
committed by GitHub
parent 5053635a70
commit e04dfd26e2
4 changed files with 64 additions and 5 deletions
@@ -71,6 +71,26 @@ describe("projectContextEngineAssemblyForCodex", () => {
expect(ordered.prePromptMessageCount).toBe(1);
});
it("neutralizes explicit mention sigils in projected history but not the current request", () => {
const result = projectContextEngineAssemblyForCodex({
assembledMessages: [
textMessage("assistant", "The user did not invoke $example-manual."),
textMessage("user", "see [$other-skill](skill://other) and [@pkg](plugin://pkg@mp)"),
],
originalHistoryMessages: [],
prompt: "run $current-skill now",
});
const context = result.promptText.slice(0, result.promptContextRange?.end);
// Codex byte-scans the whole turn text for `$name`; historical tokens must
// not survive in scannable form (codex-rs/skills/src/mentions.rs).
expect(context).not.toContain("$example-manual");
expect(context).toContain("example-manual");
expect(context).toContain("[other-skill](skill://other)");
expect(context).toContain("[pkg](plugin://pkg@mp)");
expect(result.promptText).toContain("Current user request:\nrun $current-skill now");
});
it("frames projected history as reference data and omits tool payloads", () => {
const result = projectContextEngineAssemblyForCodex({
assembledMessages: [
@@ -38,6 +38,17 @@ const DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS = 20_000;
const MIN_PROMPT_BUDGET_RATIO = 0.5;
const MIN_PROMPT_BUDGET_TOKENS = 8_000;
// Codex scans every turn text input byte-for-byte for explicit `$name` skill
// mentions and `[@name](plugin://…)` links (codex-rs/skills/src/mentions.rs);
// quoted history must never count as a current explicit invocation, so swap
// the sigils to same-length fullwidth lookalikes (same technique as
// escapeCodexChatText). Only the raw current request stays selectable.
export function neutralizeCodexExplicitMentionSigils(text: string): string {
return text
.replace(/\$(?=[A-Za-z0-9_:-])/gu, "")
.replace(/\[@(?=[A-Za-z0-9_:-]+\]\()/gu, "[");
}
/** Projects assembled OpenClaw context-engine messages into Codex prompt inputs. */
export function projectContextEngineAssemblyForCodex(params: {
assembledMessages: AgentMessage[];
@@ -50,10 +61,12 @@ export function projectContextEngineAssemblyForCodex(params: {
const prompt = params.prompt.trim();
const contextMessages = dropDuplicateTrailingPrompt(params.assembledMessages, prompt);
const maxRenderedContextChars = normalizeRenderedContextMaxChars(params.maxRenderedContextChars);
const renderedContext = renderMessagesForCodexContext(contextMessages, {
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
toolPayloadMode: params.toolPayloadMode ?? "elide",
});
const renderedContext = neutralizeCodexExplicitMentionSigils(
renderMessagesForCodexContext(contextMessages, {
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
toolPayloadMode: params.toolPayloadMode ?? "elide",
}),
);
const boundedContext = renderedContext
? truncateOlderContext(renderedContext, maxRenderedContextChars)
: undefined;
@@ -0,0 +1,21 @@
// Codex tests cover run-attempt prompt state helpers.
import { describe, expect, it } from "vitest";
import { prependCurrentInboundContext } from "./run-attempt-state.js";
describe("prependCurrentInboundContext", () => {
it("neutralizes explicit mention sigils in inbound context but not the prompt", () => {
const joined = prependCurrentInboundContext("run $current-skill now", {
text: "Quoted reply: please try $example-manual later",
});
expect(joined).toBe(
"Quoted reply: please try example-manual later\n\nrun $current-skill now",
);
});
it("returns the prompt unchanged without inbound context", () => {
expect(prependCurrentInboundContext("run $current-skill now", undefined)).toBe(
"run $current-skill now",
);
});
});
@@ -10,6 +10,7 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js";
import { CodexAppServerRpcError } from "./client.js";
import { neutralizeCodexExplicitMentionSigils } from "./context-engine-projection.js";
import { isJsonObject, type CodexServerNotification } from "./protocol.js";
import type {
CodexAppServerBindingIdentity,
@@ -136,8 +137,12 @@ export function prependCurrentInboundContext(
prompt: string,
context: EmbeddedRunAttemptParams["currentInboundContext"],
): string {
// Inbound context carries quoted replies and room backlog, not the raw
// current request; Codex must not resolve explicit mentions from it.
const text = context?.text.trim();
return text ? [text, prompt].filter(Boolean).join("\n\n") : prompt;
return text
? [neutralizeCodexExplicitMentionSigils(text), prompt].filter(Boolean).join("\n\n")
: prompt;
}
export function waitForCodexNotificationDispatchTurn(): Promise<void> {