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
13 changes: 13 additions & 0 deletions .changeset/adr-0033-phase-c-blueprint.md
Original file line number Diff line number Diff line change
@@ -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.
242 changes: 242 additions & 0 deletions packages/services/service-ai/src/__tests__/blueprint-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>();
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<BlueprintToolContext['protocol']>;
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<string, unknown>, 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<typeof vi.fn>;
let generateObject: ReturnType<typeof vi.fn>;

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<string, unknown>;
let saveMetaItem: ReturnType<typeof vi.fn>;
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']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 7 additions & 2 deletions packages/services/service-ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down
38 changes: 34 additions & 4 deletions packages/services/service-ai/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-ai/src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading