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/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/index.ts b/packages/agent/src/index.ts index 70b6d754..1a13a91d 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,8 +183,46 @@ 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 { + PACKAGE_AGENT_FACTS_PROTOCOL, + canonicalPackageAgentFactsJson, + createPackageAgentFactsSnapshot, + packageAgentFactsCardInputSchema, + packageAgentFactsCompletenessSchema, + packageAgentFactsDiagnosticCodeSchema, + packageAgentFactsDiagnosticSchema, + packageAgentFactsDirectReferenceSchema, + packageAgentFactsEvidenceReferenceSchema, + packageAgentFactsProducerSchema, + packageAgentFactsRecordSchema, + packageAgentFactsSnapshotSchema, + packageAgentFactsSourceReferenceSchema, +} from "./package-agent-facts.js"; +export type { + CreatePackageAgentFactsSnapshotInput, + PackageAgentFactsCardInput, + 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'; +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..9a227a34 --- /dev/null +++ b/packages/agent/src/package-agent-facts.spec.ts @@ -0,0 +1,564 @@ +import { + PACKAGE_AGENT_FACTS_PROTOCOL, + PACKAGE_INVENTORY_PROTOCOL, + canonicalPackageAgentFactsJson, + createPackageAgentFactsSnapshot, + packageAgentFactsRecordSchema, + 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: valid; output schema: valid; 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("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([ + { + 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("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([ + { + 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: "incomplete-extraction", + severity: "warning", + agentKey: "coordinator", + }, + { + code: "invalid-observation", + severity: "warning", + agentKey: "coordinator", + }, + { + code: "unsupported-observed-fact", + severity: "warning", + agentKey: "coordinator", + }, + { + code: "unknown-agent-key", + severity: "warning", + agentKey: "ghost", + }, + { + code: "incomplete-extraction", + severity: "warning", + agentKey: "research", + }, + ]); + }); + + 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("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: { properties: "not-an-object" }, + outputSchema: true, + declaredCapabilities: [], + observed: [], + }, + { + agentKey: "research", + description: null, + inputSchema: false, + outputSchema: { required: "not-an-array" }, + declaredCapabilities: [], + observed: [], + }, + ]); + + expect(normalized.agents[0]?.inputSchema).toEqual({ + status: "known", + validation: "invalid", + value: { properties: "not-an-object" }, + }); + 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, + }); + expect(normalized.agents[1]?.outputSchema).toEqual({ + status: "known", + validation: "invalid", + value: { required: "not-an-array" }, + }); + }); + + 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("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( + { + 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 new file mode 100644 index 00000000..82e1db03 --- /dev/null +++ b/packages/agent/src/package-agent-facts.ts @@ -0,0 +1,945 @@ +import { createHash } from "node:crypto"; + +import Ajv2020 from "ajv/dist/2020.js"; +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 jsonSchemaMetaValidator = new Ajv2020({ + allErrors: true, + strict: false, +}); + +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); +const jsonSchemaValueSchema = z.union([z.boolean(), jsonObjectSchema]); +const jsonSchemaOrNullSchema = jsonSchemaValueSchema.nullable(); +type JsonSchemaOrNull = z.infer; + +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"), + validation: z.enum(["valid", "invalid"]), + value: jsonSchemaOrNullSchema, + }) + .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(); + +export const packageAgentFactsCardInputSchema = 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: jsonSchemaOrNullSchema.optional(), + outputSchema: jsonSchemaOrNullSchema.optional(), + declaredCapabilities: z.array(capabilitySchema).optional(), + observed: z.array(z.unknown()).optional(), + completeness: packageAgentFactsCompletenessSchema.optional(), + }) + .strict(); + +export type PackageAgentFactsCardInput = z.infer< + typeof packageAgentFactsCardInputSchema +>; + +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() + .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", + }); + } + 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 +>; + +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 scopeMatches( + left: PackageInventoryVersion, + right: PackageInventoryVersion, +): boolean { + return ( + canonicalPackageAgentFactsJson(left) === + canonicalPackageAgentFactsJson(right) + ); +} + +function canonicalizeJsonObject(value: T): T { + return JSON.parse(canonicalPackageAgentFactsJson(value)) as T; +} + +function jsonSchemaValidation(value: JsonSchemaOrNull): "valid" | "invalid" { + if (value === null) return "valid"; + return jsonSchemaMetaValidator.validateSchema(value) ? "valid" : "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, +): 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 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[], +): 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" + ? 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 + ? "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, + externalDiagnostics: readonly PackageAgentFactsDiagnostic[] = [], +): { + record: PackageAgentFactsRecord; + diagnostics: PackageAgentFactsDiagnostic[]; +} { + const diagnostics: PackageAgentFactsDiagnostic[] = [...externalDiagnostics]; + 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", + validation: jsonSchemaValidation(card.inputSchema ?? null), + value: canonicalizeJsonObject(card.inputSchema ?? null), + } + : { status: "unknown" }; + const outputSchema: PackageAgentFactsSchemaField = + "outputSchema" in card + ? { + status: "known", + validation: jsonSchemaValidation(card.outputSchema ?? null), + 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 missingFieldDiagnostics = [ + description, + inputSchema, + outputSchema, + declared, + observed, + ].some((field) => field.status === "unknown") + ? [ + { + code: "incomplete-extraction", + severity: "warning", + agentKey, + } 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, ...schemaDiagnostics, ...diagnostics], + ); + const completeness = normalizeCompleteness(mergedCompleteness.completeness); + 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: mergedCompleteness.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 agentDiagnostics = snapshot.diagnostics.filter( + (diagnostic) => diagnostic.agentKey === agent.agentKey, + ); + const normalizedInput = { + ...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), + }; + 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(parsedNormalized.data) !== + canonicalPackageAgentFactsJson(agent) + ) { + context.addIssue({ + code: "custom", + path: ["agents", index], + 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( + 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); + 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 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), + ); + + for (const rawCard of parsedInput.cards) { + const parsed = packageAgentFactsCardInputSchema.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; + } + cardsByAgentKey.set(card.agentKey, [ + ...(cardsByAgentKey.get(card.agentKey) ?? []), + card, + ]); + } + + const agents: PackageAgentFactsRecord[] = []; + for (const agent of inventory.agents) { + const cards = cardsByAgentKey.get(agent.agentKey) ?? []; + if (cards.length === 0) { + const missing = { + code: "missing-card", + severity: "warning", + agentKey: agent.agentKey, + } as const; + diagnostics.push(missing); + 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, + cards[0]!, + inputDiagnosticsByAgentKey.get(agent.agentKey), + ); + 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); +} 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