feat(tools): localize tool descriptions by locale for every runtime

Tool and parameter descriptions were handed to the LLM in Chinese on every
runtime regardless of locale: the English metadata table in
packages/tools/src/tool-metadata.ts had no caller, the desktop translateTool
looked up i18n keys that exist in no locale file, and the MCP server had no
locale at all.

Fill the table for every tool whose definition text is Chinese (37 entries,
only translating what the definitions write in Chinese so the two copies
cannot drift), and route the four consumers through getLocalizedToolMetadata:
the cross-chat agent adapters, the CLI Web agent adapter, the desktop shared
tool adapter (replacing the dead translateTool), and the MCP server, which
now resolves its locale from config.locale.lang or the system locale for both
the standalone binary and the chatlab mcp subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris ChengandClaude Opus 5 authored and digua committed 2026-09-22 16:54:06 +08:00
1 parent e295a80bdf
commit 6e27968bb0
15 files changed
+362 -78

No files matched your search

+1 -1
View File
@@ -227,7 +227,7 @@ export function createCliRunAgentStream(
timeFilter: params.timeFilter,
maxMessagesLimit: params.maxMessagesLimit,
}),
{ maxToolResultTokens }
{ maxToolResultTokens, locale }
)
: []
+14 -6
View File
@@ -12,7 +12,12 @@ import type {
RawMessage,
ToolTimeRange,
} from '@openchatlab/tools'
import { CoreDataProvider, executeToolForAgent, toAgentToolParameters } from '@openchatlab/tools'
import {
CoreDataProvider,
executeToolForAgent,
getLocalizedToolMetadata,
toAgentToolParameters,
} from '@openchatlab/tools'
import type { DatabaseAdapter } from '@openchatlab/core'
import {
applyPreprocessingPipeline,
@@ -56,6 +61,8 @@ export interface ServerToolContext {
export interface AdaptToolsOptions {
maxToolResultTokens?: number
/** Request locale; decides whether the LLM sees Chinese or English tool descriptions. */
locale?: string
}
export function adaptToolsForAgent(
@@ -66,13 +73,14 @@ export function adaptToolsForAgent(
const tokenBudget = options?.maxToolResultTokens ?? DEFAULT_MAX_TOOL_RESULT_TOKENS
const chartSchemaGateState = createChartSchemaGateState()
return tools.map((tool) =>
wrapWithChartSchemaGate(
return tools.map((tool) => {
const localized = getLocalizedToolMetadata(tool, options?.locale)
return wrapWithChartSchemaGate(
{
name: tool.name,
label: tool.name,
description: tool.description,
parameters: toAgentToolParameters(tool.inputSchema) as any,
description: localized.description,
parameters: toAgentToolParameters(localized.inputSchema) as any,
executionMode: tool.executionMode,
async execute(_toolCallId: string, params: unknown, signal, onUpdate) {
const ctx = getContext()
@@ -122,5 +130,5 @@ export function adaptToolsForAgent(
},
chartSchemaGateState
)
)
})
}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
hasPendingElectronDataWarning,
verifyCliDataPath,
} from '@openchatlab/node-runtime'
import { startMcpServer } from 'chatlab-mcp'
import { resolveMcpLocale, startMcpServer } from 'chatlab-mcp'
import { getVersion } from './version'
import { resolveCliPath } from './paths'
import { assertCliDataDirCompatible } from './runtime-compat'
@@ -43,5 +43,5 @@ export function initMcpRuntime(): DatabaseManager {
export async function startCliMcpServer(): Promise<void> {
const dbManager = initMcpRuntime()
await startMcpServer({ version: getVersion(), dbManager })
await startMcpServer({ version: getVersion(), dbManager, locale: resolveMcpLocale(loadConfig()) })
}
-32
View File
@@ -8,7 +8,6 @@
import type { AgentTool } from '@openchatlab/node-runtime'
import type { ToolContext, TruncationStrategy } from './types'
import { isAnalysisToolAllowed } from './tool-filter'
import { t as i18nT } from '../../i18n'
import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime'
import { aiLogger } from '../logger'
import { getSkillConfig } from '../skills/manager'
@@ -51,36 +50,6 @@ const TRUNCATION_STRATEGY_MAP = new Map<string, TruncationStrategy>(
// 导出类型
export * from './types'
/**
* 翻译 AgentTool 的描述(工具级 + 参数级)
*
* i18n 键命名规则:
* - 工具描述:ai.tools.{toolName}.desc
* - 参数描述:ai.tools.{toolName}.params.{paramName}
*/
function translateTool(tool: AgentTool<any>): AgentTool<any> {
const name = tool.name
const descKey = `ai.tools.${name}.desc`
const translatedDesc = i18nT(descKey)
const params = tool.parameters as Record<string, unknown>
if (params?.properties && typeof params.properties === 'object') {
for (const [paramName, param] of Object.entries(params.properties as Record<string, Record<string, unknown>>)) {
const paramKey = `ai.tools.${name}.params.${paramName}`
const translated = i18nT(paramKey)
if (translated !== paramKey) {
param.description = translated
}
}
}
return {
...tool,
description: translatedDesc !== descKey ? translatedDesc : tool.description,
}
}
/**
* 预处理包装层
* 拦截工具的 execute 结果:如果 details 中包含 rawMessages,
@@ -147,7 +116,6 @@ export async function getAllTools(context: ToolContext, allowedTools?: string[])
const chartSchemaGateState = createChartSchemaGateState()
return [...coreTools, ...analysisTools, ...evidenceTools, ...semanticTools]
.map(translateTool)
.map((t) => wrapWithChartSchemaGate(t, chartSchemaGateState))
.map((t) => wrapWithPreprocessing(t, context))
}
@@ -7,6 +7,7 @@
import {
executeToolForAgent,
getLocalizedToolMetadata,
toAgentToolParameters,
type ToolDefinition,
type ToolExecutionContext,
@@ -58,13 +59,14 @@ export function adaptSharedTool(tool: ToolDefinition): ToolRegistryEntry {
category: tool.category ?? 'core',
truncationStrategy: tool.truncationStrategy,
factory(context: ToolContext): AgentTool<any> {
// Tool and parameter descriptions follow the chat locale: Chinese locales keep the definition text,
// every other locale gets the English metadata table.
const localized = getLocalizedToolMetadata(tool, context.locale)
return {
name: tool.name,
label: tool.name,
// 保留英文原始描述作为 fallback:translateTool 会在 i18n key 命中时覆盖为译文,
// 缺 key 时回退到此英文描述,避免把裸 i18n key 当作工具描述传给 LLM。
description: tool.description,
parameters: toAgentToolParameters(tool.inputSchema) as any,
description: localized.description,
parameters: toAgentToolParameters(localized.inputSchema) as any,
executionMode: tool.executionMode,
async execute(_toolCallId: string, params: unknown, signal, onUpdate) {
return executeToolForAgent(
+13 -1
View File
@@ -4,7 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime'
import { initStandaloneMcpRuntime } from './standalone-runtime'
import { initStandaloneMcpRuntime, resolveMcpLocale } from './standalone-runtime'
function makeTempDir(): string {
const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir())
@@ -28,3 +28,15 @@ test('initStandaloneMcpRuntime rejects incompatible data directories before stdi
(error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
)
})
test('resolveMcpLocale prefers the configured language and falls back to the system locale', () => {
const cases: Array<{ lang: string; systemLocale: string; expected: string }> = [
{ lang: 'zh-CN', systemLocale: 'en-US', expected: 'zh-CN' },
{ lang: '', systemLocale: 'en-US', expected: 'en-US' },
{ lang: '', systemLocale: 'zh-CN', expected: 'zh-CN' },
]
for (const { lang, systemLocale, expected } of cases) {
assert.equal(resolveMcpLocale({ locale: { lang } }, systemLocale), expected, `${lang || '(empty)'}/${systemLocale}`)
}
})
+2 -2
View File
@@ -9,7 +9,7 @@
import { loadConfig } from '@openchatlab/config'
import { startMcpServer } from './server'
import { initStandaloneMcpRuntime } from './standalone-runtime'
import { initStandaloneMcpRuntime, resolveMcpLocale } from './standalone-runtime'
import { getMcpPackageVersion } from './runtime-version'
function main(): void {
@@ -18,7 +18,7 @@ function main(): void {
const version = getMcpPackageVersion()
const { dbManager } = initStandaloneMcpRuntime(version, userDataDir)
startMcpServer({ version, dbManager }).catch((err) => {
startMcpServer({ version, dbManager, locale: resolveMcpLocale(config) }).catch((err) => {
console.error('[chatlab-mcp] Fatal error:', err)
process.exit(1)
})
+1
View File
@@ -6,4 +6,5 @@
*/
export { startMcpServer } from './server'
export { resolveMcpLocale } from './standalone-runtime'
export type { McpServerOptions, McpDatabaseManager } from './types'
+9 -8
View File
@@ -9,7 +9,7 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import { getSessionMeta, getSessionOverview, getDatabaseSchema } from '@openchatlab/core'
import { MCP_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools'
import { MCP_TOOL_REGISTRY, CoreDataProvider, getLocalizedToolMetadata } from '@openchatlab/tools'
import type { SessionListContext } from '@openchatlab/tools/src/definitions/sessions'
import type { McpDatabaseManager, McpServerOptions } from './types'
import { jsonSchemaToZod } from './schema'
@@ -30,17 +30,18 @@ function applyFormat(content: string, format: string | undefined, toolName: stri
return textResult ?? content
}
function registerTools(server: McpServer, dbManager: McpDatabaseManager): void {
function registerTools(server: McpServer, dbManager: McpDatabaseManager, locale?: string): void {
for (const tool of MCP_TOOL_REGISTRY) {
const mcpName = `${MCP_TOOL_PREFIX}${tool.name}`
const { description, inputSchema } = getLocalizedToolMetadata(tool, locale)
if (tool.name === 'list_sessions') {
const zodShape = {
...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required),
...jsonSchemaToZod(inputSchema.properties, inputSchema.required),
format: FORMAT_PARAM,
}
server.tool(mcpName, tool.description, zodShape, async (params) => {
server.tool(mcpName, description, zodShape, async (params) => {
const format = params.format as string | undefined
const context: SessionListContext = {
db: null as any,
@@ -59,11 +60,11 @@ function registerTools(server: McpServer, dbManager: McpDatabaseManager): void {
const zodShape = {
session_id: z.string().describe('Session ID'),
...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required),
...jsonSchemaToZod(inputSchema.properties, inputSchema.required),
format: FORMAT_PARAM,
}
server.tool(mcpName, tool.description, zodShape, async (params) => {
server.tool(mcpName, description, zodShape, async (params) => {
const sessionId = params.session_id as string
const format = params.format as string | undefined
const db = dbManager.open(sessionId)
@@ -162,11 +163,11 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo
}
export async function startMcpServer(options: McpServerOptions): Promise<void> {
const { version, name = 'chatlab', dbManager } = options
const { version, name = 'chatlab', dbManager, locale } = options
const server = new McpServer({ name, version })
registerTools(server, dbManager)
registerTools(server, dbManager, locale)
registerResources(server, dbManager)
const transport = new StdioServerTransport()
@@ -16,3 +16,14 @@ export function initStandaloneMcpRuntime(
const dbManager = new DatabaseManager(pathProvider, { runtime })
return { dbManager, pathProvider, runtime }
}
/**
* Locale for MCP tool descriptions: the configured UI language, or the system locale when unset.
* Mirrors Desktop's initLocale, so Chinese systems keep Chinese tool descriptions.
*/
export function resolveMcpLocale(
config: { locale?: { lang?: string } },
systemLocale: string = Intl.DateTimeFormat().resolvedOptions().locale
): string {
return config.locale?.lang || systemLocale
}
+2
View File
@@ -18,4 +18,6 @@ export interface McpServerOptions {
/** MCP server name exposed to clients (default: 'chatlab') */
name?: string
dbManager: McpDatabaseManager
/** Locale deciding whether tool descriptions are Chinese or English (default: English) */
locale?: string
}
+25
View File
@@ -161,3 +161,28 @@ test('cross-chat tool adapters share entities resolved during the current Agent
assert.equal(afterResolve.isError, undefined)
assert.deepEqual((afterResolve.details as { entries: AIMemoryEntry[] }).entries, [memory])
})
test('cross-chat tool adapters follow the locale for parameter descriptions', () => {
const base = {
analysisService: {} as CrossChatAnalysisToolService,
memoryService: {} as AIMemoryToolService,
aiChatId: 'global-chat-locale',
maxToolResultTokens: 16_000,
preprocessMessagesBySession: async (_sessionId: string, messages: never[]) => messages,
preprocessSummariesBySession: async (_sessionId: string, summaries: never[]) => summaries,
preprocessModelLabel: (value: string) => value,
} as unknown as Parameters<typeof createCrossChatAgentToolAdapters>[0]
// All thirteen cross-chat tool descriptions are already English; the shared time parameters are the
// part that is Chinese in the definitions, so they are what shows whether the locale reached the adapter.
const startTimeOf = (locale: string): string | undefined => {
const tool = createCrossChatAgentToolAdapters({ ...base, locale }).find(
(candidate) => candidate.name === 'search_messages_globally'
)
assert.ok(tool)
return (tool.parameters.properties.start_time as { description?: string }).description
}
assert.equal(startTimeOf('zh-CN'), '起始时间, 格式: YYYY-MM-DD HH:mm')
assert.equal(startTimeOf('en-US'), 'Start time in YYYY-MM-DD HH:mm format.')
})
+22 -18
View File
@@ -1,4 +1,5 @@
import { CROSS_CHAT_AGENT_TOOL_REGISTRY } from './registry'
import { getLocalizedToolMetadata } from './tool-metadata'
import type { CrossChatToolExecutionContext, JsonSchema, ToolDefinition, ToolProgress, ToolResult } from './types'
interface AgentToolParameters {
@@ -74,22 +75,25 @@ export function createCrossChatAgentToolAdapters(
...context,
resolvedEntityRefs: context.resolvedEntityRefs ?? [],
}
return CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => ({
name: tool.name,
label: tool.name,
description: tool.description,
parameters: toAgentToolParameters(tool.inputSchema),
executionMode: tool.executionMode,
execute: async (
_toolCallId: string,
params: unknown,
signal: AbortSignal,
onUpdate?: (update: { content: []; details: { progress: ToolProgress } }) => void
) =>
executeToolForAgent(tool, params, {
...runContext,
abortSignal: signal,
reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }),
}),
}))
return CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => {
const { description, inputSchema } = getLocalizedToolMetadata(tool, context.locale)
return {
name: tool.name,
label: tool.name,
description,
parameters: toAgentToolParameters(inputSchema),
executionMode: tool.executionMode,
execute: async (
_toolCallId: string,
params: unknown,
signal: AbortSignal,
onUpdate?: (update: { content: []; details: { progress: ToolProgress } }) => void
) =>
executeToolForAgent(tool, params, {
...runContext,
abortSignal: signal,
reportProgress: (progress) => onUpdate?.({ content: [], details: { progress } }),
}),
}
})
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { AGENT_TOOL_REGISTRY, CROSS_CHAT_AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY } from './registry'
import { ENGLISH_TOOL_METADATA, getLocalizedToolMetadata } from './tool-metadata'
import type { ToolDefinition } from './types'
/** CJK ideographs plus the CJK / fullwidth punctuation used by the Chinese tool definitions. */
const CHINESE_PATTERN = /[\u3000-\u303F\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/
type LocalizableTool = Pick<ToolDefinition, 'name' | 'description' | 'inputSchema'>
/** Every tool any runtime can hand to an LLM: session Agent, MCP Server and cross-chat Agent. */
const REGISTERED_TOOLS: LocalizableTool[] = [
...AGENT_TOOL_REGISTRY,
...MCP_TOOL_REGISTRY,
...CROSS_CHAT_AGENT_TOOL_REGISTRY,
].filter((tool, index, tools) => tools.findIndex((other) => other.name === tool.name) === index)
function definitionIsChinese(tool: LocalizableTool): boolean {
if (CHINESE_PATTERN.test(tool.description)) return true
return Object.values(tool.inputSchema.properties).some(
(property) => property.description !== undefined && CHINESE_PATTERN.test(property.description)
)
}
test('every tool with a Chinese definition has English metadata', () => {
const missing = REGISTERED_TOOLS.filter((tool) => definitionIsChinese(tool) && !ENGLISH_TOOL_METADATA[tool.name]).map(
(tool) => tool.name
)
assert.deepEqual(missing, [])
})
test('non-Chinese locales never receive Chinese tool or parameter descriptions', () => {
const chinese: string[] = []
for (const tool of REGISTERED_TOOLS) {
const { description, inputSchema } = getLocalizedToolMetadata(tool, 'en-US')
if (CHINESE_PATTERN.test(description)) chinese.push(`${tool.name} (description)`)
for (const [name, property] of Object.entries(inputSchema.properties)) {
if (property.description && CHINESE_PATTERN.test(property.description)) chinese.push(`${tool.name}.${name}`)
}
}
assert.deepEqual(chinese, [])
})
test('Chinese locales keep the original definition text', () => {
for (const tool of REGISTERED_TOOLS) {
const localized = getLocalizedToolMetadata(tool, 'zh-CN')
assert.equal(localized.description, tool.description, tool.name)
assert.equal(localized.inputSchema, tool.inputSchema, tool.name)
}
})
test('localization only rewrites descriptions, keeping required fields and untranslated properties', () => {
for (const tool of REGISTERED_TOOLS) {
const translations = ENGLISH_TOOL_METADATA[tool.name]?.properties ?? {}
const { inputSchema } = getLocalizedToolMetadata(tool, 'en-US')
assert.deepEqual(inputSchema.required, tool.inputSchema.required, tool.name)
assert.deepEqual(Object.keys(inputSchema.properties), Object.keys(tool.inputSchema.properties), tool.name)
for (const [name, property] of Object.entries(tool.inputSchema.properties)) {
const expected = translations[name] ? { ...property, description: translations[name] } : property
assert.deepEqual(inputSchema.properties[name], expected, `${tool.name}.${name}`)
}
}
})
+184 -4
View File
@@ -2,11 +2,20 @@ import type { JsonSchema, ToolDefinition } from './types'
import { isChineseLocale } from './utils/format'
interface EnglishToolMetadata {
description: string
/** Omitted when the definition description is already English. */
description?: string
properties?: Record<string, string>
}
const ENGLISH_TOOL_METADATA: Record<string, EnglishToolMetadata> = {
/**
* English tool/parameter descriptions handed to the LLM on non-Chinese locales.
*
* Only translates text the definitions write in Chinese: tools whose definition description and
* parameter descriptions are already English have no entry here, and a tool with an English
* description but Chinese parameters lists only `properties`. Copying English text into this table
* would let the two copies drift apart (tool-metadata.test.ts enforces both rules).
*/
export const ENGLISH_TOOL_METADATA: Record<string, EnglishToolMetadata> = {
get_chat_overview: {
description:
'Get a chat overview including its name, platform, message and member counts, time range, and most active members.',
@@ -102,6 +111,176 @@ const ENGLISH_TOOL_METADATA: Record<string, EnglishToolMetadata> = {
max_rows: 'Maximum rows to return. Defaults to 1000 and is also capped by the execution context.',
},
},
get_segment_summaries: {
description:
'Get the list of segment summaries for a quick view of the topics discussed in the chat history. Topics that have been discussed can be searched by keyword.',
properties: {
keywords: 'Filter summary content by keywords.',
limit: 'Maximum number of segments to return. Defaults to 20.',
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
response_time_analysis: {
description:
'Analyze the response-speed ranking of chat members, based on the median and mean of their reply intervals.',
properties: {
days: 'Number of most recent days of data to analyze. Defaults to 30.',
top_n: 'Number of top-ranked members to return. Defaults to 10.',
},
},
keyword_frequency: {
description:
'Count the most frequent keywords in the chat, analyzing message content through NLP word segmentation.',
properties: {
days: 'Number of most recent days of data to analyze. Defaults to 30.',
top_n: 'Number of top frequent words to return. Defaults to 50.',
},
},
semantic_search_current_chat: {
properties: {
query:
'Semantic search query. Rewrite it into a natural-language description suited to searching the current chat history (centred on the facts/people/places/events/past mentions that need to be confirmed).',
max_results:
'Expected number of relevant excerpts to return; optional. When omitted, the default configured by the user is used; it may be raised according to question complexity, with a hard limit of 20.',
},
},
retrieve_chat_evidence: {
properties: {
query:
'The question or retrieval target that has to be confirmed with chat evidence, for example "how many times have we travelled to Leshan".',
criteria:
'Judgement criteria: what counts and what does not. Counting or judgement questions should fill this in, for example "counts: evidence of an actual trip/arrival/stay; does not count: plans only, travel guides, other people\'s experiences, general chatter".',
keywords:
'Keyword list, given explicitly by you (the tool does not expand synonyms). Used by the hybrid/keyword modes for exact recall.',
mode: 'auto (default)/hybrid (semantic + keyword)/semantic (semantic only)/keyword (keyword only). Counting historical facts usually uses hybrid.',
max_results: 'Expected number of excerpts returned by the semantic path, with a hard limit of 20.',
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
message_type_breakdown: {
description:
'Break the messages of the last N days down by message type (how many text, image, voice, sticker and other messages there are). Use this to understand communication-style preferences.',
properties: { days: 'Number of most recent days of data to count.' },
},
peak_chat_hours_by_member: {
description:
"Analyze the hourly distribution of a given member's messages over the last N days to find their most active hours. Obtain member_id from get_members before calling this tool.",
properties: {
member_id: 'Member ID obtained from get_members.',
days: 'Number of most recent days of data to count.',
},
},
member_activity_trend: {
description:
"View the trend of a given member's daily message counts over the last N days. Use this to observe whether someone has become more active or more silent. Obtain member_id from get_members before calling this tool.",
properties: {
member_id: 'Member ID obtained from get_members.',
days: 'Number of most recent days of trend to view.',
},
},
silent_members: {
description:
'Detect "silent members" who have not spoken for more than N days. Use this in community management to find users at risk of churn.',
properties: { days: 'Number of days without speaking that counts as silent.' },
},
reply_interaction_ranking: {
description:
'Analyze the ranking of reply interactions in the chat to find who replies to whom the most. Use this to discover the core interaction relationships and the opinion leaders in the community.',
properties: {
days: 'Number of most recent days of data to count.',
limit: 'Number of top interaction pairs to return.',
},
},
mutual_interaction_pairs: {
description:
'Find the member pairs that interact most frequently, based on the temporal proximity of two-way messages (one interaction is counted when the other party also speaks within 5 minutes of one party speaking). Use this to discover closely connected friend pairs.',
properties: {
days: 'Number of most recent days of data to count.',
limit: 'Number of top pairs to return.',
},
},
member_message_length_stats: {
description:
'Count the average message length of each member (text messages only); long messages usually mean more thoughtful communication. Use this to discover members who communicate in depth.',
properties: {
days: 'Number of most recent days of data to count.',
top_n: 'Number of top-ranked members to return.',
},
},
daily_active_members: {
description:
'Count the daily unique speakers (DAU) and message volume, used to observe how the vitality of the chat changes. Use this for questions such as "what is the chat activity trend" and "how many people have been talking recently".',
properties: { days: 'Number of most recent days of data to count.' },
},
conversation_initiator_stats: {
description:
"Count how many times each member starts a conversation (as the sender of the conversation's first message) to find who opens topics most often.",
properties: {
days: 'Number of most recent days of data to count.',
limit: 'Number of top-ranked members to return.',
},
},
activity_heatmap: {
description:
'Return a weekday × hour matrix of message counts, suitable for generating an activity heatmap. weekday: 0=Sunday, 1=Monday, ..., 6=Saturday.',
properties: { days: 'Number of most recent days of data to count.' },
},
unanswered_messages: {
description:
'Find messages from the last N days that were never replied to; these may be unresolved customer questions. Only text messages whose content is longer than 10 characters are counted (short greetings are filtered out).',
properties: {
days: 'Number of most recent days of data to search.',
limit: 'Maximum number of messages to return.',
},
},
list_sessions: {
description:
'List all available chat sessions, returning basic information such as session name, platform, and message count.',
properties: { keyword: 'Filter sessions by name (optional).' },
},
get_session_info: {
description:
'Get detailed information about the current session, including its name, platform, total message count, member count, and time range.',
properties: { include_members: 'Whether to include the member list (defaults to false).' },
},
search_messages_globally: {
properties: {
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
get_cross_chat_overview: {
properties: {
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
rank_private_contacts: {
properties: {
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
rank_group_sessions: {
properties: {
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
inspect_contact_sessions: {
properties: {
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
inspect_shared_interactions: {
properties: {
start_time: 'Start time in YYYY-MM-DD HH:mm format.',
end_time: 'End time in YYYY-MM-DD HH:mm format.',
},
},
}
function localizeInputSchema(schema: JsonSchema, properties?: Record<string, string>): JsonSchema {
@@ -117,15 +296,16 @@ function localizeInputSchema(schema: JsonSchema, properties?: Record<string, str
}
}
/** Reads only the locale-independent fields, so tools bound to any execution context can be localized. */
export function getLocalizedToolMetadata(
tool: ToolDefinition,
tool: Pick<ToolDefinition, 'name' | 'description' | 'inputSchema'>,
locale?: string
): Pick<ToolDefinition, 'description' | 'inputSchema'> {
if (isChineseLocale(locale)) return { description: tool.description, inputSchema: tool.inputSchema }
const metadata = ENGLISH_TOOL_METADATA[tool.name]
if (!metadata) return { description: tool.description, inputSchema: tool.inputSchema }
return {
description: metadata.description,
description: metadata.description ?? tool.description,
inputSchema: localizeInputSchema(tool.inputSchema, metadata.properties),
}
}