Skip to content

fix codex custom model instruction overrides - #411

Open
AwangYes wants to merge 1 commit into
xintaofei:mainfrom
AwangYes:pr/codex-custom-model-instructions
Open

fix codex custom model instruction overrides#411
AwangYes wants to merge 1 commit into
xintaofei:mainfrom
AwangYes:pr/codex-custom-model-instructions

Conversation

@AwangYes

@AwangYes AwangYes commented Aug 5, 2026

Copy link
Copy Markdown

Summary

Fix Codex custom models losing their custom system prompt when the generated catalog inherits model_messages from the base model.

Problem

Codeg generates model_catalog_json entries for custom Codex models by cloning the selected base model and then applying user overrides.

When a custom model overrides base_instructions, the generated catalog correctly contains the custom base_instructions, but it may still keep the base model's inherited model_messages.instructions_template.

With Codex CLI 0.145.0, session instructions are rendered from model_messages.instructions_template when it is present. As a result, the custom base_instructions can be bypassed and the session still receives the official base model instructions.

Fix

When a custom model overrides base_instructions and does not explicitly override model_messages, sync model_messages.instructions_template to the same custom prompt.

Also remove inherited comp_hash when instruction-related fields change unless the user explicitly overrides comp_hash.

Explicit model_messages overrides are preserved.

Tests

Added coverage for:

  • syncing base_instructions into model_messages.instructions_template
  • preserving explicit model_messages overrides
  • preserving inherited model_messages and comp_hash when no instruction override is present

Validation

  • Verified this PR only changes src-tauri/src/acp/codex_model_catalog.rs
  • git diff --check passed locally
  • Full Rust test run was not completed locally because the local machine is resource constrained; CI should run the full suite

@xintaofei

xintaofei commented Aug 5, 2026

Copy link
Copy Markdown
Owner

先谢谢这个 PR 🙏 —— 只动一个文件就精准定位到了一个非常隐蔽的上游行为变更,这个问题确实存在而且影响挺大。

我用 codex-acp@1.1.9 自带的真实 codex-cli 0.145.0 二进制,把请求打到本地捕获端点,抓了实际请求体做了一轮对照实验来验证。结论是:方向完全正确,但建议先补两处再合。

本条评审经过第二轮独立复核后做过修订:问题 1 和问题 2 的建议修法都收窄了(原写法会误伤用户自定义的 approvals / 非 personality 变量),问题 4 的措辞也收敛了。修订处已标注。文末新增一条与本 PR 无关的既有隐患。


一、先确认:这个 bug 是真的

场景 实际发出的 instructions
当前 main:只覆盖 base_instructionsmodel_messages 继承自 base 官方 prompt 21,335 字符,自定义 prompt 完全没进请求
打上本 PR:同步 instructions_template 自定义 prompt ✅(一字不差)

所以 PR 描述里的判断不是猜测,是准确的:用户写的 system prompt 在 0.145 上属于静默失效。这个修复价值很高。

CI 现在也全绿了(7/7),本地 cargo clippy --all-targets --features test-utils -- -D warnings 同样 0 warning,改动干净、没有夹带。新增的 3 个用例也写得有意义,尤其是"无 override 时不动 model_messages/comp_hash"那条回归断言。

只是下面这两点 CI 覆盖不到,得靠人眼看:


二、问题 1(建议合并前修):import 往返之后,同一个 bug 会原样复活

codex_model_catalog.rs:126 那个 if model_messages_changed { return } 守卫,在本仓库的数据流里刚好选错了粒度。

关键在于 UI 根本没有编辑 model_messages 的入口 —— codex-model-list-editor.tsx:648 只写 base_instructions。所以 overrides 里出现 model_messages唯一来源是 import_catalog,而它是把外来 catalog 里所有和 base 不同的字段整块抓成 override 的。

我加了个临时用例实跑了一遍:

[step1] 用户设 prompt=V1 → bi=Some("PROMPT_V1") tmpl=Some("PROMPT_V1")   ✅ 修复生效
[step2] 把 step1 产出的 catalog 喂给 import_catalog → overrides 含 model_messages = true
[step3] 在此基础上只改 base_instructions=V2 → bi=Some("PROMPT_V2") tmpl=Some("PROMPT_V1")   ❌ codex 用的是 tmpl

触发条件不算罕见:codeg-model-catalog.source.json 丢失,或者用户本来就手写过 model_catalog_jsonacp.rs:2857 那条 import 分支就是专门为这种人准备的)。这些用户改完 prompt 保存,界面显示的是新的,实际发出去的还是旧的 —— 和现在这个 bug 一模一样,只是往后推了一步。

修法(已本地验证,两处必须一起改) ——

我最初只想在 import_catalog 侧整块丢掉 model_messages,但那会误伤用户手写的 approvals;而只把守卫下沉到子键也关不掉洞(往返之后 instructions_template 确实是"被显式覆盖"的状态)。实测下来要两段配合:

① compat 的守卫下沉到 instructions_template 子键
let template_overridden = overrides
    .get("model_messages")
    .and_then(Value::as_object)
    .map(|m| m.contains_key("instructions_template"))
    .unwrap_or(false);
if template_overridden || !base_instructions_changed {
    return;
}
import_catalog 只丢 instructions_template 这一个子键(其余保留)
// codeg 自己生成的形状里 instructions_template 恒等于 base_instructions;
// 把它再抓成 override 会把模板钉死,导致之后改 prompt 静默失效。
// 只丢这一个子键,approvals / 其它变量原样保留。
if let Some(bi) = obj.get("base_instructions").and_then(Value::as_str) {
    let drop_mm = match overrides.get_mut("model_messages").and_then(Value::as_object_mut) {
        Some(mm) => {
            if mm.get("instructions_template").and_then(Value::as_str) == Some(bi) {
                mm.remove("instructions_template");
            }
            mm.is_empty()
        }
        None => false,
    };
    if drop_mm {
        overrides.remove("model_messages");
    }
}

我把这两段打上去实跑了一遍,PR 原有 11 个测试全过,另加 4 条断言:

[A] 往返后改 prompt → bi=PROMPT_V2 tmpl=PROMPT_V2          ✅ 洞关上了
[B] 手写的 HAND_WRITTEN_TEMPLATE 扛过 expand + import 往返   ✅ 没被覆盖
[C] 用户自定义 approvals 在 import 后仍在,只丢了 template 子键 ✅ 没误伤
[D] instructions_variables 里 personality* 已清空            ✅

三、问题 2(建议顺手修,一行):每次请求多注入 1,748 字符官方人格

抓包发现,打上 PR 后 developer 消息从 7,640 涨到 9,388 字符,多出来的是:

<personality_spec> The user has requested a new communication style...
# Personality
You are a deeply pragmatic, effective software engineer...

原因是:模板被换成自定义 prompt 之后就没有 {{ personality }} 占位符了,但 instructions_variables 里的 personality_* 还在,codex 于是把官方人格改成独立 developer 消息补了回来。对一个专门写了 "You are an IDA Pro assistant" 的模型来说,这属于人设污染,而且每轮都带。

修法:同步 template 时把 personality 相关变量清掉。清空整个 instructions_variables → 建议只清 personality* 前缀的键,这样将来 codex 往里加别的变量也不会被误伤(当前 0.145 的 8 个官方模型里这个 map 恰好只有 personality_default/friendly/pragmatic 三个键,所以两种写法今天等价):

if let Some(vars) = model_messages
    .get_mut("instructions_variables")
    .and_then(Value::as_object_mut)
{
    vars.retain(|k, _| !k.starts_with("personality"));
}

实测(case E/H):instructions 依然是自定义 prompt,personality_spec 消失,dev 消息回到 7,640,approvals 子键保住。副作用是自定义 prompt 里若写了字面量 {{ personality }},会渲染成空串 —— 不过不清变量的话它会被替换成官方人格全文,两害相权还是清掉更符合预期。

补充:直接删掉整个 model_messages 也能修好(codex 接受缺这个字段),但会连 approvals 一起丢,不如清变量干净。


四、两个小点,知悉即可

{{ }} 会被当模板解析。 自定义 prompt 写进 instructions_template 之后,里面的 {{ personality }} 会被 codex 的模板层消费掉(实测:不清变量时替换成官方人格全文,清了变量则变空串),未知变量如 {{ user_name }} 原样保留、不报错。想写字面量的用户会得到静默变更,值得在注释里记一笔。

comp_hash 那段删除,证据上是"这次请求里看不出差别"。 对照跑了"删/不删"两种 catalog,请求体除了随机 session id 完全一致 —— 客户端在普通一轮里根本不发这个字段,catalog 也能正常加载(gpt-5.2 / codex-auto-review 本来就没这个键,说明缺失是合法的)。所以删了也安全 → 更准确的说法是:我的证据只覆盖"普通一轮请求 + catalog 加载",没覆盖 compact 等其它路径,所以它究竟有没有用其实没被证伪。既然删不删都不影响本次修复,保守起见不删更省事。

另外这段的判定口径也偏宽:instructions_changedmodel_messages_changed 也算进去了,于是"只改了 approvals"甚至"改成和 base 一样的值"都会顺手删掉 comp_hash,和 PR 描述里说的"instruction 相关字段变化"对不上。

还有一个行为变化值得心里有数: setField 在用户清空输入框时会写 base_instructions: ""codex-model-list-editor.tsx:423)。修复前这个空串被官方 prompt 盖掉了、看不出来;修复后 codex 会整个省略 instructions 字段(实测确认)—— 也就是曾经手滑清空过输入框的用户,升级后会突然变成"完全没有 system prompt"。这更像是个需要拍板的产品语义问题(空串 = 真的不要 prompt?还是 = 回退到官方?),不只是升级须知。


五、顺带发现一个既有隐患(与本 PR 无关,pre-existing

查这个 PR 的时候顺手验了一下 sanitized_overridenull 一律放行(if value.is_null() { return true })这条规则。实测把 base_instructions 写成 JSON null 之后:

Error: failed to parse model_catalog_json path `.../cat.json` as JSON:
       invalid type: null, expected a string at line 1 column 1113

也就是 codex 会拒绝整个 catalog —— 正是模块注释里警告的"所有模型从选择器里消失"那种炸法。UI 走 textarea 不会产生 null,所以正常路径碰不到;但手写过 provider.model JSON 的用户有可能踩。和本 PR 无关,不影响合并,只是既然查到了就提一句,将来可以顺手加个"null 只对可空字段放行"的收紧。


小结

问题 1 修掉之后就可以合了,问题 2 顺手带上更好。问题 3/4 注释说明一下即可。整体分析质量很高,辛苦了!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants