diff --git a/.changeset/rename-data-chat-to-ask.md b/.changeset/rename-data-chat-to-ask.md new file mode 100644 index 0000000000..a20a05c1bd --- /dev/null +++ b/.changeset/rename-data-chat-to-ask.md @@ -0,0 +1,5 @@ +--- +"@objectstack/service-ai": minor +--- + +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. diff --git a/packages/services/service-ai/src/__tests__/agent-aliases.test.ts b/packages/services/service-ai/src/__tests__/agent-aliases.test.ts new file mode 100644 index 0000000000..c702bc0d35 --- /dev/null +++ b/packages/services/service-ai/src/__tests__/agent-aliases.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Back-compat for the Path A agent rename (`data_chat`→`ask`, and cloud's + * `metadata_assistant`→`build` registered via the public registry). Verifies + * the alias table and that AgentRuntime.loadAgent normalizes a legacy name to + * its canonical record so old `/agents/:name/chat` links keep resolving. + */ +import { describe, it, expect, vi } from 'vitest'; +import type { IMetadataService } from '@objectstack/spec/contracts'; +import { AgentRuntime } from '../agent-runtime.js'; +import { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js'; +import { registerAgentAlias, resolveAgentAlias } from '../agents/agent-aliases.js'; + +function mockMetadata(overrides: Partial = {}): IMetadataService { + return { + register: vi.fn(async () => {}), + get: vi.fn(async () => undefined), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + getObject: vi.fn(async () => undefined), + listObjects: vi.fn(async () => []), + ...overrides, + } as unknown as IMetadataService; +} + +describe('agent-aliases', () => { + it('seeds the framework data-agent rename', () => { + expect(DEFAULT_DATA_AGENT_NAME).toBe('ask'); + expect(LEGACY_DATA_AGENT_NAME).toBe('data_chat'); + expect(resolveAgentAlias('data_chat')).toBe('ask'); + }); + + it('passes unknown / canonical names through unchanged', () => { + expect(resolveAgentAlias('ask')).toBe('ask'); + expect(resolveAgentAlias('sales_assistant')).toBe('sales_assistant'); + }); + + it('lets another package register its own rename (e.g. cloud build agent)', () => { + registerAgentAlias('metadata_assistant', 'build'); + expect(resolveAgentAlias('metadata_assistant')).toBe('build'); + // No-ops that must not corrupt the table. + registerAgentAlias('', 'x'); + registerAgentAlias('same', 'same'); + expect(resolveAgentAlias('same')).toBe('same'); + }); +}); + +describe('AgentRuntime.loadAgent (alias-aware)', () => { + it('resolves a legacy name to the renamed agent record', async () => { + const get = vi.fn(async (_type: string, name: string) => + name === DEFAULT_DATA_AGENT_NAME ? DATA_CHAT_AGENT : undefined, + ); + const runtime = new AgentRuntime(mockMetadata({ get: get as never })); + + const viaLegacy = await runtime.loadAgent('data_chat'); + expect(viaLegacy?.name).toBe('ask'); + expect(get).toHaveBeenCalledWith('agent', 'ask'); + + const viaCanonical = await runtime.loadAgent('ask'); + expect(viaCanonical?.name).toBe('ask'); + }); + + it('returns undefined for a genuinely unknown agent', async () => { + const runtime = new AgentRuntime(mockMetadata()); + expect(await runtime.loadAgent('nope')).toBeUndefined(); + }); +}); 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 d622b0b244..b5e4c036a2 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -560,12 +560,13 @@ describe('AgentRuntime', () => { }); describe('loadAgent', () => { - it('should return agent definition from metadata service', async () => { + it('should return agent definition from metadata service (legacy name resolves via alias)', async () => { (metadataService.get as any).mockResolvedValue(DATA_CHAT_AGENT); const agent = await runtime.loadAgent('data_chat'); - expect(metadataService.get).toHaveBeenCalledWith('agent', 'data_chat'); - expect(agent?.name).toBe('data_chat'); + // Path A: `data_chat` is an alias for the renamed `ask` agent. + expect(metadataService.get).toHaveBeenCalledWith('agent', 'ask'); + expect(agent?.name).toBe('ask'); expect(agent?.role).toBe('Business Application Assistant'); }); @@ -717,7 +718,7 @@ describe('AgentRuntime', () => { ]); const agents = await runtime.listAgents(); expect(agents).toHaveLength(2); - expect(agents[0]).toEqual({ name: 'data_chat', label: 'Assistant', role: 'Business Application Assistant' }); + expect(agents[0]).toEqual({ name: 'ask', label: 'Assistant', role: 'Business Application Assistant' }); expect(agents[1]).toEqual({ name: 'metadata_assistant', label: 'Metadata Assistant', role: 'Schema Architect' }); }); @@ -728,7 +729,7 @@ describe('AgentRuntime', () => { ]); const agents = await runtime.listAgents(); expect(agents).toHaveLength(1); - expect(agents[0].name).toBe('data_chat'); + expect(agents[0].name).toBe('ask'); }); it('should return empty array when no agents registered', async () => { @@ -744,7 +745,7 @@ describe('AgentRuntime', () => { ]); const agents = await runtime.listAgents(); expect(agents).toHaveLength(1); - expect(agents[0].name).toBe('data_chat'); + expect(agents[0].name).toBe('ask'); }); }); }); @@ -765,7 +766,8 @@ describe('Agent Routes', () => { aiService = new AIService({ adapter, logger: silentLogger, toolRegistry: registry }); metadataService = createMockMetadataService({ get: vi.fn(async (_type, name) => { - if (name === 'data_chat') return DATA_CHAT_AGENT; + // Canonical name after Path A rename; `data_chat` resolves here via alias. + if (name === 'ask') return DATA_CHAT_AGENT; if (name === 'inactive_agent') return { ...DATA_CHAT_AGENT, name: 'inactive_agent', active: false }; return undefined; }), @@ -791,7 +793,7 @@ describe('Agent Routes', () => { expect(resp.status).toBe(200); const body = resp.body as { agents: Array<{ name: string; label: string; role: string }> }; expect(body.agents).toHaveLength(2); - expect(body.agents[0].name).toBe('data_chat'); + expect(body.agents[0].name).toBe('ask'); expect(body.agents[1].name).toBe('metadata_assistant'); }); @@ -1047,7 +1049,8 @@ describe('Agent Routes', () => { describe('DATA_CHAT_AGENT', () => { it('should be a valid agent definition', () => { - expect(DATA_CHAT_AGENT.name).toBe('data_chat'); + // Path A rename: canonical id is now `ask` (was `data_chat`). + expect(DATA_CHAT_AGENT.name).toBe('ask'); expect(DATA_CHAT_AGENT.role).toBe('Business Application Assistant'); expect(DATA_CHAT_AGENT.active).toBe(true); expect(DATA_CHAT_AGENT.visibility).toBe('global'); diff --git a/packages/services/service-ai/src/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index b2d2b0466c..cc65cd05a0 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -11,6 +11,7 @@ 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'; +import { resolveAgentAlias } from './agents/agent-aliases.js'; /** * Context passed alongside a user message when chatting with an agent. @@ -96,7 +97,10 @@ export class AgentRuntime { * or validation fails. */ async loadAgent(agentName: string): Promise { - const raw = await this.metadataService.get('agent', agentName); + // Normalize legacy ids (e.g. `data_chat`→`ask`, `metadata_assistant`→`build`) + // so old `/agents/:name/chat` links and persisted conversation `agent_id`s + // keep resolving after the Path A rename. + const raw = await this.metadataService.get('agent', resolveAgentAlias(agentName)); if (!raw) return undefined; const result = AgentSchema.safeParse(raw); diff --git a/packages/services/service-ai/src/agents/agent-aliases.ts b/packages/services/service-ai/src/agents/agent-aliases.ts new file mode 100644 index 0000000000..53bc3876ac --- /dev/null +++ b/packages/services/service-ai/src/agents/agent-aliases.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Back-compat aliases for renamed built-in agents. + * + * The platform's built-in agents were renamed (Path A) so the friendly console + * URL equals the real identifier: the data agent `data_chat`→`ask`. Old clients, + * bookmarks, and persisted `ai_conversations.agent_id` values still carry the + * legacy name, so {@link AgentRuntime.loadAgent} normalizes a requested name + * through this table before loading the record — `/agents/data_chat/chat` keeps + * resolving to the `ask` agent. + * + * The table is a process-wide registry so each package that owns a built-in + * agent registers ITS OWN rename and the two stay decoupled: the framework + * seeds `data_chat`→`ask` here, and the cloud AI Studio plugin registers + * `metadata_assistant`→`build` at init via {@link registerAgentAlias}. That + * decoupling is what makes the two renames independently safe — neither alias + * points at an id its owning package hasn't registered yet. + * + * Aliases are resolution-only: they are NOT separate metadata records, so the + * agent list (`GET /api/v1/ai/agents`) still shows each agent exactly once + * under its canonical name. + */ +const AGENT_NAME_ALIASES = new Map([ + // The framework's own data agent rename. + ['data_chat', 'ask'], +]); + +/** + * Register a legacy→canonical agent-name alias. Idempotent; a later call for the + * same legacy name wins. Call at plugin init, BEFORE the canonical agent is + * looked up, so a legacy request resolves to the registered canonical id. + */ +export function registerAgentAlias(legacy: string, canonical: string): void { + if (legacy && canonical && legacy !== canonical) { + AGENT_NAME_ALIASES.set(legacy, canonical); + } +} + +/** Resolve a (possibly legacy) agent name to its canonical id, or itself. */ +export function resolveAgentAlias(name: string): string { + return AGENT_NAME_ALIASES.get(name) ?? name; +} + +/** Test/diagnostics helper: a snapshot of the current alias table. */ +export function agentAliasEntries(): Array<[string, string]> { + return Array.from(AGENT_NAME_ALIASES.entries()); +} diff --git a/packages/services/service-ai/src/agents/data-chat-agent.ts b/packages/services/service-ai/src/agents/data-chat-agent.ts index 37a6f0a0a7..66dea41fc5 100644 --- a/packages/services/service-ai/src/agents/data-chat-agent.ts +++ b/packages/services/service-ai/src/agents/data-chat-agent.ts @@ -32,11 +32,17 @@ import type { Agent } from '@objectstack/spec/ai'; * * This is the implicit default copilot for every application that does * not pin its own `app.defaultAgent`. Studio is the only built-in app - * that overrides it (→ `metadata_assistant`). Keeping the name as an - * exported constant lets the runtime resolve the fallback + * that overrides it (→ the `build` authoring agent). Keeping the name as + * an exported constant lets the runtime resolve the fallback * deterministically instead of guessing "first active agent". + * + * Path A renamed this from `data_chat`→`ask`; the legacy name stays + * resolvable via the alias table (see `agent-aliases.ts`). */ -export const DEFAULT_DATA_AGENT_NAME = 'data_chat'; +export const DEFAULT_DATA_AGENT_NAME = 'ask'; + +/** Legacy id this agent was renamed from (kept for back-compat / migrations). */ +export const LEGACY_DATA_AGENT_NAME = 'data_chat'; export const DATA_CHAT_AGENT: Agent = { name: DEFAULT_DATA_AGENT_NAME, diff --git a/packages/services/service-ai/src/agents/index.ts b/packages/services/service-ai/src/agents/index.ts index f2eade983c..ff3978a4dc 100644 --- a/packages/services/service-ai/src/agents/index.ts +++ b/packages/services/service-ai/src/agents/index.ts @@ -1,5 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME } from './data-chat-agent.js'; -// The metadata_assistant authoring agent moved to the cloud-only -// @objectstack/service-ai-studio package. +export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './data-chat-agent.js'; +export { registerAgentAlias, resolveAgentAlias, agentAliasEntries } from './agent-aliases.js'; +// The build (authoring) agent moved to the cloud-only +// @objectstack/service-ai-studio package; it registers its own +// `metadata_assistant`→`build` alias via `registerAgentAlias`. diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index fba1b0c25b..1f02c71200 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -57,7 +57,10 @@ export { SkillRegistry } from './skill-registry.js'; export type { SkillContext, SkillSummary } from './skill-registry.js'; // Built-in agents -export { DATA_CHAT_AGENT } from './agents/index.js'; +export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; +// Back-compat agent-name aliases (Path A rename). Other packages register their +// own renames (e.g. cloud AI Studio: `metadata_assistant`→`build`). +export { registerAgentAlias, resolveAgentAlias, agentAliasEntries } from './agents/index.js'; // Built-in skills export { diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index 8c72c703de..41622fad3a 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -24,7 +24,7 @@ import { registerVisualizeDataTool, VISUALIZE_DATA_TOOL } from './tools/visualiz import { registerActionsAsTools } from './tools/action-tools.js'; import { AgentRuntime } from './agent-runtime.js'; import { SkillRegistry } from './skill-registry.js'; -import { DATA_CHAT_AGENT } from './agents/index.js'; +import { DATA_CHAT_AGENT, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; import { DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL } from './skills/index.js'; import { VercelLLMAdapter } from './adapters/vercel-adapter.js'; import { MemoryLLMAdapter } from './adapters/memory-adapter.js'; @@ -848,6 +848,20 @@ export class AIServicePlugin implements Plugin { } }; await upsertBuiltin('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT); + // Path A rename (`data_chat`→`ask`): drop the stale legacy agent + // record on upgrade so the catalog doesn't list the agent twice. The + // legacy NAME stays resolvable for chat via the alias table; this only + // removes the now-duplicate registry entry. Idempotent on fresh installs. + if (DATA_CHAT_AGENT.name !== LEGACY_DATA_AGENT_NAME) { + try { + if (await withTimeout(metadataService.exists('agent', LEGACY_DATA_AGENT_NAME))) { + await withTimeout(metadataService.unregister('agent', LEGACY_DATA_AGENT_NAME)); + ctx.logger.info(`[AI] removed legacy agent record "${LEGACY_DATA_AGENT_NAME}" (renamed → "${DATA_CHAT_AGENT.name}")`); + } + } catch (err) { + ctx.logger.warn('[AI] Failed to remove legacy data agent record', err instanceof Error ? { error: err.message } : { error: String(err) }); + } + } await upsertBuiltin('skill', DATA_EXPLORER_SKILL.name, DATA_EXPLORER_SKILL); await upsertBuiltin('skill', ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL); }