Refactor UI modules and harden run/API behavior
This commit is contained in:
116
src/agents/manifest-store.ts
Normal file
116
src/agents/manifest-store.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { parseAgentManifest, type AgentManifest } from "../agents/manifest.js";
|
||||
|
||||
export type ManifestRecord = {
|
||||
path: string;
|
||||
manifest: AgentManifest;
|
||||
source: unknown;
|
||||
};
|
||||
|
||||
export type ManifestListing = {
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
async function walkJsonFiles(root: string): Promise<string[]> {
|
||||
const output: string[] = [];
|
||||
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = resolve(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
output.push(...(await walkJsonFiles(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && extname(entry.name).toLowerCase() === ".json") {
|
||||
output.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function assertWorkspacePath(workspaceRoot: string, inputPath: string): string {
|
||||
const resolved = isAbsolute(inputPath)
|
||||
? resolve(inputPath)
|
||||
: resolve(workspaceRoot, inputPath);
|
||||
const rel = relative(workspaceRoot, resolved);
|
||||
|
||||
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
||||
throw new Error("Path is outside workspace root.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function toRelativePath(workspaceRoot: string, absolutePath: string): string {
|
||||
return relative(workspaceRoot, absolutePath) || ".";
|
||||
}
|
||||
|
||||
export class ManifestStore {
|
||||
private readonly workspaceRoot: string;
|
||||
private readonly manifestDirectory: string;
|
||||
|
||||
constructor(input: { workspaceRoot: string; manifestDirectory?: string }) {
|
||||
this.workspaceRoot = resolve(input.workspaceRoot);
|
||||
this.manifestDirectory = assertWorkspacePath(
|
||||
this.workspaceRoot,
|
||||
input.manifestDirectory ?? ".ai_ops/manifests",
|
||||
);
|
||||
}
|
||||
|
||||
getManifestDirectory(): string {
|
||||
return this.manifestDirectory;
|
||||
}
|
||||
|
||||
async list(): Promise<ManifestListing> {
|
||||
try {
|
||||
const files = await walkJsonFiles(this.manifestDirectory);
|
||||
const relPaths = files
|
||||
.map((filePath) => toRelativePath(this.workspaceRoot, filePath))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return {
|
||||
paths: relPaths,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
paths: [],
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async read(pathInput: string): Promise<ManifestRecord> {
|
||||
const absolutePath = assertWorkspacePath(this.workspaceRoot, pathInput);
|
||||
const sourceText = await readFile(absolutePath, "utf8");
|
||||
const source = JSON.parse(sourceText) as unknown;
|
||||
const manifest = parseAgentManifest(source);
|
||||
|
||||
return {
|
||||
path: toRelativePath(this.workspaceRoot, absolutePath),
|
||||
manifest,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
async validate(source: unknown): Promise<AgentManifest> {
|
||||
return parseAgentManifest(source);
|
||||
}
|
||||
|
||||
async save(pathInput: string, source: unknown): Promise<ManifestRecord> {
|
||||
const manifest = parseAgentManifest(source);
|
||||
const absolutePath = assertWorkspacePath(this.workspaceRoot, pathInput);
|
||||
await mkdir(dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
|
||||
return {
|
||||
path: toRelativePath(this.workspaceRoot, absolutePath),
|
||||
manifest,
|
||||
source: manifest,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,398 +1,113 @@
|
||||
import { isRecord } from "./types.js";
|
||||
import { z } from "zod";
|
||||
import { isDomainEventType, type DomainEventType } from "./domain-events.js";
|
||||
import {
|
||||
parseToolClearancePolicy,
|
||||
toolClearancePolicySchema,
|
||||
type ToolClearancePolicy as SecurityToolClearancePolicy,
|
||||
} from "../security/schemas.js";
|
||||
|
||||
export type ToolClearancePolicy = SecurityToolClearancePolicy;
|
||||
|
||||
export type ManifestPersona = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
systemPromptTemplate: string;
|
||||
modelConstraint?: string;
|
||||
toolClearance: ToolClearancePolicy;
|
||||
};
|
||||
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 type RelationshipConstraint = {
|
||||
maxDepth?: number;
|
||||
maxChildren?: number;
|
||||
};
|
||||
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 type RelationshipEdge = {
|
||||
parentPersonaId: string;
|
||||
childPersonaId: string;
|
||||
constraints?: RelationshipConstraint;
|
||||
};
|
||||
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 type RouteCondition =
|
||||
| {
|
||||
type: "always";
|
||||
}
|
||||
| {
|
||||
type: "state_flag";
|
||||
key: string;
|
||||
equals: boolean;
|
||||
}
|
||||
| {
|
||||
type: "history_has_event";
|
||||
event: string;
|
||||
}
|
||||
| {
|
||||
type: "file_exists";
|
||||
path: string;
|
||||
};
|
||||
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 type PipelineConstraint = {
|
||||
maxRetries?: number;
|
||||
};
|
||||
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 type NodeTopologyKind = "sequential" | "parallel" | "hierarchical" | "retry-unrolled";
|
||||
export const NodeTopologyKindSchema = z.enum(["sequential", "parallel", "hierarchical", "retry-unrolled"]);
|
||||
export type NodeTopologyKind = z.infer<typeof NodeTopologyKindSchema>;
|
||||
|
||||
export type PipelineNodeTopology = {
|
||||
kind: NodeTopologyKind;
|
||||
blockId?: string;
|
||||
};
|
||||
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 type PipelineNode = {
|
||||
id: string;
|
||||
actorId: string;
|
||||
personaId: string;
|
||||
constraints?: PipelineConstraint;
|
||||
topology?: PipelineNodeTopology;
|
||||
};
|
||||
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 type PipelineEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
on?: "success" | "validation_fail" | "failure" | "always";
|
||||
event?: DomainEventType;
|
||||
when?: RouteCondition[];
|
||||
};
|
||||
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 type PipelineGraph = {
|
||||
entryNodeId: string;
|
||||
nodes: PipelineNode[];
|
||||
edges: PipelineEdge[];
|
||||
};
|
||||
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 type TopologyKind = "hierarchical" | "parallel" | "retry-unrolled" | "sequential";
|
||||
export const TopologyKindSchema = z.enum(["hierarchical", "parallel", "retry-unrolled", "sequential"]);
|
||||
export type TopologyKind = z.infer<typeof TopologyKindSchema>;
|
||||
|
||||
export type TopologyConstraint = {
|
||||
maxDepth: number;
|
||||
maxRetries: number;
|
||||
};
|
||||
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 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 {
|
||||
try {
|
||||
return parseToolClearancePolicy(value);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Manifest persona toolClearance is invalid: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePersona(value: unknown): ManifestPersona {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest persona entry must be an object.");
|
||||
}
|
||||
|
||||
const modelConstraintRaw = value.modelConstraint;
|
||||
if (
|
||||
modelConstraintRaw !== undefined &&
|
||||
(typeof modelConstraintRaw !== "string" || modelConstraintRaw.trim().length === 0)
|
||||
) {
|
||||
throw new Error('Manifest persona field "modelConstraint" must be a non-empty string when provided.');
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(value, "id"),
|
||||
displayName: readString(value, "displayName"),
|
||||
systemPromptTemplate: readString(value, "systemPromptTemplate"),
|
||||
...(typeof modelConstraintRaw === "string"
|
||||
? { modelConstraint: modelConstraintRaw.trim() }
|
||||
: {}),
|
||||
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 topology = value.topology;
|
||||
let parsedTopology: PipelineNodeTopology | undefined;
|
||||
if (topology !== undefined) {
|
||||
if (!isRecord(topology)) {
|
||||
throw new Error("Pipeline node topology must be an object when provided.");
|
||||
}
|
||||
|
||||
const kind = readString(topology, "kind");
|
||||
if (
|
||||
kind !== "sequential" &&
|
||||
kind !== "parallel" &&
|
||||
kind !== "hierarchical" &&
|
||||
kind !== "retry-unrolled"
|
||||
) {
|
||||
throw new Error(`Pipeline node topology kind "${kind}" is not supported.`);
|
||||
}
|
||||
|
||||
const blockIdRaw = topology.blockId;
|
||||
if (blockIdRaw !== undefined && (typeof blockIdRaw !== "string" || blockIdRaw.trim().length === 0)) {
|
||||
throw new Error("Pipeline node topology blockId must be a non-empty string when provided.");
|
||||
}
|
||||
|
||||
parsedTopology = {
|
||||
kind,
|
||||
...(typeof blockIdRaw === "string" ? { blockId: blockIdRaw.trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
...(parsedTopology ? { topology: parsedTopology } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePipelineEdge(value: unknown): PipelineEdge {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Pipeline edge must be an object.");
|
||||
}
|
||||
|
||||
const validEvents: NonNullable<PipelineEdge["on"]>[] = [
|
||||
"success",
|
||||
"validation_fail",
|
||||
"failure",
|
||||
"always",
|
||||
];
|
||||
|
||||
const rawOn = value.on;
|
||||
let on: PipelineEdge["on"];
|
||||
if (rawOn !== undefined) {
|
||||
if (typeof rawOn !== "string" || !validEvents.includes(rawOn as NonNullable<PipelineEdge["on"]>)) {
|
||||
throw new Error(`Pipeline edge field "on" has unsupported event "${String(rawOn)}".`);
|
||||
}
|
||||
on = rawOn as NonNullable<PipelineEdge["on"]>;
|
||||
}
|
||||
|
||||
const rawDomainEvent = value.event;
|
||||
let event: DomainEventType | undefined;
|
||||
if (rawDomainEvent !== undefined) {
|
||||
if (typeof rawDomainEvent !== "string" || !isDomainEventType(rawDomainEvent)) {
|
||||
throw new Error(`Pipeline edge field "event" has unsupported domain event "${String(rawDomainEvent)}".`);
|
||||
}
|
||||
event = rawDomainEvent;
|
||||
}
|
||||
|
||||
if (!on && !event) {
|
||||
throw new Error('Pipeline edge must provide either an "on" trigger or an "event" trigger.');
|
||||
}
|
||||
if (on && event) {
|
||||
throw new Error('Pipeline edge cannot define both "on" and "event" triggers simultaneously.');
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
...(event ? { event } : {}),
|
||||
...(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", "parallel", "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,
|
||||
};
|
||||
}
|
||||
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}\".`);
|
||||
throw new Error(`${label} contains duplicate id "${item}".`);
|
||||
}
|
||||
seen.add(item);
|
||||
}
|
||||
@@ -409,20 +124,20 @@ function assertPipelineDag(pipeline: PipelineGraph): void {
|
||||
}
|
||||
|
||||
if (!nodeIds.has(pipeline.entryNodeId)) {
|
||||
throw new Error(`Pipeline entry node \"${pipeline.entryNodeId}\" is not defined.`);
|
||||
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}\".`);
|
||||
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}\".`);
|
||||
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}\".`);
|
||||
throw new Error(`Internal DAG error for node "${edge.from}".`);
|
||||
}
|
||||
neighbors.push(edge.to);
|
||||
const currentInDegree = indegree.get(edge.to);
|
||||
@@ -503,34 +218,16 @@ function assertRelationshipDag(relationships: RelationshipEdge[]): void {
|
||||
}
|
||||
|
||||
export function parseAgentManifest(input: unknown): AgentManifest {
|
||||
if (!isRecord(input)) {
|
||||
throw new Error("AgentManifest must be an object.");
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -545,12 +242,12 @@ export function parseAgentManifest(input: unknown): AgentManifest {
|
||||
for (const relation of manifest.relationships) {
|
||||
if (!personaIds.has(relation.parentPersonaId)) {
|
||||
throw new Error(
|
||||
`Relationship references unknown parent persona \"${relation.parentPersonaId}\".`,
|
||||
`Relationship references unknown parent persona "${relation.parentPersonaId}".`,
|
||||
);
|
||||
}
|
||||
if (!personaIds.has(relation.childPersonaId)) {
|
||||
throw new Error(
|
||||
`Relationship references unknown child persona \"${relation.childPersonaId}\".`,
|
||||
`Relationship references unknown child persona "${relation.childPersonaId}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -559,7 +256,7 @@ export function parseAgentManifest(input: unknown): AgentManifest {
|
||||
|
||||
for (const node of manifest.pipeline.nodes) {
|
||||
if (!personaIds.has(node.personaId)) {
|
||||
throw new Error(`Pipeline node \"${node.id}\" references unknown persona \"${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)) {
|
||||
|
||||
602
src/agents/provider-executor.ts
Normal file
602
src/agents/provider-executor.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import { Codex } from "@openai/codex-sdk";
|
||||
import { query, type Options, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
buildClaudeAuthEnv,
|
||||
resolveAnthropicToken,
|
||||
resolveOpenAiApiKey,
|
||||
type AppConfig,
|
||||
} from "../config.js";
|
||||
import { isDomainEventType, type DomainEventEmission } from "../agents/domain-events.js";
|
||||
import type { ActorExecutionInput, ActorExecutionResult, ActorExecutor } from "../agents/pipeline.js";
|
||||
import { isRecord, type JsonObject, type JsonValue } from "../agents/types.js";
|
||||
import { createSessionContext, type SessionContext } from "../examples/session-context.js";
|
||||
|
||||
export type RunProvider = "codex" | "claude";
|
||||
|
||||
export type ProviderRunRuntime = {
|
||||
provider: RunProvider;
|
||||
config: Readonly<AppConfig>;
|
||||
sessionContext: SessionContext;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ProviderUsage = {
|
||||
tokenInput?: number;
|
||||
tokenOutput?: number;
|
||||
tokenTotal?: number;
|
||||
durationMs?: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
const ACTOR_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["success", "validation_fail", "failure"],
|
||||
},
|
||||
payload: {
|
||||
type: "object",
|
||||
},
|
||||
stateFlags: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
stateMetadata: {
|
||||
type: "object",
|
||||
},
|
||||
events: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
failureKind: {
|
||||
type: "string",
|
||||
enum: ["soft", "hard"],
|
||||
},
|
||||
failureCode: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
|
||||
const CLAUDE_OUTPUT_FORMAT = {
|
||||
type: "json_schema",
|
||||
name: "actor_execution_result",
|
||||
schema: ACTOR_RESPONSE_SCHEMA,
|
||||
} as const;
|
||||
|
||||
const CLAUDE_PROVIDER_MAX_TURNS = 2;
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const ActorResponseSchema = z.object({
|
||||
status: z.enum(["success", "validation_fail", "failure"]),
|
||||
payload: z.unknown().optional(),
|
||||
stateFlags: z.unknown().optional(),
|
||||
stateMetadata: z.unknown().optional(),
|
||||
events: z.unknown().optional(),
|
||||
failureKind: z.unknown().optional(),
|
||||
failureCode: z.unknown().optional(),
|
||||
});
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): JsonValue {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
||||
}
|
||||
|
||||
function toJsonObject(value: unknown): JsonObject | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const cloned = toJsonValue(value);
|
||||
if (!isRecord(cloned)) {
|
||||
return undefined;
|
||||
}
|
||||
return cloned as JsonObject;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toBooleanRecord(value: unknown): Record<string, boolean> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const output: Record<string, boolean> = {};
|
||||
for (const [key, candidate] of Object.entries(value)) {
|
||||
if (typeof candidate === "boolean") {
|
||||
output[key] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(output).length > 0 ? output : undefined;
|
||||
}
|
||||
|
||||
function toEventEmissions(value: unknown): DomainEventEmission[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const output: DomainEventEmission[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = item.type;
|
||||
if (typeof type !== "string" || !isDomainEventType(type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = toJsonObject(item.payload);
|
||||
output.push({
|
||||
type,
|
||||
...(payload ? { payload } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return output.length > 0 ? output : undefined;
|
||||
}
|
||||
|
||||
function extractJsonFromFencedBlock(text: string): unknown {
|
||||
const matches = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (!matches || !matches[1]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(matches[1]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractFirstBalancedJsonObject(text: string): unknown {
|
||||
const start = text.indexOf("{");
|
||||
if (start < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = start; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (!character) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const candidate = text.slice(start, index + 1);
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tryParseResponseObject(rawText: string, structuredOutput?: unknown): unknown {
|
||||
if (structuredOutput !== undefined) {
|
||||
return structuredOutput;
|
||||
}
|
||||
|
||||
const trimmed = rawText.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const fenced = extractJsonFromFencedBlock(trimmed);
|
||||
if (fenced !== undefined) {
|
||||
return fenced;
|
||||
}
|
||||
|
||||
return extractFirstBalancedJsonObject(trimmed);
|
||||
}
|
||||
|
||||
function ensureUsageMetadata(input: {
|
||||
result: ActorExecutionResult;
|
||||
providerUsage: ProviderUsage;
|
||||
}): ActorExecutionResult {
|
||||
const stateMetadata = (input.result.stateMetadata as JsonObject | undefined) ?? {};
|
||||
const existingUsage = (stateMetadata.usage as JsonObject | undefined) ?? {};
|
||||
|
||||
const usage: JsonObject = {
|
||||
...existingUsage,
|
||||
...(typeof input.providerUsage.tokenInput === "number"
|
||||
? { tokenInput: input.providerUsage.tokenInput }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.tokenOutput === "number"
|
||||
? { tokenOutput: input.providerUsage.tokenOutput }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.tokenTotal === "number"
|
||||
? { tokenTotal: input.providerUsage.tokenTotal }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.durationMs === "number"
|
||||
? { durationMs: input.providerUsage.durationMs }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.costUsd === "number"
|
||||
? { costUsd: input.providerUsage.costUsd }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return {
|
||||
...input.result,
|
||||
stateMetadata: {
|
||||
...stateMetadata,
|
||||
usage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseActorExecutionResultFromModelOutput(input: {
|
||||
rawText: string;
|
||||
structuredOutput?: unknown;
|
||||
}): ActorExecutionResult {
|
||||
const parsed = tryParseResponseObject(input.rawText, input.structuredOutput);
|
||||
|
||||
const result = ActorResponseSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
assistantResponse: input.rawText.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = result;
|
||||
const payload = toJsonObject(data.payload) ?? {
|
||||
assistantResponse: input.rawText.trim(),
|
||||
};
|
||||
const stateMetadata = toJsonObject(data.stateMetadata);
|
||||
const stateFlags = toBooleanRecord(data.stateFlags);
|
||||
const events = toEventEmissions(data.events);
|
||||
const failureKind = data.failureKind === "soft" || data.failureKind === "hard"
|
||||
? data.failureKind
|
||||
: undefined;
|
||||
const failureCode = typeof data.failureCode === "string"
|
||||
? data.failureCode
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status: data.status,
|
||||
payload,
|
||||
...(stateFlags ? { stateFlags } : {}),
|
||||
...(stateMetadata ? { stateMetadata } : {}),
|
||||
...(events ? { events } : {}),
|
||||
...(failureKind ? { failureKind } : {}),
|
||||
...(failureCode ? { failureCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildActorPrompt(input: ActorExecutionInput): string {
|
||||
const recentHistory = input.context.state.history.slice(-15);
|
||||
|
||||
return [
|
||||
"You are executing one orchestration node in a schema-driven DAG runtime.",
|
||||
"Return ONLY JSON with this object shape:",
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "success | validation_fail | failure",
|
||||
payload: {},
|
||||
stateFlags: {
|
||||
optional_boolean_flag: true,
|
||||
},
|
||||
stateMetadata: {
|
||||
optional_metadata: "value",
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "requirements_defined | tasks_planned | code_committed | task_blocked | validation_passed | validation_failed | branch_merged",
|
||||
payload: {
|
||||
summary: "optional",
|
||||
details: {},
|
||||
errorCode: "optional",
|
||||
artifactPointer: "optional",
|
||||
},
|
||||
},
|
||||
],
|
||||
failureKind: "soft | hard",
|
||||
failureCode: "optional",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"Do not include markdown or extra explanation outside JSON.",
|
||||
`Node Prompt:\n${input.prompt}`,
|
||||
`Execution Context:\n${JSON.stringify(input.executionContext, null, 2)}`,
|
||||
`Current Handoff Payload:\n${JSON.stringify(input.context.handoff.payload, null, 2)}`,
|
||||
`Session Flags:\n${JSON.stringify(input.context.state.flags, null, 2)}`,
|
||||
`Recent Domain History:\n${JSON.stringify(recentHistory, null, 2)}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
async function runCodexActor(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
}): Promise<ActorExecutionResult> {
|
||||
const { runtime, actorInput } = input;
|
||||
const prompt = buildActorPrompt(actorInput);
|
||||
const startedAt = Date.now();
|
||||
const apiKey = resolveOpenAiApiKey(runtime.config.provider);
|
||||
|
||||
const codex = new Codex({
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(runtime.config.provider.openAiBaseUrl
|
||||
? { baseUrl: runtime.config.provider.openAiBaseUrl }
|
||||
: {}),
|
||||
...(actorInput.mcp.resolvedConfig.codexConfig
|
||||
? { config: actorInput.mcp.resolvedConfig.codexConfig }
|
||||
: {}),
|
||||
env: runtime.sessionContext.runtimeInjection.env,
|
||||
});
|
||||
|
||||
const thread = codex.startThread({
|
||||
workingDirectory: runtime.sessionContext.runtimeInjection.workingDirectory,
|
||||
skipGitRepoCheck: runtime.config.provider.codexSkipGitCheck,
|
||||
});
|
||||
|
||||
const turn = await runtime.sessionContext.runInSession(() =>
|
||||
thread.run(prompt, {
|
||||
signal: actorInput.signal,
|
||||
outputSchema: ACTOR_RESPONSE_SCHEMA,
|
||||
}),
|
||||
);
|
||||
|
||||
const usage: ProviderUsage = {
|
||||
...(turn.usage
|
||||
? {
|
||||
tokenInput: turn.usage.input_tokens + turn.usage.cached_input_tokens,
|
||||
tokenOutput: turn.usage.output_tokens,
|
||||
tokenTotal: turn.usage.input_tokens + turn.usage.cached_input_tokens + turn.usage.output_tokens,
|
||||
}
|
||||
: {}),
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
|
||||
const parsed = parseActorExecutionResultFromModelOutput({
|
||||
rawText: turn.finalResponse,
|
||||
});
|
||||
|
||||
return ensureUsageMetadata({
|
||||
result: parsed,
|
||||
providerUsage: usage,
|
||||
});
|
||||
}
|
||||
|
||||
type ClaudeTurnResult = {
|
||||
text: string;
|
||||
structuredOutput?: unknown;
|
||||
usage: ProviderUsage;
|
||||
};
|
||||
|
||||
function buildClaudeOptions(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
}): Options {
|
||||
const { runtime, actorInput } = input;
|
||||
|
||||
const authOptionOverrides = runtime.config.provider.anthropicOauthToken
|
||||
? { authToken: runtime.config.provider.anthropicOauthToken }
|
||||
: (() => {
|
||||
const token = resolveAnthropicToken(runtime.config.provider);
|
||||
return token ? { apiKey: token } : {};
|
||||
})();
|
||||
|
||||
const runtimeEnv = {
|
||||
...runtime.sessionContext.runtimeInjection.env,
|
||||
...buildClaudeAuthEnv(runtime.config.provider),
|
||||
};
|
||||
|
||||
return {
|
||||
maxTurns: CLAUDE_PROVIDER_MAX_TURNS,
|
||||
...(runtime.config.provider.claudeModel
|
||||
? { model: runtime.config.provider.claudeModel }
|
||||
: {}),
|
||||
...(runtime.config.provider.claudeCodePath
|
||||
? { pathToClaudeCodeExecutable: runtime.config.provider.claudeCodePath }
|
||||
: {}),
|
||||
...authOptionOverrides,
|
||||
...(actorInput.mcp.resolvedConfig.claudeMcpServers
|
||||
? { mcpServers: actorInput.mcp.resolvedConfig.claudeMcpServers as Options["mcpServers"] }
|
||||
: {}),
|
||||
canUseTool: actorInput.mcp.createClaudeCanUseTool(),
|
||||
cwd: runtime.sessionContext.runtimeInjection.workingDirectory,
|
||||
env: runtimeEnv,
|
||||
outputFormat: CLAUDE_OUTPUT_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaudeTurn(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
prompt: string;
|
||||
}): Promise<ClaudeTurnResult> {
|
||||
const options = buildClaudeOptions({
|
||||
runtime: input.runtime,
|
||||
actorInput: input.actorInput,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
const stream = query({
|
||||
prompt: input.prompt,
|
||||
options,
|
||||
});
|
||||
|
||||
let resultText = "";
|
||||
let structuredOutput: unknown;
|
||||
let usage: ProviderUsage = {};
|
||||
|
||||
const onAbort = (): void => {
|
||||
stream.close();
|
||||
};
|
||||
|
||||
input.actorInput.signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
for await (const message of stream as AsyncIterable<SDKMessage>) {
|
||||
if (message.type !== "result") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.subtype !== "success") {
|
||||
const detail = message.errors.join("; ");
|
||||
throw new Error(
|
||||
`Claude query failed (${message.subtype})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
resultText = message.result.trim();
|
||||
structuredOutput = message.structured_output;
|
||||
usage = {
|
||||
tokenInput: message.usage.input_tokens,
|
||||
tokenOutput: message.usage.output_tokens,
|
||||
tokenTotal: message.usage.input_tokens + message.usage.output_tokens,
|
||||
durationMs: message.duration_ms,
|
||||
costUsd: message.total_cost_usd,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
input.actorInput.signal.removeEventListener("abort", onAbort);
|
||||
stream.close();
|
||||
}
|
||||
|
||||
if (!resultText && structuredOutput !== undefined) {
|
||||
resultText = JSON.stringify(structuredOutput);
|
||||
}
|
||||
|
||||
if (!resultText) {
|
||||
throw new Error("Claude run completed without a final result.");
|
||||
}
|
||||
|
||||
return {
|
||||
text: resultText,
|
||||
structuredOutput,
|
||||
usage: {
|
||||
...usage,
|
||||
durationMs: usage.durationMs ?? Date.now() - startedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaudeActor(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
}): Promise<ActorExecutionResult> {
|
||||
const prompt = buildActorPrompt(input.actorInput);
|
||||
const turn = await input.runtime.sessionContext.runInSession(() =>
|
||||
runClaudeTurn({
|
||||
runtime: input.runtime,
|
||||
actorInput: input.actorInput,
|
||||
prompt,
|
||||
}),
|
||||
);
|
||||
|
||||
const parsed = parseActorExecutionResultFromModelOutput({
|
||||
rawText: turn.text,
|
||||
structuredOutput: turn.structuredOutput,
|
||||
});
|
||||
|
||||
return ensureUsageMetadata({
|
||||
result: parsed,
|
||||
providerUsage: turn.usage,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProviderRunRuntime(input: {
|
||||
provider: RunProvider;
|
||||
initialPrompt: string;
|
||||
config: Readonly<AppConfig>;
|
||||
}): Promise<ProviderRunRuntime> {
|
||||
const sessionContext = await createSessionContext(input.provider, {
|
||||
prompt: input.initialPrompt,
|
||||
config: input.config,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
config: input.config,
|
||||
sessionContext,
|
||||
close: async () => {
|
||||
await sessionContext.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createProviderActorExecutor(runtime: ProviderRunRuntime): ActorExecutor {
|
||||
return async (actorInput) => {
|
||||
try {
|
||||
if (runtime.provider === "codex") {
|
||||
return await runCodexActor({
|
||||
runtime,
|
||||
actorInput,
|
||||
});
|
||||
}
|
||||
|
||||
return await runClaudeActor({
|
||||
runtime,
|
||||
actorInput,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failure",
|
||||
payload: {
|
||||
error: toErrorMessage(error),
|
||||
},
|
||||
failureKind: "hard",
|
||||
failureCode: `provider_${runtime.provider}_execution_error`,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user