feat(agent): add execution-plan skeleton workflow

This commit is contained in:
2026-04-09 21:11:21 +02:00
parent 29c93eeb35
commit 26d008705f
8 changed files with 708 additions and 98 deletions

View File

@@ -37,6 +37,11 @@ type AgentNodeData = {
templateId?: string; templateId?: string;
canvasId?: string; canvasId?: string;
modelId?: string; modelId?: string;
executionSteps?: Array<{ stepIndex?: number; stepTotal?: number }>;
executionStepIndex?: number;
executionStepTotal?: number;
_executionStepIndex?: number;
_executionStepTotal?: number;
clarificationQuestions?: AgentClarificationQuestion[]; clarificationQuestions?: AgentClarificationQuestion[];
clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>; clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>;
_status?: string; _status?: string;
@@ -197,6 +202,51 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
const resolvedModelId = selectedModel?.id ?? DEFAULT_AGENT_MODEL_ID; const resolvedModelId = selectedModel?.id ?? DEFAULT_AGENT_MODEL_ID;
const creditCost = selectedModel?.creditCost ?? 0; const creditCost = selectedModel?.creditCost ?? 0;
const clarificationQuestions = nodeData.clarificationQuestions ?? []; const clarificationQuestions = nodeData.clarificationQuestions ?? [];
const isExecutionActive = nodeData._status === "analyzing" || nodeData._status === "executing";
const executionProgressLine = useMemo(() => {
if (nodeData._status !== "executing") {
return null;
}
const statusMessage = typeof nodeData._statusMessage === "string" ? nodeData._statusMessage.trim() : "";
if (statusMessage.length > 0) {
return statusMessage;
}
const totalFromSteps = Array.isArray(nodeData.executionSteps) ? nodeData.executionSteps.length : 0;
const stepIndexCandidate =
typeof nodeData.executionStepIndex === "number"
? nodeData.executionStepIndex
: nodeData._executionStepIndex;
const stepTotalCandidate =
typeof nodeData.executionStepTotal === "number"
? nodeData.executionStepTotal
: nodeData._executionStepTotal;
const hasExecutionNumbers =
typeof stepIndexCandidate === "number" &&
Number.isFinite(stepIndexCandidate) &&
typeof stepTotalCandidate === "number" &&
Number.isFinite(stepTotalCandidate) &&
stepTotalCandidate > 0;
if (hasExecutionNumbers) {
return `Executing step ${Math.max(0, Math.floor(stepIndexCandidate)) + 1}/${Math.floor(stepTotalCandidate)}`;
}
if (totalFromSteps > 0) {
return `Executing planned outputs (${totalFromSteps} total)`;
}
return "Executing planned outputs";
}, [
nodeData._executionStepIndex,
nodeData._executionStepTotal,
nodeData._status,
nodeData._statusMessage,
nodeData.executionStepIndex,
nodeData.executionStepTotal,
nodeData.executionSteps,
]);
const persistNodeData = useCallback( const persistNodeData = useCallback(
(patch: Partial<AgentNodeData>) => { (patch: Partial<AgentNodeData>) => {
@@ -239,6 +289,10 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
); );
const handleRunAgent = useCallback(async () => { const handleRunAgent = useCallback(async () => {
if (isExecutionActive) {
return;
}
if (status.isOffline) { if (status.isOffline) {
toast.warning( toast.warning(
"Offline aktuell nicht unterstuetzt", "Offline aktuell nicht unterstuetzt",
@@ -257,7 +311,7 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
modelId: resolvedModelId, modelId: resolvedModelId,
}); });
}, [nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]); }, [isExecutionActive, nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]);
const handleSubmitClarification = useCallback(async () => { const handleSubmitClarification = useCallback(async () => {
if (status.isOffline) { if (status.isOffline) {
@@ -298,6 +352,11 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
id="agent-in" id="agent-in"
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background" className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
/> />
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
/>
<div className="flex h-full flex-col gap-3 p-3"> <div className="flex h-full flex-col gap-3 p-3">
<header className="space-y-1"> <header className="space-y-1">
@@ -334,10 +393,15 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
<button <button
type="button" type="button"
onClick={() => void handleRunAgent()} onClick={() => void handleRunAgent()}
className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700" disabled={isExecutionActive}
aria-busy={isExecutionActive}
className="nodrag w-full rounded-md bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
> >
Run agent Run agent
</button> </button>
{executionProgressLine ? (
<p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">{executionProgressLine}</p>
) : null}
</section> </section>
{clarificationQuestions.length > 0 ? ( {clarificationQuestions.length > 0 ? (

View File

@@ -5,6 +5,10 @@ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
type AgentOutputNodeData = { type AgentOutputNodeData = {
isSkeleton?: boolean;
stepId?: string;
stepIndex?: number;
stepTotal?: number;
title?: string; title?: string;
channel?: string; channel?: string;
outputType?: string; outputType?: string;
@@ -17,6 +21,25 @@ type AgentOutputNodeType = Node<AgentOutputNodeData, "agent-output">;
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) { export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
const nodeData = data as AgentOutputNodeData; const nodeData = data as AgentOutputNodeData;
const isSkeleton = nodeData.isSkeleton === true;
const hasStepCounter =
typeof nodeData.stepIndex === "number" &&
Number.isFinite(nodeData.stepIndex) &&
typeof nodeData.stepTotal === "number" &&
Number.isFinite(nodeData.stepTotal) &&
nodeData.stepTotal > 0;
const safeStepIndex =
typeof nodeData.stepIndex === "number" && Number.isFinite(nodeData.stepIndex)
? Math.max(0, Math.floor(nodeData.stepIndex))
: 0;
const safeStepTotal =
typeof nodeData.stepTotal === "number" && Number.isFinite(nodeData.stepTotal)
? Math.max(1, Math.floor(nodeData.stepTotal))
: 1;
const stepCounter = hasStepCounter
? `${safeStepIndex + 1}/${safeStepTotal}`
: null;
const resolvedTitle = nodeData.title ?? (isSkeleton ? "Planned output" : "Agent output");
return ( return (
<BaseNodeWrapper <BaseNodeWrapper
@@ -24,7 +47,7 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
selected={selected} selected={selected}
status={nodeData._status} status={nodeData._status}
statusMessage={nodeData._statusMessage} statusMessage={nodeData._statusMessage}
className="min-w-[300px] border-amber-500/30" className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`}
> >
<Handle <Handle
type="target" type="target"
@@ -35,9 +58,22 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
<div className="flex h-full flex-col gap-3 p-3"> <div className="flex h-full flex-col gap-3 p-3">
<header className="space-y-1"> <header className="space-y-1">
<p className="truncate text-xs font-semibold text-foreground" title={nodeData.title}> <div className="flex items-center justify-between gap-2">
{nodeData.title ?? "Agent output"} <p className="truncate text-xs font-semibold text-foreground" title={resolvedTitle}>
</p> {resolvedTitle}
</p>
{isSkeleton ? (
<span className="shrink-0 rounded-full border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-200">
Skeleton
</span>
) : null}
</div>
{isSkeleton ? (
<p className="text-[11px] text-amber-700/90 dark:text-amber-300/90">
Planned output{stepCounter ? ` - ${stepCounter}` : ""}
{nodeData.stepId ? ` - ${nodeData.stepId}` : ""}
</p>
) : null}
</header> </header>
<section className="space-y-1"> <section className="space-y-1">
@@ -62,9 +98,18 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Body Body
</p> </p>
<p className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90"> {isSkeleton ? (
{nodeData.body ?? ""} <div
</p> data-testid="agent-output-skeleton-body"
className="animate-pulse rounded-md border border-dashed border-amber-500/40 bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-amber-500/10 p-3"
>
<p className="text-[11px] text-amber-800/90 dark:text-amber-200/90">Planned content</p>
</div>
) : (
<p className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90">
{nodeData.body ?? ""}
</p>
)}
</section> </section>
</div> </div>
</BaseNodeWrapper> </BaseNodeWrapper>

View File

@@ -14,9 +14,11 @@ import { getNodeDataRecord } from "./ai_node_data";
import { formatTerminalStatusMessage } from "./ai_errors"; import { formatTerminalStatusMessage } from "./ai_errors";
import { import {
areClarificationAnswersComplete, areClarificationAnswersComplete,
normalizeAgentExecutionPlan,
normalizeAgentOutputDraft, normalizeAgentOutputDraft,
type AgentClarificationAnswerMap, type AgentClarificationAnswerMap,
type AgentClarificationQuestion, type AgentClarificationQuestion,
type AgentExecutionStep,
type AgentOutputDraft, type AgentOutputDraft,
} from "../lib/agent-run-contract"; } from "../lib/agent-run-contract";
import { import {
@@ -31,7 +33,7 @@ import { normalizePublicTier } from "../lib/tier-credits";
const ANALYZE_SCHEMA: Record<string, unknown> = { const ANALYZE_SCHEMA: Record<string, unknown> = {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
required: ["analysisSummary", "clarificationQuestions"], required: ["analysisSummary", "clarificationQuestions", "executionPlan"],
properties: { properties: {
analysisSummary: { type: "string" }, analysisSummary: { type: "string" },
clarificationQuestions: { clarificationQuestions: {
@@ -48,34 +50,65 @@ const ANALYZE_SCHEMA: Record<string, unknown> = {
}, },
}, },
}, },
}, executionPlan: {
}; type: "object",
additionalProperties: false,
const EXECUTE_SCHEMA: Record<string, unknown> = { required: ["summary", "steps"],
type: "object", properties: {
additionalProperties: false, summary: { type: "string" },
required: ["summary", "outputs"], steps: {
properties: { type: "array",
summary: { type: "string" }, minItems: 1,
outputs: { maxItems: 6,
type: "array", items: {
minItems: 1, type: "object",
maxItems: 6, additionalProperties: false,
items: { required: ["id", "title", "channel", "outputType"],
type: "object", properties: {
additionalProperties: false, id: { type: "string" },
required: ["title", "channel", "outputType", "body"], title: { type: "string" },
properties: { channel: { type: "string" },
title: { type: "string" }, outputType: { type: "string" },
channel: { type: "string" }, },
outputType: { type: "string" }, },
body: { type: "string" },
}, },
}, },
}, },
}, },
}; };
function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
const stepOutputProperties: Record<string, unknown> = {};
for (const stepId of stepIds) {
stepOutputProperties[stepId] = {
type: "object",
additionalProperties: false,
required: ["title", "channel", "outputType", "body"],
properties: {
title: { type: "string" },
channel: { type: "string" },
outputType: { type: "string" },
body: { type: "string" },
},
};
}
return {
type: "object",
additionalProperties: false,
required: ["summary", "stepOutputs"],
properties: {
summary: { type: "string" },
stepOutputs: {
type: "object",
additionalProperties: false,
required: stepIds,
properties: stepOutputProperties,
},
},
};
}
type InternalApiShape = { type InternalApiShape = {
canvasGraph: { canvasGraph: {
getInternal: FunctionReference< getInternal: FunctionReference<
@@ -111,7 +144,6 @@ type InternalApiShape = {
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
modelId: string; modelId: string;
userId: string; userId: string;
analysisSummary: string;
reservationId?: Id<"creditTransactions">; reservationId?: Id<"creditTransactions">;
shouldDecrementConcurrency: boolean; shouldDecrementConcurrency: boolean;
}, },
@@ -161,13 +193,37 @@ type InternalApiShape = {
{ nodeId: Id<"nodes">; statusMessage?: string }, { nodeId: Id<"nodes">; statusMessage?: string },
unknown unknown
>; >;
finalizeAgentSuccessWithOutputs: FunctionReference< createExecutionSkeletonOutputs: FunctionReference<
"mutation", "mutation",
"internal", "internal",
{ {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodeId: Id<"nodes">; nodeId: Id<"nodes">;
outputs: AgentOutputDraft[]; executionPlan: { summary: string; steps: AgentExecutionStep[] };
},
{ outputNodeIds: Id<"nodes">[] }
>;
completeExecutionStepOutput: FunctionReference<
"mutation",
"internal",
{
nodeId: Id<"nodes">;
outputNodeId: Id<"nodes">;
stepId: string;
stepIndex: number;
stepTotal: number;
title: string;
channel: string;
outputType: string;
body: string;
},
unknown
>;
finalizeAgentSuccessWithOutputs: FunctionReference<
"mutation",
"internal",
{
nodeId: Id<"nodes">;
summary: string; summary: string;
}, },
{ outputNodeIds: Id<"nodes">[] } { outputNodeIds: Id<"nodes">[] }
@@ -258,6 +314,57 @@ function normalizeClarificationQuestions(raw: unknown): AgentClarificationQuesti
return questions; return questions;
} }
type AgentExecutionStepRuntime = AgentExecutionStep & {
nodeId: Id<"nodes">;
stepIndex: number;
stepTotal: number;
};
function normalizeExecutionSteps(raw: unknown): AgentExecutionStepRuntime[] {
if (!Array.isArray(raw)) {
return [];
}
const steps: AgentExecutionStepRuntime[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const itemRecord = item as Record<string, unknown>;
const nodeId = trimText(itemRecord.nodeId);
const stepId = trimText(itemRecord.stepId);
const title = trimText(itemRecord.title);
const channel = trimText(itemRecord.channel);
const outputType = trimText(itemRecord.outputType);
const rawStepIndex = itemRecord.stepIndex;
const rawStepTotal = itemRecord.stepTotal;
const stepIndex =
typeof rawStepIndex === "number" && Number.isFinite(rawStepIndex)
? Math.max(0, Math.floor(rawStepIndex))
: -1;
const stepTotal =
typeof rawStepTotal === "number" && Number.isFinite(rawStepTotal)
? Math.max(0, Math.floor(rawStepTotal))
: 0;
if (!nodeId || !stepId || !title || !channel || !outputType || stepIndex < 0 || stepTotal <= 0) {
continue;
}
steps.push({
id: stepId,
title,
channel,
outputType,
nodeId: nodeId as Id<"nodes">,
stepIndex,
stepTotal,
});
}
return steps.sort((a, b) => a.stepIndex - b.stepIndex);
}
function serializeNodeDataForPrompt(data: unknown): string { function serializeNodeDataForPrompt(data: unknown): string {
if (data === undefined) { if (data === undefined) {
return "{}"; return "{}";
@@ -395,6 +502,8 @@ export const setAgentAnalyzing = internalMutation({
modelId: args.modelId, modelId: args.modelId,
reservationId: args.reservationId, reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency, shouldDecrementConcurrency: args.shouldDecrementConcurrency,
executionPlanSummary: undefined,
executionSteps: [],
}, },
}); });
}, },
@@ -455,6 +564,165 @@ export const setAgentExecuting = internalMutation({
}, },
}); });
export const createExecutionSkeletonOutputs = internalMutation({
args: {
canvasId: v.id("canvases"),
nodeId: v.id("nodes"),
executionPlan: v.object({
summary: v.string(),
steps: v.array(
v.object({
id: v.string(),
title: v.string(),
channel: v.string(),
outputType: v.string(),
}),
),
}),
},
handler: async (ctx, args) => {
const node = await ctx.db.get(args.nodeId);
if (!node) {
throw new Error("Node not found");
}
if (node.type !== "agent") {
throw new Error("Node must be an agent node");
}
if (node.canvasId !== args.canvasId) {
throw new Error("Agent node does not belong to canvas");
}
const prev = getNodeDataRecord(node.data);
const existingOutputNodeIds = Array.isArray(prev.outputNodeIds)
? prev.outputNodeIds.filter((value): value is Id<"nodes"> => typeof value === "string")
: [];
const baseX = node.positionX + node.width + 120;
const baseY = node.positionY;
const stepTotal = args.executionPlan.steps.length;
const outputNodeIds: Id<"nodes">[] = [];
const runtimeSteps: Array<{
stepId: string;
nodeId: Id<"nodes">;
stepIndex: number;
stepTotal: number;
title: string;
channel: string;
outputType: string;
}> = [];
for (let index = 0; index < args.executionPlan.steps.length; index += 1) {
const step = args.executionPlan.steps[index];
const outputNodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId,
type: "agent-output",
positionX: baseX,
positionY: baseY + index * 220,
width: 360,
height: 260,
status: "executing",
retryCount: 0,
data: {
isSkeleton: true,
stepId: step.id,
stepIndex: index,
stepTotal,
title: step.title,
channel: step.channel,
outputType: step.outputType,
body: "",
},
});
outputNodeIds.push(outputNodeId);
runtimeSteps.push({
stepId: step.id,
nodeId: outputNodeId,
stepIndex: index,
stepTotal,
title: step.title,
channel: step.channel,
outputType: step.outputType,
});
await ctx.db.insert("edges", {
canvasId: args.canvasId,
sourceNodeId: args.nodeId,
targetNodeId: outputNodeId,
sourceHandle: undefined,
targetHandle: "agent-output-in",
});
}
await ctx.db.patch(args.nodeId, {
data: {
...prev,
executionPlanSummary: trimText(args.executionPlan.summary),
executionSteps: runtimeSteps,
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds],
},
});
await ctx.db.patch(args.canvasId, {
updatedAt: Date.now(),
});
return {
outputNodeIds,
};
},
});
export const completeExecutionStepOutput = internalMutation({
args: {
nodeId: v.id("nodes"),
outputNodeId: v.id("nodes"),
stepId: v.string(),
stepIndex: v.number(),
stepTotal: v.number(),
title: v.string(),
channel: v.string(),
outputType: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
const node = await ctx.db.get(args.nodeId);
if (!node) {
throw new Error("Node not found");
}
if (node.type !== "agent") {
throw new Error("Node must be an agent node");
}
const outputNode = await ctx.db.get(args.outputNodeId);
if (!outputNode) {
throw new Error("Output node not found");
}
if (outputNode.type !== "agent-output") {
throw new Error("Node must be an agent-output node");
}
if (outputNode.canvasId !== node.canvasId) {
throw new Error("Output node does not belong to the same canvas");
}
await ctx.db.patch(args.outputNodeId, {
status: "done",
statusMessage: undefined,
retryCount: 0,
data: {
isSkeleton: false,
stepId: trimText(args.stepId),
stepIndex: Math.max(0, Math.floor(args.stepIndex)),
stepTotal: Math.max(1, Math.floor(args.stepTotal)),
title: trimText(args.title),
channel: trimText(args.channel),
outputType: trimText(args.outputType),
body: trimText(args.body),
},
});
},
});
export const setAgentError = internalMutation({ export const setAgentError = internalMutation({
args: { args: {
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
@@ -517,16 +785,7 @@ export const upsertClarificationAnswers = internalMutation({
export const finalizeAgentSuccessWithOutputs = internalMutation({ export const finalizeAgentSuccessWithOutputs = internalMutation({
args: { args: {
canvasId: v.id("canvases"),
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
outputs: v.array(
v.object({
title: v.optional(v.string()),
channel: v.optional(v.string()),
outputType: v.optional(v.string()),
body: v.optional(v.string()),
}),
),
summary: v.string(), summary: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -537,48 +796,12 @@ export const finalizeAgentSuccessWithOutputs = internalMutation({
if (node.type !== "agent") { if (node.type !== "agent") {
throw new Error("Node must be an agent node"); throw new Error("Node must be an agent node");
} }
if (node.canvasId !== args.canvasId) {
throw new Error("Agent node does not belong to canvas");
}
const prev = getNodeDataRecord(node.data); const prev = getNodeDataRecord(node.data);
const existingOutputNodeIds = Array.isArray(prev.outputNodeIds) const existingOutputNodeIds = Array.isArray(prev.outputNodeIds)
? prev.outputNodeIds.filter((value): value is Id<"nodes"> => typeof value === "string") ? prev.outputNodeIds.filter((value): value is Id<"nodes"> => typeof value === "string")
: []; : [];
const baseX = node.positionX + node.width + 120;
const baseY = node.positionY;
const outputNodeIds: Id<"nodes">[] = [];
for (let index = 0; index < args.outputs.length; index += 1) {
const normalized = normalizeAgentOutputDraft(args.outputs[index] ?? {});
const outputNodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId,
type: "agent-output",
positionX: baseX,
positionY: baseY + index * 220,
width: 360,
height: 260,
status: "done",
retryCount: 0,
data: {
title: normalized.title,
channel: normalized.channel,
outputType: normalized.outputType,
body: normalized.body,
},
});
outputNodeIds.push(outputNodeId);
await ctx.db.insert("edges", {
canvasId: args.canvasId,
sourceNodeId: args.nodeId,
targetNodeId: outputNodeId,
sourceHandle: undefined,
targetHandle: "agent-output-in",
});
}
await ctx.db.patch(args.nodeId, { await ctx.db.patch(args.nodeId, {
status: "done", status: "done",
statusMessage: undefined, statusMessage: undefined,
@@ -586,19 +809,19 @@ export const finalizeAgentSuccessWithOutputs = internalMutation({
data: { data: {
...prev, ...prev,
clarificationQuestions: [], clarificationQuestions: [],
outputNodeIds: [...existingOutputNodeIds, ...outputNodeIds], outputNodeIds: existingOutputNodeIds,
lastRunSummary: trimText(args.summary), lastRunSummary: trimText(args.summary),
reservationId: undefined, reservationId: undefined,
shouldDecrementConcurrency: undefined, shouldDecrementConcurrency: undefined,
}, },
}); });
await ctx.db.patch(args.canvasId, { await ctx.db.patch(node.canvasId, {
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
return { return {
outputNodeIds, outputNodeIds: existingOutputNodeIds,
}; };
}, },
}); });
@@ -632,6 +855,7 @@ export const analyzeAgent = internalAction({
const analysis = await generateStructuredObjectViaOpenRouter<{ const analysis = await generateStructuredObjectViaOpenRouter<{
analysisSummary: string; analysisSummary: string;
clarificationQuestions: AgentClarificationQuestion[]; clarificationQuestions: AgentClarificationQuestion[];
executionPlan: unknown;
}>(apiKey, { }>(apiKey, {
model: args.modelId, model: args.modelId,
schemaName: "agent_analyze_result", schemaName: "agent_analyze_result",
@@ -659,6 +883,10 @@ export const analyzeAgent = internalAction({
const clarificationQuestions = normalizeClarificationQuestions( const clarificationQuestions = normalizeClarificationQuestions(
analysis.clarificationQuestions, analysis.clarificationQuestions,
); );
const executionPlan = normalizeAgentExecutionPlan(analysis.executionPlan);
if (executionPlan.steps.length === 0) {
throw new Error("Agent analyze returned an empty execution plan");
}
const hasRequiredGaps = !areClarificationAnswersComplete( const hasRequiredGaps = !areClarificationAnswersComplete(
clarificationQuestions, clarificationQuestions,
existingAnswers, existingAnswers,
@@ -672,6 +900,12 @@ export const analyzeAgent = internalAction({
return; return;
} }
await ctx.runMutation(internalApi.agents.createExecutionSkeletonOutputs, {
canvasId: args.canvasId,
nodeId: args.nodeId,
executionPlan,
});
await ctx.runMutation(internalApi.agents.setAgentExecuting, { await ctx.runMutation(internalApi.agents.setAgentExecuting, {
nodeId: args.nodeId, nodeId: args.nodeId,
}); });
@@ -681,7 +915,6 @@ export const analyzeAgent = internalAction({
nodeId: args.nodeId, nodeId: args.nodeId,
modelId: args.modelId, modelId: args.modelId,
userId: args.userId, userId: args.userId,
analysisSummary: trimText(analysis.analysisSummary),
reservationId: args.reservationId, reservationId: args.reservationId,
shouldDecrementConcurrency: args.shouldDecrementConcurrency, shouldDecrementConcurrency: args.shouldDecrementConcurrency,
}); });
@@ -702,7 +935,6 @@ export const executeAgent = internalAction({
nodeId: v.id("nodes"), nodeId: v.id("nodes"),
modelId: v.string(), modelId: v.string(),
userId: v.string(), userId: v.string(),
analysisSummary: v.string(),
reservationId: v.optional(v.id("creditTransactions")), reservationId: v.optional(v.id("creditTransactions")),
shouldDecrementConcurrency: v.boolean(), shouldDecrementConcurrency: v.boolean(),
}, },
@@ -723,27 +955,43 @@ export const executeAgent = internalAction({
const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor"); const template = getAgentTemplate(trimText(agentData.templateId) || "campaign-distributor");
const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers); const clarificationAnswers = normalizeAnswerMap(agentData.clarificationAnswers);
const incomingContext = collectIncomingContext(graph, args.nodeId); const incomingContext = collectIncomingContext(graph, args.nodeId);
const executionPlanSummary = trimText(agentData.executionPlanSummary);
const executionSteps = normalizeExecutionSteps(agentData.executionSteps);
if (executionSteps.length === 0) {
throw new Error("Agent execute is missing execution steps");
}
const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id));
const execution = await generateStructuredObjectViaOpenRouter<{ const execution = await generateStructuredObjectViaOpenRouter<{
summary: string; summary: string;
outputs: AgentOutputDraft[]; stepOutputs: Record<string, AgentOutputDraft>;
}>(apiKey, { }>(apiKey, {
model: args.modelId, model: args.modelId,
schemaName: "agent_execute_result", schemaName: "agent_execute_result",
schema: EXECUTE_SCHEMA, schema: executeSchema,
messages: [ messages: [
{ {
role: "system", role: "system",
content: content:
"You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Output concise, actionable drafts.", "You are the LemonSpace Agent Executor. Produce concrete channel outputs from context and clarification answers. Return one output per step, keyed by stepId.",
}, },
{ {
role: "user", role: "user",
content: [ content: [
`Template: ${template?.name ?? "Unknown template"}`, `Template: ${template?.name ?? "Unknown template"}`,
`Template description: ${template?.description ?? ""}`, `Template description: ${template?.description ?? ""}`,
`Analyze summary: ${trimText(args.analysisSummary)}`, `Analyze summary: ${executionPlanSummary}`,
`Clarification answers: ${JSON.stringify(clarificationAnswers)}`, `Clarification answers: ${JSON.stringify(clarificationAnswers)}`,
`Execution steps: ${JSON.stringify(
executionSteps.map((step) => ({
id: step.id,
title: step.title,
channel: step.channel,
outputType: step.outputType,
})),
)}`,
"Incoming node context:", "Incoming node context:",
incomingContext, incomingContext,
"Return structured JSON matching the schema.", "Return structured JSON matching the schema.",
@@ -752,15 +1000,45 @@ export const executeAgent = internalAction({
], ],
}); });
const outputs = Array.isArray(execution.outputs) ? execution.outputs : []; const stepOutputs =
if (outputs.length === 0) { execution.stepOutputs && typeof execution.stepOutputs === "object"
throw new Error("Agent execution returned no outputs"); ? execution.stepOutputs
: {};
for (let index = 0; index < executionSteps.length; index += 1) {
const step = executionSteps[index];
await ctx.runMutation(internalApi.agents.setAgentExecuting, {
nodeId: args.nodeId,
statusMessage: `Generating ${step.title} ${step.stepIndex + 1}/${step.stepTotal}`,
});
const rawOutput = stepOutputs[step.id];
if (!rawOutput || typeof rawOutput !== "object") {
throw new Error(`Missing execution output for step ${step.id}`);
}
const normalized = normalizeAgentOutputDraft({
...rawOutput,
title: trimText(rawOutput.title) || step.title,
channel: trimText(rawOutput.channel) || step.channel,
outputType: trimText(rawOutput.outputType) || step.outputType,
});
await ctx.runMutation(internalApi.agents.completeExecutionStepOutput, {
nodeId: args.nodeId,
outputNodeId: step.nodeId,
stepId: step.id,
stepIndex: step.stepIndex,
stepTotal: step.stepTotal,
title: normalized.title,
channel: normalized.channel,
outputType: normalized.outputType,
body: normalized.body,
});
} }
await ctx.runMutation(internalApi.agents.finalizeAgentSuccessWithOutputs, { await ctx.runMutation(internalApi.agents.finalizeAgentSuccessWithOutputs, {
canvasId: args.canvasId,
nodeId: args.nodeId, nodeId: args.nodeId,
outputs,
summary: execution.summary, summary: execution.summary,
}); });

View File

@@ -13,9 +13,16 @@ export type AgentOutputDraft = {
body?: string; body?: string;
}; };
export type AgentExecutionStep = {
id: string;
title: string;
channel: string;
outputType: string;
};
export type AgentExecutionPlan = { export type AgentExecutionPlan = {
steps: string[]; summary: string;
outputs: AgentOutputDraft[]; steps: AgentExecutionStep[];
}; };
export type AgentAnalyzeResult = { export type AgentAnalyzeResult = {
@@ -32,6 +39,53 @@ function trimString(value: unknown): string {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
} }
function normalizeStepId(value: unknown): string {
return trimString(value)
.toLowerCase()
.replace(/[^a-z0-9\s-_]/g, "")
.replace(/\s+/g, "-");
}
export function normalizeAgentExecutionPlan(raw: unknown): AgentExecutionPlan {
const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: null;
const rawSteps = Array.isArray(rawRecord?.steps) ? rawRecord.steps : [];
const seenStepIds = new Set<string>();
const steps: AgentExecutionStep[] = [];
for (let index = 0; index < rawSteps.length; index += 1) {
const item = rawSteps[index];
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue;
}
const itemRecord = item as Record<string, unknown>;
const fallbackId = `step-${index + 1}`;
const normalizedCandidateId = normalizeStepId(itemRecord.id) || fallbackId;
let stepId = normalizedCandidateId;
let suffix = 2;
while (seenStepIds.has(stepId)) {
stepId = `${normalizedCandidateId}-${suffix}`;
suffix += 1;
}
seenStepIds.add(stepId);
steps.push({
id: stepId,
title: trimString(itemRecord.title) || SAFE_FALLBACK_TITLE,
channel: trimString(itemRecord.channel) || SAFE_FALLBACK_CHANNEL,
outputType: trimString(itemRecord.outputType) || SAFE_FALLBACK_OUTPUT_TYPE,
});
}
return {
summary: trimString(rawRecord?.summary),
steps,
};
}
export function areClarificationAnswersComplete( export function areClarificationAnswersComplete(
questions: AgentClarificationQuestion[], questions: AgentClarificationQuestion[],
answers: AgentClarificationAnswerMap, answers: AgentClarificationAnswerMap,

View File

@@ -293,4 +293,51 @@ describe("AgentNode runtime", () => {
expect(mocks.runAgent).not.toHaveBeenCalled(); expect(mocks.runAgent).not.toHaveBeenCalled();
expect(mocks.resumeAgent).not.toHaveBeenCalled(); expect(mocks.resumeAgent).not.toHaveBeenCalled();
}); });
it("disables run button and shows progress while executing", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-3",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent",
data: {
canvasId: "canvas-1",
templateId: "campaign-distributor",
modelId: "openai/gpt-5.4-mini",
_status: "executing",
_statusMessage: "Executing step 2/4",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const runButton = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Run agent"),
);
if (!(runButton instanceof HTMLButtonElement)) {
throw new Error("Run button not found");
}
expect(runButton.disabled).toBe(true);
expect(container.textContent).toContain("Executing step 2/4");
await act(async () => {
runButton.click();
});
expect(mocks.runAgent).not.toHaveBeenCalled();
});
}); });

View File

@@ -45,7 +45,7 @@ describe("AgentNode", () => {
root = null; root = null;
}); });
it("renders campaign distributor metadata and input-only handle", async () => { it("renders campaign distributor metadata and source/target handles", async () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
@@ -76,7 +76,7 @@ describe("AgentNode", () => {
expect(container.textContent).toContain("Instagram Feed"); expect(container.textContent).toContain("Instagram Feed");
expect(container.textContent).toContain("Caption-Pakete"); expect(container.textContent).toContain("Caption-Pakete");
expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1); expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1);
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(0); expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(1);
}); });
it("falls back to the default template when templateId is missing", async () => { it("falls back to the default template when templateId is missing", async () => {

View File

@@ -112,4 +112,41 @@ describe("AgentOutputNode", () => {
expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]); expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]);
}); });
it("renders skeleton mode with counter and placeholder", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-3",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "Planned headline",
channel: "linkedin",
outputType: "post",
isSkeleton: true,
stepIndex: 1,
stepTotal: 4,
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.textContent).toContain("Skeleton");
expect(container.textContent).toContain("2/4");
expect(container.querySelector('[data-testid="agent-output-skeleton-body"]')).not.toBeNull();
expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]);
});
}); });

View File

@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import { import {
areClarificationAnswersComplete, areClarificationAnswersComplete,
normalizeAgentExecutionPlan,
normalizeAgentOutputDraft, normalizeAgentOutputDraft,
type AgentClarificationAnswerMap, type AgentClarificationAnswerMap,
type AgentClarificationQuestion, type AgentClarificationQuestion,
type AgentExecutionPlan,
} from "@/lib/agent-run-contract"; } from "@/lib/agent-run-contract";
describe("agent run contract helpers", () => { describe("agent run contract helpers", () => {
@@ -93,4 +95,87 @@ describe("agent run contract helpers", () => {
expect(normalized.body).toBe(""); expect(normalized.body).toBe("");
}); });
}); });
describe("normalizeAgentExecutionPlan", () => {
it("trims summary and step metadata while preserving valid values", () => {
const normalized = normalizeAgentExecutionPlan({
summary: " Ship a launch kit ",
steps: [
{
id: " STEP-1 ",
title: " Instagram captions ",
channel: " Instagram ",
outputType: " caption-pack ",
},
],
});
expect(normalized).toEqual<AgentExecutionPlan>({
summary: "Ship a launch kit",
steps: [
{
id: "step-1",
title: "Instagram captions",
channel: "Instagram",
outputType: "caption-pack",
},
],
});
});
it("falls back to safe defaults for invalid payloads", () => {
const normalized = normalizeAgentExecutionPlan({
summary: null,
steps: [
{
id: "",
title: "",
channel: " ",
outputType: undefined,
},
null,
],
});
expect(normalized).toEqual<AgentExecutionPlan>({
summary: "",
steps: [
{
id: "step-1",
title: "Untitled",
channel: "general",
outputType: "text",
},
],
});
});
it("deduplicates step ids and creates deterministic fallback ids", () => {
const normalized = normalizeAgentExecutionPlan({
summary: "ready",
steps: [
{
id: "step",
title: "One",
channel: "email",
outputType: "copy",
},
{
id: "step",
title: "Two",
channel: "x",
outputType: "thread",
},
{
id: "",
title: "Three",
channel: "linkedin",
outputType: "post",
},
],
});
expect(normalized.steps.map((step) => step.id)).toEqual(["step", "step-2", "step-3"]);
});
});
}); });