273 lines
9.6 KiB
TypeScript
273 lines
9.6 KiB
TypeScript
import { z } from "zod";
|
|
import { isDomainEventType, type DomainEventType } from "./domain-events.js";
|
|
import {
|
|
toolClearancePolicySchema,
|
|
type ToolClearancePolicy as SecurityToolClearancePolicy,
|
|
} from "../security/schemas.js";
|
|
|
|
export type ToolClearancePolicy = SecurityToolClearancePolicy;
|
|
|
|
export const ManifestPersonaSchema = z.object({
|
|
id: z.string().trim().min(1, 'Manifest field "id" must be a non-empty string.'),
|
|
displayName: z.string().trim().min(1, 'Manifest field "displayName" must be a non-empty string.'),
|
|
systemPromptTemplate: z.string().trim().min(1, 'Manifest field "systemPromptTemplate" must be a non-empty string.'),
|
|
modelConstraint: z.string().trim().min(1, 'Manifest persona field "modelConstraint" must be a non-empty string when provided.').optional(),
|
|
toolClearance: toolClearancePolicySchema,
|
|
});
|
|
export type ManifestPersona = z.infer<typeof ManifestPersonaSchema>;
|
|
|
|
export const RelationshipConstraintSchema = z.object({
|
|
maxDepth: z.number().int().min(1, 'Manifest field "maxDepth" must be an integer >= 1.').optional(),
|
|
maxChildren: z.number().int().min(1, 'Manifest field "maxChildren" must be an integer >= 1.').optional(),
|
|
});
|
|
export type RelationshipConstraint = z.infer<typeof RelationshipConstraintSchema>;
|
|
|
|
export const RelationshipEdgeSchema = z.object({
|
|
parentPersonaId: z.string().trim().min(1, 'Manifest field "parentPersonaId" must be a non-empty string.'),
|
|
childPersonaId: z.string().trim().min(1, 'Manifest field "childPersonaId" must be a non-empty string.'),
|
|
constraints: RelationshipConstraintSchema.optional(),
|
|
});
|
|
export type RelationshipEdge = z.infer<typeof RelationshipEdgeSchema>;
|
|
|
|
export const RouteConditionSchema = z.discriminatedUnion("type", [
|
|
z.object({ type: z.literal("always") }),
|
|
z.object({ type: z.literal("state_flag"), key: z.string().trim().min(1), equals: z.boolean() }),
|
|
z.object({ type: z.literal("history_has_event"), event: z.string().trim().min(1) }),
|
|
z.object({ type: z.literal("file_exists"), path: z.string().trim().min(1) }),
|
|
]);
|
|
export type RouteCondition = z.infer<typeof RouteConditionSchema>;
|
|
|
|
export const PipelineConstraintSchema = z.object({
|
|
maxRetries: z.number().int().min(0, 'Manifest field "maxRetries" must be an integer >= 0.').optional(),
|
|
});
|
|
export type PipelineConstraint = z.infer<typeof PipelineConstraintSchema>;
|
|
|
|
export const NodeTopologyKindSchema = z.enum(["sequential", "parallel", "hierarchical", "retry-unrolled"]);
|
|
export type NodeTopologyKind = z.infer<typeof NodeTopologyKindSchema>;
|
|
|
|
export const PipelineNodeTopologySchema = z.object({
|
|
kind: NodeTopologyKindSchema,
|
|
blockId: z.string().trim().min(1, 'Pipeline node topology blockId must be a non-empty string when provided.').optional(),
|
|
});
|
|
export type PipelineNodeTopology = z.infer<typeof PipelineNodeTopologySchema>;
|
|
|
|
export const PipelineNodeSchema = z.object({
|
|
id: z.string().trim().min(1),
|
|
actorId: z.string().trim().min(1),
|
|
personaId: z.string().trim().min(1),
|
|
constraints: PipelineConstraintSchema.optional(),
|
|
topology: PipelineNodeTopologySchema.optional(),
|
|
});
|
|
export type PipelineNode = z.infer<typeof PipelineNodeSchema>;
|
|
|
|
export const PipelineEdgeSchema = z.object({
|
|
from: z.string().trim().min(1),
|
|
to: z.string().trim().min(1),
|
|
on: z.enum(["success", "validation_fail", "failure", "always"]).optional(),
|
|
event: z.string().refine((val): val is DomainEventType => isDomainEventType(val), {
|
|
message: "Pipeline edge field 'event' has unsupported domain event.",
|
|
}).optional(),
|
|
when: z.array(RouteConditionSchema).optional(),
|
|
}).refine((data) => {
|
|
if (!data.on && !data.event) return false;
|
|
if (data.on && data.event) return false;
|
|
return true;
|
|
}, {
|
|
message: 'Pipeline edge must provide either an "on" trigger or an "event" trigger, but not both.',
|
|
});
|
|
export type PipelineEdge = z.infer<typeof PipelineEdgeSchema>;
|
|
|
|
export const PipelineGraphSchema = z.object({
|
|
entryNodeId: z.string().trim().min(1),
|
|
nodes: z.array(PipelineNodeSchema).min(1, "Manifest pipeline.nodes must be a non-empty array."),
|
|
edges: z.array(PipelineEdgeSchema),
|
|
});
|
|
export type PipelineGraph = z.infer<typeof PipelineGraphSchema>;
|
|
|
|
export const TopologyKindSchema = z.enum(["hierarchical", "parallel", "retry-unrolled", "sequential"]);
|
|
export type TopologyKind = z.infer<typeof TopologyKindSchema>;
|
|
|
|
export const TopologyConstraintSchema = z.object({
|
|
maxDepth: z.number().int().min(1).default(4),
|
|
maxRetries: z.number().int().min(0).default(2),
|
|
});
|
|
export type TopologyConstraint = z.infer<typeof TopologyConstraintSchema>;
|
|
|
|
export const AgentManifestSchema = z.object({
|
|
schemaVersion: z.literal("1"),
|
|
topologies: z.array(TopologyKindSchema).min(1, "Manifest topologies must be a non-empty array."),
|
|
personas: z.array(ManifestPersonaSchema).min(1, "Manifest personas must be a non-empty array."),
|
|
relationships: z.array(RelationshipEdgeSchema),
|
|
pipeline: PipelineGraphSchema,
|
|
topologyConstraints: TopologyConstraintSchema,
|
|
});
|
|
export type AgentManifest = z.infer<typeof AgentManifestSchema>;
|
|
|
|
function assertNoDuplicates(items: string[], label: string): void {
|
|
const seen = new Set<string>();
|
|
for (const item of items) {
|
|
if (seen.has(item)) {
|
|
throw new Error(`${label} contains duplicate id "${item}".`);
|
|
}
|
|
seen.add(item);
|
|
}
|
|
}
|
|
|
|
function assertPipelineDag(pipeline: PipelineGraph): void {
|
|
const adjacency = new Map<string, string[]>();
|
|
const indegree = new Map<string, number>();
|
|
const nodeIds = new Set<string>(pipeline.nodes.map((node) => node.id));
|
|
|
|
for (const node of pipeline.nodes) {
|
|
adjacency.set(node.id, []);
|
|
indegree.set(node.id, 0);
|
|
}
|
|
|
|
if (!nodeIds.has(pipeline.entryNodeId)) {
|
|
throw new Error(`Pipeline entry node "${pipeline.entryNodeId}" is not defined.`);
|
|
}
|
|
|
|
for (const edge of pipeline.edges) {
|
|
if (!nodeIds.has(edge.from)) {
|
|
throw new Error(`Pipeline edge references unknown from node "${edge.from}".`);
|
|
}
|
|
if (!nodeIds.has(edge.to)) {
|
|
throw new Error(`Pipeline edge references unknown to node "${edge.to}".`);
|
|
}
|
|
|
|
const neighbors = adjacency.get(edge.from);
|
|
if (!neighbors) {
|
|
throw new Error(`Internal DAG error for node "${edge.from}".`);
|
|
}
|
|
neighbors.push(edge.to);
|
|
const currentInDegree = indegree.get(edge.to);
|
|
indegree.set(edge.to, (currentInDegree ?? 0) + 1);
|
|
}
|
|
|
|
const queue: string[] = [];
|
|
for (const [nodeId, degree] of indegree.entries()) {
|
|
if (degree === 0) {
|
|
queue.push(nodeId);
|
|
}
|
|
}
|
|
|
|
let visited = 0;
|
|
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
const current = queue[cursor];
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
visited += 1;
|
|
|
|
const neighbors = adjacency.get(current) ?? [];
|
|
for (const neighbor of neighbors) {
|
|
const degree = indegree.get(neighbor);
|
|
if (degree === undefined) {
|
|
continue;
|
|
}
|
|
const nextDegree = degree - 1;
|
|
indegree.set(neighbor, nextDegree);
|
|
if (nextDegree === 0) {
|
|
queue.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (visited !== pipeline.nodes.length) {
|
|
throw new Error("Pipeline graph must be a strict DAG (cycle detected).");
|
|
}
|
|
}
|
|
|
|
function assertRelationshipDag(relationships: RelationshipEdge[]): void {
|
|
const adjacency = new Map<string, string[]>();
|
|
|
|
for (const relationship of relationships) {
|
|
const children = adjacency.get(relationship.parentPersonaId);
|
|
if (children) {
|
|
children.push(relationship.childPersonaId);
|
|
} else {
|
|
adjacency.set(relationship.parentPersonaId, [relationship.childPersonaId]);
|
|
}
|
|
if (!adjacency.has(relationship.childPersonaId)) {
|
|
adjacency.set(relationship.childPersonaId, []);
|
|
}
|
|
}
|
|
|
|
const visiting = new Set<string>();
|
|
const visited = new Set<string>();
|
|
|
|
const visit = (nodeId: string): void => {
|
|
if (visiting.has(nodeId)) {
|
|
throw new Error("Relationship graph must be acyclic (cycle detected).");
|
|
}
|
|
if (visited.has(nodeId)) {
|
|
return;
|
|
}
|
|
|
|
visiting.add(nodeId);
|
|
for (const childId of adjacency.get(nodeId) ?? []) {
|
|
visit(childId);
|
|
}
|
|
visiting.delete(nodeId);
|
|
visited.add(nodeId);
|
|
};
|
|
|
|
for (const nodeId of adjacency.keys()) {
|
|
visit(nodeId);
|
|
}
|
|
}
|
|
|
|
export function parseAgentManifest(input: unknown): AgentManifest {
|
|
let manifest: AgentManifest;
|
|
try {
|
|
manifest = AgentManifestSchema.parse(input);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
throw new Error("Manifest invalid: " + error.issues.map((e: any) => e.message).join(", "));
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
assertNoDuplicates(
|
|
manifest.personas.map((persona) => persona.id),
|
|
"Manifest personas",
|
|
);
|
|
assertNoDuplicates(
|
|
manifest.pipeline.nodes.map((node) => node.id),
|
|
"Manifest pipeline.nodes",
|
|
);
|
|
|
|
const personaIds = new Set(manifest.personas.map((persona) => persona.id));
|
|
|
|
for (const relation of manifest.relationships) {
|
|
if (!personaIds.has(relation.parentPersonaId)) {
|
|
throw new Error(
|
|
`Relationship references unknown parent persona "${relation.parentPersonaId}".`,
|
|
);
|
|
}
|
|
if (!personaIds.has(relation.childPersonaId)) {
|
|
throw new Error(
|
|
`Relationship references unknown child persona "${relation.childPersonaId}".`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assertRelationshipDag(manifest.relationships);
|
|
|
|
for (const node of manifest.pipeline.nodes) {
|
|
if (!personaIds.has(node.personaId)) {
|
|
throw new Error(`Pipeline node "${node.id}" references unknown persona "${node.personaId}".`);
|
|
}
|
|
|
|
if (node.topology && !manifest.topologies.includes(node.topology.kind as TopologyKind)) {
|
|
throw new Error(
|
|
`Pipeline node "${node.id}" topology "${node.topology.kind}" is not listed in manifest topologies.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assertPipelineDag(manifest.pipeline);
|
|
|
|
return manifest;
|
|
}
|