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
21 changes: 21 additions & 0 deletions .changeset/adr-0063-0064-ask-build-surface.md
Original file line numberDiff line numberDiff line change
@@ -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).
5 changes: 4 additions & 1 deletion packages/objectql/src/overlay-precedence.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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> = {}): IMetadataService {
Expand All@@ -28,7 +28,7 @@ function mockMetadata(overrides: Partial<IMetadataService> = {}): 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');
});
Expand All@@ -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 }));

Expand Down
223 changes: 165 additions & 58 deletions packages/services/service-ai/src/__tests__/chatbot-features.test.ts

Large diffs are not rendered by default.

78 changes: 40 additions & 38 deletions packages/services/service-ai/src/agent-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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') }];
}
Expand DownExpand Up@@ -322,7 +307,24 @@ export class AgentRuntime {
async resolveActiveSkills(agent: Agent, context?: AgentChatContext): Promise<Skill[]> {
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;
}

/**
Expand All@@ -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<Agent | undefined> {
Expand All@@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/services/service-ai/src/agents/agent-aliases.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string> {
return new Set(AGENT_NAME_ALIASES.values());
}
102 changes: 102 additions & 0 deletions packages/services/service-ai/src/agents/ask-agent.ts
Original file line numberDiff line numberDiff line change
@@ -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,
},
};
Loading