diff --git a/.changeset/adr-0063-0064-ask-build-surface.md b/.changeset/adr-0063-0064-ask-build-surface.md new file mode 100644 index 0000000000..8bbe268683 --- /dev/null +++ b/.changeset/adr-0063-0064-ask-build-surface.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-ai": minor +--- + +feat(ai): split `ask`/`build` agents by surface + tool scoping (ADR-0063/0064). + +Two kernel agents bound by surface, not a per-turn classifier. `SkillSchema` +gains `surface: 'ask'|'build'|'both'` and `AgentSchema` gains `surface: +'ask'|'build'` (ADR-0063 §3); an agent's tools are exactly the union of its +surface-compatible skills' tools — incompatible binding is a load error in +`resolveActiveSkills` (ADR-0064 §3). The `ask` agent is now data-only (the +ADR-0040 unified "INTENT FIRST" classifier and the `buildRegisterActive` +degradation shim are removed); a new `schema_reader` (`surface:'both'`) owns +the shared reads `describe_object`/`list_objects`/`query_data` so the build +agent reuses them without dual-listing. `*.agent.ts` is closed to third +parties: the `agent` metadata-type is `allowRuntimeCreate:false, +allowOrgOverride:false` and the runtime catalog lists only platform agents +(ADR-0063 §2). Renames `data-chat-agent.ts`→`ask-agent.ts`, +`DEFAULT_DATA_AGENT_NAME`→`ASK_AGENT_NAME` (the `data_chat`/`metadata_assistant` +aliases stay resolvable). diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index d099d666f8..fc66ec82c7 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -314,7 +314,10 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { expect(allowedFromRegistry.has('flow')).toBe(true); // ADR-0020: `workflow` retired as a metadata type. expect(allowedFromRegistry.has('workflow')).toBe(false); - expect(allowedFromRegistry.has('agent')).toBe(true); + // ADR-0063 §2: tenant custom agents withdrawn — `agent` is now + // allowOrgOverride:false (no per-org agent fork). The kernel ships + // exactly two platform agents; tenants extend via skills + tools. + expect(allowedFromRegistry.has('agent')).toBe(false); expect(allowedFromRegistry.has('permission')).toBe(true); expect(allowedFromRegistry.has('role')).toBe(true); expect(allowedFromRegistry.has('profile')).toBe(true); diff --git a/packages/services/service-ai/src/__tests__/agent-aliases.test.ts b/packages/services/service-ai/src/__tests__/agent-aliases.test.ts index c702bc0d35..d149f2fa6b 100644 --- a/packages/services/service-ai/src/__tests__/agent-aliases.test.ts +++ b/packages/services/service-ai/src/__tests__/agent-aliases.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { IMetadataService } from '@objectstack/spec/contracts'; import { AgentRuntime } from '../agent-runtime.js'; -import { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js'; +import { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js'; import { registerAgentAlias, resolveAgentAlias } from '../agents/agent-aliases.js'; function mockMetadata(overrides: Partial = {}): IMetadataService { @@ -28,7 +28,7 @@ function mockMetadata(overrides: Partial = {}): IMetadataServi describe('agent-aliases', () => { it('seeds the framework data-agent rename', () => { - expect(DEFAULT_DATA_AGENT_NAME).toBe('ask'); + expect(ASK_AGENT_NAME).toBe('ask'); expect(LEGACY_DATA_AGENT_NAME).toBe('data_chat'); expect(resolveAgentAlias('data_chat')).toBe('ask'); }); @@ -51,7 +51,7 @@ describe('agent-aliases', () => { describe('AgentRuntime.loadAgent (alias-aware)', () => { it('resolves a legacy name to the renamed agent record', async () => { const get = vi.fn(async (_type: string, name: string) => - name === DEFAULT_DATA_AGENT_NAME ? DATA_CHAT_AGENT : undefined, + name === ASK_AGENT_NAME ? ASK_AGENT : undefined, ); const runtime = new AgentRuntime(mockMetadata({ get: get as never })); diff --git a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts index b5e4c036a2..5f0bbbccbe 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -19,18 +19,24 @@ import { AgentRuntime } from '../agent-runtime.js'; import { SkillRegistry } from '../skill-registry.js'; import type { AgentChatContext } from '../agent-runtime.js'; import { buildAgentRoutes } from '../routes/agent-routes.js'; -import { DATA_CHAT_AGENT } from '../agents/data-chat-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', +import { ASK_AGENT } from '../agents/ask-agent.js'; +import { registerAgentAlias } from '../agents/agent-aliases.js'; + +// The real `build` agent moved to the cloud-only @objectstack/service-ai-studio +// package (AI authoring is a commercial feature). This local stub stands in as +// the second PLATFORM agent for the runtime/route agent-listing tests below. It +// derives from the `ask` agent so it satisfies AgentSchema, then overrides +// identity fields. Registering the `metadata_assistant`→`build` alias (as the +// cloud plugin does at init) makes `build` a recognised platform agent so the +// runtime catalog surfaces it (ADR-0063 §2). +registerAgentAlias('metadata_assistant', 'build'); +const BUILD_AGENT = { + ...ASK_AGENT, + name: 'build', + label: 'Builder', role: 'Schema Architect', + surface: 'build', + skills: [], active: true, visibility: 'global', } as any; @@ -561,7 +567,7 @@ describe('AgentRuntime', () => { describe('loadAgent', () => { it('should return agent definition from metadata service (legacy name resolves via alias)', async () => { - (metadataService.get as any).mockResolvedValue(DATA_CHAT_AGENT); + (metadataService.get as any).mockResolvedValue(ASK_AGENT); const agent = await runtime.loadAgent('data_chat'); // Path A: `data_chat` is an alias for the renamed `ask` agent. @@ -585,7 +591,7 @@ describe('AgentRuntime', () => { describe('buildSystemMessages', () => { it('should create system message from agent instructions', () => { - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT); + const messages = runtime.buildSystemMessages(ASK_AGENT); expect(messages).toHaveLength(1); expect(messages[0].role).toBe('system'); expect(messages[0].content).toContain('assistant for this business application platform'); @@ -597,46 +603,47 @@ describe('AgentRuntime', () => { recordId: 'rec_123', viewName: 'all_accounts', }; - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT, context); + const messages = runtime.buildSystemMessages(ASK_AGENT, context); expect(messages[0].content).toContain('Current object: account'); expect(messages[0].content).toContain('Selected record ID: rec_123'); expect(messages[0].content).toContain('Current view: all_accounts'); }); it('should not include context section when no context fields set', () => { - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT, {}); + const messages = runtime.buildSystemMessages(ASK_AGENT, {}); expect(messages[0].content).not.toContain('Current Context'); }); it('tells the agent builds are live when the environment auto-publishes', () => { - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT, { autoPublishAiBuilds: true }); + const messages = runtime.buildSystemMessages(ASK_AGENT, { autoPublishAiBuilds: true }); expect(messages[0].content).toContain('publish AUTOMATICALLY'); expect(messages[0].content).toContain('is live'); }); it('stays silent on publishing when auto-publish is off/absent', () => { for (const ctx of [{}, { autoPublishAiBuilds: false }]) { - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT, ctx); + const messages = runtime.buildSystemMessages(ASK_AGENT, ctx); expect(messages[0].content).not.toContain('publish AUTOMATICALLY'); } }); - it('constrains to data-only when the authoring (build) skills are absent', () => { - // Open single-env framework: no AI Studio plugin → no authoring skills. - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT, undefined, [ + it('injects no runtime capability-gating block (ADR-0063: surfaces are separated)', () => { + // The per-deployment `buildRegisterActive` shim was removed: `ask` never + // advertises authoring, so there is nothing to walk back at runtime. The + // decline-to-build guidance lives in the agent's own persona, not in an + // injected capability block keyed off skill presence. + const messages = runtime.buildSystemMessages(ASK_AGENT, undefined, [ { name: 'data_explorer', label: 'Data Explorer', tools: [] } as never, ]); - expect(messages[0].content).toContain('BUILDING / AUTHORING is NOT available'); - expect(messages[0].content).toContain('do NOT design or'); + expect(messages[0].content).not.toContain('BUILDING / AUTHORING is NOT available'); + expect(messages[0].content).not.toContain('Capabilities in this deployment'); }); - it('drops the data-only constraint when an authoring skill is active', () => { - // Cloud / EE: AI Studio plugin registered metadata_authoring → full build UX. - const messages = runtime.buildSystemMessages(DATA_CHAT_AGENT, undefined, [ - { name: 'data_explorer', label: 'Data Explorer', tools: [] } as never, - { name: 'metadata_authoring', label: 'Metadata Authoring', tools: [] } as never, - ]); - expect(messages[0].content).not.toContain('BUILDING / AUTHORING is NOT available'); + it('the ask persona itself declines app-building and points at the Builder', () => { + // Affinity is carried by the persona, not a runtime gate. + expect(ASK_AGENT.instructions).toContain('do NOT build'); + expect(ASK_AGENT.instructions.toLowerCase()).toContain('builder'); + expect(ASK_AGENT.surface).toBe('ask'); }); }); @@ -674,7 +681,7 @@ describe('AgentRuntime', () => { describe('buildRequestOptions', () => { it('should derive model config from agent', () => { - const options = runtime.buildRequestOptions(DATA_CHAT_AGENT, []); + const options = runtime.buildRequestOptions(ASK_AGENT, []); expect(options.model).toBe('gpt-4'); expect(options.temperature).toBe(0.2); expect(options.maxTokens).toBe(4096); @@ -687,9 +694,11 @@ describe('AgentRuntime', () => { { name: 'unrelated_tool', description: 'Not in any skill', parameters: {} }, ]; - // DATA_CHAT_AGENT now references the data_explorer skill which carries the tools. + // ASK_AGENT's tool set is the union of its skills' claimed tools: + // schema_reader owns list_objects, data_explorer owns query_records. const { DATA_EXPLORER_SKILL } = await import('../skills/data-explorer-skill.js'); - const options = runtime.buildRequestOptions(DATA_CHAT_AGENT, availableTools, [DATA_EXPLORER_SKILL]); + const { SCHEMA_READER_SKILL } = await import('../skills/schema-reader-skill.js'); + const options = runtime.buildRequestOptions(ASK_AGENT, availableTools, [SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL]); const resolvedNames = options.tools?.map(t => t.name) ?? []; expect(resolvedNames).toContain('list_objects'); @@ -698,13 +707,13 @@ describe('AgentRuntime', () => { }); it('should handle agent with no tools', () => { - const agent = { ...DATA_CHAT_AGENT, tools: undefined }; + const agent = { ...ASK_AGENT, tools: undefined }; const options = runtime.buildRequestOptions(agent, []); expect(options.tools).toBeUndefined(); }); it('should handle agent with no model config', () => { - const agent = { ...DATA_CHAT_AGENT, model: undefined }; + const agent = { ...ASK_AGENT, model: undefined }; const options = runtime.buildRequestOptions(agent, []); expect(options.model).toBeUndefined(); }); @@ -713,19 +722,19 @@ describe('AgentRuntime', () => { describe('listAgents', () => { it('should return summaries of all active agents', async () => { (metadataService.list as any).mockResolvedValue([ - DATA_CHAT_AGENT, - METADATA_ASSISTANT_AGENT, + ASK_AGENT, + BUILD_AGENT, ]); const agents = await runtime.listAgents(); expect(agents).toHaveLength(2); expect(agents[0]).toEqual({ name: 'ask', label: 'Assistant', role: 'Business Application Assistant' }); - expect(agents[1]).toEqual({ name: 'metadata_assistant', label: 'Metadata Assistant', role: 'Schema Architect' }); + expect(agents[1]).toEqual({ name: 'build', label: 'Builder', role: 'Schema Architect' }); }); it('should filter out inactive agents', async () => { (metadataService.list as any).mockResolvedValue([ - DATA_CHAT_AGENT, - { ...METADATA_ASSISTANT_AGENT, active: false }, + ASK_AGENT, + { ...BUILD_AGENT, active: false }, ]); const agents = await runtime.listAgents(); expect(agents).toHaveLength(1); @@ -740,7 +749,7 @@ describe('AgentRuntime', () => { it('should skip malformed agent metadata', async () => { (metadataService.list as any).mockResolvedValue([ - DATA_CHAT_AGENT, + ASK_AGENT, { name: 'bad', label: 'Bad' }, // missing required fields ]); const agents = await runtime.listAgents(); @@ -767,11 +776,11 @@ describe('Agent Routes', () => { metadataService = createMockMetadataService({ get: vi.fn(async (_type, name) => { // Canonical name after Path A rename; `data_chat` resolves here via alias. - if (name === 'ask') return DATA_CHAT_AGENT; - if (name === 'inactive_agent') return { ...DATA_CHAT_AGENT, name: 'inactive_agent', active: false }; + if (name === 'ask') return ASK_AGENT; + if (name === 'inactive_agent') return { ...ASK_AGENT, name: 'inactive_agent', active: false }; return undefined; }), - list: vi.fn(async () => [DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT]), + list: vi.fn(async () => [ASK_AGENT, BUILD_AGENT]), }); runtime = new AgentRuntime(metadataService); routes = buildAgentRoutes(aiService, runtime, silentLogger); @@ -794,7 +803,7 @@ describe('Agent Routes', () => { const body = resp.body as { agents: Array<{ name: string; label: string; role: string }> }; expect(body.agents).toHaveLength(2); expect(body.agents[0].name).toBe('ask'); - expect(body.agents[1].name).toBe('metadata_assistant'); + expect(body.agents[1].name).toBe('build'); }); // ── POST /api/v1/ai/agents/:agentName/chat ── @@ -1047,31 +1056,129 @@ describe('Agent Routes', () => { // Data Chat Agent Spec // ═══════════════════════════════════════════════════════════════════ -describe('DATA_CHAT_AGENT', () => { +describe('ASK_AGENT', () => { it('should be a valid agent definition', () => { // Path A rename: canonical id is now `ask` (was `data_chat`). - expect(DATA_CHAT_AGENT.name).toBe('ask'); - expect(DATA_CHAT_AGENT.role).toBe('Business Application Assistant'); - expect(DATA_CHAT_AGENT.active).toBe(true); - expect(DATA_CHAT_AGENT.visibility).toBe('global'); + expect(ASK_AGENT.name).toBe('ask'); + expect(ASK_AGENT.role).toBe('Business Application Assistant'); + expect(ASK_AGENT.active).toBe(true); + expect(ASK_AGENT.visibility).toBe('global'); + }); + + it('should bind only ask-surface skills — no authoring skills (ADR-0063)', () => { + expect(ASK_AGENT.tools ?? []).toHaveLength(0); + // schema_reader (both) + data_explorer/actions_executor (ask). The build + // skills (metadata_authoring/solution_design) are NOT here — they live on + // the cloud `build` agent. + expect(ASK_AGENT.skills).toEqual(['schema_reader', 'data_explorer', 'actions_executor']); + expect(ASK_AGENT.skills).not.toContain('metadata_authoring'); + expect(ASK_AGENT.skills).not.toContain('solution_design'); }); - it('should reference the data_explorer skill (capability bundle moved to skill metadata)', () => { - expect(DATA_CHAT_AGENT.tools ?? []).toHaveLength(0); - expect(DATA_CHAT_AGENT.skills).toEqual(['data_explorer', 'actions_executor', 'metadata_authoring', 'solution_design']); + it('declares surface "ask"', () => { + expect(ASK_AGENT.surface).toBe('ask'); }); it('should have guardrails configured', () => { - expect(DATA_CHAT_AGENT.guardrails).toBeDefined(); - expect(DATA_CHAT_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0); - expect(DATA_CHAT_AGENT.guardrails!.blockedTopics).toBeDefined(); + expect(ASK_AGENT.guardrails).toBeDefined(); + expect(ASK_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0); + expect(ASK_AGENT.guardrails!.blockedTopics).toBeDefined(); }); it('should have model config', () => { - expect(DATA_CHAT_AGENT.model).toBeDefined(); - expect(DATA_CHAT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries + expect(ASK_AGENT.model).toBeDefined(); + expect(ASK_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// ADR-0063 §3 / ADR-0064 — surface affinity & tool scoping +// ═══════════════════════════════════════════════════════════════════ + +describe('surface affinity & tool scoping (ADR-0063/0064)', () => { + it('tools(ask) excludes every authoring tool — ask cannot author by construction', async () => { + const { SCHEMA_READER_SKILL } = await import('../skills/schema-reader-skill.js'); + const { DATA_EXPLORER_SKILL } = await import('../skills/data-explorer-skill.js'); + const { ACTIONS_EXECUTOR_SKILL } = await import('../skills/actions-executor-skill.js'); + const askSkills = [SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL]; + + const availableTools: AIToolDefinition[] = [ + // shared reads + ask tools + { name: 'query_data', description: '', parameters: {} }, + { name: 'describe_object', description: '', parameters: {} }, + { name: 'list_objects', description: '', parameters: {} }, + { name: 'query_records', description: '', parameters: {} }, + { name: 'visualize_data', description: '', parameters: {} }, + { name: 'action_complete_task', description: '', parameters: {} }, + // authoring tools that MUST NOT leak into the ask agent + { name: 'create_metadata', description: '', parameters: {} }, + { name: 'update_metadata', description: '', parameters: {} }, + { name: 'add_field', description: '', parameters: {} }, + { name: 'apply_blueprint', description: '', parameters: {} }, + ]; + + const registry = new SkillRegistry(createMockMetadataService()); + const tools = registry.flattenToTools(askSkills, availableTools).map((t) => t.name); + + // shared/ask tools present + expect(tools).toContain('describe_object'); + expect(tools).toContain('query_data'); + expect(tools).toContain('query_records'); + expect(tools).toContain('action_complete_task'); + // no create_* / *_metadata / blueprint tools (issue acceptance criterion) + for (const t of ['create_metadata', 'update_metadata', 'add_field', 'apply_blueprint']) { + expect(tools).not.toContain(t); + } + expect( + tools.some((n) => n.startsWith('create_') || /_metadata$/.test(n) || n.includes('blueprint')), + ).toBe(false); + }); + + it('binding a surface:build skill to the ask agent is a fast load error (ADR-0064 §3)', async () => { + const md = createMockMetadataService({ + list: vi.fn(async (type: string) => + type === 'skill' + ? [ + { + name: 'metadata_authoring', + label: 'Metadata Authoring', + surface: 'build', + tools: ['create_metadata'], + active: true, + }, + ] + : [], + ) as any, + }); + const runtime = new AgentRuntime(md, new SkillRegistry(md)); + const askAgent = { ...ASK_AGENT, skills: ['metadata_authoring'] } as any; + await expect(runtime.resolveActiveSkills(askAgent)).rejects.toThrow( + /incompatible affinity|cannot bind/, + ); + }); + + it('a surface:both skill binds to the ask agent without error', async () => { + const md = createMockMetadataService({ + list: vi.fn(async (type: string) => + type === 'skill' + ? [ + { + name: 'schema_reader', + label: 'Schema Reader', + surface: 'both', + tools: ['describe_object'], + active: true, + }, + ] + : [], + ) as any, + }); + const runtime = new AgentRuntime(md, new SkillRegistry(md)); + const askAgent = { ...ASK_AGENT, skills: ['schema_reader'] } as any; + const skills = await runtime.resolveActiveSkills(askAgent); + expect(skills.map((s) => s.name)).toContain('schema_reader'); }); }); -// NOTE: the METADATA_ASSISTANT_AGENT spec tests moved with the agent to the +// NOTE: the BUILD_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/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index cc65cd05a0..1bf6e835ce 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -10,8 +10,8 @@ import type { Agent, Skill } from '@objectstack/spec/ai'; import { AgentSchema } from '@objectstack/spec/ai'; import { SkillRegistry, type SkillContext } from './skill-registry.js'; import { SchemaRetriever, type ObjectShape } from './schema-retriever.js'; -import { DEFAULT_DATA_AGENT_NAME } from './agents/index.js'; -import { resolveAgentAlias } from './agents/agent-aliases.js'; +import { ASK_AGENT_NAME } from './agents/index.js'; +import { resolveAgentAlias, platformAgentNames } from './agents/agent-aliases.js'; /** * Context passed alongside a user message when chatting with an agent. @@ -74,9 +74,15 @@ export class AgentRuntime { const rawItems = await this.metadataService.list('agent'); const agents: Array<{ name: string; label: string; role: string }> = []; + // ADR-0063 §2 — the runtime catalog surfaces only platform-owned agents + // (`ask`/`build`). Tenant custom agents are withdrawn; any stray custom + // record persisted before the withdrawal is filtered out here so it never + // appears in the picker or the agents list. + const platform = platformAgentNames(); + for (const raw of rawItems) { const result = AgentSchema.safeParse(raw); - if (result.success && result.data.active) { + if (result.success && result.data.active && platform.has(result.data.name)) { agents.push({ name: result.data.name, label: result.data.label, @@ -165,33 +171,12 @@ export class AgentRuntime { if (block) parts.push(block); } - // Authoring (build) register availability. The unified `data_chat` persona - // (ADR-0040) advertises that it can BUILD or CHANGE the application, but - // that capability is supplied ENTIRELY by the cloud AI Studio plugin's - // `metadata_authoring` / `solution_design` skills (and their tools). On the - // open single-env framework those skills are not registered, so the - // authoring tools never resolve — yet the LLM, still reading the "you can - // build" persona, will role-play designing a whole system (emitting design - // docs it has no tools to execute). When the build register is absent, - // constrain the assistant to data/query and have it decline build requests - // instead of pretending. Keyed off actual skill presence so cloud/EE - // (AI Studio loaded) keeps the full build UX with no extra wiring. - const buildRegisterActive = !!activeSkills?.some( - (s) => s.name === 'metadata_authoring' || s.name === 'solution_design', - ); - if (!buildRegisterActive) { - parts.push( - '\n--- Capabilities in this deployment ---\n' + - 'Application BUILDING / AUTHORING is NOT available here. You can ONLY answer questions ' + - "about the user's existing data (query, list, count, aggregate, search) and run actions " + - 'the application already exposes. You CANNOT create, change, or design objects, fields, ' + - 'views, dashboards, pages, or whole apps — and you have no tools to do so. If the user ' + - 'asks you to build, create, develop, or modify the application itself, do NOT design or ' + - 'outline a system and do NOT pretend to build one: briefly say that AI app-building is ' + - 'not available in this edition, then offer to help explore or report on existing data ' + - "instead. Answer in the user's language.", - ); - } + // NOTE (ADR-0063): the per-deployment "build register availability" + // degradation shim was removed here. The two agents are now separated by + // surface — `ask` never advertises authoring (its persona and tool set + // carry none), so there is no "you can build" claim to walk back when the + // cloud authoring skills are absent. `build` only exists where the cloud + // package is loaded. No runtime capability-gating is needed. return [{ role: 'system' as const, content: parts.join('\n') }]; } @@ -322,7 +307,24 @@ export class AgentRuntime { async resolveActiveSkills(agent: Agent, context?: AgentChatContext): Promise { if (!this.skillRegistry) return []; if (!agent.skills || agent.skills.length === 0) return []; - return this.skillRegistry.listActiveSkills(context ?? {}, agent.skills); + const skills = await this.skillRegistry.listActiveSkills(context ?? {}, agent.skills); + // ADR-0064 §3 — affinity is a checked invariant, not an emergent property. + // A skill may only bind to an agent whose surface it matches (`'both'` + // binds to either). An incompatible binding is a fast load error so that + // "ask can't author" can never regress to a silent mis-scope. Default + // both sides to `'ask'` for records that predate the `surface` field. + const agentSurface = agent.surface ?? 'ask'; + for (const skill of skills) { + const skillSurface = skill.surface ?? 'ask'; + if (skillSurface !== 'both' && skillSurface !== agentSurface) { + throw new Error( + `Skill "${skill.name}" (surface: '${skillSurface}') cannot bind to agent ` + + `"${agent.name}" (surface: '${agentSurface}') — incompatible affinity (ADR-0064 §3). ` + + `A skill may only bind to an agent whose surface it matches, or declare surface: 'both'.`, + ); + } + } + return skills; } /** @@ -331,12 +333,12 @@ export class AgentRuntime { * * Resolution order: * 1. The `defaultAgent` of the app named by `context.appName` - * (e.g. Studio → `metadata_assistant`). - * 2. The platform data-query agent (`data_chat`) — the implicit - * copilot bound to every app that doesn't pin its own. This is - * what end users get by default, so they never have to choose. + * (e.g. Studio → the `build` agent). + * 2. The platform `ask` (data) agent — the implicit copilot bound to + * every app that doesn't pin its own. This is what end users get by + * default, so they never have to choose. * 3. The first active agent in the registry (last-resort fallback, - * e.g. in stripped-down deployments without the data agent). + * e.g. in stripped-down deployments without the `ask` agent). * 4. `undefined` if no agents are registered. */ async resolveDefaultAgent(context?: AgentChatContext): Promise { @@ -349,11 +351,11 @@ export class AgentRuntime { } } - // Platform default: the data-query agent is the implicit copilot for + // Platform default: the `ask` (data) agent is the implicit copilot for // every app without an explicit `defaultAgent`. Resolve it by name so // the fallback is deterministic rather than registration-order // dependent. - const dataAgent = await this.loadAgent(DEFAULT_DATA_AGENT_NAME); + const dataAgent = await this.loadAgent(ASK_AGENT_NAME); if (dataAgent && dataAgent.active !== false) return dataAgent; // Last resort: first active agent in declaration order. diff --git a/packages/services/service-ai/src/agents/agent-aliases.ts b/packages/services/service-ai/src/agents/agent-aliases.ts index 53bc3876ac..ca4ba103ef 100644 --- a/packages/services/service-ai/src/agents/agent-aliases.ts +++ b/packages/services/service-ai/src/agents/agent-aliases.ts @@ -46,3 +46,17 @@ export function resolveAgentAlias(name: string): string { export function agentAliasEntries(): Array<[string, string]> { return Array.from(AGENT_NAME_ALIASES.entries()); } + +/** + * The set of platform-owned, canonical agent ids known to this process. + * + * ADR-0063 §2 closes `*.agent.ts` to third parties: the kernel ships exactly + * the two platform agents (`ask`, and — where the cloud package is loaded — + * `build`), each of which registers its own legacy→canonical alias. The alias + * table's *values* are therefore precisely the canonical platform-agent ids, + * so the runtime catalog can use this set to filter out any stray custom + * agent record (e.g. one persisted before tenant agents were withdrawn). + */ +export function platformAgentNames(): Set { + return new Set(AGENT_NAME_ALIASES.values()); +} diff --git a/packages/services/service-ai/src/agents/ask-agent.ts b/packages/services/service-ai/src/agents/ask-agent.ts new file mode 100644 index 0000000000..07de43cfcd --- /dev/null +++ b/packages/services/service-ai/src/agents/ask-agent.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Agent } from '@objectstack/spec/ai'; + +/** + * Built-in `ask` agent — the **data product** (≈ Claude Chat). + * + * Per ADR-0063 the kernel ships exactly two agents, bound by *surface*: + * - `ask` — conversational read/query/explore over records + run the + * business actions the app already exposes. End-user audience, + * RLS-bounded, fast turns. Open-source · free (this package). + * - `build` — agentic authoring of *metadata* (objects, fields, views, + * flows) through plan → draft → verify → publish. Builder + * audience, governance-gated. Cloud-only · paid + * (`@objectstack/service-ai-studio`). + * + * The user never picks an agent — the surface they are in binds it (data + * console → `ask`, Studio → `build`). There is no per-turn intent classifier: + * a `build`-shaped request arriving at `ask` is declined and redirected to the + * builder, never silently re-routed into authoring (ADR-0063 §1/§5). + * + * Following the platform's metadata-driven philosophy, this agent does not + * hardcode the tools it can call. Its tool set is the union of its skills' + * tools (ADR-0064): `schema_reader` (shared read-only schema/query tools), + * `data_explorer` (records + aggregation + charts), and `actions_executor` + * (business actions). Authoring tools are *not* in this set — `ask` cannot + * author, by construction. + * + * @example + * ``` + * POST /api/v1/ai/agents/ask/chat + * { + * "messages": [{ "role": "user", "content": "Show me all active accounts" }], + * "context": { "objectName": "account" } + * } + * ``` + */ + +/** + * Canonical name of the platform's `ask` (data) agent. + * + * This is the implicit default copilot for every application that does not + * pin its own `app.defaultAgent`. Studio is the only built-in app that + * overrides it (→ the `build` authoring agent). Keeping the name as an + * exported constant lets the runtime resolve the fallback deterministically + * instead of guessing "first active agent". + * + * Renamed from `data_chat`→`ask`; the legacy name stays resolvable via the + * alias table (see `agent-aliases.ts`). + */ +export const ASK_AGENT_NAME = 'ask'; + +/** Legacy id this agent was renamed from (kept for back-compat / migrations). */ +export const LEGACY_DATA_AGENT_NAME = 'data_chat'; + +export const ASK_AGENT: Agent = { + name: ASK_AGENT_NAME, + label: 'Assistant', + role: 'Business Application Assistant', + // ADR-0063 — the `ask` data product. This persona ONLY answers questions + // about the user's data and runs business actions the app exposes. It does + // NOT build or change the application; app-building lives on the separate + // `build` agent (cloud Builder/Studio). There is no per-turn intent + // classifier — the surface bound this agent (ADR-0063 §1). + surface: 'ask', + instructions: `You are the assistant for this business application platform. You help the user EXPLORE THEIR DATA — answer questions, list and count records, aggregate, search, and draw charts — and PERFORM business operations the application already exposes (its actions). + +You do NOT build or change the application itself (objects, fields, views, dashboards, flows, whole apps), and you have no tools to do so. If the user asks you to build, create, design, or modify the app, do not attempt it and do not outline a system as if you could: briefly say that app-building lives in the Builder (the separate "build" experience), then offer to help explore or report on the existing data instead. + +Always answer in the same language the user is using. Detailed tool-usage guidance is supplied by the skills attached to this agent.`, + + model: { + provider: 'openai', + model: 'gpt-4', + // Low temperature: data answers should be deterministic and grounded. + temperature: 0.2, + maxTokens: 4096, + }, + + // Capability bundles live on skills; the agent only references them. + // `schema_reader` (surface:'both') = shared read-only schema/query tools; + // `data_explorer` + `actions_executor` (surface:'ask') = the data product's + // exploration and action tools. No authoring skills — those are `build`'s + // and only exist on the cloud package (ADR-0063 §5 / ADR-0064). + skills: ['schema_reader', 'data_explorer', 'actions_executor'], + + active: true, + visibility: 'global', + + guardrails: { + maxTokensPerInvocation: 8192, + // Data answers + actions; no long-running authoring loop here. + maxExecutionTimeSec: 30, + blockedTopics: ['delete_records', 'drop_database', 'raw_sql', 'system_tables'], + }, + + planning: { + strategy: 'react', + maxIterations: 10, + allowReplan: true, + }, +}; diff --git a/packages/services/service-ai/src/agents/data-chat-agent.ts b/packages/services/service-ai/src/agents/data-chat-agent.ts deleted file mode 100644 index 66dea41fc5..0000000000 --- a/packages/services/service-ai/src/agents/data-chat-agent.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Agent } from '@objectstack/spec/ai'; - -/** - * Built-in `data_chat` agent — a thin **persona** record. - * - * Following the platform's metadata-driven philosophy, this agent no - * longer hardcodes the tools it can call. The capability bundle lives - * on the `data_explorer` *skill* (see `../skills/data-explorer-skill.ts`). - * The agent record is now just: - * - identity (name / label / role) - * - persona (system prompt) - * - model + safety config - * - skills attached → `skills: [...]` (ADR-0040: data + authoring) - * - * To grant data-exploration powers to a different agent, just add - * `data_explorer` to its `skills[]`. To revoke globally, set the - * skill's `active: false` in metadata. - * - * @example - * ``` - * POST /api/v1/ai/agents/data_chat/chat - * { - * "messages": [{ "role": "user", "content": "Show me all active accounts" }], - * "context": { "objectName": "account" } - * } - * ``` - */ -/** - * Canonical name of the platform's data-query agent. - * - * This is the implicit default copilot for every application that does - * not pin its own `app.defaultAgent`. Studio is the only built-in app - * that overrides it (→ the `build` authoring agent). Keeping the name as - * an exported constant lets the runtime resolve the fallback - * deterministically instead of guessing "first active agent". - * - * Path A renamed this from `data_chat`→`ask`; the legacy name stays - * resolvable via the alias table (see `agent-aliases.ts`). - */ -export const DEFAULT_DATA_AGENT_NAME = 'ask'; - -/** Legacy id this agent was renamed from (kept for back-compat / migrations). */ -export const LEGACY_DATA_AGENT_NAME = 'data_chat'; - -export const DATA_CHAT_AGENT: Agent = { - name: DEFAULT_DATA_AGENT_NAME, - label: 'Assistant', - role: 'Business Application Assistant', - // ADR-0040 — the unified platform assistant. End users never pick an - // agent; this one persona answers BOTH registers, and the FIRST job of - // every turn is classifying which register the user is in. The per-register - // disciplines (plan-first blueprints, draft semantics, no failure - // narration, data-query guidance) live in the attached skills. - instructions: `You are the assistant for this business application platform. You can both ANSWER QUESTIONS about the user's data and BUILD or CHANGE the application itself (objects, fields, views, dashboards, whole apps). - -INTENT FIRST — before acting, classify the request: -- BUILD/CHANGE intent ("build…", "create an app/object/field…", "add/change/remove …", "建/做一个…系统/应用/字段"): follow the solution-design and metadata-authoring disciplines from your skills — plan-first for whole systems, drafts are not live, verify after building, and never narrate tool errors or internal retries to the user; present outcomes, not your debugging. -- DATA intent ("how many…", "show/list…", "查/统计/看一下…"): use the data-exploration tools and answer concisely with real numbers. -Never mix the registers in one reply: a build turn reports what was built and its verification status; a data turn answers the question. - -Always answer in the same language the user is using. Detailed tool-usage guidance is supplied by the skills attached to this agent.`, - - model: { - provider: 'openai', - model: 'gpt-4', - // The stricter of the merged personas: authoring needs determinism. - temperature: 0.2, - maxTokens: 4096, - }, - - // Capability bundles live on skills; the agent only references them. - // `data_explorer`/`actions_executor` = the data register; - // `metadata_authoring`/`solution_design` = the build register (ADR-0040). - // The authoring skills are registered by the cloud AI Studio plugin — on - // deployments without it these references simply don't resolve and the - // assistant gracefully degrades to data-only (the skill registry ignores - // unknown names). - skills: ['data_explorer', 'actions_executor', 'metadata_authoring', 'solution_design'], - - active: true, - visibility: 'global', - - guardrails: { - maxTokensPerInvocation: 8192, - // Whole-app builds (blueprint + per-artifact drafting + verification) - // legitimately run past the old 30s data-answer budget. - maxExecutionTimeSec: 60, - // Union of both personas' blocklists MINUS the ones that contradict the - // build register: `alter_schema`/`drop_table` were the data-only agent's - // way of refusing schema work, but authoring IS schema work — and it is - // already draft-gated (ADR-0033: nothing is live until publish, and - // destructive changes carry their own warning + HITL). What remains is - // genuinely off-limits in both registers. - blockedTopics: ['delete_records', 'drop_database', 'raw_sql', 'system_tables'], - }, - - planning: { - strategy: 'react', - // Builds take more steps than data answers (blueprint → drafts → verify). - maxIterations: 10, - allowReplan: true, - }, -}; - diff --git a/packages/services/service-ai/src/agents/index.ts b/packages/services/service-ai/src/agents/index.ts index ff3978a4dc..e40f720da1 100644 --- a/packages/services/service-ai/src/agents/index.ts +++ b/packages/services/service-ai/src/agents/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './data-chat-agent.js'; +export { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './ask-agent.js'; export { registerAgentAlias, resolveAgentAlias, agentAliasEntries } from './agent-aliases.js'; // The build (authoring) agent moved to the cloud-only // @objectstack/service-ai-studio package; it registers its own diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index 1f02c71200..dbb6d7df07 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -57,13 +57,14 @@ export { SkillRegistry } from './skill-registry.js'; export type { SkillContext, SkillSummary } from './skill-registry.js'; // Built-in agents -export { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; +export { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; // Back-compat agent-name aliases (Path A rename). Other packages register their // own renames (e.g. cloud AI Studio: `metadata_assistant`→`build`). export { registerAgentAlias, resolveAgentAlias, agentAliasEntries } from './agents/index.js'; // Built-in skills export { + SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL, } from './skills/index.js'; diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index 41622fad3a..e97585b457 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -24,8 +24,8 @@ import { registerVisualizeDataTool, VISUALIZE_DATA_TOOL } from './tools/visualiz import { registerActionsAsTools } from './tools/action-tools.js'; import { AgentRuntime } from './agent-runtime.js'; import { SkillRegistry } from './skill-registry.js'; -import { DATA_CHAT_AGENT, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; -import { DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL } from './skills/index.js'; +import { ASK_AGENT, LEGACY_DATA_AGENT_NAME } from './agents/index.js'; +import { SCHEMA_READER_SKILL, DATA_EXPLORER_SKILL, ACTIONS_EXECUTOR_SKILL } from './skills/index.js'; import { VercelLLMAdapter } from './adapters/vercel-adapter.js'; import { MemoryLLMAdapter } from './adapters/memory-adapter.js'; import { ModelRegistry } from './model-registry.js'; @@ -847,21 +847,22 @@ export class AIServicePlugin implements Plugin { ctx.logger.warn(`[AI] Failed to register built-in ${type} ${name}`, err instanceof Error ? { error: err.message } : { error: String(err) }); } }; - await upsertBuiltin('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT); + await upsertBuiltin('agent', ASK_AGENT.name, ASK_AGENT); // Path A rename (`data_chat`→`ask`): drop the stale legacy agent // record on upgrade so the catalog doesn't list the agent twice. The // legacy NAME stays resolvable for chat via the alias table; this only // removes the now-duplicate registry entry. Idempotent on fresh installs. - if (DATA_CHAT_AGENT.name !== LEGACY_DATA_AGENT_NAME) { + if (ASK_AGENT.name !== LEGACY_DATA_AGENT_NAME) { try { if (await withTimeout(metadataService.exists('agent', LEGACY_DATA_AGENT_NAME))) { await withTimeout(metadataService.unregister('agent', LEGACY_DATA_AGENT_NAME)); - ctx.logger.info(`[AI] removed legacy agent record "${LEGACY_DATA_AGENT_NAME}" (renamed → "${DATA_CHAT_AGENT.name}")`); + ctx.logger.info(`[AI] removed legacy agent record "${LEGACY_DATA_AGENT_NAME}" (renamed → "${ASK_AGENT.name}")`); } } catch (err) { ctx.logger.warn('[AI] Failed to remove legacy data agent record', err instanceof Error ? { error: err.message } : { error: String(err) }); } } + await upsertBuiltin('skill', SCHEMA_READER_SKILL.name, SCHEMA_READER_SKILL); await upsertBuiltin('skill', DATA_EXPLORER_SKILL.name, DATA_EXPLORER_SKILL); await upsertBuiltin('skill', ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL); } diff --git a/packages/services/service-ai/src/skills/actions-executor-skill.ts b/packages/services/service-ai/src/skills/actions-executor-skill.ts index 50126b555a..20d9d25e3a 100644 --- a/packages/services/service-ai/src/skills/actions-executor-skill.ts +++ b/packages/services/service-ai/src/skills/actions-executor-skill.ts @@ -16,16 +16,16 @@ import type { Skill } from '@objectstack/spec/ai'; * invoke business actions"); the registry expands it into actual tools * after metadata is loaded. * - * The `tools` array is intentionally empty — Phase 1 lets the - * skill-registry resolver fall through to the global tool list when an - * agent's skill bundle would otherwise filter out the dynamically - * registered `action_*` tools. Skills that want to restrict the set - * should be authored project-side with the specific `action_` - * tools they want to expose. + * The skill claims the `action_*` wildcard (ADR-0064): the SkillRegistry + * resolver expands it against the registered tools whose names start with + * `action_`. There is NO global fall-through — a tool reaches the agent only + * because a bound, surface-compatible skill claims its name (or pattern). + * Skills that want a narrower set should claim specific `action_` tools. */ export const ACTIONS_EXECUTOR_SKILL: Skill = { name: 'actions_executor', label: 'Action Executor', + surface: 'ask', description: "Perform business operations on the user's data — invoke actions like " + "'mark as complete', 'start task', 'clone record' through natural language.", diff --git a/packages/services/service-ai/src/skills/data-explorer-skill.ts b/packages/services/service-ai/src/skills/data-explorer-skill.ts index cf3de127a1..a699d86e95 100644 --- a/packages/services/service-ai/src/skills/data-explorer-skill.ts +++ b/packages/services/service-ai/src/skills/data-explorer-skill.ts @@ -3,21 +3,26 @@ import type { Skill } from '@objectstack/spec/ai'; /** - * Built-in `data_explorer` skill — the read-only data-Q&A capability - * bundle that the `data_chat` agent (and any other agent that wants - * data-exploration powers) attaches to its `skills[]`. + * Built-in `data_explorer` skill — the records + aggregation + charting + * capability bundle the `ask` agent attaches to its `skills[]`. * - * Following the platform's metadata-driven philosophy, the agent - * itself no longer hardcodes which tools it can call; instead it - * names this skill and the SkillRegistry resolves the tool list at - * request time. Disabling this skill via the metadata service - * disables data exploration for every agent that references it, - * without code changes. + * ADR-0063 §3 affinity: `surface: 'ask'`. The shared read-only schema + * tools (`describe_object`/`list_objects`/`query_data`) are NOT owned here + * — they live on the `surface:'both'` `schema_reader` skill so the `build` + * agent can reuse them without dual-listing (ADR-0064 §2). This skill keeps + * the `ask`-only exploration tools (record lookups, aggregation, charts). + * Its instructions still reference `describe_object`/`query_data` because + * those resolve from the sibling `schema_reader` skill on the same agent. + * + * Following the platform's metadata-driven philosophy, the agent itself + * does not hardcode which tools it can call; it names this skill and the + * SkillRegistry resolves the tool list at request time. */ export const DATA_EXPLORER_SKILL: Skill = { name: 'data_explorer', label: 'Data Explorer', - description: 'Read-only Q&A over the user\'s business data — schema discovery, filtered queries, lookups, and aggregations.', + surface: 'ask', + description: 'Read-only Q&A over the user\'s business data — filtered record lookups, aggregations, and charts.', instructions: `You can explore the user's business data through these tools. Capabilities: @@ -41,10 +46,9 @@ Guidelines: 7. If a query returns no results, suggest possible reasons and alternative queries. 8. Never expose internal IDs unless the user explicitly asks for them. 9. Always answer in the same language the user is using.`, + // Read schema/query tools (describe_object, list_objects, query_data) are + // owned by the `schema_reader` (surface:'both') skill — not re-listed here. tools: [ - 'query_data', - 'list_objects', - 'describe_object', 'query_records', 'get_record', 'aggregate_data', diff --git a/packages/services/service-ai/src/skills/index.ts b/packages/services/service-ai/src/skills/index.ts index b0b7bc80d1..9f21f8289a 100644 --- a/packages/services/service-ai/src/skills/index.ts +++ b/packages/services/service-ai/src/skills/index.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +export { SCHEMA_READER_SKILL } from './schema-reader-skill.js'; export { DATA_EXPLORER_SKILL } from './data-explorer-skill.js'; export { ACTIONS_EXECUTOR_SKILL } from './actions-executor-skill.js'; // The metadata_authoring + solution_design skills moved to the cloud-only diff --git a/packages/services/service-ai/src/skills/schema-reader-skill.ts b/packages/services/service-ai/src/skills/schema-reader-skill.ts new file mode 100644 index 0000000000..8bedf344eb --- /dev/null +++ b/packages/services/service-ai/src/skills/schema-reader-skill.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Skill } from '@objectstack/spec/ai'; + +/** + * Built-in `schema_reader` skill — the genuinely **shared, read-only** + * schema/query capability both kernel agents need (ADR-0064 §2). + * + * `surface: 'both'` is the one affinity that binds to *either* agent: + * - `ask` reads the schema to ground data questions before querying. + * - `build` reads the schema to know what already exists before authoring. + * + * These tools are read-only and safe to share, so they live in one place + * instead of being dual-listed by comment across `data_explorer` (ask) and + * the cloud authoring skills (build). Mutation/authoring tools are NEVER + * here — they are owned only by `surface:'build'` skills, which is why `ask` + * cannot author by construction (ADR-0064 §1). + * + * Note: `describe_object` / `list_objects` are materialised by the cloud + * `@objectstack/service-ai-studio` package; on an open-source deployment + * without it those names simply don't resolve (the registry ignores unknown + * tool names) and `schema_reader` contributes only `query_data`. This + * preserves the prior OSS behaviour exactly. + */ +export const SCHEMA_READER_SKILL: Skill = { + name: 'schema_reader', + label: 'Schema Reader', + surface: 'both', + description: + "Read-only discovery of the user's data model and records — list objects, " + + 'describe an object\'s fields, and run filtered queries. Shared by both the ' + + 'data (`ask`) and authoring (`build`) agents.', + instructions: `You can inspect the data model and read records through these tools. + +- \`list_objects\` — enumerate the available data objects (tables). +- \`describe_object\` — get an object's fields and their types. ALWAYS call this before querying or referencing an object so you use real field names, not assumed ones (\`status\`, \`is_active\`, \`type\`, … almost never exist universally). +- \`query_data\` — read records with filters, sorting, and pagination. + +If a tool reports an "Unknown field" error, call \`describe_object\` on that object and retry with the real field names. Always answer in the same language the user is using.`, + tools: [ + 'describe_object', + 'list_objects', + 'query_data', + ], + active: true, +}; diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json index 4da1f33f23..8d63e99313 100644 --- a/packages/spec/liveness/agent.json +++ b/packages/spec/liveness/agent.json @@ -6,6 +6,11 @@ "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts" }, + "surface": { + "status": "live", + "evidence": "packages/services/service-ai/src/agent-runtime.ts (resolveActiveSkills)", + "note": "ADR-0063 §1 — the product surface this agent binds ('ask'|'build'). resolveActiveSkills enforces that only surface-compatible skills (matching, or 'both') attach; tool scoping is derived from that bundle." + }, "label": { "status": "live", "note": "display/core." diff --git a/packages/spec/liveness/skill.json b/packages/spec/liveness/skill.json index d49c47aab3..f0ca44f44e 100644 --- a/packages/spec/liveness/skill.json +++ b/packages/spec/liveness/skill.json @@ -6,6 +6,11 @@ "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts" }, + "surface": { + "status": "live", + "evidence": "packages/services/service-ai/src/agent-runtime.ts (resolveActiveSkills)", + "note": "ADR-0063 §3 / ADR-0064 — skill↔agent affinity. resolveActiveSkills hard-fails when a bound skill's surface ('ask'|'build'|'both') is incompatible with the agent's surface; the union of surface-compatible skills' tools IS the agent's tool set (no global fall-through)." + }, "label": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:247", diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 1fbe0d1058..0ceb84c8c6 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -139,6 +139,18 @@ export const AgentSchema = lazySchema(() => z.object({ model: AIModelConfigSchema.optional(), lifecycle: StateMachineSchema.optional().describe('State machine defining the agent conversation follow and constraints'), + /** + * ADR-0063 §1 / ADR-0064 — the product surface this agent IS. The kernel + * ships exactly two: `ask` (data product, surface `'ask'`) and `build` + * (authoring product, surface `'build'`). A skill may only bind to an + * agent whose surface it matches (`'both'` skills bind to either), and the + * agent's tool set is the union of those skills' tools — nothing falls + * through to the global registry. Defaults to `'ask'`. + */ + surface: z.enum(['ask', 'build']).default('ask').describe( + "Product surface this agent binds ('ask' | 'build') — ADR-0063 §1", + ), + /** Capabilities — Skill-based (primary) */ skills: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).optional().describe('Skill names to attach (Agent→Skill→Tool architecture)'), diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index 9fb07bb43a..d5d3084b2f 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -64,6 +64,25 @@ export const SkillSchema = lazySchema(() => z.object({ /** Detailed description of the skill's purpose */ description: z.string().optional().describe('Skill description'), + /** + * ADR-0063 §3 / ADR-0064 — skill ↔ agent affinity. Which kernel agent + * surface this skill belongs to: + * + * - `'ask'` — the data product (read/query/explore + run actions). + * - `'build'` — the authoring product (metadata draft → verify → publish). + * - `'both'` — genuinely shared, read-only capability (e.g. a + * `schema_reader` exposing `describe_object`/`list_objects`). + * + * A skill may only bind to an agent whose surface it matches (`'both'` + * matches either); the runtime enforces this at load time. An agent's + * tool set is the union of its surface-compatible skills' tools — there + * is no global fall-through (ADR-0064). Defaults to `'ask'`, the + * open-source/free surface. + */ + surface: z.enum(['ask', 'build', 'both']).default('ask').describe( + "Agent surface this skill binds to ('ask' | 'build' | 'both') — ADR-0063 §3", + ), + /** * Instructions injected into the system prompt when this skill is active. * Guides the LLM on how and when to use the skill's tools. diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index b682505dc8..cd0461286b 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -681,7 +681,14 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ // `agent`: executionPinned — long-running conversations must stick to the // agent version (prompt template + tool set) they started under. // (Mirrors OpenAI Assistants v2 / Anthropic Messages assistant pinning.) - { type: 'agent', label: 'AI Agent', filePatterns: ['**/*.agent.ts', '**/*.agent.yml'], supportsOverlay: false, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: true, loadOrder: 90, domain: 'ai' }, + // + // ADR-0063 §2 — `*.agent.ts` is CLOSED to third parties. The kernel ships + // exactly two platform-owned agents (`ask`/`build`); tenants extend the + // platform by authoring skills + tools, never agents. Hence + // allowRuntimeCreate:false (no runtime "create agent") and + // allowOrgOverride:false (no per-org agent fork). The runtime catalog + // additionally filters out any non-platform agent record (see service-ai). + { type: 'agent', label: 'AI Agent', filePatterns: ['**/*.agent.ts', '**/*.agent.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: false, supportsVersioning: true, executionPinned: true, loadOrder: 90, domain: 'ai' }, { type: 'tool', label: 'AI Tool', filePatterns: ['**/*.tool.ts', '**/*.tool.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 85, domain: 'ai' }, { type: 'skill', label: 'AI Skill', filePatterns: ['**/*.skill.ts', '**/*.skill.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 88, domain: 'ai' }, ];