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
package/src/lib/db/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
1
|
+
import { sqliteTable, text, integer, real, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
2
2
|
import type { InferSelectModel } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
export const projects = sqliteTable("projects", {
|
|
@@ -38,6 +38,8 @@ export const tasks = sqliteTable(
|
|
|
38
38
|
enum: ["manual", "scheduled", "heartbeat", "workflow"],
|
|
39
39
|
}),
|
|
40
40
|
workflowRunNumber: integer("workflow_run_number"),
|
|
41
|
+
/** Resolved per-task budget cap in USD — set by workflow engine for child tasks */
|
|
42
|
+
maxBudgetUsd: real("max_budget_usd"),
|
|
41
43
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
42
44
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
43
45
|
},
|
|
@@ -61,6 +63,8 @@ export const workflows = sqliteTable("workflows", {
|
|
|
61
63
|
.default("draft")
|
|
62
64
|
.notNull(),
|
|
63
65
|
runNumber: integer("run_number").default(0).notNull(),
|
|
66
|
+
/** Runtime to use for all steps (nullable — falls back to system default) */
|
|
67
|
+
runtimeId: text("runtime_id"),
|
|
64
68
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
65
69
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
66
70
|
});
|
|
@@ -198,6 +202,14 @@ export const schedules = sqliteTable(
|
|
|
198
202
|
}),
|
|
199
203
|
/** JSON array of channel config IDs for delivery after firing */
|
|
200
204
|
deliveryChannels: text("delivery_channels"),
|
|
205
|
+
/** Exponential moving average of turns used per child task firing */
|
|
206
|
+
avgTurnsPerFiring: integer("avg_turns_per_firing"),
|
|
207
|
+
/** Turns used by the most recent firing */
|
|
208
|
+
lastTurnCount: integer("last_turn_count"),
|
|
209
|
+
/** Consecutive failed firings (reset to 0 on success). Auto-pause at 3. */
|
|
210
|
+
failureStreak: integer("failure_streak").default(0).notNull(),
|
|
211
|
+
/** Detected reason for the most recent failure (turn_limit_exceeded, timeout, etc.) */
|
|
212
|
+
lastFailureReason: text("last_failure_reason"),
|
|
201
213
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
202
214
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
203
215
|
},
|
|
@@ -1172,3 +1184,31 @@ export const license = sqliteTable("license", {
|
|
|
1172
1184
|
});
|
|
1173
1185
|
|
|
1174
1186
|
export type LicenseRow = InferSelectModel<typeof license>;
|
|
1187
|
+
|
|
1188
|
+
// ── Workflow Execution Stats ─────────────────────────────────────────
|
|
1189
|
+
|
|
1190
|
+
export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
|
|
1191
|
+
id: text("id").primaryKey(),
|
|
1192
|
+
/** Workflow pattern: sequence, parallel, swarm, etc. */
|
|
1193
|
+
pattern: text("pattern").notNull(),
|
|
1194
|
+
/** Number of steps in the workflow */
|
|
1195
|
+
stepCount: integer("step_count").notNull(),
|
|
1196
|
+
/** Average documents injected per step */
|
|
1197
|
+
avgDocsPerStep: real("avg_docs_per_step"),
|
|
1198
|
+
/** Average cost per step in microdollars */
|
|
1199
|
+
avgCostPerStepMicros: integer("avg_cost_per_step_micros"),
|
|
1200
|
+
/** Average duration per step in milliseconds */
|
|
1201
|
+
avgDurationPerStepMs: integer("avg_duration_per_step_ms"),
|
|
1202
|
+
/** Success rate (0.0 to 1.0) */
|
|
1203
|
+
successRate: real("success_rate"),
|
|
1204
|
+
/** JSON: common failure types and counts, e.g., {"budget_exceeded": 4, "timeout": 1} */
|
|
1205
|
+
commonFailures: text("common_failures"),
|
|
1206
|
+
/** JSON: per-runtime success rates, e.g., {"claude-code": 0.92, "openai-direct": 0.71} */
|
|
1207
|
+
runtimeBreakdown: text("runtime_breakdown"),
|
|
1208
|
+
/** Number of workflow runs included in these stats */
|
|
1209
|
+
sampleCount: integer("sample_count").notNull().default(0),
|
|
1210
|
+
lastUpdated: text("last_updated").notNull(),
|
|
1211
|
+
createdAt: text("created_at").notNull(),
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
export type WorkflowExecutionStatsRow = InferSelectModel<typeof workflowExecutionStats>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { license as licenseTable } from "@/lib/db/schema";
|
|
5
|
+
import { licenseManager } from "../manager";
|
|
6
|
+
|
|
7
|
+
const LICENSE_ROW_ID = "default";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression: under Turbopack module instance separation, the singleton's
|
|
11
|
+
* in-memory cache could disagree with the DB. If another module instance
|
|
12
|
+
* (or another process) updates the license row, getTier() must reflect
|
|
13
|
+
* the new value — otherwise gated limits silently fall back to community
|
|
14
|
+
* tier rules even on paid tiers.
|
|
15
|
+
*
|
|
16
|
+
* The fix in manager.ts makes getTier() / getStatus() read from the DB.
|
|
17
|
+
* This test simulates the cross-instance scenario by mutating the DB row
|
|
18
|
+
* directly without going through licenseManager.activate().
|
|
19
|
+
*/
|
|
20
|
+
describe("LicenseManager — DB-direct tier reads", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// Reset to a known community baseline.
|
|
23
|
+
db.delete(licenseTable).where(eq(licenseTable.id, LICENSE_ROW_ID)).run();
|
|
24
|
+
licenseManager.initialize();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
db.delete(licenseTable).where(eq(licenseTable.id, LICENSE_ROW_ID)).run();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("getTier() reflects DB updates that bypassed activate()", () => {
|
|
32
|
+
expect(licenseManager.getTier()).toBe("community");
|
|
33
|
+
|
|
34
|
+
// Simulate "another module instance wrote scale to the DB".
|
|
35
|
+
db.update(licenseTable)
|
|
36
|
+
.set({ tier: "scale", status: "active", updatedAt: new Date() })
|
|
37
|
+
.where(eq(licenseTable.id, LICENSE_ROW_ID))
|
|
38
|
+
.run();
|
|
39
|
+
|
|
40
|
+
expect(licenseManager.getTier()).toBe("scale");
|
|
41
|
+
expect(licenseManager.getLimit("agentMemories")).toBe(Infinity);
|
|
42
|
+
expect(licenseManager.getLimit("activeSchedules")).toBe(Infinity);
|
|
43
|
+
expect(licenseManager.getLimit("parallelWorkflows")).toBe(Infinity);
|
|
44
|
+
expect(licenseManager.isPremium()).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("getStatus() also reads from DB, not stale cache", () => {
|
|
48
|
+
expect(licenseManager.getStatus().tier).toBe("community");
|
|
49
|
+
|
|
50
|
+
db.update(licenseTable)
|
|
51
|
+
.set({
|
|
52
|
+
tier: "operator",
|
|
53
|
+
status: "active",
|
|
54
|
+
email: "ops@example.com",
|
|
55
|
+
updatedAt: new Date(),
|
|
56
|
+
})
|
|
57
|
+
.where(eq(licenseTable.id, LICENSE_ROW_ID))
|
|
58
|
+
.run();
|
|
59
|
+
|
|
60
|
+
const status = licenseManager.getStatus();
|
|
61
|
+
expect(status.tier).toBe("operator");
|
|
62
|
+
expect(status.email).toBe("ops@example.com");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -93,9 +93,15 @@ class LicenseManager {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Current tier — reads directly from DB to avoid stale singleton cache
|
|
98
|
+
* under Turbopack module instance separation. SQLite primary-key lookup
|
|
99
|
+
* is sub-millisecond, so the previous cache shortcut is not worth the
|
|
100
|
+
* staleness risk that caused gated limits to fall back to community
|
|
101
|
+
* even on paid tiers.
|
|
102
|
+
*/
|
|
97
103
|
getTier(): LicenseTier {
|
|
98
|
-
return this.
|
|
104
|
+
return this.getTierFromDb();
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
@@ -128,17 +134,40 @@ class LicenseManager {
|
|
|
128
134
|
return TIER_LIMITS[this.getTier()][resource];
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Get the full license state — reads directly from DB for the same
|
|
139
|
+
* reason as getTier(): the in-memory cache can be stale across
|
|
140
|
+
* Turbopack module instances, causing tier/email/expiry to disagree
|
|
141
|
+
* with what activate() just wrote.
|
|
142
|
+
*/
|
|
132
143
|
getStatus(): CachedLicense & { tier: LicenseTier } {
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
return this.getStatusFromDb();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read full license state directly from DB — bypasses in-memory cache.
|
|
149
|
+
* Use in API routes to avoid stale Turbopack module singleton state.
|
|
150
|
+
*/
|
|
151
|
+
getStatusFromDb(): CachedLicense & { tier: LicenseTier } {
|
|
152
|
+
const row = db
|
|
153
|
+
.select()
|
|
154
|
+
.from(licenseTable)
|
|
155
|
+
.where(eq(licenseTable.id, LICENSE_ROW_ID))
|
|
156
|
+
.get();
|
|
157
|
+
|
|
158
|
+
if (!row) {
|
|
159
|
+
return {
|
|
160
|
+
tier: "community",
|
|
161
|
+
status: "inactive",
|
|
162
|
+
email: null,
|
|
163
|
+
activatedAt: null,
|
|
164
|
+
expiresAt: null,
|
|
165
|
+
lastValidatedAt: null,
|
|
166
|
+
gracePeriodExpiresAt: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this.rowToCache(row);
|
|
142
171
|
}
|
|
143
172
|
|
|
144
173
|
/**
|
|
@@ -151,20 +180,46 @@ class LicenseManager {
|
|
|
151
180
|
encryptedToken?: string;
|
|
152
181
|
}): void {
|
|
153
182
|
const now = new Date();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
activatedAt: now,
|
|
160
|
-
expiresAt: params.expiresAt ?? null,
|
|
161
|
-
lastValidatedAt: now,
|
|
162
|
-
gracePeriodExpiresAt: null,
|
|
163
|
-
encryptedToken: params.encryptedToken ?? null,
|
|
164
|
-
updatedAt: now,
|
|
165
|
-
})
|
|
183
|
+
|
|
184
|
+
// Ensure the license row exists (may be missing if initialize() hasn't run)
|
|
185
|
+
const existing = db
|
|
186
|
+
.select({ id: licenseTable.id })
|
|
187
|
+
.from(licenseTable)
|
|
166
188
|
.where(eq(licenseTable.id, LICENSE_ROW_ID))
|
|
167
|
-
.
|
|
189
|
+
.get();
|
|
190
|
+
|
|
191
|
+
if (existing) {
|
|
192
|
+
db.update(licenseTable)
|
|
193
|
+
.set({
|
|
194
|
+
tier: params.tier,
|
|
195
|
+
status: "active",
|
|
196
|
+
email: params.email,
|
|
197
|
+
activatedAt: now,
|
|
198
|
+
expiresAt: params.expiresAt ?? null,
|
|
199
|
+
lastValidatedAt: now,
|
|
200
|
+
gracePeriodExpiresAt: null,
|
|
201
|
+
encryptedToken: params.encryptedToken ?? null,
|
|
202
|
+
updatedAt: now,
|
|
203
|
+
})
|
|
204
|
+
.where(eq(licenseTable.id, LICENSE_ROW_ID))
|
|
205
|
+
.run();
|
|
206
|
+
} else {
|
|
207
|
+
db.insert(licenseTable)
|
|
208
|
+
.values({
|
|
209
|
+
id: LICENSE_ROW_ID,
|
|
210
|
+
tier: params.tier,
|
|
211
|
+
status: "active",
|
|
212
|
+
email: params.email,
|
|
213
|
+
activatedAt: now,
|
|
214
|
+
expiresAt: params.expiresAt ?? null,
|
|
215
|
+
lastValidatedAt: now,
|
|
216
|
+
gracePeriodExpiresAt: null,
|
|
217
|
+
encryptedToken: params.encryptedToken ?? null,
|
|
218
|
+
createdAt: now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
})
|
|
221
|
+
.run();
|
|
222
|
+
}
|
|
168
223
|
|
|
169
224
|
this.refreshCache();
|
|
170
225
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
expandCronMinutes,
|
|
4
|
+
computeStaggeredCron,
|
|
5
|
+
} from "../interval-parser";
|
|
6
|
+
|
|
7
|
+
describe("expandCronMinutes", () => {
|
|
8
|
+
it("expands */N step pattern", () => {
|
|
9
|
+
expect(expandCronMinutes("*/30 * * * *")).toEqual([0, 30]);
|
|
10
|
+
expect(expandCronMinutes("*/15 * * * *")).toEqual([0, 15, 30, 45]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("expands wildcard", () => {
|
|
14
|
+
const all = expandCronMinutes("* * * * *");
|
|
15
|
+
expect(all.length).toBe(60);
|
|
16
|
+
expect(all[0]).toBe(0);
|
|
17
|
+
expect(all[59]).toBe(59);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("expands comma list", () => {
|
|
21
|
+
expect(expandCronMinutes("5,15,45 * * * *")).toEqual([5, 15, 45]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("expands ranges", () => {
|
|
25
|
+
expect(expandCronMinutes("10-13 * * * *")).toEqual([10, 11, 12, 13]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("expands stepped ranges", () => {
|
|
29
|
+
expect(expandCronMinutes("0-30/10 * * * *")).toEqual([0, 10, 20, 30]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("expands single value", () => {
|
|
33
|
+
expect(expandCronMinutes("7 * * * *")).toEqual([7]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws on invalid cron", () => {
|
|
37
|
+
expect(() => expandCronMinutes("not a cron")).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("computeStaggeredCron", () => {
|
|
42
|
+
it("returns original cron when no existing schedules", () => {
|
|
43
|
+
const result = computeStaggeredCron("*/30 * * * *", []);
|
|
44
|
+
expect(result.collided).toBe(false);
|
|
45
|
+
expect(result.offsetApplied).toBe(0);
|
|
46
|
+
expect(result.cronExpression).toBe("*/30 * * * *");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns original cron when no collision", () => {
|
|
50
|
+
const result = computeStaggeredCron("*/30 * * * *", ["7 * * * *"]);
|
|
51
|
+
expect(result.collided).toBe(false);
|
|
52
|
+
expect(result.cronExpression).toBe("*/30 * * * *");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("staggers two */30 schedules", () => {
|
|
56
|
+
// First schedule fires at :00 and :30. Second should be offset by ≥5min.
|
|
57
|
+
const result = computeStaggeredCron("*/30 * * * *", ["*/30 * * * *"]);
|
|
58
|
+
expect(result.collided).toBe(true);
|
|
59
|
+
expect(result.offsetApplied).toBeGreaterThanOrEqual(5);
|
|
60
|
+
// Should produce a comma list reflecting the new fire times
|
|
61
|
+
const minutes = expandCronMinutes(result.cronExpression);
|
|
62
|
+
// Both fire minutes should be ≥5 away from {0,30}
|
|
63
|
+
for (const m of minutes) {
|
|
64
|
+
const distTo0 = Math.min(m, 60 - m);
|
|
65
|
+
const distTo30 = Math.abs(m - 30);
|
|
66
|
+
expect(distTo0).toBeGreaterThanOrEqual(5);
|
|
67
|
+
expect(distTo30).toBeGreaterThanOrEqual(5);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("enforces 5-minute minimum gap", () => {
|
|
72
|
+
// Existing schedule at :00, new schedule wants :03 — should stagger.
|
|
73
|
+
const result = computeStaggeredCron("3 * * * *", ["0 * * * *"]);
|
|
74
|
+
expect(result.collided).toBe(true);
|
|
75
|
+
const minutes = expandCronMinutes(result.cronExpression);
|
|
76
|
+
expect(minutes.length).toBe(1);
|
|
77
|
+
const m = minutes[0];
|
|
78
|
+
const distTo0 = Math.min(m, 60 - m);
|
|
79
|
+
expect(distTo0).toBeGreaterThanOrEqual(5);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("ignores unparseable existing crons gracefully", () => {
|
|
83
|
+
const result = computeStaggeredCron("*/30 * * * *", ["garbage"]);
|
|
84
|
+
expect(result.collided).toBe(false);
|
|
85
|
+
expect(result.cronExpression).toBe("*/30 * * * *");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { analyzePromptEfficiency } from "../prompt-analyzer";
|
|
3
|
+
|
|
4
|
+
describe("analyzePromptEfficiency", () => {
|
|
5
|
+
it("returns no warnings for a clean batched prompt", () => {
|
|
6
|
+
const warnings = analyzePromptEfficiency(
|
|
7
|
+
"Search for stock prices AMZN GOOGL NVDA AAPL today and write a summary."
|
|
8
|
+
);
|
|
9
|
+
expect(warnings).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("flags 'for each' loop pattern", () => {
|
|
13
|
+
const warnings = analyzePromptEfficiency(
|
|
14
|
+
"For each stock in my portfolio, search the latest price."
|
|
15
|
+
);
|
|
16
|
+
expect(warnings.some((w) => w.type === "loop_pattern")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("flags 'for every' loop pattern", () => {
|
|
20
|
+
const warnings = analyzePromptEfficiency(
|
|
21
|
+
"For every market, fetch the current odds."
|
|
22
|
+
);
|
|
23
|
+
expect(warnings.some((w) => w.type === "loop_pattern")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("flags 'individually' loop pattern", () => {
|
|
27
|
+
const warnings = analyzePromptEfficiency(
|
|
28
|
+
"Look up each ticker individually and report results."
|
|
29
|
+
);
|
|
30
|
+
expect(warnings.some((w) => w.type === "loop_pattern")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("flags large list pattern", () => {
|
|
34
|
+
const warnings = analyzePromptEfficiency(
|
|
35
|
+
"Check all 32 markets and write a report."
|
|
36
|
+
);
|
|
37
|
+
expect(warnings.some((w) => w.type === "large_list")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("flags high turn estimate", () => {
|
|
41
|
+
// Many search verbs should trip the >30 estimate
|
|
42
|
+
const verbs = Array(35).fill("search").join(" ");
|
|
43
|
+
const warnings = analyzePromptEfficiency(verbs);
|
|
44
|
+
expect(warnings.some((w) => w.type === "high_turn_estimate")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not flag a single quick action", () => {
|
|
48
|
+
const warnings = analyzePromptEfficiency("Send me a daily news digest.");
|
|
49
|
+
expect(warnings).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -75,6 +75,193 @@ export function computeNextFireTime(cronExpression: string, from?: Date): Date {
|
|
|
75
75
|
return expr.next().toDate();
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Expand a cron expression's minute field into the concrete set of minute
|
|
80
|
+
* values (0-59) it fires on.
|
|
81
|
+
*
|
|
82
|
+
* Used by the schedule auto-stagger logic to detect collisions between
|
|
83
|
+
* existing schedules and a newly requested cron. Only the minute field is
|
|
84
|
+
* expanded — collisions across hour/day/month boundaries are intentionally
|
|
85
|
+
* out of scope (a `*/30 * * * *` schedule and a `*/30 9 * * *` schedule are
|
|
86
|
+
* treated as overlapping at minute :00 even though they only collide once
|
|
87
|
+
* a day, because that single collision is still a starvation risk).
|
|
88
|
+
*
|
|
89
|
+
* Supported minute syntax: `*`, `*/N`, comma lists (`5,15,45`), ranges
|
|
90
|
+
* (`10-30`), single values, and `step ranges` (`0-30/5`).
|
|
91
|
+
*/
|
|
92
|
+
export function expandCronMinutes(cronExpression: string): number[] {
|
|
93
|
+
const fields = cronExpression.trim().split(/\s+/);
|
|
94
|
+
if (fields.length !== 5) {
|
|
95
|
+
throw new Error(`Invalid cron expression (need 5 fields): ${cronExpression}`);
|
|
96
|
+
}
|
|
97
|
+
return expandMinuteField(fields[0]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function expandMinuteField(field: string): number[] {
|
|
101
|
+
const result = new Set<number>();
|
|
102
|
+
for (const part of field.split(",")) {
|
|
103
|
+
const trimmed = part.trim();
|
|
104
|
+
if (!trimmed) continue;
|
|
105
|
+
|
|
106
|
+
// Handle step syntax: */N, M/N, or A-B/N
|
|
107
|
+
let stepBase = trimmed;
|
|
108
|
+
let step = 1;
|
|
109
|
+
const stepIdx = trimmed.indexOf("/");
|
|
110
|
+
if (stepIdx >= 0) {
|
|
111
|
+
stepBase = trimmed.slice(0, stepIdx);
|
|
112
|
+
step = parseInt(trimmed.slice(stepIdx + 1), 10);
|
|
113
|
+
if (!Number.isFinite(step) || step <= 0) {
|
|
114
|
+
throw new Error(`Invalid step in cron minute field: ${trimmed}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let from: number;
|
|
119
|
+
let to: number;
|
|
120
|
+
if (stepBase === "*") {
|
|
121
|
+
from = 0;
|
|
122
|
+
to = 59;
|
|
123
|
+
} else if (stepBase.includes("-")) {
|
|
124
|
+
const [a, b] = stepBase.split("-").map((n) => parseInt(n, 10));
|
|
125
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
126
|
+
throw new Error(`Invalid range in cron minute field: ${trimmed}`);
|
|
127
|
+
}
|
|
128
|
+
from = a;
|
|
129
|
+
to = b;
|
|
130
|
+
} else {
|
|
131
|
+
const single = parseInt(stepBase, 10);
|
|
132
|
+
if (!Number.isFinite(single)) {
|
|
133
|
+
throw new Error(`Invalid value in cron minute field: ${trimmed}`);
|
|
134
|
+
}
|
|
135
|
+
// Bare integer with no step: just that one minute.
|
|
136
|
+
// Bare integer with /N: from value to 59 by step (cron semantics).
|
|
137
|
+
from = single;
|
|
138
|
+
to = stepIdx >= 0 ? 59 : single;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (let m = from; m <= to && m < 60; m += step) {
|
|
142
|
+
if (m >= 0) result.add(m);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...result].sort((a, b) => a - b);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const MIN_GAP_MINUTES = 5;
|
|
149
|
+
|
|
150
|
+
export interface StaggerResult {
|
|
151
|
+
cronExpression: string;
|
|
152
|
+
offsetApplied: number;
|
|
153
|
+
collided: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Compute a non-colliding cron expression for a new schedule by offsetting
|
|
158
|
+
* its minute field if any of its fire minutes are within MIN_GAP_MINUTES of
|
|
159
|
+
* an already-occupied minute.
|
|
160
|
+
*
|
|
161
|
+
* Strategy:
|
|
162
|
+
* 1. Expand both the requested cron and all existing schedule crons into
|
|
163
|
+
* minute sets.
|
|
164
|
+
* 2. Detect the "interval period" of the request (e.g. 30 for `*/30`),
|
|
165
|
+
* which bounds the offset search space.
|
|
166
|
+
* 3. Walk offsets [0..interval-1] looking for the smallest one that puts
|
|
167
|
+
* all requested fire minutes ≥ MIN_GAP_MINUTES away from every
|
|
168
|
+
* occupied minute.
|
|
169
|
+
* 4. Apply the offset by rewriting the minute field. For `*/N` patterns
|
|
170
|
+
* we rewrite to a comma list `(0+off, N+off, 2N+off, ...)`. For lists
|
|
171
|
+
* we shift each element by the offset (mod 60).
|
|
172
|
+
*
|
|
173
|
+
* Returns the original cron when no offset is needed or when no
|
|
174
|
+
* collision-free offset exists in the search space (caller can decide
|
|
175
|
+
* whether to warn).
|
|
176
|
+
*/
|
|
177
|
+
export function computeStaggeredCron(
|
|
178
|
+
requestedCron: string,
|
|
179
|
+
existingCrons: string[]
|
|
180
|
+
): StaggerResult {
|
|
181
|
+
const requestedMinutes = expandCronMinutes(requestedCron);
|
|
182
|
+
if (requestedMinutes.length === 0) {
|
|
183
|
+
return { cronExpression: requestedCron, offsetApplied: 0, collided: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const occupied = new Set<number>();
|
|
187
|
+
for (const cron of existingCrons) {
|
|
188
|
+
try {
|
|
189
|
+
for (const m of expandCronMinutes(cron)) occupied.add(m);
|
|
190
|
+
} catch {
|
|
191
|
+
// Skip cron expressions we cannot parse — better to allow the user's
|
|
192
|
+
// schedule than to block creation on a bad neighbor.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!hasCollision(requestedMinutes, occupied)) {
|
|
197
|
+
return { cronExpression: requestedCron, offsetApplied: 0, collided: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Determine the interval period bounds offset search.
|
|
201
|
+
// For `*/N * * * *` the period is N. For arbitrary lists fall back to 60.
|
|
202
|
+
const period = detectMinutePeriod(requestedCron) ?? 60;
|
|
203
|
+
|
|
204
|
+
for (let offset = 1; offset < period; offset++) {
|
|
205
|
+
const shifted = requestedMinutes.map((m) => (m + offset) % 60);
|
|
206
|
+
if (!hasCollision(shifted, occupied)) {
|
|
207
|
+
return {
|
|
208
|
+
cronExpression: applyMinuteOffset(requestedCron, offset),
|
|
209
|
+
offsetApplied: offset,
|
|
210
|
+
collided: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// No collision-free offset found in search space — return original and let
|
|
216
|
+
// the caller log a warning. The queue drain still prevents starvation.
|
|
217
|
+
return { cronExpression: requestedCron, offsetApplied: 0, collided: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasCollision(minutes: number[], occupied: Set<number>): boolean {
|
|
221
|
+
if (occupied.size === 0) return false;
|
|
222
|
+
for (const m of minutes) {
|
|
223
|
+
for (let delta = -MIN_GAP_MINUTES + 1; delta < MIN_GAP_MINUTES; delta++) {
|
|
224
|
+
const candidate = (m + delta + 60) % 60;
|
|
225
|
+
if (occupied.has(candidate)) return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Detect the minute period of a cron expression. Returns the step value for
|
|
233
|
+
* `*/N` patterns, the gap for evenly-spaced lists like `0,30`, or null when
|
|
234
|
+
* the pattern is irregular.
|
|
235
|
+
*/
|
|
236
|
+
function detectMinutePeriod(cronExpression: string): number | null {
|
|
237
|
+
const minuteField = cronExpression.trim().split(/\s+/)[0];
|
|
238
|
+
const stepMatch = minuteField.match(/^\*\/(\d+)$/);
|
|
239
|
+
if (stepMatch) return parseInt(stepMatch[1], 10);
|
|
240
|
+
|
|
241
|
+
const minutes = expandCronMinutes(cronExpression);
|
|
242
|
+
if (minutes.length < 2) return null;
|
|
243
|
+
const gap = minutes[1] - minutes[0];
|
|
244
|
+
for (let i = 2; i < minutes.length; i++) {
|
|
245
|
+
if (minutes[i] - minutes[i - 1] !== gap) return null;
|
|
246
|
+
}
|
|
247
|
+
return gap;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Rewrite the minute field of a cron expression by shifting all minutes by
|
|
252
|
+
* the given offset. Replaces `*/N` shorthand with an explicit comma list so
|
|
253
|
+
* the offset is visible to users inspecting the cron.
|
|
254
|
+
*/
|
|
255
|
+
function applyMinuteOffset(cronExpression: string, offset: number): string {
|
|
256
|
+
const fields = cronExpression.trim().split(/\s+/);
|
|
257
|
+
const minutes = expandCronMinutes(cronExpression);
|
|
258
|
+
const shifted = minutes
|
|
259
|
+
.map((m) => (m + offset) % 60)
|
|
260
|
+
.sort((a, b) => a - b);
|
|
261
|
+
fields[0] = shifted.join(",");
|
|
262
|
+
return fields.join(" ");
|
|
263
|
+
}
|
|
264
|
+
|
|
78
265
|
/**
|
|
79
266
|
* Generate a human-readable description of a cron expression.
|
|
80
267
|
*/
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic prompt-efficiency analyzer for scheduled prompts.
|
|
3
|
+
*
|
|
4
|
+
* Field deployments showed that scheduled tasks frequently exhausted their
|
|
5
|
+
* `maxTurns` budget because authors wrote prompts using per-item language
|
|
6
|
+
* ("for each holding, search the price"), which causes agents to issue N
|
|
7
|
+
* sequential tool calls instead of one batched call. This analyzer flags
|
|
8
|
+
* those patterns at schedule-creation time so the user can rewrite the
|
|
9
|
+
* prompt before it ever fires.
|
|
10
|
+
*
|
|
11
|
+
* The analyzer is intentionally conservative: it returns *warnings*, not
|
|
12
|
+
* errors. The chat tool surfaces them to the user but still creates the
|
|
13
|
+
* schedule. The runtime turn limit remains the hard backstop.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type WarningSeverity = "low" | "medium" | "high";
|
|
17
|
+
|
|
18
|
+
export interface PromptWarning {
|
|
19
|
+
type: string;
|
|
20
|
+
severity: WarningSeverity;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LOOP_PATTERNS: RegExp[] = [
|
|
25
|
+
/\bfor each\b/i,
|
|
26
|
+
/\bfor every\b/i,
|
|
27
|
+
/\bone by one\b/i,
|
|
28
|
+
/\bindividually\b/i,
|
|
29
|
+
/\bper[\s-]?(symbol|stock|item|market|ticker|holding|row)\b/i,
|
|
30
|
+
/\bsearch for [^.]+ then search for\b/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const LARGE_LIST_PATTERNS: RegExp[] = [
|
|
34
|
+
/\ball \d{2,}\b/i, // "all 32 markets"
|
|
35
|
+
/\beach of the \d{2,}\b/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Analyze a scheduled prompt for known turn-exhaustion anti-patterns.
|
|
40
|
+
*
|
|
41
|
+
* The estimate uses a deliberately rough heuristic: count occurrences of
|
|
42
|
+
* search/fetch/lookup verbs and table operations, add 3 for orchestration
|
|
43
|
+
* overhead, and warn if the result exceeds 30. Tuned against the failures
|
|
44
|
+
* observed in production (97 / 84 / 69 turn cases all triggered).
|
|
45
|
+
*/
|
|
46
|
+
export function analyzePromptEfficiency(prompt: string): PromptWarning[] {
|
|
47
|
+
const warnings: PromptWarning[] = [];
|
|
48
|
+
|
|
49
|
+
for (const pattern of LOOP_PATTERNS) {
|
|
50
|
+
if (pattern.test(prompt)) {
|
|
51
|
+
warnings.push({
|
|
52
|
+
type: "loop_pattern",
|
|
53
|
+
severity: "high",
|
|
54
|
+
message:
|
|
55
|
+
"Prompt contains per-item processing language. Consider batching: instead of \"search for each stock price\", use \"search for all stock prices in one query: AMZN GOOGL NVDA...\".",
|
|
56
|
+
});
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const pattern of LARGE_LIST_PATTERNS) {
|
|
62
|
+
if (pattern.test(prompt)) {
|
|
63
|
+
warnings.push({
|
|
64
|
+
type: "large_list",
|
|
65
|
+
severity: "medium",
|
|
66
|
+
message:
|
|
67
|
+
"Prompt references a large number of items. Consider a bulk API call instead of iterating.",
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Rough turn estimate. Each search-style verb ≈ 1 turn; each table op ≈ 1 turn.
|
|
74
|
+
const webSearchCount = (prompt.match(/\b(search|fetch|look up|check.*price)\b/gi) || []).length;
|
|
75
|
+
const tableOps = (prompt.match(/\b(read|query|update|insert|write).*(table|row)\b/gi) || []).length;
|
|
76
|
+
const estimatedTurns = webSearchCount + tableOps + 3;
|
|
77
|
+
|
|
78
|
+
if (estimatedTurns > 30) {
|
|
79
|
+
warnings.push({
|
|
80
|
+
type: "high_turn_estimate",
|
|
81
|
+
severity: "high",
|
|
82
|
+
message: `Estimated ${estimatedTurns}+ turns required. Consider splitting into a multi-step workflow or batching operations.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return warnings;
|
|
87
|
+
}
|