From 91f2e9a5c8e3b7b7c66d0d5b865e1ddb5a990e4e Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 15 Sep 2026 20:38:51 +0800 Subject: [PATCH] Fix reply language ignored when user writes Chinese. Strengthen Host prompt injection with a per-step reply reminder, read live settings, and stop persona from forcing follow-user language. Co-authored-by: Cursor --- lib/index.js | 49 ++++++++++++++++++++++++++------ package.json | 2 +- presets/netxops/PERSONA.md | 2 +- presets/netxops/agent.cordis.yml | 2 +- src/index.ts | 38 +++++++++++++++++++++++-- src/netx/model-language.ts | 26 +++++++++++++---- test/model-language.test.mjs | 8 ++++++ 7 files changed, 108 insertions(+), 19 deletions(-) diff --git a/lib/index.js b/lib/index.js index a5e7909..70f435f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1343,11 +1343,23 @@ function replyInstruction(setting) { return ""; const meta = REPLY_META[normalized]; return [ - "## Reply language requirement", - `Regardless of the language the user writes in, you must always write the entire reply in ${meta.native} (${meta.name}) —`, - "including explanations, summaries, body text, titles, and error descriptions.", - "Keep code, commands, file paths, API names, host names, and proper nouns in their original form; do not translate them.", - "This requirement outranks language hints in the user message or persona until the Netx Ops reply-language setting changes." + "## Reply language requirement (mandatory)", + `The operator set Netx Ops reply language to ${meta.native} (${meta.name}).`, + `You MUST write the entire final reply in ${meta.name} only.`, + `Do not switch to the user's language even if they write in Chinese, English, or any other language.`, + "This includes titles, bullet labels (Result / Evidence / Next may stay as English keywords), explanations, summaries, warnings, and error text.", + "Keep code, commands, file paths, API names, host names, UUIDs, and proper nouns unchanged.", + 'This rule outranks persona wording such as "follow the user language" and outranks language hints in the user message.' + ].join(" "); +} +function replyReminder(setting) { + const normalized = normalizeReplyLanguage(setting); + if (normalized === REPLY_LANGUAGE_DEFAULT) + return ""; + const meta = REPLY_META[normalized]; + return [ + `[Reply language] Mandatory: write your entire final answer in ${meta.native} (${meta.name}).`, + "Do not answer in the user's language. Code/paths/host names stay original." ].join(" "); } @@ -4119,11 +4131,26 @@ function apply(ctx, config = Config({})) { }); ctx.inject(["systemPrompt", "settings"], (promptCtx) => { const systemPrompt = promptCtx.systemPrompt; + const liveConfig = () => { + const settings = promptCtx.get("settings"); + const section = typeof settings?.get === "function" ? (() => { + try { + return settings.get(NETXOPS_SETTINGS_NAMESPACE); + } catch { + return; + } + })() : undefined; + if (section !== null && typeof section === "object" && !Array.isArray(section)) { + return { ...source(), ...section }; + } + return source(); + }; const effectiveThinking = () => { const settings = promptCtx.get("settings"); - const current = source(); + const current = liveConfig(); return resolveThinkingLanguage(normalizeThinkingLanguage(current.thinkingLanguage), readSystemLocalePreference(settings)); }; + const replySetting = () => normalizeReplyLanguage(liveConfig().replyLanguage); promptCtx.effect(() => { const disposers = []; const push = (dispose) => { @@ -4142,9 +4169,15 @@ function apply(ctx, config = Config({})) { })); push(systemPrompt.section({ name: "netxops:reply-language", - order: 890, - text: () => replyInstruction(normalizeReplyLanguage(source().replyLanguage)) + order: 10150, + text: () => replyInstruction(replySetting()) })); + push(systemPrompt.context({ + name: "netxops:reply-language-reminder", + order: 1100, + text: () => replyReminder(replySetting()) + })); + promptCtx.logger.info("netxops: model language prompts registered (thinking=%s reply=%s)", normalizeThinkingLanguage(liveConfig().thinkingLanguage), replySetting()); return () => { for (const dispose of disposers.splice(0)) dispose(); diff --git a/package.json b/package.json index e34d852..edf3a27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.31", + "version": "0.1.32", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index a09dda2..42d3df4 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -9,7 +9,7 @@ ## 原则 1. 先用工具拿证据(告警、清单、CLI),再下结论。 2. 涉及破坏性变更:先说清影响与回滚(当前 CLI 仅只读:show / display / ping 等)。 -3. 跟随用户语言(含标题与说明)。现场默认:简洁、可扫读的运维口吻。 +3. 回复语言以 Host「Netx Ops」设置里的「回复语言」为准(强制 zh/en 时不得跟随用户语言);未强制(follow-user)时再跟随用户语言。现场默认:简洁、可扫读的运维口吻。 ## 终稿骨架(告警 / 网元 / CLI 必须) diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 1ad6bec..0b9f5d6 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -18,7 +18,7 @@ ## 原则 1. 先用工具拿证据(告警、清单、CLI),再下结论。 2. 涉及破坏性变更:先说清影响与回滚(当前 CLI 仅只读:show / display / ping 等)。 - 3. 跟随用户语言(含标题与说明)。现场默认:简洁、可扫读的运维口吻。 + 3. 回复语言以 Host「Netx Ops」设置里的「回复语言」为准(强制 zh/en 时不得跟随用户语言);未强制(follow-user)时再跟随用户语言。现场默认:简洁、可扫读的运维口吻。 ## 终稿骨架(告警 / 网元 / CLI 必须) ``` diff --git a/src/index.ts b/src/index.ts index 3f326ed..6e1599d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ import { normalizeThinkingLanguage, readSystemLocalePreference, replyInstruction, + replyReminder, resolveThinkingLanguage, thinkingInstruction, thinkingReminder, @@ -412,14 +413,34 @@ export function apply(ctx: Context, config: Config = Config({})): void { context: (spec: { name: string, order: number, text: string | (() => string) }) => (() => void) | void } const systemPrompt = (promptCtx as { systemPrompt: PromptSurface }).systemPrompt + /** Prefer live settings document over cordis base config (survives UI saves). */ + const liveConfig = (): Config => { + const settings = promptCtx.get('settings') as { + get?: (ns: string) => unknown + } | undefined + const section = typeof settings?.get === 'function' + ? (() => { + try { + return settings.get!(NETXOPS_SETTINGS_NAMESPACE) + } catch { + return undefined + } + })() + : undefined + if (section !== null && typeof section === 'object' && !Array.isArray(section)) { + return { ...source(), ...(section as Partial) } + } + return source() + } const effectiveThinking = (): ReturnType => { const settings = promptCtx.get('settings') as { get?: (ns: string) => unknown } | undefined - const current = source() + const current = liveConfig() return resolveThinkingLanguage( normalizeThinkingLanguage(current.thinkingLanguage), readSystemLocalePreference(settings), ) } + const replySetting = (): string => normalizeReplyLanguage(liveConfig().replyLanguage) promptCtx.effect(() => { const disposers: Array<() => void> = [] const push = (dispose: (() => void) | void): void => { @@ -435,11 +456,22 @@ export function apply(ctx: Context, config: Config = Config({})): void { order: 1000, text: () => thinkingReminder(effectiveThinking()), })) + // Late section + per-step context so reply language beats persona / user language. push(systemPrompt.section({ name: 'netxops:reply-language', - order: 890, - text: () => replyInstruction(normalizeReplyLanguage(source().replyLanguage)), + order: 10150, + text: () => replyInstruction(replySetting()), })) + push(systemPrompt.context({ + name: 'netxops:reply-language-reminder', + order: 1100, + text: () => replyReminder(replySetting()), + })) + promptCtx.logger.info( + 'netxops: model language prompts registered (thinking=%s reply=%s)', + normalizeThinkingLanguage(liveConfig().thinkingLanguage), + replySetting(), + ) return () => { for (const dispose of disposers.splice(0)) dispose() } diff --git a/src/netx/model-language.ts b/src/netx/model-language.ts index b63933d..9d7c67c 100644 --- a/src/netx/model-language.ts +++ b/src/netx/model-language.ts @@ -135,10 +135,26 @@ export function replyInstruction(setting: string | undefined | null): string { if (normalized === REPLY_LANGUAGE_DEFAULT) return '' const meta = REPLY_META[normalized] return [ - '## Reply language requirement', - `Regardless of the language the user writes in, you must always write the entire reply in ${meta.native} (${meta.name}) —`, - 'including explanations, summaries, body text, titles, and error descriptions.', - 'Keep code, commands, file paths, API names, host names, and proper nouns in their original form; do not translate them.', - 'This requirement outranks language hints in the user message or persona until the Netx Ops reply-language setting changes.', + '## Reply language requirement (mandatory)', + `The operator set Netx Ops reply language to ${meta.native} (${meta.name}).`, + `You MUST write the entire final reply in ${meta.name} only.`, + `Do not switch to the user's language even if they write in Chinese, English, or any other language.`, + 'This includes titles, bullet labels (Result / Evidence / Next may stay as English keywords), explanations, summaries, warnings, and error text.', + 'Keep code, commands, file paths, API names, host names, UUIDs, and proper nouns unchanged.', + 'This rule outranks persona wording such as "follow the user language" and outranks language hints in the user message.', + ].join(' ') +} + +/** + * Per-step reminder for forced reply language. + * Empty when `follow-user`. Appended after the user message each model call. + */ +export function replyReminder(setting: string | undefined | null): string { + const normalized = normalizeReplyLanguage(setting) + if (normalized === REPLY_LANGUAGE_DEFAULT) return '' + const meta = REPLY_META[normalized] + return [ + `[Reply language] Mandatory: write your entire final answer in ${meta.native} (${meta.name}).`, + 'Do not answer in the user\'s language. Code/paths/host names stay original.', ].join(' ') } diff --git a/test/model-language.test.mjs b/test/model-language.test.mjs index 0fa650e..81af512 100644 --- a/test/model-language.test.mjs +++ b/test/model-language.test.mjs @@ -6,6 +6,7 @@ import { normalizeReplyLanguage, normalizeThinkingLanguage, replyInstruction, + replyReminder, resolveThinkingLanguage, thinkingInstruction, thinkingReminder, @@ -48,5 +49,12 @@ test('thinkingInstruction and reminder mention the target language', () => { test('replyInstruction is empty for follow-user and forces otherwise', () => { assert.equal(replyInstruction('follow-user'), '') assert.match(replyInstruction('en'), /English/) + assert.match(replyInstruction('en'), /Do not switch to the user's language/) assert.match(replyInstruction('zh'), /简体中文/) }) + +test('replyReminder is empty for follow-user and reminds otherwise', () => { + assert.equal(replyReminder('follow-user'), '') + assert.match(replyReminder('en'), /Mandatory/) + assert.match(replyReminder('en'), /English/) +})