From ae7f09df8023066745686f02851cc919908f1599 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:54:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20ADR-0033=20Phase=20C=20=E2=80=94=20?= =?UTF-8?q?plan-first=20blueprint=20authoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For high-level goals ("build me a project-management system") the metadata assistant designs before it builds, then confirms before drafting. - Add SolutionBlueprintSchema (@objectstack/spec/ai): objects + fields + relationships + views + dashboards + seed data, with stated assumptions. - propose_blueprint(goal): structured-output design; persists NOTHING; the agent presents it for conversational confirm, asks <=2 questions. - apply_blueprint(blueprint): only after approval, batch-drafts every artifact through the Phase A draft path (mode:'draft'), per-type validated and partial-tolerant; seed data reported, not auto-applied. - New solution_design skill (plan-first instructions) bound to metadata_assistant alongside metadata_authoring. - Export stageDraft from metadata-tools and reuse it — one draft-write path. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0033-phase-c-blueprint.md | 13 + .../src/__tests__/blueprint-tools.test.ts | 242 +++++++++++++++ .../src/__tests__/chatbot-features.test.ts | 5 +- .../src/agents/metadata-assistant-agent.ts | 6 +- packages/services/service-ai/src/index.ts | 9 +- packages/services/service-ai/src/plugin.ts | 38 ++- .../services/service-ai/src/skills/index.ts | 1 + .../src/skills/solution-design-skill.ts | 48 +++ .../src/tools/apply-blueprint.tool.ts | 33 +++ .../service-ai/src/tools/blueprint-tools.ts | 279 ++++++++++++++++++ .../services/service-ai/src/tools/index.ts | 9 +- .../service-ai/src/tools/metadata-tools.ts | 88 ++++-- .../src/tools/propose-blueprint.tool.ts | 39 +++ packages/spec/src/ai/index.ts | 1 + .../spec/src/ai/solution-blueprint.test.ts | 92 ++++++ .../spec/src/ai/solution-blueprint.zod.ts | 114 +++++++ 16 files changed, 983 insertions(+), 34 deletions(-) create mode 100644 .changeset/adr-0033-phase-c-blueprint.md create mode 100644 packages/services/service-ai/src/__tests__/blueprint-tools.test.ts create mode 100644 packages/services/service-ai/src/skills/solution-design-skill.ts create mode 100644 packages/services/service-ai/src/tools/apply-blueprint.tool.ts create mode 100644 packages/services/service-ai/src/tools/blueprint-tools.ts create mode 100644 packages/services/service-ai/src/tools/propose-blueprint.tool.ts create mode 100644 packages/spec/src/ai/solution-blueprint.test.ts create mode 100644 packages/spec/src/ai/solution-blueprint.zod.ts diff --git a/.changeset/adr-0033-phase-c-blueprint.md b/.changeset/adr-0033-phase-c-blueprint.md new file mode 100644 index 000000000..9ce38866f --- /dev/null +++ b/.changeset/adr-0033-phase-c-blueprint.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-ai": minor +--- + +feat(ai): ADR-0033 Phase C — plan-first blueprint authoring + +For high-level goals ("build me a project-management system") the metadata assistant now designs before it builds. Adds a `SolutionBlueprintSchema` (`@objectstack/spec/ai`) describing proposed objects, fields, relationships, views, dashboards, and seed data with stated assumptions, plus two tools: + +- `propose_blueprint(goal)` — emits a structured blueprint via structured output. **Nothing is persisted**; the agent presents it for conversational confirmation and asks at most 1–2 structure-deciding questions. +- `apply_blueprint(blueprint)` — only after the human approves, batch-drafts every artifact through the Phase A draft path (`protocol.saveMetaItem({mode:'draft'})`), validated per-type and partial-tolerant (a bad item is reported, the rest still draft). Seed data is reported as proposed, not auto-applied (no runtime `dataset` type). + +A new `solution_design` skill carries the plan-first instructions and is bound to `metadata_assistant` alongside `metadata_authoring`. The shared draft-write primitive is exported from the metadata tools as `stageDraft` and reused, keeping one draft-write path. diff --git a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts new file mode 100644 index 000000000..0685c51e1 --- /dev/null +++ b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import 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, 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' }, + { type: 'view', name: '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 body became a list sub-view bound to the object. + const view = drafts.get('view:open_tasks') as any; + expect(view.list.data).toEqual({ provider: 'object', object: 'task' }); + expect(view.list.columns).toEqual(['title']); + }); + + 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: '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:all_leads') as any; + expect(view.list.columns).toEqual(['name', 'email']); + }); +}); 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 aaef2ae5e..99a0f8a7f 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -1072,9 +1072,10 @@ describe('METADATA_ASSISTANT_AGENT', () => { expect(METADATA_ASSISTANT_AGENT.visibility).toBe('global'); }); - it('should reference the metadata_authoring skill (capability bundle moved to skill metadata)', () => { + it('should reference the metadata_authoring + solution_design skills (capability bundles moved to skill metadata)', () => { expect(METADATA_ASSISTANT_AGENT.tools ?? []).toHaveLength(0); - expect(METADATA_ASSISTANT_AGENT.skills).toEqual(['metadata_authoring']); + // 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', () => { diff --git a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts index a25cf5ab7..f6e03f760 100644 --- a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts +++ b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts @@ -40,8 +40,10 @@ Always answer in the same language the user is using. If the user's request is a maxTokens: 4096, }, - // Capability bundle lives on the skill; the agent only references it. - skills: ['metadata_authoring'], + // 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', diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index c9b336799..3e92b780e 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -30,8 +30,12 @@ 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 } from './tools/metadata-tools.js'; -export type { MetadataToolContext } from './tools/metadata-tools.js'; +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'; // Knowledge tools export { registerKnowledgeTools, SEARCH_KNOWLEDGE_TOOL } from './tools/knowledge-tools.js'; @@ -84,6 +88,7 @@ export { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js'; 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 d279827a5..19dd3abc0 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -19,12 +19,13 @@ import { AiTraceView, AiMessageView, AiPendingActionView, AiEvalCaseView, AiEval 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, ACTIONS_EXECUTOR_SKILL } from './skills/index.js'; +import { DATA_EXPLORER_SKILL, METADATA_AUTHORING_SKILL, SOLUTION_DESIGN_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'; @@ -799,9 +800,19 @@ export class AIServicePlugin implements Plugin { registerMetadataTools(this.service.toolRegistry, { metadataService, protocol: protocolService }); ctx.logger.info('[AI] Built-in metadata tools registered'); - // Register metadata tools as metadata (for Studio visibility) + // 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, + }); + 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) { + for (const toolDef of [...METADATA_TOOL_DEFINITIONS, ...BLUEPRINT_TOOL_DEFINITIONS]) { const toolExists = typeof metadataService.exists === 'function' ? await withTimeout(metadataService.exists('tool', toolDef.name)) @@ -821,7 +832,7 @@ export class AIServicePlugin implements Plugin { } } } - ctx.logger.info(`[AI] ${METADATA_TOOL_DEFINITIONS.length} metadata tools registered as metadata`); + 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 { @@ -862,6 +873,25 @@ export class AIServicePlugin implements Plugin { } 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); } diff --git a/packages/services/service-ai/src/skills/index.ts b/packages/services/service-ai/src/skills/index.ts index 6c3782999..4aea914bc 100644 --- a/packages/services/service-ai/src/skills/index.ts +++ b/packages/services/service-ai/src/skills/index.ts @@ -2,4 +2,5 @@ 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'; diff --git a/packages/services/service-ai/src/skills/solution-design-skill.ts b/packages/services/service-ai/src/skills/solution-design-skill.ts new file mode 100644 index 000000000..722b07e12 --- /dev/null +++ b/packages/services/service-ai/src/skills/solution-design-skill.ts @@ -0,0 +1,48 @@ +// 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) from the goal. This creates NOTHING. Present it to the user: summarize the objects/views, 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. 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. +- 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. + +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/apply-blueprint.tool.ts b/packages/services/service-ai/src/tools/apply-blueprint.tool.ts new file mode 100644 index 000000000..f946ceae7 --- /dev/null +++ b/packages/services/service-ai/src/tools/apply-blueprint.tool.ts @@ -0,0 +1,33 @@ +// 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 new file mode 100644 index 000000000..2a05dd97e --- /dev/null +++ b/packages/services/service-ai/src/tools/blueprint-tools.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IAIService, IMetadataService, ModelMessage } from '@objectstack/spec/contracts'; +import { SolutionBlueprintSchema, 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'; + +/** All blueprint (plan-first) tool definitions. */ +export const BLUEPRINT_TOOL_DEFINITIONS = [proposeBlueprintTool, applyBlueprintTool]; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +/** + * 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. + */ +export interface BlueprintToolContext { + ai: IAIService; + protocol?: DraftCapableProtocol; + metadataService: IMetadataService; +} + +// --------------------------------------------------------------------------- +// 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' + + '- 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' + + `- ${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 { + const generated = await ctx.ai.generateObject(messages, SolutionBlueprintSchema, { + schemaName: 'SolutionBlueprint', + schemaDescription: + 'A proposed solution: objects + fields + relationships + views + dashboards + seed data, with stated assumptions.', + }); + blueprint = generated.object; + } 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, + 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 body (list- or form-family). */ +function viewBody( + v: NonNullable[number], + columnsByObject: Map, +): Record { + const cols = v.columns?.length ? v.columns : columnsByObject.get(v.object) ?? ['name']; + const data = { provider: 'object', object: v.object }; + if (v.type === 'form') { + return { + form: { + type: 'simple', + data, + sections: [{ fields: cols.map((field) => ({ field })) }], + }, + ...(v.label ? { label: v.label } : {}), + }; + } + return { + list: { + type: LIST_TYPE[v.type] ?? 'grid', + data, + columns: cols, + ...(v.label ? { label: v.label } : {}), + }, + }; +} + +/** 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 } : {}), + })), + }; +} + +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. + const parsed = SolutionBlueprintSchema.safeParse(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; + + 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 }); + 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(); + for (const o of blueprint.objects ?? []) { + columnsByObject.set(o.name, (o.fields ?? []).map((f) => f.name)); + await record('object', o.name, objectBody(o)); + } + for (const v of blueprint.views ?? []) { + await record('view', v.name, viewBody(v, columnsByObject)); + } + for (const d of blueprint.dashboards ?? []) { + await record('dashboard', d.name, dashboardBody(d)); + } + + 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 (seedDataProposed.length) summaryParts.push(`${seedDataProposed.length} seed set(s) proposed (not applied)`); + + return JSON.stringify({ + status: failed.length && !drafted.length ? 'failed' : 'drafted', + drafted, + failed, + // 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/index.ts b/packages/services/service-ai/src/tools/index.ts index 2fcef0e1f..68a66a25a 100644 --- a/packages/services/service-ai/src/tools/index.ts +++ b/packages/services/service-ai/src/tools/index.ts @@ -6,8 +6,11 @@ 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 } from './metadata-tools.js'; -export type { MetadataToolContext } from './metadata-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'; @@ -24,3 +27,5 @@ 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'; diff --git a/packages/services/service-ai/src/tools/metadata-tools.ts b/packages/services/service-ai/src/tools/metadata-tools.ts index 3874bf836..556af44c2 100644 --- a/packages/services/service-ai/src/tools/metadata-tools.ts +++ b/packages/services/service-ai/src/tools/metadata-tools.ts @@ -270,26 +270,51 @@ interface ApplyDraftInput { 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; +} + /** - * Stage `item` as a draft and return the ADR-0033 result envelope - * `{ status:'drafted', type, name, summary, changedKeys }`. On validation / - * destructive-change rejection, returns the structured error as a string so - * the tool-call loop feeds it back to the model for self-correction (same - * validate-by-default spine as ADR-0032 / the `validate_expression` tool) — - * it never throws. + * 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. */ -async function applyDraft(ctx: MetadataToolContext, input: ApplyDraftInput): Promise { - if (!ctx.protocol?.saveMetaItem) { - // Safe by default: with no draft-capable protocol wired we refuse rather - // than fall back to an immediate-publish path. (In a real runtime the - // ObjectQL protocol service is always present.) - return JSON.stringify({ +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 ctx.protocol.saveMetaItem({ + await protocol.saveMetaItem({ type: input.type, name: input.name, item: input.item, @@ -300,21 +325,40 @@ async function applyDraft(ctx: MetadataToolContext, input: ApplyDraftInput): Pro ? { packageId: input.packageId } : {}), }); - return JSON.stringify({ - status: 'drafted', - type: input.type, - name: input.name, - summary: input.summary, - changedKeys: input.changedKeys, - }); + return { ok: true }; } catch (err) { const e = err as { message?: string; code?: string; issues?: unknown }; - return JSON.stringify({ + 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, + }); } /** diff --git a/packages/services/service-ai/src/tools/propose-blueprint.tool.ts b/packages/services/service-ai/src/tools/propose-blueprint.tool.ts new file mode 100644 index 000000000..7fff52f25 --- /dev/null +++ b/packages/services/service-ai/src/tools/propose-blueprint.tool.ts @@ -0,0 +1,39 @@ +// 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/spec/src/ai/index.ts b/packages/spec/src/ai/index.ts index 38853efbe..c0cbfc3da 100644 --- a/packages/spec/src/ai/index.ts +++ b/packages/spec/src/ai/index.ts @@ -34,3 +34,4 @@ export * from './usage.zod'; export * from './mcp.zod'; export * from './knowledge-source.zod'; export * from './knowledge-document.zod'; +export * from './solution-blueprint.zod'; diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts new file mode 100644 index 000000000..717f872ad --- /dev/null +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + SolutionBlueprintSchema, + defineSolutionBlueprint, + type SolutionBlueprint, +} from './solution-blueprint.zod'; + +const validBlueprint: SolutionBlueprint = { + summary: 'A simple project tracker', + assumptions: ['Projects own many tasks', 'Tasks have a status'], + objects: [ + { + name: 'project', + label: 'Project', + fields: [ + { name: 'name', label: 'Name', type: 'text', required: true }, + { name: 'due_date', type: 'date' }, + ], + }, + { + name: 'task', + label: 'Task', + fields: [ + { name: 'title', type: 'text', required: true }, + { name: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }, { label: 'Done', value: 'done' }] }, + { name: 'project_id', type: 'lookup', reference: 'project' }, + ], + }, + ], + views: [ + { object: 'task', name: 'open_tasks', label: 'Open Tasks', type: 'list', columns: ['title', 'status'] }, + ], +}; + +describe('SolutionBlueprintSchema', () => { + it('parses a valid blueprint', () => { + const parsed = SolutionBlueprintSchema.parse(validBlueprint); + expect(parsed.objects).toHaveLength(2); + expect(parsed.objects[1].fields[2]).toMatchObject({ type: 'lookup', reference: 'project' }); + expect(parsed.views?.[0].type).toBe('list'); + }); + + it('defaults assumptions to an empty array and view type to list', () => { + const parsed = SolutionBlueprintSchema.parse({ + summary: 'minimal', + objects: [{ name: 'thing', fields: [{ name: 'name', type: 'text' }] }], + views: [{ object: 'thing', name: 'all_things', columns: ['name'] }], + }); + expect(parsed.assumptions).toEqual([]); + expect(parsed.views?.[0].type).toBe('list'); + }); + + it('rejects a missing summary', () => { + const { summary: _drop, ...noSummary } = validBlueprint; + expect(() => SolutionBlueprintSchema.parse(noSummary)).toThrow(); + }); + + it('rejects an invalid field type', () => { + expect(() => + SolutionBlueprintSchema.parse({ + summary: 'bad', + objects: [{ name: 'x', fields: [{ name: 'f', type: 'not_a_real_type' }] }], + }), + ).toThrow(); + }); + + it('rejects a non-snake_case object name', () => { + expect(() => + SolutionBlueprintSchema.parse({ + summary: 'bad', + objects: [{ name: 'MyObject', fields: [{ name: 'f', type: 'text' }] }], + }), + ).toThrow(); + }); + + it('rejects more than 2 clarifying questions', () => { + expect(() => + SolutionBlueprintSchema.parse({ + summary: 'too many questions', + objects: [{ name: 'x', fields: [{ name: 'f', type: 'text' }] }], + questions: ['a?', 'b?', 'c?'], + }), + ).toThrow(); + }); + + it('defineSolutionBlueprint validates and returns the parsed value', () => { + const bp = defineSolutionBlueprint(validBlueprint); + expect(bp.summary).toBe('A simple project tracker'); + }); +}); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts new file mode 100644 index 000000000..07daa7ee6 --- /dev/null +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { FieldType } from '../data/field.zod'; + +/** + * Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) + * + * The structured-output target an AI agent emits for a *high-level* goal + * ("build me a project-management system") instead of transcribing a field + * list. It is a **simplified proposal shape** — deliberately lighter than the + * full {@link ObjectSchema} / {@link ViewSchema} / {@link DashboardSchema}. + * The `apply_blueprint` tool expands each entry into a proper metadata body + * and stages it as a draft (so the per-type Zod schema still validates the + * real artifact at write time). + * + * The blueprint is **never persisted on its own**: the agent presents it for + * conversational confirmation/edit (cheap), and only on human approval does it + * batch-draft. This is the safety valve for low-specificity input. + */ + +const SNAKE_CASE = /^[a-z_][a-z0-9_]*$/; + +/** + * A proposed field on a blueprint object. `reference` carries the target + * object for `lookup` / `master_detail` types — relationships are expressed + * inline as reference fields rather than in a separate block. + */ +export const BlueprintFieldSchema = lazySchema(() => z.object({ + name: z.string().regex(SNAKE_CASE).describe('Field machine name (snake_case)'), + label: z.string().optional().describe('Human-readable field label'), + type: FieldType.describe('Field data type'), + required: z.boolean().optional().describe('Whether the field is required'), + reference: z.string().regex(SNAKE_CASE).optional() + .describe('Target object name for lookup / master_detail relationship fields'), + options: z.array(z.object({ + label: z.string(), + value: z.string().regex(SNAKE_CASE), + })).optional().describe('Choices for select / multiselect / radio fields'), +})); +export type BlueprintField = z.infer; + +/** A proposed business object (table) with its fields. */ +export const BlueprintObjectSchema = lazySchema(() => z.object({ + name: z.string().regex(SNAKE_CASE).describe('Object machine name (snake_case)'), + label: z.string().optional().describe('Human-readable singular label'), + description: z.string().optional().describe('What this object represents'), + fields: z.array(BlueprintFieldSchema).describe('Fields to create on the object'), +})); +export type BlueprintObject = z.infer; + +/** A proposed list/form/kanban/calendar view over an object. */ +export const BlueprintViewSchema = lazySchema(() => z.object({ + object: z.string().regex(SNAKE_CASE).describe('Object this view displays (snake_case)'), + name: z.string().regex(SNAKE_CASE).describe('View machine name (snake_case)'), + label: z.string().optional().describe('Human-readable view label'), + type: z.enum(['list', 'form', 'kanban', 'calendar']).default('list').describe('View kind'), + columns: z.array(z.string().regex(SNAKE_CASE)).optional() + .describe('Field names shown as columns (in order)'), +})); +export type BlueprintView = z.infer; + +/** A proposed dashboard with a few widgets (kept intentionally light). */ +export const BlueprintDashboardSchema = lazySchema(() => z.object({ + name: z.string().regex(SNAKE_CASE).describe('Dashboard machine name (snake_case)'), + label: z.string().optional().describe('Human-readable dashboard label'), + widgets: z.array(z.object({ + id: z.string().regex(SNAKE_CASE).describe('Widget id (snake_case)'), + title: z.string().optional().describe('Widget title'), + object: z.string().regex(SNAKE_CASE).optional().describe('Source object for the widget'), + chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).optional().describe('Widget visualization'), + })).optional().describe('Widgets to place on the dashboard'), +})); +export type BlueprintDashboard = z.infer; + +/** + * Seed data the agent suggests. Mirrors {@link DatasetSchema.records}. NOTE: + * Phase C does NOT auto-apply seed data — there is no runtime-draftable + * `dataset` metadata type (seed = code-loaded `*.seed.ts`). `apply_blueprint` + * reports it as "proposed, not applied" so a human can wire it deliberately. + */ +export const BlueprintSeedSchema = lazySchema(() => z.object({ + object: z.string().regex(SNAKE_CASE).describe('Target object name (snake_case)'), + records: z.array(z.record(z.string(), z.unknown())).describe('Rows to seed'), +})); +export type BlueprintSeed = z.infer; + +/** + * The full plan-first blueprint. `assumptions` state the design choices the + * agent made from an underspecified goal; `questions` (≤2) are the only + * structure-deciding clarifications it should ask before proposing. + */ +export const SolutionBlueprintSchema = lazySchema(() => z.object({ + summary: z.string().describe('One-line description of the proposed solution'), + assumptions: z.array(z.string()).default([]) + .describe('Design assumptions made from the underspecified goal'), + questions: z.array(z.string()).max(2).optional() + .describe('At most 1-2 structure-deciding questions to confirm before building'), + objects: z.array(BlueprintObjectSchema).describe('Objects (tables) to create'), + views: z.array(BlueprintViewSchema).optional().describe('Views to create'), + dashboards: z.array(BlueprintDashboardSchema).optional().describe('Dashboards to create'), + seedData: z.array(BlueprintSeedSchema).optional() + .describe('Suggested seed data (reported, not auto-applied in Phase C)'), +})); +export type SolutionBlueprint = z.infer; + +/** + * Factory mirroring `defineAgent` / `defineTool` / `defineSkill`: validates a + * blueprint literal at authoring time and returns the parsed value. + */ +export function defineSolutionBlueprint(config: z.input): SolutionBlueprint { + return SolutionBlueprintSchema.parse(config); +}