stagent 0.9.2 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +36 -1
- package/docs/superpowers/specs/2026-04-06-workflow-intelligence-stack-design.md +388 -0
- package/package.json +1 -1
- package/src/app/api/license/route.ts +3 -2
- package/src/app/api/workflows/[id]/debug/route.ts +18 -0
- package/src/app/api/workflows/[id]/execute/route.ts +39 -8
- package/src/app/api/workflows/optimize/route.ts +30 -0
- package/src/app/layout.tsx +4 -2
- package/src/components/chat/chat-message-markdown.tsx +78 -3
- package/src/components/chat/chat-message.tsx +12 -4
- package/src/components/settings/cloud-account-section.tsx +14 -12
- package/src/components/workflows/error-timeline.tsx +83 -0
- package/src/components/workflows/step-live-metrics.tsx +182 -0
- package/src/components/workflows/step-progress-bar.tsx +77 -0
- package/src/components/workflows/workflow-debug-panel.tsx +192 -0
- package/src/components/workflows/workflow-optimizer-panel.tsx +227 -0
- package/src/lib/agents/claude-agent.ts +4 -4
- package/src/lib/agents/runtime/anthropic-direct.ts +3 -3
- package/src/lib/agents/runtime/catalog.ts +30 -1
- package/src/lib/agents/runtime/openai-direct.ts +3 -3
- package/src/lib/billing/products.ts +6 -6
- package/src/lib/book/chapter-mapping.ts +6 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/book/reading-paths.ts +1 -1
- package/src/lib/chat/__tests__/engine-stream-helpers.test.ts +57 -0
- package/src/lib/chat/engine.ts +68 -7
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/tools/runtime-tools.ts +28 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -1
- package/src/lib/chat/tools/settings-tools.ts +40 -10
- package/src/lib/chat/tools/workflow-tools.ts +93 -4
- package/src/lib/chat/types.ts +21 -0
- package/src/lib/data/clear.ts +3 -0
- package/src/lib/db/bootstrap.ts +38 -0
- package/src/lib/db/migrations/0022_workflow_intelligence_phase1.sql +5 -0
- package/src/lib/db/migrations/0023_add_execution_stats.sql +15 -0
- package/src/lib/db/schema.ts +41 -1
- package/src/lib/license/__tests__/manager.test.ts +64 -0
- package/src/lib/license/manager.ts +80 -25
- package/src/lib/schedules/__tests__/interval-parser.test.ts +87 -0
- package/src/lib/schedules/__tests__/prompt-analyzer.test.ts +51 -0
- package/src/lib/schedules/interval-parser.ts +187 -0
- package/src/lib/schedules/prompt-analyzer.ts +87 -0
- package/src/lib/schedules/scheduler.ts +179 -9
- package/src/lib/workflows/cost-estimator.ts +141 -0
- package/src/lib/workflows/engine.ts +245 -45
- package/src/lib/workflows/error-analysis.ts +249 -0
- package/src/lib/workflows/execution-stats.ts +252 -0
- package/src/lib/workflows/optimizer.ts +193 -0
- package/src/lib/workflows/types.ts +6 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import { workflows, agentLogs } from "@/lib/db/schema";
|
|
3
|
+
import { eq, sql } from "drizzle-orm";
|
|
4
|
+
import type { WorkflowDefinition, WorkflowState, StepState } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface TimelineEvent {
|
|
7
|
+
timestamp: string;
|
|
8
|
+
event: string;
|
|
9
|
+
severity: "success" | "warning" | "error";
|
|
10
|
+
details: string;
|
|
11
|
+
stepId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FixSuggestion {
|
|
15
|
+
tier: "quick" | "better" | "best";
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
action?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DebugAnalysis {
|
|
22
|
+
rootCause: {
|
|
23
|
+
type: "budget_exceeded" | "timeout" | "transient" | "unknown";
|
|
24
|
+
summary: string;
|
|
25
|
+
};
|
|
26
|
+
timeline: TimelineEvent[];
|
|
27
|
+
suggestions: FixSuggestion[];
|
|
28
|
+
stepErrors: Array<{
|
|
29
|
+
stepId: string;
|
|
30
|
+
stepName: string;
|
|
31
|
+
error: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function classifyRootCause(errors: string[]): DebugAnalysis["rootCause"] {
|
|
36
|
+
const combined = errors.join(" ");
|
|
37
|
+
|
|
38
|
+
if (/budget|Budget|maximum budget/i.test(combined)) {
|
|
39
|
+
return {
|
|
40
|
+
type: "budget_exceeded",
|
|
41
|
+
summary: "The workflow exceeded its allocated budget before completing all steps.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (/timeout|max turns|Turn limit/i.test(combined)) {
|
|
46
|
+
return {
|
|
47
|
+
type: "timeout",
|
|
48
|
+
summary: "The workflow ran out of turns or hit a timeout before completing.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (/connection|rate limit|ECONNREFUSED/i.test(combined)) {
|
|
53
|
+
return {
|
|
54
|
+
type: "transient",
|
|
55
|
+
summary: "A transient network or rate-limit error interrupted the workflow.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
type: "unknown",
|
|
61
|
+
summary: errors.length > 0
|
|
62
|
+
? `Workflow failed: ${errors[0].slice(0, 200)}`
|
|
63
|
+
: "The workflow failed for an unknown reason.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildSuggestions(type: DebugAnalysis["rootCause"]["type"]): FixSuggestion[] {
|
|
68
|
+
switch (type) {
|
|
69
|
+
case "budget_exceeded":
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
tier: "quick",
|
|
73
|
+
title: "Raise budget to $10",
|
|
74
|
+
description: "Increase the per-step or workflow budget cap to allow the agent more room to finish.",
|
|
75
|
+
action: "raise_budget",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
tier: "better",
|
|
79
|
+
title: "Reduce document context per step",
|
|
80
|
+
description: "Attach fewer or smaller documents to each step so the agent consumes less budget on context.",
|
|
81
|
+
action: "reduce_docs",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
tier: "best",
|
|
85
|
+
title: "Split into smaller workflows",
|
|
86
|
+
description: "Break the workflow into focused sub-workflows that each stay within budget.",
|
|
87
|
+
action: "restructure",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
case "timeout":
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
tier: "quick",
|
|
94
|
+
title: "Increase max turns to 100",
|
|
95
|
+
description: "Give the agent more turns to complete complex reasoning chains.",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
tier: "better",
|
|
99
|
+
title: "Simplify step prompts",
|
|
100
|
+
description: "Reduce prompt complexity so the agent can finish in fewer turns.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
tier: "best",
|
|
104
|
+
title: "Break complex steps into sub-steps",
|
|
105
|
+
description: "Decompose multi-part steps so each one is tractable within the turn limit.",
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
case "transient":
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
tier: "quick",
|
|
112
|
+
title: "Retry the workflow",
|
|
113
|
+
description: "The error was likely temporary. Re-running may succeed without changes.",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
tier: "better",
|
|
117
|
+
title: "Switch to a different runtime",
|
|
118
|
+
description: "Use an alternative agent runtime that may have better availability.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
tier: "best",
|
|
122
|
+
title: "Add retry logic to step definitions",
|
|
123
|
+
description: "Configure automatic retries on transient failures for resilient execution.",
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
default:
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
tier: "quick",
|
|
130
|
+
title: "Check agent logs for details",
|
|
131
|
+
description: "Review the full agent log output for the failed step to understand the error.",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
tier: "better",
|
|
135
|
+
title: "Simplify workflow",
|
|
136
|
+
description: "Reduce the number of steps or prompt complexity to isolate the failure.",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
tier: "best",
|
|
140
|
+
title: "Contact support",
|
|
141
|
+
description: "If the issue persists, reach out with the workflow ID and debug timeline.",
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mapEventSeverity(event: string): TimelineEvent["severity"] {
|
|
148
|
+
if (event.includes("failed") || event.includes("error")) return "error";
|
|
149
|
+
if (event.includes("completed") || event.includes("success")) return "success";
|
|
150
|
+
return "warning";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function analyzeWorkflowFailure(workflowId: string): Promise<DebugAnalysis> {
|
|
154
|
+
// 1. Fetch workflow and parse state
|
|
155
|
+
const [workflow] = await db
|
|
156
|
+
.select()
|
|
157
|
+
.from(workflows)
|
|
158
|
+
.where(eq(workflows.id, workflowId));
|
|
159
|
+
|
|
160
|
+
if (!workflow) {
|
|
161
|
+
throw new Error(`Workflow ${workflowId} not found`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const definition: WorkflowDefinition & { _state?: WorkflowState } = JSON.parse(
|
|
165
|
+
workflow.definition
|
|
166
|
+
);
|
|
167
|
+
const state = definition._state;
|
|
168
|
+
|
|
169
|
+
// 2. Query agent_logs for this workflow
|
|
170
|
+
const logs = await db
|
|
171
|
+
.select()
|
|
172
|
+
.from(agentLogs)
|
|
173
|
+
.where(sql`${agentLogs.payload} LIKE ${"%" + workflowId + "%"}`)
|
|
174
|
+
.orderBy(agentLogs.timestamp);
|
|
175
|
+
|
|
176
|
+
// 3. Build timeline from agent_logs
|
|
177
|
+
const timeline: TimelineEvent[] = logs.map((log) => {
|
|
178
|
+
let details = "";
|
|
179
|
+
try {
|
|
180
|
+
const payload = JSON.parse(log.payload ?? "{}");
|
|
181
|
+
details = payload.error || payload.result?.slice(0, 200) || payload.stepName || log.event;
|
|
182
|
+
} catch {
|
|
183
|
+
details = log.event;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let stepId: string | undefined;
|
|
187
|
+
try {
|
|
188
|
+
const payload = JSON.parse(log.payload ?? "{}");
|
|
189
|
+
stepId = payload.stepId;
|
|
190
|
+
} catch {
|
|
191
|
+
// ignore
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
timestamp: log.timestamp instanceof Date
|
|
196
|
+
? log.timestamp.toISOString()
|
|
197
|
+
: new Date(log.timestamp).toISOString(),
|
|
198
|
+
event: log.event,
|
|
199
|
+
severity: mapEventSeverity(log.event),
|
|
200
|
+
details,
|
|
201
|
+
stepId,
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// 4. Extract step errors from _state
|
|
206
|
+
const stepErrors: DebugAnalysis["stepErrors"] = [];
|
|
207
|
+
const errorMessages: string[] = [];
|
|
208
|
+
|
|
209
|
+
if (state?.stepStates) {
|
|
210
|
+
for (const ss of state.stepStates) {
|
|
211
|
+
if (ss.status === "failed" && ss.error) {
|
|
212
|
+
const stepDef = definition.steps.find((s) => s.id === ss.stepId);
|
|
213
|
+
stepErrors.push({
|
|
214
|
+
stepId: ss.stepId,
|
|
215
|
+
stepName: stepDef?.name ?? ss.stepId,
|
|
216
|
+
error: ss.error,
|
|
217
|
+
});
|
|
218
|
+
errorMessages.push(ss.error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Also gather errors from logs if no step errors found
|
|
224
|
+
if (errorMessages.length === 0) {
|
|
225
|
+
for (const log of logs) {
|
|
226
|
+
try {
|
|
227
|
+
const payload = JSON.parse(log.payload ?? "{}");
|
|
228
|
+
if (payload.error) {
|
|
229
|
+
errorMessages.push(payload.error);
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// ignore
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 5. Classify root cause
|
|
238
|
+
const rootCause = classifyRootCause(errorMessages);
|
|
239
|
+
|
|
240
|
+
// 6. Generate suggestions
|
|
241
|
+
const suggestions = buildSuggestions(rootCause.type);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
rootCause,
|
|
245
|
+
timeline,
|
|
246
|
+
suggestions,
|
|
247
|
+
stepErrors,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import {
|
|
3
|
+
workflows,
|
|
4
|
+
usageLedger,
|
|
5
|
+
agentLogs,
|
|
6
|
+
workflowExecutionStats,
|
|
7
|
+
workflowDocumentInputs,
|
|
8
|
+
} from "@/lib/db/schema";
|
|
9
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
10
|
+
import type { WorkflowDefinition } from "./types";
|
|
11
|
+
|
|
12
|
+
// ── Stats update ────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Update aggregated execution stats after a workflow run completes or fails.
|
|
16
|
+
* Keyed by (pattern, stepCount) bucket — running averages across multiple runs.
|
|
17
|
+
*/
|
|
18
|
+
export async function updateExecutionStats(workflowId: string): Promise<void> {
|
|
19
|
+
const [workflow] = await db
|
|
20
|
+
.select()
|
|
21
|
+
.from(workflows)
|
|
22
|
+
.where(eq(workflows.id, workflowId));
|
|
23
|
+
|
|
24
|
+
if (!workflow) return;
|
|
25
|
+
|
|
26
|
+
const definition: WorkflowDefinition = JSON.parse(workflow.definition);
|
|
27
|
+
const pattern = definition.pattern;
|
|
28
|
+
const stepCount = definition.steps.length;
|
|
29
|
+
const succeeded = workflow.status === "completed";
|
|
30
|
+
|
|
31
|
+
// Query usage ledger for this workflow's run
|
|
32
|
+
const usageEntries = await db
|
|
33
|
+
.select()
|
|
34
|
+
.from(usageLedger)
|
|
35
|
+
.where(eq(usageLedger.workflowId, workflowId));
|
|
36
|
+
|
|
37
|
+
const totalCostMicros = usageEntries.reduce(
|
|
38
|
+
(sum, entry) => sum + (entry.costMicros ?? 0),
|
|
39
|
+
0
|
|
40
|
+
);
|
|
41
|
+
const totalDurationMs = usageEntries.reduce((sum, entry) => {
|
|
42
|
+
if (entry.startedAt && entry.finishedAt) {
|
|
43
|
+
return sum + (entry.finishedAt.getTime() - entry.startedAt.getTime());
|
|
44
|
+
}
|
|
45
|
+
return sum;
|
|
46
|
+
}, 0);
|
|
47
|
+
|
|
48
|
+
const avgCostPerStep = stepCount > 0 ? Math.round(totalCostMicros / stepCount) : 0;
|
|
49
|
+
const avgDurationPerStep = stepCount > 0 ? Math.round(totalDurationMs / stepCount) : 0;
|
|
50
|
+
|
|
51
|
+
// Count documents per step
|
|
52
|
+
const docBindings = await db
|
|
53
|
+
.select()
|
|
54
|
+
.from(workflowDocumentInputs)
|
|
55
|
+
.where(eq(workflowDocumentInputs.workflowId, workflowId));
|
|
56
|
+
const avgDocsPerStep = stepCount > 0 ? docBindings.length / stepCount : 0;
|
|
57
|
+
|
|
58
|
+
// Collect runtime distribution
|
|
59
|
+
const runtimeCounts: Record<string, number> = {};
|
|
60
|
+
for (const entry of usageEntries) {
|
|
61
|
+
runtimeCounts[entry.runtimeId] = (runtimeCounts[entry.runtimeId] ?? 0) + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Classify failure type from agent logs
|
|
65
|
+
let failureType: string | null = null;
|
|
66
|
+
if (!succeeded) {
|
|
67
|
+
const failLogs = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(agentLogs)
|
|
70
|
+
.where(
|
|
71
|
+
and(
|
|
72
|
+
eq(agentLogs.event, "workflow_failed"),
|
|
73
|
+
sql`${agentLogs.payload} LIKE ${"%" + workflowId + "%"}`
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
for (const log of failLogs) {
|
|
78
|
+
const payload = log.payload ?? "";
|
|
79
|
+
if (payload.includes("budget") || payload.includes("Budget")) {
|
|
80
|
+
failureType = "budget_exceeded";
|
|
81
|
+
} else if (payload.includes("timeout") || payload.includes("max turns")) {
|
|
82
|
+
failureType = "timeout";
|
|
83
|
+
} else if (payload.includes("connection") || payload.includes("rate limit")) {
|
|
84
|
+
failureType = "transient";
|
|
85
|
+
} else {
|
|
86
|
+
failureType = "other";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Upsert into stats table keyed by (pattern, stepCount)
|
|
92
|
+
const bucketId = `${pattern}:${stepCount}`;
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
|
|
95
|
+
const [existing] = await db
|
|
96
|
+
.select()
|
|
97
|
+
.from(workflowExecutionStats)
|
|
98
|
+
.where(eq(workflowExecutionStats.id, bucketId));
|
|
99
|
+
|
|
100
|
+
if (existing) {
|
|
101
|
+
const n = existing.sampleCount;
|
|
102
|
+
const newN = n + 1;
|
|
103
|
+
|
|
104
|
+
// Running average: newAvg = (oldAvg * n + newValue) / (n + 1)
|
|
105
|
+
const newAvgCost = Math.round(
|
|
106
|
+
((existing.avgCostPerStepMicros ?? 0) * n + avgCostPerStep) / newN
|
|
107
|
+
);
|
|
108
|
+
const newAvgDuration = Math.round(
|
|
109
|
+
((existing.avgDurationPerStepMs ?? 0) * n + avgDurationPerStep) / newN
|
|
110
|
+
);
|
|
111
|
+
const newAvgDocs = ((existing.avgDocsPerStep ?? 0) * n + avgDocsPerStep) / newN;
|
|
112
|
+
const newSuccessRate =
|
|
113
|
+
((existing.successRate ?? 0) * n + (succeeded ? 1 : 0)) / newN;
|
|
114
|
+
|
|
115
|
+
// Merge common failures
|
|
116
|
+
const existingFailures: Record<string, number> = existing.commonFailures
|
|
117
|
+
? JSON.parse(existing.commonFailures)
|
|
118
|
+
: {};
|
|
119
|
+
if (failureType) {
|
|
120
|
+
existingFailures[failureType] = (existingFailures[failureType] ?? 0) + 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Merge runtime breakdown (convert counts to rates)
|
|
124
|
+
const existingRuntimes: Record<string, number> = existing.runtimeBreakdown
|
|
125
|
+
? JSON.parse(existing.runtimeBreakdown)
|
|
126
|
+
: {};
|
|
127
|
+
for (const [rtId, count] of Object.entries(runtimeCounts)) {
|
|
128
|
+
existingRuntimes[rtId] = (existingRuntimes[rtId] ?? 0) + count;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await db
|
|
132
|
+
.update(workflowExecutionStats)
|
|
133
|
+
.set({
|
|
134
|
+
avgCostPerStepMicros: newAvgCost,
|
|
135
|
+
avgDurationPerStepMs: newAvgDuration,
|
|
136
|
+
avgDocsPerStep: Math.round(newAvgDocs * 100) / 100,
|
|
137
|
+
successRate: Math.round(newSuccessRate * 1000) / 1000,
|
|
138
|
+
commonFailures: JSON.stringify(existingFailures),
|
|
139
|
+
runtimeBreakdown: JSON.stringify(existingRuntimes),
|
|
140
|
+
sampleCount: newN,
|
|
141
|
+
lastUpdated: now,
|
|
142
|
+
})
|
|
143
|
+
.where(eq(workflowExecutionStats.id, bucketId));
|
|
144
|
+
} else {
|
|
145
|
+
await db.insert(workflowExecutionStats).values({
|
|
146
|
+
id: bucketId,
|
|
147
|
+
pattern,
|
|
148
|
+
stepCount,
|
|
149
|
+
avgDocsPerStep: Math.round(avgDocsPerStep * 100) / 100,
|
|
150
|
+
avgCostPerStepMicros: avgCostPerStep,
|
|
151
|
+
avgDurationPerStepMs: avgDurationPerStep,
|
|
152
|
+
successRate: succeeded ? 1.0 : 0.0,
|
|
153
|
+
commonFailures: failureType ? JSON.stringify({ [failureType]: 1 }) : "{}",
|
|
154
|
+
runtimeBreakdown: JSON.stringify(runtimeCounts),
|
|
155
|
+
sampleCount: 1,
|
|
156
|
+
lastUpdated: now,
|
|
157
|
+
createdAt: now,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Optimization hints ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export interface OptimizationHints {
|
|
165
|
+
budgetRecommendation: number | null;
|
|
166
|
+
docBindingStrategy: "global" | "per-step";
|
|
167
|
+
runtimeRecommendation: string | null;
|
|
168
|
+
patternComparison: { pattern: string; successRate: number } | null;
|
|
169
|
+
similarWorkflowStats: {
|
|
170
|
+
avgCostPerStepMicros: number;
|
|
171
|
+
avgDurationPerStepMs: number;
|
|
172
|
+
successRate: number;
|
|
173
|
+
sampleCount: number;
|
|
174
|
+
} | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get optimization hints based on historical execution data.
|
|
179
|
+
* Returns sensible defaults when no history exists (cold start).
|
|
180
|
+
*/
|
|
181
|
+
export async function getWorkflowOptimizationHints(
|
|
182
|
+
pattern: string,
|
|
183
|
+
stepCount: number,
|
|
184
|
+
_docCount: number
|
|
185
|
+
): Promise<OptimizationHints> {
|
|
186
|
+
const bucketId = `${pattern}:${stepCount}`;
|
|
187
|
+
const [stats] = await db
|
|
188
|
+
.select()
|
|
189
|
+
.from(workflowExecutionStats)
|
|
190
|
+
.where(eq(workflowExecutionStats.id, bucketId));
|
|
191
|
+
|
|
192
|
+
if (!stats || stats.sampleCount === 0) {
|
|
193
|
+
return {
|
|
194
|
+
budgetRecommendation: null,
|
|
195
|
+
docBindingStrategy: "global",
|
|
196
|
+
runtimeRecommendation: null,
|
|
197
|
+
patternComparison: null,
|
|
198
|
+
similarWorkflowStats: null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Budget recommendation: avg + 50% buffer
|
|
203
|
+
const avgCostUsd = (stats.avgCostPerStepMicros ?? 0) / 1_000_000;
|
|
204
|
+
const budgetRecommendation =
|
|
205
|
+
Math.round(avgCostUsd * 1.5 * stepCount * 100) / 100;
|
|
206
|
+
|
|
207
|
+
// Doc binding strategy: per-step if avg docs > 3
|
|
208
|
+
const docBindingStrategy =
|
|
209
|
+
(stats.avgDocsPerStep ?? 0) > 3 ? "per-step" : "global";
|
|
210
|
+
|
|
211
|
+
// Runtime recommendation: pick highest success rate from breakdown
|
|
212
|
+
let runtimeRecommendation: string | null = null;
|
|
213
|
+
if (stats.runtimeBreakdown) {
|
|
214
|
+
const breakdown: Record<string, number> = JSON.parse(stats.runtimeBreakdown);
|
|
215
|
+
const totalRuns = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
|
216
|
+
if (totalRuns > 0) {
|
|
217
|
+
runtimeRecommendation = Object.entries(breakdown).sort(
|
|
218
|
+
([, a], [, b]) => b - a
|
|
219
|
+
)[0]?.[0] ?? null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Pattern comparison: check if alternative pattern has better success rate
|
|
224
|
+
let patternComparison: { pattern: string; successRate: number } | null = null;
|
|
225
|
+
const allStats = await db.select().from(workflowExecutionStats);
|
|
226
|
+
for (const altStats of allStats) {
|
|
227
|
+
if (
|
|
228
|
+
altStats.pattern !== pattern &&
|
|
229
|
+
altStats.sampleCount >= 3 &&
|
|
230
|
+
(altStats.successRate ?? 0) > (stats.successRate ?? 0) + 0.2
|
|
231
|
+
) {
|
|
232
|
+
patternComparison = {
|
|
233
|
+
pattern: altStats.pattern,
|
|
234
|
+
successRate: altStats.successRate ?? 0,
|
|
235
|
+
};
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
budgetRecommendation: budgetRecommendation > 0 ? budgetRecommendation : null,
|
|
242
|
+
docBindingStrategy,
|
|
243
|
+
runtimeRecommendation,
|
|
244
|
+
patternComparison,
|
|
245
|
+
similarWorkflowStats: {
|
|
246
|
+
avgCostPerStepMicros: stats.avgCostPerStepMicros ?? 0,
|
|
247
|
+
avgDurationPerStepMs: stats.avgDurationPerStepMs ?? 0,
|
|
248
|
+
successRate: stats.successRate ?? 0,
|
|
249
|
+
sampleCount: stats.sampleCount,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { WorkflowDefinition } from "./types";
|
|
2
|
+
import { getWorkflowOptimizationHints } from "./execution-stats";
|
|
3
|
+
import { estimateWorkflowCost } from "./cost-estimator";
|
|
4
|
+
|
|
5
|
+
export type SuggestionType =
|
|
6
|
+
| "document_binding"
|
|
7
|
+
| "budget_estimate"
|
|
8
|
+
| "runtime_recommendation"
|
|
9
|
+
| "pattern_insight";
|
|
10
|
+
|
|
11
|
+
export interface OptimizationSuggestion {
|
|
12
|
+
type: SuggestionType;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
data: Record<string, unknown>;
|
|
16
|
+
action?: {
|
|
17
|
+
label: string;
|
|
18
|
+
type: string;
|
|
19
|
+
payload: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate optimization suggestions for a workflow definition.
|
|
25
|
+
* Analyzes document bindings, budget, runtime, and pattern choices
|
|
26
|
+
* against historical execution data.
|
|
27
|
+
*
|
|
28
|
+
* Graceful on errors — each sub-analysis is independent and failures
|
|
29
|
+
* are caught so the remaining suggestions still return.
|
|
30
|
+
*/
|
|
31
|
+
export async function generateOptimizationSuggestions(
|
|
32
|
+
definition: Partial<WorkflowDefinition>,
|
|
33
|
+
workflowId?: string
|
|
34
|
+
): Promise<OptimizationSuggestion[]> {
|
|
35
|
+
const suggestions: OptimizationSuggestion[] = [];
|
|
36
|
+
|
|
37
|
+
// ── Document Binding Analysis ──────────────────────────────────────
|
|
38
|
+
try {
|
|
39
|
+
const steps = definition.steps ?? [];
|
|
40
|
+
if (steps.length > 0) {
|
|
41
|
+
// Collect all document IDs referenced at the workflow level (global docs)
|
|
42
|
+
const globalDocIds: string[] = [];
|
|
43
|
+
for (const step of steps) {
|
|
44
|
+
if (step.documentIds) {
|
|
45
|
+
for (const docId of step.documentIds) {
|
|
46
|
+
if (!globalDocIds.includes(docId)) {
|
|
47
|
+
globalDocIds.push(docId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const stepCount = steps.length;
|
|
54
|
+
const docCount = globalDocIds.length;
|
|
55
|
+
|
|
56
|
+
// If many docs across many steps, per-step binding reduces redundant injections
|
|
57
|
+
if (stepCount > 2 && docCount > 3) {
|
|
58
|
+
const totalInjections = docCount * stepCount;
|
|
59
|
+
// Estimate that per-step binding typically needs ~40% of total injections
|
|
60
|
+
const perStepInjections = Math.max(
|
|
61
|
+
stepCount,
|
|
62
|
+
Math.round(totalInjections * 0.4)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
suggestions.push({
|
|
66
|
+
type: "document_binding",
|
|
67
|
+
title: "Reduce document injections with per-step binding",
|
|
68
|
+
description: `${docCount} docs × ${stepCount} steps = ${totalInjections} injections → only ${perStepInjections} needed with per-step binding`,
|
|
69
|
+
data: {
|
|
70
|
+
globalDocCount: docCount,
|
|
71
|
+
stepCount,
|
|
72
|
+
totalInjections,
|
|
73
|
+
perStepInjections,
|
|
74
|
+
},
|
|
75
|
+
action: {
|
|
76
|
+
label: "Enable per-step docs",
|
|
77
|
+
type: "set_per_step_docs",
|
|
78
|
+
payload: { strategy: "per-step" },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Silently skip document binding analysis on error
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Budget Estimate ────────────────────────────────────────────────
|
|
88
|
+
try {
|
|
89
|
+
if (workflowId) {
|
|
90
|
+
const costEstimate = await estimateWorkflowCost(workflowId);
|
|
91
|
+
|
|
92
|
+
if (costEstimate.steps.length > 0) {
|
|
93
|
+
const perStepBreakdown = costEstimate.steps.map((s) => ({
|
|
94
|
+
name: s.name,
|
|
95
|
+
estimatedCostUsd: s.estimatedCostUsd,
|
|
96
|
+
budgetCapUsd: s.budgetCapUsd,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
suggestions.push({
|
|
100
|
+
type: "budget_estimate",
|
|
101
|
+
title: `Estimated cost: $${costEstimate.totalEstimatedCostUsd.toFixed(4)}`,
|
|
102
|
+
description: costEstimate.overBudget
|
|
103
|
+
? `Over budget — total cap is $${costEstimate.totalBudgetCapUsd.toFixed(2)}. ${costEstimate.warnings[0] ?? ""}`
|
|
104
|
+
: `Within budget cap of $${costEstimate.totalBudgetCapUsd.toFixed(2)}`,
|
|
105
|
+
data: {
|
|
106
|
+
totalEstimatedCostUsd: costEstimate.totalEstimatedCostUsd,
|
|
107
|
+
totalBudgetCapUsd: costEstimate.totalBudgetCapUsd,
|
|
108
|
+
overBudget: costEstimate.overBudget,
|
|
109
|
+
perStepBreakdown,
|
|
110
|
+
warnings: costEstimate.warnings,
|
|
111
|
+
},
|
|
112
|
+
action: costEstimate.overBudget
|
|
113
|
+
? {
|
|
114
|
+
label: "Adjust budget",
|
|
115
|
+
type: "set_budget",
|
|
116
|
+
payload: {
|
|
117
|
+
suggestedBudgetUsd:
|
|
118
|
+
Math.round(costEstimate.totalEstimatedCostUsd * 1.3 * 100) / 100,
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Silently skip budget analysis on error
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Runtime + Pattern Recommendation ───────────────────────────────
|
|
130
|
+
try {
|
|
131
|
+
const pattern = definition.pattern ?? "sequence";
|
|
132
|
+
const stepCount = definition.steps?.length ?? 0;
|
|
133
|
+
|
|
134
|
+
// Count docs across all steps
|
|
135
|
+
let docCount = 0;
|
|
136
|
+
for (const step of definition.steps ?? []) {
|
|
137
|
+
docCount += step.documentIds?.length ?? 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const hints = await getWorkflowOptimizationHints(pattern, stepCount, docCount);
|
|
141
|
+
|
|
142
|
+
// Runtime recommendation
|
|
143
|
+
if (hints.runtimeRecommendation) {
|
|
144
|
+
const successPct = hints.similarWorkflowStats
|
|
145
|
+
? `${Math.round(hints.similarWorkflowStats.successRate * 100)}%`
|
|
146
|
+
: "N/A";
|
|
147
|
+
|
|
148
|
+
suggestions.push({
|
|
149
|
+
type: "runtime_recommendation",
|
|
150
|
+
title: `Recommended runtime: ${hints.runtimeRecommendation}`,
|
|
151
|
+
description: `Based on ${hints.similarWorkflowStats?.sampleCount ?? 0} similar runs with ${successPct} success rate`,
|
|
152
|
+
data: {
|
|
153
|
+
runtimeId: hints.runtimeRecommendation,
|
|
154
|
+
similarWorkflowStats: hints.similarWorkflowStats,
|
|
155
|
+
},
|
|
156
|
+
action: {
|
|
157
|
+
label: "Use this runtime",
|
|
158
|
+
type: "set_runtime",
|
|
159
|
+
payload: { runtimeId: hints.runtimeRecommendation },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Pattern comparison — suggest alternative if >20% better success rate
|
|
165
|
+
if (hints.patternComparison) {
|
|
166
|
+
const altSuccessPct = Math.round(hints.patternComparison.successRate * 100);
|
|
167
|
+
const currentSuccessPct = hints.similarWorkflowStats
|
|
168
|
+
? Math.round(hints.similarWorkflowStats.successRate * 100)
|
|
169
|
+
: 0;
|
|
170
|
+
|
|
171
|
+
suggestions.push({
|
|
172
|
+
type: "pattern_insight",
|
|
173
|
+
title: `Consider "${hints.patternComparison.pattern}" pattern`,
|
|
174
|
+
description: `${altSuccessPct}% success rate vs ${currentSuccessPct}% for "${pattern}" — a ${altSuccessPct - currentSuccessPct}% improvement`,
|
|
175
|
+
data: {
|
|
176
|
+
currentPattern: pattern,
|
|
177
|
+
suggestedPattern: hints.patternComparison.pattern,
|
|
178
|
+
currentSuccessRate: currentSuccessPct,
|
|
179
|
+
suggestedSuccessRate: altSuccessPct,
|
|
180
|
+
},
|
|
181
|
+
action: {
|
|
182
|
+
label: "Switch pattern",
|
|
183
|
+
type: "change_pattern",
|
|
184
|
+
payload: { pattern: hints.patternComparison.pattern },
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Silently skip runtime/pattern analysis on error
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return suggestions;
|
|
193
|
+
}
|