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
26 changes: 26 additions & 0 deletions .changeset/default-agent-value-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/lint": minor
---

feat(lint): `validate-ai-agent-authoring` 新增 `app.defaultAgent` 取值检查(warning 档,#6041)

`app.defaultAgent` 的 Zod 类型是 `SnakeCaseIdentifierSchema`,任何 snake_case
字符串都能 parse、build、通过 `os:check` —— 但运行期只解析平台 agent 名单
(`ask`/`build` 及其历史别名 `data_chat`/`metadata_assistant`,ADR-0063 §2),
表外的名字会静默回落到平台默认值。#5985 实测:把坏例子
`defaultAgent: 'sales_copilot'` 放回语料后,`check:skill-examples` 仍 208
全绿、EXIT=0 —— 现有门禁对这类缺陷结构性失明,这正是坏语料当初得以发布的机制
(语料本身已由 PR #6030 修复)。

本 PR 是 `validate-ai-agent-authoring` 已有规则(此前只扫描 `stack.agents`
数组)的取值半边:遍历 `stack.apps[].defaultAgent`,取值不在
`PLATFORM_AGENT_NAMES`(复用同文件既有名单,未新建重复列表)内即产出一条
`warning` 级 finding(规则 id `default-agent-outside-roster`),消息中点名
实际取值与允许集合。维护者裁定(2026-08-07,2026-08-09 重申)为 **A 档**:
warning 而非 error —— 危害等级是静默回落而非崩溃,且不惩罚存量元数据;
schema 本身不收窄为 enum(ADR-0063 已经撤回过一次 breaking 的收紧)。

落地前已按裁定要求测量现存 in-repo `app.defaultAgent` 取值:仅
`packages/platform-objects/src/apps/studio.app.ts` 一处真实赋值
(`defaultAgent: 'metadata_assistant'`,合法平台别名),对该值实际跑规则
0 条 finding —— "不惩罚存量" 的前提已验证而非假设。
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export type {
export {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
} from './validate-ai-agent-authoring.js';
export type {
AiAgentAuthoringFinding,
Expand Down
76 changes: 76 additions & 0 deletions packages/lint/src/validate-ai-agent-authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest';
import {
validateAiAgentAuthoring,
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
} from './validate-ai-agent-authoring.js';

describe('validate-ai-agent-authoring', () => {
Expand Down Expand Up @@ -65,4 +66,79 @@ describe('validate-ai-agent-authoring', () => {
expect(validateAiAgentAuthoring({ agents: [null, 7] } as never)).toEqual([]);
expect(validateAiAgentAuthoring({ agents: [{ skills: 'nope' }] } as never)).toHaveLength(1);
});

describe('app.defaultAgent value (issue #6041)', () => {
it('flags a defaultAgent value outside the platform agent roster', () => {
// The #5985 corpus shape: a plausible-looking custom agent name pinned
// directly on the app, never caught by the schema (any snake_case string
// parses) or by the array-scanning limb above (this app declares no
// `agents` at all).
const stack = {
apps: [{ name: 'crm', defaultAgent: 'sales_copilot' }],
};
const findings = validateAiAgentAuthoring(stack);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'warning',
rule: DEFAULT_AGENT_OUTSIDE_ROSTER,
where: 'app "crm".defaultAgent',
path: 'apps[0].defaultAgent',
});
// Names the offending value.
expect(findings[0].message).toContain('"sales_copilot"');
// Names the allowed set (canonical + legacy aliases).
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].hint).toContain('ask');
expect(findings[0].hint).toContain('build');
});

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

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([]);
expect(
validateAiAgentAuthoring({ apps: [{ name: 'a', defaultAgent: 42 }] } as never),
).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [] })).toEqual([]);
expect(validateAiAgentAuthoring({})).toEqual([]);
});

it('reports every offending app with stable paths, alongside the agents-array limb', () => {
const stack = {
agents: [{ name: 'legacy_bot' }],
apps: [
{ name: 'a', defaultAgent: 'ask' },
{ name: 'b', defaultAgent: 'rogue_one' },
{ name: 'c', defaultAgent: 'rogue_two' },
],
};
const findings = validateAiAgentAuthoring(stack);
expect(findings.map((f) => f.rule)).toEqual([
AGENT_AUTHORING_WITHDRAWN,
DEFAULT_AGENT_OUTSIDE_ROSTER,
DEFAULT_AGENT_OUTSIDE_ROSTER,
]);
expect(findings.slice(1).map((f) => f.path)).toEqual([
'apps[1].defaultAgent',
'apps[2].defaultAgent',
]);
});

it('tolerates junk app shapes without throwing', () => {
expect(validateAiAgentAuthoring({ apps: 'nope' } as never)).toEqual([]);
expect(validateAiAgentAuthoring({ apps: [null, 7] } as never)).toEqual([]);
expect(
validateAiAgentAuthoring({ apps: [{ defaultAgent: 'rogue' }] } as never),
).toHaveLength(1);
});
});
});
45 changes: 45 additions & 0 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,29 @@
* that names the runtime consequence is honest for both readers; the runtime
* is what actually gates. Deliberately NOT a Zod refine — an existing stack
* must keep parsing (ADR-0078 non-goal #1).
*
* ## The value half (issue #6041)
*
* The rule above catches a stack that *declares* a withdrawn agent record.
* It never looked at `app.defaultAgent` — a plain
* `SnakeCaseIdentifierSchema` string, so any snake_case value parses, builds,
* and passes `os:check` even when it names nothing the runtime will ever
* resolve. #5985 measured the blind spot directly: replaying the bad example
* `defaultAgent: 'sales_copilot'` left `check:skill-examples` at 208 green,
* EXIT=0. `app.defaultAgent` silently falls back to the platform default at
* runtime (ADR-0063 §1) instead of crashing, which is why this limb is
* **warning**, not error, same as the rule above — the maintainer ruling on
* #6041 (2026-08-07, reaffirmed 2026-08-09) is option A: add the value check
* 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).
*/

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';

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

export interface AiAgentAuthoringFinding {
Expand Down Expand Up @@ -113,5 +132,31 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding
});
}

const roster = [...PLATFORM_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;

const appName = strName(app.name) ?? `#${appIdx}`;
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.`,
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" ` +
`/ "build" by surface affinity, not as a custom \`defaultAgent\` value.`,
});
}

return findings;
}
Loading