Skip to content

Commit be07ce7

Browse files
xuyushun441-syshotlongclaude
authored
feat(service-ai)!: rename data agent data_chat → ask (Path A) with back-compat alias (#2144)
* feat(service-ai)!: rename data agent data_chat -> ask (Path A) with alias The platform data agent's identifier is renamed so the friendly console URL equals the real name (/ai/ask). Back-compat is preserved: - DEFAULT_DATA_AGENT_NAME = 'ask' (was 'data_chat'); LEGACY_DATA_AGENT_NAME kept for migrations/diagnostics. - New process-wide alias registry (agent-aliases.ts): seeds data_chat->ask; AgentRuntime.loadAgent normalizes a requested name through it, so /agents/data_chat/chat and persisted agent_id='data_chat' keep resolving. Exposes registerAgentAlias() so other packages register their OWN renames (cloud AI Studio: metadata_assistant->build) — keeping the two renames decoupled and independently safe; no alias points at an unregistered id. - Aliases are resolution-only (not records), so GET /ai/agents still lists each agent once. On upgrade, the plugin prunes the stale 'data_chat' registry entry so the catalog isn't doubled. Unit-tested: alias table + loadAgent legacy resolution (5 cases). The objectui console already resolves both legacy and renamed catalogs (its agentAliases layer is verified live), so no client change is required. BREAKING CHANGE: the built-in data agent's canonical name is now 'ask'. The legacy 'data_chat' name remains accepted via the alias table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: changeset for data_chat->ask rename Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: remove accidentally-committed node_modules symlinks git add -A swept in local worktree node_modules symlinks (the repo .gitignore matches node_modules/ as a dir, not a symlink), which broke CI install (ENOTDIR). Untracked; functionally inert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(service-ai): update agent-name assertions for data_chat->ask rename chatbot-features: catalog/loadAgent/agent-spec now assert canonical 'ask'; legacy '/agents/data_chat/chat' POST tests kept as back-compat (resolve via alias). 83 AI tests pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci: re-trigger checks Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <zhuangjianguo@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 30c0313 commit be07ce7

9 files changed

Lines changed: 173 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/service-ai": minor
3+
---
4+
5+
Rename the built-in data agent `data_chat``ask` (Path A: friendly console URL == real id). Back-compat preserved via a new process-wide alias registry: `AgentRuntime.loadAgent` normalizes legacy names, so `/agents/data_chat/chat` and persisted `agent_id='data_chat'` keep resolving. `registerAgentAlias()` is exported so other packages register their own renames (cloud AI Studio: `metadata_assistant``build`). The plugin prunes the stale legacy agent record on upgrade so the catalog isn't doubled.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Back-compat for the Path A agent rename (`data_chat`→`ask`, and cloud's
5+
* `metadata_assistant`→`build` registered via the public registry). Verifies
6+
* the alias table and that AgentRuntime.loadAgent normalizes a legacy name to
7+
* its canonical record so old `/agents/:name/chat` links keep resolving.
8+
*/
9+
import { describe, it, expect, vi } from 'vitest';
10+
import type { IMetadataService } from '@objectstack/spec/contracts';
11+
import { AgentRuntime } from '../agent-runtime.js';
12+
import { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js';
13+
import { registerAgentAlias, resolveAgentAlias } from '../agents/agent-aliases.js';
14+
15+
function mockMetadata(overrides: Partial<IMetadataService> = {}): IMetadataService {
16+
return {
17+
register: vi.fn(async () => {}),
18+
get: vi.fn(async () => undefined),
19+
list: vi.fn(async () => []),
20+
unregister: vi.fn(async () => {}),
21+
exists: vi.fn(async () => false),
22+
listNames: vi.fn(async () => []),
23+
getObject: vi.fn(async () => undefined),
24+
listObjects: vi.fn(async () => []),
25+
...overrides,
26+
} as unknown as IMetadataService;
27+
}
28+
29+
describe('agent-aliases', () => {
30+
it('seeds the framework data-agent rename', () => {
31+
expect(DEFAULT_DATA_AGENT_NAME).toBe('ask');
32+
expect(LEGACY_DATA_AGENT_NAME).toBe('data_chat');
33+
expect(resolveAgentAlias('data_chat')).toBe('ask');
34+
});
35+
36+
it('passes unknown / canonical names through unchanged', () => {
37+
expect(resolveAgentAlias('ask')).toBe('ask');
38+
expect(resolveAgentAlias('sales_assistant')).toBe('sales_assistant');
39+
});
40+
41+
it('lets another package register its own rename (e.g. cloud build agent)', () => {
42+
registerAgentAlias('metadata_assistant', 'build');
43+
expect(resolveAgentAlias('metadata_assistant')).toBe('build');
44+
// No-ops that must not corrupt the table.
45+
registerAgentAlias('', 'x');
46+
registerAgentAlias('same', 'same');
47+
expect(resolveAgentAlias('same')).toBe('same');
48+
});
49+
});
50+
51+
describe('AgentRuntime.loadAgent (alias-aware)', () => {
52+
it('resolves a legacy name to the renamed agent record', async () => {
53+
const get = vi.fn(async (_type: string, name: string) =>
54+
name === DEFAULT_DATA_AGENT_NAME ? DATA_CHAT_AGENT : undefined,
55+
);
56+
const runtime = new AgentRuntime(mockMetadata({ get: get as never }));
57+
58+
const viaLegacy = await runtime.loadAgent('data_chat');
59+
expect(viaLegacy?.name).toBe('ask');
60+
expect(get).toHaveBeenCalledWith('agent', 'ask');
61+
62+
const viaCanonical = await runtime.loadAgent('ask');
63+
expect(viaCanonical?.name).toBe('ask');
64+
});
65+
66+
it('returns undefined for a genuinely unknown agent', async () => {
67+
const runtime = new AgentRuntime(mockMetadata());
68+
expect(await runtime.loadAgent('nope')).toBeUndefined();
69+
});
70+
});

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,13 @@ describe('AgentRuntime', () => {
560560
});
561561

562562
describe('loadAgent', () => {
563-
it('should return agent definition from metadata service', async () => {
563+
it('should return agent definition from metadata service (legacy name resolves via alias)', async () => {
564564
(metadataService.get as any).mockResolvedValue(DATA_CHAT_AGENT);
565565
const agent = await runtime.loadAgent('data_chat');
566566

567-
expect(metadataService.get).toHaveBeenCalledWith('agent', 'data_chat');
568-
expect(agent?.name).toBe('data_chat');
567+
// Path A: `data_chat` is an alias for the renamed `ask` agent.
568+
expect(metadataService.get).toHaveBeenCalledWith('agent', 'ask');
569+
expect(agent?.name).toBe('ask');
569570
expect(agent?.role).toBe('Business Application Assistant');
570571
});
571572

@@ -717,7 +718,7 @@ describe('AgentRuntime', () => {
717718
]);
718719
const agents = await runtime.listAgents();
719720
expect(agents).toHaveLength(2);
720-
expect(agents[0]).toEqual({ name: 'data_chat', label: 'Assistant', role: 'Business Application Assistant' });
721+
expect(agents[0]).toEqual({ name: 'ask', label: 'Assistant', role: 'Business Application Assistant' });
721722
expect(agents[1]).toEqual({ name: 'metadata_assistant', label: 'Metadata Assistant', role: 'Schema Architect' });
722723
});
723724

@@ -728,7 +729,7 @@ describe('AgentRuntime', () => {
728729
]);
729730
const agents = await runtime.listAgents();
730731
expect(agents).toHaveLength(1);
731-
expect(agents[0].name).toBe('data_chat');
732+
expect(agents[0].name).toBe('ask');
732733
});
733734

734735
it('should return empty array when no agents registered', async () => {
@@ -744,7 +745,7 @@ describe('AgentRuntime', () => {
744745
]);
745746
const agents = await runtime.listAgents();
746747
expect(agents).toHaveLength(1);
747-
expect(agents[0].name).toBe('data_chat');
748+
expect(agents[0].name).toBe('ask');
748749
});
749750
});
750751
});
@@ -765,7 +766,8 @@ describe('Agent Routes', () => {
765766
aiService = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
766767
metadataService = createMockMetadataService({
767768
get: vi.fn(async (_type, name) => {
768-
if (name === 'data_chat') return DATA_CHAT_AGENT;
769+
// Canonical name after Path A rename; `data_chat` resolves here via alias.
770+
if (name === 'ask') return DATA_CHAT_AGENT;
769771
if (name === 'inactive_agent') return { ...DATA_CHAT_AGENT, name: 'inactive_agent', active: false };
770772
return undefined;
771773
}),
@@ -791,7 +793,7 @@ describe('Agent Routes', () => {
791793
expect(resp.status).toBe(200);
792794
const body = resp.body as { agents: Array<{ name: string; label: string; role: string }> };
793795
expect(body.agents).toHaveLength(2);
794-
expect(body.agents[0].name).toBe('data_chat');
796+
expect(body.agents[0].name).toBe('ask');
795797
expect(body.agents[1].name).toBe('metadata_assistant');
796798
});
797799

@@ -1047,7 +1049,8 @@ describe('Agent Routes', () => {
10471049

10481050
describe('DATA_CHAT_AGENT', () => {
10491051
it('should be a valid agent definition', () => {
1050-
expect(DATA_CHAT_AGENT.name).toBe('data_chat');
1052+
// Path A rename: canonical id is now `ask` (was `data_chat`).
1053+
expect(DATA_CHAT_AGENT.name).toBe('ask');
10511054
expect(DATA_CHAT_AGENT.role).toBe('Business Application Assistant');
10521055
expect(DATA_CHAT_AGENT.active).toBe(true);
10531056
expect(DATA_CHAT_AGENT.visibility).toBe('global');

packages/services/service-ai/src/agent-runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AgentSchema } from '@objectstack/spec/ai';
1111
import { SkillRegistry, type SkillContext } from './skill-registry.js';
1212
import { SchemaRetriever, type ObjectShape } from './schema-retriever.js';
1313
import { DEFAULT_DATA_AGENT_NAME } from './agents/index.js';
14+
import { resolveAgentAlias } from './agents/agent-aliases.js';
1415

1516
/**
1617
* Context passed alongside a user message when chatting with an agent.
@@ -96,7 +97,10 @@ export class AgentRuntime {
9697
* or validation fails.
9798
*/
9899
async loadAgent(agentName: string): Promise<Agent | undefined> {
99-
const raw = await this.metadataService.get('agent', agentName);
100+
// Normalize legacy ids (e.g. `data_chat`→`ask`, `metadata_assistant`→`build`)
101+
// so old `/agents/:name/chat` links and persisted conversation `agent_id`s
102+
// keep resolving after the Path A rename.
103+
const raw = await this.metadataService.get('agent', resolveAgentAlias(agentName));
100104
if (!raw) return undefined;
101105

102106
const result = AgentSchema.safeParse(raw);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Back-compat aliases for renamed built-in agents.
5+
*
6+
* The platform's built-in agents were renamed (Path A) so the friendly console
7+
* URL equals the real identifier: the data agent `data_chat`→`ask`. Old clients,
8+
* bookmarks, and persisted `ai_conversations.agent_id` values still carry the
9+
* legacy name, so {@link AgentRuntime.loadAgent} normalizes a requested name
10+
* through this table before loading the record — `/agents/data_chat/chat` keeps
11+
* resolving to the `ask` agent.
12+
*
13+
* The table is a process-wide registry so each package that owns a built-in
14+
* agent registers ITS OWN rename and the two stay decoupled: the framework
15+
* seeds `data_chat`→`ask` here, and the cloud AI Studio plugin registers
16+
* `metadata_assistant`→`build` at init via {@link registerAgentAlias}. That
17+
* decoupling is what makes the two renames independently safe — neither alias
18+
* points at an id its owning package hasn't registered yet.
19+
*
20+
* Aliases are resolution-only: they are NOT separate metadata records, so the
21+
* agent list (`GET /api/v1/ai/agents`) still shows each agent exactly once
22+
* under its canonical name.
23+
*/
24+
const AGENT_NAME_ALIASES = new Map<string, string>([
25+
// The framework's own data agent rename.
26+
['data_chat', 'ask'],
27+
]);
28+
29+
/**
30+
* Register a legacy→canonical agent-name alias. Idempotent; a later call for the
31+
* same legacy name wins. Call at plugin init, BEFORE the canonical agent is
32+
* looked up, so a legacy request resolves to the registered canonical id.
33+
*/
34+
export function registerAgentAlias(legacy: string, canonical: string): void {
35+
if (legacy && canonical && legacy !== canonical) {
36+
AGENT_NAME_ALIASES.set(legacy, canonical);
37+
}
38+
}
39+
40+
/** Resolve a (possibly legacy) agent name to its canonical id, or itself. */
41+
export function resolveAgentAlias(name: string): string {
42+
return AGENT_NAME_ALIASES.get(name) ?? name;
43+
}
44+
45+
/** Test/diagnostics helper: a snapshot of the current alias table. */
46+
export function agentAliasEntries(): Array<[string, string]> {
47+
return Array.from(AGENT_NAME_ALIASES.entries());
48+
}

packages/services/service-ai/src/agents/data-chat-agent.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ import type { Agent } from '@objectstack/spec/ai';
3232
*
3333
* This is the implicit default copilot for every application that does
3434
* not pin its own `app.defaultAgent`. Studio is the only built-in app
35-
* that overrides it (→ `metadata_assistant`). Keeping the name as an
36-
* exported constant lets the runtime resolve the fallback
35+
* that overrides it (→ the `build` authoring agent). Keeping the name as
36+
* an exported constant lets the runtime resolve the fallback
3737
* deterministically instead of guessing "first active agent".
38+
*
39+
* Path A renamed this from `data_chat`→`ask`; the legacy name stays
40+
* resolvable via the alias table (see `agent-aliases.ts`).
3841
*/
39-
export const DEFAULT_DATA_AGENT_NAME = 'data_chat';
42+
export const DEFAULT_DATA_AGENT_NAME = 'ask';
43+
44+
/** Legacy id this agent was renamed from (kept for back-compat / migrations). */
45+
export const LEGACY_DATA_AGENT_NAME = 'data_chat';
4046

4147
export const DATA_CHAT_AGENT: Agent = {
4248
name: DEFAULT_DATA_AGENT_NAME,
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME } from './data-chat-agent.js';
4-
// The metadata_assistant authoring agent moved to the cloud-only
5-
// @objectstack/service-ai-studio package.
3+
export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './data-chat-agent.js';
4+
export { registerAgentAlias, resolveAgentAlias, agentAliasEntries } from './agent-aliases.js';
5+
// The build (authoring) agent moved to the cloud-only
6+
// @objectstack/service-ai-studio package; it registers its own
7+
// `metadata_assistant`→`build` alias via `registerAgentAlias`.

packages/services/service-ai/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ export { SkillRegistry } from './skill-registry.js';
5757
export type { SkillContext, SkillSummary } from './skill-registry.js';
5858

5959
// Built-in agents
60-
export { DATA_CHAT_AGENT } from './agents/index.js';
60+
export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './agents/index.js';
61+
// Back-compat agent-name aliases (Path A rename). Other packages register their
62+
// own renames (e.g. cloud AI Studio: `metadata_assistant`→`build`).
63+
export { registerAgentAlias, resolveAgentAlias, agentAliasEntries } from './agents/index.js';
6164

6265
// Built-in skills
6366
export {

packages/services/service-ai/src/plugin.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { registerVisualizeDataTool, VISUALIZE_DATA_TOOL } from './tools/visualiz
2424
import { registerActionsAsTools } from './tools/action-tools.js';
2525
import { AgentRuntime } from './agent-runtime.js';
2626
import { SkillRegistry } from './skill-registry.js';
27-
import { DATA_CHAT_AGENT } from './agents/index.js';
27+
import { DATA_CHAT_AGENT, LEGACY_DATA_AGENT_NAME } from './agents/index.js';
2828
import { DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL } from './skills/index.js';
2929
import { VercelLLMAdapter } from './adapters/vercel-adapter.js';
3030
import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
@@ -848,6 +848,20 @@ export class AIServicePlugin implements Plugin {
848848
}
849849
};
850850
await upsertBuiltin('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
851+
// Path A rename (`data_chat`→`ask`): drop the stale legacy agent
852+
// record on upgrade so the catalog doesn't list the agent twice. The
853+
// legacy NAME stays resolvable for chat via the alias table; this only
854+
// removes the now-duplicate registry entry. Idempotent on fresh installs.
855+
if (DATA_CHAT_AGENT.name !== LEGACY_DATA_AGENT_NAME) {
856+
try {
857+
if (await withTimeout(metadataService.exists('agent', LEGACY_DATA_AGENT_NAME))) {
858+
await withTimeout(metadataService.unregister('agent', LEGACY_DATA_AGENT_NAME));
859+
ctx.logger.info(`[AI] removed legacy agent record "${LEGACY_DATA_AGENT_NAME}" (renamed → "${DATA_CHAT_AGENT.name}")`);
860+
}
861+
} catch (err) {
862+
ctx.logger.warn('[AI] Failed to remove legacy data agent record', err instanceof Error ? { error: err.message } : { error: String(err) });
863+
}
864+
}
851865
await upsertBuiltin('skill', DATA_EXPLORER_SKILL.name, DATA_EXPLORER_SKILL);
852866
await upsertBuiltin('skill', ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL);
853867
}

0 commit comments

Comments
 (0)