feat(agent): add execution-plan skeleton workflow
This commit is contained in:
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
{resolvedTitle}
|
||||||
</p>
|
</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>
|
||||||
|
{isSkeleton ? (
|
||||||
|
<div
|
||||||
|
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">
|
<p className="line-clamp-6 whitespace-pre-wrap text-xs text-foreground/90">
|
||||||
{nodeData.body ?? ""}
|
{nodeData.body ?? ""}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
|
|||||||
418
convex/agents.ts
418
convex/agents.ts
@@ -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,20 +50,37 @@ const ANALYZE_SCHEMA: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
executionPlan: {
|
||||||
};
|
|
||||||
|
|
||||||
const EXECUTE_SCHEMA: Record<string, unknown> = {
|
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ["summary", "outputs"],
|
required: ["summary", "steps"],
|
||||||
properties: {
|
properties: {
|
||||||
summary: { type: "string" },
|
summary: { type: "string" },
|
||||||
outputs: {
|
steps: {
|
||||||
type: "array",
|
type: "array",
|
||||||
minItems: 1,
|
minItems: 1,
|
||||||
maxItems: 6,
|
maxItems: 6,
|
||||||
items: {
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "title", "channel", "outputType"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
title: { type: "string" },
|
||||||
|
channel: { type: "string" },
|
||||||
|
outputType: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
|
||||||
|
const stepOutputProperties: Record<string, unknown> = {};
|
||||||
|
for (const stepId of stepIds) {
|
||||||
|
stepOutputProperties[stepId] = {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ["title", "channel", "outputType", "body"],
|
required: ["title", "channel", "outputType", "body"],
|
||||||
@@ -71,10 +90,24 @@ const EXECUTE_SCHEMA: Record<string, unknown> = {
|
|||||||
outputType: { type: "string" },
|
outputType: { type: "string" },
|
||||||
body: { 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: {
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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" }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user