| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "unicode" |
| 7 | ) |
| 8 | |
| 9 | type reasoningLanguageContextKey struct{} |
| 10 | type responseLanguageContextKey struct{} |
| 11 | |
| 12 | // NormalizeReasoningLanguage returns one of auto|zh|en for runtime-only visible |
| 13 | // reasoning preferences. Keep this local to agent so sub-agents can inherit the |
| 14 | // preference without depending on config. |
| 15 | func NormalizeReasoningLanguage(lang string) string { |
| 16 | switch strings.ToLower(strings.TrimSpace(lang)) { |
| 17 | case "zh", "cn", "chinese", "中文": |
| 18 | return "zh" |
| 19 | case "en", "english": |
| 20 | return "en" |
| 21 | default: |
| 22 | return "auto" |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | // NormalizeResponseLanguage returns one of auto|zh|en for final-answer language |
| 27 | // preferences. Auto keeps the stable same-as-user language policy. |
| 28 | func NormalizeResponseLanguage(lang string) string { |
| 29 | switch strings.ToLower(strings.TrimSpace(lang)) { |
| 30 | case "zh", "cn", "chinese", "中文": |
| 31 | return "zh" |
| 32 | case "en", "english": |
| 33 | return "en" |
| 34 | default: |
| 35 | return "auto" |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // ResponseLanguageBlock is transient user-turn context for final answers. It |
| 40 | // stays out of the stable system prompt so changing the preference between turns |
| 41 | // does not churn the cached prefix. |
| 42 | func ResponseLanguageBlock(lang string) string { |
| 43 | switch NormalizeResponseLanguage(lang) { |
| 44 | case "zh": |
| 45 | return "<response-language>\nFinal answer language preference: use Simplified Chinese for user-facing replies unless the user explicitly asks for another language. Keep code, identifiers, file paths, shell commands, and untranslated technical terms in their original form.\n</response-language>" |
| 46 | case "en": |
| 47 | return "<response-language>\nFinal answer language preference: use English for user-facing replies unless the user explicitly asks for another language. Keep code, identifiers, file paths, shell commands, and untranslated technical terms in their original form.\n</response-language>" |
| 48 | default: |
| 49 | return "" |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // ReasoningLanguageBlock is transient user-turn context. It deliberately does |
| 54 | // not belong in the stable system prompt or tool schemas. |
| 55 | func ReasoningLanguageBlock(lang string) string { |
| 56 | switch NormalizeReasoningLanguage(lang) { |
| 57 | case "zh": |
| 58 | // Imperative wording measured against soft "偏好……请使用" phrasing: |
| 59 | // the soft form loses the first reasoning segment on Chinese prompts |
| 60 | // that embed English logs/code, and the first segment anchors the |
| 61 | // whole turn once providers round-trip prior reasoning. |
| 62 | return "<reasoning-language>\n必须使用简体中文书写全部可见思考/推理文本:从第一个字开始就用中文,并在整轮内保持中文,即使系统提示词、工具说明、工具输出或引用的代码是英文。代码、标识符、文件路径、shell 命令和未翻译的技术术语保持原文。此要求只约束可见思考文本,不覆盖用户对最终回答语言的明确要求。\n</reasoning-language>" |
| 63 | case "en": |
| 64 | return "<reasoning-language>\nVisible reasoning/thinking text preference: use English when the provider exposes reasoning text. Keep code, identifiers, file paths, shell commands, and untranslated technical terms in their original form. This preference does not override an explicit user request for the final answer language.\n</reasoning-language>" |
| 65 | default: |
| 66 | return "" |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // ResolveReasoningLanguage returns the concrete visible-reasoning language for |
| 71 | // a turn. Explicit zh/en settings win; auto anchors clear Chinese user prompts |
| 72 | // and otherwise stays provider-default to preserve the historical no-injection |
| 73 | // behaviour for English and ambiguous turns. |
| 74 | func ResolveReasoningLanguage(lang, source string) string { |
| 75 | mode := NormalizeReasoningLanguage(lang) |
| 76 | if mode != "auto" { |
| 77 | return mode |
| 78 | } |
| 79 | return InferReasoningLanguageFromText(source) |
| 80 | } |
| 81 | |
| 82 | // InferReasoningLanguageFromText conservatively detects Chinese user-authored |
| 83 | // turns for auto reasoning-language mode. It strips Reasonix-injected context |
| 84 | // wrappers first so large @file payloads or transient XML blocks do not drown |
| 85 | // out the user's actual prompt. English and ambiguous turns intentionally return |
| 86 | // auto, preserving the old no-extra-instruction behaviour. |
| 87 | func InferReasoningLanguageFromText(source string) string { |
| 88 | source = reasoningLanguageSourceText(source) |
| 89 | if source == "" { |
| 90 | return "auto" |
| 91 | } |
| 92 | han, cjkPunct := reasoningLanguageScriptCounts(source) |
| 93 | switch { |
| 94 | case han >= 4: |
| 95 | return "zh" |
| 96 | case han >= 2 && (cjkPunct > 0 || hasChineseReasoningCue(source)): |
| 97 | return "zh" |
| 98 | default: |
| 99 | return "auto" |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func reasoningLanguageSourceText(source string) string { |
| 104 | s := strings.TrimSpace(StripTransientUserBlocks(source)) |
| 105 | const preamble = "Referenced context:" |
| 106 | if !strings.HasPrefix(s, preamble) { |
| 107 | return s |
| 108 | } |
| 109 | s = strings.TrimSpace(s[len(preamble):]) |
| 110 | for { |
| 111 | s = strings.TrimSpace(s) |
| 112 | if s == "" || !strings.HasPrefix(s, "<") { |
| 113 | return s |
| 114 | } |
| 115 | tagEnd := strings.IndexAny(s, " >\t\r\n") |
| 116 | if tagEnd <= 1 { |
| 117 | return s |
| 118 | } |
| 119 | tag := s[1:tagEnd] |
| 120 | switch tag { |
| 121 | case "file", "dir", "resource", "image": |
| 122 | closeTag := "</" + tag + ">" |
| 123 | i := strings.Index(s, closeTag) |
| 124 | if i < 0 { |
| 125 | return s |
| 126 | } |
| 127 | s = strings.TrimSpace(s[i+len(closeTag):]) |
| 128 | default: |
| 129 | return s |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func reasoningLanguageScriptCounts(source string) (han, cjkPunct int) { |
| 135 | for _, r := range source { |
| 136 | switch { |
| 137 | case unicode.In(r, unicode.Han): |
| 138 | han++ |
| 139 | case isCJKPunctuation(r): |
| 140 | cjkPunct++ |
| 141 | } |
| 142 | } |
| 143 | return han, cjkPunct |
| 144 | } |
| 145 | |
| 146 | func isCJKPunctuation(r rune) bool { |
| 147 | switch { |
| 148 | case r >= 0x3000 && r <= 0x303F: |
| 149 | return true |
| 150 | case r >= 0xFF00 && r <= 0xFFEF: |
| 151 | return true |
| 152 | default: |
| 153 | return false |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func hasChineseReasoningCue(source string) bool { |
| 158 | for _, cue := range chineseReasoningLanguageCues { |
| 159 | if strings.Contains(source, cue) { |
| 160 | return true |
| 161 | } |
| 162 | } |
| 163 | return false |
| 164 | } |
| 165 | |
| 166 | var chineseReasoningLanguageCues = []string{ |
| 167 | "你好", "请", "帮我", "帮忙", "看看", "看下", "解释", "说明", "总结", "分析", |
| 168 | "修复", "实现", "优化", "排查", "处理", "继续", "为什么", "怎么", |
| 169 | "是否", "能否", "支持", "设置", "中文", "思考", "问题", "报错", |
| 170 | "代码", "文件", "这个", "那个", |
| 171 | } |
| 172 | |
| 173 | func reasoningLanguageBlockForSource(lang, source string) string { |
| 174 | return ReasoningLanguageBlock(ResolveReasoningLanguage(lang, source)) |
| 175 | } |
| 176 | |
| 177 | // WithResponseLanguage prefixes content with the transient response-language |
| 178 | // block unless the turn already starts with one. |
| 179 | func WithResponseLanguage(content, lang string) string { |
| 180 | block := ResponseLanguageBlock(lang) |
| 181 | if block == "" || hasLeadingInjectedBlock(content, "response-language") { |
| 182 | return content |
| 183 | } |
| 184 | return block + "\n\n" + content |
| 185 | } |
| 186 | |
| 187 | // WithReasoningLanguage prefixes content with the transient reasoning-language |
| 188 | // block unless the turn already starts with an injected reasoning-language |
| 189 | // block. User-authored mentions of the tag later in the prompt must not suppress |
| 190 | // the configured preference. |
| 191 | func WithReasoningLanguage(content, lang string) string { |
| 192 | return WithReasoningLanguageForSource(content, lang, content) |
| 193 | } |
| 194 | |
| 195 | // WithReasoningLanguageForSource prefixes content using source as the language |
| 196 | // signal for auto mode. Callers that expand @references should pass the raw |
| 197 | // user prompt as source so referenced English code or logs do not override the |
| 198 | // user's actual conversation language. |
| 199 | func WithReasoningLanguageForSource(content, lang, source string) string { |
| 200 | block := reasoningLanguageBlockForSource(lang, source) |
| 201 | if block == "" || hasLeadingInjectedBlock(content, "reasoning-language") { |
| 202 | return content |
| 203 | } |
| 204 | return block + "\n\n" + content |
| 205 | } |
| 206 | |
| 207 | // hasLeadingInjectedBlock reports whether target is already among the transient |
| 208 | // blocks leading content, skipping past any other injected block on the way. |
| 209 | // It walks TransientUserBlockTags rather than a list of its own: when the two |
| 210 | // disagreed, a block the host had started injecting was treated as user prose |
| 211 | // and stopped the walk early, so an already-present target went undetected and |
| 212 | // was injected a second time. |
| 213 | func hasLeadingInjectedBlock(content, target string) bool { |
| 214 | s := strings.TrimLeft(content, " \t\r\n") |
| 215 | for { |
| 216 | if hasOpenTag(s, target) { |
| 217 | return strings.Contains(s, "</"+target+">") |
| 218 | } |
| 219 | skipped := false |
| 220 | for _, tag := range TransientUserBlockTags { |
| 221 | if tag == target || !hasOpenTag(s, tag) { |
| 222 | continue |
| 223 | } |
| 224 | rest, ok := trimLeadingTransientBlock(s, tag) |
| 225 | if !ok { |
| 226 | return false |
| 227 | } |
| 228 | s, skipped = rest, true |
| 229 | break |
| 230 | } |
| 231 | if !skipped { |
| 232 | return false |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // hasOpenTag reports whether s opens with tag, with or without attributes |
| 238 | // (hook-context and capability-route carry them). |
| 239 | func hasOpenTag(s, tag string) bool { |
| 240 | return strings.HasPrefix(s, "<"+tag+">") || strings.HasPrefix(s, "<"+tag+" ") |
| 241 | } |
| 242 | |
| 243 | func trimLeadingTransientBlock(content, tag string) (string, bool) { |
| 244 | closeTag := "</" + tag + ">" |
| 245 | i := strings.Index(content, closeTag) |
| 246 | if i < 0 { |
| 247 | return content, false |
| 248 | } |
| 249 | return strings.TrimLeft(content[i+len(closeTag):], " \t\r\n"), true |
| 250 | } |
| 251 | |
| 252 | // WithResponseLanguagePreference carries the runtime final-answer language |
| 253 | // preference to spawned tools and sub-agents. |
| 254 | func WithResponseLanguagePreference(ctx context.Context, lang string) context.Context { |
| 255 | if ctx == nil { |
| 256 | ctx = context.Background() |
| 257 | } |
| 258 | return context.WithValue(ctx, responseLanguageContextKey{}, NormalizeResponseLanguage(lang)) |
| 259 | } |
| 260 | |
| 261 | // ResponseLanguageFromContext returns auto|zh|en. |
| 262 | func ResponseLanguageFromContext(ctx context.Context) string { |
| 263 | if ctx == nil { |
| 264 | return "auto" |
| 265 | } |
| 266 | if v, ok := ctx.Value(responseLanguageContextKey{}).(string); ok { |
| 267 | return NormalizeResponseLanguage(v) |
| 268 | } |
| 269 | return "auto" |
| 270 | } |
| 271 | |
| 272 | // WithReasoningLanguagePreference carries the runtime preference to spawned |
| 273 | // tools, especially sub-agents whose first user turn is created outside the |
| 274 | // parent controller. It stores auto explicitly so live zh/en -> auto changes |
| 275 | // clear stale boot-time preferences in child paths. |
| 276 | func WithReasoningLanguagePreference(ctx context.Context, lang string) context.Context { |
| 277 | if ctx == nil { |
| 278 | ctx = context.Background() |
| 279 | } |
| 280 | return context.WithValue(ctx, reasoningLanguageContextKey{}, NormalizeReasoningLanguage(lang)) |
| 281 | } |
| 282 | |
| 283 | // ReasoningLanguageFromContext returns auto|zh|en. |
| 284 | func ReasoningLanguageFromContext(ctx context.Context) string { |
| 285 | if ctx == nil { |
| 286 | return "auto" |
| 287 | } |
| 288 | if v, ok := ctx.Value(reasoningLanguageContextKey{}).(string); ok { |
| 289 | return NormalizeReasoningLanguage(v) |
| 290 | } |
| 291 | return "auto" |
| 292 | } |
| 293 |