Skip to content

Commit e062b95

Browse files
os-zhuangclaude
andauthored
refactor(service-ai): move the in-UI ask Q&A agent out to the cloud package (#2298)
The data-Q&A intelligence — the `ask` agent persona plus its data-explorer / actions-executor skills — is a commercial feature (ADR-0002 "open the mechanism, close the intelligence"). Extract it from the open-source @objectstack/service-ai so the framework AI runtime is now HEADLESS: it registers no built-in agent, skills, or tools. The persona attaches in the cloud-only @objectstack/service-ai-studio package via the `ai:ready` hook — exactly how the sibling `build` agent already attaches. What MOVES to cloud (intelligence): - agents/ask-agent.ts (ASK_AGENT) - skills/data-explorer-skill.ts (DATA_EXPLORER_SKILL) - skills/actions-executor-skill.ts (ACTIONS_EXECUTOR_SKILL) What STAYS open here (mechanism the cloud persona imports + registers against): - tools/data-tools.ts, query-data.tool.ts, visualize-data.tool.ts, action-tools.ts (the data tools) - skills/schema-reader-skill.ts (SCHEMA_READER_SKILL, surface:'both', shared with `build`) - agents/agent-aliases.ts — the alias REGISTRY + the ASK_AGENT_NAME / LEGACY_DATA_AGENT_NAME string constants (relocated here from the deleted ask-agent.ts so the framework keeps exporting them without the persona), agent-runtime.ts, skill-registry.ts, all routes/* plugin.ts: the data-engine-gated built-in registration block (data tools + ask agent + the three skills + the legacy data_chat cleanup) is removed and replaced with a NOTE; the now-dead protocolService resolution + toToolLabel helper + unused IAnalyticsService/IAutomationService imports are dropped. Tests: agent-aliases.test.ts pulls the name constants from agent-aliases.js and uses a local stub for the loadAgent path; chatbot-features.test.ts keeps the generic runtime/route MECHANISM tests against local persona stubs and the persona-as-subject specs (ASK_AGENT/affinity) move with the agent to cloud. @objectstack/mcp is byte-unchanged. Build (esm+cjs+dts) green; 401/401 tests pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 874fb66 commit e062b95

10 files changed

Lines changed: 138 additions & 642 deletions

File tree

packages/services/service-ai/src/__tests__/agent-aliases.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,26 @@
99
import { describe, it, expect, vi } from 'vitest';
1010
import type { IMetadataService } from '@objectstack/spec/contracts';
1111
import { AgentRuntime } from '../agent-runtime.js';
12-
import { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js';
13-
import { registerAgentAlias, resolveAgentAlias } from '../agents/agent-aliases.js';
12+
import {
13+
ASK_AGENT_NAME,
14+
LEGACY_DATA_AGENT_NAME,
15+
registerAgentAlias,
16+
resolveAgentAlias,
17+
} from '../agents/agent-aliases.js';
18+
import type { Agent } from '@objectstack/spec/ai';
19+
20+
// The real `ask` PERSONA moved to the cloud-only @objectstack/service-ai-studio
21+
// package; the framework now exports only the NAME CONSTANTS + the alias
22+
// registry (the mechanism). This minimal local stub stands in for the persona so
23+
// the alias-aware `AgentRuntime.loadAgent` resolution is still exercised here.
24+
const ASK_AGENT_STUB: Agent = {
25+
name: ASK_AGENT_NAME,
26+
label: 'Assistant',
27+
role: 'Business Application Assistant',
28+
instructions: 'Stub ask persona for alias resolution tests.',
29+
active: true,
30+
visibility: 'global',
31+
};
1432

1533
function mockMetadata(overrides: Partial<IMetadataService> = {}): IMetadataService {
1634
return {
@@ -66,7 +84,7 @@ describe('agent-aliases', () => {
6684
describe('AgentRuntime.loadAgent (alias-aware)', () => {
6785
it('resolves a legacy name to the renamed agent record', async () => {
6886
const get = vi.fn(async (_type: string, name: string) =>
69-
name === ASK_AGENT_NAME ? ASK_AGENT : undefined,
87+
name === ASK_AGENT_NAME ? ASK_AGENT_STUB : undefined,
7088
);
7189
const runtime = new AgentRuntime(mockMetadata({ get: get as never }));
7290

packages/services/service-ai/src/__tests__/chatbot-features.test.ts

Lines changed: 52 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,42 @@ import { AgentRuntime } from '../agent-runtime.js';
1919
import { SkillRegistry } from '../skill-registry.js';
2020
import type { AgentChatContext } from '../agent-runtime.js';
2121
import { buildAgentRoutes } from '../routes/agent-routes.js';
22-
import { ASK_AGENT } from '../agents/ask-agent.js';
2322
import { registerAgentAlias } from '../agents/agent-aliases.js';
23+
import type { Agent } from '@objectstack/spec/ai';
24+
25+
// BOTH platform PERSONAS moved to the cloud-only @objectstack/service-ai-studio
26+
// package (the `ask` data product + the `build` authoring agent are commercial
27+
// features). The open-source AI runtime is headless. These local stubs stand in
28+
// as the two PLATFORM agents for the runtime/route agent-listing tests below;
29+
// they satisfy AgentSchema and carry just enough persona text for the generic
30+
// `buildSystemMessages` assertions. The persona-as-subject specs (skill bundles,
31+
// instruction wording, surface affinity) live with the agents in the cloud
32+
// package now (see metadata-assistant-agent.test.ts / ask-agent.test.ts there).
33+
//
34+
// The `ask` stub's instructions intentionally include the "assistant for this
35+
// business application platform" / "do NOT build" / "Builder" phrases the
36+
// runtime tests assert on, so those tests still exercise buildSystemMessages
37+
// without depending on the moved persona.
38+
const ASK_AGENT: Agent = {
39+
name: 'ask',
40+
label: 'Assistant',
41+
role: 'Business Application Assistant',
42+
surface: 'ask',
43+
instructions:
44+
'You are the assistant for this business application platform. You help the ' +
45+
'user explore their data. You do NOT build or change the application itself; ' +
46+
'app-building lives in the Builder (the separate "build" experience).',
47+
model: { provider: 'openai', model: 'gpt-4', temperature: 0.2, maxTokens: 4096 },
48+
skills: ['schema_reader', 'data_explorer', 'actions_executor'],
49+
active: true,
50+
visibility: 'global',
51+
guardrails: { maxTokensPerInvocation: 8192, maxExecutionTimeSec: 30, blockedTopics: ['raw_sql'] },
52+
planning: { strategy: 'react', maxIterations: 10, allowReplan: true },
53+
} as any;
2454

25-
// The real `build` agent moved to the cloud-only @objectstack/service-ai-studio
26-
// package (AI authoring is a commercial feature). This local stub stands in as
27-
// the second PLATFORM agent for the runtime/route agent-listing tests below. It
28-
// derives from the `ask` agent so it satisfies AgentSchema, then overrides
29-
// identity fields. Registering the `metadata_assistant`→`build` alias (as the
30-
// cloud plugin does at init) makes `build` a recognised platform agent so the
31-
// runtime catalog surfaces it (ADR-0063 §2).
55+
// Registering the `metadata_assistant`→`build` alias (as the cloud plugin does
56+
// at init) makes `build` a recognised platform agent so the runtime catalog
57+
// surfaces it (ADR-0063 §2).
3258
registerAgentAlias('metadata_assistant', 'build');
3359
const BUILD_AGENT = {
3460
...ASK_AGENT,
@@ -639,12 +665,6 @@ describe('AgentRuntime', () => {
639665
expect(messages[0].content).not.toContain('Capabilities in this deployment');
640666
});
641667

642-
it('the ask persona itself declines app-building and points at the Builder', () => {
643-
// Affinity is carried by the persona, not a runtime gate.
644-
expect(ASK_AGENT.instructions).toContain('do NOT build');
645-
expect(ASK_AGENT.instructions.toLowerCase()).toContain('builder');
646-
expect(ASK_AGENT.surface).toBe('ask');
647-
});
648668
});
649669

650670
describe('buildContextSchemaMessages', () => {
@@ -694,11 +714,19 @@ describe('AgentRuntime', () => {
694714
{ name: 'unrelated_tool', description: 'Not in any skill', parameters: {} },
695715
];
696716

697-
// ASK_AGENT's tool set is the union of its skills' claimed tools:
698-
// schema_reader owns list_objects, data_explorer owns query_records.
699-
const { DATA_EXPLORER_SKILL } = await import('../skills/data-explorer-skill.js');
717+
// Mechanism test: the resolved tool set is the union of the active skills'
718+
// claimed tools. `schema_reader` stays open in this package; `data_explorer`
719+
// moved to the cloud studio package, so we stub it inline here — the runtime
720+
// resolver doesn't care WHERE a skill is defined, only what tools it claims.
700721
const { SCHEMA_READER_SKILL } = await import('../skills/schema-reader-skill.js');
701-
const options = runtime.buildRequestOptions(ASK_AGENT, availableTools, [SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL]);
722+
const dataExplorerStub = {
723+
name: 'data_explorer',
724+
label: 'Data Explorer',
725+
surface: 'ask',
726+
tools: ['query_records', 'get_record', 'aggregate_data', 'visualize_data'],
727+
active: true,
728+
} as never;
729+
const options = runtime.buildRequestOptions(ASK_AGENT, availableTools, [SCHEMA_READER_SKILL, dataExplorerStub]);
702730

703731
const resolvedNames = options.tools?.map(t => t.name) ?? [];
704732
expect(resolvedNames).toContain('list_objects');
@@ -1085,133 +1113,9 @@ describe('Agent Routes', () => {
10851113
});
10861114
});
10871115

1088-
// ═══════════════════════════════════════════════════════════════════
1089-
// Data Chat Agent Spec
1090-
// ═══════════════════════════════════════════════════════════════════
1091-
1092-
describe('ASK_AGENT', () => {
1093-
it('should be a valid agent definition', () => {
1094-
// Path A rename: canonical id is now `ask` (was `data_chat`).
1095-
expect(ASK_AGENT.name).toBe('ask');
1096-
expect(ASK_AGENT.role).toBe('Business Application Assistant');
1097-
expect(ASK_AGENT.active).toBe(true);
1098-
expect(ASK_AGENT.visibility).toBe('global');
1099-
});
1100-
1101-
it('should bind only ask-surface skills — no authoring skills (ADR-0063)', () => {
1102-
expect(ASK_AGENT.tools ?? []).toHaveLength(0);
1103-
// schema_reader (both) + data_explorer/actions_executor (ask). The build
1104-
// skills (metadata_authoring/solution_design) are NOT here — they live on
1105-
// the cloud `build` agent.
1106-
expect(ASK_AGENT.skills).toEqual(['schema_reader', 'data_explorer', 'actions_executor']);
1107-
expect(ASK_AGENT.skills).not.toContain('metadata_authoring');
1108-
expect(ASK_AGENT.skills).not.toContain('solution_design');
1109-
});
1110-
1111-
it('declares surface "ask"', () => {
1112-
expect(ASK_AGENT.surface).toBe('ask');
1113-
});
1114-
1115-
it('should have guardrails configured', () => {
1116-
expect(ASK_AGENT.guardrails).toBeDefined();
1117-
expect(ASK_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0);
1118-
expect(ASK_AGENT.guardrails!.blockedTopics).toBeDefined();
1119-
});
1120-
1121-
it('should have model config', () => {
1122-
expect(ASK_AGENT.model).toBeDefined();
1123-
expect(ASK_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries
1124-
});
1125-
});
1126-
1127-
// ═══════════════════════════════════════════════════════════════════
1128-
// ADR-0063 §3 / ADR-0064 — surface affinity & tool scoping
1129-
// ═══════════════════════════════════════════════════════════════════
1130-
1131-
describe('surface affinity & tool scoping (ADR-0063/0064)', () => {
1132-
it('tools(ask) excludes every authoring tool — ask cannot author by construction', async () => {
1133-
const { SCHEMA_READER_SKILL } = await import('../skills/schema-reader-skill.js');
1134-
const { DATA_EXPLORER_SKILL } = await import('../skills/data-explorer-skill.js');
1135-
const { ACTIONS_EXECUTOR_SKILL } = await import('../skills/actions-executor-skill.js');
1136-
const askSkills = [SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL];
1137-
1138-
const availableTools: AIToolDefinition[] = [
1139-
// shared reads + ask tools
1140-
{ name: 'query_data', description: '', parameters: {} },
1141-
{ name: 'describe_object', description: '', parameters: {} },
1142-
{ name: 'list_objects', description: '', parameters: {} },
1143-
{ name: 'query_records', description: '', parameters: {} },
1144-
{ name: 'visualize_data', description: '', parameters: {} },
1145-
{ name: 'action_complete_task', description: '', parameters: {} },
1146-
// authoring tools that MUST NOT leak into the ask agent
1147-
{ name: 'create_metadata', description: '', parameters: {} },
1148-
{ name: 'update_metadata', description: '', parameters: {} },
1149-
{ name: 'add_field', description: '', parameters: {} },
1150-
{ name: 'apply_blueprint', description: '', parameters: {} },
1151-
];
1152-
1153-
const registry = new SkillRegistry(createMockMetadataService());
1154-
const tools = registry.flattenToTools(askSkills, availableTools).map((t) => t.name);
1155-
1156-
// shared/ask tools present
1157-
expect(tools).toContain('describe_object');
1158-
expect(tools).toContain('query_data');
1159-
expect(tools).toContain('query_records');
1160-
expect(tools).toContain('action_complete_task');
1161-
// no create_* / *_metadata / blueprint tools (issue acceptance criterion)
1162-
for (const t of ['create_metadata', 'update_metadata', 'add_field', 'apply_blueprint']) {
1163-
expect(tools).not.toContain(t);
1164-
}
1165-
expect(
1166-
tools.some((n) => n.startsWith('create_') || /_metadata$/.test(n) || n.includes('blueprint')),
1167-
).toBe(false);
1168-
});
1169-
1170-
it('binding a surface:build skill to the ask agent is a fast load error (ADR-0064 §3)', async () => {
1171-
const md = createMockMetadataService({
1172-
list: vi.fn(async (type: string) =>
1173-
type === 'skill'
1174-
? [
1175-
{
1176-
name: 'metadata_authoring',
1177-
label: 'Metadata Authoring',
1178-
surface: 'build',
1179-
tools: ['create_metadata'],
1180-
active: true,
1181-
},
1182-
]
1183-
: [],
1184-
) as any,
1185-
});
1186-
const runtime = new AgentRuntime(md, new SkillRegistry(md));
1187-
const askAgent = { ...ASK_AGENT, skills: ['metadata_authoring'] } as any;
1188-
await expect(runtime.resolveActiveSkills(askAgent)).rejects.toThrow(
1189-
/incompatible affinity|cannot bind/,
1190-
);
1191-
});
1192-
1193-
it('a surface:both skill binds to the ask agent without error', async () => {
1194-
const md = createMockMetadataService({
1195-
list: vi.fn(async (type: string) =>
1196-
type === 'skill'
1197-
? [
1198-
{
1199-
name: 'schema_reader',
1200-
label: 'Schema Reader',
1201-
surface: 'both',
1202-
tools: ['describe_object'],
1203-
active: true,
1204-
},
1205-
]
1206-
: [],
1207-
) as any,
1208-
});
1209-
const runtime = new AgentRuntime(md, new SkillRegistry(md));
1210-
const askAgent = { ...ASK_AGENT, skills: ['schema_reader'] } as any;
1211-
const skills = await runtime.resolveActiveSkills(askAgent);
1212-
expect(skills.map((s) => s.name)).toContain('schema_reader');
1213-
});
1214-
});
1215-
1216-
// NOTE: the BUILD_AGENT spec tests moved with the agent to the
1217-
// cloud-only @objectstack/service-ai-studio package (metadata-assistant-agent.test.ts).
1116+
// NOTE: the persona-as-subject specs — the `ASK_AGENT` definition spec, the
1117+
// `BUILD_AGENT` spec, and the ADR-0063/0064 surface-affinity & tool-scoping
1118+
// tests — moved WITH the agents/skills to the cloud-only
1119+
// @objectstack/service-ai-studio package (see ask-agent.test.ts /
1120+
// metadata-assistant-agent.test.ts there). What remains here is the generic AI
1121+
// runtime / route mechanism, exercised against local persona STUBS.

packages/services/service-ai/src/agents/agent-aliases.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@
3333
* Studio had "registered" the alias. Anchoring the Map (and its seed) on a
3434
* `Symbol.for` key makes the two builds share ONE table.
3535
*/
36+
/**
37+
* Canonical name of the platform's `ask` (data) agent.
38+
*
39+
* This is the implicit default copilot for every application that does not pin
40+
* its own `app.defaultAgent`. The `ASK_AGENT` persona itself is a commercial
41+
* feature and now ships in the cloud-only `@objectstack/service-ai-studio`
42+
* package (it attaches via the `ai:ready` hook); the NAME stays here in the
43+
* open framework so the runtime can resolve the default/fallback deterministically
44+
* and so the legacy alias below has a canonical target even on a headless OSS
45+
* runtime where the persona is absent.
46+
*
47+
* Renamed from `data_chat`→`ask`; the legacy name stays resolvable via the
48+
* alias table seeded below.
49+
*/
50+
export const ASK_AGENT_NAME = 'ask';
51+
52+
/** Legacy id the data agent was renamed from (kept for back-compat / migrations). */
53+
export const LEGACY_DATA_AGENT_NAME = 'data_chat';
54+
3655
const ALIAS_REGISTRY_KEY: unique symbol = Symbol.for('@objectstack/service-ai#agentNameAliases');
3756

3857
/** The single process-wide alias table, created (and seeded) on first touch. */
@@ -41,7 +60,7 @@ function aliasRegistry(): Map<string, string> {
4160
let map = g[ALIAS_REGISTRY_KEY];
4261
if (!map) {
4362
// Seed the framework's own data agent rename on first access.
44-
map = new Map<string, string>([['data_chat', 'ask']]);
63+
map = new Map<string, string>([[LEGACY_DATA_AGENT_NAME, ASK_AGENT_NAME]]);
4564
g[ALIAS_REGISTRY_KEY] = map;
4665
}
4766
return map;

0 commit comments

Comments
 (0)