Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions changes/unreleased/4540.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!-- changes-log: packages/coding-agent/src/core/changes.md -->
## 2026-10-27 - The reply language survives compaction and long tool output (omo#11527)

### What changed

- `packages/coding-agent/src/core/user-prose.ts`: new. The parts of a user message the user wrote themselves: `withoutTaggedBlocks` drops `<name>...</name>` blocks (`<environment_context>`, reminders) in one linear pass. An unclosed `<` (TypeScript generics) no longer triggers the quadratic backtracking of a tag regex, which took 345-1137 ms on a 100 KB paste. An optional `ProseScan` counts the characters the pass visits. Fenced code is dropped by splitting on the fences, inline code and links by bounded regexes. `ownLines` also drops quoted spans of every style (`"..."`, curly quotes, `「」`, `『』`, `«»`, with a length bound), `>` lines and lines that read as pasted log or error output.
- `packages/coding-agent/src/core/reply-language-reading.ts`: new. `readReplyLanguage` reads the language a user message is written in from `ownLines`. The first line with two or more words decides; otherwise all remaining prose does. A non-Latin script with at least 30% of the words names the language. Latin-dominant text is not named. A script shared by several languages is named by its script (`Cyrillic script`).
- `packages/coding-agent/src/core/reply-language-request.ts`: new. `readReplyLanguageRequest` finds a request only in a message that, after fenced code and tagged blocks are removed, is ONE non-empty line of at most 120 characters. The line must hold the directive as a whole sentence ("영어로 답해줘", "앞으로 답변은 영어로 해줘", "please reply in Korean from now on"), optionally after a few lead words. It must have no mark of reported speech: a quotation mark, a speaker prefix, an em/en-dash or `--` attribution, a forwarded or mail header, or an attribution verb ("said", "mentioned", "라고", "말씀하셨", "요청하셨", "그러시", "하래"). Any multi-line message has no request: a wrong request is sticky (it outranks the user's language and survives compaction), and reported content across lines cannot be told apart reliably, so a directive inside a longer message is ignored by design. Negations and other tasks (translate, write) do not match.
- `packages/coding-agent/src/core/user-language.ts`: new. `insertReplyLanguageNotes` adds one user-role note after a tool-result run that is followed by the next assistant message (or the end of the context). It is added once 4000 characters of tool results, custom messages, bash output and branch summaries have built up since the user's last message. The note names the reply language the user explicitly asked for; otherwise the non-Latin language they write in. A run that carries words the user typed into a tool's answer gets no note, since those words already follow it as a user message (senpi#2920, senpi#2118). A note can only name one of the fixed language names, so no user text is ever copied into it. A note already in the context (converted output converted again) keeps its language. Each message's reading is cached in a `WeakMap` keyed by the message, its content object and a 32-bit FNV-1a hash of its text parts, so repeated `convertToLlm` calls do not re-read history and any in-place edit, even one that keeps every length, is read again. `replyLanguageOf` computes the same state over a message list.
- `packages/coding-agent/src/core/messages.ts`: `convertToLlm` runs `insertReplyLanguageNotes` first. A compaction summary with a `userLanguage` renders the same note after `</summary>` inside its own text block, so no new user message is added. `CompactionSummaryMessage` gains the optional `userLanguage` and `userLanguageRequested`.
- `packages/coding-agent/src/core/session-manager.ts`: `projectSession` sets `userLanguage`/`userLanguageRequested` on the newest compaction summary from the user messages before that compaction entry (`annotateCompactionUserLanguage`). It is derived at projection time, so sessions compacted before this change get it too, an explicit request survives the compaction, and the session file format is unchanged.
- `packages/coding-agent/src/core/tool-result-user-words.ts`: the private `wordMessage` is exported as `followerMessage` and takes any `Message` source, so the reply-language note reuses its provenance rule.

### Why

- omo#11527: a Korean-speaking user got English replies after compactions and tool-heavy turns. After a compaction the context held no Korean user turn and nothing that named the user's language. The summary prompt (`extensions/builtin/compaction/prompts.ts`) has no language field and its English template produces English prose. Long English tool output then sat between the last Korean message and every reply. The only language rule was one sentence in the Handoff section of the system prompt.
- The note is derived only from the messages, at fixed positions, so each request extends the previous one byte for byte and the provider's cache prefix stays valid.

### Why an extension could not handle it

- The note must be part of every provider request and of compaction-summary rendering, which `convertToLlm` and the session projection own. A `context` hook would not reach the summarization and transport paths that call `convertToLlm` directly.

### Expected merge conflict zones

- LOW: the `convertToLlm` head in `messages.ts` and the compaction-summary case; the tail of `projectSession` in `session-manager.ts`.

<!-- changes-log: packages/agent/src/changes.md -->
## 2026-10-27 - Compaction summaries can carry the user's reply language (omo#11527)

### What changed

- `packages/agent/src/harness/messages.ts`: `CompactionSummaryMessage` gains the optional `userLanguage` and `userLanguageRequested`, set by the coding agent's session projection.

### Why

- The `AgentMessage` union takes `compactionSummary` from this interface, so the coding agent's reply-language note needs the fields here (omo#11527).

### Why an extension could not handle it

- A type in the shared message union.

### Expected merge conflict zones

- LOW: the `CompactionSummaryMessage` interface.

<!-- changes-log: packages/coding-agent/CHANGELOG.md -->
### Fixed
- Replies stay in the user's language after a compaction and after long English tool output. The context now names the reply language after the compaction summary and after long tool-output stretches: the language the user explicitly asked for, otherwise the non-Latin language (such as Korean) they write in. Thanks to @miny6121-sys for the report ([#4540](https://github.com/code-yeongyu/senpi/pull/4540), [omo#11527](https://github.com/code-yeongyu/oh-my-openagent/issues/11527))
4 changes: 4 additions & 0 deletions packages/agent/src/harness/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export interface CompactionSummaryMessage {
tokensBefore: number;
details?: unknown;
timestamp: number;
/** The reply language the user's messages before the compaction established (omo#11527). */
userLanguage?: string;
/** Whether the user explicitly asked for userLanguage, rather than writing in it. */
userLanguageRequested?: boolean;
}

declare module "../types.ts" {
Expand Down
19 changes: 17 additions & 2 deletions packages/coding-agent/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ENVIRONMENT_CONTEXT_MESSAGE_TYPE, foldEnvironmentContextIntoNextUserMes
import { replaceElidedImages } from "./image-elision-placeholder.ts";
import { omitProviderRejectedImages } from "./provider-rejected-images.ts";
import { appendToolResultUserWords } from "./tool-result-user-words.ts";
import { insertReplyLanguageNotes, replyLanguageNote } from "./user-language.ts";

export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:

Expand Down Expand Up @@ -98,6 +99,10 @@ export interface CompactionSummaryMessage {
tokensBefore: number;
details?: unknown;
timestamp: number;
/** The reply language the user's messages before the compaction established (omo#11527). */
userLanguage?: string;
/** Whether the user explicitly asked for userLanguage, rather than writing in it. */
userLanguageRequested?: boolean;
}

export interface ConfigurationUpdateMessage {
Expand Down Expand Up @@ -194,7 +199,8 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
// Continuations are append-only here too: the transport array must extend the
// previous request verbatim to keep the provider's cache prefix valid.
const environmentMessages = new Set<Message>();
const converted = messages
// omo#11527: a reply-language note follows long stretches of tool output.
const converted = insertReplyLanguageNotes(messages)
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
Expand Down Expand Up @@ -231,7 +237,16 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
return withContextProvenance(m, {
role: "user",
content: [
{ type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX },
{
type: "text" as const,
text:
COMPACTION_SUMMARY_PREFIX +
m.summary +
COMPACTION_SUMMARY_SUFFIX +
(m.userLanguage === undefined
? ""
: `\n\n${replyLanguageNote({ language: m.userLanguage, requested: m.userLanguageRequested === true })}`),
},
],
timestamp: m.timestamp,
});
Expand Down
88 changes: 88 additions & 0 deletions packages/coding-agent/src/core/reply-language-reading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { ownLines } from "./user-prose.ts";

/**
* The language a user message is written in (omo#11527), read from the user's own lines only
* (`ownLines`): code, tagged blocks, links, quoted spans and lines, and pasted log or error lines
* do not count.
*/

export type ReplyLanguageReading = { kind: "none" } | { kind: "latin" } | { kind: "language"; language: string };

type ScriptName = "hangul" | "kana" | "han" | "thai" | "greek" | "hebrew" | "cyrillic" | "arabic" | "devanagari";

const SCRIPTS: readonly { readonly name: ScriptName; readonly pattern: RegExp; readonly spaced: boolean }[] = [
{ name: "hangul", pattern: /\p{Script=Hangul}/gu, spaced: true },
{ name: "kana", pattern: /[\p{Script=Hiragana}\p{Script=Katakana}]/gu, spaced: false },
{ name: "han", pattern: /\p{Script=Han}/gu, spaced: false },
{ name: "thai", pattern: /\p{Script=Thai}/gu, spaced: false },
{ name: "greek", pattern: /\p{Script=Greek}/gu, spaced: true },
{ name: "hebrew", pattern: /\p{Script=Hebrew}/gu, spaced: true },
{ name: "cyrillic", pattern: /\p{Script=Cyrillic}/gu, spaced: true },
{ name: "arabic", pattern: /\p{Script=Arabic}/gu, spaced: true },
{ name: "devanagari", pattern: /\p{Script=Devanagari}/gu, spaced: true },
];

/** A script shared by several languages is named by its script, never by a guessed language. */
const LANGUAGE_OF_SCRIPT: Record<Exclude<ScriptName, "han">, string> = {
hangul: "Korean",
kana: "Japanese",
thai: "Thai",
greek: "Greek",
hebrew: "Hebrew",
cyrillic: "Cyrillic script",
arabic: "Arabic script",
devanagari: "Devanagari script",
};

export const SCRIPT_LANGUAGE_NAMES: readonly string[] = [...Object.values(LANGUAGE_OF_SCRIPT), "Chinese"];

/** A script that is at least this share of the words decides the language. */
const SCRIPT_SHARE = 0.3;

/** Characters per word for scripts written without spaces (Chinese, Japanese, Thai). */
const UNSPACED_CHARS_PER_WORD = 2;

function lettered(words: readonly string[]): number {
return words.filter((word) => /\p{L}/u.test(word)).length;
}

function readWords(text: string): ReplyLanguageReading {
const weights = new Map<ScriptName, number>();
let latinWords = 0;
for (const word of text.split(/\s+/)) {
const script = SCRIPTS.find((candidate) => candidate.pattern.test(word));
for (const candidate of SCRIPTS) candidate.pattern.lastIndex = 0;
if (script) {
const count = word.match(script.pattern)?.length ?? 0;
weights.set(
script.name,
(weights.get(script.name) ?? 0) + (script.spaced ? 1 : count / UNSPACED_CHARS_PER_WORD),
);
} else if (/\p{Script=Latin}/u.test(word)) latinWords++;
}
// Japanese mixes kana with Han characters; Han without kana is Chinese.
const kana = weights.get("kana") ?? 0;
if (kana > 0) {
weights.set("kana", kana + (weights.get("han") ?? 0));
weights.delete("han");
}
let best: { name: ScriptName; weight: number } | undefined;
let total = latinWords;
for (const [name, weight] of weights) {
total += weight;
if (!best || weight > best.weight) best = { name, weight };
}
if (total === 0) return { kind: "none" };
if (!best || best.weight < total * SCRIPT_SHARE) return { kind: "latin" };
return { kind: "language", language: best.name === "han" ? "Chinese" : LANGUAGE_OF_SCRIPT[best.name] };
}

/**
* The language of the user's own words. The first line with at least two words decides, since a
* pasted block usually follows the user's request; without such a line, all of the prose decides.
*/
export function readReplyLanguage(text: string): ReplyLanguageReading {
const lines = ownLines(text);
const first = lines.find((line) => lettered(line.split(/\s+/)) >= 2);
return readWords(first ?? lines.join(" "));
}
112 changes: 112 additions & 0 deletions packages/coding-agent/src/core/reply-language-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { QUOTE_MARK, withoutTaggedBlocks } from "./user-prose.ts";

/**
* An explicit reply-language request (omo#11527). A message has a request only when, after fenced
* code and tagged blocks are removed, it is ONE non-empty line of at most 120 characters whose
* sentence is the directive ("영어로 답해줘", "please reply in Korean from now on"), optionally after a
* few lead words, and the line has no mark of reported speech: no quotation mark, speaker prefix,
* dash attribution, header or attribution verb. Any message of several lines has no request: four
* review rounds showed that pasted or reported content across lines cannot be told apart by a list
* of markers, a wrong request is sticky (it outranks the user's language and survives compaction),
* and a missed one only costs the note. A directive inside a longer message is ignored by design.
*/

/** Languages a user can ask for by name, with their Korean names. */
const REQUESTABLE_LANGUAGES: Record<string, readonly string[]> = {
English: ["영어"],
Korean: ["한국어", "한글"],
Japanese: ["일본어"],
Chinese: ["중국어"],
French: ["프랑스어", "불어"],
German: ["독일어"],
Spanish: ["스페인어"],
Russian: ["러시아어"],
Portuguese: ["포르투갈어"],
Italian: ["이탈리아어"],
Vietnamese: ["베트남어"],
Thai: ["태국어"],
};

export const REQUESTABLE_LANGUAGE_NAMES: readonly string[] = Object.keys(REQUESTABLE_LANGUAGES);

const KOREAN_NAME_TO_LANGUAGE = new Map(
Object.entries(REQUESTABLE_LANGUAGES).flatMap(([language, names]) => names.map((name) => [name, language] as const)),
);
const KOREAN_NAMES = [...KOREAN_NAME_TO_LANGUAGE.keys()].join("|");
const ENGLISH_NAMES = REQUESTABLE_LANGUAGE_NAMES.join("|");

const LEAD_WORDS =
"앞으로|이제|이제부터|지금부터|다음부터|그리고|그럼|그냥|혹시|제발|꼭|아니|아니다|아니요|아뇨|음|자|ok|okay|please|and|also|so|now|no|wait|actually|from now on";
const LEAD = `^(?:(?:${LEAD_WORDS})\\s*,?\\s*)*`;
const KOREAN_ENDING =
"(?:해|해줘|해 줘|해주세요|해 주세요|해줄래|해요|하세요|해라|하자|해주라|해주시겠어요|부탁해|부탁해요|부탁드려요|부탁합니다)";
const KOREAN_REPLY_NOUN = "(?:답변|대답|응답|답장)";

/** Each pattern spans the whole sentence; group 1 is the language. */
const DIRECTIVES: readonly RegExp[] = [
new RegExp(
`${LEAD}${KOREAN_REPLY_NOUN}\\s*(?:은|는|도|을)?\\s*(?:앞으로|이제|전부|모두|다)?\\s*(${KOREAN_NAMES})\\s*(?:으로|로)\\s*(?:만|좀)?\\s*${KOREAN_ENDING}$`,
),
new RegExp(
`${LEAD}(${KOREAN_NAMES})\\s*(?:으로|로)\\s*(?:만|좀)?\\s*(?:답변|대답|응답|답장|답|말|얘기|이야기|소통)\\s*(?:을|좀)?\\s*${KOREAN_ENDING}$`,
),
new RegExp(
`${LEAD}(?:(?:can|could|would|will) you\\s+)?(?:please\\s+)?(?:reply|respond|answer|talk|speak|communicate|chat)(?:\\s+(?:to me|me|back))?\\s+(?:only\\s+)?in\\s+(${ENGLISH_NAMES})(?:\\s*,?\\s*(?:from now on|please|only|going forward|for now|from here on))*$`,
"i",
),
new RegExp(
`${LEAD}(?:please\\s+)?use\\s+(${ENGLISH_NAMES})\\s+(?:only\\s+)?(?:from now on|for (?:your |all )?(?:replies|responses|answers))(?:\\s*,?\\s*please)?$`,
"i",
),
];

/** A message with a request is one line of at most this many characters. */
const MAX_REQUEST_CHARS = 120;

/** Anything that marks reported speech; one match means the line is not the user's own directive. */
const REPORTED_CONTENT: readonly RegExp[] = [
/[::]\s*$/m,
/^\s*[^\s::][^::\n]{0,30}[::]\s*\S/m,
/^\s*>/m,
QUOTE_MARK,
/-{2,}\s*(?:forwarded|original)\b|\b(?:forwarded message|original message)\b|^\s*(?:from|sent|to|subject|date)\s*[::]|전달된\s*메시지|원본\s*메시지|원문/im,
/\b(?:said|says|wrote|writes|asked|asks|told|tells|replied|commented|quoted|mentioned|mentions|according to)\b|라고|이라고|말했|말하길|말씀하셨|말씀하시길|요청하셨|그러시|썼|적었|했대|하래|하랬|달래|하더라|하네|그러더라|그랬|에\s*따르면/i,
/[—–]|(?:^|\s)-{1,2}\s*\p{L}/u,
];

function withoutFencedCode(text: string): string {
return text
.split("```")
.filter((_, index) => index % 2 === 0)
.join("\n");
}

function languageOf(name: string): string | undefined {
return (
KOREAN_NAME_TO_LANGUAGE.get(name) ??
REQUESTABLE_LANGUAGE_NAMES.find((language) => language.toLowerCase() === name.toLowerCase())
);
}

/** The language the last whole-sentence directive on a line asks for. */
function lineDirective(line: string): string | undefined {
let requested: string | undefined;
for (const sentence of line.split(/[.!?。!?~]+/)) {
const trimmed = sentence.trim();
for (const directive of DIRECTIVES) {
const name = directive.exec(trimmed)?.[1];
if (name !== undefined) requested = languageOf(name) ?? requested;
}
}
return requested;
}

/** The reply language the user explicitly asks for, if the whole message is a one-line directive. */
export function readReplyLanguageRequest(text: string): string | undefined {
const message = withoutFencedCode(withoutTaggedBlocks(text)).trim();
const lines = message.split("\n").filter((line) => line.trim() !== "");
const line = lines[0];
if (line === undefined || lines.length > 1 || line.trim().length > MAX_REQUEST_CHARS) return undefined;
if (REPORTED_CONTENT.some((marker) => marker.test(line))) return undefined;
return lineDirective(line);
}
Loading
Loading