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: 25 additions & 1 deletion lib/capabilities/descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 42 additions & 16 deletions lib/capabilities/discovery-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import {
validateDescriptor,
computeSemanticKey,
resolveOutputKind,
type CapabilityDescriptor,
type Capability,
} from "./descriptor";
Expand Down Expand Up @@ -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<string, UsageInput> = {};
const exampleArgs: Record<string, unknown> = {};
Expand Down Expand Up @@ -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,
Expand All @@ -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[],
Expand All @@ -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,
Expand All @@ -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,
};
}

/**
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/capability-descriptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
parseDescriptor,
validateDescriptor,
computeSemanticKey,
inferOutputKind,
resolveOutputKind,
CapabilityDescriptorSchema,
} from "@/lib/capabilities/descriptor";

Expand Down Expand Up @@ -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<string, unknown>;
const res = validateDescriptor({ capability: capNoOutput });
expect(res.ok).toBe(true);
});
});
14 changes: 11 additions & 3 deletions tests/unit/capability-discovery-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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);
});

Expand Down
Loading