From ce7b8ad7cb9f574d5ec2bc945a5ed53501cd071b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 23:45:03 +0800 Subject: [PATCH] refactor(service-ai): move the in-UI `ask` Q&A agent out to the cloud package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/__tests__/agent-aliases.test.ts | 24 +- .../src/__tests__/chatbot-features.test.ts | 200 ++++----------- .../service-ai/src/agents/agent-aliases.ts | 21 +- .../service-ai/src/agents/ask-agent.ts | 115 --------- .../services/service-ai/src/agents/index.ts | 20 +- packages/services/service-ai/src/index.ts | 17 +- packages/services/service-ai/src/plugin.ts | 234 ++---------------- .../src/skills/actions-executor-skill.ts | 63 ----- .../src/skills/data-explorer-skill.ts | 76 ------ .../services/service-ai/src/skills/index.ts | 10 +- 10 files changed, 138 insertions(+), 642 deletions(-) delete mode 100644 packages/services/service-ai/src/agents/ask-agent.ts delete mode 100644 packages/services/service-ai/src/skills/actions-executor-skill.ts delete mode 100644 packages/services/service-ai/src/skills/data-explorer-skill.ts diff --git a/packages/services/service-ai/src/__tests__/agent-aliases.test.ts b/packages/services/service-ai/src/__tests__/agent-aliases.test.ts index ff77606a65..75ed56b0dd 100644 --- a/packages/services/service-ai/src/__tests__/agent-aliases.test.ts +++ b/packages/services/service-ai/src/__tests__/agent-aliases.test.ts @@ -9,8 +9,26 @@ import { describe, it, expect, vi } from 'vitest'; import type { IMetadataService } from '@objectstack/spec/contracts'; import { AgentRuntime } from '../agent-runtime.js'; -import { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js'; -import { registerAgentAlias, resolveAgentAlias } from '../agents/agent-aliases.js'; +import { + ASK_AGENT_NAME, + LEGACY_DATA_AGENT_NAME, + registerAgentAlias, + resolveAgentAlias, +} from '../agents/agent-aliases.js'; +import type { Agent } from '@objectstack/spec/ai'; + +// The real `ask` PERSONA moved to the cloud-only @objectstack/service-ai-studio +// package; the framework now exports only the NAME CONSTANTS + the alias +// registry (the mechanism). This minimal local stub stands in for the persona so +// the alias-aware `AgentRuntime.loadAgent` resolution is still exercised here. +const ASK_AGENT_STUB: Agent = { + name: ASK_AGENT_NAME, + label: 'Assistant', + role: 'Business Application Assistant', + instructions: 'Stub ask persona for alias resolution tests.', + active: true, + visibility: 'global', +}; function mockMetadata(overrides: Partial = {}): IMetadataService { return { @@ -66,7 +84,7 @@ describe('agent-aliases', () => { 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 === ASK_AGENT_NAME ? ASK_AGENT : undefined, + name === ASK_AGENT_NAME ? ASK_AGENT_STUB : undefined, ); const runtime = new AgentRuntime(mockMetadata({ get: get as never })); 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 a72e1df498..20b3f4e55d 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -19,16 +19,42 @@ import { AgentRuntime } from '../agent-runtime.js'; import { SkillRegistry } from '../skill-registry.js'; import type { AgentChatContext } from '../agent-runtime.js'; import { buildAgentRoutes } from '../routes/agent-routes.js'; -import { ASK_AGENT } from '../agents/ask-agent.js'; import { registerAgentAlias } from '../agents/agent-aliases.js'; +import type { Agent } from '@objectstack/spec/ai'; + +// BOTH platform PERSONAS moved to the cloud-only @objectstack/service-ai-studio +// package (the `ask` data product + the `build` authoring agent are commercial +// features). The open-source AI runtime is headless. These local stubs stand in +// as the two PLATFORM agents for the runtime/route agent-listing tests below; +// they satisfy AgentSchema and carry just enough persona text for the generic +// `buildSystemMessages` assertions. The persona-as-subject specs (skill bundles, +// instruction wording, surface affinity) live with the agents in the cloud +// package now (see metadata-assistant-agent.test.ts / ask-agent.test.ts there). +// +// The `ask` stub's instructions intentionally include the "assistant for this +// business application platform" / "do NOT build" / "Builder" phrases the +// runtime tests assert on, so those tests still exercise buildSystemMessages +// without depending on the moved persona. +const ASK_AGENT: Agent = { + name: 'ask', + label: 'Assistant', + role: 'Business Application Assistant', + surface: 'ask', + instructions: + 'You are the assistant for this business application platform. You help the ' + + 'user explore their data. You do NOT build or change the application itself; ' + + 'app-building lives in the Builder (the separate "build" experience).', + model: { provider: 'openai', model: 'gpt-4', temperature: 0.2, maxTokens: 4096 }, + skills: ['schema_reader', 'data_explorer', 'actions_executor'], + active: true, + visibility: 'global', + guardrails: { maxTokensPerInvocation: 8192, maxExecutionTimeSec: 30, blockedTopics: ['raw_sql'] }, + planning: { strategy: 'react', maxIterations: 10, allowReplan: true }, +} as any; -// The real `build` agent moved to the cloud-only @objectstack/service-ai-studio -// package (AI authoring is a commercial feature). This local stub stands in as -// the second PLATFORM agent for the runtime/route agent-listing tests below. It -// derives from the `ask` agent so it satisfies AgentSchema, then overrides -// identity fields. Registering the `metadata_assistant`→`build` alias (as the -// cloud plugin does at init) makes `build` a recognised platform agent so the -// runtime catalog surfaces it (ADR-0063 §2). +// Registering the `metadata_assistant`→`build` alias (as the cloud plugin does +// at init) makes `build` a recognised platform agent so the runtime catalog +// surfaces it (ADR-0063 §2). registerAgentAlias('metadata_assistant', 'build'); const BUILD_AGENT = { ...ASK_AGENT, @@ -639,12 +665,6 @@ describe('AgentRuntime', () => { expect(messages[0].content).not.toContain('Capabilities in this deployment'); }); - it('the ask persona itself declines app-building and points at the Builder', () => { - // Affinity is carried by the persona, not a runtime gate. - expect(ASK_AGENT.instructions).toContain('do NOT build'); - expect(ASK_AGENT.instructions.toLowerCase()).toContain('builder'); - expect(ASK_AGENT.surface).toBe('ask'); - }); }); describe('buildContextSchemaMessages', () => { @@ -694,11 +714,19 @@ describe('AgentRuntime', () => { { name: 'unrelated_tool', description: 'Not in any skill', parameters: {} }, ]; - // ASK_AGENT's tool set is the union of its skills' claimed tools: - // schema_reader owns list_objects, data_explorer owns query_records. - const { DATA_EXPLORER_SKILL } = await import('../skills/data-explorer-skill.js'); + // Mechanism test: the resolved tool set is the union of the active skills' + // claimed tools. `schema_reader` stays open in this package; `data_explorer` + // moved to the cloud studio package, so we stub it inline here — the runtime + // resolver doesn't care WHERE a skill is defined, only what tools it claims. const { SCHEMA_READER_SKILL } = await import('../skills/schema-reader-skill.js'); - const options = runtime.buildRequestOptions(ASK_AGENT, availableTools, [SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL]); + const dataExplorerStub = { + name: 'data_explorer', + label: 'Data Explorer', + surface: 'ask', + tools: ['query_records', 'get_record', 'aggregate_data', 'visualize_data'], + active: true, + } as never; + const options = runtime.buildRequestOptions(ASK_AGENT, availableTools, [SCHEMA_READER_SKILL, dataExplorerStub]); const resolvedNames = options.tools?.map(t => t.name) ?? []; expect(resolvedNames).toContain('list_objects'); @@ -1085,133 +1113,9 @@ describe('Agent Routes', () => { }); }); -// ═══════════════════════════════════════════════════════════════════ -// Data Chat Agent Spec -// ═══════════════════════════════════════════════════════════════════ - -describe('ASK_AGENT', () => { - it('should be a valid agent definition', () => { - // Path A rename: canonical id is now `ask` (was `data_chat`). - expect(ASK_AGENT.name).toBe('ask'); - expect(ASK_AGENT.role).toBe('Business Application Assistant'); - expect(ASK_AGENT.active).toBe(true); - expect(ASK_AGENT.visibility).toBe('global'); - }); - - it('should bind only ask-surface skills — no authoring skills (ADR-0063)', () => { - expect(ASK_AGENT.tools ?? []).toHaveLength(0); - // schema_reader (both) + data_explorer/actions_executor (ask). The build - // skills (metadata_authoring/solution_design) are NOT here — they live on - // the cloud `build` agent. - expect(ASK_AGENT.skills).toEqual(['schema_reader', 'data_explorer', 'actions_executor']); - expect(ASK_AGENT.skills).not.toContain('metadata_authoring'); - expect(ASK_AGENT.skills).not.toContain('solution_design'); - }); - - it('declares surface "ask"', () => { - expect(ASK_AGENT.surface).toBe('ask'); - }); - - it('should have guardrails configured', () => { - expect(ASK_AGENT.guardrails).toBeDefined(); - expect(ASK_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0); - expect(ASK_AGENT.guardrails!.blockedTopics).toBeDefined(); - }); - - it('should have model config', () => { - expect(ASK_AGENT.model).toBeDefined(); - expect(ASK_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// ADR-0063 §3 / ADR-0064 — surface affinity & tool scoping -// ═══════════════════════════════════════════════════════════════════ - -describe('surface affinity & tool scoping (ADR-0063/0064)', () => { - it('tools(ask) excludes every authoring tool — ask cannot author by construction', async () => { - const { SCHEMA_READER_SKILL } = await import('../skills/schema-reader-skill.js'); - const { DATA_EXPLORER_SKILL } = await import('../skills/data-explorer-skill.js'); - const { ACTIONS_EXECUTOR_SKILL } = await import('../skills/actions-executor-skill.js'); - const askSkills = [SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL]; - - const availableTools: AIToolDefinition[] = [ - // shared reads + ask tools - { name: 'query_data', description: '', parameters: {} }, - { name: 'describe_object', description: '', parameters: {} }, - { name: 'list_objects', description: '', parameters: {} }, - { name: 'query_records', description: '', parameters: {} }, - { name: 'visualize_data', description: '', parameters: {} }, - { name: 'action_complete_task', description: '', parameters: {} }, - // authoring tools that MUST NOT leak into the ask agent - { name: 'create_metadata', description: '', parameters: {} }, - { name: 'update_metadata', description: '', parameters: {} }, - { name: 'add_field', description: '', parameters: {} }, - { name: 'apply_blueprint', description: '', parameters: {} }, - ]; - - const registry = new SkillRegistry(createMockMetadataService()); - const tools = registry.flattenToTools(askSkills, availableTools).map((t) => t.name); - - // shared/ask tools present - expect(tools).toContain('describe_object'); - expect(tools).toContain('query_data'); - expect(tools).toContain('query_records'); - expect(tools).toContain('action_complete_task'); - // no create_* / *_metadata / blueprint tools (issue acceptance criterion) - for (const t of ['create_metadata', 'update_metadata', 'add_field', 'apply_blueprint']) { - expect(tools).not.toContain(t); - } - expect( - tools.some((n) => n.startsWith('create_') || /_metadata$/.test(n) || n.includes('blueprint')), - ).toBe(false); - }); - - it('binding a surface:build skill to the ask agent is a fast load error (ADR-0064 §3)', async () => { - const md = createMockMetadataService({ - list: vi.fn(async (type: string) => - type === 'skill' - ? [ - { - name: 'metadata_authoring', - label: 'Metadata Authoring', - surface: 'build', - tools: ['create_metadata'], - active: true, - }, - ] - : [], - ) as any, - }); - const runtime = new AgentRuntime(md, new SkillRegistry(md)); - const askAgent = { ...ASK_AGENT, skills: ['metadata_authoring'] } as any; - await expect(runtime.resolveActiveSkills(askAgent)).rejects.toThrow( - /incompatible affinity|cannot bind/, - ); - }); - - it('a surface:both skill binds to the ask agent without error', async () => { - const md = createMockMetadataService({ - list: vi.fn(async (type: string) => - type === 'skill' - ? [ - { - name: 'schema_reader', - label: 'Schema Reader', - surface: 'both', - tools: ['describe_object'], - active: true, - }, - ] - : [], - ) as any, - }); - const runtime = new AgentRuntime(md, new SkillRegistry(md)); - const askAgent = { ...ASK_AGENT, skills: ['schema_reader'] } as any; - const skills = await runtime.resolveActiveSkills(askAgent); - expect(skills.map((s) => s.name)).toContain('schema_reader'); - }); -}); - -// NOTE: the BUILD_AGENT spec tests moved with the agent to the -// cloud-only @objectstack/service-ai-studio package (metadata-assistant-agent.test.ts). +// NOTE: the persona-as-subject specs — the `ASK_AGENT` definition spec, the +// `BUILD_AGENT` spec, and the ADR-0063/0064 surface-affinity & tool-scoping +// tests — moved WITH the agents/skills to the cloud-only +// @objectstack/service-ai-studio package (see ask-agent.test.ts / +// metadata-assistant-agent.test.ts there). What remains here is the generic AI +// runtime / route mechanism, exercised against local persona STUBS. diff --git a/packages/services/service-ai/src/agents/agent-aliases.ts b/packages/services/service-ai/src/agents/agent-aliases.ts index a29776772d..9708292e9a 100644 --- a/packages/services/service-ai/src/agents/agent-aliases.ts +++ b/packages/services/service-ai/src/agents/agent-aliases.ts @@ -33,6 +33,25 @@ * Studio had "registered" the alias. Anchoring the Map (and its seed) on a * `Symbol.for` key makes the two builds share ONE table. */ +/** + * Canonical name of the platform's `ask` (data) agent. + * + * This is the implicit default copilot for every application that does not pin + * its own `app.defaultAgent`. The `ASK_AGENT` persona itself is a commercial + * feature and now ships in the cloud-only `@objectstack/service-ai-studio` + * package (it attaches via the `ai:ready` hook); the NAME stays here in the + * open framework so the runtime can resolve the default/fallback deterministically + * and so the legacy alias below has a canonical target even on a headless OSS + * runtime where the persona is absent. + * + * Renamed from `data_chat`→`ask`; the legacy name stays resolvable via the + * alias table seeded below. + */ +export const ASK_AGENT_NAME = 'ask'; + +/** Legacy id the data agent was renamed from (kept for back-compat / migrations). */ +export const LEGACY_DATA_AGENT_NAME = 'data_chat'; + const ALIAS_REGISTRY_KEY: unique symbol = Symbol.for('@objectstack/service-ai#agentNameAliases'); /** The single process-wide alias table, created (and seeded) on first touch. */ @@ -41,7 +60,7 @@ function aliasRegistry(): Map { let map = g[ALIAS_REGISTRY_KEY]; if (!map) { // Seed the framework's own data agent rename on first access. - map = new Map([['data_chat', 'ask']]); + map = new Map([[LEGACY_DATA_AGENT_NAME, ASK_AGENT_NAME]]); g[ALIAS_REGISTRY_KEY] = map; } return map; diff --git a/packages/services/service-ai/src/agents/ask-agent.ts b/packages/services/service-ai/src/agents/ask-agent.ts deleted file mode 100644 index a2172c0d8a..0000000000 --- a/packages/services/service-ai/src/agents/ask-agent.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Agent } from '@objectstack/spec/ai'; - -/** - * Built-in `ask` agent — the **data product** (≈ Claude Chat). - * - * Per ADR-0063 the kernel ships exactly two agents, bound by *surface*: - * - `ask` — conversational read/query/explore over records + run the - * business actions the app already exposes. End-user audience, - * RLS-bounded, fast turns. Open-source · free (this package). - * - `build` — agentic authoring of *metadata* (objects, fields, views, - * flows) through plan → draft → verify → publish. Builder - * audience, governance-gated. Cloud-only · paid - * (`@objectstack/service-ai-studio`). - * - * The user never picks an agent — the surface they are in binds it (data - * console → `ask`, Studio → `build`). There is no per-turn intent classifier: - * a `build`-shaped request arriving at `ask` is declined and redirected to the - * builder, never silently re-routed into authoring (ADR-0063 §1/§5). - * - * Following the platform's metadata-driven philosophy, this agent does not - * hardcode the tools it can call. Its tool set is the union of its skills' - * tools (ADR-0064): `schema_reader` (shared read-only schema/query tools), - * `data_explorer` (records + aggregation + charts), and `actions_executor` - * (business actions). Authoring tools are *not* in this set — `ask` cannot - * author, by construction. - * - * @example - * ``` - * POST /api/v1/ai/agents/ask/chat - * { - * "messages": [{ "role": "user", "content": "Show me all active accounts" }], - * "context": { "objectName": "account" } - * } - * ``` - */ - -/** - * Canonical name of the platform's `ask` (data) agent. - * - * 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 (→ the `build` authoring agent). Keeping the name as an - * exported constant lets the runtime resolve the fallback deterministically - * instead of guessing "first active agent". - * - * Renamed from `data_chat`→`ask`; the legacy name stays resolvable via the - * alias table (see `agent-aliases.ts`). - */ -export const ASK_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 ASK_AGENT: Agent = { - name: ASK_AGENT_NAME, - label: 'Assistant', - role: 'Business Application Assistant', - // ADR-0063 — the `ask` data product. This persona ONLY answers questions - // about the user's data and runs business actions the app exposes. It does - // NOT build or change the application; app-building lives on the separate - // `build` agent (cloud Builder/Studio). There is no per-turn intent - // classifier — the surface bound this agent (ADR-0063 §1). - surface: 'ask', - instructions: `You are the assistant for this business application platform. You help the user EXPLORE THEIR DATA — answer questions, list and count records, aggregate, search, and draw charts — and PERFORM business operations the application already exposes (its actions). - -You do NOT build or change the application itself (objects, fields, views, dashboards, flows, whole apps), and you have no tools to do so. If the user asks you to build, create, design, or modify the app, do not attempt it and do not outline a system as if you could: briefly say that app-building lives in the Builder (the separate "build" experience), then offer to help explore or report on the existing data instead. - -Always answer in the same language the user is using. Detailed tool-usage guidance is supplied by the skills attached to this agent.`, - - model: { - provider: 'openai', - model: 'gpt-4', - // Low temperature: data answers should be deterministic and grounded. - temperature: 0.2, - maxTokens: 4096, - }, - - // Capability bundles live on skills; the agent only references them. - // `schema_reader` (surface:'both') = shared read-only schema/query tools; - // `data_explorer` + `actions_executor` (surface:'ask') = the data product's - // exploration and action tools. No authoring skills — those are `build`'s - // and only exist on the cloud package (ADR-0063 §5 / ADR-0064). - skills: ['schema_reader', 'data_explorer', 'actions_executor'], - - active: true, - visibility: 'global', - - guardrails: { - maxTokensPerInvocation: 8192, - // Data answers + actions; no long-running authoring loop here. - maxExecutionTimeSec: 30, - blockedTopics: ['delete_records', 'drop_database', 'raw_sql', 'system_tables'], - }, - - planning: { - strategy: 'react', - maxIterations: 10, - allowReplan: true, - }, - - // ADR-0063 §2 / ADR-0010 §3.7 — built-in platform agent. Tenants extend the - // platform with skills + tools, never by editing this persona, so it is fully - // locked against overlay edits/deletes. (The platform's own boot-time refresh - // writes through the authoritative register path, which the lock does not gate.) - // This author-protection envelope is ALSO the intrinsic, persisted signal that - // `AgentRuntime.listAgents()` keys off to keep `ask` in the catalog regardless - // of whether the in-memory alias table happened to be populated — a missed - // alias registration must never hide a real platform agent. - protection: { - lock: 'full', - reason: 'Built-in platform assistant shipped by @objectstack/service-ai.', - }, -}; diff --git a/packages/services/service-ai/src/agents/index.ts b/packages/services/service-ai/src/agents/index.ts index e40f720da1..5788346483 100644 --- a/packages/services/service-ai/src/agents/index.ts +++ b/packages/services/service-ai/src/agents/index.ts @@ -1,7 +1,17 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -export { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './ask-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`. +// The `ask` agent PERSONA (`ASK_AGENT`) + its data-explorer/actions-executor +// skills are a commercial feature and moved to the cloud-only +// @objectstack/service-ai-studio package; they attach via the `ai:ready` hook. +// Only the canonical/legacy NAME CONSTANTS and the back-compat alias registry +// stay open here (the mechanism the cloud persona registers against). The cloud +// AI Studio plugin likewise registers its own `metadata_assistant`→`build` +// alias via `registerAgentAlias`. +export { + ASK_AGENT_NAME, + LEGACY_DATA_AGENT_NAME, + registerAgentAlias, + resolveAgentAlias, + agentAliasEntries, + platformAgentNames, +} from './agent-aliases.js'; diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index dbb6d7df07..60bc6e8a63 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -56,18 +56,19 @@ export type { AgentChatContext } from './agent-runtime.js'; export { SkillRegistry } from './skill-registry.js'; export type { SkillContext, SkillSummary } from './skill-registry.js'; -// Built-in agents -export { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; +// Built-in agent NAME CONSTANTS (the `ASK_AGENT` persona itself moved to the +// cloud-only @objectstack/service-ai-studio package; the names stay open so the +// runtime resolves the default/fallback deterministically and the legacy alias +// has a canonical target on a headless OSS runtime). +export { ASK_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 { - SCHEMA_READER_SKILL, - DATA_EXPLORER_SKILL, - ACTIONS_EXECUTOR_SKILL, -} from './skills/index.js'; +// Built-in skills — only the shared, read-only `schema_reader` (surface:'both') +// stays open as the mechanism. The `data_explorer` + `actions_executor` skills +// (the `ask` data product) moved to @objectstack/service-ai-studio. +export { SCHEMA_READER_SKILL } from './skills/index.js'; // Object definitions export { AiConversationObject, AiMessageObject, AiTraceObject } from './objects/index.js'; diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index ee3789d98f..1a8b56c617 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -2,10 +2,9 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { readEnvWithDeprecation } from '@objectstack/types'; -import type { IAIService, IAIConversationService, IAnalyticsService, IAutomationService, IDataEngine, IEmbedder, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts'; +import type { IAIService, IAIConversationService, IDataEngine, IEmbedder, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts'; import { EMBEDDER_SERVICE } from '@objectstack/spec/contracts'; import type * as AI from '@objectstack/spec/ai'; -import { applyProtection } from '@objectstack/spec/shared'; import { AIService } from './ai-service.js'; import type { AIServiceConfig } from './ai-service.js'; import { buildAIRoutes } from './routes/ai-routes.js'; @@ -19,14 +18,8 @@ import { AiConversationObject, AiMessageObject, AiPendingActionObject, AiTraceOb import { DailyMessageQuota, type AgentChatQuota } from './quota/agent-chat-quota.js'; import { AiTraceView, AiMessageView, AiPendingActionView, AiEvalCaseView, AiEvalRunView } from './views/index.js'; import { EvalRunner } from './eval/index.js'; -import { registerDataTools } from './tools/data-tools.js'; -import { registerQueryDataTool } from './tools/query-data.tool.js'; -import { registerVisualizeDataTool, VISUALIZE_DATA_TOOL } from './tools/visualize-data.tool.js'; -import { registerActionsAsTools } from './tools/action-tools.js'; import { AgentRuntime } from './agent-runtime.js'; import { SkillRegistry } from './skill-registry.js'; -import { ASK_AGENT, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; -import { SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL } from './skills/index.js'; import { VercelLLMAdapter } from './adapters/vercel-adapter.js'; import { MemoryLLMAdapter } from './adapters/memory-adapter.js'; import { ModelRegistry } from './model-registry.js'; @@ -682,202 +675,16 @@ export class AIServicePlugin implements Plugin { } } - // Resolve protocol shim once — used by data, metadata, and query_data - // tools so they can see ObjectQL SchemaRegistry items (sys_user, etc.) - // in addition to MetadataManager registry items. - let protocolService: { getMetaItems(req: { type: string; packageId?: string; organizationId?: string }): Promise } | undefined; - try { - const p = ctx.getService('protocol'); - if (p && typeof p.getMetaItems === 'function') protocolService = p; - } catch { - protocolService = undefined; - } - - // Data tools require only the data engine. When metadata service is - // wired we also pass it (+ protocol) so the tools can validate - // field references at runtime and reject hallucinated field names - // with a structured error instead of silently returning empty data. - try { - const dataEngine = ctx.getService('data'); - if (dataEngine) { - registerDataTools(this.service.toolRegistry, { - dataEngine, - metadataService, - protocol: protocolService, - }); - ctx.logger.info('[AI] Built-in data tools registered'); - - // Register visualize_data when an analytics service is available — it - // turns an aggregation into an SDUI chart that renders inline in chat - // (emitted as a `data-chart` stream part). Only needs analytics, so it - // sits outside the metadata gate below. - let analyticsService: IAnalyticsService | undefined; - try { - analyticsService = ctx.getService('analytics'); - } catch { - analyticsService = undefined; - } - if (analyticsService) { - registerVisualizeDataTool(this.service.toolRegistry, { analytics: analyticsService }); - ctx.logger.info('[AI] visualize_data tool registered'); - } else { - ctx.logger.debug('[AI] No analytics service — visualize_data tool not registered'); - } - - // Register query_data tool when metadata service is also available — - // it composes AI + Metadata + Data into a single NL-to-records call. - if (metadataService) { - registerQueryDataTool(this.service.toolRegistry, { - ai: this.service, - metadata: metadataService, - dataEngine, - protocol: protocolService, - }); - ctx.logger.info('[AI] query_data tool registered'); - - // Register actions-as-tools: enumerate every object's actions[] - // and surface the script-type ones as `action_` tools. - // This is what gives agents the ability to *do things* (mark - // task complete, clone record, ...) — the write-side counterpart - // to query_data. - try { - // Resolve automation service (optional — flow actions get - // skipped gracefully if unavailable). - let automation: IAutomationService | undefined; - try { - automation = ctx.getService('automation'); - } catch { - automation = undefined; - } - const apiBaseUrl = - this.options.apiActionBaseUrl ?? process.env.OS_AI_ACTION_API_BASE_URL; - const apiHeaders = this.options.apiActionHeaders; - const { registered, skipped, warnings } = await registerActionsAsTools( - this.service.toolRegistry, - { - metadata: metadataService, - dataEngine, - automation, - apiBaseUrl, - apiHeaders, - enableActionApproval: this.options.enableActionApproval ?? false, - aiService: this.service, - }, - ); - if (registered.length > 0) { - ctx.logger.info( - `[AI] ${registered.length} action tool(s) registered: ${registered.join(', ')}`, - ); - } - if (skipped.length > 0) { - ctx.logger.debug( - `[AI] Skipped ${skipped.length} action(s) for AI exposure`, - { skipped }, - ); - } - for (const w of warnings) { - ctx.logger.warn(`[AI] action '${w.action}': ${w.warning}`); - } - } catch (err) { - ctx.logger.warn( - '[AI] Failed to register action tools', - err instanceof Error ? { error: err.message } : { error: String(err) }, - ); - } - } - - // Register data tools as metadata (for Studio visibility) - if (metadataService) { - const { DATA_TOOL_DEFINITIONS } = await import('./tools/data-tools.js'); - // visualize_data is only usable (and only registered above) when an - // analytics service is present — persist it as metadata in lockstep. - const toolDefsToPersist = analyticsService - ? [...DATA_TOOL_DEFINITIONS, VISUALIZE_DATA_TOOL] - : DATA_TOOL_DEFINITIONS; - for (const toolDef of toolDefsToPersist) { - const toolExists = - typeof metadataService.exists === 'function' - ? await withTimeout(metadataService.exists('tool', toolDef.name)) - : false; - - if (toolExists === null) { - ctx.logger.warn('[AI] Metadata service timed out checking tool existence (non-fatal), skipping persistence'); - break; - } - - if (!toolExists) { - try { - // `ToolSchema` requires a `label`; the bare `AIToolDefinition` - // used for LLM function-calling may omit it. Fall back to a - // name-derived label so persisted tool metadata always validates. - const label = toolDef.label ?? toToolLabel(toolDef.name); - await withTimeout(metadataService.register('tool', toolDef.name, { ...toolDef, label })); - } catch (err) { - ctx.logger.warn('[AI] Failed to persist tool metadata (non-fatal)', - err instanceof Error ? { tool: toolDef.name, error: err.message } : { tool: toolDef.name }); - } - } - } - ctx.logger.info(`[AI] ${toolDefsToPersist.length} data tools registered as metadata`); - } - - // Register the built-in agent + skills (requires metadata service). - // - // UPSERT, not exists-gate (ADR-0040): built-in records are - // platform-owned — when the shipped definition changes (new skills, - // new instructions), existing environments must pick it up on next - // boot. The old exists-gate froze the FIRST shipped version forever - // once sys_metadata became durable, which silently stranded every - // persona/skill improvement on existing envs. Tenants who want a - // different assistant define a CUSTOM agent and bind it via - // app.defaultAgent — editing built-ins in place is not a supported - // path, so an unconditional content-refresh clobbers nothing legit. - if (metadataService) { - const upsertBuiltin = async (type: string, name: string, def: unknown): Promise => { - try { - const stored = await withTimeout(metadataService.get(type, name)); - if (stored !== null && stored !== undefined && JSON.stringify(stored) === JSON.stringify(def)) { - ctx.logger.debug(`[AI] built-in ${type} ${name} up to date`); - return; - } - await withTimeout(metadataService.register(type, name, def)); - ctx.logger.info( - stored ? `[AI] built-in ${type} ${name} refreshed (shipped definition changed)` : `[AI] built-in ${type} ${name} registered`, - ); - } catch (err) { - ctx.logger.warn(`[AI] Failed to register built-in ${type} ${name}`, err instanceof Error ? { error: err.message } : { error: String(err) }); - } - }; - // Translate the agent's author `protection` block into the runtime - // `_lock`/`_provenance:'package'` envelope before persisting (the - // direct `metadataService.register` path does NOT run the loader's - // `applyProtection`, so do it here). That envelope is both the - // ADR-0010 lock and the intrinsic signal `AgentRuntime.listAgents()` - // uses to keep `ask` in the catalog independent of the alias table. - // Clone first so the shared `ASK_AGENT` export is never mutated. - await upsertBuiltin('agent', ASK_AGENT.name, applyProtection({ ...ASK_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 (ASK_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 → "${ASK_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', SCHEMA_READER_SKILL.name, SCHEMA_READER_SKILL); - await upsertBuiltin('skill', DATA_EXPLORER_SKILL.name, DATA_EXPLORER_SKILL); - await upsertBuiltin('skill', ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL); - } - } - } catch { - ctx.logger.debug('[AI] Data engine not available, skipping data tools'); - } + // NOTE: data Q&A intelligence — the `ask` agent + the data-explorer / + // actions-executor skills, AND the data tools (query/get/aggregate, + // query_data, visualize_data, action_* tools) + the schema_reader skill — + // is a commercial feature and now ships in the cloud-only + // @objectstack/service-ai-studio package; it attaches via the `ai:ready` + // hook below (the same extension point any third-party tool plugin uses), so + // the open-source AI runtime is HEADLESS — it registers no built-in + // agent/skills/tools. The data tools, schema_reader skill, and the + // agent-name alias registry stay open in this package as the mechanism the + // cloud persona imports and registers against. // NOTE: AI-driven metadata authoring (the metadata_assistant agent, the // metadata/blueprint/package authoring tools, and the metadata_authoring / @@ -895,8 +702,10 @@ export class AIServicePlugin implements Plugin { // them. Agents declared via defineStack({ agents: [...] }) are stored // in the ObjectQL registry by the AppPlugin, but the MetadataManager // keeps an independent in-memory store. Without this bridge, - // /api/v1/ai/agents would only return agents the AI plugin registered - // itself (data_chat, metadata_assistant). + // /api/v1/ai/agents would miss stack-defined agents entirely — the + // open-source AI plugin itself now registers NONE (the `ask`/`build` + // personas ship in the cloud @objectstack/service-ai-studio package and + // attach via `ai:ready`). if (metadataService) { try { const objectql = ctx.getService('objectql'); @@ -1338,19 +1147,6 @@ export class AIServicePlugin implements Plugin { } } -/** - * Derive a human-readable label from a snake_case tool name, e.g. - * `query_records` → `Query Records`. Used as a fallback when persisting - * an `AIToolDefinition` as `tool` metadata that has no explicit `label`. - */ -function toToolLabel(name: string): string { - return name - .split('_') - .filter(Boolean) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); -} - /** * Settings test handlers receive `payload` as the raw HTTP body. The Studio * form posts overrides in two shapes depending on caller: diff --git a/packages/services/service-ai/src/skills/actions-executor-skill.ts b/packages/services/service-ai/src/skills/actions-executor-skill.ts deleted file mode 100644 index 20d9d25e3a..0000000000 --- a/packages/services/service-ai/src/skills/actions-executor-skill.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Skill } from '@objectstack/spec/ai'; - -/** - * Built-in `actions_executor` skill — the write-side counterpart to - * `data_explorer`. - * - * Where `data_explorer` lets an agent **answer** questions about the - * user's data, `actions_executor` lets the same agent **perform** - * business operations: complete tasks, start workflows, send invites, - * etc. The concrete tools are not enumerated here — they're materialised - * at runtime from every `Action` declared on every object the metadata - * service knows about (see `registerActionsAsTools` in - * `tools/action-tools.ts`). The skill records the *intent* ("agent may - * invoke business actions"); the registry expands it into actual tools - * after metadata is loaded. - * - * The skill claims the `action_*` wildcard (ADR-0064): the SkillRegistry - * resolver expands it against the registered tools whose names start with - * `action_`. There is NO global fall-through — a tool reaches the agent only - * because a bound, surface-compatible skill claims its name (or pattern). - * Skills that want a narrower set should claim specific `action_` tools. - */ -export const ACTIONS_EXECUTOR_SKILL: Skill = { - name: 'actions_executor', - label: 'Action Executor', - surface: 'ask', - description: - "Perform business operations on the user's data — invoke actions like " + - "'mark as complete', 'start task', 'clone record' through natural language.", - instructions: `You can perform business operations by invoking the user's registered actions. - -Capabilities: -- Each tool whose name starts with \`action_\` is a business operation declared on an object. -- Read the tool description carefully — it tells you what the action does and what record types it applies to. -- Most actions need a \`recordId\` argument. If you don't already have one from a prior \`query_data\` call, run \`query_data\` first to find the right record, then invoke the action with its id. - -Guidelines: -1. Confirm intent — when the user says "complete it" / "start that one", make sure you know *which* record they mean. Ask if ambiguous. -2. Use \`query_data\` to look up records by natural-language description ("the design review task", "tickets assigned to me"). -3. After invoking an action, the tool returns \`{ ok, message, result }\`. Summarise success in plain language; surface errors verbatim. -4. Never invent recordIds. If \`query_data\` didn't return one, tell the user instead of guessing. -5. Action tools are pre-filtered for safety — destructive operations (\`mode: 'delete'\`, \`variant: 'danger'\`, anything with \`confirmText\`) are *not* exposed here and require explicit user confirmation in the UI. -6. Always answer in the same language the user is using.`, - // Dynamically materialised: the runtime registers one tool per Action, - // and the skill subscribes to the whole family via the `action_*` - // glob (resolved by SkillRegistry.flattenToTools). - tools: ['action_*'], - triggerPhrases: [ - 'complete', - 'mark as', - 'start', - 'finish', - 'clone', - 'duplicate', - 'do it', - 'run', - 'invoke', - 'execute', - ], - active: true, -}; diff --git a/packages/services/service-ai/src/skills/data-explorer-skill.ts b/packages/services/service-ai/src/skills/data-explorer-skill.ts deleted file mode 100644 index a699d86e95..0000000000 --- a/packages/services/service-ai/src/skills/data-explorer-skill.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Skill } from '@objectstack/spec/ai'; - -/** - * Built-in `data_explorer` skill — the records + aggregation + charting - * capability bundle the `ask` agent attaches to its `skills[]`. - * - * ADR-0063 §3 affinity: `surface: 'ask'`. The shared read-only schema - * tools (`describe_object`/`list_objects`/`query_data`) are NOT owned here - * — they live on the `surface:'both'` `schema_reader` skill so the `build` - * agent can reuse them without dual-listing (ADR-0064 §2). This skill keeps - * the `ask`-only exploration tools (record lookups, aggregation, charts). - * Its instructions still reference `describe_object`/`query_data` because - * those resolve from the sibling `schema_reader` skill on the same agent. - * - * Following the platform's metadata-driven philosophy, the agent itself - * does not hardcode which tools it can call; it names this skill and the - * SkillRegistry resolves the tool list at request time. - */ -export const DATA_EXPLORER_SKILL: Skill = { - name: 'data_explorer', - label: 'Data Explorer', - surface: 'ask', - description: 'Read-only Q&A over the user\'s business data — filtered record lookups, aggregations, and charts.', - instructions: `You can explore the user's business data through these tools. - -Capabilities: -- List available data objects (tables) and their schemas -- Query records with filters, sorting, and pagination -- Look up individual records by ID -- Perform aggregations and statistical analysis (count, sum, avg, min, max) -- Render results as a CHART when a visualization communicates the answer better than text - -Choosing the right tool (decide this BEFORE querying): -- The user wants a CHART — they say "chart/plot/graph/visualize/draw", "图表/柱状图/折线图/饼图/画图/可视化", OR they ask to show/compare/break down a count or sum grouped by a category, OR a trend over time → you MUST call visualize_data. It is the ONLY tool that draws a chart; query_data/aggregate_data return numbers, never a chart. Do NOT answer a chart request with a markdown table. If you already fetched the numbers, still call visualize_data to render them. The chart shows inline automatically — afterwards reply with one or two sentences describing it; do NOT re-print the numbers as a table. -- The user wants the underlying records or a single number → query_data / query_records / aggregate_data. - -Guidelines: -1. Always use the describe_object tool first to understand a table's structure before querying it. -2. Do NOT assume generic fields like \`status\`, \`is_active\`, \`deleted_at\`, \`type\`, or \`enabled\` exist on every object — they almost never do. Field names in \`where\`, \`fields\`, \`orderBy\`, \`groupBy\`, and aggregations MUST come from describe_object output. If the tool returns an "Unknown field" error, call describe_object on that object and retry with real field names. -3. Respect the user's current context — if they are viewing a specific object or record, use that as the default scope. -4. For record lists or a plain numeric answer, format clearly with markdown tables or bullet lists. When a chart was requested, use visualize_data instead of a table (see "Choosing the right tool" above). -5. For large result sets, summarize the data and mention the total count. -6. When performing aggregations, explain the results in plain language. -7. If a query returns no results, suggest possible reasons and alternative queries. -8. Never expose internal IDs unless the user explicitly asks for them. -9. Always answer in the same language the user is using.`, - // Read schema/query tools (describe_object, list_objects, query_data) are - // owned by the `schema_reader` (surface:'both') skill — not re-listed here. - tools: [ - 'query_records', - 'get_record', - 'aggregate_data', - 'visualize_data', - ], - triggerPhrases: [ - 'show me', - 'list', - 'how many', - 'count', - 'find records', - 'query', - 'aggregate', - 'sum', - 'average', - 'chart', - 'plot', - 'graph', - 'visualize', - 'trend', - 'breakdown', - 'distribution', - ], - active: true, -}; diff --git a/packages/services/service-ai/src/skills/index.ts b/packages/services/service-ai/src/skills/index.ts index 9f21f8289a..875e5fb162 100644 --- a/packages/services/service-ai/src/skills/index.ts +++ b/packages/services/service-ai/src/skills/index.ts @@ -1,7 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// `schema_reader` (surface:'both') is the shared, read-only schema/query +// capability both kernel agents need, so it stays open here as the mechanism. export { SCHEMA_READER_SKILL } from './schema-reader-skill.js'; -export { DATA_EXPLORER_SKILL } from './data-explorer-skill.js'; -export { ACTIONS_EXECUTOR_SKILL } from './actions-executor-skill.js'; -// The metadata_authoring + solution_design skills moved to the cloud-only -// @objectstack/service-ai-studio package. +// The `data_explorer` + `actions_executor` skills (the `ask` data product's +// exploration/action intelligence) and the metadata_authoring + solution_design +// authoring skills all moved to the cloud-only @objectstack/service-ai-studio +// package, which registers them on the `ai:ready` hook.