@sweny-ai/core 0.1.75 → 0.1.77

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/executor.js CHANGED
@@ -15,6 +15,8 @@
15
15
  import { consoleLogger } from "./types.js";
16
16
  import { resolveSources } from "./source-resolver.js";
17
17
  import { evaluateVerify } from "./verify.js";
18
+ import { evaluateRequires } from "./requires.js";
19
+ import { buildRetryPreamble } from "./retry.js";
18
20
  /**
19
21
  * Execute a workflow from entry to completion.
20
22
  *
@@ -96,6 +98,33 @@ export async function execute(workflow, input, options) {
96
98
  input,
97
99
  ...Object.fromEntries([...results.entries()].map(([k, v]) => [k, v.data])),
98
100
  };
101
+ // Pre-condition gate: evaluate `requires` against the cross-node context
102
+ // BEFORE invoking the LLM. Failure either marks the node failed (on_fail
103
+ // default) or skipped (on_fail: "skip") and skips execution entirely.
104
+ const requiresError = evaluateRequires(node.requires, context);
105
+ if (requiresError) {
106
+ const onFail = node.requires?.on_fail ?? "fail";
107
+ const result = onFail === "skip"
108
+ ? {
109
+ status: "skipped",
110
+ data: { skipped_reason: requiresError.replace(/^requires failed:/, "requires not met:") },
111
+ toolCalls: [],
112
+ }
113
+ : {
114
+ status: "failed",
115
+ data: { error: requiresError },
116
+ toolCalls: [],
117
+ };
118
+ results.set(currentId, result);
119
+ trace.steps.push({ node: currentId, status: result.status, iteration });
120
+ safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
121
+ logger.warn(` requires ${onFail === "skip" ? "skipped" : "failed"}: ${requiresError}`, { node: currentId });
122
+ // Apply normal routing rules (dry run gate + resolveNext).
123
+ // TODO: dedupe with requires path — see advanceFromNode helper below
124
+ const next = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace);
125
+ currentId = next;
126
+ continue;
127
+ }
99
128
  // Wrap tool handlers to emit events + inject context
100
129
  const trackedTools = tools.map((t) => ({
101
130
  ...t,
@@ -111,56 +140,59 @@ export async function execute(workflow, input, options) {
111
140
  const effectiveRules = assembleCascaded("rules", currentId, workflow.nodes[currentId], inputRules.length, (workflow.rules ?? []).length, resolvedSources);
112
141
  const effectiveContext = assembleCascaded("context", currentId, workflow.nodes[currentId], inputContext.length, (workflow.context ?? []).length, resolvedSources);
113
142
  const instruction = buildNodeInstruction(resolvedInstruction, effectiveRules, effectiveContext, input, skillInstructions);
114
- // Run Claude on this node
115
- const result = await claude.run({
116
- instruction,
117
- context,
118
- tools: trackedTools,
119
- outputSchema: node.output,
120
- maxTurns: node.max_turns,
121
- onProgress: (message) => {
122
- safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
123
- },
124
- });
125
- // Machine-checked post-condition. Enforced AFTER the LLM finishes so that
126
- // a model claiming success cannot bypass side-effect requirements (e.g.
127
- // reporting "issue created" when no `linear_create_issue` call was made).
128
- // Skipped entirely when the node already failed — the node's own error is
129
- // the root cause and verify would just add noise.
130
- if (result.status === "success") {
143
+ // Run Claude on this node, with optional verify-failure retry loop.
144
+ let attempt = 0;
145
+ let result;
146
+ let currentInstruction = instruction;
147
+ const retry = node.retry;
148
+ while (true) {
149
+ result = await claude.run({
150
+ instruction: currentInstruction,
151
+ context,
152
+ tools: trackedTools,
153
+ outputSchema: node.output,
154
+ maxTurns: node.max_turns,
155
+ onProgress: (message) => {
156
+ safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
157
+ },
158
+ });
159
+ // Retry only triggers on verify failure — bail on tool/API errors.
160
+ if (result.status !== "success")
161
+ break;
131
162
  const verifyError = evaluateVerify(node.verify, result);
132
- if (verifyError) {
133
- result.status = "failed";
134
- result.data = { ...result.data, error: verifyError };
163
+ if (!verifyError)
164
+ break;
165
+ // Apply verify failure to the result.
166
+ result.status = "failed";
167
+ result.data = { ...result.data, error: verifyError };
168
+ if (!retry || attempt >= retry.max) {
135
169
  logger.warn(` verify failed: ${verifyError}`, { node: currentId });
170
+ break;
136
171
  }
172
+ // Build retry preamble + record attempt in trace.
173
+ trace.steps.push({ node: currentId, status: "failed", iteration, retryAttempt: attempt });
174
+ logger.warn(` verify failed (attempt ${attempt + 1}/${retry.max + 1}): ${verifyError}`, { node: currentId });
175
+ const preamble = await buildRetryPreamble({
176
+ retry,
177
+ verifyError,
178
+ toolCalls: result.toolCalls,
179
+ nodeInstruction: resolvedInstruction,
180
+ claude,
181
+ logger,
182
+ context,
183
+ });
184
+ currentInstruction = `${preamble}\n\n---\n\n${instruction}`;
185
+ safeObserve(observer, { type: "node:retry", node: currentId, attempt: attempt + 1, reason: verifyError, preamble }, logger);
186
+ attempt++;
137
187
  }
138
188
  results.set(currentId, result);
139
- trace.steps.push({ node: currentId, status: result.status, iteration });
189
+ trace.steps.push(attempt > 0
190
+ ? { node: currentId, status: result.status, iteration, retryAttempt: attempt }
191
+ : { node: currentId, status: result.status, iteration });
140
192
  safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
141
193
  logger.info(` ✓ ${result.status}`, { node: currentId, toolCalls: result.toolCalls.length });
142
- // Dry run hard gate — stop at the first conditional routing decision.
143
- // Unconditional edges are analysis flow (prepare→gather→investigate);
144
- // conditional edges are action decisions (investigate→create_issue/skip).
145
- // Enforced in the executor so it cannot be bypassed by LLM evaluation.
146
- const isDryRun = input && typeof input === "object" && input.dryRun === true;
147
- if (isDryRun) {
148
- const outEdges = workflow.edges.filter((e) => e.from === currentId);
149
- if (outEdges.some((e) => e.when)) {
150
- safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "dry run" }, logger);
151
- currentId = null;
152
- continue;
153
- }
154
- }
155
- // Resolve next node via edge conditions
156
- const prevId = currentId;
157
- currentId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger);
158
- // Record the routing decision in the trace
159
- if (currentId) {
160
- const edgeKey = `${prevId}→${currentId}`;
161
- const reason = workflow.edges.find((e) => e.from === prevId && e.to === currentId)?.when ?? "only path";
162
- trace.edges.push({ from: prevId, to: currentId, reason });
163
- }
194
+ // Dry run gate + routing — shared with requires path via advanceFromNode helper.
195
+ currentId = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace);
164
196
  }
165
197
  safeObserve(observer, {
166
198
  type: "workflow:end",
@@ -341,6 +373,35 @@ function resolveConfig(skills, overrides) {
341
373
  }
342
374
  return config;
343
375
  }
376
+ /**
377
+ * Apply the dry-run gate and then resolve the next node via edge conditions.
378
+ *
379
+ * Used in both the normal execution path and the requires-failure path so
380
+ * the dry-run guard + resolveNext + trace-edge recording logic lives in one place.
381
+ *
382
+ * Returns the next node ID, or null when execution should stop.
383
+ */
384
+ async function advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace) {
385
+ // Dry run hard gate — stop at the first conditional routing decision.
386
+ // Unconditional edges are analysis flow (prepare→gather→investigate);
387
+ // conditional edges are action decisions (investigate→create_issue/skip).
388
+ // Enforced in the executor so it cannot be bypassed by LLM evaluation.
389
+ const isDryRun = input && typeof input === "object" && input.dryRun === true;
390
+ if (isDryRun) {
391
+ const outEdges = workflow.edges.filter((e) => e.from === currentId);
392
+ if (outEdges.some((e) => e.when)) {
393
+ safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "dry run" }, logger);
394
+ return null;
395
+ }
396
+ }
397
+ const prevId = currentId;
398
+ const nextId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger);
399
+ if (nextId) {
400
+ const reason = workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when ?? "only path";
401
+ trace.edges.push({ from: prevId, to: nextId, reason });
402
+ }
403
+ return nextId;
404
+ }
344
405
  /**
345
406
  * Resolve which edge to follow from the current node.
346
407
  *
@@ -353,6 +353,9 @@ describe("skill instruction injection", () => {
353
353
  async evaluate() {
354
354
  return "";
355
355
  },
356
+ async ask() {
357
+ return "";
358
+ },
356
359
  };
357
360
  await execute(workflow, {}, {
358
361
  skills: createSkillMap([instructionSkill]),
@@ -405,6 +408,9 @@ describe("skill instruction injection", () => {
405
408
  async evaluate() {
406
409
  return "";
407
410
  },
411
+ async ask() {
412
+ return "";
413
+ },
408
414
  };
409
415
  await execute(workflow, {}, {
410
416
  skills: createSkillMap([skillA, skillB]),
@@ -448,6 +454,9 @@ describe("skill instruction injection", () => {
448
454
  async evaluate() {
449
455
  return "";
450
456
  },
457
+ async ask() {
458
+ return "";
459
+ },
451
460
  };
452
461
  // Should NOT throw "none are configured"
453
462
  const { results } = await execute(workflow, {}, {
@@ -487,6 +496,9 @@ describe("skill instruction injection", () => {
487
496
  async evaluate() {
488
497
  return "";
489
498
  },
499
+ async ask() {
500
+ return "";
501
+ },
490
502
  };
491
503
  // No "my-rubric" in the skill map — executor must pull it from workflow.skills
492
504
  const { results } = await execute(workflow, {}, {
@@ -537,6 +549,9 @@ describe("skill instruction injection", () => {
537
549
  async evaluate() {
538
550
  return "";
539
551
  },
552
+ async ask() {
553
+ return "";
554
+ },
540
555
  };
541
556
  await execute(workflow, {}, {
542
557
  skills: createSkillMap([callerSkill]),
@@ -0,0 +1,7 @@
1
+ import type { NodeRequires } from "./types.js";
2
+ /**
3
+ * Evaluate a node's `requires` block against the cross-node context map.
4
+ * Returns null when all checks pass (or when `requires` is undefined),
5
+ * otherwise a single concatenated failure string.
6
+ */
7
+ export declare function evaluateRequires(requires: NodeRequires | undefined, context: Record<string, unknown>): string | null;
@@ -0,0 +1,34 @@
1
+ // ─── Requires: pre-condition gate ───────────────────────────────────
2
+ //
3
+ // Evaluates `node.requires` BEFORE the LLM runs. Same path grammar and
4
+ // resolver as verify — the only differences are the data root (cross-node
5
+ // context map instead of result.data) and the error prefix.
6
+ import { checkOutputRequired, checkOutputMatches } from "./verify.js";
7
+ /**
8
+ * Evaluate a node's `requires` block against the cross-node context map.
9
+ * Returns null when all checks pass (or when `requires` is undefined),
10
+ * otherwise a single concatenated failure string.
11
+ */
12
+ export function evaluateRequires(requires, context) {
13
+ if (!requires)
14
+ return null;
15
+ const hasRequiredChecks = requires.output_required && requires.output_required.length > 0;
16
+ const hasMatchChecks = requires.output_matches && requires.output_matches.length > 0;
17
+ if (!hasRequiredChecks && !hasMatchChecks) {
18
+ return `requires failed:\n - Requires block present but no checks declared`;
19
+ }
20
+ const failures = [];
21
+ if (hasRequiredChecks) {
22
+ const e = checkOutputRequired(requires.output_required, context);
23
+ if (e)
24
+ failures.push(e);
25
+ }
26
+ if (hasMatchChecks) {
27
+ const e = checkOutputMatches(requires.output_matches, context);
28
+ if (e)
29
+ failures.push(e);
30
+ }
31
+ if (failures.length === 0)
32
+ return null;
33
+ return `requires failed:\n - ${failures.join("\n - ")}`;
34
+ }
@@ -0,0 +1,23 @@
1
+ import type { Claude, NodeRetry, ToolCall, Logger } from "./types.js";
2
+ export interface BuildRetryPreambleOptions {
3
+ retry: NodeRetry;
4
+ verifyError: string;
5
+ toolCalls: ToolCall[];
6
+ nodeInstruction: string;
7
+ claude: Claude;
8
+ logger: Logger;
9
+ context: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * Build the retry preamble for a single retry attempt.
13
+ *
14
+ * Resolution order:
15
+ * 1. retry.instruction is a string → static preamble + verify error
16
+ * 2. retry.instruction is { auto: true } → claude.ask(default prompt)
17
+ * 3. retry.instruction is { reflect: "..." } → claude.ask(author prompt)
18
+ * 4. Otherwise (omitted) → default preamble
19
+ *
20
+ * Reflection failure (throw or empty response) silently falls back to the
21
+ * default static preamble and logs a warning.
22
+ */
23
+ export declare function buildRetryPreamble(opts: BuildRetryPreambleOptions): Promise<string>;
package/dist/retry.js ADDED
@@ -0,0 +1,72 @@
1
+ // ─── Retry: preamble construction for verify-failure self-healing ──
2
+ //
3
+ // Builds the preamble that gets prepended to the node instruction on retry.
4
+ // In autonomous modes ({ auto: true } or { reflect: ... }), this calls
5
+ // claude.ask to generate a diagnosis. If reflection fails or returns empty,
6
+ // falls back to the default static preamble — reflection failure never
7
+ // escalates to a workflow failure.
8
+ import { isErrorOutput } from "./verify.js";
9
+ const DEFAULT_REFLECTION_PROMPT = "Briefly diagnose the failure and state your strategy for the retry. Keep your response to 2-4 sentences.";
10
+ const DEFAULT_PREAMBLE_HEADER = "## Previous attempt failed verification";
11
+ /**
12
+ * Build the retry preamble for a single retry attempt.
13
+ *
14
+ * Resolution order:
15
+ * 1. retry.instruction is a string → static preamble + verify error
16
+ * 2. retry.instruction is { auto: true } → claude.ask(default prompt)
17
+ * 3. retry.instruction is { reflect: "..." } → claude.ask(author prompt)
18
+ * 4. Otherwise (omitted) → default preamble
19
+ *
20
+ * Reflection failure (throw or empty response) silently falls back to the
21
+ * default static preamble and logs a warning.
22
+ */
23
+ export async function buildRetryPreamble(opts) {
24
+ const { retry, verifyError, toolCalls, nodeInstruction, claude, logger, context } = opts;
25
+ const inst = retry.instruction;
26
+ if (typeof inst === "string") {
27
+ return `## Retry guidance\n\n${inst}\n\n${DEFAULT_PREAMBLE_HEADER}\n\n${verifyError}`;
28
+ }
29
+ if (inst && typeof inst === "object") {
30
+ const reflectPrompt = "reflect" in inst && typeof inst.reflect === "string" ? inst.reflect : DEFAULT_REFLECTION_PROMPT;
31
+ const askInstruction = buildReflectionPrompt(reflectPrompt, nodeInstruction, verifyError, toolCalls);
32
+ try {
33
+ const diagnosis = await claude.ask({ instruction: askInstruction, context });
34
+ const trimmed = diagnosis.trim();
35
+ if (trimmed.length > 0) {
36
+ return `## Reflection on previous attempt\n\n${trimmed}\n\n${DEFAULT_PREAMBLE_HEADER}\n\n${verifyError}`;
37
+ }
38
+ logger.warn("Retry reflection returned empty response; falling back to default preamble.");
39
+ }
40
+ catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ logger.warn(`Retry reflection threw: ${msg}; falling back to default preamble.`);
43
+ }
44
+ }
45
+ return defaultPreamble(verifyError);
46
+ }
47
+ function defaultPreamble(verifyError) {
48
+ return `${DEFAULT_PREAMBLE_HEADER}\n\n${verifyError}\n\nFix the issue and try again.`;
49
+ }
50
+ function buildReflectionPrompt(reflectPrompt, nodeInstruction, verifyError, toolCalls) {
51
+ const summary = summarizeToolCalls(toolCalls);
52
+ return [
53
+ `You attempted to: ${nodeInstruction}`,
54
+ "",
55
+ `Verification failed with: ${verifyError}`,
56
+ "",
57
+ `You called these tools during the failed attempt:`,
58
+ summary,
59
+ "",
60
+ reflectPrompt,
61
+ ].join("\n");
62
+ }
63
+ function summarizeToolCalls(toolCalls) {
64
+ if (toolCalls.length === 0)
65
+ return "(no tools were called)";
66
+ return toolCalls
67
+ .map((c) => {
68
+ const status = isErrorOutput(c.output) ? "error" : "ok";
69
+ return ` - ${c.tool} (${status})`;
70
+ })
71
+ .join("\n");
72
+ }