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
5 changes: 5 additions & 0 deletions .changeset/rename-data-chat-to-ask.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 70 additions & 0 deletions packages/services/service-ai/src/__tests__/agent-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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' });
});

Expand All @@ -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 () => {
Expand All @@ -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');
});
});
});
Expand All @@ -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;
}),
Expand All @@ -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');
});

Expand Down Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion packages/services/service-ai/src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -96,7 +97,10 @@ export class AgentRuntime {
* or validation fails.
*/
async loadAgent(agentName: string): Promise<Agent | undefined> {
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);
Expand Down
48 changes: 48 additions & 0 deletions packages/services/service-ai/src/agents/agent-aliases.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>([
// 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());
}
12 changes: 9 additions & 3 deletions packages/services/service-ai/src/agents/data-chat-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions packages/services/service-ai/src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 4 additions & 1 deletion packages/services/service-ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion packages/services/service-ai/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
Loading