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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
72 changes: 72 additions & 0 deletions .changeset/default-agent-canonical-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/lint": patch
"@objectstack/mcp": patch
---

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)

`skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
"are **not** vocabulary — always write `ask` / `build`". The platform then
taught the opposite from every live example it ships. Nothing was broken at
runtime; what was wrong is what an author copies.

**Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
and it spelled the alias:

```
- defaultAgent: 'metadata_assistant',
+ defaultAgent: 'build',
```

The triage card left this undecidable — if the cloud plugin registered the
agent under the legacy id, re-pinning would be a behaviour change in a
consumer this repo cannot see. Measured instead of assumed, 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 old pin reached it by detour.

Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
`registerAgentAlias` call having run at plugin init, and cloud carries two
defensive docblocks about that registration silently no-op'ing for real under
bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
`service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
real platform agent like `build`"). The canonical id never touches the alias
table, so this drops a load-order dependency from the platform's own flagship
authoring surface. On the UI side nothing moves: `objectui`'s
`AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
`SURFACE_DEFAULT['studio-build']` was already `'build'`.

**The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
described itself as `'Name of the agent to load (e.g. "data_chat",
"metadata_assistant")'` — two retired aliases, neither canonical id present.
That string is served to every MCP client asking what to pass, so the one
surface that suggests a spelling to an LLM suggested the two the catalogue
forbids. Now `(e.g. "ask", "build")`.

**The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
**value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
deliberately passed `metadata_assistant` — the gate that exists to make
authoring mistakes loud waved through the exact spelling the catalogue bans,
which is the silent-tolerance shape ADR-0078 exists to close, committed by the
gate itself. The two limbs now read different tables, because they ask
different questions:

- **declaration limb** — unchanged, still all four names. Declaring
`metadata_assistant` shadows the `build` record through the alias exactly as
declaring `build` does.
- **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
rule id `default-agent-legacy-alias` (exported) and its own wording, because
an alias **resolves** (the app gets the agent it meant — a spelling defect)
while an unknown name does **not** (the pin is inert). Describing the alias
as "no effect" would send an author hunting a bug that is not there.

Both of the #6041 ruling's operative decisions are kept intact: still
`warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
keeps parsing, building, and resolving — the only change is that authoring it
now says so.

Not breaking: nothing an author can write was removed, and both aliases stay
resolvable for old bookmarks and persisted `agent_id`s, which is the only job
ADR-0063 §2 ever gave them.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -623,6 +623,7 @@ export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
69 changes: 64 additions & 5 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_LEGACY_ALIAS,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand DownExpand Up@@ -86,22 +87,80 @@ describe('validate-ai-agent-authoring', () => {
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
// Names the allowed set — the CANONICAL two only (#14461). The legacy
// aliases must not appear here: this string is the prescription, and
// offering `metadata_assistant` as a thing to write is the very defect
// #14461 closed.
expect(findings[0].message).toContain('ask');
expect(findings[0].message).toContain('build');
expect(findings[0].message).toContain('data_chat');
expect(findings[0].message).toContain('metadata_assistant');
expect(findings[0].message).not.toContain('data_chat');
expect(findings[0].message).not.toContain('metadata_assistant');
expect(findings[0].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
expect(findings[0].hint).not.toContain('metadata_assistant');
});

it('passes every canonical platform agent name and every legacy alias', () => {
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
it('passes the canonical platform agent names', () => {
for (const defaultAgent of ['ask', 'build']) {
const stack = { apps: [{ name: 'app', defaultAgent }] };
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
}
});

describe('legacy alias values (issue #14461)', () => {
// Studio itself pinned `metadata_assistant` while the published skill
// told authors never to write it, and this rule — reusing the four-name
// roster — waved the alias through. The value limb now judges against
// the canonical two, and an alias gets its own id and prescription.
it.each([
['metadata_assistant', 'build'],
['data_chat', 'ask'],
])('flags %s and prescribes %s', (alias, canonical) => {
const findings = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: alias }],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: 'app "studio".defaultAgent',
path: 'apps[0].defaultAgent',
});
expect(findings[0].message).toContain(`"${alias}"`);
expect(findings[0].message).toContain(`"${canonical}"`);
expect(findings[0].hint).toContain(`defaultAgent: '${canonical}'`);
});

it('says the alias RESOLVES — it is a spelling defect, not a broken pin', () => {
// The distinction the separate rule id exists to carry: an unknown
// name is inert at runtime, an alias is not. A message that described
// the alias as "no effect" would be false, and an author who read it
// would go looking for a bug that is not there.
const [alias] = validateAiAgentAuthoring({
apps: [{ name: 'studio', defaultAgent: 'metadata_assistant' }],
});
const [unknown] = validateAiAgentAuthoring({
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
});
expect(alias.message).toContain('still resolves');
expect(alias.message).not.toContain('has no effect');
expect(unknown.message).toContain('has no effect');
});

it('leaves the DECLARATION limb reading all four names', () => {
// The two limbs ask different questions, so they keep different
// rosters: declaring `metadata_assistant` shadows the `build` record
// through the alias exactly as declaring `build` does, and that
// judgement is unchanged by #14461.
for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
const findings = validateAiAgentAuthoring({ agents: [{ name }] });
expect(findings, name).toHaveLength(1);
expect(findings[0].rule, name).toBe(AGENT_AUTHORING_WITHDRAWN);
expect(findings[0].message, name).toContain('PLATFORM agent id');
}
});
});

it('is silent when defaultAgent is absent, empty, or not a string', () => {
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
Expand Down
110 changes: 96 additions & 14 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,45 @@
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
* schema to an enum (a breaking authoring change ADR-0063 already walked
* back once).
*
* ## Why the value limb no longer reads the same roster (issue #14461)
*
* `PLATFORM_AGENT_NAMES` holds FOUR names, and reusing it for the value limb
* meant this gate accepted `defaultAgent: 'metadata_assistant'` — the exact
* spelling `skills/objectstack-ai` tells authors is "not vocabulary". The
* platform then taught it from its own only live example: `studio.app.ts`
* pinned the alias. So an AI author copying the one working example in the
* repo wrote the forbidden spelling and this rule waved it through — the
* silent-tolerance shape ADR-0078 exists to close, committed by the gate
* itself.
*
* The maintainer ruling on #14461 (2026-09-03) re-pins Studio to `build` and
* SPLITS the two limbs' rosters, keeping both of #6041's operative decisions
* intact (warning tier, no Zod enum):
*
* - the DECLARATION limb still reads all four names. Its question is "does
* this record shadow a platform record?", and declaring `metadata_assistant`
* shadows `build` through the alias exactly as declaring `build` does. That
* judgement is unchanged.
* - the VALUE limb reads `CANONICAL_AGENT_NAMES` only, and a legacy alias
* gets its own rule id and wording ({@link DEFAULT_AGENT_LEGACY_ALIAS}).
* Its question is "is this the right thing to WRITE?", and the answer for
* an alias is no even though it resolves.
*
* The alias limb stays `warning`, not `error`, and for a sharper reason than
* the roster limb: an aliased pin is not broken. It resolves, the app gets the
* agent it meant, and nothing a user can see is wrong — which is precisely why
* the signal has to be an authoring-time nudge rather than a build break.
*/

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

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

/** `app.defaultAgent` spells a platform agent by its RETIRED alias (#14461). */
export const DEFAULT_AGENT_LEGACY_ALIAS = 'default-agent-legacy-alias';

export type AiAgentAuthoringSeverity = 'error' | 'warning';

export interface AiAgentAuthoringFinding {
Expand DownExpand Up@@ -81,14 +113,38 @@ function strName(v: unknown): string | undefined {
}

/**
* The two platform agent ids (`ask`, `build`) plus their two legacy aliases
* (`data_chat` → `ask`, `metadata_assistant` → `build`, registered via the
* cloud alias registry — ADR-0063 §2). A stack that re-declares any of these
* four names is doing something different from inventing a custom persona
* (it is shadowing a platform record, directly or through its alias), so it
* gets its own wording.
* The two platform agent ids — the only two names that are AUTHORING
* vocabulary (ADR-0063 §1). This is the roster the `app.defaultAgent` value
* limb judges against.
*/
const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']);
const CANONICAL_AGENT_NAMES: readonly string[] = ['ask', 'build'];

/**
* Retired spellings → the canonical id each resolves to (`data_chat` → `ask`,
* `metadata_assistant` → `build`), registered one-way in the cloud alias
* registry at plugin init — ADR-0063 §2. Resolution-only: they are not
* separate records, and the agent catalog shows each agent once under its
* canonical name. Kept resolvable for old bookmarks and persisted `agent_id`s;
* never for new authoring (#14461).
*/
const LEGACY_AGENT_ALIASES = new Map<string, string>([
['data_chat', 'ask'],
['metadata_assistant', 'build'],
]);

/**
* Every name that refers to a platform agent, canonically or through its
* alias. A stack that re-declares any of these four is doing something
* different from inventing a custom persona (it is shadowing a platform
* record, directly or through its alias), so it gets its own wording.
*
* Deliberately NOT the roster the value limb reads — see the docblock's
* "#14461" section for why the two questions take different tables.
*/
const PLATFORM_AGENT_NAMES = new Set<string>([
...CANONICAL_AGENT_NAMES,
...LEGACY_AGENT_ALIASES.keys(),
]);

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

const roster = [...PLATFORM_AGENT_NAMES].join(', ');
const roster = CANONICAL_AGENT_NAMES.join(', ');
const apps = asArray(stack.apps);
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
const app = apps[appIdx];
const defaultAgent = strName(app.defaultAgent);
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
if (!defaultAgent || CANONICAL_AGENT_NAMES.includes(defaultAgent)) continue;

const appName = strName(app.name) ?? `#${appIdx}`;
const canonical = LEGACY_AGENT_ALIASES.get(defaultAgent);

// [#14461] Two different defects share this slot, and collapsing them
// would misdescribe both: an alias RESOLVES (the app gets the agent it
// meant) and an unknown name does NOT (the pin is inert). Separate rule
// ids so a consumer can act on them separately.
if (canonical) {
findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_LEGACY_ALIAS,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", the RETIRED alias of the ` +
`platform agent "${canonical}". It still resolves — the alias registry maps legacy ` +
`names to canonical ones for old bookmarks and persisted \`agent_id\`s (ADR-0063 §2) — ` +
`so nothing is broken at runtime; what is wrong is the spelling in the artifact. It is ` +
`also the weaker pin: resolution depends on the owning package's in-process alias ` +
`registration having run, which the canonical id does not.`,
hint:
`Write \`defaultAgent: '${canonical}'\`. The aliases are back-compat resolution, not ` +
`authoring vocabulary — always author the canonical id (${roster}).`,
});
continue;
}

findings.push({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: `app "${appName}".defaultAgent`,
path: `apps[${appIdx}].defaultAgent`,
message:
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
`the pin has no effect and the value drifts from what actually serves the app.`,
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not a platform ` +
`agent (${roster}). The kernel ships exactly two agents (ADR-0063 §2) and resolves this ` +
`key against them and their legacy aliases only — an unrecognized name is not rejected, ` +
`it silently falls back to the platform default at runtime, so the pin has no effect and ` +
`the value drifts from what actually serves the app.`,
hint:
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1346,7 +1346,14 @@ export class MCPServerRuntime {
description: 'Load an agent\'s system prompt with optional UI context. ' +
'Use the agentName argument to select which agent\'s instructions to use.',
argsSchema: {
agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
// [#14461] The example names the two CANONICAL platform agent ids.
// It used to read `"data_chat", "metadata_assistant"` — both retired
// aliases, neither canonical id present — so every MCP client asking
// what to pass was taught the exact two spellings
// `skills/objectstack-ai` forbids. The aliases still resolve (cloud's
// one-way alias registry), so nothing broke; what was wrong is that
// this is the suggestion an author copies.
agentName: z.string().describe('Name of the agent to load (e.g. "ask", "build")'),
objectName: z.string().optional().describe('Current object the user is viewing'),
recordId: z.string().optional().describe('Currently selected record ID'),
viewName: z.string().optional().describe('Current view name'),
Expand Down
26 changes: 21 additions & 5 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,27 @@ export const STUDIO_APP: App = {
reason: 'Core developer workbench shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
// Studio is the metadata-authoring host, so its ambient copilot is
// pinned to the schema-architect agent. Resolved by the ambient chat
// endpoint via `app.defaultAgent` — no UI-side `?agent=` override
// needed. Every other app falls back to the data-query agent.
defaultAgent: 'metadata_assistant',
// Studio is the metadata-authoring host, so its ambient copilot is pinned
// to `build`, the authoring agent. Resolved by the ambient chat endpoint
// via `app.defaultAgent` — no UI-side `?agent=` override needed. Every
// other app falls back to `ask`, the data-query agent.
//
// [#14461] This spelled `'metadata_assistant'` until the maintainer ruling
// (2026-09-03): the legacy alias that `skills/objectstack-ai` tells authors
// is "not vocabulary", taught from the repo's ONLY live `defaultAgent`
// example. `build` is not a rename in flight — it is the record's canonical
// id today (`cloud` `service-ai-studio/src/agents/metadata-assistant-agent.ts:40`
// ships `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58` registers
// `metadata_assistant` as a ONE-WAY legacy alias, resolution-only).
//
// Nor is the re-pin cosmetic. The alias resolves only if an in-memory
// `registerAgentAlias` call has actually run, and cloud carries two
// defensive docblocks about that registration silently no-op'ing under
// bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
// `service-ai/src/agent-runtime.ts:30-41`). The canonical id never touches
// the alias table, so this drops a load-order dependency from the
// platform's own flagship authoring surface.
defaultAgent: 'build',
branding: {
primaryColor: '#6366f1', // Indigo-500 — distinct from Setup's slate
},
Expand Down
Loading