From a2a15fc58094d88b7d861cb9d6696f25f441d8ef Mon Sep 17 00:00:00 2001 From: qianghan Date: Sun, 19 Jul 2026 16:58:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(capabilities):=20fulfill=20=C2=A73.5=20aut?= =?UTF-8?q?hor-by-derivation=20in=20PR-1=20+=20PR-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two gaps between the merged PR-1/PR-3 and the "authoring by derivation" design (plan §3.5): providers must not hand-type fields that have a standard. PR-1 (descriptor.ts): - output_kind is now OPTIONAL — GENERATED from modality, overridable. Add inferOutputKind(modality) + resolveOutputKind(cap). A provider needn't type it. (semantic_key derive-not-trust / V1 was already shipped.) PR-3 (discovery-sync.ts): - planDiscoverySync now returns the full §3.5 V2 verdict — NEW / ADD-CAPACITY / SYNONYM. An existing identity offered with the name omitted OR matching is ADD-CAPACITY (a Plane-1 offering: no registry rewrite), not a rejected synonym; only an EXPLICIT different name is a synonym. Fixes the case where omitting the name on an existing cap was wrongly rejected. - descriptorToEntry + synthesizeUsage use resolveOutputKind (infer when omitted). Additive + golden-neutral. 14 descriptor + 14 discovery-sync tests green. Plan PR-1/PR-3 rows updated to mark §3.5 fulfilled. Co-Authored-By: Claude Opus 4.8 --- lib/capabilities/descriptor.ts | 26 ++++++++- lib/capabilities/discovery-sync.ts | 58 ++++++++++++++------ tests/unit/capability-descriptor.test.ts | 23 ++++++++ tests/unit/capability-discovery-sync.test.ts | 14 ++++- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/lib/capabilities/descriptor.ts b/lib/capabilities/descriptor.ts index 5cd0e4d4..feb63fb7 100644 --- a/lib/capabilities/descriptor.ts +++ b/lib/capabilities/descriptor.ts @@ -146,7 +146,9 @@ export const CapabilitySchema = z family: z.string().min(1), modality: z.string().min(1), variant: z.string().min(1).nullable().default(null), - output_kind: z.enum(DESCRIPTOR_OUTPUT_KINDS), + // §3.5: output_kind is GENERATED (inferred from modality), overridable — so + // a provider may omit it. resolveOutputKind() fills it downstream. + output_kind: z.enum(DESCRIPTOR_OUTPUT_KINDS).optional(), /** kind:modality:family:variant — optional; derived + verified (see below). */ semantic_key: z.string().min(1).optional(), aliases: z.array(z.string()).optional().default([]), @@ -188,6 +190,28 @@ export function computeSemanticKey(c: { return `${c.kind}:${c.modality}:${c.family}:${c.variant ?? "-"}`.toLowerCase(); } +/** + * Infer output_kind from the modality when a provider omits it (§3.5 — + * output_kind is GENERATED, overridable). Best-effort over the known modality + * suffixes; understanding / analysis / unknown modalities → "text". + */ +export function inferOutputKind(modality: string): (typeof DESCRIPTOR_OUTPUT_KINDS)[number] { + const m = modality.toLowerCase(); + if (["t2i", "i2i", "edit", "fill", "erase", "bg-remove", "upscale", "vto"].includes(m)) return "image"; + if (["t2v", "i2v", "ref2v", "a2v", "transition", "hdr", "ingredient", "lipsync"].includes(m)) return "video"; + if (["tts", "t2m", "v2m", "audio", "inpaint", "extend"].includes(m)) return "audio"; + if (["i3d", "t3d"].includes(m)) return "3d"; + return "text"; +} + +/** The effective output_kind: an explicit value wins; else inferred from modality. */ +export function resolveOutputKind(cap: { + output_kind?: (typeof DESCRIPTOR_OUTPUT_KINDS)[number]; + modality: string; +}): (typeof DESCRIPTOR_OUTPUT_KINDS)[number] { + return cap.output_kind ?? inferOutputKind(cap.modality); +} + /** Parse + fill the semantic_key. Throws (ZodError) on an invalid descriptor. */ export function parseDescriptor(input: unknown): CapabilityDescriptor { const parsed = CapabilityDescriptorSchema.parse(input); diff --git a/lib/capabilities/discovery-sync.ts b/lib/capabilities/discovery-sync.ts index 0203a50e..935bc161 100644 --- a/lib/capabilities/discovery-sync.ts +++ b/lib/capabilities/discovery-sync.ts @@ -23,6 +23,7 @@ import { validateDescriptor, computeSemanticKey, + resolveOutputKind, type CapabilityDescriptor, type Capability, } from "./descriptor"; @@ -53,8 +54,9 @@ export function canonicalName(cap: Capability): string { /** Synthesize the agent-usage block (describe_capability card) from the io contract. */ export function synthesizeUsage(cap: Capability, name: string): UsageBlock { - const invokeVia = MEDIA_KINDS.has(cap.output_kind) ? "create_media" : "run_capability"; - const actions = ACTIONS_BY_OUTPUT[cap.output_kind] ?? ["generate"]; + const ok = resolveOutputKind(cap); + const invokeVia = MEDIA_KINDS.has(ok) ? "create_media" : "run_capability"; + const actions = ACTIONS_BY_OUTPUT[ok] ?? ["generate"]; const inputs: Record = {}; const exampleArgs: Record = {}; @@ -100,7 +102,7 @@ export function descriptorToEntry(cap: Capability): RegistryEntry { variant: cap.variant ?? null, semantic_key: cap.semantic_key ?? computeSemanticKey(cap), aliases: cap.aliases ?? [], - output_kind: cap.output_kind as OutputKind, + output_kind: resolveOutputKind(cap) as OutputKind, fallback_chain: null, sla: null, unit_kind: cap.offering.price.display_unit ?? null, @@ -113,16 +115,27 @@ export function descriptorToEntry(cap: Capability): RegistryEntry { } export interface SyncPlan { - /** New/refreshed identities to upsert into the universal overlay. */ + /** New identities to register into the universal overlay. */ register: RegistryEntry[]; - /** Descriptors refused because their key already belongs to another name. */ + /** + * §3.5 V2 ADD-CAPACITY: the descriptor's identity already exists (same name, + * or the provider omitted the name). This is a permissionless Plane-1 offering + * — the capacity lives in discovery + is consumed by select_provider — so the + * sync makes NO registry write (never rewrites a known identity's fields). + */ + addCapacity: Array<{ name: string; key: string }>; + /** §3.5 V2 SYNONYM: refused because the key belongs to a DIFFERENT name. */ synonymSkipped: Array<{ name: string; key: string; owner: string }>; } /** - * PURE planner. Dedups descriptors against the existing registry view AND within - * the batch (mirrors cap_seed): a key owned by a DIFFERENT name is a synonym and - * is skipped; same name (or unseen) registers. + * PURE planner (§3.5 V2 dedup verdict). Keys every descriptor by semantic_key + * against the existing registry view AND within the batch: + * • key unseen → NEW, register it. + * • key seen, name omitted OR matches → ADD-CAPACITY (offering only, no write). + * • key seen, EXPLICIT different name → SYNONYM, skip + point at the owner. + * The name check is on the descriptor's EXPLICIT `name` (not the derived name), + * so an existing cap offered with an omitted name is add-capacity, not a synonym. */ export function planDiscoverySync( descriptors: CapabilityDescriptor[], @@ -142,28 +155,36 @@ export function planDiscoverySync( ); const register: RegistryEntry[] = []; + const addCapacity: SyncPlan["addCapacity"] = []; const synonymSkipped: SyncPlan["synonymSkipped"] = []; for (const d of descriptors) { - const entry = descriptorToEntry(d.capability); - const owner = seen.get(entry.semantic_key); - if (owner && owner !== entry.name) { - synonymSkipped.push({ name: entry.name, key: entry.semantic_key, owner }); + const cap = d.capability; + const key = cap.semantic_key ?? computeSemanticKey(cap); + const owner = seen.get(key); + if (owner) { + if (cap.name && cap.name !== owner) { + synonymSkipped.push({ name: cap.name, key, owner }); + } else { + addCapacity.push({ name: owner, key }); + } continue; } + const entry = descriptorToEntry(cap); register.push(entry); - seen.set(entry.semantic_key, entry.name); + seen.set(key, entry.name); } - return { register, synonymSkipped }; + return { register, addCapacity, synonymSkipped }; } export interface SyncReport { registered: string[]; + addCapacity: SyncPlan["addCapacity"]; synonymSkipped: SyncPlan["synonymSkipped"]; invalid: number; } -/** Apply a sync: read the universal view, plan, upsert. Best-effort (per-entry). */ +/** Apply a sync: read the universal view, plan, upsert NEW identities. Best-effort. */ export async function applyDiscoverySync( descriptors: CapabilityDescriptor[], invalidCount = 0, @@ -179,7 +200,12 @@ export async function applyDiscoverySync( // best-effort: one Blob write failing must not abort the rest } } - return { registered, synonymSkipped: plan.synonymSkipped, invalid: invalidCount }; + return { + registered, + addCapacity: plan.addCapacity, + synonymSkipped: plan.synonymSkipped, + invalid: invalidCount, + }; } /** diff --git a/tests/unit/capability-descriptor.test.ts b/tests/unit/capability-descriptor.test.ts index 73b58ef6..5b3b998a 100644 --- a/tests/unit/capability-descriptor.test.ts +++ b/tests/unit/capability-descriptor.test.ts @@ -3,6 +3,8 @@ import { parseDescriptor, validateDescriptor, computeSemanticKey, + inferOutputKind, + resolveOutputKind, CapabilityDescriptorSchema, } from "@/lib/capabilities/descriptor"; @@ -171,4 +173,25 @@ describe("Capability Descriptor (PR-1)", () => { it("schema is exported for reuse by the sync (PR-3) + cap-check (PR-7)", () => { expect(CapabilityDescriptorSchema.safeParse(SCREEN_AGENT).success).toBe(true); }); + + // §3.5 — output_kind is GENERATED (inferred from modality), overridable. + it("infers output_kind from modality when omitted", () => { + expect(inferOutputKind("t2v")).toBe("video"); + expect(inferOutputKind("tts")).toBe("audio"); + expect(inferOutputKind("i3d")).toBe("3d"); + expect(inferOutputKind("t2i")).toBe("image"); + // an understanding/analysis modality (no media suffix) → text + expect(inferOutputKind("video-understanding")).toBe("text"); + }); + + it("resolveOutputKind: explicit wins, else inferred", () => { + expect(resolveOutputKind({ modality: "t2i" })).toBe("image"); + expect(resolveOutputKind({ output_kind: "text", modality: "t2i" })).toBe("text"); + }); + + it("parses a descriptor that OMITS output_kind (provider needn't supply it)", () => { + const { output_kind: _drop, ...capNoOutput } = SCREEN_AGENT.capability as Record; + const res = validateDescriptor({ capability: capNoOutput }); + expect(res.ok).toBe(true); + }); }); diff --git a/tests/unit/capability-discovery-sync.test.ts b/tests/unit/capability-discovery-sync.test.ts index 146f2783..f3aa3200 100644 --- a/tests/unit/capability-discovery-sync.test.ts +++ b/tests/unit/capability-discovery-sync.test.ts @@ -95,10 +95,18 @@ describe("discovery-sync (PR-3)", () => { expect(plan.synonymSkipped).toHaveLength(0); }); - it("registers a new PROVIDER of an existing identity when the name matches (idempotent refresh)", () => { - // flux-dev offering reuses the exact identity + name → not a synonym, allowed. + it("ADD-CAPACITY: existing identity offered with the SAME name → no registry write", () => { const plan = planDiscoverySync([parseDescriptor({ capability: fluxDevOffering })], existing); - expect(plan.register.map((e) => e.name)).toEqual(["flux-dev"]); + expect(plan.register).toHaveLength(0); + expect(plan.addCapacity).toEqual([{ name: "flux-dev", key: "ai:t2i:flux:dev" }]); + expect(plan.synonymSkipped).toHaveLength(0); + }); + + it("ADD-CAPACITY: existing identity offered with the name OMITTED → add-capacity, not synonym", () => { + const { name: _drop, ...noName } = fluxDevOffering as Record; + const plan = planDiscoverySync([parseDescriptor({ capability: noName })], existing); + expect(plan.register).toHaveLength(0); + expect(plan.addCapacity.map((a) => a.name)).toEqual(["flux-dev"]); expect(plan.synonymSkipped).toHaveLength(0); });