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,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { paragraphSeparator, inlineScreenshotMarkdown } from "../engine";
|
|
3
|
+
|
|
4
|
+
describe("paragraphSeparator", () => {
|
|
5
|
+
it("returns empty string when fullText is empty", () => {
|
|
6
|
+
expect(paragraphSeparator("")).toBe("");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns two newlines between adjacent text blocks ending without a newline", () => {
|
|
10
|
+
expect(paragraphSeparator("review the app.")).toBe("\n\n");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns empty string when fullText already ends with a newline", () => {
|
|
14
|
+
expect(paragraphSeparator("first paragraph.\n")).toBe("");
|
|
15
|
+
expect(paragraphSeparator("first paragraph.\n\n")).toBe("");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("inlineScreenshotMarkdown", () => {
|
|
20
|
+
it("emits markdown image with leading paragraph break when prose precedes it", () => {
|
|
21
|
+
const md = inlineScreenshotMarkdown(
|
|
22
|
+
"Let me take a screenshot of the dashboard.",
|
|
23
|
+
"/screenshots/thumb-1.png"
|
|
24
|
+
);
|
|
25
|
+
expect(md).toBe("\n\n\n\n");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("omits the leading break when fullText is empty", () => {
|
|
29
|
+
const md = inlineScreenshotMarkdown("", "/screenshots/thumb-1.png");
|
|
30
|
+
expect(md).toBe("\n\n");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("omits the leading break when fullText already ends with a newline", () => {
|
|
34
|
+
const md = inlineScreenshotMarkdown(
|
|
35
|
+
"intro paragraph.\n\n",
|
|
36
|
+
"/screenshots/thumb-2.png"
|
|
37
|
+
);
|
|
38
|
+
expect(md).toBe("\n\n");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("simulates a streaming sequence: text → screenshot → text stays well-separated", () => {
|
|
42
|
+
let fullText = "";
|
|
43
|
+
// first text block
|
|
44
|
+
fullText += "Now let me take a screenshot of the dashboard.";
|
|
45
|
+
// screenshot capture
|
|
46
|
+
const inline = inlineScreenshotMarkdown(fullText, "/screenshots/thumb-1.png");
|
|
47
|
+
fullText += inline;
|
|
48
|
+
// next text block (after a tool_use turn break) — engine would inject a
|
|
49
|
+
// paragraph separator on content_block_start before appending.
|
|
50
|
+
fullText += paragraphSeparator(fullText);
|
|
51
|
+
fullText += "Good, I can see the dashboard.";
|
|
52
|
+
|
|
53
|
+
expect(fullText).toBe(
|
|
54
|
+
"Now let me take a screenshot of the dashboard.\n\n\n\nGood, I can see the dashboard."
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/lib/chat/engine.ts
CHANGED
|
@@ -102,6 +102,30 @@ function diagnoseProcessError(rawMessage: string, stderr: string): string {
|
|
|
102
102
|
return rawMessage;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// ── Stream-shaping helpers (exported for unit tests) ──────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns the separator to insert before appending new text to `fullText`.
|
|
109
|
+
* The Anthropic stream delivers text in `content_block`s with no trailing
|
|
110
|
+
* newline, so adjacent blocks (e.g. before/after a tool_use turn break) fuse
|
|
111
|
+
* together visually unless we inject a paragraph break.
|
|
112
|
+
*/
|
|
113
|
+
export function paragraphSeparator(fullText: string): string {
|
|
114
|
+
return fullText.length > 0 && !fullText.endsWith("\n") ? "\n\n" : "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Builds the inline markdown segment for a captured screenshot. The leading
|
|
119
|
+
* separator preserves paragraph spacing relative to the prose that came
|
|
120
|
+
* before; the trailing `\n\n` makes sure subsequent text starts a new block.
|
|
121
|
+
*/
|
|
122
|
+
export function inlineScreenshotMarkdown(
|
|
123
|
+
fullText: string,
|
|
124
|
+
thumbnailUrl: string
|
|
125
|
+
): string {
|
|
126
|
+
return `${paragraphSeparator(fullText)}\n\n`;
|
|
127
|
+
}
|
|
128
|
+
|
|
105
129
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
106
130
|
|
|
107
131
|
/**
|
|
@@ -417,7 +441,16 @@ export async function* sendMessage(
|
|
|
417
441
|
if (raw.type === "stream_event") {
|
|
418
442
|
// SDK wraps Anthropic API events inside stream_event.event
|
|
419
443
|
const innerEvent = raw.event as Record<string, unknown> | undefined;
|
|
420
|
-
if (innerEvent?.type === "
|
|
444
|
+
if (innerEvent?.type === "content_block_start") {
|
|
445
|
+
const block = innerEvent.content_block as Record<string, unknown> | undefined;
|
|
446
|
+
if (block?.type === "text" && fullText.length > 0 && !fullText.endsWith("\n")) {
|
|
447
|
+
// New text block after a previous block (often a tool_use turn break) —
|
|
448
|
+
// models don't end blocks with paragraph breaks, so insert one to keep
|
|
449
|
+
// sequential turns visually separated in the chat bubble.
|
|
450
|
+
fullText += "\n\n";
|
|
451
|
+
yield { type: "delta", content: "\n\n" };
|
|
452
|
+
}
|
|
453
|
+
} else if (innerEvent?.type === "content_block_delta") {
|
|
421
454
|
const delta = innerEvent.delta as Record<string, unknown> | undefined;
|
|
422
455
|
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
423
456
|
fullText += delta.text;
|
|
@@ -425,6 +458,12 @@ export async function* sendMessage(
|
|
|
425
458
|
yield { type: "delta", content: delta.text };
|
|
426
459
|
}
|
|
427
460
|
}
|
|
461
|
+
} else if (raw.type === "content_block_start") {
|
|
462
|
+
const block = (raw as Record<string, unknown>).content_block as Record<string, unknown> | undefined;
|
|
463
|
+
if (block?.type === "text" && fullText.length > 0 && !fullText.endsWith("\n")) {
|
|
464
|
+
fullText += "\n\n";
|
|
465
|
+
yield { type: "delta", content: "\n\n" };
|
|
466
|
+
}
|
|
428
467
|
} else if (raw.type === "content_block_delta") {
|
|
429
468
|
const delta = raw.delta as Record<string, unknown> | undefined;
|
|
430
469
|
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
@@ -457,6 +496,10 @@ export async function* sendMessage(
|
|
|
457
496
|
if (assistantBlocks) {
|
|
458
497
|
for (const block of assistantBlocks) {
|
|
459
498
|
if (block.type === "text" && typeof block.text === "string" && !fullText.includes(block.text)) {
|
|
499
|
+
if (fullText.length > 0 && !fullText.endsWith("\n")) {
|
|
500
|
+
fullText += "\n\n";
|
|
501
|
+
yield { type: "delta", content: "\n\n" };
|
|
502
|
+
}
|
|
460
503
|
fullText += block.text;
|
|
461
504
|
yield { type: "delta", content: block.text };
|
|
462
505
|
}
|
|
@@ -486,6 +529,16 @@ export async function* sendMessage(
|
|
|
486
529
|
if (attachment) {
|
|
487
530
|
screenshotAttachments.push(attachment);
|
|
488
531
|
yield { type: "screenshot" as const, ...attachment };
|
|
532
|
+
// Also inject the screenshot inline into the text stream as a
|
|
533
|
+
// markdown image so it renders next to the prose that captured
|
|
534
|
+
// it (the markdown renderer resolves the thumbnail src back to
|
|
535
|
+
// the full attachment via the message's metadata.attachments).
|
|
536
|
+
const inlineMd = inlineScreenshotMarkdown(
|
|
537
|
+
fullText,
|
|
538
|
+
attachment.thumbnailUrl
|
|
539
|
+
);
|
|
540
|
+
fullText += inlineMd;
|
|
541
|
+
yield { type: "delta" as const, content: inlineMd };
|
|
489
542
|
}
|
|
490
543
|
}
|
|
491
544
|
}
|
|
@@ -513,13 +566,21 @@ export async function* sendMessage(
|
|
|
513
566
|
const result = (raw as Record<string, unknown>).result;
|
|
514
567
|
if (typeof result === "string" && result.length > 0) {
|
|
515
568
|
if (result !== fullText) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
569
|
+
if (result.startsWith(fullText)) {
|
|
570
|
+
const remainder = result.slice(fullText.length);
|
|
571
|
+
if (remainder) {
|
|
572
|
+
yield { type: "delta" as const, content: remainder };
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
// Result is unrelated to what we have so far — treat as a new
|
|
576
|
+
// text block and insert a paragraph break before appending.
|
|
577
|
+
if (fullText.length > 0 && !fullText.endsWith("\n")) {
|
|
578
|
+
yield { type: "delta" as const, content: "\n\n" };
|
|
579
|
+
fullText += "\n\n";
|
|
580
|
+
}
|
|
581
|
+
yield { type: "delta" as const, content: result };
|
|
521
582
|
}
|
|
522
|
-
fullText = result;
|
|
583
|
+
fullText = result.startsWith(fullText) ? result : fullText + result;
|
|
523
584
|
}
|
|
524
585
|
}
|
|
525
586
|
}
|
|
@@ -20,6 +20,7 @@ import { settingsTools } from "./tools/settings-tools";
|
|
|
20
20
|
import { chatHistoryTools } from "./tools/chat-history-tools";
|
|
21
21
|
import { handoffTools } from "./tools/handoff-tools";
|
|
22
22
|
import { tableTools } from "./tools/table-tools";
|
|
23
|
+
import { runtimeTools } from "./tools/runtime-tools";
|
|
23
24
|
|
|
24
25
|
// ── Tool server types ────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -54,6 +55,7 @@ function collectAllTools(ctx: ToolContext): ToolDefinition[] {
|
|
|
54
55
|
...chatHistoryTools(ctx),
|
|
55
56
|
...handoffTools(ctx),
|
|
56
57
|
...tableTools(ctx),
|
|
58
|
+
...runtimeTools(ctx),
|
|
57
59
|
];
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineTool } from "../tool-registry";
|
|
2
|
+
import { ok, type ToolContext } from "./helpers";
|
|
3
|
+
import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
|
|
4
|
+
|
|
5
|
+
export function runtimeTools(_ctx: ToolContext) {
|
|
6
|
+
return [
|
|
7
|
+
defineTool(
|
|
8
|
+
"list_runtimes",
|
|
9
|
+
"List all available AI runtimes with their models and capabilities. Use this to discover which runtimes can be assigned to workflows.",
|
|
10
|
+
{},
|
|
11
|
+
async () => {
|
|
12
|
+
const catalog = listRuntimeCatalog();
|
|
13
|
+
return ok(
|
|
14
|
+
catalog.map((entry) => ({
|
|
15
|
+
id: entry.id,
|
|
16
|
+
label: entry.label,
|
|
17
|
+
provider: entry.providerId,
|
|
18
|
+
description: entry.description,
|
|
19
|
+
models: entry.models,
|
|
20
|
+
capabilities: Object.entries(entry.capabilities)
|
|
21
|
+
.filter(([, v]) => v)
|
|
22
|
+
.map(([k]) => k),
|
|
23
|
+
}))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
|
|
|
4
4
|
import { schedules } from "@/lib/db/schema";
|
|
5
5
|
import { eq, and, desc } from "drizzle-orm";
|
|
6
6
|
import { ok, err, type ToolContext } from "./helpers";
|
|
7
|
+
import { analyzePromptEfficiency } from "@/lib/schedules/prompt-analyzer";
|
|
7
8
|
|
|
8
9
|
const VALID_SCHEDULE_STATUSES = [
|
|
9
10
|
"active",
|
|
@@ -97,6 +98,39 @@ export function scheduleTools(ctx: ToolContext) {
|
|
|
97
98
|
const effectiveProjectId = args.projectId ?? ctx.projectId ?? null;
|
|
98
99
|
const now = new Date();
|
|
99
100
|
const id = crypto.randomUUID();
|
|
101
|
+
|
|
102
|
+
// Auto-stagger: if other active schedules in this project would
|
|
103
|
+
// collide with the requested cron, offset its minute field. We scope
|
|
104
|
+
// to the same project so unrelated workspaces don't interfere.
|
|
105
|
+
const { computeStaggeredCron } = await import(
|
|
106
|
+
"@/lib/schedules/interval-parser"
|
|
107
|
+
);
|
|
108
|
+
const existing = await db
|
|
109
|
+
.select({ cron: schedules.cronExpression })
|
|
110
|
+
.from(schedules)
|
|
111
|
+
.where(
|
|
112
|
+
effectiveProjectId
|
|
113
|
+
? and(
|
|
114
|
+
eq(schedules.status, "active"),
|
|
115
|
+
eq(schedules.projectId, effectiveProjectId)
|
|
116
|
+
)
|
|
117
|
+
: eq(schedules.status, "active")
|
|
118
|
+
);
|
|
119
|
+
const staggerResult = computeStaggeredCron(
|
|
120
|
+
cronExpression,
|
|
121
|
+
existing.map((s) => s.cron)
|
|
122
|
+
);
|
|
123
|
+
if (staggerResult.offsetApplied > 0) {
|
|
124
|
+
console.log(
|
|
125
|
+
`[scheduler] staggered "${args.name}" by ${staggerResult.offsetApplied}min to avoid collision (${cronExpression} → ${staggerResult.cronExpression})`
|
|
126
|
+
);
|
|
127
|
+
cronExpression = staggerResult.cronExpression;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Surface prompt-efficiency warnings before creating the schedule.
|
|
131
|
+
// We still create the schedule — these are guidance, not blockers.
|
|
132
|
+
const warnings = analyzePromptEfficiency(args.prompt);
|
|
133
|
+
|
|
100
134
|
const nextFireAt = computeNextFireTime(cronExpression, now);
|
|
101
135
|
const expiresAt = args.expiresInHours
|
|
102
136
|
? new Date(now.getTime() + args.expiresInHours * 60 * 60 * 1000)
|
|
@@ -126,7 +160,16 @@ export function scheduleTools(ctx: ToolContext) {
|
|
|
126
160
|
.where(eq(schedules.id, id));
|
|
127
161
|
|
|
128
162
|
ctx.onToolResult?.("create_schedule", schedule);
|
|
129
|
-
return ok(
|
|
163
|
+
return ok({
|
|
164
|
+
schedule,
|
|
165
|
+
warnings,
|
|
166
|
+
staggered: staggerResult.offsetApplied > 0
|
|
167
|
+
? {
|
|
168
|
+
offsetMinutes: staggerResult.offsetApplied,
|
|
169
|
+
originalCron: staggerResult.collided ? args.interval : undefined,
|
|
170
|
+
}
|
|
171
|
+
: undefined,
|
|
172
|
+
});
|
|
130
173
|
} catch (e) {
|
|
131
174
|
return err(e instanceof Error ? e.message : "Failed to create schedule");
|
|
132
175
|
}
|
|
@@ -64,6 +64,29 @@ const WRITABLE_SETTINGS: Record<string, WritableSetting> = {
|
|
|
64
64
|
validate: (v) =>
|
|
65
65
|
v.trim().length === 0 ? "Must be non-empty string" : null,
|
|
66
66
|
},
|
|
67
|
+
"budget_max_cost_per_task": {
|
|
68
|
+
description: "Max cost per task in USD (0.5–50)",
|
|
69
|
+
validate: (v) => {
|
|
70
|
+
const n = parseFloat(v);
|
|
71
|
+
return isNaN(n) || n < 0.5 || n > 50 ? "Must be number 0.5–50" : null;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"budget_max_tokens_per_task": {
|
|
75
|
+
description: "Max tokens per task (1000–500000)",
|
|
76
|
+
validate: (v) => {
|
|
77
|
+
const n = parseInt(v, 10);
|
|
78
|
+
return isNaN(n) || n < 1000 || n > 500000
|
|
79
|
+
? "Must be integer 1000–500000"
|
|
80
|
+
: null;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
"budget_max_daily_cost": {
|
|
84
|
+
description: "Max daily spend in USD (1–500)",
|
|
85
|
+
validate: (v) => {
|
|
86
|
+
const n = parseFloat(v);
|
|
87
|
+
return isNaN(n) || n < 1 || n > 500 ? "Must be number 1–500" : null;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
67
90
|
};
|
|
68
91
|
|
|
69
92
|
const WRITABLE_KEYS_DOC = Object.entries(WRITABLE_SETTINGS)
|
|
@@ -109,10 +132,14 @@ export function settingsTools(_ctx: ToolContext) {
|
|
|
109
132
|
|
|
110
133
|
if (args.key) {
|
|
111
134
|
const value = await getSetting(args.key);
|
|
112
|
-
return ok({
|
|
135
|
+
return ok({
|
|
136
|
+
key: args.key,
|
|
137
|
+
value,
|
|
138
|
+
writable: args.key in WRITABLE_SETTINGS,
|
|
139
|
+
});
|
|
113
140
|
}
|
|
114
141
|
|
|
115
|
-
// Return common settings + workspace context
|
|
142
|
+
// Return common settings + workspace context with writability tags
|
|
116
143
|
const keys = [
|
|
117
144
|
"auth_method",
|
|
118
145
|
"default_runtime",
|
|
@@ -121,17 +148,20 @@ export function settingsTools(_ctx: ToolContext) {
|
|
|
121
148
|
"budget_max_cost_per_task",
|
|
122
149
|
"budget_max_daily_cost",
|
|
123
150
|
];
|
|
124
|
-
const entries: Record<string, string | null> = {};
|
|
151
|
+
const entries: Record<string, { value: string | null; writable: boolean }> = {};
|
|
125
152
|
for (const key of keys) {
|
|
126
|
-
entries[key] =
|
|
153
|
+
entries[key] = {
|
|
154
|
+
value: await getSetting(key),
|
|
155
|
+
writable: key in WRITABLE_SETTINGS,
|
|
156
|
+
};
|
|
127
157
|
}
|
|
128
158
|
|
|
129
|
-
// Append workspace context
|
|
159
|
+
// Append workspace context (read-only)
|
|
130
160
|
const ws = getWorkspaceContext();
|
|
131
|
-
entries.workspace_cwd = ws.cwd;
|
|
132
|
-
entries.workspace_git_branch = ws.gitBranch;
|
|
133
|
-
entries.workspace_is_worktree = ws.isWorktree ? "true" : "false";
|
|
134
|
-
entries.workspace_folder_name = ws.folderName;
|
|
161
|
+
entries.workspace_cwd = { value: ws.cwd, writable: false };
|
|
162
|
+
entries.workspace_git_branch = { value: ws.gitBranch, writable: false };
|
|
163
|
+
entries.workspace_is_worktree = { value: ws.isWorktree ? "true" : "false", writable: false };
|
|
164
|
+
entries.workspace_folder_name = { value: ws.folderName, writable: false };
|
|
135
165
|
|
|
136
166
|
return ok(entries);
|
|
137
167
|
} catch (e) {
|
|
@@ -151,7 +181,7 @@ export function settingsTools(_ctx: ToolContext) {
|
|
|
151
181
|
const spec = WRITABLE_SETTINGS[args.key];
|
|
152
182
|
if (!spec) {
|
|
153
183
|
return err(
|
|
154
|
-
`Key "${args.key}" is not writable.
|
|
184
|
+
`Key "${args.key}" is not writable via set_settings. Use get_settings to see which keys are writable (writable: true). Writable keys: ${Object.keys(WRITABLE_SETTINGS).join(", ")}`
|
|
155
185
|
);
|
|
156
186
|
}
|
|
157
187
|
const validationError = spec.validate(args.value);
|
|
@@ -87,6 +87,12 @@ export function workflowTools(ctx: ToolContext) {
|
|
|
87
87
|
.describe(
|
|
88
88
|
"Optional array of document IDs from the project pool to attach as input context. These documents will be injected into all workflow steps at execution time."
|
|
89
89
|
),
|
|
90
|
+
runtime: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe(
|
|
94
|
+
"Runtime to use for workflow execution (e.g., 'openai-direct', 'anthropic-direct'). Use list_runtimes to see available options. Omit to use the system default."
|
|
95
|
+
),
|
|
90
96
|
},
|
|
91
97
|
async (args) => {
|
|
92
98
|
try {
|
|
@@ -110,6 +116,16 @@ export function workflowTools(ctx: ToolContext) {
|
|
|
110
116
|
}
|
|
111
117
|
args.definition = JSON.stringify(parsedDef);
|
|
112
118
|
|
|
119
|
+
// Validate runtime if provided
|
|
120
|
+
let runtimeId: string | null = null;
|
|
121
|
+
if (args.runtime) {
|
|
122
|
+
const { isAgentRuntimeId } = await import("@/lib/agents/runtime/catalog");
|
|
123
|
+
if (!isAgentRuntimeId(args.runtime)) {
|
|
124
|
+
return err(`Invalid runtime "${args.runtime}". Use list_runtimes to see available options.`);
|
|
125
|
+
}
|
|
126
|
+
runtimeId = args.runtime;
|
|
127
|
+
}
|
|
128
|
+
|
|
113
129
|
const effectiveProjectId = args.projectId ?? ctx.projectId ?? null;
|
|
114
130
|
const now = new Date();
|
|
115
131
|
const id = crypto.randomUUID();
|
|
@@ -119,12 +135,13 @@ export function workflowTools(ctx: ToolContext) {
|
|
|
119
135
|
name: args.name,
|
|
120
136
|
projectId: effectiveProjectId,
|
|
121
137
|
definition: args.definition,
|
|
138
|
+
runtimeId,
|
|
122
139
|
status: "draft",
|
|
123
140
|
createdAt: now,
|
|
124
141
|
updatedAt: now,
|
|
125
142
|
});
|
|
126
143
|
|
|
127
|
-
// Attach pool documents if provided
|
|
144
|
+
// Attach global pool documents if provided
|
|
128
145
|
const attachedDocs: string[] = [];
|
|
129
146
|
if (args.documentIds && args.documentIds.length > 0) {
|
|
130
147
|
for (const docId of args.documentIds) {
|
|
@@ -143,6 +160,27 @@ export function workflowTools(ctx: ToolContext) {
|
|
|
143
160
|
}
|
|
144
161
|
}
|
|
145
162
|
|
|
163
|
+
// Attach per-step documents from step definitions
|
|
164
|
+
let stepDocCount = 0;
|
|
165
|
+
for (const step of parsedDef.steps) {
|
|
166
|
+
if (step.documentIds && Array.isArray(step.documentIds)) {
|
|
167
|
+
for (const docId of step.documentIds) {
|
|
168
|
+
try {
|
|
169
|
+
await db.insert(workflowDocumentInputs).values({
|
|
170
|
+
id: crypto.randomUUID(),
|
|
171
|
+
workflowId: id,
|
|
172
|
+
documentId: docId,
|
|
173
|
+
stepId: step.id,
|
|
174
|
+
createdAt: now,
|
|
175
|
+
});
|
|
176
|
+
stepDocCount++;
|
|
177
|
+
} catch {
|
|
178
|
+
// Skip duplicates or invalid doc IDs
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
const [workflow] = await db
|
|
147
185
|
.select()
|
|
148
186
|
.from(workflows)
|
|
@@ -154,8 +192,10 @@ export function workflowTools(ctx: ToolContext) {
|
|
|
154
192
|
name: workflow.name,
|
|
155
193
|
projectId: workflow.projectId,
|
|
156
194
|
status: workflow.status,
|
|
195
|
+
runtimeId: workflow.runtimeId,
|
|
157
196
|
createdAt: workflow.createdAt,
|
|
158
197
|
attachedDocuments: attachedDocs.length,
|
|
198
|
+
stepScopedDocuments: stepDocCount,
|
|
159
199
|
});
|
|
160
200
|
} catch (e) {
|
|
161
201
|
return err(e instanceof Error ? e.message : "Failed to create workflow");
|
|
@@ -328,10 +368,59 @@ export function workflowTools(ctx: ToolContext) {
|
|
|
328
368
|
.get();
|
|
329
369
|
|
|
330
370
|
if (!workflow) return err(`Workflow not found: ${args.workflowId}`);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (workflow.status
|
|
371
|
+
|
|
372
|
+
// Allow re-execution from crashed "active" if no live tasks
|
|
373
|
+
if (workflow.status === "active") {
|
|
374
|
+
const liveTasks = await db
|
|
375
|
+
.select({ id: tasks.id })
|
|
376
|
+
.from(tasks)
|
|
377
|
+
.where(
|
|
378
|
+
and(
|
|
379
|
+
eq(tasks.workflowId, args.workflowId),
|
|
380
|
+
inArray(tasks.status, ["running", "queued"])
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
if (liveTasks.length > 0) {
|
|
384
|
+
return err("Workflow is already running");
|
|
385
|
+
}
|
|
386
|
+
// Crashed — fall through to reset + re-execute
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (
|
|
390
|
+
workflow.status !== "draft" &&
|
|
391
|
+
workflow.status !== "paused" &&
|
|
392
|
+
workflow.status !== "failed" &&
|
|
393
|
+
workflow.status !== "active" &&
|
|
394
|
+
workflow.status !== "completed"
|
|
395
|
+
) {
|
|
334
396
|
return err(`Cannot execute a workflow in '${workflow.status}' status`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Reset state for re-execution from non-draft status
|
|
400
|
+
if (workflow.status !== "draft") {
|
|
401
|
+
// Cancel orphaned tasks
|
|
402
|
+
await db
|
|
403
|
+
.update(tasks)
|
|
404
|
+
.set({ status: "cancelled", updatedAt: new Date() })
|
|
405
|
+
.where(
|
|
406
|
+
and(
|
|
407
|
+
eq(tasks.workflowId, args.workflowId),
|
|
408
|
+
inArray(tasks.status, ["running", "queued"])
|
|
409
|
+
)
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Clear execution state
|
|
413
|
+
const { parseWorkflowState } = await import("@/lib/workflows/engine");
|
|
414
|
+
const { definition } = parseWorkflowState(workflow.definition);
|
|
415
|
+
await db
|
|
416
|
+
.update(workflows)
|
|
417
|
+
.set({
|
|
418
|
+
definition: JSON.stringify(definition),
|
|
419
|
+
status: "draft",
|
|
420
|
+
updatedAt: new Date(),
|
|
421
|
+
})
|
|
422
|
+
.where(eq(workflows.id, args.workflowId));
|
|
423
|
+
}
|
|
335
424
|
|
|
336
425
|
// Atomic claim: set to active
|
|
337
426
|
await db
|
package/src/lib/chat/types.ts
CHANGED
|
@@ -67,6 +67,27 @@ export const CHAT_MODELS: ChatModelOption[] = [
|
|
|
67
67
|
|
|
68
68
|
export const DEFAULT_CHAT_MODEL = "haiku";
|
|
69
69
|
|
|
70
|
+
// Validate CHAT_MODELS against runtime catalog at module load
|
|
71
|
+
// Warns on stale model IDs that don't appear in any runtime's supported list
|
|
72
|
+
try {
|
|
73
|
+
const { listRuntimeCatalog } = require("@/lib/agents/runtime/catalog");
|
|
74
|
+
const allSupportedModels = new Set<string>();
|
|
75
|
+
for (const runtime of listRuntimeCatalog()) {
|
|
76
|
+
for (const model of runtime.models.supported) {
|
|
77
|
+
allSupportedModels.add(model);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const model of CHAT_MODELS) {
|
|
81
|
+
if (!allSupportedModels.has(model.id)) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[chat-models] CHAT_MODELS entry "${model.id}" not found in any runtime's supported models — may be stale`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Catalog not available during build/test — skip validation
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
/** Resolve a model ID to its display label (e.g., "opus" → "Opus", "gpt-5.4" → "GPT-5.4") */
|
|
71
92
|
export function resolveModelLabel(modelId: string): string {
|
|
72
93
|
const model = CHAT_MODELS.find((m) => m.id === modelId);
|
package/src/lib/data/clear.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
taskTableInputs,
|
|
43
43
|
workflowTableInputs,
|
|
44
44
|
scheduleTableInputs,
|
|
45
|
+
workflowExecutionStats,
|
|
45
46
|
} from "@/lib/db/schema";
|
|
46
47
|
import { readdirSync, unlinkSync, mkdirSync } from "fs";
|
|
47
48
|
import { join } from "path";
|
|
@@ -119,6 +120,7 @@ export function clearAllData() {
|
|
|
119
120
|
const documentsDeleted = db.delete(documents).run().changes;
|
|
120
121
|
const agentMemoryDeleted = db.delete(agentMemory).run().changes;
|
|
121
122
|
const learnedContextDeleted = db.delete(learnedContext).run().changes;
|
|
123
|
+
const executionStatsDeleted = db.delete(workflowExecutionStats).run().changes;
|
|
122
124
|
const tasksDeleted = db.delete(tasks).run().changes;
|
|
123
125
|
const workflowsDeleted = db.delete(workflows).run().changes;
|
|
124
126
|
const schedulesDeleted = db.delete(schedules).run().changes;
|
|
@@ -194,5 +196,6 @@ export function clearAllData() {
|
|
|
194
196
|
files: filesDeleted,
|
|
195
197
|
screenshots: screenshotsDeleted,
|
|
196
198
|
license: licenseDeleted,
|
|
199
|
+
workflowExecutionStats: executionStatsDeleted,
|
|
197
200
|
};
|
|
198
201
|
}
|
package/src/lib/db/bootstrap.ts
CHANGED
|
@@ -46,6 +46,7 @@ const STAGENT_TABLES = [
|
|
|
46
46
|
"user_table_row_history",
|
|
47
47
|
"snapshots",
|
|
48
48
|
"license",
|
|
49
|
+
"workflow_execution_stats",
|
|
49
50
|
] as const;
|
|
50
51
|
|
|
51
52
|
export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
@@ -74,6 +75,7 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
|
74
75
|
session_id TEXT,
|
|
75
76
|
resume_count INTEGER DEFAULT 0 NOT NULL,
|
|
76
77
|
workflow_run_number INTEGER,
|
|
78
|
+
max_budget_usd REAL,
|
|
77
79
|
created_at INTEGER NOT NULL,
|
|
78
80
|
updated_at INTEGER NOT NULL,
|
|
79
81
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
|
|
@@ -88,6 +90,7 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
|
88
90
|
definition TEXT NOT NULL,
|
|
89
91
|
status TEXT DEFAULT 'draft' NOT NULL,
|
|
90
92
|
run_number INTEGER DEFAULT 0 NOT NULL,
|
|
93
|
+
runtime_id TEXT,
|
|
91
94
|
created_at INTEGER NOT NULL,
|
|
92
95
|
updated_at INTEGER NOT NULL,
|
|
93
96
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
|
|
@@ -176,6 +179,10 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
|
176
179
|
heartbeat_budget_per_day INTEGER,
|
|
177
180
|
heartbeat_spent_today INTEGER DEFAULT 0 NOT NULL,
|
|
178
181
|
heartbeat_budget_reset_at INTEGER,
|
|
182
|
+
avg_turns_per_firing INTEGER,
|
|
183
|
+
last_turn_count INTEGER,
|
|
184
|
+
failure_streak INTEGER DEFAULT 0 NOT NULL,
|
|
185
|
+
last_failure_reason TEXT,
|
|
179
186
|
created_at INTEGER NOT NULL,
|
|
180
187
|
updated_at INTEGER NOT NULL,
|
|
181
188
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
|
|
@@ -536,6 +543,13 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
|
536
543
|
`);
|
|
537
544
|
|
|
538
545
|
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN delivery_channels TEXT;`);
|
|
546
|
+
// Schedule health-monitoring columns (collision-prevention feature).
|
|
547
|
+
// Nullable so existing rows backfill cleanly; failure_streak defaults to 0
|
|
548
|
+
// so the auto-pause logic treats existing schedules as "no failures yet".
|
|
549
|
+
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN avg_turns_per_firing INTEGER;`);
|
|
550
|
+
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN last_turn_count INTEGER;`);
|
|
551
|
+
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN failure_streak INTEGER DEFAULT 0 NOT NULL;`);
|
|
552
|
+
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN last_failure_reason TEXT;`);
|
|
539
553
|
addColumnIfMissing(`ALTER TABLE channel_configs ADD COLUMN direction TEXT DEFAULT 'outbound' NOT NULL;`);
|
|
540
554
|
|
|
541
555
|
// ── Bidirectional Channel Chat ──────────────────────────────────────────
|
|
@@ -856,7 +870,31 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
|
856
870
|
created_at INTEGER NOT NULL,
|
|
857
871
|
updated_at INTEGER NOT NULL
|
|
858
872
|
);
|
|
873
|
+
|
|
874
|
+
CREATE TABLE IF NOT EXISTS workflow_execution_stats (
|
|
875
|
+
id TEXT PRIMARY KEY,
|
|
876
|
+
pattern TEXT NOT NULL,
|
|
877
|
+
step_count INTEGER NOT NULL,
|
|
878
|
+
avg_docs_per_step REAL,
|
|
879
|
+
avg_cost_per_step_micros INTEGER,
|
|
880
|
+
avg_duration_per_step_ms INTEGER,
|
|
881
|
+
success_rate REAL,
|
|
882
|
+
common_failures TEXT,
|
|
883
|
+
runtime_breakdown TEXT,
|
|
884
|
+
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
885
|
+
last_updated TEXT NOT NULL,
|
|
886
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
887
|
+
);
|
|
859
888
|
`);
|
|
889
|
+
|
|
890
|
+
// Safety: add columns that may be missing on existing databases.
|
|
891
|
+
// SQLite ALTER TABLE ADD COLUMN is a no-op if column exists (throws, caught).
|
|
892
|
+
for (const alter of [
|
|
893
|
+
"ALTER TABLE tasks ADD COLUMN max_budget_usd REAL",
|
|
894
|
+
"ALTER TABLE workflows ADD COLUMN runtime_id TEXT",
|
|
895
|
+
]) {
|
|
896
|
+
try { sqlite.exec(alter); } catch { /* column already exists — expected */ }
|
|
897
|
+
}
|
|
860
898
|
}
|
|
861
899
|
|
|
862
900
|
export function hasLegacyStagentTables(sqlite: Database.Database): boolean {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Workflow Intelligence Stack — Phase 2: Execution learning
|
|
2
|
+
CREATE TABLE IF NOT EXISTS workflow_execution_stats (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
pattern TEXT NOT NULL,
|
|
5
|
+
step_count INTEGER NOT NULL,
|
|
6
|
+
avg_docs_per_step REAL,
|
|
7
|
+
avg_cost_per_step_micros INTEGER,
|
|
8
|
+
avg_duration_per_step_ms INTEGER,
|
|
9
|
+
success_rate REAL,
|
|
10
|
+
common_failures TEXT,
|
|
11
|
+
runtime_breakdown TEXT,
|
|
12
|
+
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
last_updated TEXT NOT NULL,
|
|
14
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
15
|
+
);
|