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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
44 changes: 44 additions & 0 deletions packages/services/service-ai/src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<ModelMessage[]> {
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.
*
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-ai/src/routes/agent-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down
7 changes: 7 additions & 0 deletions packages/services/service-ai/src/routes/assistant-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
};
Expand Down
31 changes: 26 additions & 5 deletions packages/services/service-ai/src/schema-retriever.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
55 changes: 55 additions & 0 deletions packages/services/service-ai/src/tools/query-data.tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 38 additions & 2 deletions packages/services/service-ai/src/tools/query-data.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SchemaHit['object'] | undefined> => {
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<number> => {
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/src/contracts/ai-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down