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