From 42e9e822a48d1497bc6e78e0f1970d35a6b15e2c Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:33:34 +0000 Subject: [PATCH 1/7] feat(agent): add package agent facts protocol --- packages/agent/src/index.ts | 31 + packages/agent/src/package-agent-facts.ts | 693 ++++++++++++++++++++++ 2 files changed, 724 insertions(+) create mode 100644 packages/agent/src/package-agent-facts.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 70b6d754..f2c39ecf 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -157,6 +157,37 @@ export type { PackageGraphStaticEvidenceState, } from './package-graph-evidence.js'; +// Package-scoped AgentFacts — factual metadata keyed by inventory agentKey. +export { + PACKAGE_AGENT_FACTS_PROTOCOL, + canonicalPackageAgentFactsJson, + createPackageAgentFactsSnapshot, + packageAgentFactsCompletenessSchema, + packageAgentFactsDiagnosticSchema, + packageAgentFactsDirectReferenceSchema, + packageAgentFactsEvidenceReferenceSchema, + packageAgentFactsProducerSchema, + packageAgentFactsRecordSchema, + packageAgentFactsSnapshotSchema, + packageAgentFactsSourceReferenceSchema, +} from './package-agent-facts.js'; +export type { + CreatePackageAgentFactsSnapshotInput, + PackageAgentFactsCapabilityField, + PackageAgentFactsCompleteness, + PackageAgentFactsDiagnostic, + PackageAgentFactsDiagnosticCode, + PackageAgentFactsDigest, + PackageAgentFactsDirectReference, + PackageAgentFactsEvidenceReference, + PackageAgentFactsProducer, + PackageAgentFactsRecord, + PackageAgentFactsSchemaField, + PackageAgentFactsSnapshot, + PackageAgentFactsSourceReference, + PackageAgentFactsStringField, +} from './package-agent-facts.js'; + // Manifest generator + graph validation — called by the build phase. export { buildManifest, validateGraph, assertValidGraph } from './build-manifest.js'; export type { GraphValidation } from './build-manifest.js'; diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts new file mode 100644 index 00000000..2fc90726 --- /dev/null +++ b/packages/agent/src/package-agent-facts.ts @@ -0,0 +1,693 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod/v4"; + +import { + packageInventorySchema, + packageInventoryVersionSchema, + type PackageInventory, + type PackageInventoryVersion, +} from "./package-inventory.js"; + +/** Protocol version for package-scoped, per-agent factual metadata. */ +export const PACKAGE_AGENT_FACTS_PROTOCOL = 1 as const; + +export type PackageAgentFactsDigest = `sha256:${string}`; + +const SHA256 = /^sha256:[0-9a-f]{64}$/; +const OPAQUE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/; +const PRODUCER_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$/; +const ZERO_DIGEST = `sha256:${"0".repeat(64)}` as const; + +const digestSchema = z + .string() + .regex(SHA256, "Expected lowercase sha256:<64 hex characters>") + .transform((value) => value as PackageAgentFactsDigest); +const opaqueReferenceValueSchema = z + .string() + .regex(OPAQUE_REFERENCE, "Expected a public-safe opaque reference"); +const producerComponentSchema = z + .string() + .regex(PRODUCER_COMPONENT, "Expected a safe producer identifier"); + +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.string(), + z.boolean(), + z.number().finite(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); +const jsonObjectSchema = z.record(z.string(), jsonValueSchema); + +function referenceSchema(kind: Kind) { + return z + .object({ + kind: z.literal(kind), + ref: opaqueReferenceValueSchema, + }) + .strict(); +} + +export const packageAgentFactsSourceReferenceSchema = z.discriminatedUnion( + "kind", + [referenceSchema("source-card"), referenceSchema("manifest")], +); +export type PackageAgentFactsSourceReference = z.infer< + typeof packageAgentFactsSourceReferenceSchema +>; + +export const packageAgentFactsDirectReferenceSchema = z.discriminatedUnion( + "kind", + [referenceSchema("agent-card"), referenceSchema("manifest-field")], +); +export type PackageAgentFactsDirectReference = z.infer< + typeof packageAgentFactsDirectReferenceSchema +>; + +export const packageAgentFactsEvidenceReferenceSchema = z.discriminatedUnion( + "kind", + [ + referenceSchema("agent-facts-evidence"), + referenceSchema("graph-evidence-result"), + referenceSchema("graph-evidence-record"), + ], +); +export type PackageAgentFactsEvidenceReference = z.infer< + typeof packageAgentFactsEvidenceReferenceSchema +>; + +export const packageAgentFactsProducerSchema = z + .object({ + id: producerComponentSchema, + version: producerComponentSchema, + }) + .strict(); +export type PackageAgentFactsProducer = z.infer< + typeof packageAgentFactsProducerSchema +>; + +const capabilitySchema = z + .string() + .trim() + .min(1) + .max(256) + .refine( + (value) => + ![...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }), + "Capability names must not contain control characters", + ); + +const stringOrNullFieldSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("known"), value: z.string().nullable() }).strict(), + z.object({ status: z.literal("unknown") }).strict(), +]); +const schemaFieldSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("known"), value: jsonObjectSchema.nullable() }).strict(), + z.object({ status: z.literal("unknown") }).strict(), +]); +const capabilityFieldSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("known"), values: z.array(capabilitySchema) }).strict(), + z.object({ status: z.literal("unknown") }).strict(), +]); + +export type PackageAgentFactsStringField = z.infer< + typeof stringOrNullFieldSchema +>; +export type PackageAgentFactsSchemaField = z.infer; +export type PackageAgentFactsCapabilityField = z.infer< + typeof capabilityFieldSchema +>; + +export const packageAgentFactsDiagnosticCodeSchema = z.enum([ + "missing-card", + "invalid-card", + "duplicate-card", + "unknown-agent-key", + "invalid-observation", + "unsupported-observed-fact", + "incomplete-extraction", + "dynamic-data", +]); +export type PackageAgentFactsDiagnosticCode = z.infer< + typeof packageAgentFactsDiagnosticCodeSchema +>; + +export const packageAgentFactsDiagnosticSchema = z + .object({ + code: packageAgentFactsDiagnosticCodeSchema, + severity: z.enum(["warning", "error"]), + agentKey: z.string().optional(), + reference: z + .union([ + packageAgentFactsSourceReferenceSchema, + packageAgentFactsDirectReferenceSchema, + packageAgentFactsEvidenceReferenceSchema, + ]) + .optional(), + }) + .strict(); +export type PackageAgentFactsDiagnostic = z.infer< + typeof packageAgentFactsDiagnosticSchema +>; + +export const packageAgentFactsCompletenessSchema = z.discriminatedUnion( + "status", + [ + z.object({ status: z.literal("complete") }).strict(), + z + .object({ + status: z.literal("partial"), + diagnostics: z.array(packageAgentFactsDiagnosticSchema).min(1), + }) + .strict(), + z + .object({ + status: z.literal("unknown"), + diagnostics: z.array(packageAgentFactsDiagnosticSchema).min(1), + }) + .strict(), + ], +); +export type PackageAgentFactsCompleteness = z.infer< + typeof packageAgentFactsCompletenessSchema +>; + +const observedCapabilityObservationSchema = z + .object({ + kind: z.literal("capability-call"), + capability: capabilitySchema, + reference: packageAgentFactsEvidenceReferenceSchema.optional(), + }) + .strict(); + +const cardInputSchema = z + .object({ + agentKey: z.string(), + sourceReferences: z.array(packageAgentFactsSourceReferenceSchema).optional(), + directReferences: z.array(packageAgentFactsDirectReferenceSchema).optional(), + evidenceReferences: z.array(packageAgentFactsEvidenceReferenceSchema).optional(), + description: z.string().nullable().optional(), + inputSchema: jsonObjectSchema.nullable().optional(), + outputSchema: jsonObjectSchema.nullable().optional(), + declaredCapabilities: z.array(capabilitySchema).optional(), + observed: z.array(z.unknown()).optional(), + completeness: packageAgentFactsCompletenessSchema.optional(), + }) + .strict(); + +export type PackageAgentFactsCardInput = z.infer; + +export const packageAgentFactsRecordSchema = z + .object({ + agentKey: z.string(), + description: stringOrNullFieldSchema, + inputSchema: schemaFieldSchema, + outputSchema: schemaFieldSchema, + capabilities: z + .object({ + declared: capabilityFieldSchema, + observed: capabilityFieldSchema, + }) + .strict(), + references: z + .object({ + source: z.array(packageAgentFactsSourceReferenceSchema), + direct: z.array(packageAgentFactsDirectReferenceSchema), + evidence: z.array(packageAgentFactsEvidenceReferenceSchema), + }) + .strict(), + completeness: packageAgentFactsCompletenessSchema, + summary: z.string(), + }) + .strict(); +export type PackageAgentFactsRecord = z.infer< + typeof packageAgentFactsRecordSchema +>; + +const snapshotBaseSchema = z + .object({ + protocol: z.literal(PACKAGE_AGENT_FACTS_PROTOCOL), + kind: z.literal("agent-facts-snapshot"), + snapshotId: digestSchema, + scope: packageInventoryVersionSchema, + extractor: packageAgentFactsProducerSchema, + inventoryStatus: z.enum(["complete", "degraded"]), + agents: z.array(packageAgentFactsRecordSchema), + diagnostics: z.array(packageAgentFactsDiagnosticSchema), + }) + .strict(); + +export type PackageAgentFactsSnapshot = z.infer; + +export interface CreatePackageAgentFactsSnapshotInput { + scope: PackageInventoryVersion; + extractor: PackageAgentFactsProducer; + cards: readonly unknown[]; + diagnostics?: readonly PackageAgentFactsDiagnostic[]; +} + +const createSnapshotInputSchema = z + .object({ + scope: packageInventoryVersionSchema, + extractor: packageAgentFactsProducerSchema, + cards: z.array(z.unknown()), + diagnostics: z.array(packageAgentFactsDiagnosticSchema).optional(), + }) + .strict(); + +/** Canonical JSON for already-validated AgentFacts values. */ +export function canonicalPackageAgentFactsJson(value: unknown): string { + const seen = new Set(); + + const visit = (current: unknown): string => { + if ( + current === null || + typeof current === "string" || + typeof current === "boolean" + ) { + return JSON.stringify(current); + } + if (typeof current === "number") { + if (!Number.isFinite(current)) + throw new TypeError("Canonical JSON requires finite numbers"); + return JSON.stringify(current); + } + if (typeof current !== "object") { + throw new TypeError("Canonical JSON accepts JSON values only"); + } + if (seen.has(current)) + throw new TypeError("Canonical JSON rejects cyclic values"); + seen.add(current); + try { + if (Array.isArray(current)) return `[${current.map(visit).join(",")}]`; + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Canonical JSON accepts plain objects only"); + } + const entries = Object.entries(current as Record).sort( + ([left], [right]) => compareText(left, right), + ); + if (entries.some(([, child]) => child === undefined)) { + throw new TypeError("Canonical JSON rejects undefined object fields"); + } + return `{${entries + .map(([key, child]) => `${JSON.stringify(key)}:${visit(child)}`) + .join(",")}}`; + } finally { + seen.delete(current); + } + }; + + return visit(value); +} + +function packageAgentFactsSha256(value: unknown): PackageAgentFactsDigest { + return `sha256:${createHash("sha256").update(canonicalPackageAgentFactsJson(value)).digest("hex")}`; +} + +function compareText(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function canonicalizeJsonObject(value: T): T { + return JSON.parse(canonicalPackageAgentFactsJson(value)) as T; +} + +function normalizeCapabilities(capabilities: readonly string[]): string[] { + return [...new Set(capabilities)].sort(compareText); +} + +function normalizeByCanonical(values: readonly T[], parse: (value: T) => T): T[] { + return [ + ...new Map( + values + .map(parse) + .sort((left, right) => + compareText( + canonicalPackageAgentFactsJson(left), + canonicalPackageAgentFactsJson(right), + ), + ) + .map((value) => [canonicalPackageAgentFactsJson(value), value]), + ).values(), + ]; +} + +function compareDiagnostic( + left: PackageAgentFactsDiagnostic, + right: PackageAgentFactsDiagnostic, +): number { + return ( + compareText(left.agentKey ?? "", right.agentKey ?? "") || + compareText(left.code, right.code) || + compareText(left.severity, right.severity) || + compareText(left.reference?.kind ?? "", right.reference?.kind ?? "") || + compareText(left.reference?.ref ?? "", right.reference?.ref ?? "") + ); +} + +function normalizeDiagnostics( + diagnostics: readonly PackageAgentFactsDiagnostic[], +): PackageAgentFactsDiagnostic[] { + return [ + ...new Map( + diagnostics + .map((diagnostic) => packageAgentFactsDiagnosticSchema.parse(diagnostic)) + .sort(compareDiagnostic) + .map((diagnostic) => [ + canonicalPackageAgentFactsJson(diagnostic), + diagnostic, + ]), + ).values(), + ]; +} + +function normalizeCompleteness( + completeness: PackageAgentFactsCompleteness, +): PackageAgentFactsCompleteness { + if (completeness.status === "complete") return completeness; + return { + ...completeness, + diagnostics: normalizeDiagnostics(completeness.diagnostics), + }; +} + +function unknownRecord( + agentKey: string, + diagnostics: readonly PackageAgentFactsDiagnostic[], +): PackageAgentFactsRecord { + const completeness = normalizeCompleteness({ + status: "unknown", + diagnostics: [...diagnostics], + }); + return { + agentKey, + description: { status: "unknown" }, + inputSchema: { status: "unknown" }, + outputSchema: { status: "unknown" }, + capabilities: { + declared: { status: "unknown" }, + observed: { status: "unknown" }, + }, + references: { source: [], direct: [], evidence: [] }, + completeness, + summary: summarizeAgentFacts(agentKey, { + description: { status: "unknown" }, + inputSchema: { status: "unknown" }, + outputSchema: { status: "unknown" }, + declared: { status: "unknown" }, + observed: { status: "unknown" }, + }), + }; +} + +function summarizeAgentFacts( + agentKey: string, + fields: { + description: PackageAgentFactsStringField; + inputSchema: PackageAgentFactsSchemaField; + outputSchema: PackageAgentFactsSchemaField; + declared: PackageAgentFactsCapabilityField; + observed: PackageAgentFactsCapabilityField; + }, +): string { + const description = + fields.description.status === "known" + ? fields.description.value === null + ? "none" + : fields.description.value + : "unknown"; + const input = fields.inputSchema.status === "known" ? "known" : "unknown"; + const output = fields.outputSchema.status === "known" ? "known" : "unknown"; + const declared = + fields.declared.status === "known" + ? fields.declared.values.length === 0 + ? "none" + : fields.declared.values.join(", ") + : "unknown"; + const observed = + fields.observed.status === "known" + ? fields.observed.values.length === 0 + ? "none" + : fields.observed.values.join(", ") + : "unknown"; + return `agentKey: ${agentKey}; authored description: ${description}; input schema: ${input}; output schema: ${output}; declared capabilities: ${declared}; observed capabilities: ${observed}`; +} + +function recordFromCard( + agentKey: string, + card: PackageAgentFactsCardInput, +): { + record: PackageAgentFactsRecord; + diagnostics: PackageAgentFactsDiagnostic[]; +} { + const diagnostics: PackageAgentFactsDiagnostic[] = []; + const observedCapabilities: string[] = []; + const evidenceReferences: PackageAgentFactsEvidenceReference[] = [ + ...(card.evidenceReferences ?? []), + ]; + + for (const observed of card.observed ?? []) { + const parsed = observedCapabilityObservationSchema.safeParse(observed); + if (!parsed.success) { + const rawKind = + typeof observed === "object" && + observed !== null && + "kind" in observed && + typeof observed.kind === "string" + ? observed.kind + : undefined; + diagnostics.push({ + code: + rawKind === undefined || rawKind === "capability-call" + ? "invalid-observation" + : "unsupported-observed-fact", + severity: "warning", + agentKey, + }); + continue; + } + observedCapabilities.push(parsed.data.capability); + if (parsed.data.reference) evidenceReferences.push(parsed.data.reference); + } + + const description: PackageAgentFactsStringField = + "description" in card + ? { status: "known", value: card.description ?? null } + : { status: "unknown" }; + const inputSchema: PackageAgentFactsSchemaField = + "inputSchema" in card + ? { status: "known", value: canonicalizeJsonObject(card.inputSchema ?? null) } + : { status: "unknown" }; + const outputSchema: PackageAgentFactsSchemaField = + "outputSchema" in card + ? { status: "known", value: canonicalizeJsonObject(card.outputSchema ?? null) } + : { status: "unknown" }; + const declared: PackageAgentFactsCapabilityField = + card.declaredCapabilities !== undefined + ? { status: "known", values: normalizeCapabilities(card.declaredCapabilities) } + : { status: "unknown" }; + const observed: PackageAgentFactsCapabilityField = + card.observed !== undefined + ? { status: "known", values: normalizeCapabilities(observedCapabilities) } + : { status: "unknown" }; + const completeness = normalizeCompleteness( + card.completeness ?? { status: "complete" }, + ); + const record = { + agentKey, + description, + inputSchema, + outputSchema, + capabilities: { declared, observed }, + references: { + source: normalizeByCanonical( + card.sourceReferences ?? [], + packageAgentFactsSourceReferenceSchema.parse, + ), + direct: normalizeByCanonical( + card.directReferences ?? [], + packageAgentFactsDirectReferenceSchema.parse, + ), + evidence: normalizeByCanonical( + evidenceReferences, + packageAgentFactsEvidenceReferenceSchema.parse, + ), + }, + completeness, + summary: summarizeAgentFacts(agentKey, { + description, + inputSchema, + outputSchema, + declared, + observed, + }), + }; + return { record: packageAgentFactsRecordSchema.parse(record), diagnostics }; +} + +function snapshotWithoutId( + snapshot: PackageAgentFactsSnapshot, +): Omit { + return Object.fromEntries( + Object.entries(snapshot).filter(([key]) => key !== "snapshotId"), + ) as Omit; +} + +export const packageAgentFactsSnapshotSchema = + snapshotBaseSchema.superRefine((snapshot, context) => { + const sortedAgents = [...snapshot.agents].sort((left, right) => + compareText(left.agentKey, right.agentKey), + ); + if ( + new Set(sortedAgents.map((agent) => agent.agentKey)).size !== + sortedAgents.length || + canonicalPackageAgentFactsJson(sortedAgents) !== + canonicalPackageAgentFactsJson(snapshot.agents) + ) { + context.addIssue({ + code: "custom", + path: ["agents"], + message: "Agent facts must be unique and canonically ordered", + }); + } + for (const [index, agent] of snapshot.agents.entries()) { + const normalized = packageAgentFactsRecordSchema.parse({ + ...agent, + references: { + source: normalizeByCanonical( + agent.references.source, + packageAgentFactsSourceReferenceSchema.parse, + ), + direct: normalizeByCanonical( + agent.references.direct, + packageAgentFactsDirectReferenceSchema.parse, + ), + evidence: normalizeByCanonical( + agent.references.evidence, + packageAgentFactsEvidenceReferenceSchema.parse, + ), + }, + completeness: normalizeCompleteness(agent.completeness), + }); + if ( + canonicalPackageAgentFactsJson(normalized) !== + canonicalPackageAgentFactsJson(agent) + ) { + context.addIssue({ + code: "custom", + path: ["agents", index], + message: "Agent facts record is not normalized", + }); + } + } + if ( + canonicalPackageAgentFactsJson(normalizeDiagnostics(snapshot.diagnostics)) !== + canonicalPackageAgentFactsJson(snapshot.diagnostics) + ) { + context.addIssue({ + code: "custom", + path: ["diagnostics"], + message: "Diagnostics must be unique and canonically ordered", + }); + } + if ( + snapshot.snapshotId !== + packageAgentFactsSha256(snapshotWithoutId(snapshot)) + ) { + context.addIssue({ + code: "custom", + path: ["snapshotId"], + message: "Snapshot ID does not match canonical snapshot content", + }); + } + }); + +/** + * Normalize factual per-agent metadata against one authoritative inventory. + * + * The helper never discovers, remaps, or infers agent identity. It emits one + * record for every inventory `agentKey`; missing or invalid extraction cards + * become unknown facts with diagnostics instead of deleting the agent node. + */ +export function createPackageAgentFactsSnapshot( + input: CreatePackageAgentFactsSnapshotInput, + inventoryInput: PackageInventory, +): PackageAgentFactsSnapshot { + const parsedInput = createSnapshotInputSchema.parse(input); + const inventory = packageInventorySchema.parse(inventoryInput); + const cardsByAgentKey = new Map(); + const diagnostics: PackageAgentFactsDiagnostic[] = [ + ...(parsedInput.diagnostics ?? []), + ]; + const inventoryKeys = new Set(inventory.agents.map((agent) => agent.agentKey)); + + for (const rawCard of parsedInput.cards) { + const parsed = cardInputSchema.safeParse(rawCard); + if (!parsed.success) { + diagnostics.push({ code: "invalid-card", severity: "warning" }); + continue; + } + const card = parsed.data; + if (!inventoryKeys.has(card.agentKey)) { + diagnostics.push({ + code: "unknown-agent-key", + severity: "warning", + agentKey: card.agentKey, + }); + continue; + } + if (cardsByAgentKey.has(card.agentKey)) { + diagnostics.push({ + code: "duplicate-card", + severity: "warning", + agentKey: card.agentKey, + }); + continue; + } + cardsByAgentKey.set(card.agentKey, card); + } + + const agents: PackageAgentFactsRecord[] = []; + for (const agent of inventory.agents) { + const card = cardsByAgentKey.get(agent.agentKey); + if (!card) { + const missing = { + code: "missing-card", + severity: "warning", + agentKey: agent.agentKey, + } as const; + diagnostics.push(missing); + agents.push(unknownRecord(agent.agentKey, [missing])); + continue; + } + const { record, diagnostics: cardDiagnostics } = recordFromCard( + agent.agentKey, + card, + ); + diagnostics.push(...cardDiagnostics); + agents.push(record); + } + + const draft: PackageAgentFactsSnapshot = { + protocol: PACKAGE_AGENT_FACTS_PROTOCOL, + kind: "agent-facts-snapshot", + snapshotId: ZERO_DIGEST, + scope: parsedInput.scope, + extractor: parsedInput.extractor, + inventoryStatus: inventory.status, + agents: agents.sort((left, right) => compareText(left.agentKey, right.agentKey)), + diagnostics: normalizeDiagnostics(diagnostics), + }; + const snapshot = { + ...draft, + snapshotId: packageAgentFactsSha256(snapshotWithoutId(draft)), + }; + return packageAgentFactsSnapshotSchema.parse(snapshot); +} From 7e5e73a47dfc025f63fff4c8cffb2a47ee91f9e0 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:36:02 +0000 Subject: [PATCH 2/7] test(agent): cover package agent facts normalization --- .changeset/plain-agents-report.md | 8 + packages/agent/README.md | 14 + packages/agent/src/index.ts | 96 ++++-- .../agent/src/package-agent-facts.spec.ts | 273 ++++++++++++++++++ packages/agent/src/package-agent-facts.ts | 83 ++++-- 5 files changed, 424 insertions(+), 50 deletions(-) create mode 100644 .changeset/plain-agents-report.md create mode 100644 packages/agent/src/package-agent-facts.spec.ts diff --git a/.changeset/plain-agents-report.md b/.changeset/plain-agents-report.md new file mode 100644 index 00000000..e52b2436 --- /dev/null +++ b/.changeset/plain-agents-report.md @@ -0,0 +1,8 @@ +--- +"@sapiom/agent": minor +--- + +Add the package AgentFacts protocol with deterministic inventory-keyed +normalization, authored schema and capability fields, allowlisted observed +capabilities, explicit references, completeness diagnostics, and stable +template summaries. diff --git a/packages/agent/README.md b/packages/agent/README.md index 88fb759a..0ec69317 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -120,6 +120,20 @@ whether a permitted string contains a sensitive identifier. Graph evidence is explanatory metadata only: it cannot change execution, routing, authorization, deployment, builds, or billing. +## Package AgentFacts + +`PackageInventory` is also the authority for per-agent factual metadata. The +AgentFacts protocol emits one normalized record for every inventory `agentKey`; +extractors must not rediscover, rename, or infer identity. Missing or partial +cards become `unknown` or `partial` fields with diagnostics, not deleted agents. + +Supported facts are intentionally narrow: authored descriptions, exact +input/output JSON Schemas, declared capabilities, observed capabilities from +allowlisted structured capability-call observations, direct/source/evidence +references, completeness, diagnostics, and deterministic template summaries. +Unsupported observations are diagnosed and ignored, and semantic prose or +relationships are never inferred from names or paths. + ## The entry input contract A step's `inputSchema` (a zod schema, imported from `zod/v4`) types and validates that diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index f2c39ecf..da6950c6 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -11,7 +11,14 @@ */ // Directives — the load-bearing protocol contract -export { DIRECTIVE_KIND, isContinue, isRetry, isPause, isTerminate, isFail } from './directives.js'; +export { + DIRECTIVE_KIND, + isContinue, + isRetry, + isPause, + isTerminate, + isFail, +} from "./directives.js"; export type { DirectiveKind, NextStepDirective, @@ -20,16 +27,22 @@ export type { PauseUntilSignalDirective, TerminateDirective, FailDirective, -} from './directives.js'; +} from "./directives.js"; // Transition constructors + their branded types (the authoring surface) -export { goto, terminate, fail, pauseUntilSignal, retry } from './directives.js'; -export type { Goto, Terminate, Fail, Pause, Retry } from './directives.js'; +export { + goto, + terminate, + fail, + pauseUntilSignal, + retry, +} from "./directives.js"; +export type { Goto, Terminate, Fail, Pause, Retry } from "./directives.js"; // Step authoring: defineStep + the derived `Allowed` return type + StepDefinition. // Step + StepResult are retained for the engine (deprecated for authoring). -export { defineStep } from './step.js'; -export type { Step, StepResult, StepDefinition, Allowed } from './step.js'; +export { defineStep } from "./step.js"; +export type { Step, StepResult, StepDefinition, Allowed } from "./step.js"; // Execution context — what a step's `run` receives (metadata + shared store + logger) export type { @@ -39,18 +52,18 @@ export type { StepExecutionRecord, StepLogger, FinishedStepStatus, -} from './context.js'; -export { InMemoryContextStore } from './context.js'; +} from "./context.js"; +export { InMemoryContextStore } from "./context.js"; // Agent definition + defineAgent factory + brand guards (current + pre-rename legacy) -export type { AgentDefinition } from './agent.js'; +export type { AgentDefinition } from "./agent.js"; export { defineAgent, isAgentDefinition, AGENT_DEFINITION_BRAND, isLegacyOrchestrationDefinition, LEGACY_ORCHESTRATION_DEFINITION_BRAND, -} from './agent.js'; +} from "./agent.js"; // Errors that are part of the public contract surface export { @@ -61,8 +74,8 @@ export { STEP_INPUT_VALIDATION_ERROR_CONTRACT, stepInputValidationErrorPayloadSchema, isStepInputValidationErrorPayload, -} from './errors.js'; -export type { StepInputValidationErrorPayload } from './errors.js'; +} from "./errors.js"; +export type { StepInputValidationErrorPayload } from "./errors.js"; // ctx.shared quota — the versioned cross-process size/error contract export { @@ -73,13 +86,13 @@ export { findCtxSharedSizeViolation, isCtxSharedSizeLimitExceededPayload, measureCtxSharedSnapshotBytes, -} from './ctx-shared-quota.js'; +} from "./ctx-shared-quota.js"; export type { CtxSharedSizeLimitExceededErrorOptions, CtxSharedSizeLimitExceededPayload, CtxSharedSizeLimitPhase, CtxSharedSizeViolation, -} from './ctx-shared-quota.js'; +} from "./ctx-shared-quota.js"; // ctx.shared serialization — terminal JSON encoding failures at enforcement boundaries export { @@ -87,39 +100,54 @@ export { CtxSharedSerializationError, ctxSharedSerializationErrorPayloadSchema, isCtxSharedSerializationErrorPayload, -} from './ctx-shared-serialization.js'; +} from "./ctx-shared-serialization.js"; export type { CtxSharedSerializationErrorOptions, CtxSharedSerializationErrorPayload, CtxSharedSerializationPhase, -} from './ctx-shared-serialization.js'; +} from "./ctx-shared-serialization.js"; // Closed platform retry-classification registry. -export { isNonRetryableStepErrorPayload, parseNonRetryableStepErrorPayload } from './non-retryable-step-error.js'; -export type { NonRetryableStepErrorPayload } from './non-retryable-step-error.js'; +export { + isNonRetryableStepErrorPayload, + parseNonRetryableStepErrorPayload, +} from "./non-retryable-step-error.js"; +export type { NonRetryableStepErrorPayload } from "./non-retryable-step-error.js"; // Injected run configuration — the seam a step reads a chosen resource handle // from (the entry input the setup panel's settings / resource picker drive). -export { resolveResourceHandle } from './config.js'; -export type { ResolveResourceHandleOptions } from './config.js'; +export { resolveResourceHandle } from "./config.js"; +export type { ResolveResourceHandleOptions } from "./config.js"; // Introspection — zod→JSON-Schema conversion + step/workflow input contracts. // Shared by engine tooling and the build phase (runs outside the engine). -export { zodToJsonSchema, exampleFromJsonSchema, stepInputContract, workflowInputContract } from './introspection.js'; -export type { StepInputContract, AgentInputContract } from './introspection.js'; +export { + zodToJsonSchema, + exampleFromJsonSchema, + stepInputContract, + workflowInputContract, +} from "./introspection.js"; +export type { StepInputContract, AgentInputContract } from "./introspection.js"; // Manifest types, Zod schema, and generator — the build→engine contract. -export { MANIFEST_PROTOCOL, agentManifestSchema } from './manifest.js'; -export type { AgentManifest, AgentStepManifest, ManifestTransition } from './manifest.js'; +export { MANIFEST_PROTOCOL, agentManifestSchema } from "./manifest.js"; +export type { + AgentManifest, + AgentStepManifest, + ManifestTransition, +} from "./manifest.js"; // Multi-agent package inventory — separate from the single-agent build manifest. -export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema } from './package-inventory.js'; +export { + PACKAGE_INVENTORY_PROTOCOL, + packageInventorySchema, +} from "./package-inventory.js"; export type { PackageInventory, PackageInventoryAgent, PackageInventoryIdentityIssue, PackageInventoryVersion, -} from './package-inventory.js'; +} from "./package-inventory.js"; // Package-scoped relationship evidence — separate from identity inventory. export { @@ -131,7 +159,7 @@ export { packageGraphEvidenceStaticResultSchema, packageGraphRuntimeEvidenceEventSchema, projectPackageGraphEvidence, -} from './package-graph-evidence.js'; +} from "./package-graph-evidence.js"; export type { AppendPackageGraphRuntimeEvidenceEventResult, CreatePackageGraphEvidenceStaticResultInput, @@ -155,7 +183,7 @@ export type { PackageGraphRuntimeEvidenceState, PackageGraphStaticEvidenceCandidate, PackageGraphStaticEvidenceState, -} from './package-graph-evidence.js'; +} from "./package-graph-evidence.js"; // Package-scoped AgentFacts — factual metadata keyed by inventory agentKey. export { @@ -170,7 +198,7 @@ export { packageAgentFactsRecordSchema, packageAgentFactsSnapshotSchema, packageAgentFactsSourceReferenceSchema, -} from './package-agent-facts.js'; +} from "./package-agent-facts.js"; export type { CreatePackageAgentFactsSnapshotInput, PackageAgentFactsCapabilityField, @@ -186,8 +214,12 @@ export type { PackageAgentFactsSnapshot, PackageAgentFactsSourceReference, PackageAgentFactsStringField, -} from './package-agent-facts.js'; +} from "./package-agent-facts.js"; // Manifest generator + graph validation — called by the build phase. -export { buildManifest, validateGraph, assertValidGraph } from './build-manifest.js'; -export type { GraphValidation } from './build-manifest.js'; +export { + buildManifest, + validateGraph, + assertValidGraph, +} from "./build-manifest.js"; +export type { GraphValidation } from "./build-manifest.js"; diff --git a/packages/agent/src/package-agent-facts.spec.ts b/packages/agent/src/package-agent-facts.spec.ts new file mode 100644 index 00000000..ab61bf1b --- /dev/null +++ b/packages/agent/src/package-agent-facts.spec.ts @@ -0,0 +1,273 @@ +import { + PACKAGE_AGENT_FACTS_PROTOCOL, + PACKAGE_INVENTORY_PROTOCOL, + canonicalPackageAgentFactsJson, + createPackageAgentFactsSnapshot, + packageAgentFactsSnapshotSchema, + type PackageAgentFactsProducer, + type PackageInventory, +} from "./index.js"; + +const SHA_A = `sha256:${"a".repeat(64)}` as const; +const SHA_B = `sha256:${"b".repeat(64)}` as const; +const EXTRACTOR: PackageAgentFactsProducer = { + id: "sapiom.agent-card", + version: "1.0.0", +}; + +function inventory(): PackageInventory { + return { + protocol: PACKAGE_INVENTORY_PROTOCOL, + version: { + kind: "working-tree", + workspaceKey: "workspace-acme", + revision: SHA_A, + }, + status: "complete", + agents: [ + { + agentKey: "research", + identityStatus: "canonical", + path: "agents/research", + entrypoint: "index.ts", + }, + { + agentKey: "coordinator", + identityStatus: "canonical", + path: "agents/coordinator", + entrypoint: "index.ts", + }, + ], + }; +} + +function snapshot(cards: readonly unknown[], sourceInventory = inventory()) { + return createPackageAgentFactsSnapshot( + { scope: sourceInventory.version, extractor: EXTRACTOR, cards }, + sourceInventory, + ); +} + +describe("package AgentFacts protocol 1", () => { + it("normalizes complete authored facts under authoritative inventory agent keys", () => { + const normalized = snapshot([ + { + agentKey: "research", + sourceReferences: [ + { kind: "source-card", ref: "card:research" }, + { kind: "source-card", ref: "card:research" }, + ], + directReferences: [ + { kind: "manifest-field", ref: "manifest:research" }, + ], + evidenceReferences: [ + { kind: "graph-evidence-record", ref: "edge:coordinator.research" }, + ], + description: "Researches public filings.", + inputSchema: { + properties: { ticker: { type: "string" } }, + type: "object", + }, + outputSchema: { + type: "object", + properties: { memo: { type: "string" } }, + }, + declaredCapabilities: ["web.scrape", "llm.run", "web.scrape"], + observed: [ + { + kind: "capability-call", + capability: "database.query", + reference: { kind: "agent-facts-evidence", ref: "obs:query" }, + }, + ], + }, + { + agentKey: "coordinator", + description: null, + inputSchema: null, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }, + ]); + + expect(normalized.protocol).toBe(PACKAGE_AGENT_FACTS_PROTOCOL); + expect(normalized.agents.map((agent) => agent.agentKey)).toEqual([ + "coordinator", + "research", + ]); + expect(normalized.agents[1]).toMatchObject({ + agentKey: "research", + description: { status: "known", value: "Researches public filings." }, + capabilities: { + declared: { status: "known", values: ["llm.run", "web.scrape"] }, + observed: { status: "known", values: ["database.query"] }, + }, + completeness: { status: "complete" }, + }); + expect(normalized.agents[1]?.references.source).toEqual([ + { kind: "source-card", ref: "card:research" }, + ]); + expect(normalized.agents[1]?.references.evidence).toEqual([ + { kind: "agent-facts-evidence", ref: "obs:query" }, + { kind: "graph-evidence-record", ref: "edge:coordinator.research" }, + ]); + expect(normalized.agents[1]?.summary).toBe( + "agentKey: research; authored description: Researches public filings.; input schema: known; output schema: known; declared capabilities: llm.run, web.scrape; observed capabilities: database.query", + ); + expect(packageAgentFactsSnapshotSchema.parse(normalized)).toEqual( + normalized, + ); + }); + + it("keeps partial and missing card extraction from invalidating agent nodes", () => { + const normalized = snapshot([ + { + agentKey: "research", + description: "Reads source documents.", + declaredCapabilities: ["web.scrape"], + completeness: { + status: "partial", + diagnostics: [ + { + code: "incomplete-extraction", + severity: "warning", + agentKey: "research", + }, + ], + }, + }, + ]); + + expect(normalized.agents.map((agent) => agent.agentKey)).toEqual([ + "coordinator", + "research", + ]); + expect(normalized.agents[0]).toMatchObject({ + agentKey: "coordinator", + description: { status: "unknown" }, + inputSchema: { status: "unknown" }, + outputSchema: { status: "unknown" }, + capabilities: { + declared: { status: "unknown" }, + observed: { status: "unknown" }, + }, + completeness: { + status: "unknown", + diagnostics: [ + { + code: "missing-card", + severity: "warning", + agentKey: "coordinator", + }, + ], + }, + }); + expect(normalized.agents[1]?.completeness.status).toBe("partial"); + expect(normalized.diagnostics).toContainEqual({ + code: "missing-card", + severity: "warning", + agentKey: "coordinator", + }); + }); + + it("emits byte-identical normalized output for equivalent unordered inputs", () => { + const first = snapshot([ + { + agentKey: "research", + declaredCapabilities: ["z.capability", "a.capability"], + inputSchema: { z: true, a: { b: 1 } }, + observed: [ + { kind: "capability-call", capability: "email.send" }, + { kind: "capability-call", capability: "database.query" }, + ], + }, + { agentKey: "coordinator", observed: [] }, + ]); + const second = snapshot([ + { agentKey: "coordinator", observed: [] }, + { + agentKey: "research", + inputSchema: { a: { b: 1 }, z: true }, + observed: [ + { kind: "capability-call", capability: "database.query" }, + { kind: "capability-call", capability: "email.send" }, + ], + declaredCapabilities: ["a.capability", "z.capability"], + }, + ]); + + expect(canonicalPackageAgentFactsJson(first)).toBe( + canonicalPackageAgentFactsJson(second), + ); + expect(first.snapshotId).toBe(second.snapshotId); + }); + + it("rejects or ignores unsupported observed facts without inventing capabilities", () => { + const normalized = snapshot([ + { + agentKey: "coordinator", + observed: [ + { kind: "tool-result", capability: "llm.run" }, + { kind: "capability-call", capability: "" }, + { kind: "capability-call", capability: "email.send" }, + ], + }, + { agentKey: "research" }, + { agentKey: "ghost", description: "Unknown inventory member." }, + { nope: true }, + ]); + + expect(normalized.agents[0]?.capabilities.observed).toEqual({ + status: "known", + values: ["email.send"], + }); + expect(normalized.diagnostics).toEqual([ + { code: "invalid-card", severity: "warning" }, + { + code: "invalid-observation", + severity: "warning", + agentKey: "coordinator", + }, + { + code: "unsupported-observed-fact", + severity: "warning", + agentKey: "coordinator", + }, + { + code: "unknown-agent-key", + severity: "warning", + agentKey: "ghost", + }, + ]); + }); + + it("does not infer semantic prose or relationships from inventory names", () => { + const normalized = snapshot([{ agentKey: "coordinator" }]); + + expect(normalized.agents[0]?.summary).toBe( + "agentKey: coordinator; authored description: unknown; input schema: unknown; output schema: unknown; declared capabilities: unknown; observed capabilities: unknown", + ); + expect(normalized.agents[0]?.references.evidence).toEqual([]); + expect(normalized.agents[1]?.summary).toBe( + "agentKey: research; authored description: unknown; input schema: unknown; output schema: unknown; declared capabilities: unknown; observed capabilities: unknown", + ); + }); + + it("requires the exact package inventory version instead of remapping identity", () => { + expect(() => + createPackageAgentFactsSnapshot( + { + scope: { + kind: "working-tree", + workspaceKey: "workspace-acme", + revision: SHA_B, + }, + extractor: EXTRACTOR, + cards: [], + }, + inventory(), + ), + ).toThrow(/scope must match/); + }); +}); diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts index 2fc90726..aaca39f1 100644 --- a/packages/agent/src/package-agent-facts.ts +++ b/packages/agent/src/package-agent-facts.ts @@ -104,15 +104,21 @@ const capabilitySchema = z ); const stringOrNullFieldSchema = z.discriminatedUnion("status", [ - z.object({ status: z.literal("known"), value: z.string().nullable() }).strict(), + z + .object({ status: z.literal("known"), value: z.string().nullable() }) + .strict(), z.object({ status: z.literal("unknown") }).strict(), ]); const schemaFieldSchema = z.discriminatedUnion("status", [ - z.object({ status: z.literal("known"), value: jsonObjectSchema.nullable() }).strict(), + z + .object({ status: z.literal("known"), value: jsonObjectSchema.nullable() }) + .strict(), z.object({ status: z.literal("unknown") }).strict(), ]); const capabilityFieldSchema = z.discriminatedUnion("status", [ - z.object({ status: z.literal("known"), values: z.array(capabilitySchema) }).strict(), + z + .object({ status: z.literal("known"), values: z.array(capabilitySchema) }) + .strict(), z.object({ status: z.literal("unknown") }).strict(), ]); @@ -189,9 +195,15 @@ const observedCapabilityObservationSchema = z const cardInputSchema = z .object({ agentKey: z.string(), - sourceReferences: z.array(packageAgentFactsSourceReferenceSchema).optional(), - directReferences: z.array(packageAgentFactsDirectReferenceSchema).optional(), - evidenceReferences: z.array(packageAgentFactsEvidenceReferenceSchema).optional(), + sourceReferences: z + .array(packageAgentFactsSourceReferenceSchema) + .optional(), + directReferences: z + .array(packageAgentFactsDirectReferenceSchema) + .optional(), + evidenceReferences: z + .array(packageAgentFactsEvidenceReferenceSchema) + .optional(), description: z.string().nullable().optional(), inputSchema: jsonObjectSchema.nullable().optional(), outputSchema: jsonObjectSchema.nullable().optional(), @@ -315,6 +327,16 @@ function compareText(left: string, right: string): number { return left === right ? 0 : left < right ? -1 : 1; } +function scopeMatches( + left: PackageInventoryVersion, + right: PackageInventoryVersion, +): boolean { + return ( + canonicalPackageAgentFactsJson(left) === + canonicalPackageAgentFactsJson(right) + ); +} + function canonicalizeJsonObject(value: T): T { return JSON.parse(canonicalPackageAgentFactsJson(value)) as T; } @@ -323,7 +345,10 @@ function normalizeCapabilities(capabilities: readonly string[]): string[] { return [...new Set(capabilities)].sort(compareText); } -function normalizeByCanonical(values: readonly T[], parse: (value: T) => T): T[] { +function normalizeByCanonical( + values: readonly T[], + parse: (value: T) => T, +): T[] { return [ ...new Map( values @@ -358,7 +383,9 @@ function normalizeDiagnostics( return [ ...new Map( diagnostics - .map((diagnostic) => packageAgentFactsDiagnosticSchema.parse(diagnostic)) + .map((diagnostic) => + packageAgentFactsDiagnosticSchema.parse(diagnostic), + ) .sort(compareDiagnostic) .map((diagnostic) => [ canonicalPackageAgentFactsJson(diagnostic), @@ -483,15 +510,24 @@ function recordFromCard( : { status: "unknown" }; const inputSchema: PackageAgentFactsSchemaField = "inputSchema" in card - ? { status: "known", value: canonicalizeJsonObject(card.inputSchema ?? null) } + ? { + status: "known", + value: canonicalizeJsonObject(card.inputSchema ?? null), + } : { status: "unknown" }; const outputSchema: PackageAgentFactsSchemaField = "outputSchema" in card - ? { status: "known", value: canonicalizeJsonObject(card.outputSchema ?? null) } + ? { + status: "known", + value: canonicalizeJsonObject(card.outputSchema ?? null), + } : { status: "unknown" }; const declared: PackageAgentFactsCapabilityField = card.declaredCapabilities !== undefined - ? { status: "known", values: normalizeCapabilities(card.declaredCapabilities) } + ? { + status: "known", + values: normalizeCapabilities(card.declaredCapabilities), + } : { status: "unknown" }; const observed: PackageAgentFactsCapabilityField = card.observed !== undefined @@ -540,8 +576,8 @@ function snapshotWithoutId( ) as Omit; } -export const packageAgentFactsSnapshotSchema = - snapshotBaseSchema.superRefine((snapshot, context) => { +export const packageAgentFactsSnapshotSchema = snapshotBaseSchema.superRefine( + (snapshot, context) => { const sortedAgents = [...snapshot.agents].sort((left, right) => compareText(left.agentKey, right.agentKey), ); @@ -588,8 +624,9 @@ export const packageAgentFactsSnapshotSchema = } } if ( - canonicalPackageAgentFactsJson(normalizeDiagnostics(snapshot.diagnostics)) !== - canonicalPackageAgentFactsJson(snapshot.diagnostics) + canonicalPackageAgentFactsJson( + normalizeDiagnostics(snapshot.diagnostics), + ) !== canonicalPackageAgentFactsJson(snapshot.diagnostics) ) { context.addIssue({ code: "custom", @@ -607,7 +644,8 @@ export const packageAgentFactsSnapshotSchema = message: "Snapshot ID does not match canonical snapshot content", }); } - }); + }, +); /** * Normalize factual per-agent metadata against one authoritative inventory. @@ -622,11 +660,18 @@ export function createPackageAgentFactsSnapshot( ): PackageAgentFactsSnapshot { const parsedInput = createSnapshotInputSchema.parse(input); const inventory = packageInventorySchema.parse(inventoryInput); + if (!scopeMatches(parsedInput.scope, inventory.version)) { + throw new TypeError( + "AgentFacts snapshot scope must match the package inventory version", + ); + } const cardsByAgentKey = new Map(); const diagnostics: PackageAgentFactsDiagnostic[] = [ ...(parsedInput.diagnostics ?? []), ]; - const inventoryKeys = new Set(inventory.agents.map((agent) => agent.agentKey)); + const inventoryKeys = new Set( + inventory.agents.map((agent) => agent.agentKey), + ); for (const rawCard of parsedInput.cards) { const parsed = cardInputSchema.safeParse(rawCard); @@ -682,7 +727,9 @@ export function createPackageAgentFactsSnapshot( scope: parsedInput.scope, extractor: parsedInput.extractor, inventoryStatus: inventory.status, - agents: agents.sort((left, right) => compareText(left.agentKey, right.agentKey)), + agents: agents.sort((left, right) => + compareText(left.agentKey, right.agentKey), + ), diagnostics: normalizeDiagnostics(diagnostics), }; const snapshot = { From 3dead5b20d00c5ba70517468aeed95cf903c3603 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:38:45 +0000 Subject: [PATCH 3/7] fix(agent): harden agent facts snapshot validation --- .../agent/src/package-agent-facts.spec.ts | 35 ++++++++++++++++++ packages/agent/src/package-agent-facts.ts | 36 ++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/package-agent-facts.spec.ts b/packages/agent/src/package-agent-facts.spec.ts index ab61bf1b..0b16034c 100644 --- a/packages/agent/src/package-agent-facts.spec.ts +++ b/packages/agent/src/package-agent-facts.spec.ts @@ -254,6 +254,41 @@ describe("package AgentFacts protocol 1", () => { ); }); + it("rejects tampered summaries and non-normalized capability sets", () => { + const normalized = snapshot([ + { agentKey: "coordinator", declaredCapabilities: ["a", "b"] }, + { agentKey: "research" }, + ]); + + expect(() => + packageAgentFactsSnapshotSchema.parse({ + ...normalized, + agents: [ + { + ...normalized.agents[0]!, + summary: "Research coordinator with inferred routing behavior.", + }, + normalized.agents[1]!, + ], + }), + ).toThrow(/Summary does not match/); + expect(() => + packageAgentFactsSnapshotSchema.parse({ + ...normalized, + agents: [ + { + ...normalized.agents[0]!, + capabilities: { + ...normalized.agents[0]!.capabilities, + declared: { status: "known", values: ["b", "a", "b"] }, + }, + }, + normalized.agents[1]!, + ], + }), + ).toThrow(/Capabilities must be unique/); + }); + it("requires the exact package inventory version instead of remapping identity", () => { expect(() => createPackageAgentFactsSnapshot( diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts index aaca39f1..529e0645 100644 --- a/packages/agent/src/package-agent-facts.ts +++ b/packages/agent/src/package-agent-facts.ts @@ -237,7 +237,41 @@ export const packageAgentFactsRecordSchema = z completeness: packageAgentFactsCompletenessSchema, summary: z.string(), }) - .strict(); + .strict() + .superRefine((record, context) => { + for (const [field, capabilities] of [ + ["declared", record.capabilities.declared], + ["observed", record.capabilities.observed], + ] as const) { + if ( + capabilities.status === "known" && + canonicalPackageAgentFactsJson(capabilities.values) !== + canonicalPackageAgentFactsJson( + normalizeCapabilities(capabilities.values), + ) + ) { + context.addIssue({ + code: "custom", + path: ["capabilities", field, "values"], + message: "Capabilities must be unique and canonically ordered", + }); + } + } + const expectedSummary = summarizeAgentFacts(record.agentKey, { + description: record.description, + inputSchema: record.inputSchema, + outputSchema: record.outputSchema, + declared: record.capabilities.declared, + observed: record.capabilities.observed, + }); + if (record.summary !== expectedSummary) { + context.addIssue({ + code: "custom", + path: ["summary"], + message: "Summary does not match deterministic template content", + }); + } + }); export type PackageAgentFactsRecord = z.infer< typeof packageAgentFactsRecordSchema >; From 9221e0f045357f6ebf5da43dae8dafbd921f96b3 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:39:38 +0000 Subject: [PATCH 4/7] fix(agent): expose agent facts input schema --- packages/agent/src/index.ts | 3 +++ packages/agent/src/package-agent-facts.ts | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index da6950c6..1a13a91d 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -190,7 +190,9 @@ export { PACKAGE_AGENT_FACTS_PROTOCOL, canonicalPackageAgentFactsJson, createPackageAgentFactsSnapshot, + packageAgentFactsCardInputSchema, packageAgentFactsCompletenessSchema, + packageAgentFactsDiagnosticCodeSchema, packageAgentFactsDiagnosticSchema, packageAgentFactsDirectReferenceSchema, packageAgentFactsEvidenceReferenceSchema, @@ -201,6 +203,7 @@ export { } from "./package-agent-facts.js"; export type { CreatePackageAgentFactsSnapshotInput, + PackageAgentFactsCardInput, PackageAgentFactsCapabilityField, PackageAgentFactsCompleteness, PackageAgentFactsDiagnostic, diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts index 529e0645..d6dfecfa 100644 --- a/packages/agent/src/package-agent-facts.ts +++ b/packages/agent/src/package-agent-facts.ts @@ -192,7 +192,7 @@ const observedCapabilityObservationSchema = z }) .strict(); -const cardInputSchema = z +export const packageAgentFactsCardInputSchema = z .object({ agentKey: z.string(), sourceReferences: z @@ -213,7 +213,9 @@ const cardInputSchema = z }) .strict(); -export type PackageAgentFactsCardInput = z.infer; +export type PackageAgentFactsCardInput = z.infer< + typeof packageAgentFactsCardInputSchema +>; export const packageAgentFactsRecordSchema = z .object({ @@ -708,7 +710,7 @@ export function createPackageAgentFactsSnapshot( ); for (const rawCard of parsedInput.cards) { - const parsed = cardInputSchema.safeParse(rawCard); + const parsed = packageAgentFactsCardInputSchema.safeParse(rawCard); if (!parsed.success) { diagnostics.push({ code: "invalid-card", severity: "warning" }); continue; From 7abf1addea364eee7b8941247628e96aa245e409 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:42:55 +0000 Subject: [PATCH 5/7] fix(agent): make agent facts card conflicts deterministic --- .../agent/src/package-agent-facts.spec.ts | 85 ++++++++++++++++++ packages/agent/src/package-agent-facts.ts | 88 +++++++++++++++---- 2 files changed, 157 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/package-agent-facts.spec.ts b/packages/agent/src/package-agent-facts.spec.ts index 0b16034c..56458fb0 100644 --- a/packages/agent/src/package-agent-facts.spec.ts +++ b/packages/agent/src/package-agent-facts.spec.ts @@ -171,6 +171,34 @@ describe("package AgentFacts protocol 1", () => { }); }); + it("marks sparse cards partial instead of silently claiming complete extraction", () => { + const normalized = snapshot([ + { agentKey: "coordinator" }, + { + agentKey: "research", + description: "Reads source documents.", + inputSchema: null, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }, + ]); + + expect(normalized.agents[0]?.completeness).toEqual({ + status: "partial", + diagnostics: [ + { + code: "incomplete-extraction", + severity: "warning", + agentKey: "coordinator", + }, + ], + }); + expect(normalized.agents[1]?.completeness).toEqual({ + status: "complete", + }); + }); + it("emits byte-identical normalized output for equivalent unordered inputs", () => { const first = snapshot([ { @@ -203,6 +231,53 @@ describe("package AgentFacts protocol 1", () => { expect(first.snapshotId).toBe(second.snapshotId); }); + it("keeps conflicting duplicate cards order-independent by preserving the agent as unknown", () => { + const firstCard = { + agentKey: "research", + description: "First authored description.", + inputSchema: null, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }; + const secondCard = { + agentKey: "research", + description: "Second authored description.", + inputSchema: null, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }; + const first = snapshot([ + { agentKey: "coordinator" }, + firstCard, + secondCard, + ]); + const second = snapshot([ + secondCard, + firstCard, + { agentKey: "coordinator" }, + ]); + + expect(canonicalPackageAgentFactsJson(first)).toBe( + canonicalPackageAgentFactsJson(second), + ); + expect(first.agents[1]).toMatchObject({ + agentKey: "research", + description: { status: "unknown" }, + completeness: { + status: "unknown", + diagnostics: [ + { + code: "duplicate-card", + severity: "warning", + agentKey: "research", + }, + ], + }, + }); + }); + it("rejects or ignores unsupported observed facts without inventing capabilities", () => { const normalized = snapshot([ { @@ -224,6 +299,11 @@ describe("package AgentFacts protocol 1", () => { }); expect(normalized.diagnostics).toEqual([ { code: "invalid-card", severity: "warning" }, + { + code: "incomplete-extraction", + severity: "warning", + agentKey: "coordinator", + }, { code: "invalid-observation", severity: "warning", @@ -239,6 +319,11 @@ describe("package AgentFacts protocol 1", () => { severity: "warning", agentKey: "ghost", }, + { + code: "incomplete-extraction", + severity: "warning", + agentKey: "research", + }, ]); }); diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts index d6dfecfa..24447adb 100644 --- a/packages/agent/src/package-agent-facts.ts +++ b/packages/agent/src/package-agent-facts.ts @@ -441,6 +441,36 @@ function normalizeCompleteness( }; } +function mergeIncompleteCompleteness( + agentKey: string, + completeness: PackageAgentFactsCompleteness | undefined, + diagnostics: readonly PackageAgentFactsDiagnostic[], +): { + completeness: PackageAgentFactsCompleteness; + diagnostics: PackageAgentFactsDiagnostic[]; +} { + if (diagnostics.length === 0) { + return { + completeness: completeness ?? { status: "complete" }, + diagnostics: [], + }; + } + const partialDiagnostics = normalizeDiagnostics([ + ...(completeness?.status === "partial" || completeness?.status === "unknown" + ? completeness.diagnostics + : []), + ...diagnostics, + { code: "incomplete-extraction", severity: "warning", agentKey }, + ]); + return { + completeness: { + status: completeness?.status === "unknown" ? "unknown" : "partial", + diagnostics: partialDiagnostics, + }, + diagnostics: partialDiagnostics, + }; +} + function unknownRecord( agentKey: string, diagnostics: readonly PackageAgentFactsDiagnostic[], @@ -569,9 +599,27 @@ function recordFromCard( card.observed !== undefined ? { status: "known", values: normalizeCapabilities(observedCapabilities) } : { status: "unknown" }; - const completeness = normalizeCompleteness( - card.completeness ?? { status: "complete" }, + const missingFieldDiagnostics = [ + description, + inputSchema, + outputSchema, + declared, + observed, + ].some((field) => field.status === "unknown") + ? [ + { + code: "incomplete-extraction", + severity: "warning", + agentKey, + } as const, + ] + : []; + const mergedCompleteness = mergeIncompleteCompleteness( + agentKey, + card.completeness, + [...missingFieldDiagnostics, ...diagnostics], ); + const completeness = normalizeCompleteness(mergedCompleteness.completeness); const record = { agentKey, description, @@ -601,7 +649,10 @@ function recordFromCard( observed, }), }; - return { record: packageAgentFactsRecordSchema.parse(record), diagnostics }; + return { + record: packageAgentFactsRecordSchema.parse(record), + diagnostics: mergedCompleteness.diagnostics, + }; } function snapshotWithoutId( @@ -701,7 +752,7 @@ export function createPackageAgentFactsSnapshot( "AgentFacts snapshot scope must match the package inventory version", ); } - const cardsByAgentKey = new Map(); + const cardsByAgentKey = new Map(); const diagnostics: PackageAgentFactsDiagnostic[] = [ ...(parsedInput.diagnostics ?? []), ]; @@ -724,21 +775,16 @@ export function createPackageAgentFactsSnapshot( }); continue; } - if (cardsByAgentKey.has(card.agentKey)) { - diagnostics.push({ - code: "duplicate-card", - severity: "warning", - agentKey: card.agentKey, - }); - continue; - } - cardsByAgentKey.set(card.agentKey, card); + cardsByAgentKey.set(card.agentKey, [ + ...(cardsByAgentKey.get(card.agentKey) ?? []), + card, + ]); } const agents: PackageAgentFactsRecord[] = []; for (const agent of inventory.agents) { - const card = cardsByAgentKey.get(agent.agentKey); - if (!card) { + const cards = cardsByAgentKey.get(agent.agentKey) ?? []; + if (cards.length === 0) { const missing = { code: "missing-card", severity: "warning", @@ -748,9 +794,19 @@ export function createPackageAgentFactsSnapshot( agents.push(unknownRecord(agent.agentKey, [missing])); continue; } + if (cards.length > 1) { + const duplicate = { + code: "duplicate-card", + severity: "warning", + agentKey: agent.agentKey, + } as const; + diagnostics.push(duplicate); + agents.push(unknownRecord(agent.agentKey, [duplicate])); + continue; + } const { record, diagnostics: cardDiagnostics } = recordFromCard( agent.agentKey, - card, + cards[0]!, ); diagnostics.push(...cardDiagnostics); agents.push(record); From e64be81f8bb4b82f7122e9ec14fb6c5de2c713f8 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:49:50 +0000 Subject: [PATCH 6/7] fix(agent): enforce agent facts completeness invariants --- .../agent/src/package-agent-facts.spec.ts | 142 +++++++++++++++++- packages/agent/src/package-agent-facts.ts | 136 ++++++++++++++++- 2 files changed, 270 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/package-agent-facts.spec.ts b/packages/agent/src/package-agent-facts.spec.ts index 56458fb0..7e0f7668 100644 --- a/packages/agent/src/package-agent-facts.spec.ts +++ b/packages/agent/src/package-agent-facts.spec.ts @@ -3,6 +3,7 @@ import { PACKAGE_INVENTORY_PROTOCOL, canonicalPackageAgentFactsJson, createPackageAgentFactsSnapshot, + packageAgentFactsRecordSchema, packageAgentFactsSnapshotSchema, type PackageAgentFactsProducer, type PackageInventory, @@ -113,7 +114,7 @@ describe("package AgentFacts protocol 1", () => { { kind: "graph-evidence-record", ref: "edge:coordinator.research" }, ]); expect(normalized.agents[1]?.summary).toBe( - "agentKey: research; authored description: Researches public filings.; input schema: known; output schema: known; declared capabilities: llm.run, web.scrape; observed capabilities: database.query", + "agentKey: research; authored description: Researches public filings.; input schema: valid; output schema: valid; declared capabilities: llm.run, web.scrape; observed capabilities: database.query", ); expect(packageAgentFactsSnapshotSchema.parse(normalized)).toEqual( normalized, @@ -339,6 +340,145 @@ describe("package AgentFacts protocol 1", () => { ); }); + it("downgrades agent-scoped dynamic-data diagnostics to partial completeness", () => { + const normalized = createPackageAgentFactsSnapshot( + { + scope: inventory().version, + extractor: EXTRACTOR, + diagnostics: [ + { + code: "dynamic-data", + severity: "warning", + agentKey: "research", + }, + ], + cards: [ + { + agentKey: "coordinator", + description: null, + inputSchema: null, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }, + { + agentKey: "research", + description: "Runtime-defined behavior.", + inputSchema: null, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }, + ], + }, + inventory(), + ); + + expect(normalized.agents[1]?.completeness).toEqual({ + status: "partial", + diagnostics: [ + { + code: "dynamic-data", + severity: "warning", + agentKey: "research", + }, + { + code: "incomplete-extraction", + severity: "warning", + agentKey: "research", + }, + ], + }); + }); + + it("rejects complete records with unknown fields or partial diagnostics", () => { + const normalized = snapshot([ + { agentKey: "coordinator" }, + { agentKey: "research" }, + ]); + const completeWithUnknown = { + ...normalized.agents[0]!, + completeness: { status: "complete" }, + }; + + expect(() => + packageAgentFactsRecordSchema.parse(completeWithUnknown), + ).toThrow(/complete AgentFacts record/); + expect(() => + packageAgentFactsSnapshotSchema.parse({ + ...normalized, + agents: [completeWithUnknown, normalized.agents[1]!], + }), + ).toThrow(/complete AgentFacts record/); + expect(() => + packageAgentFactsSnapshotSchema.parse({ + ...normalized, + agents: [ + { + ...normalized.agents[0]!, + description: { status: "known", value: null }, + inputSchema: { status: "known", validation: "valid", value: null }, + outputSchema: { status: "known", validation: "valid", value: null }, + capabilities: { + declared: { status: "known", values: [] }, + observed: { status: "known", values: [] }, + }, + completeness: { status: "complete" }, + summary: + "agentKey: coordinator; authored description: none; input schema: valid; output schema: valid; declared capabilities: none; observed capabilities: none", + }, + normalized.agents[1]!, + ], + diagnostics: [ + { + code: "dynamic-data", + severity: "warning", + agentKey: "coordinator", + }, + ], + }), + ).toThrow(/diagnostics requiring partial/); + }); + + it("preserves but labels invalid JSON Schemas and prevents complete extraction", () => { + const normalized = snapshot([ + { + agentKey: "coordinator", + description: null, + inputSchema: { type: "strng" }, + outputSchema: true, + declaredCapabilities: [], + observed: [], + }, + { + agentKey: "research", + description: null, + inputSchema: false, + outputSchema: null, + declaredCapabilities: [], + observed: [], + }, + ]); + + expect(normalized.agents[0]?.inputSchema).toEqual({ + status: "known", + validation: "invalid", + value: { type: "strng" }, + }); + expect(normalized.agents[0]?.outputSchema).toEqual({ + status: "known", + validation: "valid", + value: true, + }); + expect(normalized.agents[0]?.completeness.status).toBe("partial"); + expect(normalized.agents[0]?.summary).toContain("input schema: invalid"); + expect(normalized.agents[1]?.inputSchema).toEqual({ + status: "known", + validation: "valid", + value: false, + }); + }); + it("rejects tampered summaries and non-normalized capability sets", () => { const normalized = snapshot([ { agentKey: "coordinator", declaredCapabilities: ["a", "b"] }, diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts index 24447adb..593d0141 100644 --- a/packages/agent/src/package-agent-facts.ts +++ b/packages/agent/src/package-agent-facts.ts @@ -41,6 +41,9 @@ const jsonValueSchema: z.ZodType = z.lazy(() => ]), ); const jsonObjectSchema = z.record(z.string(), jsonValueSchema); +const jsonSchemaValueSchema = z.union([z.boolean(), jsonObjectSchema]); +const jsonSchemaOrNullSchema = jsonSchemaValueSchema.nullable(); +type JsonSchemaOrNull = z.infer; function referenceSchema(kind: Kind) { return z @@ -111,7 +114,11 @@ const stringOrNullFieldSchema = z.discriminatedUnion("status", [ ]); const schemaFieldSchema = z.discriminatedUnion("status", [ z - .object({ status: z.literal("known"), value: jsonObjectSchema.nullable() }) + .object({ + status: z.literal("known"), + validation: z.enum(["valid", "invalid"]), + value: jsonSchemaOrNullSchema, + }) .strict(), z.object({ status: z.literal("unknown") }).strict(), ]); @@ -205,8 +212,8 @@ export const packageAgentFactsCardInputSchema = z .array(packageAgentFactsEvidenceReferenceSchema) .optional(), description: z.string().nullable().optional(), - inputSchema: jsonObjectSchema.nullable().optional(), - outputSchema: jsonObjectSchema.nullable().optional(), + inputSchema: jsonSchemaOrNullSchema.optional(), + outputSchema: jsonSchemaOrNullSchema.optional(), declaredCapabilities: z.array(capabilitySchema).optional(), observed: z.array(z.unknown()).optional(), completeness: packageAgentFactsCompletenessSchema.optional(), @@ -273,6 +280,17 @@ export const packageAgentFactsRecordSchema = z message: "Summary does not match deterministic template content", }); } + if ( + record.completeness.status === "complete" && + recordRequiresPartial(record) + ) { + context.addIssue({ + code: "custom", + path: ["completeness"], + message: + "A complete AgentFacts record cannot contain unknown, invalid, dynamic, or partial facts", + }); + } }); export type PackageAgentFactsRecord = z.infer< typeof packageAgentFactsRecordSchema @@ -377,10 +395,68 @@ function canonicalizeJsonObject(value: T): T { return JSON.parse(canonicalPackageAgentFactsJson(value)) as T; } +const JSON_SCHEMA_TYPES = new Set([ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +]); + +function jsonSchemaValidation(value: JsonSchemaOrNull): "valid" | "invalid" { + if (value === null || typeof value === "boolean") return "valid"; + if ( + !Object.prototype.hasOwnProperty.call(value, "type") || + value.type === undefined + ) { + return "valid"; + } + if (typeof value.type === "string") { + return JSON_SCHEMA_TYPES.has(value.type) ? "valid" : "invalid"; + } + if (Array.isArray(value.type)) { + return value.type.every( + (item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item), + ) + ? "valid" + : "invalid"; + } + return "invalid"; +} + function normalizeCapabilities(capabilities: readonly string[]): string[] { return [...new Set(capabilities)].sort(compareText); } +const PARTIAL_DIAGNOSTIC_CODES = new Set([ + "invalid-observation", + "unsupported-observed-fact", + "incomplete-extraction", + "dynamic-data", +]); + +function diagnosticRequiresPartial( + diagnostic: PackageAgentFactsDiagnostic, +): boolean { + return PARTIAL_DIAGNOSTIC_CODES.has(diagnostic.code); +} + +function recordRequiresPartial(record: PackageAgentFactsRecord): boolean { + return ( + record.description.status === "unknown" || + record.inputSchema.status === "unknown" || + record.inputSchema.validation === "invalid" || + record.outputSchema.status === "unknown" || + record.outputSchema.validation === "invalid" || + record.capabilities.declared.status === "unknown" || + record.capabilities.observed.status === "unknown" || + (record.completeness.status !== "complete" && + record.completeness.diagnostics.some(diagnosticRequiresPartial)) + ); +} + function normalizeByCanonical( values: readonly T[], parse: (value: T) => T, @@ -516,8 +592,14 @@ function summarizeAgentFacts( ? "none" : fields.description.value : "unknown"; - const input = fields.inputSchema.status === "known" ? "known" : "unknown"; - const output = fields.outputSchema.status === "known" ? "known" : "unknown"; + const input = + fields.inputSchema.status === "known" + ? fields.inputSchema.validation + : "unknown"; + const output = + fields.outputSchema.status === "known" + ? fields.outputSchema.validation + : "unknown"; const declared = fields.declared.status === "known" ? fields.declared.values.length === 0 @@ -536,11 +618,12 @@ function summarizeAgentFacts( function recordFromCard( agentKey: string, card: PackageAgentFactsCardInput, + externalDiagnostics: readonly PackageAgentFactsDiagnostic[] = [], ): { record: PackageAgentFactsRecord; diagnostics: PackageAgentFactsDiagnostic[]; } { - const diagnostics: PackageAgentFactsDiagnostic[] = []; + const diagnostics: PackageAgentFactsDiagnostic[] = [...externalDiagnostics]; const observedCapabilities: string[] = []; const evidenceReferences: PackageAgentFactsEvidenceReference[] = [ ...(card.evidenceReferences ?? []), @@ -578,6 +661,7 @@ function recordFromCard( "inputSchema" in card ? { status: "known", + validation: jsonSchemaValidation(card.inputSchema ?? null), value: canonicalizeJsonObject(card.inputSchema ?? null), } : { status: "unknown" }; @@ -585,6 +669,7 @@ function recordFromCard( "outputSchema" in card ? { status: "known", + validation: jsonSchemaValidation(card.outputSchema ?? null), value: canonicalizeJsonObject(card.outputSchema ?? null), } : { status: "unknown" }; @@ -614,10 +699,21 @@ function recordFromCard( } as const, ] : []; + const schemaDiagnostics = [inputSchema, outputSchema].some( + (schema) => schema.status === "known" && schema.validation === "invalid", + ) + ? [ + { + code: "incomplete-extraction", + severity: "warning", + agentKey, + } as const, + ] + : []; const mergedCompleteness = mergeIncompleteCompleteness( agentKey, card.completeness, - [...missingFieldDiagnostics, ...diagnostics], + [...missingFieldDiagnostics, ...schemaDiagnostics, ...diagnostics], ); const completeness = normalizeCompleteness(mergedCompleteness.completeness); const record = { @@ -681,6 +777,9 @@ export const packageAgentFactsSnapshotSchema = snapshotBaseSchema.superRefine( }); } for (const [index, agent] of snapshot.agents.entries()) { + const agentDiagnostics = snapshot.diagnostics.filter( + (diagnostic) => diagnostic.agentKey === agent.agentKey, + ); const normalized = packageAgentFactsRecordSchema.parse({ ...agent, references: { @@ -709,6 +808,17 @@ export const packageAgentFactsSnapshotSchema = snapshotBaseSchema.superRefine( message: "Agent facts record is not normalized", }); } + if ( + agent.completeness.status === "complete" && + agentDiagnostics.some(diagnosticRequiresPartial) + ) { + context.addIssue({ + code: "custom", + path: ["agents", index, "completeness"], + message: + "Agent-scoped diagnostics requiring partial extraction cannot accompany a complete record", + }); + } } if ( canonicalPackageAgentFactsJson( @@ -756,6 +866,17 @@ export function createPackageAgentFactsSnapshot( const diagnostics: PackageAgentFactsDiagnostic[] = [ ...(parsedInput.diagnostics ?? []), ]; + const inputDiagnosticsByAgentKey = new Map< + string, + PackageAgentFactsDiagnostic[] + >(); + for (const diagnostic of parsedInput.diagnostics ?? []) { + if (diagnostic.agentKey === undefined) continue; + inputDiagnosticsByAgentKey.set(diagnostic.agentKey, [ + ...(inputDiagnosticsByAgentKey.get(diagnostic.agentKey) ?? []), + diagnostic, + ]); + } const inventoryKeys = new Set( inventory.agents.map((agent) => agent.agentKey), ); @@ -807,6 +928,7 @@ export function createPackageAgentFactsSnapshot( const { record, diagnostics: cardDiagnostics } = recordFromCard( agent.agentKey, cards[0]!, + inputDiagnosticsByAgentKey.get(agent.agentKey), ); diagnostics.push(...cardDiagnostics); agents.push(record); From 2c0a556de17ee946795e925617e4ffe4a0badbb7 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 22:56:02 +0000 Subject: [PATCH 7/7] fix(agent): validate agent facts json schemas --- packages/agent/package.json | 3 +- .../agent/src/package-agent-facts.spec.ts | 37 +++++++++++-- packages/agent/src/package-agent-facts.ts | 53 ++++++++----------- pnpm-lock.yaml | 3 ++ 4 files changed, 61 insertions(+), 35 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 8f8055df..7fff5d75 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -49,7 +49,8 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@sapiom/tools": "workspace:^" + "@sapiom/tools": "workspace:^", + "ajv": "^8.12.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.0.0" diff --git a/packages/agent/src/package-agent-facts.spec.ts b/packages/agent/src/package-agent-facts.spec.ts index 7e0f7668..9a227a34 100644 --- a/packages/agent/src/package-agent-facts.spec.ts +++ b/packages/agent/src/package-agent-facts.spec.ts @@ -445,7 +445,7 @@ describe("package AgentFacts protocol 1", () => { { agentKey: "coordinator", description: null, - inputSchema: { type: "strng" }, + inputSchema: { properties: "not-an-object" }, outputSchema: true, declaredCapabilities: [], observed: [], @@ -454,7 +454,7 @@ describe("package AgentFacts protocol 1", () => { agentKey: "research", description: null, inputSchema: false, - outputSchema: null, + outputSchema: { required: "not-an-array" }, declaredCapabilities: [], observed: [], }, @@ -463,7 +463,7 @@ describe("package AgentFacts protocol 1", () => { expect(normalized.agents[0]?.inputSchema).toEqual({ status: "known", validation: "invalid", - value: { type: "strng" }, + value: { properties: "not-an-object" }, }); expect(normalized.agents[0]?.outputSchema).toEqual({ status: "known", @@ -477,6 +477,11 @@ describe("package AgentFacts protocol 1", () => { validation: "valid", value: false, }); + expect(normalized.agents[1]?.outputSchema).toEqual({ + status: "known", + validation: "invalid", + value: { required: "not-an-array" }, + }); }); it("rejects tampered summaries and non-normalized capability sets", () => { @@ -514,6 +519,32 @@ describe("package AgentFacts protocol 1", () => { ).toThrow(/Capabilities must be unique/); }); + it("returns safeParse issues instead of throwing from nested snapshot invariants", () => { + const normalized = snapshot([ + { agentKey: "coordinator", declaredCapabilities: ["a", "b"] }, + { agentKey: "research" }, + ]); + const parsed = packageAgentFactsSnapshotSchema.safeParse({ + ...normalized, + agents: [ + { + ...normalized.agents[0]!, + capabilities: { + ...normalized.agents[0]!.capabilities, + declared: { status: "known", values: ["b", "a", "b"] }, + }, + }, + normalized.agents[1]!, + ], + }); + + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("expected invalid snapshot"); + expect(parsed.error.issues.map((issue) => issue.message)).toContain( + "Capabilities must be unique and canonically ordered", + ); + }); + it("requires the exact package inventory version instead of remapping identity", () => { expect(() => createPackageAgentFactsSnapshot( diff --git a/packages/agent/src/package-agent-facts.ts b/packages/agent/src/package-agent-facts.ts index 593d0141..82e1db03 100644 --- a/packages/agent/src/package-agent-facts.ts +++ b/packages/agent/src/package-agent-facts.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; +import Ajv2020 from "ajv/dist/2020.js"; import { z } from "zod/v4"; import { @@ -18,6 +19,10 @@ const SHA256 = /^sha256:[0-9a-f]{64}$/; const OPAQUE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/; const PRODUCER_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$/; const ZERO_DIGEST = `sha256:${"0".repeat(64)}` as const; +const jsonSchemaMetaValidator = new Ajv2020({ + allErrors: true, + strict: false, +}); const digestSchema = z .string() @@ -395,35 +400,9 @@ function canonicalizeJsonObject(value: T): T { return JSON.parse(canonicalPackageAgentFactsJson(value)) as T; } -const JSON_SCHEMA_TYPES = new Set([ - "array", - "boolean", - "integer", - "null", - "number", - "object", - "string", -]); - function jsonSchemaValidation(value: JsonSchemaOrNull): "valid" | "invalid" { - if (value === null || typeof value === "boolean") return "valid"; - if ( - !Object.prototype.hasOwnProperty.call(value, "type") || - value.type === undefined - ) { - return "valid"; - } - if (typeof value.type === "string") { - return JSON_SCHEMA_TYPES.has(value.type) ? "valid" : "invalid"; - } - if (Array.isArray(value.type)) { - return value.type.every( - (item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item), - ) - ? "valid" - : "invalid"; - } - return "invalid"; + if (value === null) return "valid"; + return jsonSchemaMetaValidator.validateSchema(value) ? "valid" : "invalid"; } function normalizeCapabilities(capabilities: readonly string[]): string[] { @@ -780,7 +759,7 @@ export const packageAgentFactsSnapshotSchema = snapshotBaseSchema.superRefine( const agentDiagnostics = snapshot.diagnostics.filter( (diagnostic) => diagnostic.agentKey === agent.agentKey, ); - const normalized = packageAgentFactsRecordSchema.parse({ + const normalizedInput = { ...agent, references: { source: normalizeByCanonical( @@ -797,9 +776,21 @@ export const packageAgentFactsSnapshotSchema = snapshotBaseSchema.superRefine( ), }, completeness: normalizeCompleteness(agent.completeness), - }); + }; + const parsedNormalized = + packageAgentFactsRecordSchema.safeParse(normalizedInput); + if (!parsedNormalized.success) { + for (const issue of parsedNormalized.error.issues) { + context.addIssue({ + code: "custom", + path: ["agents", index, ...issue.path], + message: issue.message, + }); + } + continue; + } if ( - canonicalPackageAgentFactsJson(normalized) !== + canonicalPackageAgentFactsJson(parsedNormalized.data) !== canonicalPackageAgentFactsJson(agent) ) { context.addIssue({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c0a05e4..f4cf561d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@sapiom/tools': specifier: workspace:^ version: link:../tools + ajv: + specifier: ^8.12.0 + version: 8.20.0 devDependencies: '@types/jest': specifier: ^29.5.14