From aa5229d220921c583c7caacf3b61dd2b6c9c915b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 11:08:09 +0800 Subject: [PATCH] feat(ai): make the user's language explicit in the agent system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents were only told to 'answer in the same language' (inferred), which produced English labels on non-English apps via the granular authoring path. AgentRuntime now detects the user's language from their latest message and states it explicitly in the system prompt (AgentChatContext.userLanguage), requiring generated labels in it — mirroring the autoPublish context block. Blueprint labels are fixed separately in service-ai-studio's propose_blueprint prompt. Co-Authored-By: Claude Opus 4.8 --- .changeset/ai-explicit-user-language.md | 14 ++++++ .../agent-runtime-user-language.test.ts | 46 +++++++++++++++++++ .../services/service-ai/src/agent-runtime.ts | 14 ++++++ .../service-ai/src/routes/agent-routes.ts | 44 +++++++++++++++++- 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 .changeset/ai-explicit-user-language.md create mode 100644 packages/services/service-ai/src/__tests__/agent-runtime-user-language.test.ts diff --git a/.changeset/ai-explicit-user-language.md b/.changeset/ai-explicit-user-language.md new file mode 100644 index 0000000000..6555a411c6 --- /dev/null +++ b/.changeset/ai-explicit-user-language.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-ai': patch +--- + +feat(ai): tell the agent the user's language explicitly so generated labels match it + +The build/ask agents were only told to "answer in the same language the user is using" +— left to infer it. On the granular authoring path that produced English labels on +non-English apps (and then broke same-language object resolution on later turns). The +agent runtime now detects the user's language from their latest message and states it +explicitly in the system prompt (AgentChatContext.userLanguage), requiring all generated +labels to be written in it. Mirrors the existing autoPublish context block. (Whole-app +blueprints are generated by a separate internal call — labels there are fixed in +@objectstack/service-ai-studio's propose_blueprint prompt.) diff --git a/packages/services/service-ai/src/__tests__/agent-runtime-user-language.test.ts b/packages/services/service-ai/src/__tests__/agent-runtime-user-language.test.ts new file mode 100644 index 0000000000..c7df38fc30 --- /dev/null +++ b/packages/services/service-ai/src/__tests__/agent-runtime-user-language.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect } from 'vitest'; +import { AgentRuntime } from '../agent-runtime.js'; + +// Dogfood regression: apps built from a Chinese prompt came back with English +// labels because the model was only told to "use the same language" (inferred). +// The runtime now detects the user's language and states it EXPLICITLY in the +// agent's system prompt so generated labels (on the granular authoring path) +// reliably match it. +const metadata = { + get: async () => undefined, + list: async () => [], + getObject: async () => undefined, +} as never; + +const agent = { + name: 'build', + label: 'Builder', + role: 'Architect', + instructions: 'Base persona.', + surface: 'build', + skills: [], + active: true, +} as never; + +describe('buildSystemMessages — explicit user-language directive', () => { + const rt = new AgentRuntime(metadata); + + it('states the language and requires labels in it when context.userLanguage is set', () => { + const sys = rt + .buildSystemMessages(agent, { userLanguage: 'Chinese' } as never) + .map((m) => m.content) + .join('\n'); + expect(sys).toContain('--- User language ---'); + expect(sys).toContain('Chinese'); + expect(sys).toMatch(/MUST be written in/); + }); + + it('omits the directive when no userLanguage is provided', () => { + const sys = rt + .buildSystemMessages(agent, {} as never) + .map((m) => m.content) + .join('\n'); + expect(sys).not.toContain('--- User language ---'); + }); +}); diff --git a/packages/services/service-ai/src/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index 8c0b156d37..d67a7dcf80 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -67,6 +67,13 @@ export interface AgentChatContext extends SkillContext { * "publish it to make it live". Absent/false keeps the conservative framing. */ autoPublishAiBuilds?: boolean; + /** + * Detected language of the user's own messages (e.g. "Chinese"). When set, + * the agent is told to write ALL generated labels in it — relying on the + * model to infer "the same language" produced English labels on non-English + * apps, which then broke same-language object resolution on later turns. + */ + userLanguage?: string; } /** @@ -198,6 +205,13 @@ export class AgentRuntime { 'incremental edits are still staged for review — the chat panel shows each change\'s status.', ); } + // Make the user's language EXPLICIT so generated labels reliably match it. + if (context.userLanguage) { + parts.push( + '\n--- User language ---\n' + + `The user is communicating in ${context.userLanguage}. EVERY user-facing label you generate or change — object labels, field labels, select/multiselect option labels, and view/dashboard titles — MUST be written in ${context.userLanguage}, even though these instructions are written in English. Only machine names stay snake_case ASCII. For example, in Chinese a field is label "标题" with machine name "title" — never an English label like "Title" on a non-English app.`, + ); + } } // Active skill bundle diff --git a/packages/services/service-ai/src/routes/agent-routes.ts b/packages/services/service-ai/src/routes/agent-routes.ts index 1ba01092dd..e96758b7f7 100644 --- a/packages/services/service-ai/src/routes/agent-routes.ts +++ b/packages/services/service-ai/src/routes/agent-routes.ts @@ -11,6 +11,39 @@ import { normalizeMessage, validateMessageContent } from './message-utils.js'; import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js'; import { evaluateAgentAccess } from './agent-access.js'; +/** + * Best-effort detection of the user's language from their latest message, so the + * agent can be told it EXPLICITLY (generated labels then reliably match it). + * Only non-Latin scripts are claimed — Latin-script languages are left to the + * model, which matches them well and can't be told apart reliably by script. + */ +function extractMessageText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((p) => (typeof p === 'string' ? p : ((p as { text?: string })?.text ?? ''))) + .join(' '); + } + return ''; +} + +function detectUserLanguage(messages: readonly unknown[]): string | undefined { + let text = ''; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] as { role?: string; content?: unknown } | undefined; + if (m?.role !== 'user') continue; + text = extractMessageText(m.content); + if (text) break; + } + if (!text) return undefined; + if (/[\u4e00-\u9fff]/.test(text)) return 'Chinese'; + if (/[\u3040-\u30ff]/.test(text)) return 'Japanese'; + if (/[\uac00-\ud7af]/.test(text)) return 'Korean'; + if (/[\u0400-\u04ff]/.test(text)) return 'Russian'; + if (/[\u0600-\u06ff]/.test(text)) return 'Arabic'; + return undefined; +} + /** * Allowed message roles for the agent chat endpoint. * @@ -196,11 +229,18 @@ export function buildAgentRoutes( } try { + // Detect the user's language from their messages and make it explicit to + // the agent, so generated labels reliably match it (see AgentChatContext.userLanguage). + const detectedUserLanguage = detectUserLanguage(rawMessages); + const chatContextWithLang: AgentChatContext | undefined = detectedUserLanguage + ? { ...(chatContext ?? {}), userLanguage: detectedUserLanguage } + : chatContext; + // Resolve active skills for this agent in the current context - const activeSkills = await agentRuntime.resolveActiveSkills(agent, chatContext); + const activeSkills = await agentRuntime.resolveActiveSkills(agent, chatContextWithLang); // Build system messages from agent instructions + UI context + skills - const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext, activeSkills); + const systemMessages = agentRuntime.buildSystemMessages(agent, chatContextWithLang, activeSkills); // Inject the schema of the object the user is currently viewing so // "analyse / describe this object" works without a lookup tool and