@fred-drake/anvil 0.0.1
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/README.md +72 -0
- package/assets/anvil-logo.png +0 -0
- package/assets/anvil-logo.txt +21 -0
- package/extensions/anvil/index.ts +1 -0
- package/package.json +58 -0
- package/skills/anvil-workflow-builder/SKILL.md +109 -0
- package/src/discovery.ts +149 -0
- package/src/engine.ts +591 -0
- package/src/errors.ts +18 -0
- package/src/gates.ts +198 -0
- package/src/index.ts +700 -0
- package/src/paths.ts +35 -0
- package/src/prompts.ts +253 -0
- package/src/shell.ts +3 -0
- package/src/subagent/child.ts +61 -0
- package/src/subagent/cmux.ts +227 -0
- package/src/subagent/exit.ts +113 -0
- package/src/subagent/herdr.ts +142 -0
- package/src/subagent/runner.ts +178 -0
- package/src/types.ts +122 -0
- package/src/ui.ts +94 -0
- package/src/validate.ts +340 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { AnvilAbortError, isAnvilAbortError, throwIfAborted } from "./errors.ts";
|
|
2
|
+
import { executeAgentCheck, executeDeterministicCheck, type GateResult, type Verdict } from "./gates.ts";
|
|
3
|
+
import { buildStepInstruction, buildSubagentStepTask, getCurrentLoopCount, resolveStepDelegation } from "./prompts.ts";
|
|
4
|
+
import type {
|
|
5
|
+
Check,
|
|
6
|
+
OnFailPolicy,
|
|
7
|
+
WorkflowContext,
|
|
8
|
+
WorkflowDefinition,
|
|
9
|
+
WorkflowStep,
|
|
10
|
+
WorkflowSubagentBackend,
|
|
11
|
+
WorkflowThinkingLevel,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
import { formatStatus, formatStepWidget } from "./ui.ts";
|
|
14
|
+
|
|
15
|
+
export interface EngineExecOptions {
|
|
16
|
+
cwd?: string;
|
|
17
|
+
timeout?: number;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EngineExecResult {
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
code: number;
|
|
25
|
+
killed?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StepModelSelection {
|
|
29
|
+
model?: string;
|
|
30
|
+
thinkingLevel?: WorkflowThinkingLevel;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const THINKING_LEVELS = new Set<WorkflowThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
34
|
+
|
|
35
|
+
export interface SubagentStepRunRequest {
|
|
36
|
+
runId: string;
|
|
37
|
+
workflowName: string;
|
|
38
|
+
stepId: string;
|
|
39
|
+
stepTitle: string;
|
|
40
|
+
stepIndex: number;
|
|
41
|
+
stepCount: number;
|
|
42
|
+
backend: WorkflowSubagentBackend;
|
|
43
|
+
/** Full task prompt for the subagent session. */
|
|
44
|
+
task: string;
|
|
45
|
+
cwd: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
thinkingLevel?: WorkflowThinkingLevel;
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SubagentStepRunResult {
|
|
52
|
+
summary: string;
|
|
53
|
+
sessionFile?: string;
|
|
54
|
+
exitCode: number;
|
|
55
|
+
errorMessage?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface EngineHost {
|
|
59
|
+
applyStepModelSelection?(selection: StepModelSelection | undefined): void | Promise<void>;
|
|
60
|
+
/** Run a subagent-delegated step to completion. Required for workflows using delegation: { subagent }. */
|
|
61
|
+
runSubagent?(request: SubagentStepRunRequest, signal?: AbortSignal): Promise<SubagentStepRunResult>;
|
|
62
|
+
sendInstruction(instruction: string): void;
|
|
63
|
+
waitForTurnComplete(signal?: AbortSignal): Promise<void>;
|
|
64
|
+
exec(command: string, args: string[], options?: EngineExecOptions): Promise<EngineExecResult>;
|
|
65
|
+
awaitVerdict(checkId: string, timeoutMs: number, signal?: AbortSignal): Promise<Verdict | undefined>;
|
|
66
|
+
checkpoint(entry: AnvilCheckpoint): void;
|
|
67
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
68
|
+
setStatus(text: string | undefined): void;
|
|
69
|
+
setWidget(lines: string[] | undefined): void;
|
|
70
|
+
postSummary(summary: RunSummary): void | Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ResumeWorkflowOptions {
|
|
74
|
+
/** One-based workflow step number to resume from. */
|
|
75
|
+
stepNumber: number;
|
|
76
|
+
/** Current retry/loop count for the resumed step. Defaults to 0. */
|
|
77
|
+
retryCount?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RunWorkflowOptions {
|
|
81
|
+
workflow: WorkflowDefinition;
|
|
82
|
+
input: string;
|
|
83
|
+
cwd: string;
|
|
84
|
+
host: EngineHost;
|
|
85
|
+
runId?: string;
|
|
86
|
+
resume?: ResumeWorkflowOptions;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type StepRunStatus = "pending" | "running" | "passed" | "failed" | "skipped" | "continued";
|
|
91
|
+
|
|
92
|
+
export interface CheckRunState {
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
pass: boolean;
|
|
96
|
+
reason: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface StepRunState {
|
|
100
|
+
id: string;
|
|
101
|
+
title?: string;
|
|
102
|
+
status: StepRunStatus;
|
|
103
|
+
loops: number;
|
|
104
|
+
checks: CheckRunState[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RunSummary {
|
|
108
|
+
runId: string;
|
|
109
|
+
workflowName: string;
|
|
110
|
+
input: string;
|
|
111
|
+
state: "succeeded" | "failed" | "aborted";
|
|
112
|
+
startedAt: string;
|
|
113
|
+
endedAt: string;
|
|
114
|
+
steps: StepRunState[];
|
|
115
|
+
loopCounts: Record<string, number>;
|
|
116
|
+
failureReason?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type AnvilCheckpointPhase = "run_start" | "step_start" | "check_result" | "step_pass" | "run_end";
|
|
120
|
+
|
|
121
|
+
export interface AnvilCheckpoint {
|
|
122
|
+
runId: string;
|
|
123
|
+
workflowName: string;
|
|
124
|
+
input: string;
|
|
125
|
+
phase: AnvilCheckpointPhase;
|
|
126
|
+
timestamp: string;
|
|
127
|
+
workflowFile?: string;
|
|
128
|
+
stepId?: string;
|
|
129
|
+
stepIndex?: number;
|
|
130
|
+
checkId?: string;
|
|
131
|
+
pass?: boolean;
|
|
132
|
+
reason?: string;
|
|
133
|
+
loopCounts?: Record<string, number>;
|
|
134
|
+
finalState?: RunSummary["state"];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface FailureDecision {
|
|
138
|
+
kind: "stop" | "continue" | "goto";
|
|
139
|
+
reason?: string;
|
|
140
|
+
targetIndex?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function runWorkflow(options: RunWorkflowOptions): Promise<RunSummary> {
|
|
144
|
+
const runId = options.runId ?? newRunId();
|
|
145
|
+
const startedAt = new Date().toISOString();
|
|
146
|
+
const loopCounts: Record<string, number> = {};
|
|
147
|
+
const feedbackByStep = new Map<string, string>();
|
|
148
|
+
const attempts = new Map<string, number>();
|
|
149
|
+
const workflowHasModelSelectionOverrides = hasWorkflowModelSelectionOverrides(options.workflow);
|
|
150
|
+
let shouldRestoreModelSelection = false;
|
|
151
|
+
const steps = options.workflow.steps.map<StepRunState>((step) => ({
|
|
152
|
+
id: step.id,
|
|
153
|
+
title: step.title,
|
|
154
|
+
status: "pending",
|
|
155
|
+
loops: 0,
|
|
156
|
+
checks: [],
|
|
157
|
+
}));
|
|
158
|
+
const resume = resolveResumeState(options, loopCounts, steps);
|
|
159
|
+
|
|
160
|
+
const checkpoint = (entry: Omit<AnvilCheckpoint, "runId" | "workflowName" | "input" | "timestamp">) => {
|
|
161
|
+
options.host.checkpoint({
|
|
162
|
+
runId,
|
|
163
|
+
workflowName: options.workflow.name,
|
|
164
|
+
input: options.input,
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
loopCounts: { ...loopCounts },
|
|
167
|
+
...entry,
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const finish = async (state: RunSummary["state"], failureReason?: string): Promise<RunSummary> => {
|
|
172
|
+
if (shouldRestoreModelSelection) {
|
|
173
|
+
try {
|
|
174
|
+
await options.host.applyStepModelSelection?.(undefined);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
options.host.notify(
|
|
177
|
+
`Failed to restore workflow-start model/thinking: ${error instanceof Error ? error.message : String(error)}`,
|
|
178
|
+
"warning",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const summary: RunSummary = {
|
|
183
|
+
runId,
|
|
184
|
+
workflowName: options.workflow.name,
|
|
185
|
+
input: options.input,
|
|
186
|
+
state,
|
|
187
|
+
startedAt,
|
|
188
|
+
endedAt: new Date().toISOString(),
|
|
189
|
+
steps,
|
|
190
|
+
loopCounts: { ...loopCounts },
|
|
191
|
+
failureReason,
|
|
192
|
+
};
|
|
193
|
+
checkpoint({ phase: "run_end", finalState: state, reason: failureReason });
|
|
194
|
+
options.host.setStatus(formatStatus({ workflowName: options.workflow.name, phase: state === "succeeded" ? "done" : state }));
|
|
195
|
+
options.host.setWidget(formatStepWidget(steps));
|
|
196
|
+
await options.host.postSummary(summary);
|
|
197
|
+
return summary;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
throwIfAborted(options.signal);
|
|
202
|
+
options.host.setStatus(formatStatus({ workflowName: options.workflow.name, phase: "starting" }));
|
|
203
|
+
options.host.setWidget(formatStepWidget(steps));
|
|
204
|
+
checkpoint({ phase: "run_start" });
|
|
205
|
+
if (resume.error) return finish("failed", resume.error);
|
|
206
|
+
|
|
207
|
+
let stepIndex = resume.startIndex;
|
|
208
|
+
while (stepIndex < options.workflow.steps.length) {
|
|
209
|
+
throwIfAborted(options.signal);
|
|
210
|
+
const step = options.workflow.steps[stepIndex]!;
|
|
211
|
+
const stepState = steps[stepIndex]!;
|
|
212
|
+
const ctx = makeWorkflowContext(options.input, step, stepIndex, loopCounts, options.cwd);
|
|
213
|
+
|
|
214
|
+
if (step.skipIf && (await step.skipIf(ctx))) {
|
|
215
|
+
stepState.status = "skipped";
|
|
216
|
+
checkpoint({ phase: "step_pass", stepId: step.id, stepIndex, reason: "skipped" });
|
|
217
|
+
updateStepUi(options, steps, stepIndex, "step");
|
|
218
|
+
stepIndex += 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
stepState.status = "running";
|
|
223
|
+
updateStepUi(options, steps, stepIndex, "step");
|
|
224
|
+
const delegation = resolveStepDelegation(options.workflow, step);
|
|
225
|
+
if (delegation.mode === "subagent") {
|
|
226
|
+
if (!options.host.runSubagent) {
|
|
227
|
+
stepState.status = "failed";
|
|
228
|
+
updateStepUi(options, steps, stepIndex, "failed");
|
|
229
|
+
return finish(
|
|
230
|
+
"failed",
|
|
231
|
+
`step "${step.id}" declares delegation: { subagent: "${delegation.backend}" }, but this host cannot run subagents`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const task = await buildSubagentStepTask({
|
|
235
|
+
workflow: options.workflow,
|
|
236
|
+
step,
|
|
237
|
+
ctx,
|
|
238
|
+
stepIndex,
|
|
239
|
+
stepCount: options.workflow.steps.length,
|
|
240
|
+
feedback: feedbackByStep.get(step.id),
|
|
241
|
+
});
|
|
242
|
+
feedbackByStep.delete(step.id);
|
|
243
|
+
|
|
244
|
+
checkpoint({ phase: "step_start", stepId: step.id, stepIndex });
|
|
245
|
+
const selection = resolveStepModelSelection(step, getCurrentLoopCount(ctx));
|
|
246
|
+
let result: SubagentStepRunResult;
|
|
247
|
+
try {
|
|
248
|
+
result = await options.host.runSubagent(
|
|
249
|
+
{
|
|
250
|
+
runId,
|
|
251
|
+
workflowName: options.workflow.name,
|
|
252
|
+
stepId: step.id,
|
|
253
|
+
stepTitle: step.title ?? step.id,
|
|
254
|
+
stepIndex,
|
|
255
|
+
stepCount: options.workflow.steps.length,
|
|
256
|
+
backend: delegation.backend,
|
|
257
|
+
task,
|
|
258
|
+
cwd: options.cwd,
|
|
259
|
+
model: selection?.model,
|
|
260
|
+
thinkingLevel: selection?.thinkingLevel,
|
|
261
|
+
timeoutMs: step.subagentTimeoutMs ?? options.workflow.defaults?.subagentTimeoutMs,
|
|
262
|
+
},
|
|
263
|
+
options.signal,
|
|
264
|
+
);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
stepState.status = "failed";
|
|
267
|
+
updateStepUi(options, steps, stepIndex, "failed");
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
throwIfAborted(options.signal);
|
|
271
|
+
if (result.errorMessage || result.exitCode !== 0) {
|
|
272
|
+
stepState.status = "failed";
|
|
273
|
+
updateStepUi(options, steps, stepIndex, "failed");
|
|
274
|
+
return finish(
|
|
275
|
+
"failed",
|
|
276
|
+
result.errorMessage ?? `subagent for step "${step.id}" exited with code ${result.exitCode}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
if (workflowHasModelSelectionOverrides) {
|
|
281
|
+
try {
|
|
282
|
+
shouldRestoreModelSelection = true;
|
|
283
|
+
await options.host.applyStepModelSelection?.(resolveStepModelSelection(step, getCurrentLoopCount(ctx)));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
stepState.status = "failed";
|
|
286
|
+
updateStepUi(options, steps, stepIndex, "failed");
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const instruction = await buildStepInstruction({
|
|
291
|
+
workflow: options.workflow,
|
|
292
|
+
step,
|
|
293
|
+
ctx,
|
|
294
|
+
stepIndex,
|
|
295
|
+
stepCount: options.workflow.steps.length,
|
|
296
|
+
feedback: feedbackByStep.get(step.id),
|
|
297
|
+
});
|
|
298
|
+
feedbackByStep.delete(step.id);
|
|
299
|
+
|
|
300
|
+
checkpoint({ phase: "step_start", stepId: step.id, stepIndex });
|
|
301
|
+
options.host.sendInstruction(instruction);
|
|
302
|
+
await options.host.waitForTurnComplete(options.signal);
|
|
303
|
+
throwIfAborted(options.signal);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const checks = step.checks ?? [];
|
|
307
|
+
let jumpedOrAdvanced = false;
|
|
308
|
+
for (let checkIndex = 0; checkIndex < checks.length; checkIndex += 1) {
|
|
309
|
+
throwIfAborted(options.signal);
|
|
310
|
+
const check = checks[checkIndex]!;
|
|
311
|
+
const displayName = check.name ?? check.id ?? `check ${checkIndex + 1}`;
|
|
312
|
+
updateStepUi(options, steps, stepIndex, "check", checkIndex, checks.length, displayName);
|
|
313
|
+
const checkId = makeRuntimeCheckId(runId, step.id, checkIndex, attempts);
|
|
314
|
+
const result = await executeCheck({
|
|
315
|
+
host: options.host,
|
|
316
|
+
workflow: options.workflow,
|
|
317
|
+
step,
|
|
318
|
+
check,
|
|
319
|
+
ctx,
|
|
320
|
+
checkId,
|
|
321
|
+
signal: options.signal,
|
|
322
|
+
});
|
|
323
|
+
const checkState: CheckRunState = {
|
|
324
|
+
id: checkId,
|
|
325
|
+
name: displayName,
|
|
326
|
+
pass: result.pass,
|
|
327
|
+
reason: result.reason,
|
|
328
|
+
};
|
|
329
|
+
stepState.checks.push(checkState);
|
|
330
|
+
checkpoint({
|
|
331
|
+
phase: "check_result",
|
|
332
|
+
stepId: step.id,
|
|
333
|
+
stepIndex,
|
|
334
|
+
checkId,
|
|
335
|
+
pass: result.pass,
|
|
336
|
+
reason: result.reason,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (result.pass) continue;
|
|
340
|
+
|
|
341
|
+
const decision = resolveFailure({
|
|
342
|
+
workflow: options.workflow,
|
|
343
|
+
step,
|
|
344
|
+
check,
|
|
345
|
+
checkIndex,
|
|
346
|
+
result,
|
|
347
|
+
loopCounts,
|
|
348
|
+
steps,
|
|
349
|
+
feedbackByStep,
|
|
350
|
+
host: options.host,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (decision.kind === "stop") {
|
|
354
|
+
stepState.status = "failed";
|
|
355
|
+
updateStepUi(options, steps, stepIndex, "failed");
|
|
356
|
+
return finish("failed", decision.reason ?? result.reason);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (decision.kind === "continue") {
|
|
360
|
+
stepState.status = "continued";
|
|
361
|
+
checkpoint({ phase: "step_pass", stepId: step.id, stepIndex, reason: decision.reason ?? "continued" });
|
|
362
|
+
stepIndex += 1;
|
|
363
|
+
jumpedOrAdvanced = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
stepState.status = "pending";
|
|
368
|
+
updateStepUi(options, steps, stepIndex, "loop");
|
|
369
|
+
stepIndex = decision.targetIndex!;
|
|
370
|
+
jumpedOrAdvanced = true;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (jumpedOrAdvanced) continue;
|
|
375
|
+
|
|
376
|
+
stepState.status = "passed";
|
|
377
|
+
checkpoint({ phase: "step_pass", stepId: step.id, stepIndex });
|
|
378
|
+
updateStepUi(options, steps, stepIndex, "step");
|
|
379
|
+
stepIndex += 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return finish("succeeded");
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (options.signal?.aborted || isAnvilAbortError(error)) {
|
|
385
|
+
return finish("aborted", "aborted");
|
|
386
|
+
}
|
|
387
|
+
return finish("failed", error instanceof Error ? error.message : String(error));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resolveResumeState(
|
|
392
|
+
options: RunWorkflowOptions,
|
|
393
|
+
loopCounts: Record<string, number>,
|
|
394
|
+
steps: StepRunState[],
|
|
395
|
+
): { startIndex: number; error?: string } {
|
|
396
|
+
if (!options.resume) return { startIndex: 0 };
|
|
397
|
+
|
|
398
|
+
const { stepNumber, retryCount = 0 } = options.resume;
|
|
399
|
+
if (!Number.isInteger(stepNumber) || stepNumber < 1 || stepNumber > options.workflow.steps.length) {
|
|
400
|
+
return { startIndex: 0, error: `resume step must be an integer from 1 to ${options.workflow.steps.length}` };
|
|
401
|
+
}
|
|
402
|
+
if (!Number.isInteger(retryCount) || retryCount < 0) {
|
|
403
|
+
return { startIndex: 0, error: "resume retry count must be a non-negative integer" };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const startIndex = stepNumber - 1;
|
|
407
|
+
for (let index = 0; index < startIndex; index += 1) steps[index]!.status = "skipped";
|
|
408
|
+
|
|
409
|
+
if (retryCount > 0) {
|
|
410
|
+
const step = options.workflow.steps[startIndex]!;
|
|
411
|
+
loopCounts[`resume->${step.id}`] = retryCount;
|
|
412
|
+
steps[startIndex]!.loops = retryCount;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { startIndex };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function resolveFailure(args: {
|
|
419
|
+
workflow: WorkflowDefinition;
|
|
420
|
+
step: WorkflowStep;
|
|
421
|
+
check: Check;
|
|
422
|
+
checkIndex: number;
|
|
423
|
+
result: GateResult;
|
|
424
|
+
loopCounts: Record<string, number>;
|
|
425
|
+
steps: StepRunState[];
|
|
426
|
+
feedbackByStep: Map<string, string>;
|
|
427
|
+
host: EngineHost;
|
|
428
|
+
}): FailureDecision {
|
|
429
|
+
const policy = args.check.onFail ?? args.step.onFail ?? args.workflow.defaults?.onFail ?? "stop";
|
|
430
|
+
if (policy === "stop") return { kind: "stop", reason: args.result.reason };
|
|
431
|
+
if (policy === "continue") return { kind: "continue", reason: args.result.reason };
|
|
432
|
+
|
|
433
|
+
const targetIndex = args.workflow.steps.findIndex((step) => step.id === policy.goto);
|
|
434
|
+
if (targetIndex === -1) return { kind: "stop", reason: `goto target "${policy.goto}" does not exist` };
|
|
435
|
+
|
|
436
|
+
const loopKey = `${args.check.id ?? `${args.step.id}:check${args.checkIndex + 1}`}->${policy.goto}`;
|
|
437
|
+
const resumeSeed = args.loopCounts[`resume->${policy.goto}`] ?? 0;
|
|
438
|
+
const nextCount = Math.max(args.loopCounts[loopKey] ?? 0, resumeSeed) + 1;
|
|
439
|
+
args.loopCounts[loopKey] = nextCount;
|
|
440
|
+
const maxLoops = policy.maxLoops ?? args.workflow.defaults?.maxLoops ?? 3;
|
|
441
|
+
|
|
442
|
+
if (nextCount > maxLoops) {
|
|
443
|
+
const exhausted = `loop budget exhausted for ${loopKey} (${maxLoops})`;
|
|
444
|
+
const reason = `${exhausted}: ${args.result.reason}`;
|
|
445
|
+
args.host.notify(reason, "warning");
|
|
446
|
+
return { kind: policy.onExhausted === "continue" ? "continue" : "stop", reason };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
args.steps[targetIndex]!.loops += 1;
|
|
450
|
+
if (policy.feedback !== false) args.feedbackByStep.set(policy.goto, args.result.reason);
|
|
451
|
+
args.host.notify(`Anvil check failed; returning to step "${policy.goto}" (${nextCount}/${maxLoops}).`, "warning");
|
|
452
|
+
return { kind: "goto", targetIndex };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function hasWorkflowModelSelectionOverrides(workflow: WorkflowDefinition): boolean {
|
|
456
|
+
return workflow.steps.some(
|
|
457
|
+
(step) =>
|
|
458
|
+
step.model !== undefined ||
|
|
459
|
+
step.thinkingLevel !== undefined ||
|
|
460
|
+
(step.retryModelSelections !== undefined && step.retryModelSelections.length > 0),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function resolveStepModelSelection(step: WorkflowStep, retryCount = 0): StepModelSelection | undefined {
|
|
465
|
+
const baseSelection = mergeModelSelection(parseModelReference(step.model), { thinkingLevel: step.thinkingLevel });
|
|
466
|
+
const retrySelection = selectRetryModelSelection(step, retryCount);
|
|
467
|
+
if (!retrySelection) return emptyToUndefined(baseSelection);
|
|
468
|
+
|
|
469
|
+
const parsedRetryModel = parseModelReference(retrySelection.model);
|
|
470
|
+
return emptyToUndefined(
|
|
471
|
+
mergeModelSelection(baseSelection, parsedRetryModel, { thinkingLevel: retrySelection.thinkingLevel }),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function selectRetryModelSelection(step: WorkflowStep, retryCount: number): StepModelSelection | undefined {
|
|
476
|
+
let selected: (StepModelSelection & { retry: number }) | undefined;
|
|
477
|
+
for (const candidate of step.retryModelSelections ?? []) {
|
|
478
|
+
if (candidate.retry > retryCount) continue;
|
|
479
|
+
if (!selected || candidate.retry > selected.retry) selected = candidate;
|
|
480
|
+
}
|
|
481
|
+
return selected;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function mergeModelSelection(...selections: Array<StepModelSelection | undefined>): StepModelSelection {
|
|
485
|
+
const merged: StepModelSelection = {};
|
|
486
|
+
for (const selection of selections) {
|
|
487
|
+
if (!selection) continue;
|
|
488
|
+
if (selection.model !== undefined) merged.model = selection.model;
|
|
489
|
+
if (selection.thinkingLevel !== undefined) merged.thinkingLevel = selection.thinkingLevel;
|
|
490
|
+
}
|
|
491
|
+
return merged;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function emptyToUndefined(selection: StepModelSelection): StepModelSelection | undefined {
|
|
495
|
+
return selection.model !== undefined || selection.thinkingLevel !== undefined ? selection : undefined;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function parseModelReference(model: string | undefined): StepModelSelection | undefined {
|
|
499
|
+
if (!model) return undefined;
|
|
500
|
+
const lastColon = model.lastIndexOf(":");
|
|
501
|
+
if (lastColon <= 0) return { model };
|
|
502
|
+
|
|
503
|
+
const suffix = model.slice(lastColon + 1);
|
|
504
|
+
if (!THINKING_LEVELS.has(suffix as WorkflowThinkingLevel)) return { model };
|
|
505
|
+
|
|
506
|
+
const baseModel = model.slice(0, lastColon);
|
|
507
|
+
if (!baseModel) return { model };
|
|
508
|
+
return { model: baseModel, thinkingLevel: suffix as WorkflowThinkingLevel };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function executeCheck(args: {
|
|
512
|
+
host: EngineHost;
|
|
513
|
+
workflow: WorkflowDefinition;
|
|
514
|
+
step: WorkflowStep;
|
|
515
|
+
check: Check;
|
|
516
|
+
ctx: WorkflowContext;
|
|
517
|
+
checkId: string;
|
|
518
|
+
signal?: AbortSignal;
|
|
519
|
+
}): Promise<GateResult> {
|
|
520
|
+
if (args.check.type === "deterministic") {
|
|
521
|
+
return executeDeterministicCheck({
|
|
522
|
+
host: args.host,
|
|
523
|
+
check: args.check,
|
|
524
|
+
ctx: args.ctx,
|
|
525
|
+
checkId: args.checkId,
|
|
526
|
+
signal: args.signal,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return executeAgentCheck({
|
|
530
|
+
host: args.host,
|
|
531
|
+
workflow: args.workflow,
|
|
532
|
+
step: args.step,
|
|
533
|
+
check: args.check,
|
|
534
|
+
ctx: args.ctx,
|
|
535
|
+
checkId: args.checkId,
|
|
536
|
+
signal: args.signal,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function updateStepUi(
|
|
541
|
+
options: RunWorkflowOptions,
|
|
542
|
+
steps: StepRunState[],
|
|
543
|
+
stepIndex: number,
|
|
544
|
+
phase: "step" | "check" | "loop" | "failed",
|
|
545
|
+
checkIndex?: number,
|
|
546
|
+
checkTotal?: number,
|
|
547
|
+
checkName?: string,
|
|
548
|
+
): void {
|
|
549
|
+
const step = steps[stepIndex]!;
|
|
550
|
+
options.host.setStatus(
|
|
551
|
+
formatStatus({
|
|
552
|
+
workflowName: options.workflow.name,
|
|
553
|
+
stepIndex,
|
|
554
|
+
stepTotal: options.workflow.steps.length,
|
|
555
|
+
stepTitle: step.title ?? step.id,
|
|
556
|
+
phase,
|
|
557
|
+
checkIndex,
|
|
558
|
+
checkTotal,
|
|
559
|
+
checkName,
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
562
|
+
options.host.setWidget(formatStepWidget(steps, step.id));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function makeWorkflowContext(
|
|
566
|
+
input: string,
|
|
567
|
+
step: WorkflowStep,
|
|
568
|
+
stepIndex: number,
|
|
569
|
+
loopCounts: Record<string, number>,
|
|
570
|
+
cwd: string,
|
|
571
|
+
): WorkflowContext {
|
|
572
|
+
return {
|
|
573
|
+
input,
|
|
574
|
+
step: { id: step.id, index: stepIndex },
|
|
575
|
+
loopCounts: { ...loopCounts },
|
|
576
|
+
cwd,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function makeRuntimeCheckId(runId: string, stepId: string, checkIndex: number, attempts: Map<string, number>): string {
|
|
581
|
+
const key = `${stepId}:${checkIndex}`;
|
|
582
|
+
const attempt = attempts.get(key) ?? 0;
|
|
583
|
+
attempts.set(key, attempt + 1);
|
|
584
|
+
return `${runId}:${stepId}:${checkIndex}:${attempt}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function newRunId(): string {
|
|
588
|
+
return `anvil-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export { AnvilAbortError };
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class AnvilAbortError extends Error {
|
|
2
|
+
constructor(message = "Anvil run aborted") {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "AnvilAbortError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
9
|
+
if (signal?.aborted) throw new AnvilAbortError();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function abortError(): AnvilAbortError {
|
|
13
|
+
return new AnvilAbortError();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isAnvilAbortError(error: unknown): error is AnvilAbortError {
|
|
17
|
+
return error instanceof AnvilAbortError;
|
|
18
|
+
}
|