Skip to content

Commit 2a18188

Browse files
hotlongclaude
andcommitted
fix(ai): author the canonical agent id everywhere the platform teaches one (#14461)
`skills/objectstack-ai` tells authors `data_chat` / `metadata_assistant` are "not vocabulary — always write `ask` / `build`", and every live example the platform shipped taught the opposite. Measured rather than assumed — the card left this undecidable because the consumer lives in the closed `cloud` repo. At `cloud` main@3856fbf7, `service-ai-studio/src/agents/metadata-assistant-agent.ts:12,40` ships the record as `name: BUILD_AGENT_NAME` = 'build' and `plugin.ts:58` registers `metadata_assistant` as a one-way, resolution-only legacy alias. The canonical id IS `build`; the reading that made this a ruling is falsified. - `studio.app.ts` — the repo's only `app.defaultAgent` usage, re-pinned from the alias to `'build'`. Not cosmetic: alias resolution depends on an in-memory `registerAgentAlias` call having run, which cloud documents silently no-op'ing under bundle load ordering; the canonical id never touches the alias table. - `mcp-server-runtime.ts` — the `agent_prompt` `agentName` example named two retired aliases and neither canonical id, to every MCP client that asked. - `validate-ai-agent-authoring` — the value limb reused the four-name roster, so the gate deliberately passed the spelling the catalogue bans. The two limbs now read different tables: declaration keeps all four (shadowing is shadowing), value takes `ask` / `build` with legacy aliases under their own rule id `default-agent-legacy-alias`, because an alias resolves and an unknown name does not. #6041's operative decisions are intact — warning tier, no Zod enum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent df657d9 commit 2a18188

6 files changed

Lines changed: 262 additions & 25 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/lint": patch
4+
"@objectstack/mcp": patch
5+
---
6+
7+
fix(ai): author the CANONICAL agent id everywhere the platform teaches one — Studio's pin, the MCP prompt example, and the lint's value roster (#14461)
8+
9+
`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
10+
"are **not** vocabulary — always write `ask` / `build`". The platform then
11+
taught the opposite from every live example it ships. Nothing was broken at
12+
runtime; what was wrong is what an author copies.
13+
14+
**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
15+
and it spelled the alias:
16+
17+
```
18+
- defaultAgent: 'metadata_assistant',
19+
+ defaultAgent: 'build',
20+
```
21+
22+
The triage card left this undecidable — if the cloud plugin registered the
23+
agent under the legacy id, re-pinning would be a behaviour change in a
24+
consumer this repo cannot see. Measured instead of assumed, at `cloud`
25+
`main@3856fbf7`: `service-ai-studio/src/agents/metadata-assistant-agent.ts:12,40`
26+
ships the record as `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58`
27+
registers `metadata_assistant` as a **one-way, resolution-only** legacy alias.
28+
The canonical id *is* `build`; the old pin reached it by detour.
29+
30+
Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
31+
`registerAgentAlias` call having run at plugin init, and cloud carries two
32+
defensive docblocks about that registration silently no-op'ing for real under
33+
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
34+
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
35+
real platform agent like `build`"). The canonical id never touches the alias
36+
table, so this drops a load-order dependency from the platform's own flagship
37+
authoring surface. On the UI side nothing moves: `objectui`'s
38+
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
39+
`SURFACE_DEFAULT['studio-build']` was already `'build'`.
40+
41+
**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
42+
described itself as `'Name of the agent to load (e.g. "data_chat",
43+
"metadata_assistant")'` — two retired aliases, neither canonical id present.
44+
That string is served to every MCP client asking what to pass, so the one
45+
surface that suggests a spelling to an LLM suggested the two the catalogue
46+
forbids. Now `(e.g. "ask", "build")`.
47+
48+
**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
49+
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
50+
deliberately passed `metadata_assistant` — the gate that exists to make
51+
authoring mistakes loud waved through the exact spelling the catalogue bans,
52+
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
53+
gate itself. The two limbs now read different tables, because they ask
54+
different questions:
55+
56+
- **declaration limb** — unchanged, still all four names. Declaring
57+
`metadata_assistant` shadows the `build` record through the alias exactly as
58+
declaring `build` does.
59+
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
60+
rule id `default-agent-legacy-alias` (exported) and its own wording, because
61+
an alias **resolves** (the app gets the agent it meant — a spelling defect)
62+
while an unknown name does **not** (the pin is inert). Describing the alias
63+
as "no effect" would send an author hunting a bug that is not there.
64+
65+
Both of the #6041 ruling's operative decisions are kept intact: still
66+
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
67+
keeps parsing, building, and resolving — the only change is that authoring it
68+
now says so.
69+
70+
Not breaking: nothing an author can write was removed, and both aliases stay
71+
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
72+
ADR-0063 §2 ever gave them.

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,7 @@ export {
623623
validateAiAgentAuthoring,
624624
AGENT_AUTHORING_WITHDRAWN,
625625
DEFAULT_AGENT_OUTSIDE_ROSTER,
626+
DEFAULT_AGENT_LEGACY_ALIAS,
626627
} from './validate-ai-agent-authoring.js';
627628
export type {
628629
AiAgentAuthoringFinding,

packages/lint/src/validate-ai-agent-authoring.test.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
validateAiAgentAuthoring,
66
AGENT_AUTHORING_WITHDRAWN,
77
DEFAULT_AGENT_OUTSIDE_ROSTER,
8+
DEFAULT_AGENT_LEGACY_ALIAS,
89
} from './validate-ai-agent-authoring.js';
910

1011
describe('validate-ai-agent-authoring', () => {
@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
8687
});
8788
// Names the offending value.
8889
expect(findings[0].message).toContain('"sales_copilot"');
89-
// Names the allowed set (canonical + legacy aliases).
90+
// Names the allowed set — the CANONICAL two only (#14461). The legacy
91+
// aliases must not appear here: this string is the prescription, and
92+
// offering `metadata_assistant` as a thing to write is the very defect
93+
// #14461 closed.
9094
expect(findings[0].message).toContain('ask');
9195
expect(findings[0].message).toContain('build');
92-
expect(findings[0].message).toContain('data_chat');
93-
expect(findings[0].message).toContain('metadata_assistant');
96+
expect(findings[0].message).not.toContain('data_chat');
97+
expect(findings[0].message).not.toContain('metadata_assistant');
9498
expect(findings[0].hint).toContain('ask');
9599
expect(findings[0].hint).toContain('build');
100+
expect(findings[0].hint).not.toContain('metadata_assistant');
96101
});
97102

98-
it('passes every canonical platform agent name and every legacy alias', () => {
99-
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
103+
it('passes the canonical platform agent names', () => {
104+
for (const defaultAgent of ['ask', 'build']) {
100105
const stack = { apps: [{ name: 'app', defaultAgent }] };
101106
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
102107
}
103108
});
104109

110+
describe('legacy alias values (issue #14461)', () => {
111+
// Studio itself pinned `metadata_assistant` while the published skill
112+
// told authors never to write it, and this rule — reusing the four-name
113+
// roster — waved the alias through. The value limb now judges against
114+
// the canonical two, and an alias gets its own id and prescription.
115+
it.each([
116+
['metadata_assistant', 'build'],
117+
['data_chat', 'ask'],
118+
])('flags %s and prescribes %s', (alias, canonical) => {
119+
const findings = validateAiAgentAuthoring({
120+
apps: [{ name: 'studio', defaultAgent: alias }],
121+
});
122+
expect(findings).toHaveLength(1);
123+
expect(findings[0]).toMatchObject({
124+
severity: 'warning',
125+
rule: DEFAULT_AGENT_LEGACY_ALIAS,
126+
where: 'app "studio".defaultAgent',
127+
path: 'apps[0].defaultAgent',
128+
});
129+
expect(findings[0].message).toContain(`"${alias}"`);
130+
expect(findings[0].message).toContain(`"${canonical}"`);
131+
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
132+
});
133+
134+
it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
135+
// The distinction the separate rule id exists to carry: an unknown
136+
// name is inert at runtime, an alias is not. A message that described
137+
// the alias as "no effect" would be false, and an author who read it
138+
// would go looking for a bug that is not there.
139+
const [alias] = validateAiAgentAuthoring({
140+
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
141+
});
142+
const [unknown] = validateAiAgentAuthoring({
143+
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
144+
});
145+
expect(alias.message).toContain('still resolves');
146+
expect(alias.message).not.toContain('has no effect');
147+
expect(unknown.message).toContain('has no effect');
148+
});
149+
150+
it('leaves the DECLARATION limb reading all four names', () => {
151+
// The two limbs ask different questions, so they keep different
152+
// rosters: declaring `metadata_assistant` shadows the `build` record
153+
// through the alias exactly as declaring `build` does, and that
154+
// judgement is unchanged by #14461.
155+
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
156+
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
157+
expect(findings, name).toHaveLength(1);
158+
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
159+
expect(findings[0].message, name).toContain('PLATFORM agent id');
160+
}
161+
});
162+
});
163+
105164
it('is silent when defaultAgent is absent, empty, or not a string', () => {
106165
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
107166
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);

packages/lint/src/validate-ai-agent-authoring.ts

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,45 @@
4242
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
4343
* schema to an enum (a breaking authoring change ADR-0063 already walked
4444
* back once).
45+
*
46+
* ## Why the value limb no longer reads the same roster (issue #14461)
47+
*
48+
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
49+
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
50+
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
51+
* platform then taught it from its own only live example: `studio.app.ts`
52+
* pinned the alias. So an AI author copying the one working example in the
53+
* repo wrote the forbidden spelling and this rule waved it through — the
54+
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
55+
* itself.
56+
*
57+
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
58+
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
59+
* intact (warning tier, no Zod enum):
60+
*
61+
* - the DECLARATION limb still reads all four names. Its question is "does
62+
* this record shadow a platform record?", and declaring `metadata_assistant`
63+
* shadows `build` through the alias exactly as declaring `build` does. That
64+
* judgement is unchanged.
65+
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
66+
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
67+
* Its question is "is this the right thing to WRITE?", and the answer for
68+
* an alias is no even though it resolves.
69+
*
70+
* The alias limb stays `warning`, not `error`, and for a sharper reason than
71+
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
72+
* agent it meant, and nothing a user can see is wrong — which is precisely why
73+
* the signal has to be an authoring-time nudge rather than a build break.
4574
*/
4675

4776
export const AGENT_AUTHORING_WITHDRAWN = 'agent-authoring-withdrawn';
4877

4978
/** `app.defaultAgent` names something outside the platform agent roster. */
5079
export const DEFAULT_AGENT_OUTSIDE_ROSTER = 'default-agent-outside-roster';
5180

81+
/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
82+
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';
83+
5284
export type AiAgentAuthoringSeverity = 'error' | 'warning';
5385

5486
export interface AiAgentAuthoringFinding {
@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
81113
}
82114

83115
/**
84-
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
85-
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
86-
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
87-
* four names is doing something different from inventing a custom persona
88-
* (it is shadowing a platform record, directly or through its alias), so it
89-
* gets its own wording.
116+
* The two platform agent ids — the only two names that are AUTHORING
117+
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
118+
* limb judges against.
90119
*/
91-
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
120+
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];
121+
122+
/**
123+
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
124+
* `metadata_assistant` → `build`), registered one-way in the cloud alias
125+
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
126+
* separate records, and the agent catalog shows each agent once under its
127+
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
128+
* never for new authoring (#14461).
129+
*/
130+
const LEGACY_AGENT_ALIASES = new Map<string, string>([
131+
['data_chat', 'ask'],
132+
['metadata_assistant', 'build'],
133+
]);
134+
135+
/**
136+
* Every name that refers to a platform agent, canonically or through its
137+
* alias. A stack that re-declares any of these four is doing something
138+
* different from inventing a custom persona (it is shadowing a platform
139+
* record, directly or through its alias), so it gets its own wording.
140+
*
141+
* Deliberately NOT the roster the value limb reads — see the docblock's
142+
* "#14461" section for why the two questions take different tables.
143+
*/
144+
const PLATFORM_AGENT_NAMES = new Set<string>([
145+
...CANONICAL_AGENT_NAMES,
146+
...LEGACY_AGENT_ALIASES.keys(),
147+
]);
92148

93149
/**
94150
* Flag every agent declared in a stack. Returns findings (empty = clean,
@@ -132,25 +188,51 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding
132188
});
133189
}
134190

135-
const roster = [...PLATFORM_AGENT_NAMES].join(', ');
191+
const roster = CANONICAL_AGENT_NAMES.join(', ');
136192
const apps = asArray(stack.apps);
137193
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
138194
const app = apps[appIdx];
139195
const defaultAgent = strName(app.defaultAgent);
140-
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
196+
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;
141197

142198
const appName = strName(app.name) ?? `#${appIdx}`;
199+
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);
200+
201+
// [#14461] Two different defects share this slot, and collapsing them
202+
// would misdescribe both: an alias RESOLVES (the app gets the agent it
203+
// meant) and an unknown name does NOT (the pin is inert). Separate rule
204+
// ids so a consumer can act on them separately.
205+
if (canonical) {
206+
findings.push({
207+
severity: 'warning',
208+
rule: DEFAULT_AGENT_LEGACY_ALIAS,
209+
where: `app "${appName}".defaultAgent`,
210+
path: `apps[${appIdx}].defaultAgent`,
211+
message:
212+
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
213+
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
214+
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
215+
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
216+
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
217+
`registration having run, which the canonical id does not.`,
218+
hint:
219+
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
220+
`authoring vocabulary — always author the canonical id (${roster}).`,
221+
});
222+
continue;
223+
}
224+
143225
findings.push({
144226
severity: 'warning',
145227
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
146228
where: `app "${appName}".defaultAgent`,
147229
path: `apps[${appIdx}].defaultAgent`,
148230
message:
149-
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
150-
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
151-
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
152-
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
153-
`the pin has no effect and the value drifts from what actually serves the app.`,
231+
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
232+
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
233+
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
234+
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
235+
`the value drifts from what actually serves the app.`,
154236
hint:
155237
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
156238
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +

packages/mcp/src/mcp-server-runtime.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
13461346
description: 'Load an agent\'s system prompt with optional UI context. ' +
13471347
'Use the agentName argument to select which agent\'s instructions to use.',
13481348
argsSchema: {
1349-
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
1349+
// [#14461] The example names the two CANONICAL platform agent ids.
1350+
// It used to read `"data_chat", "metadata_assistant"` — both retired
1351+
// aliases, neither canonical id present — so every MCP client asking
1352+
// what to pass was taught the exact two spellings
1353+
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
1354+
// one-way alias registry), so nothing broke; what was wrong is that
1355+
// this is the suggestion an author copies.
1356+
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
13501357
objectName: z.string().optional().describe('Current object the user is viewing'),
13511358
recordId: z.string().optional().describe('Currently selected record ID'),
13521359
viewName: z.string().optional().describe('Current view name'),

packages/platform-objects/src/apps/studio.app.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
4343
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
4444
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
4545
},
46-
// Studio is the metadata-authoring host, so its ambient copilot is
47-
// pinned to the schema-architect agent. Resolved by the ambient chat
48-
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
49-
// needed. Every other app falls back to the data-query agent.
50-
defaultAgent: 'metadata_assistant',
46+
// Studio is the metadata-authoring host, so its ambient copilot is pinned
47+
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
48+
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
49+
// other app falls back to `ask`, the data-query agent.
50+
//
51+
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
52+
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
53+
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
54+
// example. `build` is not a rename in flight — it is the record's canonical
55+
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
56+
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
57+
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
58+
//
59+
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
60+
// `registerAgentAlias` call has actually run, and cloud carries two
61+
// defensive docblocks about that registration silently no-op'ing under
62+
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
63+
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
64+
// the alias table, so this drops a load-order dependency from the
65+
// platform's own flagship authoring surface.
66+
defaultAgent: 'build',
5167
branding: {
5268
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
5369
},

0 commit comments

Comments
 (0)