From ab930befb3729c5ed4cea7ceb21f1b18f36b9265 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:40:14 +0800 Subject: [PATCH] feat(service-ai): extract AI metadata authoring into cloud-only AI Studio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the AI-driven metadata-authoring layer out of the open-source @objectstack/service-ai (the metadata_assistant agent, metadata/blueprint/ package authoring tools, and metadata_authoring/solution_design skills). The generic AI runtime and the metadata WRITE mechanism stay open. - Delete the authoring agent/skills/tools + their tests from service-ai; clean the barrels and the inline plugin registration. The `ai:ready` hook is retained so the cloud package can attach. - capability-loader: add install-aware `aiStudio` capability (@objectstack/service-ai-studio → AIStudioPlugin; dynamic import, silently skipped when absent). - cli serve: auto-register AIStudioPlugin when the package is installed. - ArtifactKernelFactory / createObjectOSStack: add `defaultRequires` so a host can force-mount capabilities (e.g. ['ai','aiStudio']) on every per-environment kernel regardless of the app artifact's requires. - RuntimeConfigPlugin: add `features.aiStudio` (default true; override-to-hide). Authoring intelligence now ships only in the private cloud package and as an installable enterprise plugin. See cloud ADR-0002. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/serve.ts | 25 + .../src/cloud/artifact-kernel-factory.ts | 17 +- .../runtime/src/cloud/capability-loader.ts | 10 + packages/runtime/src/cloud/objectos-stack.ts | 10 + .../src/cloud/runtime-config-plugin.ts | 11 + .../src/__tests__/blueprint-tools.test.ts | 588 --------- .../src/__tests__/chatbot-features.test.ts | 134 +- .../src/__tests__/metadata-tools.test.ts | 881 ------------- .../solution-design-guardrail.test.ts | 37 - .../services/service-ai/src/agents/index.ts | 3 +- .../src/agents/metadata-assistant-agent.ts | 70 - packages/services/service-ai/src/index.ts | 39 +- packages/services/service-ai/src/plugin.ts | 143 +- .../services/service-ai/src/skills/index.ts | 4 +- .../src/skills/metadata-authoring-skill.ts | 71 - .../src/skills/solution-design-skill.ts | 58 - .../service-ai/src/tools/add-field.tool.ts | 74 -- .../src/tools/apply-blueprint.tool.ts | 33 - .../service-ai/src/tools/blueprint-tools.ts | 503 ------- .../src/tools/create-metadata.tool.ts | 49 - .../src/tools/create-object.tool.ts | 69 - .../src/tools/create-package.tool.ts | 54 - .../service-ai/src/tools/delete-field.tool.ts | 40 - .../src/tools/describe-metadata.tool.ts | 36 - .../src/tools/describe-object.tool.ts | 31 - .../src/tools/get-active-package.tool.ts | 23 - .../src/tools/get-metadata-schema.tool.ts | 36 - .../service-ai/src/tools/get-package.tool.ts | 29 - .../services/service-ai/src/tools/index.ts | 23 +- .../src/tools/list-metadata.tool.ts | 35 - .../service-ai/src/tools/list-objects.tool.ts | 34 - .../src/tools/list-packages.tool.ts | 34 - .../service-ai/src/tools/metadata-tools.ts | 1165 ----------------- .../service-ai/src/tools/modify-field.tool.ts | 48 - .../service-ai/src/tools/package-tools.ts | 396 ------ .../src/tools/propose-blueprint.tool.ts | 39 - .../src/tools/set-active-package.tool.ts | 30 - .../src/tools/update-metadata.tool.ts | 47 - .../src/tools/validate-expression.tool.ts | 50 - 39 files changed, 112 insertions(+), 4867 deletions(-) delete mode 100644 packages/services/service-ai/src/__tests__/blueprint-tools.test.ts delete mode 100644 packages/services/service-ai/src/__tests__/metadata-tools.test.ts delete mode 100644 packages/services/service-ai/src/__tests__/solution-design-guardrail.test.ts delete mode 100644 packages/services/service-ai/src/agents/metadata-assistant-agent.ts delete mode 100644 packages/services/service-ai/src/skills/metadata-authoring-skill.ts delete mode 100644 packages/services/service-ai/src/skills/solution-design-skill.ts delete mode 100644 packages/services/service-ai/src/tools/add-field.tool.ts delete mode 100644 packages/services/service-ai/src/tools/apply-blueprint.tool.ts delete mode 100644 packages/services/service-ai/src/tools/blueprint-tools.ts delete mode 100644 packages/services/service-ai/src/tools/create-metadata.tool.ts delete mode 100644 packages/services/service-ai/src/tools/create-object.tool.ts delete mode 100644 packages/services/service-ai/src/tools/create-package.tool.ts delete mode 100644 packages/services/service-ai/src/tools/delete-field.tool.ts delete mode 100644 packages/services/service-ai/src/tools/describe-metadata.tool.ts delete mode 100644 packages/services/service-ai/src/tools/describe-object.tool.ts delete mode 100644 packages/services/service-ai/src/tools/get-active-package.tool.ts delete mode 100644 packages/services/service-ai/src/tools/get-metadata-schema.tool.ts delete mode 100644 packages/services/service-ai/src/tools/get-package.tool.ts delete mode 100644 packages/services/service-ai/src/tools/list-metadata.tool.ts delete mode 100644 packages/services/service-ai/src/tools/list-objects.tool.ts delete mode 100644 packages/services/service-ai/src/tools/list-packages.tool.ts delete mode 100644 packages/services/service-ai/src/tools/metadata-tools.ts delete mode 100644 packages/services/service-ai/src/tools/modify-field.tool.ts delete mode 100644 packages/services/service-ai/src/tools/package-tools.ts delete mode 100644 packages/services/service-ai/src/tools/propose-blueprint.tool.ts delete mode 100644 packages/services/service-ai/src/tools/set-active-package.tool.ts delete mode 100644 packages/services/service-ai/src/tools/update-metadata.tool.ts delete mode 100644 packages/services/service-ai/src/tools/validate-expression.tool.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3c1830edf..6f5788d9b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1365,6 +1365,31 @@ export default class Serve extends Command { } // @objectstack/service-ai not installed — AI features unavailable } + + // 4b. Auto-register AI Studio (AI-driven metadata authoring / "online + // development") when the private @objectstack/service-ai-studio package + // is installed. It is NOT part of the open-source framework: the dynamic + // import below silently skips when absent, so open-source installs get + // the generic AI runtime only. Enterprise installs that ship the package + // get full AI authoring. AIStudioPlugin attaches via the `ai:ready` hook. + const hasAIStudio = plugins.some( + (p: any) => p.name === 'com.objectstack.service-ai-studio' + || p.constructor?.name === 'AIStudioPlugin' + ); + if (!hasAIStudio) { + try { + const studioPkg = '@objectstack/service-ai-studio'; + const { AIStudioPlugin } = await import(/* webpackIgnore: true */ studioPkg); + await kernel.use(new AIStudioPlugin()); + trackPlugin('AIStudio'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) { + console.error('[AI Studio] AIStudioPlugin failed to start:', msg); + } + // @objectstack/service-ai-studio not installed — AI authoring unavailable + } + } } // 5. Capability resolver — auto-load service plugins declared in diff --git a/packages/runtime/src/cloud/artifact-kernel-factory.ts b/packages/runtime/src/cloud/artifact-kernel-factory.ts index c611a1dc5..c19fc6b18 100644 --- a/packages/runtime/src/cloud/artifact-kernel-factory.ts +++ b/packages/runtime/src/cloud/artifact-kernel-factory.ts @@ -61,6 +61,12 @@ export interface ArtifactKernelFactoryConfig { * `process.env.OS_AUTH_SECRET` / `AUTH_SECRET` at construction time. */ authBaseSecret?: string; + /** + * Capability tokens force-mounted on every per-environment kernel, merged + * (and de-duped by the loader) with the artifact's own `requires`. Lets a + * host make a capability ubiquitous across tenants — e.g. `['ai','aiStudio']`. + */ + defaultRequires?: string[]; } /** @@ -81,12 +87,14 @@ export class ArtifactKernelFactory implements EnvironmentKernelFactory { private readonly logger: NonNullable; private readonly kernelConfig?: ArtifactKernelFactoryConfig['kernelConfig']; private readonly authBaseSecret: string; + private readonly defaultRequires: string[]; constructor(config: ArtifactKernelFactoryConfig) { this.client = config.client; this.envRegistry = config.envRegistry; this.logger = config.logger ?? console; this.kernelConfig = config.kernelConfig; + this.defaultRequires = config.defaultRequires ?? []; this.authBaseSecret = ( config.authBaseSecret ?? readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET']) @@ -363,8 +371,13 @@ export class ArtifactKernelFactory implements EnvironmentKernelFactory { (Array.isArray(bundle?.requires) ? bundle.requires : null) ?? (Array.isArray(sys?.requires) ? sys.requires : null) ?? []; - const requires: string[] = (requiresRaw as unknown[]) - .filter((x): x is string => typeof x === 'string' && x.length > 0); + // Merge host-forced defaults (e.g. cloud's ['ai','aiStudio']) with the + // artifact's own requires. loadCapabilities de-dupes via a Set, so + // overlap is safe. + const requires: string[] = [ + ...(requiresRaw as unknown[]), + ...this.defaultRequires, + ].filter((x): x is string => typeof x === 'string' && x.length > 0); if (requires.length > 0) { const installed = await loadCapabilities({ diff --git a/packages/runtime/src/cloud/capability-loader.ts b/packages/runtime/src/cloud/capability-loader.ts index a61e71f10..de086eaf2 100644 --- a/packages/runtime/src/cloud/capability-loader.ts +++ b/packages/runtime/src/cloud/capability-loader.ts @@ -56,6 +56,16 @@ export const CAPABILITY_PROVIDERS: Record = { pkg: '@objectstack/service-ai', export: 'AIServicePlugin', }, + // AI Studio — AI-driven metadata authoring ("online development"). This is + // a commercial capability that ships in the private @objectstack/service-ai-studio + // package (not part of the open-source framework). The dynamic import below + // silently skips when the package isn't installed, so the open-source build + // is unaffected; cloud and enterprise installs that ship the package light it + // up. Pair with `ai` in `requires` (it attaches via the `ai:ready` hook). + aiStudio: { + pkg: '@objectstack/service-ai-studio', + export: 'AIStudioPlugin', + }, analytics: { pkg: '@objectstack/service-analytics', export: 'AnalyticsServicePlugin', diff --git a/packages/runtime/src/cloud/objectos-stack.ts b/packages/runtime/src/cloud/objectos-stack.ts index 32f9d31e5..13d503e8b 100644 --- a/packages/runtime/src/cloud/objectos-stack.ts +++ b/packages/runtime/src/cloud/objectos-stack.ts @@ -79,6 +79,15 @@ export interface ObjectOSStackConfig { * (ADR §5.2 — "framework exposes seams; cloud supplies metadata + policy"). */ extraPlugins?: Plugin[]; + /** + * Capability tokens force-mounted on EVERY per-environment kernel, in + * addition to whatever the app artifact declares in `requires`. Merged and + * de-duped with `bundle.requires` before the capability loader runs. This + * is the host seam for a cloud operator to make a capability ubiquitous + * across all tenants without editing each app — e.g. `['ai','aiStudio']` + * so every cloud environment supports AI-driven online development. + */ + defaultRequires?: string[]; } export interface ObjectOSStackResult { @@ -209,6 +218,7 @@ class ObjectOSEnvironmentPlugin implements Plugin { client: client as ArtifactApiClient, envRegistry, logger: ctx.logger, + defaultRequires: this.config.defaultRequires, }); const kernelManager = new KernelManager({ diff --git a/packages/runtime/src/cloud/runtime-config-plugin.ts b/packages/runtime/src/cloud/runtime-config-plugin.ts index f7ee52be7..c2e4707dd 100644 --- a/packages/runtime/src/cloud/runtime-config-plugin.ts +++ b/packages/runtime/src/cloud/runtime-config-plugin.ts @@ -40,6 +40,14 @@ export interface RuntimeConfigPluginConfig { controlPlaneUrl?: string; /** Override the `features.installLocal` flag. Default: false. */ installLocal?: boolean; + /** + * Override the `features.aiStudio` flag — whether the SPA should surface + * AI-driven metadata authoring ("online development") affordances. Default: + * true (the actual authoring capability is still gated server-side by the + * presence of the `metadata_assistant` agent / @objectstack/service-ai-studio + * package; set false to force-hide the authoring UI for a tier/deployment). + */ + aiStudio?: boolean; /** * Report this runtime as a single-environment deployment (CLI * `objectstack dev` / `os serve`). Defaults to `false` for @@ -63,6 +71,7 @@ export class RuntimeConfigPlugin implements Plugin { private readonly cloudUrl: string; private readonly installLocal: boolean; + private readonly aiStudio: boolean; private readonly singleEnvironment: boolean; private readonly productName: string; private readonly productShortName: string; @@ -74,6 +83,7 @@ export class RuntimeConfigPlugin implements Plugin { ? '' : (resolveCloudUrl(config.controlPlaneUrl) ?? ''); this.installLocal = !!config.installLocal; + this.aiStudio = config.aiStudio !== false; // default true (override-to-hide) this.singleEnvironment = !!config.singleEnvironment; const envName = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_NAME : undefined)?.trim(); const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim(); @@ -113,6 +123,7 @@ export class RuntimeConfigPlugin implements Plugin { const features = { installLocal: this.installLocal, marketplace: true, + aiStudio: this.aiStudio, }; let envRegistry: any = null; try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ } diff --git a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts deleted file mode 100644 index 9ce9f018d..000000000 --- a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts +++ /dev/null @@ -1,588 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SolutionBlueprintStrictSchema, type SolutionBlueprint } from '@objectstack/spec/ai'; -import { ToolRegistry } from '../tools/tool-registry.js'; -import { - registerBlueprintTools, - BLUEPRINT_TOOL_DEFINITIONS, - type BlueprintToolContext, -} from '../tools/blueprint-tools.js'; - -// ── Helpers ──────────────────────────────────────────────────────── - -const SAMPLE_BLUEPRINT: SolutionBlueprint = { - summary: 'A project tracker', - assumptions: ['Projects own many tasks'], - objects: [ - { name: 'project', label: 'Project', fields: [{ name: 'name', type: 'text', required: true }] }, - { - name: 'task', label: 'Task', - fields: [ - { name: 'title', type: 'text', required: true }, - { name: 'project_id', type: 'lookup', reference: 'project' }, - ], - }, - ], - views: [{ object: 'task', name: 'open_tasks', label: 'Open Tasks', type: 'list', columns: ['title'] }], - seedData: [{ object: 'project', records: [{ name: 'Apollo' }, { name: 'Gemini' }] }], -}; - -/** Mock protocol with a draft store + saveMetaItem honoring mode:'draft'. */ -function createMockProtocol(existingObjects: string[] = []) { - const drafts = new Map(); - const saveMetaItem = vi.fn(async (req: any) => { - if (req.mode === 'draft') drafts.set(`${req.type}:${req.name}`, req.item); - return { success: true }; - }); - const getMetaItems = vi.fn(async (_req: any) => - existingObjects.map((name) => ({ name, label: name })), - ); - const getMetaItem = vi.fn(async () => ({ item: undefined })); - const protocol = { getMetaItems, getMetaItem, saveMetaItem } as NonNullable; - return { protocol, drafts, saveMetaItem, getMetaItems }; -} - -function createMockMetadataService() { - 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 () => []), - } as any; -} - -/** Mock AI service whose generateObject returns a fixed blueprint. */ -function createMockAi(blueprint: SolutionBlueprint = SAMPLE_BLUEPRINT) { - const generateObject = vi.fn(async () => ({ object: blueprint, model: 'mock', usage: undefined })); - return { ai: { generateObject } as any, generateObject }; -} - -function parse(result: any): any { - return JSON.parse((result.output as any).value); -} - -const call = (toolName: string, input: Record, id = 't') => ({ - type: 'tool-call' as const, - toolCallId: id, - toolName, - input, -}); - -// ═══════════════════════════════════════════════════════════════════ -// Definitions & registration -// ═══════════════════════════════════════════════════════════════════ - -describe('Blueprint tool definitions', () => { - it('defines exactly propose_blueprint + apply_blueprint', () => { - expect(BLUEPRINT_TOOL_DEFINITIONS.map((t) => t.name)).toEqual(['propose_blueprint', 'apply_blueprint']); - }); - - it('registers both tools separately (so the model must take two turns)', () => { - const registry = new ToolRegistry(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, - protocol: createMockProtocol().protocol, - metadataService: createMockMetadataService(), - }); - expect(registry.has('propose_blueprint')).toBe(true); - expect(registry.has('apply_blueprint')).toBe(true); - expect(registry.size).toBe(2); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// propose_blueprint -// ═══════════════════════════════════════════════════════════════════ - -describe('propose_blueprint handler', () => { - let registry: ToolRegistry; - let saveMetaItem: ReturnType; - let generateObject: ReturnType; - - beforeEach(() => { - registry = new ToolRegistry(); - const proto = createMockProtocol(['existing_obj']); - const ai = createMockAi(); - saveMetaItem = proto.saveMetaItem; - generateObject = ai.generateObject; - registerBlueprintTools(registry, { ai: ai.ai, protocol: proto.protocol, metadataService: createMockMetadataService() }); - }); - - it('returns a proposed blueprint and persists NOTHING', async () => { - const parsed = parse(await registry.execute(call('propose_blueprint', { goal: 'build a project tracker' }))); - expect(parsed.status).toBe('blueprint_proposed'); - expect(parsed.blueprint.objects).toHaveLength(2); - expect(parsed.counts).toEqual({ objects: 2, views: 1, dashboards: 0, app: 0, seedData: 1 }); - // Crucially: proposing creates no drafts. - expect(saveMetaItem).not.toHaveBeenCalled(); - expect(generateObject).toHaveBeenCalledOnce(); - }); - - it('includes existing object names in the model context', async () => { - await registry.execute(call('propose_blueprint', { goal: 'extend the system' })); - const messages = generateObject.mock.calls[0][0] as Array<{ role: string; content: string }>; - expect(messages[0].content).toContain('existing_obj'); - }); - - it('errors when goal is missing', async () => { - const parsed = parse(await registry.execute(call('propose_blueprint', {}))); - expect(parsed.error).toContain('goal'); - }); - - it('errors cleanly when the adapter lacks structured output', async () => { - const registry2 = new ToolRegistry(); - registerBlueprintTools(registry2, { - ai: { /* no generateObject */ } as any, - protocol: createMockProtocol().protocol, - metadataService: createMockMetadataService(), - }); - const parsed = parse(await registry2.execute(call('propose_blueprint', { goal: 'x' }))); - expect(parsed.error).toContain('structured-output'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// apply_blueprint -// ═══════════════════════════════════════════════════════════════════ - -describe('apply_blueprint handler', () => { - let registry: ToolRegistry; - let drafts: Map; - let saveMetaItem: ReturnType; - let metadataService: any; - - beforeEach(() => { - registry = new ToolRegistry(); - const proto = createMockProtocol(); - drafts = proto.drafts; - saveMetaItem = proto.saveMetaItem; - metadataService = createMockMetadataService(); - registerBlueprintTools(registry, { ai: createMockAi().ai, protocol: proto.protocol, metadataService }); - }); - - it('batch-drafts every object and view via mode:draft, never publishing', async () => { - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: SAMPLE_BLUEPRINT }))); - - expect(parsed.status).toBe('drafted'); - expect(parsed.drafted).toEqual([ - { type: 'object', name: 'project' }, - { type: 'object', name: 'task' }, - // View is named `.` so the console binds it to the object. - { type: 'view', name: 'task.open_tasks' }, - ]); - expect(parsed.failed).toEqual([]); - - // Every write was a draft; the live-publish path is never touched. - for (const c of saveMetaItem.mock.calls) expect(c[0].mode).toBe('draft'); - expect(metadataService.register).not.toHaveBeenCalled(); - - // Object body expanded fields into a record keyed by name. - const task = drafts.get('object:task') as any; - expect(task.fields.project_id).toMatchObject({ type: 'lookup', reference: 'project' }); - // View is the canonical record shape: top-level object + viewKind + config - // (NOT a bare `{ list }`), so the console can bind + render it. - const view = drafts.get('view:task.open_tasks') as any; - // Top-level name is REQUIRED — getMetaItems only surfaces overlay rows - // whose body carries `name`, so a nameless view never lists as a tab. - expect(view.name).toBe('task.open_tasks'); - expect(view.object).toBe('task'); - expect(view.viewKind).toBe('list'); - expect(view.config.data).toEqual({ provider: 'object', object: 'task' }); - expect(view.config.columns).toEqual(['title']); - }); - - it('surfaces packageId + bindingHint so follow-up automation (a flow) binds to the app package', async () => { - const reg = new ToolRegistry(); - const proto = createMockProtocol(); - // installPackage present → ensureAppPackage materialises an app package. - (proto.protocol as any).installPackage = vi.fn(async () => ({ success: true })); - registerBlueprintTools(reg, { ai: createMockAi().ai, protocol: proto.protocol, metadataService: createMockMetadataService() }); - - const parsed = parse(await reg.execute(call('apply_blueprint', { - blueprint: { ...SAMPLE_BLUEPRINT, app: { name: 'pm_app', label: 'PM' } }, - }))); - - // Without these, the agent's follow-up create_metadata(flow) had no package - // to bind to and produced an ORPHAN flow draft. - expect(parsed.packageId).toBe('app.pm_app'); - expect(parsed.bindingHint).toContain('app.pm_app'); - expect(parsed.bindingHint).toMatch(/create_metadata/); - }); - - it('emits kanban config (groupByField + columns) — explicit groupBy wins, else infers the select field', async () => { - const blueprint = { - summary: 'recruiting', - objects: [{ - name: 'lead', - label: 'Lead', - fields: [ - { name: 'name', label: 'Name', type: 'text' }, - { name: 'stage', label: 'Stage', type: 'select', options: [{ label: 'New', value: 'new' }] }, - ], - }], - views: [ - { object: 'lead', name: 'lead_board', label: 'Board', type: 'kanban', columns: ['name', 'stage'] }, - { object: 'lead', name: 'lead_board2', label: 'Board2', type: 'kanban', columns: ['name'], groupBy: 'stage' }, - ], - }; - await registry.execute(call('apply_blueprint', { blueprint })); - - // Inferred from the object's first select field. - const inferred = drafts.get('view:lead.lead_board') as any; - expect(inferred.object).toBe('lead'); - expect(inferred.viewKind).toBe('list'); - expect(inferred.config.type).toBe('kanban'); - expect(inferred.config.kanban).toEqual({ groupByField: 'stage', columns: ['name', 'stage'] }); - // Explicit groupBy on the view wins. - const explicit = drafts.get('view:lead.lead_board2') as any; - expect(explicit.config.kanban.groupByField).toBe('stage'); - }); - - it('reports seed data as proposed-but-not-applied', async () => { - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: SAMPLE_BLUEPRINT }))); - expect(parsed.seedDataProposed).toEqual([{ object: 'project', rows: 2 }]); - // No draft was written for the seed (no 'dataset' type). - expect(drafts.has('dataset:project')).toBe(false); - }); - - it('isolates a per-item failure — others still draft', async () => { - // Make the view write fail, objects succeed. - saveMetaItem.mockImplementation(async (req: any) => { - if (req.type === 'view') { - const e: any = new Error('[invalid_metadata] view/open_tasks failed spec validation'); - e.code = 'invalid_metadata'; - throw e; - } - return { success: true }; - }); - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: SAMPLE_BLUEPRINT }))); - expect(parsed.drafted.map((d: any) => d.name)).toEqual(['project', 'task']); - expect(parsed.failed).toHaveLength(1); - expect(parsed.failed[0]).toMatchObject({ type: 'view', name: 'task.open_tasks', code: 'invalid_metadata' }); - // Partial success is still 'drafted' (some items landed). - expect(parsed.status).toBe('drafted'); - }); - - it('rejects a malformed blueprint with fixable issues (nothing drafted)', async () => { - const parsed = parse(await registry.execute(call('apply_blueprint', { - blueprint: { summary: 'bad', objects: [{ name: 'X', fields: [{ name: 'f', type: 'text' }] }] }, - }))); - expect(parsed.error).toContain('validation'); - expect(Array.isArray(parsed.issues)).toBe(true); - expect(saveMetaItem).not.toHaveBeenCalled(); - }); - - it('errors when blueprint is missing', async () => { - const parsed = parse(await registry.execute(call('apply_blueprint', {}))); - expect(parsed.error).toContain('blueprint'); - }); - - it('defaults view columns to the object fields when none are given', async () => { - const bp: SolutionBlueprint = { - summary: 'x', - assumptions: [], - objects: [{ name: 'lead', fields: [{ name: 'name', type: 'text' }, { name: 'email', type: 'email' }] }], - views: [{ object: 'lead', name: 'all_leads', type: 'list' }], - }; - await registry.execute(call('apply_blueprint', { blueprint: bp })); - const view = drafts.get('view:lead.all_leads') as any; - expect(view.config.columns).toEqual(['name', 'email']); - }); - - it('drafts the app (navigation shell) with explicit nav referencing the objects', async () => { - const bp: SolutionBlueprint = { - ...SAMPLE_BLUEPRINT, - app: { - name: 'project_mgmt', - label: 'Project Management', - icon: 'kanban', - nav: [ - { type: 'object', target: 'project', label: 'Projects' }, - { type: 'object', target: 'task' }, - ], - }, - }; - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: bp }))); - expect(parsed.drafted).toContainEqual({ type: 'app', name: 'project_mgmt' }); - expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', mode: 'draft' })); - - const app = drafts.get('app:project_mgmt') as any; - expect(app.label).toBe('Project Management'); - expect(app.icon).toBe('kanban'); - expect(app.isDefault).toBeUndefined(); // never hijack the default app - expect(app.navigation).toEqual([ - { id: 'nav_project', label: 'Projects', order: 0, type: 'object', objectName: 'project' }, - { id: 'nav_task', label: 'task', order: 1, type: 'object', objectName: 'task' }, - ]); - }); - - it('auto-surfaces every object then dashboard when app.nav is omitted', async () => { - const bp: SolutionBlueprint = { - summary: 'crm', - assumptions: [], - objects: [ - { name: 'account', label: 'Account', fields: [{ name: 'name', type: 'text' }] }, - { name: 'contact', label: 'Contact', fields: [{ name: 'name', type: 'text' }] }, - ], - dashboards: [{ name: 'sales', label: 'Sales', widgets: [] }], - app: { name: 'crm', label: 'CRM' }, - }; - await registry.execute(call('apply_blueprint', { blueprint: bp })); - const app = drafts.get('app:crm') as any; - expect(app.navigation).toEqual([ - { id: 'nav_account', label: 'Account', order: 0, type: 'object', objectName: 'account' }, - { id: 'nav_contact', label: 'Contact', order: 1, type: 'object', objectName: 'contact' }, - { id: 'nav_sales', label: 'Sales', order: 2, type: 'dashboard', dashboardName: 'sales' }, - ]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// OpenAI strict structured outputs (live-verified bug: optional fields made -// OpenAI reject the schema; the model emits null for "empty" fields) -// ═══════════════════════════════════════════════════════════════════ - -describe('blueprint ⨯ OpenAI strict structured outputs', () => { - // A blueprint shaped like the strict mirror's output: every optional field - // present as `null` rather than absent. - const bpWithNulls: any = { - summary: 's', - assumptions: [], - questions: null, - objects: [ - { - name: 'project', - label: 'Project', - description: null, - fields: [ - { name: 'name', label: null, type: 'text', required: null, reference: null, options: null }, - ], - }, - ], - views: null, - dashboards: null, - app: null, - }; - - it('propose_blueprint uses the strict mirror schema and strips the model\'s nulls', async () => { - const registry = new ToolRegistry(); - const generateObject = vi.fn(async () => ({ object: bpWithNulls, model: 'mock', usage: undefined })); - registerBlueprintTools(registry, { - ai: { generateObject } as any, - protocol: createMockProtocol().protocol, - metadataService: createMockMetadataService(), - }); - - const parsed = parse(await registry.execute(call('propose_blueprint', { goal: 'x' }))); - - // The OpenAI-strict mirror is the output contract sent to generateObject. - expect((generateObject.mock.calls[0] as unknown[])[1]).toBe(SolutionBlueprintStrictSchema); - // Nulls are stripped so the result conforms to the lenient schema. - expect(parsed.status).toBe('blueprint_proposed'); - expect(parsed.blueprint.objects[0].description).toBeUndefined(); - expect(parsed.blueprint.objects[0].fields[0].label).toBeUndefined(); - expect(parsed.blueprint.views).toBeUndefined(); - expect(parsed.blueprint.app).toBeUndefined(); - }); - - it('apply_blueprint tolerates a blueprint carrying nulls (strips before validating)', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocol(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, - protocol: proto.protocol, - metadataService: createMockMetadataService(), - }); - - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: bpWithNulls }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.drafted).toEqual([{ type: 'object', name: 'project' }]); - // null field props were stripped, not persisted as null - const project = proto.drafts.get('object:project') as any; - expect(project.fields.name).toEqual({ type: 'text' }); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// Zero-package app building — apply_blueprint auto-homes the app's artifacts -// in a writable app package (one app ⇒ one package), best-effort. -// ═══════════════════════════════════════════════════════════════════ - -function createMockPackageService(existing: string[] = []) { - const published: any[] = []; - const get = vi.fn(async (id: string) => - existing.includes(id) ? { manifest: { id, name: id } } : null, - ); - const publish = vi.fn(async (data: any) => { - published.push(data); - return { success: true }; - }); - return { svc: { get, publish }, get, publish, published }; -} - -const APP_BLUEPRINT: SolutionBlueprint = { - summary: 'pm', - assumptions: [], - objects: [{ name: 'project', label: 'Project', fields: [{ name: 'name', type: 'text' }] }], - views: [{ object: 'project', name: 'all_projects', type: 'list', columns: ['name'] }], - app: { name: 'project_management', label: '项目管理', nav: [{ type: 'object', target: 'project' }] }, -}; - -describe('apply_blueprint — auto app package', () => { - it('creates app. once and binds every artifact to it', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocol(); - const pkg = createMockPackageService(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, - metadataService: createMockMetadataService(), packageService: pkg.svc as any, - }); - - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - - // looked up, found absent, published exactly one app package - expect(pkg.get).toHaveBeenCalledWith('app.project_management'); - expect(pkg.publish).toHaveBeenCalledOnce(); - expect(pkg.published[0].manifest).toMatchObject({ - id: 'app.project_management', type: 'application', namespace: 'project_management', scope: 'environment', - }); - // every staged artifact carries the package id - expect(proto.saveMetaItem.mock.calls.length).toBe(3); // object + view + app - for (const c of proto.saveMetaItem.mock.calls) { - expect(c[0].packageId).toBe('app.project_management'); - expect(c[0].mode).toBe('draft'); - } - expect(parsed.package).toEqual({ id: 'app.project_management', name: '项目管理', created: true }); - }); - - it('reuses an existing app package (no second publish) and still binds', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocol(); - const pkg = createMockPackageService(['app.project_management']); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, - metadataService: createMockMetadataService(), packageService: pkg.svc as any, - }); - - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - expect(pkg.publish).not.toHaveBeenCalled(); - expect(parsed.package).toEqual({ id: 'app.project_management', name: 'app.project_management', created: false }); - for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBe('app.project_management'); - }); - - it('falls back to package-less drafting when no package service is wired', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocol(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, metadataService: createMockMetadataService(), - }); - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.package).toBeUndefined(); - for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBeUndefined(); - }); - - it('does nothing package-wise when the blueprint has no app', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocol(); - const pkg = createMockPackageService(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, - metadataService: createMockMetadataService(), packageService: pkg.svc as any, - }); - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: SAMPLE_BLUEPRINT }))); - expect(pkg.get).not.toHaveBeenCalled(); - expect(pkg.publish).not.toHaveBeenCalled(); - expect(parsed.package).toBeUndefined(); - }); - - it('still drafts when publish fails (degrades to package-less, never blocks)', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocol(); - const pkg = createMockPackageService(); - pkg.publish.mockResolvedValueOnce({ success: false, error: 'boom' } as any); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, - metadataService: createMockMetadataService(), packageService: pkg.svc as any, - }); - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.package).toBeUndefined(); - for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBeUndefined(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// ADR-0033 consolidation — when the protocol exposes the canonical -// `installPackage` primitive, the app package is written through it (registry -// + sys_packages) instead of the legacy package-service publish, so the app -// actually surfaces in Studio. -// ═══════════════════════════════════════════════════════════════════ - -function createMockProtocolWithInstall(existingObjects: string[] = []) { - const base = createMockProtocol(existingObjects); - const installPackage = vi.fn(async (req: any) => ({ package: { manifest: req.manifest }, message: 'ok' })); - (base.protocol as any).installPackage = installPackage; - return { ...base, installPackage }; -} - -describe('apply_blueprint — app package via protocol.installPackage', () => { - it('prefers protocol.installPackage over the legacy publish and binds artifacts', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocolWithInstall(); - const pkg = createMockPackageService(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, - metadataService: createMockMetadataService(), packageService: pkg.svc as any, - }); - - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - - expect(proto.installPackage).toHaveBeenCalledOnce(); - expect((proto.installPackage.mock.calls[0] as any[])[0].manifest).toMatchObject({ - id: 'app.project_management', type: 'application', namespace: 'project_management', scope: 'environment', - }); - expect(pkg.publish).not.toHaveBeenCalled(); // canonical primitive wins; legacy publish skipped - expect(parsed.package).toEqual({ id: 'app.project_management', name: '项目管理', created: true }); - expect(proto.saveMetaItem.mock.calls.length).toBe(3); // object + view + app - for (const c of proto.saveMetaItem.mock.calls) { - expect(c[0].packageId).toBe('app.project_management'); - expect(c[0].mode).toBe('draft'); - } - }); - - it('installs via protocol.installPackage even with no package service wired', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocolWithInstall(); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, metadataService: createMockMetadataService(), - }); - - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - - expect(proto.installPackage).toHaveBeenCalledOnce(); - expect(parsed.package).toEqual({ id: 'app.project_management', name: '项目管理', created: true }); - for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBe('app.project_management'); - }); - - it('reuses an existing package (get-guard) without calling installPackage', async () => { - const registry = new ToolRegistry(); - const proto = createMockProtocolWithInstall(); - const pkg = createMockPackageService(['app.project_management']); - registerBlueprintTools(registry, { - ai: createMockAi().ai, protocol: proto.protocol, - metadataService: createMockMetadataService(), packageService: pkg.svc as any, - }); - - const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); - - expect(proto.installPackage).not.toHaveBeenCalled(); - expect(pkg.publish).not.toHaveBeenCalled(); - expect(parsed.package).toEqual({ id: 'app.project_management', name: 'app.project_management', created: false }); - }); -}); 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 99a0f8a7f..90c61b248 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -20,7 +20,20 @@ import { SkillRegistry } from '../skill-registry.js'; import type { AgentChatContext } from '../agent-runtime.js'; import { buildAgentRoutes } from '../routes/agent-routes.js'; import { DATA_CHAT_AGENT } from '../agents/data-chat-agent.js'; -import { METADATA_ASSISTANT_AGENT } from '../agents/metadata-assistant-agent.js'; + +// The real metadata_assistant agent moved to the cloud-only +// @objectstack/service-ai-studio package (AI authoring is a commercial +// feature). This local stub stands in as a generic "second agent" fixture +// for the runtime/route agent-listing tests below. It derives from the real +// data_chat agent so it satisfies AgentSchema, then overrides identity fields. +const METADATA_ASSISTANT_AGENT = { + ...DATA_CHAT_AGENT, + name: 'metadata_assistant', + label: 'Metadata Assistant', + role: 'Schema Architect', + active: true, + visibility: 'global', +} as any; // ── Helpers ──────────────────────────────────────────────────────── @@ -345,74 +358,9 @@ describe('Data Tools', () => { expect(registry.has('aggregate_data')).toBe(true); }); - it('list_objects should return object names and labels (via metadata tools)', async () => { - // list_objects is now part of metadata tools — register them - const { registerMetadataTools } = await import('../tools/metadata-tools.js'); - registerMetadataTools(registry, { metadataService }); - - (metadataService.listObjects as any).mockResolvedValue([ - { name: 'account', label: 'Account', fields: { name: { type: 'text' } } }, - { name: 'contact', label: 'Contact', fields: {} }, - ]); - - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'list_objects', - input: {}, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.objects).toHaveLength(2); - expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', label: 'Account' })); - }); - - it('describe_object should return field schema (via metadata tools)', async () => { - // describe_object is now part of metadata tools — register them - const { registerMetadataTools } = await import('../tools/metadata-tools.js'); - registerMetadataTools(registry, { metadataService }); - - (metadataService.getObject as any).mockResolvedValue({ - name: 'account', - label: 'Account', - fields: { - name: { type: 'text', label: 'Account Name', required: true }, - revenue: { type: 'number', label: 'Revenue' }, - }, - }); - - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'describe_object', - input: { objectName: 'account' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.name).toBe('account'); - // Unified handler returns fields as array (not object) - const nameField = parsed.fields.find((f: any) => f.name === 'name'); - expect(nameField.type).toBe('text'); - expect(nameField.required).toBe(true); - const revenueField = parsed.fields.find((f: any) => f.name === 'revenue'); - expect(revenueField.type).toBe('number'); - }); - - it('describe_object should return error for unknown object (via metadata tools)', async () => { - // describe_object is now part of metadata tools — register them - const { registerMetadataTools } = await import('../tools/metadata-tools.js'); - registerMetadataTools(registry, { metadataService }); - - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'describe_object', - input: { objectName: 'nonexistent' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.error).toContain('not found'); - }); + // NOTE: list_objects / describe_object are part of the metadata authoring + // tools, which moved to the cloud-only @objectstack/service-ai-studio + // package. Their tests live there now (see metadata-tools.test.ts). it('query_records should call dataEngine.find with correct params', async () => { const records = [{ id: '1', name: 'Acme' }, { id: '2', name: 'Beta' }]; @@ -1059,49 +1007,5 @@ describe('DATA_CHAT_AGENT', () => { }); }); -// ═══════════════════════════════════════════════════════════════════ -// Metadata Assistant Agent Spec -// ═══════════════════════════════════════════════════════════════════ - -describe('METADATA_ASSISTANT_AGENT', () => { - it('should be a valid agent definition', () => { - expect(METADATA_ASSISTANT_AGENT.name).toBe('metadata_assistant'); - expect(METADATA_ASSISTANT_AGENT.label).toBe('Metadata Assistant'); - expect(METADATA_ASSISTANT_AGENT.role).toBe('Schema Architect'); - expect(METADATA_ASSISTANT_AGENT.active).toBe(true); - expect(METADATA_ASSISTANT_AGENT.visibility).toBe('global'); - }); - - it('should reference the metadata_authoring + solution_design skills (capability bundles moved to skill metadata)', () => { - expect(METADATA_ASSISTANT_AGENT.tools ?? []).toHaveLength(0); - // ADR-0033: per-item authoring + plan-first blueprint authoring. - expect(METADATA_ASSISTANT_AGENT.skills).toEqual(['metadata_authoring', 'solution_design']); - }); - - it('should keep the schema-architect persona in instructions', () => { - const instructions = METADATA_ASSISTANT_AGENT.instructions; - expect(instructions).toMatch(/architect/i); - }); - - it('should have guardrails configured', () => { - expect(METADATA_ASSISTANT_AGENT.guardrails).toBeDefined(); - expect(METADATA_ASSISTANT_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0); - expect(METADATA_ASSISTANT_AGENT.guardrails!.blockedTopics).toBeDefined(); - }); - - it('should have model config with low temperature for schema ops', () => { - expect(METADATA_ASSISTANT_AGENT.model).toBeDefined(); - expect(METADATA_ASSISTANT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); - }); - - it('should allow higher maxIterations for multi-step schema changes', () => { - expect(METADATA_ASSISTANT_AGENT.planning).toBeDefined(); - expect(METADATA_ASSISTANT_AGENT.planning!.maxIterations).toBeGreaterThanOrEqual(10); - expect(METADATA_ASSISTANT_AGENT.planning!.allowReplan).toBe(true); - }); - - it('should have instructions mentioning the architect persona', () => { - const instructions = METADATA_ASSISTANT_AGENT.instructions; - expect(instructions).toMatch(/architect|metadata/i); - }); -}); +// NOTE: the METADATA_ASSISTANT_AGENT spec tests moved with the agent to the +// cloud-only @objectstack/service-ai-studio package (metadata-assistant-agent.test.ts). diff --git a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts deleted file mode 100644 index d1f6f7c35..000000000 --- a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts +++ /dev/null @@ -1,881 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { IMetadataService } from '@objectstack/spec/contracts'; -import { ToolRegistry } from '../tools/tool-registry.js'; -import { - registerMetadataTools, - METADATA_TOOL_DEFINITIONS, -} from '../tools/metadata-tools.js'; -import { - registerDataTools, -} from '../tools/data-tools.js'; -import type { MetadataToolContext } from '../tools/metadata-tools.js'; - -// Individual tool metadata imports -import { createObjectTool } from '../tools/create-object.tool.js'; -import { addFieldTool } from '../tools/add-field.tool.js'; -import { modifyFieldTool } from '../tools/modify-field.tool.js'; -import { deleteFieldTool } from '../tools/delete-field.tool.js'; -import { listObjectsTool } from '../tools/list-objects.tool.js'; -import { describeObjectTool } from '../tools/describe-object.tool.js'; -import { createMetadataTool } from '../tools/create-metadata.tool.js'; -import { updateMetadataTool } from '../tools/update-metadata.tool.js'; -import { describeMetadataTool } from '../tools/describe-metadata.tool.js'; -import { listMetadataTool } from '../tools/list-metadata.tool.js'; - -// ── Helpers ──────────────────────────────────────────────────────── - -/** Build a mock IMetadataService with optionally pre-loaded objects. */ -function createMockMetadataService( - objects: Record = {}, - overrides: Partial = {}, -): IMetadataService { - // Keep a mutable store so handlers can modify it - const store: Record = { ...objects }; - - return { - register: vi.fn(async (_type: string, name: string, data: unknown) => { - store[name] = data; - }), - get: vi.fn(async (_type: string, name: string) => store[name] ?? undefined), - list: vi.fn(async () => Object.values(store)), - unregister: vi.fn(async (_type: string, name: string) => { - delete store[name]; - }), - exists: vi.fn(async (_type: string, name: string) => name in store), - listNames: vi.fn(async () => Object.keys(store)), - getObject: vi.fn(async (name: string) => store[name] ?? undefined), - listObjects: vi.fn(async () => Object.values(store)), - ...overrides, - }; -} - -/** - * Build a mock protocol that mimics ObjectStackProtocolImplementation's - * draft-aware behaviour: `saveMetaItem({ mode:'draft' })` stages a draft; - * `getMetaItem({ state:'draft' })` returns it or throws `no_draft` (404); - * the published value is served when `state` is omitted. This is what - * `applyDraft` writes through (ADR-0033) — nothing reaches a live store. - */ -function createMockProtocol(seedActive: Record = {}) { - const active = new Map(Object.entries(seedActive)); - const drafts = new Map(); - - const saveMetaItem = vi.fn(async (req: any) => { - const key = `${req.type}:${req.name}`; - if (req.mode === 'draft') drafts.set(key, req.item); - else active.set(key, req.item); - return { success: true }; - }); - const getMetaItem = vi.fn(async (req: any) => { - const key = `${req.type}:${req.name}`; - if (req.state === 'draft') { - if (!drafts.has(key)) { - const e: any = new Error(`[no_draft] No pending draft for ${key}.`); - e.code = 'no_draft'; - e.status = 404; - throw e; - } - return { type: req.type, name: req.name, item: drafts.get(key) }; - } - return { type: req.type, name: req.name, item: active.get(key) }; - }); - // Returns the bare array form (the metadata-tools handlers normalize both - // `unknown[]` and `{ items }`, but the declared protocol contract is - // `Promise`). - const getMetaItems = vi.fn(async (req: any) => { - const fromActive = [...active.entries()] - .filter(([k]) => k.startsWith(`${req.type}:`)) - .map(([, v]) => v); - if (!req.previewDrafts) return fromActive; - // Mirror protocol.getMetaItems({ previewDrafts }): overlay draft rows on top - // of active (draft wins by name; draft-only surfaces). - const byName = new Map(); - for (const v of fromActive) byName.set((v as any)?.name, v); - for (const [k, v] of drafts.entries()) { - if (k.startsWith(`${req.type}:`)) byName.set((v as any)?.name ?? k, v); - } - return [...byName.values()]; - }); - - const protocol: NonNullable = { - getMetaItems, - getMetaItem, - saveMetaItem, - }; - return { protocol, active, drafts, saveMetaItem, getMetaItem, getMetaItems }; -} - -/** Parse a tool-call result envelope into an object. */ -function parse(result: any): any { - return JSON.parse((result.output as any).value); -} - -const call = (toolName: string, input: Record, id = 't') => ({ - type: 'tool-call' as const, - toolCallId: id, - toolName, - input, -}); - -// ═══════════════════════════════════════════════════════════════════ -// Metadata Tool Definitions -// ═══════════════════════════════════════════════════════════════════ - -describe('Metadata Tool Definitions', () => { - it('should define exactly 12 tools', () => { - expect(METADATA_TOOL_DEFINITIONS).toHaveLength(12); - }); - - it('should include all expected tool names', () => { - const names = METADATA_TOOL_DEFINITIONS.map(t => t.name); - expect(names).toEqual([ - // ADR-0033 type-agnostic apply surface first - 'get_metadata_schema', - 'create_metadata', - 'update_metadata', - 'describe_metadata', - 'list_metadata', - // object/field convenience tools - 'create_object', - 'add_field', - 'modify_field', - 'delete_field', - 'list_objects', - 'describe_object', - 'validate_expression', - ]); - }); - - it('should have descriptions and parameters for each tool', () => { - for (const def of METADATA_TOOL_DEFINITIONS) { - expect(def.description).toBeTruthy(); - expect(def.parameters).toBeDefined(); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// Individual Tool Metadata Files (.tool.ts) -// ═══════════════════════════════════════════════════════════════════ - -describe('Individual Tool Metadata (.tool.ts)', () => { - const tools = [ - { tool: createMetadataTool, expectedName: 'create_metadata', expectedLabel: 'Create Metadata' }, - { tool: updateMetadataTool, expectedName: 'update_metadata', expectedLabel: 'Update Metadata' }, - { tool: describeMetadataTool, expectedName: 'describe_metadata', expectedLabel: 'Describe Metadata' }, - { tool: listMetadataTool, expectedName: 'list_metadata', expectedLabel: 'List Metadata' }, - { tool: createObjectTool, expectedName: 'create_object', expectedLabel: 'Create Object' }, - { tool: addFieldTool, expectedName: 'add_field', expectedLabel: 'Add Field' }, - { tool: modifyFieldTool, expectedName: 'modify_field', expectedLabel: 'Modify Field' }, - { tool: deleteFieldTool, expectedName: 'delete_field', expectedLabel: 'Delete Field' }, - { tool: listObjectsTool, expectedName: 'list_objects', expectedLabel: 'List Objects' }, - { tool: describeObjectTool, expectedName: 'describe_object', expectedLabel: 'Describe Object' }, - ]; - - for (const { tool, expectedName, expectedLabel } of tools) { - describe(expectedName, () => { - it('should have correct name', () => { - expect(tool.name).toBe(expectedName); - }); - - it('should have a label', () => { - expect(tool.label).toBe(expectedLabel); - }); - - it('should be categorized as data', () => { - expect(tool.category).toBe('data'); - }); - - it('should be marked as built-in', () => { - expect(tool.builtIn).toBe(true); - }); - - it('should have a description', () => { - expect(tool.description).toBeTruthy(); - }); - - it('should have parameters schema', () => { - expect(tool.parameters).toBeDefined(); - expect(tool.parameters.type).toBe('object'); - }); - - it('should be included in METADATA_TOOL_DEFINITIONS', () => { - expect(METADATA_TOOL_DEFINITIONS).toContain(tool); - }); - }); - } - - // ADR-0033: the draft workspace is the approval gate, so no tool relies on - // the (never-enforced) requiresConfirmation flag. - it('should leave requiresConfirmation false on write tools (draft is the gate)', () => { - expect(createObjectTool.requiresConfirmation).toBe(false); - expect(deleteFieldTool.requiresConfirmation).toBe(false); - expect(addFieldTool.requiresConfirmation).toBe(false); - expect(modifyFieldTool.requiresConfirmation).toBe(false); - expect(createMetadataTool.requiresConfirmation).toBe(false); - expect(updateMetadataTool.requiresConfirmation).toBe(false); - }); - - it('should leave requiresConfirmation false on read tools', () => { - expect(listObjectsTool.requiresConfirmation).toBe(false); - expect(describeObjectTool.requiresConfirmation).toBe(false); - expect(listMetadataTool.requiresConfirmation).toBe(false); - expect(describeMetadataTool.requiresConfirmation).toBe(false); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// registerMetadataTools -// ═══════════════════════════════════════════════════════════════════ - -describe('registerMetadataTools', () => { - let registry: ToolRegistry; - - beforeEach(() => { - registry = new ToolRegistry(); - const metadataService = createMockMetadataService(); - const { protocol } = createMockProtocol(); - registerMetadataTools(registry, { metadataService, protocol }); - }); - - it('should register all 12 tools', () => { - expect(registry.size).toBe(12); - for (const name of [ - 'get_metadata_schema', - 'create_metadata', 'update_metadata', 'describe_metadata', 'list_metadata', - 'create_object', 'add_field', 'modify_field', 'delete_field', - 'list_objects', 'describe_object', 'validate_expression', - ]) { - expect(registry.has(name)).toBe(true); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// get_metadata_schema — lets the AI read the real protocol on demand -// ═══════════════════════════════════════════════════════════════════ - -describe('get_metadata_schema', () => { - let registry: ToolRegistry; - - beforeEach(() => { - registry = new ToolRegistry(); - const { protocol } = createMockProtocol(); - registerMetadataTools(registry, { metadataService: createMockMetadataService(), protocol }); - }); - - it('returns the JSON Schema (contract) for a known type', async () => { - const parsed = parse(await registry.execute(call('get_metadata_schema', { type: 'view' }))); - expect(parsed.type).toBe('view'); - expect(parsed.jsonSchema).toBeTruthy(); - // A JSON-Schema-shaped object (has $schema/type/properties or $ref/anyOf). - const js = parsed.jsonSchema as Record; - expect( - typeof js === 'object' && - ('properties' in js || 'anyOf' in js || '$ref' in js || 'oneOf' in js || '$defs' in js), - ).toBe(true); - expect(parsed.error).toBeUndefined(); - }); - - it('resolves a plural type to its singular schema', async () => { - const parsed = parse(await registry.execute(call('get_metadata_schema', { type: 'views' }))); - expect(parsed.type).toBe('view'); - expect(parsed.jsonSchema).toBeTruthy(); - }); - - it('returns a helpful error for an unknown type', async () => { - const parsed = parse(await registry.execute(call('get_metadata_schema', { type: 'nonsense_type' }))); - expect(parsed.jsonSchema).toBeUndefined(); - expect(String(parsed.error)).toContain('nonsense_type'); - }); - - // Every app-development metadata type must yield a usable contract — including - // object/action, whose schemas wrap/nest a transform pipe that trips Zod v4's - // toJSONSchema (handled by the robust unwrap-and-recurse converter). - it('serializes ALL app-development metadata types (no validation-blind spots)', async () => { - const types = [ - 'object', 'field', 'view', 'page', 'dashboard', 'report', - 'app', 'flow', 'action', 'agent', 'role', - ]; - for (const type of types) { - const parsed = parse(await registry.execute(call('get_metadata_schema', { type }))); - expect(parsed.error, `'${type}' should serialize`).toBeUndefined(); - expect(parsed.jsonSchema, `'${type}' should return a schema`).toBeTruthy(); - expect(parsed.jsonSchema.type ?? parsed.jsonSchema.properties ?? parsed.jsonSchema.anyOf).toBeTruthy(); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// Dual registration (data tools + metadata tools) -// ═══════════════════════════════════════════════════════════════════ - -describe('registerDataTools + registerMetadataTools — unified list/describe', () => { - it('should register both tool sets on the same registry', () => { - const registry = new ToolRegistry(); - const metadataService = createMockMetadataService(); - const { protocol } = createMockProtocol(); - const dataEngine = { - find: vi.fn(), - findOne: vi.fn(), - aggregate: vi.fn(), - } as any; - - registerDataTools(registry, { dataEngine }); - const sizeAfterData = registry.size; - - registerMetadataTools(registry, { metadataService, protocol }); - const sizeAfterBoth = registry.size; - - // Data tools define: query_records, get_record, aggregate_data (3) - // Metadata tools define 12. - expect(sizeAfterData).toBe(3); - expect(sizeAfterBoth).toBe(sizeAfterData + 12); - - expect(registry.has('list_objects')).toBe(true); - expect(registry.has('describe_object')).toBe(true); - expect(registry.has('query_records')).toBe(true); - expect(registry.has('create_object')).toBe(true); - expect(registry.has('create_metadata')).toBe(true); - expect(registry.has('get_metadata_schema')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// Draft gating — the core ADR-0033 invariant -// ═══════════════════════════════════════════════════════════════════ - -describe('ADR-0033 draft gating', () => { - it('write tools NEVER publish (metadataService.register is never called) and stage mode:draft', async () => { - const registry = new ToolRegistry(); - const metadataService = createMockMetadataService(); - const { protocol, saveMetaItem, drafts } = createMockProtocol(); - registerMetadataTools(registry, { metadataService, protocol }); - - const result = await registry.execute(call('create_object', { name: 'project', label: 'Project' }, 'c1')); - const parsed = parse(result); - - expect(parsed.status).toBe('drafted'); - expect(parsed.type).toBe('object'); - expect(parsed.name).toBe('project'); - expect(parsed.summary).toContain('project'); - expect(Array.isArray(parsed.changedKeys)).toBe(true); - - // The live-publish path is dead. - expect(metadataService.register).not.toHaveBeenCalled(); - // The change is staged as a draft. - expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ - type: 'object', - name: 'project', - mode: 'draft', - })); - expect(drafts.get('object:project')).toEqual(expect.objectContaining({ name: 'project', label: 'Project' })); - }); - - it('refuses to write when no draft-capable protocol is wired (safe by default)', async () => { - const registry = new ToolRegistry(); - const metadataService = createMockMetadataService(); - // No protocol — applyDraft must refuse rather than fall back to publish. - registerMetadataTools(registry, { metadataService }); - - const result = await registry.execute(call('create_object', { name: 'project', label: 'Project' }, 'c1')); - const parsed = parse(result); - - expect(parsed.status).toBeUndefined(); - expect(parsed.error).toMatch(/draft persistence is unavailable/i); - expect(metadataService.register).not.toHaveBeenCalled(); - }); - - it('feeds per-type validation errors back to the model (does not throw)', async () => { - const registry = new ToolRegistry(); - const metadataService = createMockMetadataService(); - const { protocol, saveMetaItem } = createMockProtocol(); - // saveMetaItem rejects with the structured invalid_metadata shape. - (saveMetaItem as any).mockImplementation(async () => { - const e: any = new Error('[invalid_metadata] object/project failed spec validation: label: Required'); - e.code = 'invalid_metadata'; - e.status = 422; - e.issues = [{ path: 'label', message: 'Required', code: 'invalid_type' }]; - throw e; - }); - registerMetadataTools(registry, { metadataService, protocol }); - - const result = await registry.execute(call('create_object', { name: 'project', label: 'Project' }, 'c1')); - const parsed = parse(result); - - expect(parsed.error).toContain('invalid_metadata'); - expect(parsed.code).toBe('invalid_metadata'); - expect(parsed.issues).toEqual([{ path: 'label', message: 'Required', code: 'invalid_type' }]); - // It returned a string error, not a thrown exception — the loop continues. - expect(result.isError).toBeFalsy(); - }); - - it('stacks repeated field ops into a SINGLE object draft (no fork)', async () => { - const registry = new ToolRegistry(); - const metadataService = createMockMetadataService(); - const { protocol, drafts } = createMockProtocol(); - registerMetadataTools(registry, { metadataService, protocol }); - - await registry.execute(call('create_object', { name: 'invoice', label: 'Invoice' }, 's1')); - await registry.execute(call('add_field', { objectName: 'invoice', name: 'amount', type: 'number' }, 's2')); - await registry.execute(call('add_field', { objectName: 'invoice', name: 'status', type: 'text' }, 's3')); - - const draft = drafts.get('object:invoice') as any; - expect(Object.keys(draft.fields)).toEqual(['amount', 'status']); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// create_object handler -// ═══════════════════════════════════════════════════════════════════ - -describe('create_object handler', () => { - let registry: ToolRegistry; - let metadataService: IMetadataService; - let drafts: Map; - - beforeEach(() => { - registry = new ToolRegistry(); - metadataService = createMockMetadataService(); - const mock = createMockProtocol(); - drafts = mock.drafts; - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - }); - - it('should draft an object with name and label', async () => { - const parsed = parse(await registry.execute(call('create_object', { name: 'project', label: 'Project' }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.name).toBe('project'); - expect(drafts.get('object:project')).toEqual(expect.objectContaining({ name: 'project', label: 'Project' })); - }); - - it('should draft an object with initial fields', async () => { - await registry.execute(call('create_object', { - name: 'task', - label: 'Task', - fields: [ - { name: 'title', type: 'text', label: 'Title', required: true }, - { name: 'status', type: 'select' }, - ], - })); - expect(drafts.get('object:task')).toEqual(expect.objectContaining({ - fields: { - title: { type: 'text', label: 'Title', required: true }, - status: { type: 'select' }, - }, - })); - }); - - it('should draft an object with enableFeatures', async () => { - await registry.execute(call('create_object', { - name: 'account', - label: 'Account', - enableFeatures: { trackHistory: true, apiEnabled: true }, - })); - expect(drafts.get('object:account')).toEqual(expect.objectContaining({ - enable: { trackHistory: true, apiEnabled: true }, - })); - }); - - it('should reject invalid snake_case name', async () => { - const parsed = parse(await registry.execute(call('create_object', { name: 'MyProject', label: 'My Project' }))); - expect(parsed.error).toContain('snake_case'); - expect(drafts.size).toBe(0); - }); - - it('should reject duplicate object names (published)', async () => { - metadataService = createMockMetadataService({ project: { name: 'project', label: 'Project' } }); - registry = new ToolRegistry(); - const mock = createMockProtocol(); - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - - const parsed = parse(await registry.execute(call('create_object', { name: 'project', label: 'Project v2' }))); - expect(parsed.error).toContain('already exists'); - }); - - it('should reject duplicate object names (already drafted)', async () => { - await registry.execute(call('create_object', { name: 'project', label: 'Project' })); - const parsed = parse(await registry.execute(call('create_object', { name: 'project', label: 'Project v2' }))); - expect(parsed.error).toContain('already exists'); - }); - - it('should return error when name or label is missing', async () => { - const parsed = parse(await registry.execute(call('create_object', { name: 'project' }))); - expect(parsed.error).toContain('required'); - }); - - it('should reject fields with invalid snake_case names', async () => { - const parsed = parse(await registry.execute(call('create_object', { - name: 'project', label: 'Project', fields: [{ name: 'ValidField', type: 'text' }], - }))); - expect(parsed.error).toContain('snake_case'); - expect(drafts.size).toBe(0); - }); - - it('should reject fields with duplicate names', async () => { - const parsed = parse(await registry.execute(call('create_object', { - name: 'project', label: 'Project', - fields: [{ name: 'status', type: 'text' }, { name: 'status', type: 'select' }], - }))); - expect(parsed.error).toContain('Duplicate'); - expect(drafts.size).toBe(0); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// add_field handler -// ═══════════════════════════════════════════════════════════════════ - -describe('add_field handler', () => { - let registry: ToolRegistry; - let metadataService: IMetadataService; - let drafts: Map; - - beforeEach(() => { - metadataService = createMockMetadataService({ - project: { name: 'project', label: 'Project', fields: {} }, - }); - registry = new ToolRegistry(); - const mock = createMockProtocol(); - drafts = mock.drafts; - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - }); - - it('should draft a new field onto an existing (published) object', async () => { - const parsed = parse(await registry.execute(call('add_field', - { objectName: 'project', name: 'due_date', type: 'date', label: 'Due Date' }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.changedKeys).toEqual(['fields.due_date']); - expect(drafts.get('object:project')).toEqual(expect.objectContaining({ - fields: expect.objectContaining({ - due_date: expect.objectContaining({ type: 'date', label: 'Due Date' }), - }), - })); - }); - - it('should draft a select field with options', async () => { - await registry.execute(call('add_field', { - objectName: 'project', name: 'priority', type: 'select', - options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }], - })); - expect(drafts.get('object:project')).toEqual(expect.objectContaining({ - fields: expect.objectContaining({ - priority: expect.objectContaining({ - type: 'select', - options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }], - }), - }), - })); - }); - - it('should reject adding field to non-existent object', async () => { - const parsed = parse(await registry.execute(call('add_field', - { objectName: 'nonexistent', name: 'field_a', type: 'text' }))); - expect(parsed.error).toContain('not found'); - }); - - it('should reject duplicate field name (against the pending draft)', async () => { - await registry.execute(call('add_field', { objectName: 'project', name: 'status', type: 'text' }, 'a')); - const parsed = parse(await registry.execute(call('add_field', - { objectName: 'project', name: 'status', type: 'select' }, 'b'))); - expect(parsed.error).toContain('already exists'); - }); - - it('should reject invalid field name', async () => { - const parsed = parse(await registry.execute(call('add_field', - { objectName: 'project', name: 'MyField', type: 'text' }))); - expect(parsed.error).toContain('snake_case'); - }); - - it('should accept reference as a string', async () => { - const parsed = parse(await registry.execute(call('add_field', - { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'account' }))); - expect(parsed.status).toBe('drafted'); - expect(drafts.get('object:project')).toEqual(expect.objectContaining({ - fields: expect.objectContaining({ - account_id: expect.objectContaining({ type: 'lookup', reference: 'account' }), - }), - })); - }); - - it('should reject invalid reference (not snake_case)', async () => { - const parsed = parse(await registry.execute(call('add_field', - { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'MyAccount' }))); - expect(parsed.error).toContain('snake_case'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// modify_field handler -// ═══════════════════════════════════════════════════════════════════ - -describe('modify_field handler', () => { - let registry: ToolRegistry; - let drafts: Map; - - beforeEach(() => { - const metadataService = createMockMetadataService({ - project: { - name: 'project', label: 'Project', - fields: { - status: { type: 'text', label: 'Status', required: false }, - budget: { type: 'number', label: 'Budget' }, - }, - }, - }); - registry = new ToolRegistry(); - const mock = createMockProtocol(); - drafts = mock.drafts; - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - }); - - it('should draft a field-label change', async () => { - const parsed = parse(await registry.execute(call('modify_field', - { objectName: 'project', fieldName: 'status', changes: { label: 'Project Status' } }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.changedKeys).toEqual(['fields.status.label']); - expect((drafts.get('object:project') as any).fields.status.label).toBe('Project Status'); - }); - - it('should draft multiple property changes', async () => { - const parsed = parse(await registry.execute(call('modify_field', - { objectName: 'project', fieldName: 'status', changes: { label: 'Project Status', required: true } }))); - expect(parsed.changedKeys).toEqual(expect.arrayContaining(['fields.status.label', 'fields.status.required'])); - expect((drafts.get('object:project') as any).fields.status.required).toBe(true); - }); - - it('should return error for non-existent object', async () => { - const parsed = parse(await registry.execute(call('modify_field', - { objectName: 'nonexistent', fieldName: 'status', changes: { label: 'New' } }))); - expect(parsed.error).toContain('not found'); - }); - - it('should return error for non-existent field', async () => { - const parsed = parse(await registry.execute(call('modify_field', - { objectName: 'project', fieldName: 'nonexistent_field', changes: { label: 'New' } }))); - expect(parsed.error).toContain('not found'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// delete_field handler -// ═══════════════════════════════════════════════════════════════════ - -describe('delete_field handler', () => { - let registry: ToolRegistry; - let drafts: Map; - - beforeEach(() => { - const metadataService = createMockMetadataService({ - project: { - name: 'project', label: 'Project', - fields: { - status: { type: 'text', label: 'Status' }, - budget: { type: 'number', label: 'Budget' }, - }, - }, - }); - registry = new ToolRegistry(); - const mock = createMockProtocol(); - drafts = mock.drafts; - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - }); - - it('should draft the removal of a field', async () => { - const parsed = parse(await registry.execute(call('delete_field', - { objectName: 'project', fieldName: 'budget' }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.changedKeys).toEqual(['fields.budget']); - const draft = drafts.get('object:project') as any; - expect(draft.fields.budget).toBeUndefined(); - expect(draft.fields.status).toBeDefined(); - }); - - it('should return error for non-existent object', async () => { - const parsed = parse(await registry.execute(call('delete_field', - { objectName: 'nonexistent', fieldName: 'status' }))); - expect(parsed.error).toContain('not found'); - }); - - it('should return error for non-existent field', async () => { - const parsed = parse(await registry.execute(call('delete_field', - { objectName: 'project', fieldName: 'nonexistent_field' }))); - expect(parsed.error).toContain('not found'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// Generic type-agnostic tools (ADR-0033) -// ═══════════════════════════════════════════════════════════════════ - -describe('create_metadata / update_metadata / describe_metadata / list_metadata', () => { - let registry: ToolRegistry; - let drafts: Map; - let active: Map; - let saveMetaItem: ReturnType; - - beforeEach(() => { - const metadataService = createMockMetadataService(); - registry = new ToolRegistry(); - const mock = createMockProtocol({ - 'view:account_list': { name: 'account_list', label: 'Accounts', object: 'account' }, - 'dashboard:sales': { name: 'sales', label: 'Sales' }, - }); - drafts = mock.drafts; - active = mock.active; - saveMetaItem = mock.saveMetaItem; - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - }); - - it('create_metadata drafts a new view with the name folded in', async () => { - const parsed = parse(await registry.execute(call('create_metadata', - { type: 'view', name: 'contact_list', definition: { label: 'Contacts', object: 'contact' } }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.type).toBe('view'); - expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ type: 'view', mode: 'draft' })); - expect(drafts.get('view:contact_list')).toEqual({ name: 'contact_list', label: 'Contacts', object: 'contact' }); - }); - - it('create_metadata rejects an item that already exists', async () => { - const parsed = parse(await registry.execute(call('create_metadata', - { type: 'view', name: 'account_list', definition: { label: 'X' } }))); - expect(parsed.error).toContain('already exists'); - }); - - it('create_metadata rejects an invalid snake_case name', async () => { - const parsed = parse(await registry.execute(call('create_metadata', - { type: 'view', name: 'BadName', definition: {} }))); - expect(parsed.error).toContain('snake_case'); - }); - - it('update_metadata merges a patch into the published item and drafts it', async () => { - const parsed = parse(await registry.execute(call('update_metadata', - { type: 'view', name: 'account_list', patch: { label: 'All Accounts' } }))); - expect(parsed.status).toBe('drafted'); - expect(parsed.changedKeys).toEqual(['label']); - expect(drafts.get('view:account_list')).toEqual(expect.objectContaining({ - name: 'account_list', label: 'All Accounts', object: 'account', - })); - // Published value untouched. - expect((active.get('view:account_list') as any).label).toBe('Accounts'); - }); - - it('update_metadata deletes a key when the patch value is null (RFC 7386)', async () => { - await registry.execute(call('update_metadata', - { type: 'view', name: 'account_list', patch: { object: null } })); - const draft = drafts.get('view:account_list') as any; - expect(draft.object).toBeUndefined(); - expect(draft.label).toBe('Accounts'); - }); - - it('update_metadata returns not-found for an unknown item', async () => { - const parsed = parse(await registry.execute(call('update_metadata', - { type: 'view', name: 'ghost', patch: { label: 'X' } }))); - expect(parsed.error).toContain('not found'); - }); - - it('describe_metadata returns the draft body when one exists (draft-first)', async () => { - await registry.execute(call('update_metadata', { type: 'view', name: 'account_list', patch: { label: 'Edited' } })); - const parsed = parse(await registry.execute(call('describe_metadata', { type: 'view', name: 'account_list' }))); - expect(parsed.item.label).toBe('Edited'); - }); - - it('describe_metadata falls back to the published body when no draft', async () => { - const parsed = parse(await registry.execute(call('describe_metadata', { type: 'dashboard', name: 'sales' }))); - expect(parsed.item.label).toBe('Sales'); - }); - - it('list_metadata enumerates items of a type with an optional filter', async () => { - const all = parse(await registry.execute(call('list_metadata', { type: 'view' }))); - expect(all.totalCount).toBe(1); - expect(all.items[0]).toEqual({ name: 'account_list', label: 'Accounts' }); - - const filtered = parse(await registry.execute(call('list_metadata', { type: 'view', filter: 'zzz' }))); - expect(filtered.totalCount).toBe(0); - }); - - it('list_metadata surfaces a draft-only item (previewDrafts) so the agent sees its own pending work', async () => { - // A brand-new object the agent just drafted (never published). Active-only - // reads hide it, so the agent reports its own object as "not found" when it - // later tries to author a flow against it. previewDrafts overlays it. - drafts.set('object:expense_claim', { name: 'expense_claim', label: 'Expense Claim' }); - const res = parse(await registry.execute(call('list_metadata', { type: 'object' }))); - expect(res.items.map((i: any) => i.name)).toContain('expense_claim'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// describe_object / list_objects (read side, unchanged behaviour) -// ═══════════════════════════════════════════════════════════════════ - -describe('list_objects + describe_object handlers', () => { - let registry: ToolRegistry; - - beforeEach(() => { - const metadataService = createMockMetadataService({ - account: { name: 'account', label: 'Account', fields: { name: { type: 'text' } } }, - contact: { name: 'contact', label: 'Contact', fields: { email: { type: 'text' }, phone: { type: 'text' } } }, - }); - registry = new ToolRegistry(); - // No protocol getMetaItems seeded for objects → falls back to metadataService.listObjects. - registerMetadataTools(registry, { metadataService }); - }); - - it('list_objects lists objects with field counts', async () => { - const parsed = parse(await registry.execute(call('list_objects', {}))); - expect(parsed.totalCount).toBe(2); - expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', fieldCount: 1 })); - }); - - it('describe_object returns full schema', async () => { - const parsed = parse(await registry.execute(call('describe_object', { objectName: 'account' }))); - expect(parsed.name).toBe('account'); - expect(parsed.fields).toHaveLength(1); - }); - - it('describe_object errors for an unknown object', async () => { - const parsed = parse(await registry.execute(call('describe_object', { objectName: 'nope' }))); - expect(parsed.error).toContain('not found'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════ -// End-to-End: full draft lifecycle through the generic + object tools -// ═══════════════════════════════════════════════════════════════════ - -describe('Metadata Tools — full draft lifecycle', () => { - let registry: ToolRegistry; - let drafts: Map; - - beforeEach(() => { - const metadataService = createMockMetadataService(); - registry = new ToolRegistry(); - const mock = createMockProtocol(); - drafts = mock.drafts; - registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - }); - - it('create → add_field → describe_metadata → modify → delete all stage one draft', async () => { - await registry.execute(call('create_object', { name: 'invoice', label: 'Invoice' }, 's1')); - await registry.execute(call('add_field', { objectName: 'invoice', name: 'amount', type: 'number', label: 'Amount' }, 's2')); - await registry.execute(call('add_field', { objectName: 'invoice', name: 'status', type: 'text', label: 'Status' }, 's3')); - - // describe_metadata is draft-aware and sees both fields. - const desc = parse(await registry.execute(call('describe_metadata', { type: 'object', name: 'invoice' }, 's4'))); - expect(Object.keys(desc.item.fields)).toEqual(['amount', 'status']); - - await registry.execute(call('modify_field', { - objectName: 'invoice', fieldName: 'status', changes: { type: 'select', label: 'Invoice Status' }, - }, 's5')); - - const del = parse(await registry.execute(call('delete_field', { objectName: 'invoice', fieldName: 'amount' }, 's6'))); - expect(del.status).toBe('drafted'); - - const draft = drafts.get('object:invoice') as any; - expect(Object.keys(draft.fields)).toEqual(['status']); - expect(draft.fields.status.type).toBe('select'); - expect(draft.fields.status.label).toBe('Invoice Status'); - }); -}); diff --git a/packages/services/service-ai/src/__tests__/solution-design-guardrail.test.ts b/packages/services/service-ai/src/__tests__/solution-design-guardrail.test.ts deleted file mode 100644 index 414834d6e..000000000 --- a/packages/services/service-ai/src/__tests__/solution-design-guardrail.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { SOLUTION_DESIGN_SKILL } from '../skills/solution-design-skill.js'; - -/** - * Regression guard for the "don't model a process as data" rule. The default - * behaviour was that asking the agent to "design expense reimbursement" made it - * invent an `approval_record` TABLE instead of a flow. The fix lives in the - * skill instructions (and the propose_blueprint generation prompt): status is a - * select field, an approval process is a FLOW, and the agent proactively drafts - * that flow instead of waiting to be asked. These assertions keep that guidance - * from silently regressing. - */ -describe('solution_design — process/state guardrail', () => { - const text = SOLUTION_DESIGN_SKILL.instructions.toLowerCase(); - - it('tells the agent NOT to model a process/approval as a table', () => { - expect(text).toContain('do not model a process as data'); - expect(text).toMatch(/never create objects for approvals/); - expect(text).toMatch(/is a flow, not a table/); - }); - - it('tells the agent to model status as a select field', () => { - expect(text).toMatch(/status\b.*\bselect\b|\bselect\b.*\bstatus/); - }); - - it('tells the agent to proactively draft the approval flow (not wait to be asked)', () => { - expect(text).toContain('get_metadata_schema'); - expect(text).toMatch(/create_metadata\(type:'flow'/); - expect(text).toMatch(/do not wait for the user to ask/); - }); - - it('still drives the plan-first propose -> apply flow', () => { - expect(SOLUTION_DESIGN_SKILL.tools).toEqual(['propose_blueprint', 'apply_blueprint']); - }); -}); diff --git a/packages/services/service-ai/src/agents/index.ts b/packages/services/service-ai/src/agents/index.ts index a162f95cf..f2eade983 100644 --- a/packages/services/service-ai/src/agents/index.ts +++ b/packages/services/service-ai/src/agents/index.ts @@ -1,4 +1,5 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME } from './data-chat-agent.js'; -export { METADATA_ASSISTANT_AGENT } from './metadata-assistant-agent.js'; +// The metadata_assistant authoring agent moved to the cloud-only +// @objectstack/service-ai-studio package. diff --git a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts deleted file mode 100644 index f6e03f760..000000000 --- a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Agent } from '@objectstack/spec/ai'; - -/** - * Built-in `metadata_assistant` agent — a thin **persona** record. - * - * Capability bundle is no longer hardcoded here; it lives on the - * `metadata_authoring` *skill* (see - * `../skills/metadata-authoring-skill.ts`). Studio's Universal - * Assistant pins this agent via `?agent=metadata_assistant` because - * Studio is a metadata-authoring host. - * - * To extend this agent (e.g. give it data-exploration too), just add - * the skill name: `skills: ['metadata_authoring', 'data_explorer']`. - * - * @example - * ``` - * POST /api/v1/ai/agents/metadata_assistant/chat - * { - * "messages": [{ "role": "user", "content": "Create a contracts table with name, value, and status fields" }], - * "context": {} - * } - * ``` - */ -export const METADATA_ASSISTANT_AGENT: Agent = { - name: 'metadata_assistant', - label: 'Metadata Assistant', - role: 'Schema Architect', - instructions: `You are an expert metadata architect that helps users design and manage their data models through natural language. - -You author metadata as DRAFTS: every change you make is staged for the human to review as a diff and publish. You never publish, and you must never claim a change is live or applied — describe it as "drafted for your review". The human's publish is the only path to production. - -Always answer in the same language the user is using. If the user's request is ambiguous, ask clarifying questions before proceeding. Detailed tool-usage guidance is supplied by the skills attached to this agent.`, - - model: { - provider: 'openai', - model: 'gpt-4', - temperature: 0.2, - maxTokens: 4096, - }, - - // Capability bundles live on the skills; the agent only references them. - // `metadata_authoring` = per-item authoring (draft-gated); `solution_design` - // = plan-first blueprint authoring for whole-system goals (ADR-0033 §4). - skills: ['metadata_authoring', 'solution_design'], - - active: true, - visibility: 'global', - - guardrails: { - maxTokensPerInvocation: 8192, - maxExecutionTimeSec: 60, - blockedTopics: ['drop_database', 'raw_sql', 'system_tables'], - }, - - planning: { - strategy: 'react', - maxIterations: 10, - allowReplan: true, - }, - - memory: { - shortTerm: { - maxMessages: 30, - maxTokens: 8192, - }, - }, -}; - diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index 3e92b780e..578a2d277 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -29,41 +29,16 @@ export type { ToolHandler, ToolExecutionResult } from './tools/tool-registry.js' export { registerDataTools, DATA_TOOL_DEFINITIONS } from './tools/data-tools.js'; export type { DataToolContext } from './tools/data-tools.js'; -// Metadata tools -export { registerMetadataTools, METADATA_TOOL_DEFINITIONS, stageDraft } from './tools/metadata-tools.js'; -export type { MetadataToolContext, StageDraftInput, StageDraftResult, DraftCapableProtocol } from './tools/metadata-tools.js'; - -// Blueprint tools (ADR-0033 §4 — plan-first authoring) -export { registerBlueprintTools, BLUEPRINT_TOOL_DEFINITIONS, proposeBlueprintTool, applyBlueprintTool } from './tools/blueprint-tools.js'; -export type { BlueprintToolContext } from './tools/blueprint-tools.js'; +// NOTE: AI metadata-authoring (metadata tools, plan-first blueprint tools, +// package context tools, and the metadata_assistant agent + authoring skills) +// moved to the cloud-only @objectstack/service-ai-studio package. The generic +// AI runtime, data tools, knowledge tools, and the metadata WRITE mechanism in +// the kernel stay open here. // Knowledge tools export { registerKnowledgeTools, SEARCH_KNOWLEDGE_TOOL } from './tools/knowledge-tools.js'; export type { KnowledgeToolContext } from './tools/knowledge-tools.js'; -// Individual tool metadata (first-class Tool definitions via defineTool) -export { - createObjectTool, - addFieldTool, - modifyFieldTool, - deleteFieldTool, - listObjectsTool, - describeObjectTool, -} from './tools/metadata-tools.js'; - -// Package tools -export { registerPackageTools, PACKAGE_TOOL_DEFINITIONS } from './tools/package-tools.js'; -export type { PackageToolContext, IPackageRegistry, IConversationService } from './tools/package-tools.js'; - -// Individual package tool metadata -export { - listPackagesTool, - getPackageTool, - createPackageTool, - getActivePackageTool, - setActivePackageTool, -} from './tools/package-tools.js'; - // Action tools (write-side: turn declarative Actions into AI-callable tools) export { registerActionsAsTools, @@ -82,13 +57,11 @@ export { SkillRegistry } from './skill-registry.js'; export type { SkillContext, SkillSummary } from './skill-registry.js'; // Built-in agents -export { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js'; +export { DATA_CHAT_AGENT } from './agents/index.js'; // Built-in skills export { DATA_EXPLORER_SKILL, - METADATA_AUTHORING_SKILL, - SOLUTION_DESIGN_SKILL, ACTIONS_EXECUTOR_SKILL, } from './skills/index.js'; diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index 576d2574f..6a8f81be9 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -18,14 +18,12 @@ import { AiConversationObject, AiMessageObject, AiPendingActionObject, AiTraceOb import { AiTraceView, AiMessageView, AiPendingActionView, AiEvalCaseView, AiEvalRunView } from './views/index.js'; import { EvalRunner } from './eval/index.js'; import { registerDataTools } from './tools/data-tools.js'; -import { registerMetadataTools } from './tools/metadata-tools.js'; -import { registerBlueprintTools, BLUEPRINT_TOOL_DEFINITIONS } from './tools/blueprint-tools.js'; import { registerQueryDataTool } from './tools/query-data.tool.js'; import { registerActionsAsTools } from './tools/action-tools.js'; import { AgentRuntime } from './agent-runtime.js'; import { SkillRegistry } from './skill-registry.js'; -import { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js'; -import { DATA_EXPLORER_SKILL, METADATA_AUTHORING_SKILL, SOLUTION_DESIGN_SKILL, ACTIONS_EXECUTOR_SKILL } from './skills/index.js'; +import { DATA_CHAT_AGENT } 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'; import { ModelRegistry } from './model-registry.js'; @@ -626,33 +624,6 @@ export class AIServicePlugin implements Plugin { protocolService = undefined; } - // Lazy `package` service accessor so the blueprint tools can give an app a - // writable "home" package automatically — zero-package app building. Resolved - // PER CALL (at apply_blueprint time, long after startup) so service init - // order doesn't matter and a later-loaded `package` capability (the - // opt-in `marketplace` tier) is picked up. Throws when absent → ensureAppPackage - // catches it and degrades to package-less drafting. - const resolvePackage = (): any | undefined => { - try { - const pk = ctx.getService('package'); - return pk && typeof pk.get === 'function' && typeof pk.publish === 'function' ? pk : undefined; - } catch { - return undefined; - } - }; - const packageService = { - get: async (id: string) => { - const pk = resolvePackage(); - if (!pk) throw new Error('package service unavailable'); - return pk.get(id); - }, - publish: async (p: unknown) => { - const pk = resolvePackage(); - if (!pk) throw new Error('package service unavailable'); - return pk.publish(p); - }, - }; - // 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 @@ -821,109 +792,13 @@ export class AIServicePlugin implements Plugin { ctx.logger.debug('[AI] Data engine not available, skipping data tools'); } - // Metadata tools require only the metadata service - if (metadataService) { - try { - registerMetadataTools(this.service.toolRegistry, { metadataService, protocol: protocolService }); - ctx.logger.info('[AI] Built-in metadata tools registered'); - - // Plan-first blueprint tools (ADR-0033 §4) — design a whole solution, - // confirm, then batch-draft. Needs the AI service for structured output - // and the protocol for draft writes (reused via stageDraft). - registerBlueprintTools(this.service.toolRegistry, { - ai: this.service, - protocol: protocolService, - metadataService, - packageService: packageService as any, - }); - ctx.logger.info('[AI] Plan-first blueprint tools registered'); - - // Register metadata + blueprint tools as metadata (for Studio visibility) - const { METADATA_TOOL_DEFINITIONS } = await import('./tools/metadata-tools.js'); - for (const toolDef of [...METADATA_TOOL_DEFINITIONS, ...BLUEPRINT_TOOL_DEFINITIONS]) { - 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 { - await withTimeout(metadataService.register('tool', toolDef.name, toolDef)); - } 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] ${METADATA_TOOL_DEFINITIONS.length + BLUEPRINT_TOOL_DEFINITIONS.length} metadata + blueprint tools registered as metadata`); - - // Register the built-in metadata_assistant agent - try { - const agentExists = - typeof metadataService.exists === 'function' - ? await withTimeout(metadataService.exists('agent', METADATA_ASSISTANT_AGENT.name)) - : false; - - if (agentExists === null) { - ctx.logger.warn('[AI] Metadata service timed out checking metadata_assistant agent, skipping'); - } else if (!agentExists) { - await withTimeout(metadataService.register('agent', METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT)); - console.log('[AI] Registered metadata_assistant agent to metadataService'); - ctx.logger.info('[AI] metadata_assistant agent registered'); - } else { - console.log('[AI] metadata_assistant agent already exists, skipping'); - ctx.logger.debug('[AI] metadata_assistant agent already exists, skipping auto-registration'); - } - } catch (err) { - ctx.logger.warn('[AI] Failed to register metadata_assistant agent', err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) }); - } - - // Register the built-in metadata_authoring skill (capability bundle for metadata_assistant) - try { - const skillExists = - typeof metadataService.exists === 'function' - ? await withTimeout(metadataService.exists('skill', METADATA_AUTHORING_SKILL.name)) - : false; - - if (skillExists === null) { - ctx.logger.warn('[AI] Metadata service timed out checking metadata_authoring skill, skipping'); - } else if (!skillExists) { - await withTimeout(metadataService.register('skill', METADATA_AUTHORING_SKILL.name, METADATA_AUTHORING_SKILL)); - ctx.logger.info('[AI] metadata_authoring skill registered'); - } else { - ctx.logger.debug('[AI] metadata_authoring skill already exists, skipping auto-registration'); - } - } catch (err) { - ctx.logger.warn('[AI] Failed to register metadata_authoring skill', err instanceof Error ? { error: err.message } : { error: String(err) }); - } - - // Register the built-in solution_design skill (plan-first blueprint authoring) - try { - const skillExists = - typeof metadataService.exists === 'function' - ? await withTimeout(metadataService.exists('skill', SOLUTION_DESIGN_SKILL.name)) - : false; - - if (skillExists === null) { - ctx.logger.warn('[AI] Metadata service timed out checking solution_design skill, skipping'); - } else if (!skillExists) { - await withTimeout(metadataService.register('skill', SOLUTION_DESIGN_SKILL.name, SOLUTION_DESIGN_SKILL)); - ctx.logger.info('[AI] solution_design skill registered'); - } else { - ctx.logger.debug('[AI] solution_design skill already exists, skipping auto-registration'); - } - } catch (err) { - ctx.logger.warn('[AI] Failed to register solution_design skill', err instanceof Error ? { error: err.message } : { error: String(err) }); - } - } catch (err) { - ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined); - } - } + // NOTE: AI-driven metadata authoring (the metadata_assistant agent, the + // metadata/blueprint/package authoring tools, and the metadata_authoring / + // solution_design skills) 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 runtime keeps the generic AI chat/data + // capabilities while authoring "intelligence" is layered on in the cloud. // Trigger hook to notify AI service is ready — other plugins can register tools await ctx.trigger('ai:ready', this.service); diff --git a/packages/services/service-ai/src/skills/index.ts b/packages/services/service-ai/src/skills/index.ts index 4aea914bc..b0b7bc80d 100644 --- a/packages/services/service-ai/src/skills/index.ts +++ b/packages/services/service-ai/src/skills/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { DATA_EXPLORER_SKILL } from './data-explorer-skill.js'; -export { METADATA_AUTHORING_SKILL } from './metadata-authoring-skill.js'; -export { SOLUTION_DESIGN_SKILL } from './solution-design-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. diff --git a/packages/services/service-ai/src/skills/metadata-authoring-skill.ts b/packages/services/service-ai/src/skills/metadata-authoring-skill.ts deleted file mode 100644 index 48b485ef3..000000000 --- a/packages/services/service-ai/src/skills/metadata-authoring-skill.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Skill } from '@objectstack/spec/ai'; - -/** - * Built-in `metadata_authoring` skill — the write-side schema-design - * capability bundle attached to the `metadata_assistant` agent (and - * any other agent that should be allowed to mutate schema). - * - * Splitting this off from the agent record lets us: - * - Reuse the same authoring tools across multiple agent personas - * (e.g. an "ops bot" that ALSO can author). - * - Disable authoring globally by setting `active: false` on the - * skill metadata, without redeploying the agent. - * - Layer permissions via `Skill.permissions` independent of the - * agent's permissions. - */ -export const METADATA_AUTHORING_SKILL: Skill = { - name: 'metadata_authoring', - label: 'Metadata Authoring', - description: 'Create and modify ObjectStack metadata — objects, fields, schema changes through natural language.', - instructions: `You are an expert metadata architect. When the user asks you to design or change a data model, use these tools. - -IMPORTANT — you propose drafts; you never publish. Every change you make with these tools lands in a DRAFT workspace, not the live schema. The human reviews your draft as a diff and publishes it themselves. Never tell the user a change is "live", "applied", or "saved to production" — say it is "drafted for your review". You have no publish tool, and that is by design (the draft is the approval gate). - -Capabilities: -- Read a type's exact contract: get_metadata_schema returns the JSON Schema for a metadata type (the same schema your output is validated against). -- Create or update any metadata type (object, view, dashboard, flow, report, app) via create_metadata / update_metadata — prefer these for non-object types. -- Create new data objects (tables) with fields, and add / modify / delete fields on objects (object-specific convenience tools). -- Inspect what exists: list_metadata / describe_metadata (any type), list_objects / describe_object (objects). - -Guidelines: -1. Before creating, use list_objects / list_metadata to check if a similar item already exists. -2. Before authoring a non-trivial type you are not 100% sure of the shape of (view, dashboard, flow, report, page — anything beyond a plain object/field), FIRST call get_metadata_schema for that type and conform your create_metadata / update_metadata payload to it. This gets the structure right the first time instead of guessing and learning from validation errors. (Example: a kanban view's list config requires a kanban: { groupByField, columns } block — the schema tells you exactly what is required.) -3. Before updating, modifying, or deleting, use describe_object / describe_metadata to understand the current shape. -3. Always use snake_case for type names and field names (e.g. project_task, due_date). -4. Suggest meaningful field types based on the user's description (e.g. "deadline" → date, "active" → boolean). -5. When creating objects, propose a reasonable set of initial fields based on the entity type. -6. Explain what changes you are about to make before executing them. -7. After drafting changes, tell the user the change is drafted and ask them to review and publish; summarize what you staged (the tools return a { status: 'drafted', summary, changedKeys } envelope). -8. For destructive operations (deleting fields), warn the user about potential data loss on publish. -9. If a tool returns an error with validation issues, fix your input and try again — do not surface the raw error to the user as a failure if you can self-correct. -10. Always answer in the same language the user is using. -11. If the user's request is ambiguous, ask clarifying questions before proceeding.`, - tools: [ - 'get_metadata_schema', - 'create_metadata', - 'update_metadata', - 'describe_metadata', - 'list_metadata', - 'create_object', - 'add_field', - 'modify_field', - 'delete_field', - 'list_objects', - 'describe_object', - ], - triggerPhrases: [ - 'create object', - 'create table', - 'add field', - 'add column', - 'modify field', - 'change field', - 'delete field', - 'drop field', - 'design schema', - 'new entity', - ], - active: true, -}; diff --git a/packages/services/service-ai/src/skills/solution-design-skill.ts b/packages/services/service-ai/src/skills/solution-design-skill.ts deleted file mode 100644 index e4577baea..000000000 --- a/packages/services/service-ai/src/skills/solution-design-skill.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Skill } from '@objectstack/spec/ai'; - -/** - * Built-in `solution_design` skill — the plan-first authoring capability - * (ADR-0033 §4). Attached to `metadata_assistant` alongside `metadata_authoring`. - * - * Where `metadata_authoring` handles "add a field to this object", this skill - * handles "build me a whole system": the agent designs a structured blueprint, - * the human confirms it conversationally, and only then does it batch-draft. - * It is a separate skill so the plan-first behaviour can be toggled or reused - * independently of the per-item authoring tools. - */ -export const SOLUTION_DESIGN_SKILL: Skill = { - name: 'solution_design', - label: 'Solution Design', - description: 'Design whole solutions (objects + views + dashboards) from a high-level goal, plan-first: propose a blueprint, confirm, then batch-draft.', - instructions: `Use this skill when the user asks you to build a whole SYSTEM, APP, or MODULE ("build me a CRM", "I need an applicant tracking system"), not a single object or field. - -The flow is PLAN-FIRST and has two steps: -1. propose_blueprint — design a structured blueprint (objects, fields, relationships, views, dashboards, and an app that surfaces them in the navigation) from the goal. This creates NOTHING. Present it to the user: summarize the objects/views and the app, state your assumptions, and ask any (at most 1-2) structure-deciding questions the tool returned. -2. apply_blueprint — ONLY after the user approves (or edits) the blueprint, call this to batch-draft every artifact. Pass the approved/edited blueprint object. - -Hard rules: -- NEVER call apply_blueprint before the user has explicitly approved the blueprint. The blueprint-confirm step is the safety valve against mass-generating unreviewed artifacts. -- Everything apply_blueprint creates is a DRAFT — including the app (navigation shell), which the user will find in the App Launcher once published. Tell the user the artifacts are "drafted for your review" and that they must publish them in the designer to make them live. Never say they are live/created/applied. -- The app's artifacts are automatically grouped under an app package (the result includes a "package" field) — the user does NOT need to create a package. Do not ask them to; just mention everything is grouped under the app. -- If apply_blueprint reports per-item failures, explain which items failed and why, and offer to fix them (e.g. via update_metadata) — the successfully drafted items still stand. -- Seed data in a blueprint is a suggestion only; it is not auto-applied. -- Always answer in the same language the user is using. - -Process & state modeling — do NOT model a process as data: -- Record STATUS / lifecycle is a \`select\` field on the main object (e.g. status = draft/submitted/approved/rejected/paid), never a separate table. -- NEVER create objects for approvals, approval steps/records, workflows, routing, sign-offs, or audit trails. An approval/automation process is a FLOW, not a table; its trail comes from platform history. Model only the people the process references (approver/reviewer/owner) as lookup fields to the user object. -- If the goal involves a process (approval / 审批 / review / routing / multi-step state transitions / sign-off / escalation), the blueprint covers only the DATA model. After apply_blueprint drafts it, PROACTIVELY draft the approval flow yourself — do NOT wait for the user to ask "now create the flow": - 1. Call get_metadata_schema('flow') to get the exact shape (it supports an \`approval\` node). - 2. Call create_metadata(type:'flow', ...) with the approval node(s) wired to the status field and approver lookups, binding it to the SAME app package (pass the packageId from apply_blueprint's result, or set_active_package first) so it is not orphaned. - 3. Optionally add a state_machine validation rule on the object so illegal status transitions are blocked. - Then tell the user you have ALSO drafted the approval flow for their review (it is a draft, like everything else). - -For small, specific changes ("add a status field to account") use the metadata_authoring tools directly instead of a blueprint.`, - tools: [ - 'propose_blueprint', - 'apply_blueprint', - ], - triggerPhrases: [ - 'build me', - 'build a', - 'create a system', - 'design a system', - 'set up an app', - 'i need a', - 'build an app', - 'scaffold', - ], - active: true, -}; diff --git a/packages/services/service-ai/src/tools/add-field.tool.ts b/packages/services/service-ai/src/tools/add-field.tool.ts deleted file mode 100644 index 38071d5f1..000000000 --- a/packages/services/service-ai/src/tools/add-field.tool.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * add_field — AI Tool Metadata - * - * Adds a new field (column) to an existing data object. - * Validates snake_case for objectName, field name, reference, - * and select option values before merging into the definition. - */ -export const addFieldTool = defineTool({ - name: 'add_field', - label: 'Add Field', - description: - 'Adds a new field (column) to an existing data object. ' + - 'Use this when the user wants to add a property, column, or attribute to a table.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - packageId: { - type: 'string', - description: 'Package ID that owns the target object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', - }, - objectName: { - type: 'string', - description: 'Target object machine name (snake_case)', - }, - name: { - type: 'string', - description: 'Field machine name (snake_case, e.g. due_date)', - }, - label: { - type: 'string', - description: 'Human-readable field label (e.g. Due Date)', - }, - type: { - type: 'string', - description: 'Field data type', - enum: ['text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'select', 'lookup', 'formula', 'autonumber'], - }, - required: { - type: 'boolean', - description: 'Whether the field is required', - }, - defaultValue: { - description: 'Default value for the field', - }, - options: { - type: 'array', - description: 'Options for select/picklist fields', - items: { - type: 'object', - properties: { - label: { type: 'string' }, - value: { - type: 'string', - description: 'Option machine identifier (lowercase snake_case, e.g. high_priority)', - pattern: '^[a-z_][a-z0-9_]*$', - }, - }, - }, - }, - reference: { - type: 'string', - description: 'Referenced object name for lookup fields (snake_case, e.g. account)', - }, - }, - required: ['objectName', 'name', 'type'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/apply-blueprint.tool.ts b/packages/services/service-ai/src/tools/apply-blueprint.tool.ts deleted file mode 100644 index f946ceae7..000000000 --- a/packages/services/service-ai/src/tools/apply-blueprint.tool.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * apply_blueprint — AI Tool Metadata (ADR-0033 §4, plan-first) - * - * Batch-drafts every artifact in an (approved, possibly human-edited) solution - * blueprint. Each object/view/dashboard is staged as a DRAFT (never published) - * and validated against its type's Zod schema; a bad item is reported but does - * not sink the rest. Call this ONLY after the human has approved the blueprint - * returned by `propose_blueprint`. - */ -export const applyBlueprintTool = defineTool({ - name: 'apply_blueprint', - label: 'Apply Blueprint', - description: - 'Batch-draft all objects, views, and dashboards in an approved solution blueprint. Every artifact is staged as a draft for human review — nothing is published. ' + - 'Call this ONLY after the user has confirmed the blueprint from propose_blueprint. Pass the (possibly edited) blueprint object exactly.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - blueprint: { - type: 'object', - description: 'The approved SolutionBlueprint object (the same shape propose_blueprint returned, with any human edits applied).', - }, - }, - required: ['blueprint'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/blueprint-tools.ts b/packages/services/service-ai/src/tools/blueprint-tools.ts deleted file mode 100644 index 183a8bd7f..000000000 --- a/packages/services/service-ai/src/tools/blueprint-tools.ts +++ /dev/null @@ -1,503 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { IAIService, IMetadataService, ModelMessage } from '@objectstack/spec/contracts'; -import { SolutionBlueprintSchema, SolutionBlueprintStrictSchema, type SolutionBlueprint } from '@objectstack/spec/ai'; -import { stageDraft, type DraftCapableProtocol } from './metadata-tools.js'; -import type { ToolHandler, ToolRegistry } from './tool-registry.js'; -import { proposeBlueprintTool } from './propose-blueprint.tool.js'; -import { applyBlueprintTool } from './apply-blueprint.tool.js'; - -export { proposeBlueprintTool } from './propose-blueprint.tool.js'; -export { applyBlueprintTool } from './apply-blueprint.tool.js'; - -/** - * Recursively drop object keys whose value is `null`. The OpenAI-strict output - * contract ({@link SolutionBlueprintStrictSchema}) requires every key present - * and emits `null` for "empty" optional fields; stripping those nulls makes the - * result conform to the lenient {@link SolutionBlueprintSchema} (which uses - * `.optional()` — absent, not null) so every downstream consumer is unchanged. - */ -function stripNulls(value: T): T { - if (Array.isArray(value)) { - return value.map((v) => stripNulls(v)) as unknown as T; - } - if (value && typeof value === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (v === null) continue; - out[k] = stripNulls(v); - } - return out as T; - } - return value; -} - -/** All blueprint (plan-first) tool definitions. */ -export const BLUEPRINT_TOOL_DEFINITIONS = [proposeBlueprintTool, applyBlueprintTool]; - -// --------------------------------------------------------------------------- -// Context -// --------------------------------------------------------------------------- - -/** - * The narrow slice of the runtime `package` service the blueprint tools use to - * give an app a home (see {@link ensureAppPackage}). A subset of the service - * registered at `ctx.registerService('package', …)` in `@objectstack/service-package`. - */ -export interface BlueprintPackageService { - /** Look up a package by id (latest version); null/undefined when absent. */ - get(packageId: string): Promise<{ manifest?: { name?: string } } | null | undefined>; - /** Insert/publish a package record (writable, source:'database'). */ - publish(data: { manifest: Record; metadata?: Record }): - Promise<{ success?: boolean; error?: string } | undefined>; -} - -/** - * Services the plan-first blueprint tools need (ADR-0033 §4). - * - * - {@link IAIService} drives `generateObject` for the structured blueprint. - * - `protocol` is the draft-capable write path reused from the metadata tools - * ({@link stageDraft}) — every artifact is staged, never published. - * - {@link IMetadataService} is a fallback enumerator for existing objects. - * - `packageService` (optional) lets a blueprint's app auto-create a writable - * "app package" home so the user never has to make one (zero-package UX). - */ -export interface BlueprintToolContext { - ai: IAIService; - protocol?: DraftCapableProtocol; - metadataService: IMetadataService; - packageService?: BlueprintPackageService; -} - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -/** Best-effort list of existing object names, so the agent doesn't redesign - * what already exists. Mirrors `list_metadata`'s protocol-first enumeration. */ -async function listExistingObjectNames(ctx: BlueprintToolContext): Promise { - try { - if (ctx.protocol?.getMetaItems) { - const res = await ctx.protocol.getMetaItems({ type: 'object' }); - const arr = Array.isArray(res) - ? res - : res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items) - ? (res as { items: unknown[] }).items - : []; - return (arr as Array<{ name?: string }>).map((o) => o?.name).filter((n): n is string => !!n); - } - } catch { - /* fall through to metadata service */ - } - try { - const objs = (await ctx.metadataService.listObjects()) as Array<{ name?: string }>; - return objs.map((o) => o?.name).filter((n): n is string => !!n); - } catch { - return []; - } -} - -// --------------------------------------------------------------------------- -// propose_blueprint — structured design, NOTHING persisted -// --------------------------------------------------------------------------- - -function createProposeBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { - return async (args) => { - const { goal, context } = args as { goal?: string; context?: string }; - if (!goal || typeof goal !== 'string') { - return JSON.stringify({ error: 'propose_blueprint: "goal" is required' }); - } - if (!ctx.ai.generateObject) { - return JSON.stringify({ - error: - 'propose_blueprint requires structured-output support. Configure a ' + - 'Vercel-AI-SDK-backed adapter (OpenAI, Anthropic, Google).', - }); - } - - const existing = await listExistingObjectNames(ctx); - const existingNote = existing.length - ? `Objects that ALREADY exist (do not recreate these; reference them in lookups): ${existing.join(', ')}.` - : 'There are no existing objects yet.'; - - const messages: ModelMessage[] = [ - { - role: 'system', - content: - 'You are a metadata architect. Turn the user\'s high-level goal into a concrete, ' + - 'minimal-but-complete solution blueprint: the objects (tables) and their fields, the ' + - 'relationships (expressed as lookup/master_detail fields with a `reference` to the target ' + - 'object), a few useful list views, and optionally a dashboard.\n\n' + - 'Rules:\n' + - '- Use snake_case for every object, field, and view name.\n' + - '- Prefer a small, sensible field set per object over an exhaustive one.\n' + - '- Model record STATUS / lifecycle stage as a single `select` field on the ' + - 'main object (e.g. a `status` field with options like draft/submitted/approved/' + - 'rejected/paid), NOT as a separate table.\n' + - '- A PROCESS is not data. Do NOT create objects for approvals, approval ' + - 'steps/records, workflows, routing, sign-offs, or audit trails (no ' + - '`approval`, `approval_record`, `approval_step`, `workflow`, `process` tables). ' + - 'Approval/automation logic belongs in a separate FLOW authored after this ' + - 'blueprint, and the trail comes from platform history — never a hand-built table. ' + - 'Model only the PEOPLE the process references (approver / reviewer / owner) as ' + - '`lookup` fields to the user object.\n' + - '- If the goal implies an approval or automation process, add an `assumption` ' + - 'stating the approval *flow* will be drafted as a separate step (it is not part ' + - 'of this data blueprint).\n' + - '- State the design choices you made as `assumptions`.\n' + - '- If (and only if) a genuinely structure-deciding choice is unclear, put at most 1-2 ' + - 'short `questions`; otherwise pick the most likely interpretation and proceed.\n' + - '- Do NOT invent field types — use the allowed enum values.\n' + - '- Include an `app` (navigation shell) that surfaces the created objects (and any ' + - 'dashboards) so the user can actually open the solution: give it a snake_case `name`, a ' + - 'friendly `label`, and a Lucide `icon`. Keep it to a single app with a flat list of nav ' + - 'entries (you may omit `nav` to auto-surface every object and dashboard).\n' + - `- ${existingNote}\n` + - 'This is a PROPOSAL. Nothing is built from it until the human approves.', - }, - { - role: 'user', - content: context ? `${goal}\n\nAdditional context: ${context}` : goal, - }, - ]; - - let blueprint: SolutionBlueprint; - try { - // Use the OpenAI-strict-compatible mirror as the output contract (the - // lenient SolutionBlueprintSchema's optional fields make OpenAI strict - // structured outputs reject the schema). Strip the nulls it emits so the - // result conforms to the lenient schema everything else consumes. - const generated = await ctx.ai.generateObject(messages, SolutionBlueprintStrictSchema, { - schemaName: 'SolutionBlueprint', - schemaDescription: - 'A proposed solution: objects + fields + relationships + views + dashboards + an app (navigation shell), with stated assumptions. Use null for fields that do not apply.', - }); - blueprint = stripNulls(generated.object) as SolutionBlueprint; - } catch (err) { - return JSON.stringify({ - error: `Failed to design blueprint: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - return JSON.stringify({ - status: 'blueprint_proposed', - blueprint, - summary: blueprint.summary, - counts: { - objects: blueprint.objects?.length ?? 0, - views: blueprint.views?.length ?? 0, - dashboards: blueprint.dashboards?.length ?? 0, - app: blueprint.app ? 1 : 0, - seedData: blueprint.seedData?.length ?? 0, - }, - questions: blueprint.questions ?? [], - note: 'Nothing has been created. Present this to the user; only call apply_blueprint after they approve.', - }); - }; -} - -// --------------------------------------------------------------------------- -// apply_blueprint — batch-draft every artifact (per-item, partial-tolerant) -// --------------------------------------------------------------------------- - -/** Convert a blueprint object into an `object` metadata body. */ -function objectBody(o: SolutionBlueprint['objects'][number]): Record { - const fields: Record = {}; - for (const f of o.fields ?? []) { - fields[f.name] = { - type: f.type, - ...(f.label ? { label: f.label } : {}), - ...(f.required !== undefined ? { required: f.required } : {}), - ...(f.reference ? { reference: f.reference } : {}), - ...(f.options ? { options: f.options } : {}), - }; - } - return { - name: o.name, - ...(o.label ? { label: o.label } : {}), - ...(o.description ? { description: o.description } : {}), - fields, - }; -} - -/** Map a blueprint view's kind to a ListView `type`. */ -const LIST_TYPE: Record = { list: 'grid', kanban: 'kanban', calendar: 'calendar' }; - -/** - * Convert a blueprint view into a `view` metadata RECORD (ADR-0005 view item). - * - * Emits the canonical single-view shape the console binds + renders: - * `{ object, viewKind: 'list'|'form', config: }` - * NOT the bare `{ list: … }` container fragment — without the top-level - * `object` (and the `.` record name set by {@link viewName}) the - * console can't associate the view with its object, so it never surfaces as a - * tab and a kanban silently falls back to the default grid. - */ -function viewBody( - v: NonNullable[number], - columnsByObject: Map, - groupFieldByObject?: Map, -): Record { - const cols = v.columns?.length ? v.columns : columnsByObject.get(v.object) ?? ['name']; - const data = { provider: 'object', object: v.object }; - // The body MUST carry a top-level `name` (the `.` record name): - // getMetaItems only surfaces overlay rows whose body has `name`, so a view - // without it is silently dropped from the object's view list (never a tab). - const name = viewName(v); - if (v.type === 'form') { - return { - name, - object: v.object, - viewKind: 'form', - config: { - type: 'simple', - data, - sections: [{ fields: cols.map((field) => ({ field })) }], - ...(v.label ? { label: v.label } : {}), - }, - ...(v.label ? { label: v.label } : {}), - }; - } - const config: Record = { - type: LIST_TYPE[v.type] ?? 'grid', - data, - columns: cols, - ...(v.label ? { label: v.label } : {}), - }; - // A kanban board needs a group-by field to form its columns; without it the - // renderer falls back to a flat grid. Prefer the blueprint's explicit - // `groupBy`, else infer the object's first select/status field. - if (v.type === 'kanban') { - const groupByField = v.groupBy || groupFieldByObject?.get(v.object); - // KanbanConfig requires both the group-by field and the card columns. - if (groupByField) config.kanban = { groupByField, columns: cols }; - } - return { - name, - object: v.object, - viewKind: 'list', - config, - ...(v.label ? { label: v.label } : {}), - }; -} - -/** - * Canonical view record name: `.` (e.g. `delivery_task.task_kanban`). - * The console keys an object's view tabs off this `.` prefix, so a bare - * view name never appears as a selectable view on the object page. - */ -function viewName(v: NonNullable[number]): string { - return v.name.startsWith(`${v.object}.`) ? v.name : `${v.object}.${v.name}`; -} - -/** Convert a blueprint dashboard into a `dashboard` metadata body. */ -function dashboardBody(d: NonNullable[number]): Record { - return { - name: d.name, - label: d.label ?? d.name, - widgets: (d.widgets ?? []).map((w) => ({ - id: w.id, - ...(w.title ? { title: w.title } : {}), - ...(w.object ? { object: w.object } : {}), - ...(w.chart ? { chart: w.chart } : {}), - })), - }; -} - -/** - * Convert the blueprint's app into an `app` metadata body — the navigation - * shell end users open in the App Launcher. When the blueprint gives no - * explicit `nav`, auto-surface every created object (then every dashboard) as a - * top-level nav entry. Never sets `isDefault` (don't hijack the default app). - */ -function appBody( - app: NonNullable, - blueprint: SolutionBlueprint, -): Record { - const navSource: Array<{ type: 'object' | 'dashboard'; target: string; label?: string; icon?: string }> = - app.nav && app.nav.length > 0 - ? app.nav - : [ - ...(blueprint.objects ?? []).map((o) => ({ type: 'object' as const, target: o.name, label: o.label })), - ...(blueprint.dashboards ?? []).map((d) => ({ type: 'dashboard' as const, target: d.name, label: d.label })), - ]; - const navigation = navSource.map((n, i) => { - const base = { - id: `nav_${n.target}`, - label: n.label ?? n.target, - ...(n.icon ? { icon: n.icon } : {}), - order: i, - }; - return n.type === 'dashboard' - ? { ...base, type: 'dashboard', dashboardName: n.target } - : { ...base, type: 'object', objectName: n.target }; - }); - return { - name: app.name, - label: app.label ?? app.name, - ...(app.icon ? { icon: app.icon } : {}), - navigation, - }; -} - -/** - * Give a blueprint's app a writable "home" package so the user never has to - * create one (mainstream AI builders — Power Apps' default solution, Salesforce - * orgs — never make a business user make a package to start building). Idempotent: - * one app ⇒ one `app.` package. Returns the package descriptor, or `null` - * to fall back to today's package-less drafting (no package service wired, or - * publish failed) — never throws, never blocks the build. - * - * NOTE: this stamps the *legacy* `sys_metadata.package_id` (a real grouping that - * shows in Studio's package selector and is the foundation for later - * version/export/promote). Full cross-environment promotion still needs the - * sealed `sys_package_version` model from ADR-0027, which is separate. - */ -async function ensureAppPackage( - protocol: DraftCapableProtocol | undefined, - pkgSvc: BlueprintPackageService | undefined, - app: { name: string; label?: string; icon?: string }, -): Promise<{ id: string; name: string; created: boolean } | null> { - const id = `app.${app.name}`; - const name = app.label ?? app.name; - const manifest: Record = { - id, - name, - version: '1.0.0', - type: 'application', - namespace: app.name, - // Must NOT be 'system'/'cloud' — Studio's package selector filters those - // out (studio.app.ts optionsSource). 'environment' keeps it visible. - scope: 'environment', - ...(app.icon ? { icon: app.icon } : {}), - }; - try { - // Idempotency: reuse an existing package when we can look one up. - if (pkgSvc?.get) { - const existing = await pkgSvc.get(id); - if (existing) return { id, name: existing.manifest?.name ?? name, created: false }; - } - // Preferred write path: the canonical `protocol.installPackage` primitive - // lands the package in BOTH the in-memory registry (Studio's selector reads - // this) and the durable `sys_packages` table — so the app package actually - // surfaces in Studio (ADR-0033 consolidation). - if (protocol?.installPackage) { - await protocol.installPackage({ manifest }); - return { id, name, created: true }; - } - // Fallback (older/remote protocol): the `package` service writes only - // `sys_packages`. Preserves prior behaviour when the primitive is absent. - if (pkgSvc?.publish) { - const res = await pkgSvc.publish({ manifest, metadata: { createdBy: 'ai', source: 'database' } }); - if (res && res.success === false) return null; // degrade to package-less - return { id, name, created: true }; - } - return null; // no write path available → package-less - } catch { - return null; // never block the build on packaging - } -} - -function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { - return async (args, exec) => { - const raw = (args as { blueprint?: unknown }).blueprint; - if (raw === undefined || raw === null) { - return JSON.stringify({ error: 'apply_blueprint: "blueprint" is required' }); - } - - // Defensive: the model re-emits the (possibly edited) blueprint — validate - // it before fanning out so a malformed plan fails fast with fixable issues. - // Strip any nulls first: the strict output contract emits `null` for empty - // optional fields, and the model may carry those through to this call; the - // lenient schema expects them absent. - const parsed = SolutionBlueprintSchema.safeParse(stripNulls(raw)); - if (!parsed.success) { - return JSON.stringify({ - error: 'Blueprint failed validation — fix and resend.', - issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message, code: i.code })), - }); - } - const blueprint = parsed.data; - const actor = exec?.actor?.id; - - // Zero-package UX: if the blueprint has an app, ensure a writable home - // package up front and bind every drafted artifact to it. Best-effort — - // `null` (no package service / publish failed) falls back to package-less. - const appPackage = blueprint.app ? await ensureAppPackage(ctx.protocol, ctx.packageService, blueprint.app) : null; - const packageId = appPackage?.id; - - const drafted: Array<{ type: string; name: string }> = []; - const failed: Array<{ type: string; name: string; error: string; code?: string }> = []; - - const record = async (type: string, name: string, item: unknown) => { - const res = await stageDraft(ctx.protocol, { type, name, item, actor, packageId }); - if (res.ok) drafted.push({ type, name }); - else failed.push({ type, name, error: res.error ?? 'unknown error', ...(res.code ? { code: res.code } : {}) }); - }; - - // Objects first (views/dashboards reference them). - const columnsByObject = new Map(); - const groupFieldByObject = new Map(); - for (const o of blueprint.objects ?? []) { - columnsByObject.set(o.name, (o.fields ?? []).map((f) => f.name)); - // Remember the first select field — the natural kanban group-by column. - const sel = (o.fields ?? []).find((f) => f.type === 'select'); - if (sel) groupFieldByObject.set(o.name, sel.name); - await record('object', o.name, objectBody(o)); - } - for (const v of blueprint.views ?? []) { - await record('view', viewName(v), viewBody(v, columnsByObject, groupFieldByObject)); - } - for (const d of blueprint.dashboards ?? []) { - await record('dashboard', d.name, dashboardBody(d)); - } - // The app (navigation shell) is drafted last — it references everything above. - if (blueprint.app) { - await record('app', blueprint.app.name, appBody(blueprint.app, blueprint)); - } - - const seedDataProposed = (blueprint.seedData ?? []).map((s) => ({ - object: s.object, - rows: s.records.length, - })); - - const summaryParts = [`drafted ${drafted.length} artifact(s)`]; - if (failed.length) summaryParts.push(`${failed.length} failed`); - if (appPackage) summaryParts.push(`grouped under app package "${appPackage.name}"`); - if (seedDataProposed.length) summaryParts.push(`${seedDataProposed.length} seed set(s) proposed (not applied)`); - - return JSON.stringify({ - status: failed.length && !drafted.length ? 'failed' : 'drafted', - drafted, - failed, - // The app's artifacts were auto-homed in a writable package (zero user - // package steps); informational only — no action required. - ...(appPackage ? { package: appPackage } : {}), - // Surface the package id at the top level (not just nested under `package`) - // and spell out the binding rule, so when the agent proceeds to draft - // automation for this app (e.g. an approval `flow`) it passes this - // `packageId` to create_metadata and the new artifact lands in the app - // package instead of becoming an orphan draft. - ...(packageId ? { packageId } : {}), - ...(packageId - ? { bindingHint: `To add automation (e.g. an approval flow) for this app, pass packageId="${packageId}" to create_metadata so it is grouped under the app — do not leave it unbound.` } - : {}), - // Phase C does not auto-apply seed data — no runtime-draftable `dataset` - // type exists; surface it so a human can wire it deliberately. - seedDataProposed, - summary: - `${summaryParts.join(', ')}. Review the drafted items in the designer and publish to make them live.` + - (seedDataProposed.length ? ' Seed data is suggested only — load it separately.' : ''), - }); - }; -} - -// --------------------------------------------------------------------------- -// Registration -// --------------------------------------------------------------------------- - -/** Register the plan-first blueprint tools (`propose_blueprint`, `apply_blueprint`). */ -export function registerBlueprintTools(registry: ToolRegistry, context: BlueprintToolContext): void { - registry.register(proposeBlueprintTool, createProposeBlueprintHandler(context)); - registry.register(applyBlueprintTool, createApplyBlueprintHandler(context)); -} diff --git a/packages/services/service-ai/src/tools/create-metadata.tool.ts b/packages/services/service-ai/src/tools/create-metadata.tool.ts deleted file mode 100644 index 33d58fbb5..000000000 --- a/packages/services/service-ai/src/tools/create-metadata.tool.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * create_metadata — AI Tool Metadata (ADR-0033) - * - * Type-agnostic creation of ANY metadata item (object, view, dashboard, flow, - * …). The new item is staged as a **draft** — it never goes live until a human - * reviews and publishes. The payload is validated against the type's canonical - * Zod schema (ADR-0005); invalid output is rejected with a fixable error. - * - * For new data objects + their fields the dedicated `create_object` / - * `add_field` tools offer a friendlier shape, but they ultimately stage the - * same way. - */ -export const createMetadataTool = defineTool({ - name: 'create_metadata', - label: 'Create Metadata', - description: - 'Create a new metadata item of ANY type (view, dashboard, flow, report, app, object, …) and stage it as a draft for human review. ' + - 'Use for non-object types, or any type, when no dedicated tool fits. The change is NOT published — a human must publish it.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow", "report", "app".', - }, - name: { - type: 'string', - description: 'Machine name for the item (snake_case, e.g. account_kanban).', - }, - definition: { - type: 'object', - description: - 'The full metadata definition body for this type, conforming to the type\'s schema. The "name" field is set automatically from the name argument.', - }, - packageId: { - type: 'string', - description: 'Package ID that will own this item. If omitted, uses the active package from conversation context.', - }, - }, - required: ['type', 'name', 'definition'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/create-object.tool.ts b/packages/services/service-ai/src/tools/create-object.tool.ts deleted file mode 100644 index 959bd8fa5..000000000 --- a/packages/services/service-ai/src/tools/create-object.tool.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * create_object — AI Tool Metadata - * - * Creates a new data object (table) with schema validation. - * Validates snake_case naming for object and initial fields, - * checks for duplicates, and stages the object as a draft. - * - * ADR-0033: this never publishes — the object lands in the draft workspace - * for a human to review and publish. The draft IS the approval gate, which is - * why no `requiresConfirmation` flag is needed (it was never enforced anyway). - */ -export const createObjectTool = defineTool({ - name: 'create_object', - label: 'Create Object', - description: - 'Creates a new data object (table) with the specified name, label, and optional field definitions, staged as a draft for human review. ' + - 'Use this when the user wants to create a new entity, table, or data model. The change is NOT published.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Machine name for the object (snake_case, e.g. project_task)', - }, - label: { - type: 'string', - description: 'Human-readable display name (e.g. Project Task)', - }, - packageId: { - type: 'string', - description: 'Package ID that will own this object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', - }, - fields: { - type: 'array', - description: 'Initial fields to create with the object', - items: { - type: 'object', - properties: { - name: { type: 'string', description: 'Field machine name (snake_case)' }, - label: { type: 'string', description: 'Field display name' }, - type: { - type: 'string', - description: 'Field data type', - enum: ['text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'select', 'lookup', 'formula', 'autonumber'], - }, - required: { type: 'boolean', description: 'Whether the field is required' }, - }, - required: ['name', 'type'], - }, - }, - enableFeatures: { - type: 'object', - description: 'Object capability flags', - properties: { - trackHistory: { type: 'boolean' }, - apiEnabled: { type: 'boolean' }, - }, - }, - }, - required: ['name', 'label'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/create-package.tool.ts b/packages/services/service-ai/src/tools/create-package.tool.ts deleted file mode 100644 index 3ad30d503..000000000 --- a/packages/services/service-ai/src/tools/create-package.tool.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * create_package — AI Tool Metadata - * - * Creates a new package for organizing metadata. - * All metadata (objects, views, flows, etc.) should belong to a package. - */ -export const createPackageTool = defineTool({ - name: 'create_package', - label: 'Create Package', - description: - 'Creates a new package (metadata container) with the specified manifest. ' + - 'All metadata in ObjectStack should belong to a package. Use this when starting new development ' + - 'or when the user wants to organize their metadata into a new module.', - category: 'utility', - builtIn: true, - parameters: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Package identifier in reverse domain notation (e.g., com.acme.crm, org.mycompany.sales)', - }, - name: { - type: 'string', - description: 'Human-readable package name (e.g., "CRM Application", "Sales Module")', - }, - version: { - type: 'string', - description: 'Semantic version (e.g., "1.0.0")', - default: '1.0.0', - }, - description: { - type: 'string', - description: 'Brief description of what this package provides', - }, - namespace: { - type: 'string', - description: 'Namespace prefix for metadata (snake_case, e.g., crm, sales). If not provided, derived from package ID.', - }, - type: { - type: 'string', - description: 'Package type', - enum: ['application', 'plugin', 'library', 'template'], - default: 'application', - }, - }, - required: ['id', 'name'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/delete-field.tool.ts b/packages/services/service-ai/src/tools/delete-field.tool.ts deleted file mode 100644 index fb4f73c3a..000000000 --- a/packages/services/service-ai/src/tools/delete-field.tool.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * delete_field — AI Tool Metadata - * - * Removes a field (column) from an existing data object. This is a destructive - * operation, but ADR-0033 stages it as a draft — the field is only actually - * dropped when a human reviews and publishes (which re-runs the destructive - * data-loss check). The draft IS the approval gate. - */ -export const deleteFieldTool = defineTool({ - name: 'delete_field', - label: 'Delete Field', - description: - 'Removes a field (column) from an existing data object, staged as a draft for human review. This is a destructive operation; it is NOT published until a human publishes the draft. ' + - 'Use this when the user explicitly wants to remove an attribute or column from a table.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - packageId: { - type: 'string', - description: 'Package ID that owns the target object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', - }, - objectName: { - type: 'string', - description: 'Target object machine name (snake_case)', - }, - fieldName: { - type: 'string', - description: 'Field machine name to delete (snake_case)', - }, - }, - required: ['objectName', 'fieldName'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/describe-metadata.tool.ts b/packages/services/service-ai/src/tools/describe-metadata.tool.ts deleted file mode 100644 index c7aa84a39..000000000 --- a/packages/services/service-ai/src/tools/describe-metadata.tool.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * describe_metadata — AI Tool Metadata (ADR-0033) - * - * Type-agnostic read of a single metadata item's full body. Returns the pending - * draft if one exists, else the published value — so the agent edits against - * what it (or the user) most recently staged. Use before `update_metadata` to - * see the current shape. - */ -export const describeMetadataTool = defineTool({ - name: 'describe_metadata', - label: 'Describe Metadata', - description: - 'Return the full definition of a metadata item of ANY type (draft-first: shows the pending draft if one exists, else the published value). ' + - 'Use to inspect an item before updating it.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow".', - }, - name: { - type: 'string', - description: 'Machine name of the item (snake_case).', - }, - }, - required: ['type', 'name'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/describe-object.tool.ts b/packages/services/service-ai/src/tools/describe-object.tool.ts deleted file mode 100644 index 805962317..000000000 --- a/packages/services/service-ai/src/tools/describe-object.tool.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * describe_object — AI Tool Metadata - * - * Returns the full schema of a data object including all fields, types, - * relationships, and configuration. This is the single, unified tool for - * describing objects — used by both data_chat and metadata_assistant agents. - */ -export const describeObjectTool = defineTool({ - name: 'describe_object', - label: 'Describe Object', - description: - 'Returns the full schema details of a data object, including all fields, types, relationships, and configuration. ' + - 'Use this to understand the structure of a table before querying or modifying it.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - objectName: { - type: 'string', - description: 'Object machine name to describe (snake_case)', - }, - }, - required: ['objectName'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/get-active-package.tool.ts b/packages/services/service-ai/src/tools/get-active-package.tool.ts deleted file mode 100644 index 19a064b15..000000000 --- a/packages/services/service-ai/src/tools/get-active-package.tool.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * get_active_package — AI Tool Metadata - * - * Gets the currently active package in the conversation context. - */ -export const getActivePackageTool = defineTool({ - name: 'get_active_package', - label: 'Get Active Package', - description: - 'Gets the currently active package in this conversation. The active package determines ' + - 'where new metadata will be created. Returns null if no package is set.', - category: 'utility', - builtIn: true, - parameters: { - type: 'object', - properties: {}, - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/get-metadata-schema.tool.ts b/packages/services/service-ai/src/tools/get-metadata-schema.tool.ts deleted file mode 100644 index 9b47c5c42..000000000 --- a/packages/services/service-ai/src/tools/get-metadata-schema.tool.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * get_metadata_schema — AI Tool Metadata (ADR-0033) - * - * Lets the agent READ a metadata type's canonical contract on demand: returns - * the JSON Schema derived from the type's live Zod schema (the same schema that - * `saveMetaItem` validates against). The AI never sees the raw spec source, so - * without this it has to *guess* the shape of complex types (view, dashboard, - * flow, …) and learn from validation errors by trial-and-error. Calling this - * first lets it author a correct payload in one shot. - * - * Read-only: returns a schema, stages nothing. - */ -export const getMetadataSchemaTool = defineTool({ - name: 'get_metadata_schema', - label: 'Get Metadata Schema', - description: - 'Return the JSON Schema (contract) for a metadata type — the exact shape `create_metadata` / `update_metadata` must produce. ' + - 'ALWAYS call this BEFORE authoring a non-trivial type you are unsure about (view, dashboard, flow, report, page, …) so you get the structure right the first time instead of guessing. Read-only.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Metadata type (singular), e.g. "view", "dashboard", "flow", "report", "page", "object", "app".', - }, - }, - required: ['type'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/get-package.tool.ts b/packages/services/service-ai/src/tools/get-package.tool.ts deleted file mode 100644 index 6766a0e6b..000000000 --- a/packages/services/service-ai/src/tools/get-package.tool.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * get_package — AI Tool Metadata - * - * Gets detailed information about a specific package. - */ -export const getPackageTool = defineTool({ - name: 'get_package', - label: 'Get Package', - description: - 'Gets detailed information about a specific installed package, including its manifest, ' + - 'metadata, and installation status.', - category: 'utility', - builtIn: true, - parameters: { - type: 'object', - properties: { - packageId: { - type: 'string', - description: 'Package identifier (reverse domain notation, e.g., com.acme.crm)', - }, - }, - required: ['packageId'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/index.ts b/packages/services/service-ai/src/tools/index.ts index 68a66a25a..d3b71f3d7 100644 --- a/packages/services/service-ai/src/tools/index.ts +++ b/packages/services/service-ai/src/tools/index.ts @@ -6,26 +6,9 @@ export type { ToolHandler, ToolExecutionResult } from './tool-registry.js'; export { registerDataTools, DATA_TOOL_DEFINITIONS } from './data-tools.js'; export type { DataToolContext } from './data-tools.js'; -export { registerMetadataTools, METADATA_TOOL_DEFINITIONS, stageDraft } from './metadata-tools.js'; -export type { MetadataToolContext, StageDraftInput, StageDraftResult, DraftCapableProtocol } from './metadata-tools.js'; - -export { registerBlueprintTools, BLUEPRINT_TOOL_DEFINITIONS } from './blueprint-tools.js'; -export type { BlueprintToolContext } from './blueprint-tools.js'; - export { registerKnowledgeTools, SEARCH_KNOWLEDGE_TOOL } from './knowledge-tools.js'; export type { KnowledgeToolContext } from './knowledge-tools.js'; -// Individual tool metadata exports -export { createObjectTool } from './create-object.tool.js'; -export { addFieldTool } from './add-field.tool.js'; -export { modifyFieldTool } from './modify-field.tool.js'; -export { deleteFieldTool } from './delete-field.tool.js'; -export { listObjectsTool } from './list-objects.tool.js'; -export { describeObjectTool } from './describe-object.tool.js'; -export { validateExpressionTool } from './validate-expression.tool.js'; -export { createMetadataTool } from './create-metadata.tool.js'; -export { updateMetadataTool } from './update-metadata.tool.js'; -export { describeMetadataTool } from './describe-metadata.tool.js'; -export { listMetadataTool } from './list-metadata.tool.js'; -export { proposeBlueprintTool } from './propose-blueprint.tool.js'; -export { applyBlueprintTool } from './apply-blueprint.tool.js'; +// NOTE: the AI metadata-authoring tools (metadata-tools, blueprint-tools, +// package-tools, and the create-object / add-field / *-metadata.tool surfaces) +// moved to the cloud-only @objectstack/service-ai-studio package. diff --git a/packages/services/service-ai/src/tools/list-metadata.tool.ts b/packages/services/service-ai/src/tools/list-metadata.tool.ts deleted file mode 100644 index a430366f9..000000000 --- a/packages/services/service-ai/src/tools/list-metadata.tool.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * list_metadata — AI Tool Metadata (ADR-0033) - * - * Type-agnostic enumeration of all items of a metadata type (name + label), - * backed by the same source as `GET /api/v1/meta/:type`. Use to discover what - * exists before creating or to find an item to update. - */ -export const listMetadataTool = defineTool({ - name: 'list_metadata', - label: 'List Metadata', - description: - 'List all metadata items of a given type (name and label), with an optional name/label substring filter. ' + - 'Use to discover existing views, dashboards, flows, etc. before creating or updating one.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow", "report", "app".', - }, - filter: { - type: 'string', - description: 'Optional case-insensitive substring to filter items by name or label.', - }, - }, - required: ['type'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/list-objects.tool.ts b/packages/services/service-ai/src/tools/list-objects.tool.ts deleted file mode 100644 index f465c50a2..000000000 --- a/packages/services/service-ai/src/tools/list-objects.tool.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * list_objects — AI Tool Metadata - * - * Lists all registered data objects (tables) with optional filtering - * and field summaries. This is the single, unified tool for listing - * objects — used by both data_chat and metadata_assistant agents. - */ -export const listObjectsTool = defineTool({ - name: 'list_objects', - label: 'List Objects', - description: - 'Lists all registered data objects (tables) in the current environment. ' + - 'Use this when the user wants to see what tables, entities, or data models are available.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - filter: { - type: 'string', - description: 'Optional name or label substring to filter objects', - }, - includeFields: { - type: 'boolean', - description: 'Whether to include field summaries for each object (default: false)', - }, - }, - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/list-packages.tool.ts b/packages/services/service-ai/src/tools/list-packages.tool.ts deleted file mode 100644 index 92517ade7..000000000 --- a/packages/services/service-ai/src/tools/list-packages.tool.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * list_packages — AI Tool Metadata - * - * Lists all installed packages in the ObjectStack instance. - * Useful for understanding what packages are available before creating metadata. - */ -export const listPackagesTool = defineTool({ - name: 'list_packages', - label: 'List Packages', - description: - 'Lists all installed packages in the system. Use this to see what packages are available ' + - 'before creating or modifying metadata. Packages are the containers that hold metadata.', - category: 'utility', - builtIn: true, - parameters: { - type: 'object', - properties: { - status: { - type: 'string', - description: 'Filter by package status', - enum: ['installed', 'disabled', 'installing', 'upgrading', 'uninstalling', 'error'], - }, - enabled: { - type: 'boolean', - description: 'Filter by enabled state (true = only enabled, false = only disabled)', - }, - }, - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/metadata-tools.ts b/packages/services/service-ai/src/tools/metadata-tools.ts deleted file mode 100644 index 39c045fbd..000000000 --- a/packages/services/service-ai/src/tools/metadata-tools.ts +++ /dev/null @@ -1,1165 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { IMetadataService } from '@objectstack/spec/contracts'; -import type { Tool } from '@objectstack/spec/ai'; -import type { ToolHandler } from './tool-registry.js'; -import type { ToolRegistry } from './tool-registry.js'; -import { z } from 'zod'; -import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; - -// --------------------------------------------------------------------------- -// Tool Metadata — individual .tool.ts files (single source of truth) -// --------------------------------------------------------------------------- - -export { createObjectTool } from './create-object.tool.js'; -export { addFieldTool } from './add-field.tool.js'; -export { modifyFieldTool } from './modify-field.tool.js'; -export { deleteFieldTool } from './delete-field.tool.js'; -export { listObjectsTool } from './list-objects.tool.js'; -export { describeObjectTool } from './describe-object.tool.js'; -export { validateExpressionTool } from './validate-expression.tool.js'; -export { createMetadataTool } from './create-metadata.tool.js'; -export { updateMetadataTool } from './update-metadata.tool.js'; -export { describeMetadataTool } from './describe-metadata.tool.js'; -export { listMetadataTool } from './list-metadata.tool.js'; -export { getMetadataSchemaTool } from './get-metadata-schema.tool.js'; - -import { createObjectTool } from './create-object.tool.js'; -import { addFieldTool } from './add-field.tool.js'; -import { modifyFieldTool } from './modify-field.tool.js'; -import { deleteFieldTool } from './delete-field.tool.js'; -import { listObjectsTool } from './list-objects.tool.js'; -import { describeObjectTool } from './describe-object.tool.js'; -import { validateExpressionTool } from './validate-expression.tool.js'; -import { createMetadataTool } from './create-metadata.tool.js'; -import { updateMetadataTool } from './update-metadata.tool.js'; -import { describeMetadataTool } from './describe-metadata.tool.js'; -import { listMetadataTool } from './list-metadata.tool.js'; -import { getMetadataSchemaTool } from './get-metadata-schema.tool.js'; -import { validateExpression, introspectScope, type FieldRole } from '@objectstack/formula'; - -/** All built-in metadata management tool definitions (Tool metadata). */ -export const METADATA_TOOL_DEFINITIONS: Tool[] = [ - // ADR-0033 type-agnostic apply surface (preferred for any metadata type) - getMetadataSchemaTool, - createMetadataTool, - updateMetadataTool, - describeMetadataTool, - listMetadataTool, - // Object/field convenience tools (now draft-gated thin wrappers) - createObjectTool, - addFieldTool, - modifyFieldTool, - deleteFieldTool, - listObjectsTool, - describeObjectTool, - validateExpressionTool, -]; - -// --------------------------------------------------------------------------- -// Internal type aliases for metadata payloads (returned as `unknown` from -// IMetadataService — we cast to these lightweight shapes for field access). -// --------------------------------------------------------------------------- - -/** Minimal shape of an object definition as returned by IMetadataService. */ -interface ObjectDef { - name: string; - label?: string; - fields?: Record; - enable?: Record; -} - -/** Minimal shape of a field definition inside an object. */ -interface FieldDef { - name?: string; - type?: string; - label?: string; - required?: boolean; - reference?: string; - options?: unknown; - defaultValue?: unknown; -} - -// --------------------------------------------------------------------------- -// Shared validation helpers -// --------------------------------------------------------------------------- - -/** snake_case identifier pattern (e.g. `project_task`, `due_date`). */ -const SNAKE_CASE_RE = /^[a-z_][a-z0-9_]*$/; - -/** Validate that a value matches snake_case. */ -function isSnakeCase(value: string): boolean { - return SNAKE_CASE_RE.test(value); -} - -// --------------------------------------------------------------------------- -// Package Resolution Helpers -// --------------------------------------------------------------------------- - -/** - * Retrieves the active package ID from the conversation context. - * Returns null if no conversation service is available or no active package is set. - */ -async function getActivePackageId(ctx: MetadataToolContext): Promise { - if (!ctx.conversationService?.getMetadata || !ctx.conversationId) { - return null; - } - - const metadata = await ctx.conversationService.getMetadata(ctx.conversationId); - return (metadata?.activePackageId as string) ?? null; -} - -/** - * Resolves the package ID to use for a metadata operation. - * Priority: explicit packageId > active package from conversation > error - * - * Also validates that the package exists and checks if it's read-only. - * - * @returns Object with packageId or error message - */ -async function resolvePackageId( - ctx: MetadataToolContext, - explicitPackageId?: string, -): Promise<{ packageId: string | null; error?: string; warning?: string }> { - let packageId: string | null = null; - - // 1. Try explicit packageId parameter - if (explicitPackageId) { - packageId = explicitPackageId; - } else { - // 2. Try active package from conversation - packageId = await getActivePackageId(ctx); - } - - // If no package ID could be resolved, return null (backward compatibility) - // This allows metadata to be stored without package association - if (!packageId) { - return { - packageId: null, - warning: 'No package specified. Metadata will be created without package association. Consider using set_active_package or providing packageId parameter.', - }; - } - - // Validate package exists (if registry is available) - if (ctx.packageRegistry) { - const exists = await ctx.packageRegistry.exists(packageId); - if (!exists) { - return { - packageId: null, - error: `Package "${packageId}" not found. Use list_packages to see available packages or create_package to create a new one.`, - }; - } - - // Check if package is read-only (code-based) - const pkg = await ctx.packageRegistry.get(packageId); - if (pkg?.manifest.source === 'filesystem') { - return { - packageId: null, - error: `Package "${packageId}" is read-only (loaded from code). Only database packages can be modified. Use create_package to create a new database package.`, - }; - } - } - - return { packageId }; -} - -// --------------------------------------------------------------------------- -// Context — injected once at registration time -// --------------------------------------------------------------------------- - -/** - * Services required by the metadata management tools. - * - * Provided by the kernel at `ai:ready` time and closed over - * by the handler functions so they stay framework-agnostic. - */ -export interface MetadataToolContext { - /** Metadata service for schema CRUD operations. */ - metadataService: IMetadataService; - - /** Optional: Conversation service for retrieving active package context */ - conversationService?: { - getMetadata?(conversationId: string): Promise | undefined>; - }; - - /** Optional: Current conversation ID (if in a conversation context) */ - conversationId?: string; - - /** Optional: Package registry for validating package existence */ - packageRegistry?: { - exists(packageId: string): Promise; - get(packageId: string): Promise<{ manifest: { scope?: string; source?: string } } | undefined>; - }; - - /** - * Optional: ObjectStack protocol for cross-source metadata enumeration. - * - * `IMetadataService.listObjects()` only sees items registered through the - * MetadataManager (in-memory registry + loaders). It misses objects that - * live in ObjectQL's SchemaRegistry — most notably system objects shipped - * by plugins (e.g. `sys_user`, `sys_organization` from plugin-auth) and - * environment-scoped objects persisted to `sys_metadata`. - * - * When provided, `list_objects` will use `protocol.getMetaItems({ type: 'object' })` - * (the same source that backs `GET /api/v1/meta/object`) so the agent sees the - * complete set of available objects. - */ - protocol?: { - /** - * `previewDrafts` overlays pending `state='draft'` rows on the active list - * so the authoring agent can DISCOVER metadata it (or a prior turn) just - * drafted but nobody has published yet — e.g. referencing a just-drafted - * object when authoring a flow. Without it, `getMetaItems` is active-only - * and the agent reports its own draft objects as "not found". Older runtimes - * ignore the unknown property (graceful: stays active-only). - */ - getMetaItems(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }): Promise; - /** - * Read a single metadata item. With `state:'draft'` returns the pending - * draft row and throws `no_draft` (404) when none exists — it does NOT - * fall through to the published value, so callers must catch and fall - * back. The runtime object backing `ctx.protocol` is the full - * ObjectStackProtocolImplementation, which provides this. - */ - getMetaItem?(request: { - type: string; - name: string; - packageId?: string; - organizationId?: string; - state?: 'active' | 'draft'; - }): Promise; - /** - * Save a metadata item. ADR-0033: AI writes ALWAYS pass `mode:'draft'` so - * nothing the agent authors goes live until a human publishes. Validates - * against the per-type Zod schema (ADR-0005) and throws `invalid_metadata` - * / `destructive_change` with structured `issues` on rejection. - */ - saveMetaItem?(request: { - type: string; - name: string; - item?: unknown; - organizationId?: string; - parentVersion?: string | null; - actor?: string; - force?: boolean; - mode?: 'draft' | 'publish'; - packageId?: string | null; - }): Promise; - /** - * Install a package from a manifest — the canonical write primitive that - * lands the package in BOTH the in-memory registry (Studio's selector reads - * this) and the durable `sys_packages` table (ADR-0033 consolidation). The - * runtime object backing `ctx.protocol` is the full - * ObjectStackProtocolImplementation, which provides this; older/remote - * protocols may omit it (callers fall back to the `package` service). - */ - installPackage?(request: { - manifest: Record; - settings?: Record; - }): Promise<{ package?: unknown; message?: string } | unknown>; - }; -} - -// --------------------------------------------------------------------------- -// ADR-0033 — draft-gated write core -// -// Every metadata mutation an AI makes routes through `applyDraft`, which -// writes `mode:'draft'` via the protocol's `saveMetaItem`. The draft IS the -// approval gate: nothing is live until a human publishes. We never call -// `metadataService.register(...)` from a tool handler — that path publishes -// straight to the live schema (the exact hazard ADR-0033 closes). -// --------------------------------------------------------------------------- - -interface ApplyDraftInput { - /** Metadata type (singular, e.g. 'object', 'view'). */ - type: string; - /** Item name (snake_case). */ - name: string; - /** The full item body to stage as a draft. */ - item: unknown; - /** Acting user id (from the tool execution context) for provenance/audit. */ - actor?: string; - /** Owning package id, when resolved. */ - packageId?: string | null; - /** - * Bypass the destructive-data 409. Defaults to `true` for draft writes: a - * draft never applies DDL or drops data — the human's *publish* is the - * moment data is touched, and it re-runs its own checks. Blocking staging on - * the publish-time guard would prevent the agent from proposing schema - * changes for review, which is the whole point. - */ - force?: boolean; - /** Human-readable one-line summary for the result envelope. */ - summary: string; - /** Paths that changed, for the review/diff surface. */ - changedKeys: string[]; -} - -/** The draft-capable subset of the ObjectStack protocol (a `saveMetaItem` that - * honours `mode:'draft'`). Shared by the metadata tools and the blueprint - * apply step so there is one draft-write path. */ -export type DraftCapableProtocol = NonNullable; - -/** Input to {@link stageDraft} — the type/name/body plus provenance. */ -export interface StageDraftInput { - type: string; - name: string; - item: unknown; - actor?: string; - packageId?: string | null; - /** See {@link ApplyDraftInput.force}. Defaults to `true` for draft writes. */ - force?: boolean; -} - -/** Structured outcome of a single draft write (no JSON, no throw). */ -export interface StageDraftResult { - ok: boolean; - error?: string; - code?: string; - issues?: unknown; -} - -/** - * The single ADR-0033 draft-write primitive: stage `item` via - * `protocol.saveMetaItem({ mode:'draft' })`. Validates against the per-type Zod - * schema (ADR-0005) and never throws — a rejection comes back as - * `{ ok:false, error, code, issues }` so callers can feed it to the model or - * collect per-item results (the blueprint apply step). Safe by default: with no - * draft-capable protocol it refuses rather than falling back to publish. - */ -export async function stageDraft( - protocol: DraftCapableProtocol | undefined, - input: StageDraftInput, -): Promise { - if (!protocol?.saveMetaItem) { - return { - ok: false, - error: - 'Draft persistence is unavailable: no protocol service is wired, so metadata changes cannot be staged for review.', - }; - } - try { - await protocol.saveMetaItem({ - type: input.type, - name: input.name, - item: input.item, - mode: 'draft', - force: input.force ?? true, - ...(input.actor ? { actor: input.actor } : {}), - ...(input.packageId !== undefined && input.packageId !== null - ? { packageId: input.packageId } - : {}), - }); - return { ok: true }; - } catch (err) { - const e = err as { message?: string; code?: string; issues?: unknown }; - return { - ok: false, - error: e.message ?? String(err), - ...(e.code ? { code: e.code } : {}), - ...(e.issues ? { issues: e.issues } : {}), - }; - } -} - -/** - * Stage `item` as a draft and return the ADR-0033 result envelope - * `{ status:'drafted', type, name, summary, changedKeys }` as a JSON string. - * Thin wrapper over {@link stageDraft} that shapes the per-tool envelope and - * the error feedback the tool-call loop expects. - */ -async function applyDraft(ctx: MetadataToolContext, input: ApplyDraftInput): Promise { - const res = await stageDraft(ctx.protocol, input); - if (!res.ok) { - return JSON.stringify({ - error: res.error, - ...(res.code ? { code: res.code } : {}), - ...(res.issues ? { issues: res.issues } : {}), - }); - } - return JSON.stringify({ - status: 'drafted', - type: input.type, - name: input.name, - summary: input.summary, - changedKeys: input.changedKeys, - }); -} - -/** - * Read the current body of a metadata item, **draft-first**: returns the - * pending draft if one exists, else the live/published value, else undefined. - * This is what lets successive field ops (`add_field`, `modify_field`, …) - * stack into the *same* single draft rather than each starting from the last - * published version (ADR-0033 §3: "read-modify-write the single object draft, - * they do not fork drafts"). - */ -async function readDraftFirst( - ctx: MetadataToolContext, - type: string, - name: string, -): Promise { - if (ctx.protocol?.getMetaItem) { - // Draft row first. `getMetaItem({state:'draft'})` throws `no_draft` (404) - // when none exists — catch and fall through to the published value. - try { - const draft = await ctx.protocol.getMetaItem({ type, name, state: 'draft' }); - const draftItem = (draft as { item?: unknown } | undefined)?.item; - if (draftItem) return draftItem; - } catch { - /* no draft — fall through */ - } - try { - const active = await ctx.protocol.getMetaItem({ type, name }); - const activeItem = (active as { item?: unknown } | undefined)?.item; - if (activeItem) return activeItem; - } catch { - /* not found via protocol — fall through to the metadata service */ - } - } - if (type === 'object') { - return ctx.metadataService.getObject(name); - } - return ctx.metadataService.get(type, name); -} - -/** - * RFC 7386 JSON Merge Patch: recursively merge `patch` into `target`; a `null` - * value deletes that key; a non-object `patch` replaces wholesale. Used by - * `update_metadata` so the agent can express a partial change without - * restating the whole item. - */ -function mergePatch(target: unknown, patch: unknown): unknown { - if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) { - return patch; - } - const base: Record = - target && typeof target === 'object' && !Array.isArray(target) - ? { ...(target as Record) } - : {}; - for (const [key, value] of Object.entries(patch as Record)) { - if (value === null) { - delete base[key]; - } else { - base[key] = mergePatch(base[key], value); - } - } - return base; -} - -// --------------------------------------------------------------------------- -// Handler Factories -// --------------------------------------------------------------------------- - -/** - * validate_expression (ADR-0032 §1e) — run the shared validator on an - * expression before it is saved, so the agent self-corrects at authoring time. - * Resolves the object's field names (when `objectName` is given) for - * schema-aware field-existence checks. - */ -function createValidateExpressionHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { - const { role, source, objectName } = args as { role?: string; source?: string; objectName?: string }; - if (!source || typeof source !== 'string') { - return JSON.stringify({ ok: false, errors: [{ message: '"source" is required' }] }); - } - const fieldRole: FieldRole = role === 'template' || role === 'value' ? role : 'predicate'; - - let fields: string[] | undefined; - if (objectName) { - try { - const objectDef = (await ctx.metadataService.getObject(objectName)) as ObjectDef | undefined; - if (objectDef?.fields) fields = Object.keys(objectDef.fields); - } catch { - // schema lookup is best-effort — fall back to syntax-only validation - } - } - - const result = validateExpression(fieldRole, source, objectName ? { objectName, fields } : undefined); - const scope = introspectScope(fieldRole, objectName ? { objectName, fields } : undefined); - return JSON.stringify({ - ok: result.ok, - errors: result.errors, - dialect: scope.dialect, - // On failure, surface what IS in scope so the agent can fix the reference. - ...(result.ok ? {} : { availableFields: scope.fields, roots: scope.roots, functions: scope.functions }), - }); - }; -} - -function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { - return async (args, exec) => { - const { name, label, packageId: explicitPackageId, fields, enableFeatures } = args as { - name: string; - label: string; - packageId?: string; - fields?: Array<{ name: string; label?: string; type: string; required?: boolean }>; - enableFeatures?: Record; - }; - - if (!name || !label) { - return JSON.stringify({ error: 'Both "name" and "label" are required' }); - } - - // Resolve package ID - const resolved = await resolvePackageId(ctx, explicitPackageId); - if (resolved.error) { - return JSON.stringify({ error: resolved.error }); - } - const packageId = resolved.packageId; - - // Validate snake_case name - if (!isSnakeCase(name)) { - return JSON.stringify({ error: `Invalid object name "${name}". Must be snake_case.` }); - } - - // Check if the object already exists (draft-first — an AI-drafted object - // not yet published still counts as existing). - const existing = await readDraftFirst(ctx, 'object', name); - if (existing) { - return JSON.stringify({ error: `Object "${name}" already exists` }); - } - - // Build field map from array input with per-field validation - const fieldMap: Record> = {}; - if (fields && Array.isArray(fields)) { - const seenNames = new Set(); - for (const f of fields) { - if (!f.name) { - return JSON.stringify({ error: 'Each field must have a "name" property' }); - } - if (!isSnakeCase(f.name)) { - return JSON.stringify({ error: `Invalid field name "${f.name}". Must be snake_case.` }); - } - if (seenNames.has(f.name)) { - return JSON.stringify({ error: `Duplicate field name "${f.name}" in initial fields` }); - } - seenNames.add(f.name); - fieldMap[f.name] = { - type: f.type, - ...(f.label ? { label: f.label } : {}), - ...(f.required !== undefined ? { required: f.required } : {}), - }; - } - } - - const objectDef: Record = { - name, - label, - ...(packageId ? { packageId } : {}), - ...(Object.keys(fieldMap).length > 0 ? { fields: fieldMap } : {}), - ...(enableFeatures ? { enable: enableFeatures } : {}), - }; - - return applyDraft(ctx, { - type: 'object', - name, - item: objectDef, - actor: exec?.actor?.id, - packageId, - summary: `Drafted new object "${name}" (${label})${ - Object.keys(fieldMap).length ? ` with ${Object.keys(fieldMap).length} field(s)` : '' - }`, - changedKeys: Object.keys(objectDef), - }); - }; -} - -function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { - return async (args, exec) => { - const { objectName, name, label, type, required, defaultValue, options, reference, packageId: explicitPackageId } = args as { - objectName: string; - name: string; - label?: string; - type: string; - required?: boolean; - defaultValue?: unknown; - options?: Array<{ label: string; value: string }>; - reference?: string; - packageId?: string; - }; - - if (!objectName || !name || !type) { - return JSON.stringify({ error: '"objectName", "name", and "type" are required' }); - } - - // Resolve package ID (for validation and tracking) - const resolved = await resolvePackageId(ctx, explicitPackageId); - if (resolved.error) { - return JSON.stringify({ error: resolved.error }); - } - - // Validate snake_case names - if (!isSnakeCase(objectName)) { - return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); - } - if (!isSnakeCase(name)) { - return JSON.stringify({ error: `Invalid field name "${name}". Must be snake_case.` }); - } - - // Validate reference as snake_case if provided - if (reference && !isSnakeCase(reference)) { - return JSON.stringify({ error: `Invalid reference "${reference}". Must be a snake_case object name.` }); - } - - // Validate select option values as snake_case if provided - if (options && Array.isArray(options)) { - for (const opt of options) { - if (opt.value && !isSnakeCase(opt.value)) { - return JSON.stringify({ error: `Invalid option value "${opt.value}". Must be lowercase snake_case.` }); - } - } - } - - // Verify the target object exists (draft-first so repeated field ops stack - // into the same single draft rather than forking from the published copy). - const objectDef = await readDraftFirst(ctx, 'object', objectName); - if (!objectDef) { - return JSON.stringify({ error: `Object "${objectName}" not found` }); - } - - // Check if field already exists - const def = objectDef as ObjectDef; - if (def.fields && def.fields[name]) { - return JSON.stringify({ error: `Field "${name}" already exists on object "${objectName}"` }); - } - - // Build new field definition - const fieldDef: Record = { - type, - ...(label ? { label } : {}), - ...(required !== undefined ? { required } : {}), - ...(defaultValue !== undefined ? { defaultValue } : {}), - ...(options ? { options } : {}), - ...(reference ? { reference } : {}), - }; - - // Merge the new field into the existing object definition and stage it. - const updatedFields = { ...(def.fields ?? {}), [name]: fieldDef }; - return applyDraft(ctx, { - type: 'object', - name: objectName, - item: { ...def, fields: updatedFields }, - actor: exec?.actor?.id, - packageId: resolved.packageId, - summary: `Drafted field "${name}" (${type}) on object "${objectName}"`, - changedKeys: [`fields.${name}`], - }); - }; -} - -function createModifyFieldHandler(ctx: MetadataToolContext): ToolHandler { - return async (args, exec) => { - const { objectName, fieldName, changes, packageId: explicitPackageId } = args as { - objectName: string; - fieldName: string; - changes: Record; - packageId?: string; - }; - - if (!objectName || !fieldName || !changes) { - return JSON.stringify({ error: '"objectName", "fieldName", and "changes" are required' }); - } - - // Resolve package ID (for validation and tracking) - const resolved = await resolvePackageId(ctx, explicitPackageId); - if (resolved.error) { - return JSON.stringify({ error: resolved.error }); - } - - // Validate snake_case names - if (!isSnakeCase(objectName)) { - return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); - } - if (!isSnakeCase(fieldName)) { - return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` }); - } - - // Verify the target object exists (draft-first — see add_field). - const objectDef = await readDraftFirst(ctx, 'object', objectName); - if (!objectDef) { - return JSON.stringify({ error: `Object "${objectName}" not found` }); - } - - const def = objectDef as ObjectDef; - if (!def.fields || !def.fields[fieldName]) { - return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` }); - } - - // Apply changes to the field definition - const existingField = def.fields[fieldName]; - const updatedField = { ...existingField, ...changes }; - const updatedFields = { ...def.fields, [fieldName]: updatedField }; - - return applyDraft(ctx, { - type: 'object', - name: objectName, - item: { ...def, fields: updatedFields }, - actor: exec?.actor?.id, - packageId: resolved.packageId, - summary: `Drafted change to field "${fieldName}" on object "${objectName}" (${Object.keys(changes).join(', ')})`, - changedKeys: Object.keys(changes).map((k) => `fields.${fieldName}.${k}`), - }); - }; -} - -function createDeleteFieldHandler(ctx: MetadataToolContext): ToolHandler { - return async (args, exec) => { - const { objectName, fieldName, packageId: explicitPackageId } = args as { - objectName: string; - fieldName: string; - packageId?: string; - }; - - if (!objectName || !fieldName) { - return JSON.stringify({ error: '"objectName" and "fieldName" are required' }); - } - - // Resolve package ID (for validation and tracking) - const resolved = await resolvePackageId(ctx, explicitPackageId); - if (resolved.error) { - return JSON.stringify({ error: resolved.error }); - } - - // Validate snake_case names - if (!isSnakeCase(objectName)) { - return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); - } - if (!isSnakeCase(fieldName)) { - return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` }); - } - - // Verify the target object exists (draft-first — see add_field). - const objectDef = await readDraftFirst(ctx, 'object', objectName); - if (!objectDef) { - return JSON.stringify({ error: `Object "${objectName}" not found` }); - } - - const def = objectDef as ObjectDef; - if (!def.fields || !def.fields[fieldName]) { - return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` }); - } - - // Remove the field and stage the change. Dropping a field is destructive, - // but it only lands in the draft here — the human's publish is the gate - // that actually touches data (and re-runs the destructive check). - const { [fieldName]: _removed, ...remainingFields } = def.fields; - return applyDraft(ctx, { - type: 'object', - name: objectName, - item: { ...def, fields: remainingFields }, - actor: exec?.actor?.id, - packageId: resolved.packageId, - summary: `Drafted removal of field "${fieldName}" from object "${objectName}"`, - changedKeys: [`fields.${fieldName}`], - }); - }; -} - -function createListObjectsHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { - const { filter, includeFields } = (args ?? {}) as { - filter?: string; - includeFields?: boolean; - }; - - // Prefer the protocol-level enumerator when available — it merges - // ObjectQL's SchemaRegistry (system plugins like plugin-auth contribute - // `sys_user`, `sys_organization`, …), persisted `sys_metadata` overlays, - // and MetadataService runtime registrations into a single list. Falling - // back to `metadataService.listObjects()` alone would miss everything - // registered through the SchemaRegistry, which is why agents previously - // reported "no user object exists" despite `sys_user` being present. - let objects: unknown[] = []; - if (ctx.protocol?.getMetaItems) { - try { - const fromProtocol = await ctx.protocol.getMetaItems({ type: 'object', previewDrafts: true }); - // Protocol can return either a plain array OR a wrapped envelope - // `{ type, items: [] }` (the shape returned by the protocol shim - // backing `GET /api/v1/meta/object`). Normalize both. - const arr = Array.isArray(fromProtocol) - ? fromProtocol - : (fromProtocol && typeof fromProtocol === 'object' && Array.isArray((fromProtocol as any).items) - ? (fromProtocol as any).items - : null); - objects = arr ?? await ctx.metadataService.listObjects(); - } catch { - objects = await ctx.metadataService.listObjects(); - } - } else { - objects = await ctx.metadataService.listObjects(); - } - if (!Array.isArray(objects)) objects = []; - if (!Array.isArray(objects)) objects = []; - let result = (objects as ObjectDef[]).map(o => { - const base: Record = { - name: o.name, - label: o.label ?? o.name, - fieldCount: o.fields ? Object.keys(o.fields).length : 0, - }; - if (includeFields && o.fields) { - base.fields = Object.entries(o.fields).map(([key, f]) => ({ - name: key, - type: f.type, - label: f.label ?? key, - })); - } - return base; - }); - - // Apply optional name/label substring filter - if (filter) { - const lower = filter.toLowerCase(); - result = result.filter(o => - (o.name as string).toLowerCase().includes(lower) || - (o.label as string).toLowerCase().includes(lower), - ); - } - - return JSON.stringify({ - objects: result, - totalCount: result.length, - }); - }; -} - -function createDescribeObjectHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { - const { objectName } = args as { objectName: string }; - - if (!objectName) { - return JSON.stringify({ error: '"objectName" is required' }); - } - - // Validate snake_case name - if (!isSnakeCase(objectName)) { - return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); - } - - // Same protocol-first lookup rationale as `list_objects` — `getObject` - // alone won't find objects living in ObjectQL's SchemaRegistry. - let objectDef: unknown | undefined = await ctx.metadataService.getObject(objectName); - if (!objectDef && ctx.protocol?.getMetaItems) { - try { - const all = await ctx.protocol.getMetaItems({ type: 'object', previewDrafts: true }); - const arr: ObjectDef[] = Array.isArray(all) - ? (all as ObjectDef[]) - : (all && typeof all === 'object' && Array.isArray((all as any).items) - ? ((all as any).items as ObjectDef[]) - : []); - objectDef = arr.find(o => o?.name === objectName); - } catch { - // fall through — still report not found below - } - } - if (!objectDef) { - return JSON.stringify({ error: `Object "${objectName}" not found` }); - } - - const def = objectDef as ObjectDef; - const fields = def.fields ?? {}; - const fieldSummary = Object.entries(fields).map(([key, f]) => ({ - name: key, - type: f.type, - label: f.label ?? key, - required: f.required ?? false, - ...(f.reference ? { reference: f.reference } : {}), - ...(f.options ? { options: f.options } : {}), - })); - - return JSON.stringify({ - name: def.name, - label: def.label ?? def.name, - fields: fieldSummary, - enableFeatures: def.enable ?? {}, - }); - }; -} - -// --------------------------------------------------------------------------- -// ADR-0033 — type-agnostic apply surface -// -// A small generic surface (`create_metadata` / `update_metadata` / -// `describe_metadata` / `list_metadata`) that works for ANY metadata type — -// view, dashboard, flow, … — not just objects. Coverage of new types grows by -// teaching the agent these tools, not by adding bespoke per-type write tools. -// Every write goes through `applyDraft` (draft-gated, per-type Zod validated). -// --------------------------------------------------------------------------- - -function createCreateMetadataHandler(ctx: MetadataToolContext): ToolHandler { - return async (args, exec) => { - const { type, name, definition, packageId: explicitPackageId } = args as { - type: string; - name: string; - definition: unknown; - packageId?: string; - }; - - if (!type || !name || definition === undefined || definition === null) { - return JSON.stringify({ error: '"type", "name", and "definition" are required' }); - } - if (!isSnakeCase(name)) { - return JSON.stringify({ error: `Invalid name "${name}". Must be snake_case.` }); - } - - // Reject re-creating an item that already exists (draft or published). - const existing = await readDraftFirst(ctx, type, name); - if (existing) { - return JSON.stringify({ - error: `${type} "${name}" already exists — use update_metadata to change it.`, - }); - } - - // Ensure the canonical `name` is present on the body (most type schemas - // require it); the explicit `name` arg is authoritative. - const item = - definition && typeof definition === 'object' && !Array.isArray(definition) - ? { name, ...(definition as Record) } - : definition; - const changedKeys = - item && typeof item === 'object' && !Array.isArray(item) - ? Object.keys(item as Record) - : []; - - return applyDraft(ctx, { - type, - name, - item, - actor: exec?.actor?.id, - packageId: explicitPackageId ?? null, - summary: `Drafted new ${type} "${name}"`, - changedKeys, - }); - }; -} - -function createUpdateMetadataHandler(ctx: MetadataToolContext): ToolHandler { - return async (args, exec) => { - const { type, name, patch, packageId: explicitPackageId } = args as { - type: string; - name: string; - patch: unknown; - packageId?: string; - }; - - if (!type || !name || patch === undefined) { - return JSON.stringify({ error: '"type", "name", and "patch" are required' }); - } - - // Read-modify-write the SINGLE draft (never fork): start from the pending - // draft if any, else the published value. - const current = await readDraftFirst(ctx, type, name); - if (!current) { - return JSON.stringify({ - error: `${type} "${name}" not found — use create_metadata to create it first.`, - }); - } - - const merged = mergePatch(current, patch); - const changedKeys = - patch && typeof patch === 'object' && !Array.isArray(patch) - ? Object.keys(patch as Record) - : []; - - return applyDraft(ctx, { - type, - name, - item: merged, - actor: exec?.actor?.id, - packageId: explicitPackageId ?? null, - summary: `Drafted update to ${type} "${name}"`, - changedKeys, - }); - }; -} - -function createDescribeMetadataHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { - const { type, name } = args as { type: string; name: string }; - if (!type || !name) { - return JSON.stringify({ error: '"type" and "name" are required' }); - } - const item = await readDraftFirst(ctx, type, name); - if (!item) { - return JSON.stringify({ error: `${type} "${name}" not found` }); - } - return JSON.stringify({ type, name, item }); - }; -} - -function createListMetadataHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { - const { type, filter } = args as { type: string; filter?: string }; - if (!type) { - return JSON.stringify({ error: '"type" is required' }); - } - - // Prefer the protocol enumerator (same source as GET /api/v1/meta/:type); - // fall back to the metadata service registry. - let items: unknown[] = []; - if (ctx.protocol?.getMetaItems) { - try { - const res = await ctx.protocol.getMetaItems({ type, previewDrafts: true }); - items = Array.isArray(res) - ? res - : res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items) - ? (res as { items: unknown[] }).items - : []; - } catch { - items = await ctx.metadataService.list(type); - } - } else { - items = await ctx.metadataService.list(type); - } - if (!Array.isArray(items)) items = []; - - let summaries = (items as Array>).map((it) => ({ - name: it?.name, - label: it?.label ?? it?.name, - })); - if (filter) { - const lower = filter.toLowerCase(); - summaries = summaries.filter( - (s) => - String(s.name ?? '').toLowerCase().includes(lower) || - String(s.label ?? '').toLowerCase().includes(lower), - ); - } - - return JSON.stringify({ type, items: summaries, totalCount: summaries.length }); - }; -} - -// JSON-Schema conversion options for the authoring contract: emit the INPUT -// side of the schema (what the agent writes — `io:'input'` skips the output of -// transforms) and degrade anything genuinely unrepresentable to permissive -// `{}` instead of throwing. -const TO_JSON_SCHEMA_OPTS = { - target: 'draft-2020-12', - io: 'input', - unrepresentable: 'any', -} as Parameters[1]; - -/** Peel any top-level `pipe` (transform/refine) chain down to its INPUT schema. */ -function unwrapToInput(schema: unknown): unknown { - let cur = schema as { _zod?: { def?: { type?: string; in?: unknown } } }; - for (let i = 0; i < 12; i++) { - const def = cur?._zod?.def; - if (def?.type === 'pipe' && def.in) cur = def.in as typeof cur; - else break; - } - return cur; -} - -/** - * Robustly convert ANY metadata-type Zod schema to JSON Schema. Some schemas - * (object, action, …) wrap a `.transform()`/refine pipe or nest one (e.g. an - * object's `actions: z.array(ActionSchema)`), which makes Zod v4's - * `toJSONSchema` throw. We peel pipes to their input and, when the whole-schema - * conversion still fails, recurse property-by-property / element-by-element so - * every type yields a usable contract (an unconvertible leaf degrades to a - * placeholder rather than failing the whole call). - */ -function metadataTypeToJsonSchema(schema: unknown): Record { - const s = unwrapToInput(schema); - try { - return z.toJSONSchema(s as z.ZodType, TO_JSON_SCHEMA_OPTS) as Record; - } catch { - const def = (s as { _zod?: { def?: { type?: string; shape?: Record; element?: unknown; innerType?: unknown } } })?._zod?.def; - if (def?.type === 'object' && def.shape) return objectShapeToJsonSchema(def.shape); - if (def?.type === 'array' && def.element) return { type: 'array', items: metadataTypeToJsonSchema(def.element) }; - if (def?.type === 'optional' && def.innerType) return metadataTypeToJsonSchema(def.innerType); - return { description: '(schema omitted — not representable as JSON Schema)' }; - } -} - -function objectShapeToJsonSchema(shape: Record): Record { - const properties: Record = {}; - const omitted: string[] = []; - for (const [key, value] of Object.entries(shape)) { - try { - properties[key] = metadataTypeToJsonSchema(value); - } catch { - omitted.push(key); - } - } - return { - type: 'object', - properties, - ...(omitted.length ? { 'x-omittedProperties': omitted } : {}), - }; -} - -/** - * `get_metadata_schema` — return the JSON Schema (contract) for a metadata type - * so the agent can author a correct payload in one shot instead of guessing the - * shape of complex types and learning from validation errors. The schema is - * derived from the SAME live Zod schema `saveMetaItem` validates against - * ({@link getMetadataTypeSchema}). - */ -function createGetMetadataSchemaHandler(_ctx: MetadataToolContext): ToolHandler { - return async (args) => { - const raw = (args as { type?: string }).type; - if (!raw || typeof raw !== 'string') { - return JSON.stringify({ error: '"type" is required, e.g. "view", "dashboard", "flow".' }); - } - // Accept a plural ("views") by falling back to the singular form. - const candidates = raw.endsWith('s') ? [raw, raw.slice(0, -1)] : [raw]; - let resolved: { type: string; schema: z.ZodType } | undefined; - for (const t of candidates) { - const s = getMetadataTypeSchema(t); - if (s) { resolved = { type: t, schema: s }; break; } - } - if (!resolved) { - return JSON.stringify({ - error: `No schema registered for metadata type '${raw}'. Use a singular type like: object, view, page, dashboard, report, app, flow.`, - }); - } - try { - const jsonSchema = metadataTypeToJsonSchema(resolved.schema); - return JSON.stringify({ type: resolved.type, jsonSchema }); - } catch (err) { - return JSON.stringify({ - type: resolved.type, - error: `Schema for '${resolved.type}' could not be serialized: ${(err as Error)?.message ?? String(err)}`, - }); - } - }; -} - -// --------------------------------------------------------------------------- -// Public Registration Helper -// --------------------------------------------------------------------------- - -/** - * Register all built-in metadata management tools on the given {@link ToolRegistry}. - * - * Typically called from the `ai:ready` hook after the metadata service is available. - * - * @example - * ```ts - * ctx.hook('ai:ready', async (aiService) => { - * const metadataService = ctx.getService('metadata'); - * registerMetadataTools(aiService.toolRegistry, { metadataService }); - * }); - * ``` - */ -export function registerMetadataTools( - registry: ToolRegistry, - context: MetadataToolContext, -): void { - // ADR-0033 type-agnostic apply surface. - registry.register(getMetadataSchemaTool, createGetMetadataSchemaHandler(context)); - registry.register(createMetadataTool, createCreateMetadataHandler(context)); - registry.register(updateMetadataTool, createUpdateMetadataHandler(context)); - registry.register(describeMetadataTool, createDescribeMetadataHandler(context)); - registry.register(listMetadataTool, createListMetadataHandler(context)); - // Object/field convenience tools (draft-gated thin wrappers). - registry.register(createObjectTool, createCreateObjectHandler(context)); - registry.register(addFieldTool, createAddFieldHandler(context)); - registry.register(modifyFieldTool, createModifyFieldHandler(context)); - registry.register(deleteFieldTool, createDeleteFieldHandler(context)); - registry.register(listObjectsTool, createListObjectsHandler(context)); - registry.register(describeObjectTool, createDescribeObjectHandler(context)); - registry.register(validateExpressionTool, createValidateExpressionHandler(context)); -} diff --git a/packages/services/service-ai/src/tools/modify-field.tool.ts b/packages/services/service-ai/src/tools/modify-field.tool.ts deleted file mode 100644 index 8c2424ab7..000000000 --- a/packages/services/service-ai/src/tools/modify-field.tool.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * modify_field — AI Tool Metadata - * - * Modifies an existing field definition (label, type, required, default value, etc.) - * on a data object. Does not support renaming the field. - */ -export const modifyFieldTool = defineTool({ - name: 'modify_field', - label: 'Modify Field', - description: - 'Modifies an existing field definition (label, type, required, default value, etc.) on a data object. ' + - 'Use this when the user wants to change or reconfigure an existing column or attribute (not rename it).', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - packageId: { - type: 'string', - description: 'Package ID that owns the target object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', - }, - objectName: { - type: 'string', - description: 'Target object machine name (snake_case)', - }, - fieldName: { - type: 'string', - description: 'Existing field machine name to modify (snake_case)', - }, - changes: { - type: 'object', - description: 'Field properties to update (partial patch)', - properties: { - label: { type: 'string', description: 'New display label' }, - type: { type: 'string', description: 'New field type' }, - required: { type: 'boolean', description: 'Update required constraint' }, - defaultValue: { description: 'New default value' }, - }, - }, - }, - required: ['objectName', 'fieldName', 'changes'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/package-tools.ts b/packages/services/service-ai/src/tools/package-tools.ts deleted file mode 100644 index a1a405c9b..000000000 --- a/packages/services/service-ai/src/tools/package-tools.ts +++ /dev/null @@ -1,396 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Tool } from '@objectstack/spec/ai'; -import type { InstalledPackage } from '@objectstack/spec/kernel'; -import type { ToolHandler } from './tool-registry.js'; -import type { ToolRegistry } from './tool-registry.js'; - -// --------------------------------------------------------------------------- -// Tool Metadata — individual .tool.ts files (single source of truth) -// --------------------------------------------------------------------------- - -export { listPackagesTool } from './list-packages.tool.js'; -export { getPackageTool } from './get-package.tool.js'; -export { createPackageTool } from './create-package.tool.js'; -export { getActivePackageTool } from './get-active-package.tool.js'; -export { setActivePackageTool } from './set-active-package.tool.js'; - -import { listPackagesTool } from './list-packages.tool.js'; -import { getPackageTool } from './get-package.tool.js'; -import { createPackageTool } from './create-package.tool.js'; -import { getActivePackageTool } from './get-active-package.tool.js'; -import { setActivePackageTool } from './set-active-package.tool.js'; - -/** All built-in package management tool definitions (Tool metadata). */ -export const PACKAGE_TOOL_DEFINITIONS: Tool[] = [ - listPackagesTool, - getPackageTool, - createPackageTool, - getActivePackageTool, - setActivePackageTool, -]; - -// --------------------------------------------------------------------------- -// Package Registry Interface (minimal contract for package operations) -// --------------------------------------------------------------------------- - -/** - * Minimal package registry interface for tool operations. - * The actual implementation may be a full PackageRegistry service. - */ -export interface IPackageRegistry { - /** List all installed packages */ - list(filter?: { status?: string; enabled?: boolean }): Promise; - - /** Get a specific package by ID */ - get(packageId: string): Promise; - - /** Install a new package */ - install(manifest: Record): Promise; - - /** Check if a package exists */ - exists(packageId: string): Promise; -} - -// --------------------------------------------------------------------------- -// Conversation Service Interface (for tracking active package) -// --------------------------------------------------------------------------- - -/** - * Minimal conversation service interface for context management. - */ -export interface IConversationService { - /** Get conversation metadata */ - getMetadata?(conversationId: string): Promise | undefined>; - - /** Update conversation metadata */ - updateMetadata?(conversationId: string, metadata: Record): Promise; -} - -// --------------------------------------------------------------------------- -// Context — injected once at registration time -// --------------------------------------------------------------------------- - -/** - * Services required by the package management tools. - * - * Provided by the kernel at `ai:ready` time and closed over - * by the handler functions so they stay framework-agnostic. - */ -export interface PackageToolContext { - /** Package registry for package CRUD operations */ - packageRegistry: IPackageRegistry; - - /** Conversation service for tracking active package context (optional) */ - conversationService?: IConversationService; - - /** Current conversation ID (if in a conversation context) */ - conversationId?: string; -} - -// --------------------------------------------------------------------------- -// Shared validation helpers -// --------------------------------------------------------------------------- - -/** Reverse domain notation pattern (e.g. com.acme.crm). */ -const REVERSE_DOMAIN_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/; - -/** snake_case identifier pattern. */ -const SNAKE_CASE_RE = /^[a-z_][a-z0-9_]*$/; - -/** Semantic version pattern. */ -const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-z0-9]+(\.[a-z0-9]+)*)?$/; - -/** - * Validate that a value matches reverse domain notation. - */ -function isReverseDomain(value: string): boolean { - return REVERSE_DOMAIN_RE.test(value); -} - -/** - * Validate that a value matches snake_case. - */ -function isSnakeCase(value: string): boolean { - return SNAKE_CASE_RE.test(value); -} - -/** - * Validate semantic version. - */ -function isSemVer(value: string): boolean { - return SEMVER_RE.test(value); -} - -/** - * Derive namespace from package ID. - * Example: "com.acme.crm" -> "crm" - */ -function deriveNamespace(packageId: string): string { - const parts = packageId.split('.'); - return parts[parts.length - 1]; -} - -// --------------------------------------------------------------------------- -// Handler Factories -// --------------------------------------------------------------------------- - -function createListPackagesHandler(ctx: PackageToolContext): ToolHandler { - return async (args) => { - const { status, enabled } = (args ?? {}) as { - status?: string; - enabled?: boolean; - }; - - const filter: { status?: string; enabled?: boolean } = {}; - if (status) filter.status = status; - if (enabled !== undefined) filter.enabled = enabled; - - const packages = await ctx.packageRegistry.list(filter); - - const result = packages.map(pkg => ({ - id: pkg.manifest.id, - name: pkg.manifest.name, - version: pkg.manifest.version, - type: pkg.manifest.type, - status: pkg.status, - enabled: pkg.enabled, - installedAt: pkg.installedAt, - description: pkg.manifest.description, - })); - - return JSON.stringify({ - packages: result, - total: result.length, - }); - }; -} - -function createGetPackageHandler(ctx: PackageToolContext): ToolHandler { - return async (args) => { - const { packageId } = args as { packageId: string }; - - if (!packageId) { - return JSON.stringify({ error: 'packageId is required' }); - } - - const pkg = await ctx.packageRegistry.get(packageId); - - if (!pkg) { - return JSON.stringify({ error: `Package "${packageId}" not found` }); - } - - return JSON.stringify({ - id: pkg.manifest.id, - name: pkg.manifest.name, - version: pkg.manifest.version, - type: pkg.manifest.type, - status: pkg.status, - enabled: pkg.enabled, - installedAt: pkg.installedAt, - updatedAt: pkg.updatedAt, - description: pkg.manifest.description, - namespace: pkg.manifest.namespace, - dependencies: pkg.manifest.dependencies, - registeredNamespaces: pkg.registeredNamespaces, - }); - }; -} - -function createCreatePackageHandler(ctx: PackageToolContext): ToolHandler { - return async (args) => { - const { id, name, version = '1.0.0', description, namespace, type = 'application' } = args as { - id: string; - name: string; - version?: string; - description?: string; - namespace?: string; - type?: string; - }; - - // Validate required fields - if (!id || !name) { - return JSON.stringify({ error: 'Both "id" and "name" are required' }); - } - - // Validate package ID format (reverse domain notation) - if (!isReverseDomain(id)) { - return JSON.stringify({ - error: `Invalid package ID "${id}". Must be in reverse domain notation (e.g., com.acme.crm, org.mycompany.sales)`, - }); - } - - // Validate version format - if (!isSemVer(version)) { - return JSON.stringify({ - error: `Invalid version "${version}". Must be semantic version (e.g., 1.0.0, 2.1.3-beta)`, - }); - } - - // Check if package already exists - const exists = await ctx.packageRegistry.exists(id); - if (exists) { - return JSON.stringify({ error: `Package "${id}" already exists` }); - } - - // Derive or validate namespace - const derivedNamespace = namespace || deriveNamespace(id); - if (!isSnakeCase(derivedNamespace)) { - return JSON.stringify({ - error: `Invalid namespace "${derivedNamespace}". Must be snake_case (e.g., crm, sales_module)`, - }); - } - - // Build manifest - const manifest: Record = { - id, - name, - version, - type, - namespace: derivedNamespace, - ...(description ? { description } : {}), - }; - - // Install the package - const installedPackage = await ctx.packageRegistry.install(manifest); - - // Set as active package in conversation if conversation service is available - if (ctx.conversationService && ctx.conversationId) { - try { - await ctx.conversationService.updateMetadata?.(ctx.conversationId, { - activePackageId: id, - }); - } catch (err) { - // Non-critical error - package was created successfully - console.warn('Failed to set active package in conversation:', err); - } - } - - return JSON.stringify({ - packageId: installedPackage.manifest.id, - name: installedPackage.manifest.name, - version: installedPackage.manifest.version, - namespace: installedPackage.manifest.namespace, - status: installedPackage.status, - message: `Package "${name}" created successfully and set as active package`, - }); - }; -} - -function createGetActivePackageHandler(ctx: PackageToolContext): ToolHandler { - return async () => { - // If no conversation service, can't track active package - if (!ctx.conversationService || !ctx.conversationId) { - return JSON.stringify({ - activePackageId: null, - message: 'No conversation context available to track active package', - }); - } - - try { - const metadata = await ctx.conversationService.getMetadata?.(ctx.conversationId); - const activePackageId = metadata?.activePackageId as string | undefined; - - if (!activePackageId) { - return JSON.stringify({ - activePackageId: null, - message: 'No active package set. Use set_active_package or create a new package.', - }); - } - - // Get package details - const pkg = await ctx.packageRegistry.get(activePackageId); - - if (!pkg) { - return JSON.stringify({ - activePackageId, - error: `Active package "${activePackageId}" not found. It may have been uninstalled.`, - }); - } - - return JSON.stringify({ - activePackageId: pkg.manifest.id, - name: pkg.manifest.name, - version: pkg.manifest.version, - namespace: pkg.manifest.namespace, - type: pkg.manifest.type, - }); - } catch (err) { - return JSON.stringify({ - error: `Failed to get active package: ${(err as Error).message}`, - }); - } - }; -} - -function createSetActivePackageHandler(ctx: PackageToolContext): ToolHandler { - return async (args) => { - const { packageId } = args as { packageId: string }; - - if (!packageId) { - return JSON.stringify({ error: 'packageId is required' }); - } - - // Verify package exists - const pkg = await ctx.packageRegistry.get(packageId); - if (!pkg) { - return JSON.stringify({ error: `Package "${packageId}" not found` }); - } - - // If no conversation service, return error - if (!ctx.conversationService || !ctx.conversationId) { - return JSON.stringify({ - error: 'No conversation context available. Cannot set active package.', - }); - } - - try { - await ctx.conversationService.updateMetadata?.(ctx.conversationId, { - activePackageId: packageId, - }); - - return JSON.stringify({ - activePackageId: packageId, - name: pkg.manifest.name, - namespace: pkg.manifest.namespace, - message: `Active package set to "${pkg.manifest.name}"`, - }); - } catch (err) { - return JSON.stringify({ - error: `Failed to set active package: ${(err as Error).message}`, - }); - } - }; -} - -// --------------------------------------------------------------------------- -// Public Registration Helper -// --------------------------------------------------------------------------- - -/** - * Register all built-in package management tools on the given {@link ToolRegistry}. - * - * Typically called from the `ai:ready` hook after the package registry is available. - * - * @example - * ```ts - * ctx.hook('ai:ready', async (aiService) => { - * const packageRegistry = ctx.getService('packageRegistry'); - * const conversationService = ctx.getService('conversation'); - * registerPackageTools(aiService.toolRegistry, { - * packageRegistry, - * conversationService, - * }); - * }); - * ``` - */ -export function registerPackageTools( - registry: ToolRegistry, - context: PackageToolContext, -): void { - registry.register(listPackagesTool, createListPackagesHandler(context)); - registry.register(getPackageTool, createGetPackageHandler(context)); - registry.register(createPackageTool, createCreatePackageHandler(context)); - registry.register(getActivePackageTool, createGetActivePackageHandler(context)); - registry.register(setActivePackageTool, createSetActivePackageHandler(context)); -} diff --git a/packages/services/service-ai/src/tools/propose-blueprint.tool.ts b/packages/services/service-ai/src/tools/propose-blueprint.tool.ts deleted file mode 100644 index 7fff52f25..000000000 --- a/packages/services/service-ai/src/tools/propose-blueprint.tool.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * propose_blueprint — AI Tool Metadata (ADR-0033 §4, plan-first) - * - * For a HIGH-LEVEL goal ("build me a project-management system") the agent - * designs a structured solution blueprint — objects + fields + relationships + - * views + dashboards + seed data, with stated assumptions — instead of - * transcribing a field list. **Nothing is persisted.** The agent presents the - * blueprint for the human to confirm/edit conversationally; only after approval - * does it call `apply_blueprint`. This is the safety valve against - * mass-generating unreviewed artifacts from a vague prompt. - */ -export const proposeBlueprintTool = defineTool({ - name: 'propose_blueprint', - label: 'Propose Blueprint', - description: - 'Design a structured solution blueprint (objects, fields, relationships, views, dashboards, seed data) for a high-level goal, WITHOUT building anything. ' + - 'Use this when the user asks to build a whole system/app/module rather than a single object or field. The blueprint is a proposal for the human to confirm — nothing is created until you call apply_blueprint after they approve.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - goal: { - type: 'string', - description: 'The user\'s high-level goal in their own words, e.g. "build me a recruiting system to track candidates and interviews".', - }, - context: { - type: 'string', - description: 'Optional extra constraints or details the user gave (industry, must-have fields, naming preferences).', - }, - }, - required: ['goal'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/set-active-package.tool.ts b/packages/services/service-ai/src/tools/set-active-package.tool.ts deleted file mode 100644 index 53c9157ea..000000000 --- a/packages/services/service-ai/src/tools/set-active-package.tool.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * set_active_package — AI Tool Metadata - * - * Sets the active package for the current conversation. - * All metadata operations will use this package context. - */ -export const setActivePackageTool = defineTool({ - name: 'set_active_package', - label: 'Set Active Package', - description: - 'Sets the active package for this conversation. All subsequent metadata creation operations ' + - '(objects, views, flows, etc.) will be associated with this package unless explicitly overridden.', - category: 'utility', - builtIn: true, - parameters: { - type: 'object', - properties: { - packageId: { - type: 'string', - description: 'Package identifier to set as active (e.g., com.acme.crm)', - }, - }, - required: ['packageId'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/update-metadata.tool.ts b/packages/services/service-ai/src/tools/update-metadata.tool.ts deleted file mode 100644 index 53f13c0a6..000000000 --- a/packages/services/service-ai/src/tools/update-metadata.tool.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * update_metadata — AI Tool Metadata (ADR-0033) - * - * Type-agnostic update of ANY existing metadata item. Applies an RFC 7386 JSON - * Merge Patch to the item's current draft (or, if none, the published value) - * and re-stages the result as a **draft** — read-modify-write of the single - * draft, never a fork. A `null` value in the patch deletes that key. The merged - * body is validated against the type's Zod schema before it enters the draft. - * The change is NOT published — a human reviews the diff and publishes. - */ -export const updateMetadataTool = defineTool({ - name: 'update_metadata', - label: 'Update Metadata', - description: - 'Apply a partial change (JSON merge patch) to an existing metadata item of ANY type and stage it as a draft for human review. ' + - 'Set a key to null to remove it. The change is NOT published — a human must publish it. Use describe_metadata first to see the current body.', - category: 'data', - builtIn: true, - parameters: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow".', - }, - name: { - type: 'string', - description: 'Machine name of the existing item (snake_case).', - }, - patch: { - type: 'object', - description: - 'Partial change to merge into the item. Only the keys you include are changed; nested objects merge recursively; a null value deletes that key. Example: { "label": "New Label", "fields": { "old_field": null } }.', - }, - packageId: { - type: 'string', - description: 'Package ID owning this item. If omitted, uses the active package from conversation context.', - }, - }, - required: ['type', 'name', 'patch'], - additionalProperties: false, - }, -}); diff --git a/packages/services/service-ai/src/tools/validate-expression.tool.ts b/packages/services/service-ai/src/tools/validate-expression.tool.ts deleted file mode 100644 index 202e051c8..000000000 --- a/packages/services/service-ai/src/tools/validate-expression.tool.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTool } from '@objectstack/spec/ai'; - -/** - * validate_expression — AI Tool Metadata (ADR-0032 §Decision 1e) - * - * Lets an authoring agent check an expression *before* committing it — closing - * the self-correction loop at authoring time instead of at build/run time. It - * runs the same shared validator (`@objectstack/formula`) that `objectstack - * build` and `registerFlow` use, so the verdict and the corrective message are - * identical across every surface. - * - * Use it whenever writing a `condition` / `guard` / formula / computed value - * (role `predicate` or `value`, bare CEL) or a notification/title text body - * (role `template`, `{{ path }}` holes). Pass `objectName` to additionally - * check that referenced `record.` names exist on that object. - */ -export const validateExpressionTool = defineTool({ - name: 'validate_expression', - label: 'Validate Expression', - description: - 'Validate an ObjectStack expression (flow/validation condition, formula, computed value, or text template) ' + - 'BEFORE saving it. Returns { ok, errors[] } with self-correcting messages. ' + - 'Predicates and computed values are bare CEL (e.g. `record.rating >= 4`) — never wrap field references in `{…}` braces. ' + - 'Templates use `{{ path }}` holes. Pass objectName to also check that record. references exist.', - category: 'utility', - builtIn: true, - parameters: { - type: 'object', - properties: { - role: { - type: 'string', - enum: ['predicate', 'value', 'template'], - description: - "Field role: 'predicate' (boolean condition/guard, bare CEL), 'value' (computed value, bare CEL), or 'template' (text with {{ path }} holes).", - }, - source: { - type: 'string', - description: 'The expression source to validate.', - }, - objectName: { - type: 'string', - description: 'Optional object machine name (snake_case) for schema-aware field-existence checks.', - }, - }, - required: ['role', 'source'], - additionalProperties: false, - }, -});