Skip to content

Commit aa5229d

Browse files
os-zhuangclaude
andcommitted
feat(ai): make the user's language explicit in the agent system prompt
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 <noreply@anthropic.com>
1 parent ffa0aca commit aa5229d

4 files changed

Lines changed: 116 additions & 2 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-ai': patch
3+
---
4+
5+
feat(ai): tell the agent the user's language explicitly so generated labels match it
6+
7+
The build/ask agents were only told to "answer in the same language the user is using"
8+
— left to infer it. On the granular authoring path that produced English labels on
9+
non-English apps (and then broke same-language object resolution on later turns). The
10+
agent runtime now detects the user's language from their latest message and states it
11+
explicitly in the system prompt (AgentChatContext.userLanguage), requiring all generated
12+
labels to be written in it. Mirrors the existing autoPublish context block. (Whole-app
13+
blueprints are generated by a separate internal call — labels there are fixed in
14+
@objectstack/service-ai-studio's propose_blueprint prompt.)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { AgentRuntime } from '../agent-runtime.js';
4+
5+
// Dogfood regression: apps built from a Chinese prompt came back with English
6+
// labels because the model was only told to "use the same language" (inferred).
7+
// The runtime now detects the user's language and states it EXPLICITLY in the
8+
// agent's system prompt so generated labels (on the granular authoring path)
9+
// reliably match it.
10+
const metadata = {
11+
get: async () => undefined,
12+
list: async () => [],
13+
getObject: async () => undefined,
14+
} as never;
15+
16+
const agent = {
17+
name: 'build',
18+
label: 'Builder',
19+
role: 'Architect',
20+
instructions: 'Base persona.',
21+
surface: 'build',
22+
skills: [],
23+
active: true,
24+
} as never;
25+
26+
describe('buildSystemMessages — explicit user-language directive', () => {
27+
const rt = new AgentRuntime(metadata);
28+
29+
it('states the language and requires labels in it when context.userLanguage is set', () => {
30+
const sys = rt
31+
.buildSystemMessages(agent, { userLanguage: 'Chinese' } as never)
32+
.map((m) => m.content)
33+
.join('\n');
34+
expect(sys).toContain('--- User language ---');
35+
expect(sys).toContain('Chinese');
36+
expect(sys).toMatch(/MUST be written in/);
37+
});
38+
39+
it('omits the directive when no userLanguage is provided', () => {
40+
const sys = rt
41+
.buildSystemMessages(agent, {} as never)
42+
.map((m) => m.content)
43+
.join('\n');
44+
expect(sys).not.toContain('--- User language ---');
45+
});
46+
});

packages/services/service-ai/src/agent-runtime.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ export interface AgentChatContext extends SkillContext {
6767
* "publish it to make it live". Absent/false keeps the conservative framing.
6868
*/
6969
autoPublishAiBuilds?: boolean;
70+
/**
71+
* Detected language of the user's own messages (e.g. "Chinese"). When set,
72+
* the agent is told to write ALL generated labels in it — relying on the
73+
* model to infer "the same language" produced English labels on non-English
74+
* apps, which then broke same-language object resolution on later turns.
75+
*/
76+
userLanguage?: string;
7077
}
7178

7279
/**
@@ -198,6 +205,13 @@ export class AgentRuntime {
198205
'incremental edits are still staged for review — the chat panel shows each change\'s status.',
199206
);
200207
}
208+
// Make the user's language EXPLICIT so generated labels reliably match it.
209+
if (context.userLanguage) {
210+
parts.push(
211+
'\n--- User language ---\n' +
212+
`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.`,
213+
);
214+
}
201215
}
202216

203217
// Active skill bundle

packages/services/service-ai/src/routes/agent-routes.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,39 @@ import { normalizeMessage, validateMessageContent } from './message-utils.js';
1111
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
1212
import { evaluateAgentAccess } from './agent-access.js';
1313

14+
/**
15+
* Best-effort detection of the user's language from their latest message, so the
16+
* agent can be told it EXPLICITLY (generated labels then reliably match it).
17+
* Only non-Latin scripts are claimed — Latin-script languages are left to the
18+
* model, which matches them well and can't be told apart reliably by script.
19+
*/
20+
function extractMessageText(content: unknown): string {
21+
if (typeof content === 'string') return content;
22+
if (Array.isArray(content)) {
23+
return content
24+
.map((p) => (typeof p === 'string' ? p : ((p as { text?: string })?.text ?? '')))
25+
.join(' ');
26+
}
27+
return '';
28+
}
29+
30+
function detectUserLanguage(messages: readonly unknown[]): string | undefined {
31+
let text = '';
32+
for (let i = messages.length - 1; i >= 0; i--) {
33+
const m = messages[i] as { role?: string; content?: unknown } | undefined;
34+
if (m?.role !== 'user') continue;
35+
text = extractMessageText(m.content);
36+
if (text) break;
37+
}
38+
if (!text) return undefined;
39+
if (/[\u4e00-\u9fff]/.test(text)) return 'Chinese';
40+
if (/[\u3040-\u30ff]/.test(text)) return 'Japanese';
41+
if (/[\uac00-\ud7af]/.test(text)) return 'Korean';
42+
if (/[\u0400-\u04ff]/.test(text)) return 'Russian';
43+
if (/[\u0600-\u06ff]/.test(text)) return 'Arabic';
44+
return undefined;
45+
}
46+
1447
/**
1548
* Allowed message roles for the agent chat endpoint.
1649
*
@@ -196,11 +229,18 @@ export function buildAgentRoutes(
196229
}
197230

198231
try {
232+
// Detect the user's language from their messages and make it explicit to
233+
// the agent, so generated labels reliably match it (see AgentChatContext.userLanguage).
234+
const detectedUserLanguage = detectUserLanguage(rawMessages);
235+
const chatContextWithLang: AgentChatContext | undefined = detectedUserLanguage
236+
? { ...(chatContext ?? {}), userLanguage: detectedUserLanguage }
237+
: chatContext;
238+
199239
// Resolve active skills for this agent in the current context
200-
const activeSkills = await agentRuntime.resolveActiveSkills(agent, chatContext);
240+
const activeSkills = await agentRuntime.resolveActiveSkills(agent, chatContextWithLang);
201241

202242
// Build system messages from agent instructions + UI context + skills
203-
const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext, activeSkills);
243+
const systemMessages = agentRuntime.buildSystemMessages(agent, chatContextWithLang, activeSkills);
204244

205245
// Inject the schema of the object the user is currently viewing so
206246
// "analyse / describe this object" works without a lookup tool and

0 commit comments

Comments
 (0)