Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/get-metadata-schema-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-ai': minor
---

feat(ai): add `get_metadata_schema` tool so the agent can read a type's contract before authoring

The metadata-authoring agent never sees the real spec Zod schemas — it works against a simplified blueprint or sends a free-form `definition` and only learns the true shape from post-hoc validation errors. For complex types (view, dashboard, flow, …) that means guessing, e.g. a kanban view's required `kanban: { groupByField, columns }` block.

New `get_metadata_schema` tool returns the JSON Schema (via Zod v4's `toJSONSchema`) derived from the SAME live schema `saveMetaItem` validates against (`getMetadataTypeSchema`). The `metadata_authoring` skill now instructs the agent to call it before authoring a non-trivial type, so it conforms first time instead of trial-and-error. Read-only; resolves plural type names; returns a graceful error for types that can't be serialized (e.g. `object`, which the dedicated `create_object` tools cover anyway).
70 changes: 64 additions & 6 deletions packages/services/service-ai/src/__tests__/metadata-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,15 @@ const call = (toolName: string, input: Record<string, unknown>, id = 't') => ({
// ═══════════════════════════════════════════════════════════════════

describe('Metadata Tool Definitions', () => {
it('should define exactly 11 tools', () => {
expect(METADATA_TOOL_DEFINITIONS).toHaveLength(11);
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',
Expand Down Expand Up @@ -230,9 +231,10 @@ describe('registerMetadataTools', () => {
registerMetadataTools(registry, { metadataService, protocol });
});

it('should register all 11 tools', () => {
expect(registry.size).toBe(11);
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',
Expand All @@ -242,6 +244,61 @@ describe('registerMetadataTools', () => {
});
});

// ═══════════════════════════════════════════════════════════════════
// 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<string, unknown>;
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)
// ═══════════════════════════════════════════════════════════════════
Expand All @@ -264,15 +321,16 @@ describe('registerDataTools + registerMetadataTools — unified list/describe',
const sizeAfterBoth = registry.size;

// Data tools define: query_records, get_record, aggregate_data (3)
// Metadata tools define 11.
// Metadata tools define 12.
expect(sizeAfterData).toBe(3);
expect(sizeAfterBoth).toBe(sizeAfterData + 11);
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);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ export const METADATA_AUTHORING_SKILL: Skill = {
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 updating, modifying, or deleting, use describe_object / describe_metadata to understand the current shape.
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.
Expand All @@ -41,6 +43,7 @@ Guidelines:
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',
Expand Down
36 changes: 36 additions & 0 deletions packages/services/service-ai/src/tools/get-metadata-schema.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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,
},
});
103 changes: 103 additions & 0 deletions packages/services/service-ai/src/tools/metadata-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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)
Expand All @@ -20,6 +22,7 @@ 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';
Expand All @@ -32,11 +35,13 @@ 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,
Expand Down Expand Up @@ -1017,6 +1022,103 @@ function createListMetadataHandler(ctx: MetadataToolContext): ToolHandler {
};
}

// 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<typeof z.toJSONSchema>[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<string, unknown> {
const s = unwrapToInput(schema);
try {
return z.toJSONSchema(s as z.ZodType, TO_JSON_SCHEMA_OPTS) as Record<string, unknown>;
} catch {
const def = (s as { _zod?: { def?: { type?: string; shape?: Record<string, unknown>; 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<string, unknown>): Record<string, unknown> {
const properties: Record<string, unknown> = {};
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
// ---------------------------------------------------------------------------
Expand All @@ -1039,6 +1141,7 @@ export function registerMetadataTools(
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));
Expand Down