first commit
This commit is contained in:
506
src/agents/manifest.ts
Normal file
506
src/agents/manifest.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { isRecord } from "./types.js";
|
||||
|
||||
export type ToolClearancePolicy = {
|
||||
allowlist: string[];
|
||||
banlist: string[];
|
||||
};
|
||||
|
||||
export type ManifestPersona = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
systemPromptTemplate: string;
|
||||
toolClearance: ToolClearancePolicy;
|
||||
};
|
||||
|
||||
export type RelationshipConstraint = {
|
||||
maxDepth?: number;
|
||||
maxChildren?: number;
|
||||
};
|
||||
|
||||
export type RelationshipEdge = {
|
||||
parentPersonaId: string;
|
||||
childPersonaId: string;
|
||||
constraints?: RelationshipConstraint;
|
||||
};
|
||||
|
||||
export type RouteCondition =
|
||||
| {
|
||||
type: "always";
|
||||
}
|
||||
| {
|
||||
type: "state_flag";
|
||||
key: string;
|
||||
equals: boolean;
|
||||
}
|
||||
| {
|
||||
type: "history_has_event";
|
||||
event: string;
|
||||
}
|
||||
| {
|
||||
type: "file_exists";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type PipelineConstraint = {
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
export type PipelineNode = {
|
||||
id: string;
|
||||
actorId: string;
|
||||
personaId: string;
|
||||
constraints?: PipelineConstraint;
|
||||
};
|
||||
|
||||
export type PipelineEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
on:
|
||||
| "success"
|
||||
| "validation_fail"
|
||||
| "failure"
|
||||
| "always"
|
||||
| "onTaskComplete"
|
||||
| "onValidationFail";
|
||||
when?: RouteCondition[];
|
||||
};
|
||||
|
||||
export type PipelineGraph = {
|
||||
entryNodeId: string;
|
||||
nodes: PipelineNode[];
|
||||
edges: PipelineEdge[];
|
||||
};
|
||||
|
||||
export type TopologyKind = "hierarchical" | "retry-unrolled" | "sequential";
|
||||
|
||||
export type TopologyConstraint = {
|
||||
maxDepth: number;
|
||||
maxRetries: number;
|
||||
};
|
||||
|
||||
export type AgentManifest = {
|
||||
schemaVersion: "1";
|
||||
topologies: TopologyKind[];
|
||||
personas: ManifestPersona[];
|
||||
relationships: RelationshipEdge[];
|
||||
pipeline: PipelineGraph;
|
||||
topologyConstraints: TopologyConstraint;
|
||||
};
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Manifest field \"${key}\" must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function readOptionalInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
input: {
|
||||
min: number;
|
||||
},
|
||||
): number | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < input.min) {
|
||||
throw new Error(`Manifest field \"${key}\" must be an integer >= ${String(input.min)}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, key: string): string[] {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Manifest field \"${key}\" must be an array.`);
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string" || item.trim().length === 0) {
|
||||
throw new Error(`Manifest field \"${key}\" contains an invalid string.`);
|
||||
}
|
||||
output.push(item.trim());
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseToolClearance(value: unknown): ToolClearancePolicy {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest persona toolClearance must be an object.");
|
||||
}
|
||||
|
||||
return {
|
||||
allowlist: readStringArray(value, "allowlist"),
|
||||
banlist: readStringArray(value, "banlist"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePersona(value: unknown): ManifestPersona {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest persona entry must be an object.");
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(value, "id"),
|
||||
displayName: readString(value, "displayName"),
|
||||
systemPromptTemplate: readString(value, "systemPromptTemplate"),
|
||||
toolClearance: parseToolClearance(value.toolClearance),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRelationship(value: unknown): RelationshipEdge {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest relationship entry must be an object.");
|
||||
}
|
||||
|
||||
const constraints = isRecord(value.constraints)
|
||||
? {
|
||||
maxDepth: readOptionalInteger(value.constraints, "maxDepth", { min: 1 }),
|
||||
maxChildren: readOptionalInteger(value.constraints, "maxChildren", { min: 1 }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
parentPersonaId: readString(value, "parentPersonaId"),
|
||||
childPersonaId: readString(value, "childPersonaId"),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCondition(value: unknown): RouteCondition {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Route condition must be an object.");
|
||||
}
|
||||
|
||||
const type = readString(value, "type");
|
||||
if (type === "always") {
|
||||
return { type };
|
||||
}
|
||||
|
||||
if (type === "state_flag") {
|
||||
const key = readString(value, "key");
|
||||
const equals = value.equals;
|
||||
if (typeof equals !== "boolean") {
|
||||
throw new Error('Route condition field "equals" must be a boolean.');
|
||||
}
|
||||
return {
|
||||
type,
|
||||
key,
|
||||
equals,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "history_has_event") {
|
||||
return {
|
||||
type,
|
||||
event: readString(value, "event"),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "file_exists") {
|
||||
return {
|
||||
type,
|
||||
path: readString(value, "path"),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported route condition type \"${type}\".`);
|
||||
}
|
||||
|
||||
function parsePipelineNode(value: unknown): PipelineNode {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Pipeline node must be an object.");
|
||||
}
|
||||
|
||||
const constraints = isRecord(value.constraints)
|
||||
? {
|
||||
maxRetries: readOptionalInteger(value.constraints, "maxRetries", { min: 0 }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: readString(value, "id"),
|
||||
actorId: readString(value, "actorId"),
|
||||
personaId: readString(value, "personaId"),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePipelineEdge(value: unknown): PipelineEdge {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Pipeline edge must be an object.");
|
||||
}
|
||||
|
||||
const on = readString(value, "on");
|
||||
const validEvents: PipelineEdge["on"][] = [
|
||||
"success",
|
||||
"validation_fail",
|
||||
"failure",
|
||||
"always",
|
||||
"onTaskComplete",
|
||||
"onValidationFail",
|
||||
];
|
||||
|
||||
if (!validEvents.includes(on as PipelineEdge["on"])) {
|
||||
throw new Error(`Pipeline edge field \"on\" has unsupported event \"${on}\".`);
|
||||
}
|
||||
|
||||
const rawWhen = value.when;
|
||||
const when: RouteCondition[] = [];
|
||||
if (rawWhen !== undefined) {
|
||||
if (!Array.isArray(rawWhen)) {
|
||||
throw new Error('Pipeline edge field "when" must be an array when provided.');
|
||||
}
|
||||
for (const condition of rawWhen) {
|
||||
when.push(parseCondition(condition));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: readString(value, "from"),
|
||||
to: readString(value, "to"),
|
||||
on: on as PipelineEdge["on"],
|
||||
...(when.length > 0 ? { when } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePipeline(value: unknown): PipelineGraph {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest pipeline must be an object.");
|
||||
}
|
||||
|
||||
const nodesValue = value.nodes;
|
||||
if (!Array.isArray(nodesValue) || nodesValue.length === 0) {
|
||||
throw new Error("Manifest pipeline.nodes must be a non-empty array.");
|
||||
}
|
||||
|
||||
const edgesValue = value.edges;
|
||||
if (!Array.isArray(edgesValue)) {
|
||||
throw new Error("Manifest pipeline.edges must be an array.");
|
||||
}
|
||||
|
||||
const nodes = nodesValue.map(parsePipelineNode);
|
||||
const edges = edgesValue.map(parsePipelineEdge);
|
||||
|
||||
return {
|
||||
entryNodeId: readString(value, "entryNodeId"),
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTopologies(value: unknown): TopologyKind[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error("Manifest topologies must be a non-empty array.");
|
||||
}
|
||||
|
||||
const valid = new Set<TopologyKind>(["hierarchical", "retry-unrolled", "sequential"]);
|
||||
const result: TopologyKind[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string" || !valid.has(item as TopologyKind)) {
|
||||
throw new Error("Manifest topologies contains an unsupported topology kind.");
|
||||
}
|
||||
result.push(item as TopologyKind);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTopologyConstraints(value: unknown): TopologyConstraint {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest topologyConstraints must be an object.");
|
||||
}
|
||||
|
||||
const maxDepth = readOptionalInteger(value, "maxDepth", { min: 1 });
|
||||
const maxRetries = readOptionalInteger(value, "maxRetries", { min: 0 });
|
||||
|
||||
return {
|
||||
maxDepth: maxDepth ?? 4,
|
||||
maxRetries: maxRetries ?? 2,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!isRecord(input)) {
|
||||
throw new Error("AgentManifest must be an object.");
|
||||
}
|
||||
|
||||
const schemaVersion = readString(input, "schemaVersion");
|
||||
if (schemaVersion !== "1") {
|
||||
throw new Error(`Unsupported AgentManifest schemaVersion \"${schemaVersion}\".`);
|
||||
}
|
||||
|
||||
const personasValue = input.personas;
|
||||
if (!Array.isArray(personasValue) || personasValue.length === 0) {
|
||||
throw new Error("Manifest personas must be a non-empty array.");
|
||||
}
|
||||
|
||||
const relationshipsValue = input.relationships;
|
||||
if (!Array.isArray(relationshipsValue)) {
|
||||
throw new Error("Manifest relationships must be an array.");
|
||||
}
|
||||
|
||||
const manifest: AgentManifest = {
|
||||
schemaVersion: "1",
|
||||
topologies: parseTopologies(input.topologies),
|
||||
personas: personasValue.map(parsePersona),
|
||||
relationships: relationshipsValue.map(parseRelationship),
|
||||
pipeline: parsePipeline(input.pipeline),
|
||||
topologyConstraints: parseTopologyConstraints(input.topologyConstraints),
|
||||
};
|
||||
|
||||
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}\".`);
|
||||
}
|
||||
}
|
||||
|
||||
assertPipelineDag(manifest.pipeline);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
Reference in New Issue
Block a user