Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/ai-explicit-user-language.md
Original file line number Diff line number Diff line change
@@ -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.)
Original file line number Diff line number Diff line change
@@ -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 ---');
});
});
14 changes: 14 additions & 0 deletions packages/services/service-ai/src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down
44 changes: 42 additions & 2 deletions packages/services/service-ai/src/routes/agent-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down