diff --git a/.changeset/default-agent-value-lint.md b/.changeset/default-agent-value-lint.md new file mode 100644 index 0000000000..32c6897a05 --- /dev/null +++ b/.changeset/default-agent-value-lint.md @@ -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 —— "不惩罚存量" 的前提已验证而非假设。 diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index ba82e16df1..f85e24d2b2 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -422,6 +422,7 @@ export type { export { validateAiAgentAuthoring, AGENT_AUTHORING_WITHDRAWN, + DEFAULT_AGENT_OUTSIDE_ROSTER, } from './validate-ai-agent-authoring.js'; export type { AiAgentAuthoringFinding, diff --git a/packages/lint/src/validate-ai-agent-authoring.test.ts b/packages/lint/src/validate-ai-agent-authoring.test.ts index 02c4ca491c..3dd05fcf80 100644 --- a/packages/lint/src/validate-ai-agent-authoring.test.ts +++ b/packages/lint/src/validate-ai-agent-authoring.test.ts @@ -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', () => { @@ -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); + }); + }); }); diff --git a/packages/lint/src/validate-ai-agent-authoring.ts b/packages/lint/src/validate-ai-agent-authoring.ts index 819ad12b0c..15843d3df8 100644 --- a/packages/lint/src/validate-ai-agent-authoring.ts +++ b/packages/lint/src/validate-ai-agent-authoring.ts @@ -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 { @@ -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; }