Skip to content

Commit e411a82

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(ai): split ask/build agents by surface + tool scoping (ADR-0063/0064) (#2181)
* feat(ai): split ask/build agents by surface + tool scoping (ADR-0063/0064) Implements the framework side of #2168. spec: - SkillSchema gains `surface: 'ask'|'build'|'both'` affinity (ADR-0063 §3). - AgentSchema gains `surface: 'ask'|'build'` (the product it binds). - `agent` metadata-type → allowRuntimeCreate:false, allowOrgOverride:false (ADR-0063 §2: *.agent.ts closed to third parties). service-ai (`ask`): - Rename data-chat-agent.ts → ask-agent.ts; DATA_CHAT_AGENT → ASK_AGENT, DEFAULT_DATA_AGENT_NAME → ASK_AGENT_NAME (LEGACY_DATA_AGENT_NAME kept). - `ask` persona is data-only: drop the ADR-0040 unified "INTENT FIRST" build/data classifier; it declines app-building and points at the Builder. - New `schema_reader` (surface:'both') owns the shared read tools describe_object/list_objects/query_data; data_explorer/actions_executor marked surface:'ask' and stop dual-listing the shared reads. - Remove the `buildRegisterActive` degradation shim in agent-runtime. - resolveActiveSkills now hard-fails on incompatible skill↔agent surface (ADR-0064 §3); listAgents filters the catalog to platform agents only. - Tests: tools(ask) carries no create_*/*_metadata/blueprint tools; a surface:'build' skill bound to ask is a load error; surface:'both' binds. Verified: turbo build (spec+service-ai DTS) green; 404/404 service-ai tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: add changeset for ADR-0063/0064 ask/build split Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(spec): classify agent/skill `surface` in the liveness ratchet Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(objectql): agent leaves the overlay whitelist (ADR-0063 §2) agent.allowOrgOverride is now false (tenant custom agents withdrawn), so the registry-derived OVERLAY_ALLOWED_TYPES no longer contains 'agent'. Update the source-of-truth invariant assertion to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a83d2b4 commit e411a82

20 files changed

Lines changed: 477 additions & 233 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-ai": minor
4+
---
5+
6+
feat(ai): split `ask`/`build` agents by surface + tool scoping (ADR-0063/0064).
7+
8+
Two kernel agents bound by surface, not a per-turn classifier. `SkillSchema`
9+
gains `surface: 'ask'|'build'|'both'` and `AgentSchema` gains `surface:
10+
'ask'|'build'` (ADR-0063 §3); an agent's tools are exactly the union of its
11+
surface-compatible skills' tools — incompatible binding is a load error in
12+
`resolveActiveSkills` (ADR-0064 §3). The `ask` agent is now data-only (the
13+
ADR-0040 unified "INTENT FIRST" classifier and the `buildRegisterActive`
14+
degradation shim are removed); a new `schema_reader` (`surface:'both'`) owns
15+
the shared reads `describe_object`/`list_objects`/`query_data` so the build
16+
agent reuses them without dual-listing. `*.agent.ts` is closed to third
17+
parties: the `agent` metadata-type is `allowRuntimeCreate:false,
18+
allowOrgOverride:false` and the runtime catalog lists only platform agents
19+
(ADR-0063 §2). Renames `data-chat-agent.ts`→`ask-agent.ts`,
20+
`DEFAULT_DATA_AGENT_NAME`→`ASK_AGENT_NAME` (the `data_chat`/`metadata_assistant`
21+
aliases stay resolvable).

‎packages/objectql/src/overlay-precedence.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,10 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => {
314314
expect(allowedFromRegistry.has('flow')).toBe(true);
315315
// ADR-0020: `workflow` retired as a metadata type.
316316
expect(allowedFromRegistry.has('workflow')).toBe(false);
317-
expect(allowedFromRegistry.has('agent')).toBe(true);
317+
// ADR-0063 §2: tenant custom agents withdrawn — `agent` is now
318+
// allowOrgOverride:false (no per-org agent fork). The kernel ships
319+
// exactly two platform agents; tenants extend via skills + tools.
320+
expect(allowedFromRegistry.has('agent')).toBe(false);
318321
expect(allowedFromRegistry.has('permission')).toBe(true);
319322
expect(allowedFromRegistry.has('role')).toBe(true);
320323
expect(allowedFromRegistry.has('profile')).toBe(true);

‎packages/services/service-ai/src/__tests__/agent-aliases.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { describe, it, expect, vi } from 'vitest';
1010
import type { IMetadataService } from '@objectstack/spec/contracts';
1111
import { AgentRuntime } from '../agent-runtime.js';
12-
import { DATA_CHAT_AGENT, DEFAULT_DATA_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js';
12+
import { ASK_AGENT, ASK_AGENT_NAME, LEGACY_DATA_AGENT_NAME } from '../agents/index.js';
1313
import { registerAgentAlias, resolveAgentAlias } from '../agents/agent-aliases.js';
1414

1515
function mockMetadata(overrides: Partial<IMetadataService> = {}): IMetadataService {
@@ -28,7 +28,7 @@ function mockMetadata(overrides: Partial<IMetadataService> = {}): IMetadataServi
2828

2929
describe('agent-aliases', () => {
3030
it('seeds the framework data-agent rename', () => {
31-
expect(DEFAULT_DATA_AGENT_NAME).toBe('ask');
31+
expect(ASK_AGENT_NAME).toBe('ask');
3232
expect(LEGACY_DATA_AGENT_NAME).toBe('data_chat');
3333
expect(resolveAgentAlias('data_chat')).toBe('ask');
3434
});
@@ -51,7 +51,7 @@ describe('agent-aliases', () => {
5151
describe('AgentRuntime.loadAgent (alias-aware)', () => {
5252
it('resolves a legacy name to the renamed agent record', async () => {
5353
const get = vi.fn(async (_type: string, name: string) =>
54-
name === DEFAULT_DATA_AGENT_NAME ? DATA_CHAT_AGENT : undefined,
54+
name === ASK_AGENT_NAME ? ASK_AGENT : undefined,
5555
);
5656
const runtime = new AgentRuntime(mockMetadata({ get: get as never }));
5757

‎packages/services/service-ai/src/__tests__/chatbot-features.test.ts‎

Lines changed: 165 additions & 58 deletions
Large diffs are not rendered by default.

‎packages/services/service-ai/src/agent-runtime.ts‎

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type { Agent, Skill } from '@objectstack/spec/ai';
1010
import { AgentSchema } from '@objectstack/spec/ai';
1111
import { SkillRegistry, type SkillContext } from './skill-registry.js';
1212
import { SchemaRetriever, type ObjectShape } from './schema-retriever.js';
13-
import { DEFAULT_DATA_AGENT_NAME } from './agents/index.js';
14-
import { resolveAgentAlias } from './agents/agent-aliases.js';
13+
import { ASK_AGENT_NAME } from './agents/index.js';
14+
import { resolveAgentAlias, platformAgentNames } from './agents/agent-aliases.js';
1515

1616
/**
1717
* Context passed alongside a user message when chatting with an agent.
@@ -74,9 +74,15 @@ export class AgentRuntime {
7474
const rawItems = await this.metadataService.list('agent');
7575
const agents: Array<{ name: string; label: string; role: string }> = [];
7676

77+
// ADR-0063 §2 — the runtime catalog surfaces only platform-owned agents
78+
// (`ask`/`build`). Tenant custom agents are withdrawn; any stray custom
79+
// record persisted before the withdrawal is filtered out here so it never
80+
// appears in the picker or the agents list.
81+
const platform = platformAgentNames();
82+
7783
for (const raw of rawItems) {
7884
const result = AgentSchema.safeParse(raw);
79-
if (result.success && result.data.active) {
85+
if (result.success && result.data.active && platform.has(result.data.name)) {
8086
agents.push({
8187
name: result.data.name,
8288
label: result.data.label,
@@ -165,33 +171,12 @@ export class AgentRuntime {
165171
if (block) parts.push(block);
166172
}
167173

168-
// Authoring (build) register availability. The unified `data_chat` persona
169-
// (ADR-0040) advertises that it can BUILD or CHANGE the application, but
170-
// that capability is supplied ENTIRELY by the cloud AI Studio plugin's
171-
// `metadata_authoring` / `solution_design` skills (and their tools). On the
172-
// open single-env framework those skills are not registered, so the
173-
// authoring tools never resolve — yet the LLM, still reading the "you can
174-
// build" persona, will role-play designing a whole system (emitting design
175-
// docs it has no tools to execute). When the build register is absent,
176-
// constrain the assistant to data/query and have it decline build requests
177-
// instead of pretending. Keyed off actual skill presence so cloud/EE
178-
// (AI Studio loaded) keeps the full build UX with no extra wiring.
179-
const buildRegisterActive = !!activeSkills?.some(
180-
(s) => s.name === 'metadata_authoring' || s.name === 'solution_design',
181-
);
182-
if (!buildRegisterActive) {
183-
parts.push(
184-
'\n--- Capabilities in this deployment ---\n' +
185-
'Application BUILDING / AUTHORING is NOT available here. You can ONLY answer questions ' +
186-
"about the user's existing data (query, list, count, aggregate, search) and run actions " +
187-
'the application already exposes. You CANNOT create, change, or design objects, fields, ' +
188-
'views, dashboards, pages, or whole apps — and you have no tools to do so. If the user ' +
189-
'asks you to build, create, develop, or modify the application itself, do NOT design or ' +
190-
'outline a system and do NOT pretend to build one: briefly say that AI app-building is ' +
191-
'not available in this edition, then offer to help explore or report on existing data ' +
192-
"instead. Answer in the user's language.",
193-
);
194-
}
174+
// NOTE (ADR-0063): the per-deployment "build register availability"
175+
// degradation shim was removed here. The two agents are now separated by
176+
// surface — `ask` never advertises authoring (its persona and tool set
177+
// carry none), so there is no "you can build" claim to walk back when the
178+
// cloud authoring skills are absent. `build` only exists where the cloud
179+
// package is loaded. No runtime capability-gating is needed.
195180

196181
return [{ role: 'system' as const, content: parts.join('\n') }];
197182
}
@@ -322,7 +307,24 @@ export class AgentRuntime {
322307
async resolveActiveSkills(agent: Agent, context?: AgentChatContext): Promise<Skill[]> {
323308
if (!this.skillRegistry) return [];
324309
if (!agent.skills || agent.skills.length === 0) return [];
325-
return this.skillRegistry.listActiveSkills(context ?? {}, agent.skills);
310+
const skills = await this.skillRegistry.listActiveSkills(context ?? {}, agent.skills);
311+
// ADR-0064 §3 — affinity is a checked invariant, not an emergent property.
312+
// A skill may only bind to an agent whose surface it matches (`'both'`
313+
// binds to either). An incompatible binding is a fast load error so that
314+
// "ask can't author" can never regress to a silent mis-scope. Default
315+
// both sides to `'ask'` for records that predate the `surface` field.
316+
const agentSurface = agent.surface ?? 'ask';
317+
for (const skill of skills) {
318+
const skillSurface = skill.surface ?? 'ask';
319+
if (skillSurface !== 'both' && skillSurface !== agentSurface) {
320+
throw new Error(
321+
`Skill "${skill.name}" (surface: '${skillSurface}') cannot bind to agent ` +
322+
`"${agent.name}" (surface: '${agentSurface}') — incompatible affinity (ADR-0064 §3). ` +
323+
`A skill may only bind to an agent whose surface it matches, or declare surface: 'both'.`,
324+
);
325+
}
326+
}
327+
return skills;
326328
}
327329

328330
/**
@@ -331,12 +333,12 @@ export class AgentRuntime {
331333
*
332334
* Resolution order:
333335
* 1. The `defaultAgent` of the app named by `context.appName`
334-
* (e.g. Studio → `metadata_assistant`).
335-
* 2. The platform data-query agent (`data_chat`) — the implicit
336-
* copilot bound to every app that doesn't pin its own. This is
337-
* what end users get by default, so they never have to choose.
336+
* (e.g. Studio → the `build` agent).
337+
* 2. The platform `ask` (data) agent — the implicit copilot bound to
338+
* every app that doesn't pin its own. This is what end users get by
339+
* default, so they never have to choose.
338340
* 3. The first active agent in the registry (last-resort fallback,
339-
* e.g. in stripped-down deployments without the data agent).
341+
* e.g. in stripped-down deployments without the `ask` agent).
340342
* 4. `undefined` if no agents are registered.
341343
*/
342344
async resolveDefaultAgent(context?: AgentChatContext): Promise<Agent | undefined> {
@@ -349,11 +351,11 @@ export class AgentRuntime {
349351
}
350352
}
351353

352-
// Platform default: the data-query agent is the implicit copilot for
354+
// Platform default: the `ask` (data) agent is the implicit copilot for
353355
// every app without an explicit `defaultAgent`. Resolve it by name so
354356
// the fallback is deterministic rather than registration-order
355357
// dependent.
356-
const dataAgent = await this.loadAgent(DEFAULT_DATA_AGENT_NAME);
358+
const dataAgent = await this.loadAgent(ASK_AGENT_NAME);
357359
if (dataAgent && dataAgent.active !== false) return dataAgent;
358360

359361
// Last resort: first active agent in declaration order.

‎packages/services/service-ai/src/agents/agent-aliases.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,17 @@ export function resolveAgentAlias(name: string): string {
4646
export function agentAliasEntries(): Array<[string, string]> {
4747
return Array.from(AGENT_NAME_ALIASES.entries());
4848
}
49+
50+
/**
51+
* The set of platform-owned, canonical agent ids known to this process.
52+
*
53+
* ADR-0063 §2 closes `*.agent.ts` to third parties: the kernel ships exactly
54+
* the two platform agents (`ask`, and — where the cloud package is loaded —
55+
* `build`), each of which registers its own legacy→canonical alias. The alias
56+
* table's *values* are therefore precisely the canonical platform-agent ids,
57+
* so the runtime catalog can use this set to filter out any stray custom
58+
* agent record (e.g. one persisted before tenant agents were withdrawn).
59+
*/
60+
export function platformAgentNames(): Set<string> {
61+
return new Set(AGENT_NAME_ALIASES.values());
62+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Agent } from '@objectstack/spec/ai';
4+
5+
/**
6+
* Built-in `ask` agent — the **data product** (≈ Claude Chat).
7+
*
8+
* Per ADR-0063 the kernel ships exactly two agents, bound by *surface*:
9+
* - `ask` — conversational read/query/explore over records + run the
10+
* business actions the app already exposes. End-user audience,
11+
* RLS-bounded, fast turns. Open-source · free (this package).
12+
* - `build` — agentic authoring of *metadata* (objects, fields, views,
13+
* flows) through plan → draft → verify → publish. Builder
14+
* audience, governance-gated. Cloud-only · paid
15+
* (`@objectstack/service-ai-studio`).
16+
*
17+
* The user never picks an agent — the surface they are in binds it (data
18+
* console → `ask`, Studio → `build`). There is no per-turn intent classifier:
19+
* a `build`-shaped request arriving at `ask` is declined and redirected to the
20+
* builder, never silently re-routed into authoring (ADR-0063 §1/§5).
21+
*
22+
* Following the platform's metadata-driven philosophy, this agent does not
23+
* hardcode the tools it can call. Its tool set is the union of its skills'
24+
* tools (ADR-0064): `schema_reader` (shared read-only schema/query tools),
25+
* `data_explorer` (records + aggregation + charts), and `actions_executor`
26+
* (business actions). Authoring tools are *not* in this set — `ask` cannot
27+
* author, by construction.
28+
*
29+
* @example
30+
* ```
31+
* POST /api/v1/ai/agents/ask/chat
32+
* {
33+
* "messages": [{ "role": "user", "content": "Show me all active accounts" }],
34+
* "context": { "objectName": "account" }
35+
* }
36+
* ```
37+
*/
38+
39+
/**
40+
* Canonical name of the platform's `ask` (data) agent.
41+
*
42+
* This is the implicit default copilot for every application that does not
43+
* pin its own `app.defaultAgent`. Studio is the only built-in app that
44+
* overrides it (→ the `build` authoring agent). Keeping the name as an
45+
* exported constant lets the runtime resolve the fallback deterministically
46+
* instead of guessing "first active agent".
47+
*
48+
* Renamed from `data_chat`→`ask`; the legacy name stays resolvable via the
49+
* alias table (see `agent-aliases.ts`).
50+
*/
51+
export const ASK_AGENT_NAME = 'ask';
52+
53+
/** Legacy id this agent was renamed from (kept for back-compat / migrations). */
54+
export const LEGACY_DATA_AGENT_NAME = 'data_chat';
55+
56+
export const ASK_AGENT: Agent = {
57+
name: ASK_AGENT_NAME,
58+
label: 'Assistant',
59+
role: 'Business Application Assistant',
60+
// ADR-0063 — the `ask` data product. This persona ONLY answers questions
61+
// about the user's data and runs business actions the app exposes. It does
62+
// NOT build or change the application; app-building lives on the separate
63+
// `build` agent (cloud Builder/Studio). There is no per-turn intent
64+
// classifier — the surface bound this agent (ADR-0063 §1).
65+
surface: 'ask',
66+
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).
67+
68+
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.
69+
70+
Always answer in the same language the user is using. Detailed tool-usage guidance is supplied by the skills attached to this agent.`,
71+
72+
model: {
73+
provider: 'openai',
74+
model: 'gpt-4',
75+
// Low temperature: data answers should be deterministic and grounded.
76+
temperature: 0.2,
77+
maxTokens: 4096,
78+
},
79+
80+
// Capability bundles live on skills; the agent only references them.
81+
// `schema_reader` (surface:'both') = shared read-only schema/query tools;
82+
// `data_explorer` + `actions_executor` (surface:'ask') = the data product's
83+
// exploration and action tools. No authoring skills — those are `build`'s
84+
// and only exist on the cloud package (ADR-0063 §5 / ADR-0064).
85+
skills: ['schema_reader', 'data_explorer', 'actions_executor'],
86+
87+
active: true,
88+
visibility: 'global',
89+
90+
guardrails: {
91+
maxTokensPerInvocation: 8192,
92+
// Data answers + actions; no long-running authoring loop here.
93+
maxExecutionTimeSec: 30,
94+
blockedTopics: ['delete_records', 'drop_database', 'raw_sql', 'system_tables'],
95+
},
96+
97+
planning: {
98+
strategy: 'react',
99+
maxIterations: 10,
100+
allowReplan: true,
101+
},
102+
};

0 commit comments

Comments
 (0)