Skip to content

Commit d0e5537

Browse files
feat(lint): validate app.defaultAgent value against platform agent roster (#6041) (#7272)
validate-ai-agent-authoring previously scanned only stack.agents. Add the value half: app.defaultAgent outside PLATFORM_AGENT_NAMES (canonical + legacy aliases, reused not duplicated) now emits a warning finding naming the offending value and the allowed roster. Warning tier per maintainer ruling on #6041 (2026-08-07, reaffirmed 2026-08-09, option A) -- the failure mode is a silent runtime fallback, not a crash; schema stays a plain SnakeCaseIdentifierSchema (narrowing to an enum was explicitly rejected as a breaking change). Existing-metadata hit count measured before landing per the ruling's sequencing requirement: 0 (only real in-repo assignment is studio.app.ts's defaultAgent: 'metadata_assistant', an in-roster legacy alias). Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude <noreply@anthropic.com>
1 parent f586f1a commit d0e5537

4 files changed

Lines changed: 148 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): `validate-ai-agent-authoring` 新增 `app.defaultAgent` 取值检查(warning 档,#6041)
6+
7+
`app.defaultAgent` 的 Zod 类型是 `SnakeCaseIdentifierSchema`,任何 snake_case
8+
字符串都能 parse、build、通过 `os:check` —— 但运行期只解析平台 agent 名单
9+
(`ask`/`build` 及其历史别名 `data_chat`/`metadata_assistant`,ADR-0063 §2),
10+
表外的名字会静默回落到平台默认值。#5985 实测:把坏例子
11+
`defaultAgent: 'sales_copilot'` 放回语料后,`check:skill-examples` 仍 208
12+
全绿、EXIT=0 —— 现有门禁对这类缺陷结构性失明,这正是坏语料当初得以发布的机制
13+
(语料本身已由 PR #6030 修复)。
14+
15+
本 PR 是 `validate-ai-agent-authoring` 已有规则(此前只扫描 `stack.agents`
16+
数组)的取值半边:遍历 `stack.apps[].defaultAgent`,取值不在
17+
`PLATFORM_AGENT_NAMES`(复用同文件既有名单,未新建重复列表)内即产出一条
18+
`warning` 级 finding(规则 id `default-agent-outside-roster`),消息中点名
19+
实际取值与允许集合。维护者裁定(2026-08-07,2026-08-09 重申)为 **A 档**:
20+
warning 而非 error —— 危害等级是静默回落而非崩溃,且不惩罚存量元数据;
21+
schema 本身不收窄为 enum(ADR-0063 已经撤回过一次 breaking 的收紧)。
22+
23+
落地前已按裁定要求测量现存 in-repo `app.defaultAgent` 取值:仅
24+
`packages/platform-objects/src/apps/studio.app.ts` 一处真实赋值
25+
(`defaultAgent: 'metadata_assistant'`,合法平台别名),对该值实际跑规则
26+
0 条 finding —— "不惩罚存量" 的前提已验证而非假设。

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ export type {
434434
export {
435435
validateAiAgentAuthoring,
436436
AGENT_AUTHORING_WITHDRAWN,
437+
DEFAULT_AGENT_OUTSIDE_ROSTER,
437438
} from './validate-ai-agent-authoring.js';
438439
export type {
439440
AiAgentAuthoringFinding,

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest';
44
import {
55
validateAiAgentAuthoring,
66
AGENT_AUTHORING_WITHDRAWN,
7+
DEFAULT_AGENT_OUTSIDE_ROSTER,
78
} from './validate-ai-agent-authoring.js';
89

910
describe('validate-ai-agent-authoring', () => {
@@ -65,4 +66,79 @@ describe('validate-ai-agent-authoring', () => {
6566
expect(validateAiAgentAuthoring({ agents: [null, 7] } as never)).toEqual([]);
6667
expect(validateAiAgentAuthoring({ agents: [{ skills: 'nope' }] } as never)).toHaveLength(1);
6768
});
69+
70+
describe('app.defaultAgent value (issue #6041)', () => {
71+
it('flags a defaultAgent value outside the platform agent roster', () => {
72+
// The #5985 corpus shape: a plausible-looking custom agent name pinned
73+
// directly on the app, never caught by the schema (any snake_case string
74+
// parses) or by the array-scanning limb above (this app declares no
75+
// `agents` at all).
76+
const stack = {
77+
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
78+
};
79+
const findings = validateAiAgentAuthoring(stack);
80+
expect(findings).toHaveLength(1);
81+
expect(findings[0]).toMatchObject({
82+
severity: 'warning',
83+
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
84+
where: 'app "crm".defaultAgent',
85+
path: 'apps[0].defaultAgent',
86+
});
87+
// Names the offending value.
88+
expect(findings[0].message).toContain('"sales_copilot"');
89+
// Names the allowed set (canonical + legacy aliases).
90+
expect(findings[0].message).toContain('ask');
91+
expect(findings[0].message).toContain('build');
92+
expect(findings[0].message).toContain('data_chat');
93+
expect(findings[0].message).toContain('metadata_assistant');
94+
expect(findings[0].hint).toContain('ask');
95+
expect(findings[0].hint).toContain('build');
96+
});
97+
98+
it('passes every canonical platform agent name and every legacy alias', () => {
99+
for (const defaultAgent of ['ask', 'build', 'data_chat', 'metadata_assistant']) {
100+
const stack = { apps: [{ name: 'app', defaultAgent }] };
101+
expect(validateAiAgentAuthoring(stack), defaultAgent).toEqual([]);
102+
}
103+
});
104+
105+
it('is silent when defaultAgent is absent, empty, or not a string', () => {
106+
expect(validateAiAgentAuthoring({ apps: [{ name: 'a' }] })).toEqual([]);
107+
expect(validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: '' }] })).toEqual([]);
108+
expect(
109+
validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: 42 }] } as never),
110+
).toEqual([]);
111+
expect(validateAiAgentAuthoring({ apps: [] })).toEqual([]);
112+
expect(validateAiAgentAuthoring({})).toEqual([]);
113+
});
114+
115+
it('reports every offending app with stable paths, alongside the agents-array limb', () => {
116+
const stack = {
117+
agents: [{ name: 'legacy_bot' }],
118+
apps: [
119+
{ name: 'a', defaultAgent: 'ask' },
120+
{ name: 'b', defaultAgent: 'rogue_one' },
121+
{ name: 'c', defaultAgent: 'rogue_two' },
122+
],
123+
};
124+
const findings = validateAiAgentAuthoring(stack);
125+
expect(findings.map((f) => f.rule)).toEqual([
126+
AGENT_AUTHORING_WITHDRAWN,
127+
DEFAULT_AGENT_OUTSIDE_ROSTER,
128+
DEFAULT_AGENT_OUTSIDE_ROSTER,
129+
]);
130+
expect(findings.slice(1).map((f) => f.path)).toEqual([
131+
'apps[1].defaultAgent',
132+
'apps[2].defaultAgent',
133+
]);
134+
});
135+
136+
it('tolerates junk app shapes without throwing', () => {
137+
expect(validateAiAgentAuthoring({ apps: 'nope' } as never)).toEqual([]);
138+
expect(validateAiAgentAuthoring({ apps: [null, 7] } as never)).toEqual([]);
139+
expect(
140+
validateAiAgentAuthoring({ apps: [{ defaultAgent: 'rogue' }] } as never),
141+
).toHaveLength(1);
142+
});
143+
});
68144
});

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,29 @@
2626
* that names the runtime consequence is honest for both readers; the runtime
2727
* is what actually gates. Deliberately NOT a Zod refine — an existing stack
2828
* must keep parsing (ADR-0078 non-goal #1).
29+
*
30+
* ## The value half (issue #6041)
31+
*
32+
* The rule above catches a stack that *declares* a withdrawn agent record.
33+
* It never looked at `app.defaultAgent` — a plain
34+
* `SnakeCaseIdentifierSchema` string, so any snake_case value parses, builds,
35+
* and passes `os:check` even when it names nothing the runtime will ever
36+
* resolve. #5985 measured the blind spot directly: replaying the bad example
37+
* `defaultAgent: 'sales_copilot'` left `check:skill-examples` at 208 green,
38+
* EXIT=0. `app.defaultAgent` silently falls back to the platform default at
39+
* runtime (ADR-0063 §1) instead of crashing, which is why this limb is
40+
* **warning**, not error, same as the rule above — the maintainer ruling on
41+
* #6041 (2026-08-07, reaffirmed 2026-08-09) is option A: add the value check
42+
* at warning tier, reusing `PLATFORM_AGENT_NAMES` rather than narrowing the
43+
* schema to an enum (a breaking authoring change ADR-0063 already walked
44+
* back once).
2945
*/
3046

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

49+
/** `app.defaultAgent` names something outside the platform agent roster. */
50+
export const DEFAULT_AGENT_OUTSIDE_ROSTER = 'default-agent-outside-roster';
51+
3352
export type AiAgentAuthoringSeverity = 'error' | 'warning';
3453

3554
export interface AiAgentAuthoringFinding {
@@ -113,5 +132,31 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding
113132
});
114133
}
115134

135+
const roster = [...PLATFORM_AGENT_NAMES].join(', ');
136+
const apps = asArray(stack.apps);
137+
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
138+
const app = apps[appIdx];
139+
const defaultAgent = strName(app.defaultAgent);
140+
if (!defaultAgent || PLATFORM_AGENT_NAMES.has(defaultAgent)) continue;
141+
142+
const appName = strName(app.name) ?? `#${appIdx}`;
143+
findings.push({
144+
severity: 'warning',
145+
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
146+
where: `app "${appName}".defaultAgent`,
147+
path: `apps[${appIdx}].defaultAgent`,
148+
message:
149+
`app "${appName}" pins \`defaultAgent\` to "${defaultAgent}", which is not in the ` +
150+
`platform agent roster (${roster}). The kernel ships exactly two agents (ADR-0063 §2) ` +
151+
`and resolves this key against them and their legacy aliases only — an unrecognized ` +
152+
`name is not rejected, it silently falls back to the platform default at runtime, so ` +
153+
`the pin has no effect and the value drifts from what actually serves the app.`,
154+
hint:
155+
`Set \`defaultAgent\` to one of the platform agent names: ${roster}. If the goal is a ` +
156+
`dedicated persona or capability, express it as skills instead — they attach to "ask" ` +
157+
`/ "build" by surface affinity, not as a custom \`defaultAgent\` value.`,
158+
});
159+
}
160+
116161
return findings;
117162
}

0 commit comments

Comments
 (0)