diff --git a/packages/services/service-ai/src/__tests__/agent-chat-quota.test.ts b/packages/services/service-ai/src/__tests__/agent-chat-quota.test.ts index 8cb40098cf..fdd253f95f 100644 --- a/packages/services/service-ai/src/__tests__/agent-chat-quota.test.ts +++ b/packages/services/service-ai/src/__tests__/agent-chat-quota.test.ts @@ -120,6 +120,7 @@ function mockAgentRuntime(): AgentRuntime { })), resolveActiveSkills: vi.fn(async () => []), buildSystemMessages: vi.fn(() => [{ role: 'system', content: 'sys' }]), + buildContextSchemaMessages: vi.fn(async () => []), buildRequestOptions: vi.fn(() => ({})), listAgents: vi.fn(async () => []), } as unknown as AgentRuntime; diff --git a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts index 39a9324b69..d622b0b244 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -639,6 +639,38 @@ describe('AgentRuntime', () => { }); }); + describe('buildContextSchemaMessages', () => { + it('injects the current object schema so "this object" resolves without a tool', async () => { + (metadataService.getObject as any).mockResolvedValue({ + name: 'showcase_task', + label: 'Task', + fields: { id: { type: 'text' }, subject: { type: 'text', label: 'Subject' } }, + }); + const messages = await runtime.buildContextSchemaMessages({ objectName: 'showcase_task' }); + expect(metadataService.getObject).toHaveBeenCalledWith('showcase_task'); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('system'); + expect(messages[0].content).toContain('currently viewing the "showcase_task" object'); + expect(messages[0].content).toContain('### showcase_task'); + expect(messages[0].content).toContain('subject: text'); + }); + + it('returns nothing when no object is in context', async () => { + expect(await runtime.buildContextSchemaMessages(undefined)).toEqual([]); + expect(await runtime.buildContextSchemaMessages({})).toEqual([]); + }); + + it('returns nothing when the object cannot be resolved', async () => { + (metadataService.getObject as any).mockResolvedValue(undefined); + expect(await runtime.buildContextSchemaMessages({ objectName: 'nope' })).toEqual([]); + }); + + it('degrades gracefully when metadata lookup throws', async () => { + (metadataService.getObject as any).mockRejectedValue(new Error('boom')); + expect(await runtime.buildContextSchemaMessages({ objectName: 'showcase_task' })).toEqual([]); + }); + }); + describe('buildRequestOptions', () => { it('should derive model config from agent', () => { const options = runtime.buildRequestOptions(DATA_CHAT_AGENT, []); diff --git a/packages/services/service-ai/src/__tests__/schema-retriever.test.ts b/packages/services/service-ai/src/__tests__/schema-retriever.test.ts index 35b9e4c3aa..28785c6661 100644 --- a/packages/services/service-ai/src/__tests__/schema-retriever.test.ts +++ b/packages/services/service-ai/src/__tests__/schema-retriever.test.ts @@ -68,6 +68,18 @@ describe('SchemaRetriever', () => { expect(hits).toEqual([]); }); + it('tokenises CJK queries so a Chinese label still matches', async () => { + const cjkTask: ObjectShape = { + name: 'showcase_task', + label: '任务', + pluralLabel: '任务', + fields: { id: { type: 'text' }, 标题: { type: 'text', label: '标题' } }, + }; + const r = new SchemaRetriever(mockMetadata([cjkTask, accountObject])); + const hits = await r.retrieve('帮我分析任务对象'); + expect(hits[0]?.object.name).toBe('showcase_task'); + }); + it('respects limit option', async () => { const r = new SchemaRetriever( mockMetadata([taskObject, accountObject, unrelatedObject]), diff --git a/packages/services/service-ai/src/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index 4eef869bf2..b2d2b0466c 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -9,6 +9,7 @@ import type { import type { Agent, Skill } from '@objectstack/spec/ai'; import { AgentSchema } from '@objectstack/spec/ai'; import { SkillRegistry, type SkillContext } from './skill-registry.js'; +import { SchemaRetriever, type ObjectShape } from './schema-retriever.js'; import { DEFAULT_DATA_AGENT_NAME } from './agents/index.js'; /** @@ -191,6 +192,49 @@ export class AgentRuntime { return [{ role: 'system' as const, content: parts.join('\n') }]; } + /** + * Build an extra system message carrying the schema of the object the user + * is currently viewing ({@link AgentChatContext.objectName}). + * + * `buildSystemMessages` only states the object's *name*; it can't fetch the + * schema because it is synchronous. This async companion looks the object up + * and renders a compact field snippet so the agent can answer "analyse / + * describe this object" questions with ZERO tool calls — important on the + * open-framework deployment where `describe_object` isn't registered, and + * for non-English prompts that the keyword-based {@link SchemaRetriever} + * can't otherwise resolve to an English-named object. + * + * Returns an empty array when there is no current object or it can't be + * resolved — callers append the result, so a miss simply injects nothing. + */ + async buildContextSchemaMessages(context?: AgentChatContext): Promise { + const objectName = context?.objectName; + if (!objectName) return []; + + let raw: unknown; + try { + raw = await this.metadataService.getObject(objectName); + } catch { + return []; + } + if (!raw || typeof raw !== 'object' || !(raw as ObjectShape).name) return []; + + const snippet = SchemaRetriever.renderSnippet([{ object: raw as ObjectShape, score: 1 }]); + if (!snippet) return []; + + return [ + { + role: 'system' as const, + content: + `The user is currently viewing the "${objectName}" object. When they refer to ` + + '"this object", "the current object", or its label without naming another one, ' + + `assume they mean "${objectName}". Use the schema below to answer questions about ` + + 'its structure directly, and as the target object name for data-query tools.\n\n' + + snippet, + }, + ]; + } + /** * Derive {@link AIRequestOptions} from an agent definition. * diff --git a/packages/services/service-ai/src/routes/agent-routes.ts b/packages/services/service-ai/src/routes/agent-routes.ts index c725e11655..a3d6fd52c1 100644 --- a/packages/services/service-ai/src/routes/agent-routes.ts +++ b/packages/services/service-ai/src/routes/agent-routes.ts @@ -189,6 +189,11 @@ export function buildAgentRoutes( // Build system messages from agent instructions + UI context + skills const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext, activeSkills); + // Inject the schema of the object the user is currently viewing so + // "analyse / describe this object" works without a lookup tool and + // regardless of the prompt's language. + systemMessages.push(...(await agentRuntime.buildContextSchemaMessages(chatContext))); + // Resolve agent model/tools + skill tools → request options const agentOptions = agentRuntime.buildRequestOptions( agent, @@ -234,6 +239,13 @@ export function buildAgentRoutes( typeof chatContext?.environmentId === 'string' ? chatContext.environmentId : undefined, + // The object/view the user has open — lets built-in data + // tools fall back to "this object" when the request doesn't + // name one (ADR-aligned with the schema injection above). + currentObjectName: + typeof chatContext?.objectName === 'string' ? chatContext.objectName : undefined, + currentViewName: + typeof chatContext?.viewName === 'string' ? chatContext.viewName : undefined, } : undefined, }; diff --git a/packages/services/service-ai/src/routes/assistant-routes.ts b/packages/services/service-ai/src/routes/assistant-routes.ts index d3230a085b..f3956f411d 100644 --- a/packages/services/service-ai/src/routes/assistant-routes.ts +++ b/packages/services/service-ai/src/routes/assistant-routes.ts @@ -197,6 +197,9 @@ export function buildAssistantRoutes( } const systemMessages = agentRuntime.buildSystemMessages(agent, context, activeSkills); + // Inject the current object's schema so "describe this object" works + // without a lookup tool and regardless of prompt language. + systemMessages.push(...(await agentRuntime.buildContextSchemaMessages(context))); const agentOptions = agentRuntime.buildRequestOptions( agent, aiService.toolRegistry.getAll(), @@ -237,6 +240,10 @@ export function buildAssistantRoutes( typeof body.conversationId === 'string' ? body.conversationId : undefined, environmentId: typeof context.environmentId === 'string' ? context.environmentId : undefined, + currentObjectName: + typeof context.objectName === 'string' ? context.objectName : undefined, + currentViewName: + typeof context.viewName === 'string' ? context.viewName : undefined, } : undefined, }; diff --git a/packages/services/service-ai/src/schema-retriever.ts b/packages/services/service-ai/src/schema-retriever.ts index 348bdeeeaa..379934a549 100644 --- a/packages/services/service-ai/src/schema-retriever.ts +++ b/packages/services/service-ai/src/schema-retriever.ts @@ -181,12 +181,33 @@ export interface FieldShape { // ── internal helpers ────────────────────────────────────────────── -/** Lower-case alphanumeric tokens of length ≥ 2 (English stop-words excluded). */ +/** + * Tokenise a query into match terms. + * + * Latin/digit runs split on any non-alphanumeric (including underscores) so + * `todo_task` tokenises to ['todo', 'task'] and matches snake_case names. + * + * CJK text carries no word boundaries, so a `[a-z0-9]+` scan drops it entirely + * — a question like "分析任务对象" would yield zero terms and surface a + * misleading "no matching objects". To keep CJK queries scoreable against + * CJK object/field labels, every ideograph is emitted as a single-char term + * plus each adjacent bigram (so "任务" matches a label containing "任务"). + */ function tokenise(query: string): string[] { - // Split on any non-alphanumeric (including underscores) so `todo_task` - // tokenises to ['todo', 'task'] and matches snake_case object names. - const raw = query.toLowerCase().match(/[a-z0-9]+/g) ?? []; - return raw.filter(t => t.length >= 2 && !STOPWORDS.has(t)); + const lower = query.toLowerCase(); + const latin = (lower.match(/[a-z0-9]+/g) ?? []).filter( + t => t.length >= 2 && !STOPWORDS.has(t), + ); + const tokens = [...latin]; + // CJK Unified Ideographs (+ Ext-A, compatibility) and Japanese kana. + const cjkRuns = lower.match(/[぀-ヿ㐀-䶿一-鿿豈-﫿]+/g) ?? []; + for (const run of cjkRuns) { + for (let i = 0; i < run.length; i++) { + tokens.push(run[i]); + if (i + 1 < run.length) tokens.push(run.slice(i, i + 2)); + } + } + return tokens; } const STOPWORDS = new Set([ diff --git a/packages/services/service-ai/src/tools/query-data.tool.test.ts b/packages/services/service-ai/src/tools/query-data.tool.test.ts index 24598a1119..085aa5c8c7 100644 --- a/packages/services/service-ai/src/tools/query-data.tool.test.ts +++ b/packages/services/service-ai/src/tools/query-data.tool.test.ts @@ -95,6 +95,61 @@ describe('query_data — federated query timeout (P4)', () => { expect(out.records[0].id).toBe('o1'); }); + it('falls back to the current object when keyword retrieval finds nothing', async () => { + const currentObject = { + name: 'showcase_task', + label: 'Task', + fields: { id: { type: 'text' }, subject: { type: 'text' } }, + }; + let findObject: string | undefined; + const ctx: QueryDataToolContext = { + ai: { + generateObject: async () => ({ + object: { + objectName: 'showcase_task', + whereJson: null, + fields: null, + orderBy: null, + limit: null, + } satisfies QueryPlan, + }), + } as never, + metadata: { + // Catalogue is non-empty, but a Chinese request tokenises to terms + // that don't match the English name/label → zero keyword hits. + listObjects: async () => [currentObject], + getObject: async (name: string) => (name === 'showcase_task' ? currentObject : undefined), + } as never, + dataEngine: { + find: async (objectName: string) => { + findObject = objectName; + return [{ id: 't1', subject: 'Build the thing' }]; + }, + } as never, + }; + const handler = createQueryDataHandler(ctx); + const out = JSON.parse( + (await handler({ request: '分析这个对象的数据' }, { currentObjectName: 'showcase_task' } as never)) as string, + ); + expect(out.error).toBeUndefined(); + expect(findObject).toBe('showcase_task'); + expect(out.count).toBe(1); + }); + + it('still errors when retrieval is empty and no current object is supplied', async () => { + const ctx: QueryDataToolContext = { + ai: { generateObject: async () => ({ object: {} as QueryPlan }) } as never, + metadata: { + listObjects: async () => [{ name: 'account', label: 'Account', fields: {} }], + getObject: async () => undefined, + } as never, + dataEngine: { find: async () => [] } as never, + }; + const handler = createQueryDataHandler(ctx); + const out = JSON.parse((await handler({ request: '分析这个对象' })) as string); + expect(out.error).toMatch(/No matching objects in metadata/); + }); + it('does not wrap managed (non-external) objects in a timeout', async () => { const managedObject = { name: 'task', diff --git a/packages/services/service-ai/src/tools/query-data.tool.ts b/packages/services/service-ai/src/tools/query-data.tool.ts index 62ebb2053a..270b8dfccb 100644 --- a/packages/services/service-ai/src/tools/query-data.tool.ts +++ b/packages/services/service-ai/src/tools/query-data.tool.ts @@ -9,7 +9,7 @@ import type { ModelMessage, } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import { SchemaRetriever } from '../schema-retriever.js'; +import { SchemaRetriever, type SchemaHit } from '../schema-retriever.js'; import type { ToolHandler, ToolRegistry, ToolExecutionContext } from './tool-registry.js'; /** See `data-tools.ts#buildEngineContext` — duplicated here to keep @@ -161,6 +161,35 @@ export function createQueryDataHandler(ctx: QueryDataToolContext): ToolHandler { const maxLimit = ctx.maxLimit ?? 100; const externalTimeoutFallback = ctx.externalQueryTimeoutMs ?? 30_000; + /** Load a single object definition by exact name, mirroring the dual-source + * lookup (MetadataManager → ObjectQL SchemaRegistry via protocol) so the + * current-object fallback can see system objects too. Never throws. */ + const loadObjectByName = async (name: string): Promise => { + try { + const direct = await ctx.metadata.getObject?.(name); + if (direct && typeof direct === 'object' && (direct as { name?: string }).name) { + return direct as SchemaHit['object']; + } + } catch { + // fall through to protocol enumeration + } + if (ctx.protocol?.getMetaItems) { + try { + const all = await ctx.protocol.getMetaItems({ type: 'object' }); + const arr = Array.isArray(all) + ? all + : (all && typeof all === 'object' && Array.isArray((all as { items?: unknown }).items) + ? (all as { items: unknown[] }).items + : []); + const found = (arr as Array<{ name?: string }>).find(o => o?.name === name); + if (found) return found as SchemaHit['object']; + } catch { + // ignore — caller treats undefined as "not found" + } + } + return undefined; + }; + /** Resolve a federated object's per-query timeout (datasource-declared, * else the tool fallback). Never throws — degrades to the fallback. */ const resolveExternalTimeout = async (datasource: string): Promise => { @@ -190,7 +219,14 @@ export function createQueryDataHandler(ctx: QueryDataToolContext): ToolHandler { } // 1. Schema retrieval - const hits = await retriever.retrieve(request); + let hits = await retriever.retrieve(request); + // Fallback: when keyword retrieval finds nothing (e.g. a non-English + // request, or one that says "this object" without naming it), target the + // object the user is currently viewing if the UI supplied one. + if (hits.length === 0 && execCtx?.currentObjectName) { + const current = await loadObjectByName(execCtx.currentObjectName); + if (current) hits = [{ object: current, score: 1 }]; + } if (hits.length === 0) { return JSON.stringify({ error: diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index 038876c678..e85c14cef4 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -402,6 +402,16 @@ export interface ToolExecutionContext { messageId?: string; /** Active environment (multi-tenant project) id, if known. */ environmentId?: string; + /** + * Object the user is currently viewing in the UI (e.g. the list/detail + * page they have open). Built-in data tools use this as a fallback target + * when the user refers to "this object" / "the current object" and the + * free-text request doesn't name one explicitly — so a question phrased in + * any language still resolves to the right object without a keyword match. + */ + currentObjectName?: string; + /** View the user is currently viewing, if known. */ + currentViewName?: string; /** Distributed-trace id for cross-service correlation. */ traceId?: string; /**