@kortyx/hooks 0.23.1 → 0.25.0

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/index.js CHANGED
@@ -19,10 +19,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/index.ts
20
20
  var index_exports = {};
21
21
  __export(index_exports, {
22
+ ParallelError: () => ParallelError,
22
23
  WorkflowCallError: () => WorkflowCallError,
23
24
  completeResponse: () => completeResponse,
24
25
  createWorkflowHooks: () => createWorkflowHooks,
25
26
  emitWorkflowCall: () => emitWorkflowCall,
27
+ parallel: () => parallel,
26
28
  runWithHookContext: () => runWithHookContext,
27
29
  useAbortSignal: () => useAbortSignal,
28
30
  useInterrupt: () => useInterrupt,
@@ -90,6 +92,11 @@ var createInternalContext = (ctx) => {
90
92
  ...ctx,
91
93
  workflowCallActive: false,
92
94
  workflowCallIds: /* @__PURE__ */ new Set(),
95
+ workflowTasks: [],
96
+ parallelJoins: [],
97
+ parallelGroup: void 0,
98
+ parallelIndex: 0,
99
+ workflowContextClosed: false,
93
100
  nodeStateIndex: 0,
94
101
  reasonCallIndex: 0,
95
102
  currentNodeState,
@@ -161,10 +168,29 @@ function getReasonTraceAdapter() {
161
168
  async function runWithHookContext(ctx, fn) {
162
169
  const internal = createInternalContext(ctx);
163
170
  try {
164
- const result = await storage.run(internal, fn);
171
+ const result = await storage.run(internal, async () => {
172
+ const drain = async () => {
173
+ let drained = -1;
174
+ while (drained !== internal.parallelJoins.length + internal.workflowTasks.length) {
175
+ drained = internal.parallelJoins.length + internal.workflowTasks.length;
176
+ await Promise.allSettled(internal.parallelJoins);
177
+ await Promise.all(internal.workflowTasks.map((task) => task.done));
178
+ }
179
+ internal.workflowContextClosed = true;
180
+ };
181
+ try {
182
+ const result2 = await fn();
183
+ await drain();
184
+ if (internal.workflowControlError) throw internal.workflowControlError;
185
+ return result2;
186
+ } catch (error) {
187
+ await drain();
188
+ throw internal.workflowControlError ?? error;
189
+ }
190
+ });
165
191
  cleanupCompletedReasonCheckpoints(internal);
166
192
  for (const key of Object.keys(internal.currentNodeState.byKey)) {
167
- if (key.startsWith("__useWorkflow:") || key === "__useWorkflowActivation") {
193
+ if (key.startsWith("__useWorkflow:") || key.startsWith("__parallel:") || key === "__useWorkflowActivation") {
168
194
  internal.currentNodeState.byKey[key] = null;
169
195
  internal.stateDirty = true;
170
196
  }
@@ -263,13 +289,18 @@ var resolveHookStatePatch = (args) => ({
263
289
  });
264
290
 
265
291
  // src/validation.ts
292
+ var import_errors = require("@kortyx/core/errors");
266
293
  var parseWithSchema = (schema, value, label) => {
267
294
  if (!schema) return value;
268
295
  const parsed = schema.safeParse(value);
269
296
  if (parsed.success) return parsed.data;
270
297
  const parsedError = "error" in parsed && parsed.error ? parsed.error : void 0;
271
298
  const reason = typeof parsedError?.message === "string" && parsedError.message.length > 0 ? parsedError.message : "invalid payload";
272
- throw new Error(`${label} validation failed: ${reason}`);
299
+ throw new import_errors.ValidationError(
300
+ label === "useReason output" ? "MODEL_OUTPUT_SCHEMA" : "SCHEMA_VALIDATION",
301
+ `${label} validation failed: ${reason}`,
302
+ parsedError
303
+ );
273
304
  };
274
305
 
275
306
  // src/interrupt.ts
@@ -411,6 +442,21 @@ var import_core3 = require("@kortyx/core");
411
442
  // src/reason-engine.ts
412
443
  var import_node_crypto2 = require("crypto");
413
444
  var import_core2 = require("@kortyx/core");
445
+ var import_errors2 = require("@kortyx/core/errors");
446
+ function assertModelFinish(result) {
447
+ if (result.finishReason?.unified !== "content-filter") return;
448
+ throw Object.assign(
449
+ new import_errors2.KortyxError("PROVIDER_REFUSAL", "The provider refused the request.", {
450
+ category: "provider",
451
+ retryable: false,
452
+ safeMessage: "The provider refused the request."
453
+ }),
454
+ {
455
+ ...result.usage ? { usage: result.usage } : {},
456
+ finishReason: result.finishReason
457
+ }
458
+ );
459
+ }
414
460
  var toError = (value) => value instanceof Error ? value : new Error(String(value));
415
461
  var createRuntimeId = () => {
416
462
  try {
@@ -524,15 +570,18 @@ async function runReasonEngine(args) {
524
570
  };
525
571
  const failTrace = (span, error) => {
526
572
  const detail = error;
527
- span?.fail?.(error, {
528
- ...detail?.usage ? { usage: detail.usage } : {},
529
- ...detail?.finishReason ? { finishReason: detail.finishReason } : {},
530
- ...detail?.providerMetadata ? { providerMetadata: detail.providerMetadata } : {},
531
- attributes: {
532
- providerId: args.model.provider.id,
533
- modelId: args.model.modelId
534
- }
535
- });
573
+ try {
574
+ span?.fail?.(error, {
575
+ ...detail?.usage ? { usage: detail.usage } : {},
576
+ ...detail?.finishReason ? { finishReason: detail.finishReason } : {},
577
+ ...detail?.providerMetadata ? { providerMetadata: detail.providerMetadata } : {},
578
+ attributes: {
579
+ providerId: args.model.provider.id,
580
+ modelId: args.model.modelId
581
+ }
582
+ });
583
+ } catch {
584
+ }
536
585
  };
537
586
  try {
538
587
  if (stream) {
@@ -617,6 +666,8 @@ async function runReasonEngine(args) {
617
666
  }
618
667
  }
619
668
  }
669
+ (0, import_core2.throwIfExecutionAborted)(abortSignal);
670
+ assertModelFinish({ finishReason, usage });
620
671
  if (emit) {
621
672
  emitNodeEvent(args.emitEvent, args.nodeId, "text-end", {
622
673
  ...commonMeta
@@ -638,6 +689,7 @@ async function runReasonEngine(args) {
638
689
  }
639
690
  const response = await model.invoke(messages);
640
691
  (0, import_core2.throwIfExecutionAborted)(abortSignal);
692
+ assertModelFinish(response);
641
693
  if (response.content) args.onTextChunk?.(response.content);
642
694
  if (emit) {
643
695
  emitNodeEvent(args.emitEvent, args.nodeId, "text-start", {
@@ -1437,6 +1489,7 @@ function createStructuredOutputStreamer(args, id, opId) {
1437
1489
  }
1438
1490
 
1439
1491
  // src/reason/parsing.ts
1492
+ var import_errors3 = require("@kortyx/core/errors");
1440
1493
  var tryJsonParse = (value) => {
1441
1494
  try {
1442
1495
  return { ok: true, value: JSON.parse(value) };
@@ -1474,132 +1527,135 @@ var resolveOutputCandidate = (text) => {
1474
1527
  }
1475
1528
  return text;
1476
1529
  };
1477
- var looksLikeStructuredJsonAttempt = (value) => {
1478
- const trimmed = value.trim();
1479
- if (!trimmed) return false;
1480
- return trimmed.startsWith("{") || trimmed.startsWith("[") || /^```(?:json)?/i.test(trimmed);
1481
- };
1482
- var isLikelyTruncatedStructuredOutput = (text) => {
1483
- const trimmed = text.trim();
1484
- if (!looksLikeStructuredJsonAttempt(trimmed)) return false;
1485
- const direct = tryJsonParse(trimmed);
1486
- if (!direct.ok && direct.error) return true;
1487
- const block = extractJsonCodeBlock(trimmed);
1488
- if (!block) return false;
1489
- const parsedBlock = tryJsonParse(block);
1490
- return !parsedBlock.ok && Boolean(parsedBlock.error);
1491
- };
1492
- var createTruncatedStructuredOutputError = (label) => new Error(
1493
- `${label} was truncated before producing valid structured output. The model stopped due to output length. Increase maxOutputTokens or simplify the requested output.`
1530
+ var createTruncatedStructuredOutputError = (label, cause) => new import_errors3.ValidationError(
1531
+ "OUTPUT_TRUNCATED",
1532
+ `${label} was truncated before producing valid structured output. The model stopped due to output length. Increase maxOutputTokens or simplify the requested output.`,
1533
+ cause
1494
1534
  );
1495
- var createInvalidStructuredOutputError = (label) => new Error(
1496
- `${label} did not produce valid structured output. The model returned text instead of the expected JSON payload. This often happens when output is truncated or the provider does not follow JSON mode. Increase maxOutputTokens or simplify the requested output.`
1535
+ var createInvalidStructuredOutputError = (label, cause) => new import_errors3.ValidationError(
1536
+ "INVALID_MODEL_JSON",
1537
+ `${label} did not produce valid structured output. The model returned text instead of the expected JSON payload. Check outputSchema and provider structured-output settings.`,
1538
+ cause
1497
1539
  );
1498
1540
  var parseReasonOutputWithSchema = (args) => {
1499
1541
  const candidate = resolveOutputCandidate(args.text);
1542
+ const block = extractJsonCodeBlock(args.text.trim());
1543
+ const validJson = tryJsonParse(args.text.trim()).ok || block !== null && tryJsonParse(block).ok;
1500
1544
  try {
1501
1545
  return parseWithSchema(args.schema, candidate, args.label);
1502
1546
  } catch (error) {
1503
- if (args.finishReason?.unified === "length" || isLikelyTruncatedStructuredOutput(args.text)) {
1504
- throw createTruncatedStructuredOutputError(args.label);
1505
- }
1506
- if (typeof candidate === "string" && candidate.trim().length > 0) {
1507
- throw createInvalidStructuredOutputError(args.label);
1508
- }
1509
- throw error;
1547
+ if (!(error instanceof import_errors3.ValidationError)) throw error;
1548
+ const failure = args.finishReason?.unified === "length" ? createTruncatedStructuredOutputError(args.label, error) : typeof candidate === "string" && !validJson ? createInvalidStructuredOutputError(args.label, error) : error;
1549
+ if (failure && typeof failure === "object")
1550
+ Object.assign(failure, {
1551
+ ...args.usage ? { usage: args.usage } : {},
1552
+ ...args.finishReason ? { finishReason: args.finishReason } : {}
1553
+ });
1554
+ throw failure;
1510
1555
  }
1511
1556
  };
1512
1557
  var parseInterruptFirstPassResult = (args) => {
1513
- const candidate = resolveOutputCandidate(args.text);
1514
- if (!isRecord2(candidate)) {
1515
- if (args.finishReason?.unified === "length" || isLikelyTruncatedStructuredOutput(args.text)) {
1516
- throw createTruncatedStructuredOutputError("useReason output");
1517
- }
1518
- if (typeof candidate === "string" && candidate.trim().length > 0) {
1519
- throw createInvalidStructuredOutputError("useReason output");
1520
- }
1521
- throw new Error(
1522
- "useReason first pass with interrupt must return a JSON object."
1523
- );
1524
- }
1525
- const mode = args.mode ?? "required";
1526
- let interruptRequired = true;
1527
- if (mode === "optional") {
1528
- if (candidate.decision !== "continue" && candidate.decision !== "interrupt") {
1529
- throw new Error(
1530
- 'useReason optional interrupt first pass must include decision "continue" or "interrupt".'
1558
+ try {
1559
+ const candidate = resolveOutputCandidate(args.text);
1560
+ if (!isRecord2(candidate)) {
1561
+ if (args.finishReason?.unified === "length") {
1562
+ throw createTruncatedStructuredOutputError("useReason output");
1563
+ }
1564
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
1565
+ throw createInvalidStructuredOutputError("useReason output");
1566
+ }
1567
+ throw new import_errors3.ValidationError(
1568
+ "MODEL_OUTPUT_SCHEMA",
1569
+ "useReason first pass with interrupt must return a JSON object."
1531
1570
  );
1532
1571
  }
1533
- interruptRequired = candidate.decision === "interrupt";
1534
- if (!interruptRequired) {
1535
- const hasUnexpectedRequest = [
1572
+ const mode = args.mode ?? "required";
1573
+ let interruptRequired = true;
1574
+ if (mode === "optional") {
1575
+ if (candidate.decision !== "continue" && candidate.decision !== "interrupt") {
1576
+ throw new import_errors3.ValidationError(
1577
+ "MODEL_OUTPUT_SCHEMA",
1578
+ 'useReason optional interrupt first pass must include decision "continue" or "interrupt".'
1579
+ );
1580
+ }
1581
+ interruptRequired = candidate.decision === "interrupt";
1582
+ if (!interruptRequired) {
1583
+ const hasUnexpectedRequest = [
1584
+ candidate.interruptRequest,
1585
+ candidate.request,
1586
+ candidate.interrupt
1587
+ ].some((value) => value !== null && value !== void 0);
1588
+ if (hasUnexpectedRequest) {
1589
+ throw new import_errors3.ValidationError(
1590
+ "MODEL_OUTPUT_SCHEMA",
1591
+ 'useReason optional interrupt first pass with decision "continue" must not include an interrupt request.'
1592
+ );
1593
+ }
1594
+ }
1595
+ }
1596
+ let request;
1597
+ if (interruptRequired) {
1598
+ const requestCandidates = [
1536
1599
  candidate.interruptRequest,
1537
1600
  candidate.request,
1538
- candidate.interrupt
1539
- ].some((value) => value !== null && value !== void 0);
1540
- if (hasUnexpectedRequest) {
1541
- throw new Error(
1542
- 'useReason optional interrupt first pass with decision "continue" must not include an interrupt request.'
1601
+ candidate.interrupt,
1602
+ mode === "required" ? candidate : void 0
1603
+ ];
1604
+ const requestPayload = requestCandidates.find(
1605
+ (value) => value !== null && value !== void 0
1606
+ );
1607
+ request = parseWithSchema(
1608
+ args.requestSchema,
1609
+ requestPayload,
1610
+ "useReason interrupt.request"
1611
+ );
1612
+ }
1613
+ let output;
1614
+ if (args.outputSchema) {
1615
+ const outputPayload = Object.hasOwn(candidate, "output") ? candidate.output : candidate;
1616
+ try {
1617
+ output = parseWithSchema(
1618
+ args.outputSchema,
1619
+ outputPayload,
1620
+ "useReason output"
1543
1621
  );
1622
+ } catch (error) {
1623
+ if (!(error instanceof import_errors3.ValidationError)) throw error;
1624
+ if (args.finishReason?.unified === "length") {
1625
+ throw createTruncatedStructuredOutputError("useReason output", error);
1626
+ }
1627
+ throw error;
1544
1628
  }
1545
1629
  }
1546
- }
1547
- let request;
1548
- if (interruptRequired) {
1549
- const requestCandidates = [
1550
- candidate.interruptRequest,
1551
- candidate.request,
1552
- candidate.interrupt,
1553
- mode === "required" ? candidate : void 0
1554
- ];
1555
- const requestPayload = requestCandidates.find(
1556
- (value) => value !== null && value !== void 0
1557
- );
1558
- request = parseWithSchema(
1559
- args.requestSchema,
1560
- requestPayload,
1561
- "useReason interrupt.request"
1562
- );
1563
- }
1564
- let output;
1565
- if (args.outputSchema) {
1566
- const outputPayload = Object.hasOwn(candidate, "output") ? candidate.output : candidate;
1567
- try {
1568
- output = parseWithSchema(
1569
- args.outputSchema,
1570
- outputPayload,
1571
- "useReason output"
1630
+ const explicitDraft = typeof candidate.draftText === "string" && candidate.draftText.length > 0 ? candidate.draftText : typeof candidate.text === "string" && candidate.text.length > 0 ? candidate.text : void 0;
1631
+ if (!args.outputSchema && !explicitDraft) {
1632
+ throw new import_errors3.ValidationError(
1633
+ "MODEL_OUTPUT_SCHEMA",
1634
+ "useReason first pass with interrupt requires `draftText` when outputSchema is not provided."
1572
1635
  );
1573
- } catch (error) {
1574
- if (args.finishReason?.unified === "length" || isLikelyTruncatedStructuredOutput(args.text)) {
1575
- throw createTruncatedStructuredOutputError("useReason output");
1576
- }
1577
- if (typeof outputPayload === "string" && outputPayload.trim().length > 0) {
1578
- throw createInvalidStructuredOutputError("useReason output");
1579
- }
1580
- throw error;
1581
1636
  }
1582
- }
1583
- const explicitDraft = typeof candidate.draftText === "string" && candidate.draftText.length > 0 ? candidate.draftText : typeof candidate.text === "string" && candidate.text.length > 0 ? candidate.text : void 0;
1584
- if (!args.outputSchema && !explicitDraft) {
1585
- throw new Error(
1586
- "useReason first pass with interrupt requires `draftText` when outputSchema is not provided."
1587
- );
1588
- }
1589
- const draftText = explicitDraft ?? (output !== void 0 ? tryStringify(output) : String(args.text ?? ""));
1590
- if (!interruptRequired) {
1637
+ const draftText = explicitDraft ?? (output !== void 0 ? tryStringify(output) : String(args.text ?? ""));
1638
+ if (!interruptRequired) {
1639
+ return {
1640
+ draftText,
1641
+ interruptRequired: false,
1642
+ ...output !== void 0 ? { output } : {}
1643
+ };
1644
+ }
1591
1645
  return {
1592
1646
  draftText,
1593
- interruptRequired: false,
1647
+ interruptRequired: true,
1648
+ request,
1594
1649
  ...output !== void 0 ? { output } : {}
1595
1650
  };
1651
+ } catch (error) {
1652
+ if (error && typeof error === "object")
1653
+ Object.assign(error, {
1654
+ ...args.usage ? { usage: args.usage } : {},
1655
+ ...args.finishReason ? { finishReason: args.finishReason } : {}
1656
+ });
1657
+ throw error;
1596
1658
  }
1597
- return {
1598
- draftText,
1599
- interruptRequired: true,
1600
- request,
1601
- ...output !== void 0 ? { output } : {}
1602
- };
1603
1659
  };
1604
1660
 
1605
1661
  // src/reason/prompting.ts
@@ -1813,7 +1869,7 @@ var emitReasonStructuredOutput = (args) => {
1813
1869
 
1814
1870
  // src/reason/tools.ts
1815
1871
  var import_core4 = require("@kortyx/core");
1816
- var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
1872
+ var import_errors4 = require("@kortyx/core/errors");
1817
1873
  var toToolDefinitions = (tools) => tools.map((tool) => ({
1818
1874
  name: tool.name,
1819
1875
  ...tool.title ? { title: tool.title } : {},
@@ -1847,17 +1903,27 @@ var normalizeToolResult = (toolCall, value) => {
1847
1903
  ...value !== void 0 && value !== null && typeof value === "object" && !Array.isArray(value) ? { structuredContent: value } : {}
1848
1904
  };
1849
1905
  };
1850
- var closeOwnedTools = async (tools) => {
1906
+ var closeOwnedTools = async (tools, preserveFailure = false, traceSpan) => {
1851
1907
  const closeFns = /* @__PURE__ */ new Set();
1852
1908
  for (const tool of tools) {
1853
1909
  if (tool.closeAfterUse === false || !tool.close) continue;
1854
1910
  closeFns.add(tool.close);
1855
1911
  }
1856
- await Promise.all(
1857
- [...closeFns].map(async (close) => {
1858
- await close();
1859
- })
1860
- );
1912
+ try {
1913
+ await Promise.all(
1914
+ [...closeFns].map(async (close) => {
1915
+ await close();
1916
+ })
1917
+ );
1918
+ } catch (error) {
1919
+ if (!preserveFailure) throw error;
1920
+ try {
1921
+ traceSpan?.addEvent?.("useReason.cleanup.failed", {
1922
+ failure: (0, import_errors4.serializeFailure)(error)
1923
+ });
1924
+ } catch {
1925
+ }
1926
+ }
1861
1927
  };
1862
1928
  var createInitialMessages = (args) => [
1863
1929
  ...typeof args.system === "string" && args.system.length > 0 ? [{ role: "system", content: args.system }] : [],
@@ -1874,6 +1940,7 @@ var runReasonToolLoop = async (args) => {
1874
1940
  const saved = ctx.currentNodeState.byKey[checkpointKey];
1875
1941
  const checkpoint = saved?.status === "tool_loop" ? saved : void 0;
1876
1942
  const opId = checkpoint?.opId ?? args.opId;
1943
+ let primaryFailure = false;
1877
1944
  const tools = useReasonArgs.tools ?? [];
1878
1945
  const toolByName = /* @__PURE__ */ new Map();
1879
1946
  let validationCompleted = false;
@@ -1906,7 +1973,7 @@ var runReasonToolLoop = async (args) => {
1906
1973
  validationCompleted = true;
1907
1974
  } finally {
1908
1975
  if (!validationCompleted) {
1909
- await closeOwnedTools(tools);
1976
+ await closeOwnedTools(tools, true, traceSpan);
1910
1977
  }
1911
1978
  }
1912
1979
  const toolDefinitions = toToolDefinitions(tools);
@@ -2025,6 +2092,7 @@ var runReasonToolLoop = async (args) => {
2025
2092
  text: step.text,
2026
2093
  schema: useReasonArgs.outputSchema,
2027
2094
  ...step.finishReason ? { finishReason: step.finishReason } : {},
2095
+ ...aggregatedUsage ? { usage: aggregatedUsage } : {},
2028
2096
  label: "useReason output"
2029
2097
  });
2030
2098
  aggregatedWarnings = mergeWarnings(aggregatedWarnings, [
@@ -2112,12 +2180,12 @@ var runReasonToolLoop = async (args) => {
2112
2180
  options: [
2113
2181
  { id: "approve", label: "Approve" },
2114
2182
  { id: "deny", label: "Deny" }
2115
- ],
2116
- meta: {
2117
- tool: tool.name,
2118
- toolCallId: toolCall.id,
2119
- input: toolCall.input
2120
- }
2183
+ ]
2184
+ },
2185
+ meta: {
2186
+ tool: tool.name,
2187
+ toolCallId: toolCall.id,
2188
+ input: toolCall.input
2121
2189
  }
2122
2190
  });
2123
2191
  const approved = Array.isArray(approval) ? approval.includes("approve") : approval === "approve";
@@ -2179,12 +2247,12 @@ var runReasonToolLoop = async (args) => {
2179
2247
  });
2180
2248
  } catch (error) {
2181
2249
  (0, import_core4.throwIfExecutionAborted)(abortSignal);
2182
- if ((0, import_core4.isExecutionLimitReached)(error) || (0, import_core4.isExecutionCancelled)(error))
2183
- throw error;
2250
+ if ((0, import_errors4.isControlFlowError)(error)) throw error;
2184
2251
  const result2 = {
2185
2252
  toolCallId: toolCall.id,
2186
2253
  name: tool.name,
2187
- content: toErrorMessage(error),
2254
+ content: (0, import_errors4.serializeFailure)(error).message,
2255
+ failure: (0, import_errors4.serializeFailure)(error),
2188
2256
  isError: true
2189
2257
  };
2190
2258
  toolResults.push(result2);
@@ -2193,14 +2261,16 @@ var runReasonToolLoop = async (args) => {
2193
2261
  emitToolEvent("tool-call-error", {
2194
2262
  tool: tool.name,
2195
2263
  toolCallId: toolCall.id,
2196
- message: result2.content
2264
+ message: result2.content,
2265
+ failure: result2.failure
2197
2266
  });
2198
2267
  }
2199
2268
  traceSpan?.addEvent?.("useReason.tool-call.error", {
2200
2269
  stepIndex,
2201
2270
  tool: tool.name,
2202
2271
  toolCallId: toolCall.id,
2203
- message: result2.content
2272
+ message: result2.content,
2273
+ failure: result2.failure
2204
2274
  });
2205
2275
  }
2206
2276
  const completed2 = toolResults.at(-1);
@@ -2227,6 +2297,7 @@ var runReasonToolLoop = async (args) => {
2227
2297
  text: finalText,
2228
2298
  schema: useReasonArgs.outputSchema,
2229
2299
  ...finalFinishReason ? { finishReason: finalFinishReason } : {},
2300
+ ...aggregatedUsage ? { usage: aggregatedUsage } : {},
2230
2301
  label: "useReason output"
2231
2302
  });
2232
2303
  }
@@ -2273,15 +2344,19 @@ var runReasonToolLoop = async (args) => {
2273
2344
  ctx.stateDirty = true;
2274
2345
  return result;
2275
2346
  } catch (error) {
2276
- traceSpan?.fail?.(error, {
2277
- attributes: {
2278
- toolStepCount: steps.length,
2279
- toolCallCount: allToolCalls.length
2280
- }
2281
- });
2347
+ primaryFailure = true;
2348
+ try {
2349
+ traceSpan?.fail?.(error, {
2350
+ attributes: {
2351
+ toolStepCount: steps.length,
2352
+ toolCallCount: allToolCalls.length
2353
+ }
2354
+ });
2355
+ } catch {
2356
+ }
2282
2357
  throw error;
2283
2358
  } finally {
2284
- await closeOwnedTools(tools);
2359
+ await closeOwnedTools(tools, primaryFailure, traceSpan);
2285
2360
  }
2286
2361
  };
2287
2362
 
@@ -2446,6 +2521,7 @@ async function useReason(args) {
2446
2521
  text: first.text,
2447
2522
  requestSchema: args.interrupt.requestSchema,
2448
2523
  ...first.finishReason ? { finishReason: first.finishReason } : {},
2524
+ ...first.usage ? { usage: first.usage } : {},
2449
2525
  ...args.outputSchema ? { outputSchema: args.outputSchema } : {},
2450
2526
  mode: args.interrupt.mode ?? "required"
2451
2527
  });
@@ -2463,6 +2539,7 @@ async function useReason(args) {
2463
2539
  text: first.text,
2464
2540
  schema: args.outputSchema,
2465
2541
  ...first.finishReason ? { finishReason: first.finishReason } : {},
2542
+ ...first.usage ? { usage: first.usage } : {},
2466
2543
  label: "useReason output"
2467
2544
  });
2468
2545
  }
@@ -2557,6 +2634,7 @@ async function useReason(args) {
2557
2634
  text: finalText,
2558
2635
  schema: args.outputSchema,
2559
2636
  ...second.finishReason ? { finishReason: second.finishReason } : {},
2637
+ ...aggregatedUsage ? { usage: aggregatedUsage } : {},
2560
2638
  label: "useReason output"
2561
2639
  });
2562
2640
  }
@@ -2662,9 +2740,179 @@ function useWorkflowState(key, ...rest) {
2662
2740
  return [getValue(), setValue];
2663
2741
  }
2664
2742
 
2743
+ // src/parallel.ts
2744
+ var import_core6 = require("@kortyx/core");
2745
+ var import_errors5 = require("@kortyx/core/errors");
2746
+ var ParallelChildWaiting = class extends Error {
2747
+ name = "ParallelChildWaiting";
2748
+ };
2749
+ var ParallelError = class extends AggregateError {
2750
+ constructor(results) {
2751
+ super(
2752
+ results.flatMap(
2753
+ (result) => result.status === "rejected" ? [result.reason] : []
2754
+ ),
2755
+ "One or more parallel operations failed."
2756
+ );
2757
+ this.results = results;
2758
+ this.name = "ParallelError";
2759
+ this.failure = (0, import_errors5.serializeFailure)({
2760
+ version: 1,
2761
+ code: this.code,
2762
+ category: "workflow",
2763
+ message: this.message,
2764
+ retryable: null,
2765
+ results: results.map(
2766
+ (result) => result.status === "fulfilled" ? { status: "fulfilled" } : { status: "rejected", failure: (0, import_errors5.serializeFailure)(result.reason) }
2767
+ )
2768
+ });
2769
+ }
2770
+ results;
2771
+ code = "PARALLEL_FAILED";
2772
+ failure;
2773
+ toJSON() {
2774
+ return (0, import_errors5.serializeFailure)(this);
2775
+ }
2776
+ };
2777
+ function registerWorkflowTask(id) {
2778
+ const ctx = getHookContext();
2779
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2780
+ if (ctx.workflowContextClosed)
2781
+ throw new Error("The workflow node activation has finished.");
2782
+ const group = ctx.parallelGroup;
2783
+ if (group?.restored && !group.record.calls.includes(id)) {
2784
+ throw new Error(`Parallel call membership changed during replay: '${id}'.`);
2785
+ }
2786
+ let finish;
2787
+ const task = {
2788
+ id,
2789
+ ...group ? { group } : {},
2790
+ completed: false,
2791
+ done: new Promise((resolve) => {
2792
+ finish = resolve;
2793
+ }),
2794
+ finish: () => {
2795
+ task.completed = true;
2796
+ finish();
2797
+ }
2798
+ };
2799
+ ctx.workflowTasks.push(task);
2800
+ if (group && !group.record.calls.includes(id)) group.record.calls.push(id);
2801
+ return task;
2802
+ }
2803
+ function parallel(values) {
2804
+ const settled = Promise.allSettled(values);
2805
+ let ctx;
2806
+ try {
2807
+ ctx = getHookContext();
2808
+ } catch (error) {
2809
+ return Promise.reject(error);
2810
+ }
2811
+ const join = async () => {
2812
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2813
+ if (ctx.parallelGroup)
2814
+ throw new Error(
2815
+ "Await each parallel group before starting another in the same node."
2816
+ );
2817
+ const key = `__parallel:${ctx.parallelIndex++}`;
2818
+ const saved = ctx.currentNodeState.byKey[key];
2819
+ const tasks = ctx.workflowTasks.filter(
2820
+ (task) => !task.completed && !task.group
2821
+ );
2822
+ const ids = tasks.map((task) => task.id);
2823
+ if (saved && (saved.version !== 1 || ids.some((id) => !saved.calls.includes(id)))) {
2824
+ throw new Error("Parallel call membership changed during replay.");
2825
+ }
2826
+ const group = {
2827
+ key,
2828
+ record: saved ?? { version: 1, calls: ids, interrupts: [] },
2829
+ restored: Boolean(saved),
2830
+ ready: Promise.resolve()
2831
+ };
2832
+ ctx.currentNodeState.byKey[key] = group.record;
2833
+ ctx.stateDirty = true;
2834
+ ctx.parallelGroup = group;
2835
+ for (const task of tasks) task.group = group;
2836
+ const callRecord = (id) => ctx.currentNodeState.byKey[`__useWorkflow:${id}`];
2837
+ const bridge = async (callId, index) => {
2838
+ const interruption = callRecord(callId)?.interrupts[index];
2839
+ if (!interruption)
2840
+ throw new Error(
2841
+ "Parallel interrupt history does not match the saved child."
2842
+ );
2843
+ try {
2844
+ interruption.response = await awaitInterruptInternal({
2845
+ request: interruption.request,
2846
+ ...interruption.request.meta ? { meta: interruption.request.meta } : {}
2847
+ });
2848
+ } catch (error) {
2849
+ ctx.workflowControlError = error;
2850
+ throw error;
2851
+ }
2852
+ };
2853
+ group.ready = (async () => {
2854
+ for (const entry of group.record.interrupts)
2855
+ await bridge(entry.callId, entry.index);
2856
+ })();
2857
+ try {
2858
+ const [ready, outcomes] = await Promise.all([
2859
+ Promise.allSettled([group.ready]),
2860
+ settled
2861
+ ]);
2862
+ let drained = -1;
2863
+ while (drained !== ctx.workflowTasks.length) {
2864
+ drained = ctx.workflowTasks.length;
2865
+ await Promise.all(
2866
+ ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.done)
2867
+ );
2868
+ }
2869
+ if (ready[0]?.status === "rejected") throw ready[0].reason;
2870
+ (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2871
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2872
+ const rejected = outcomes.filter(
2873
+ (outcome) => outcome.status === "rejected"
2874
+ );
2875
+ const control = rejected.find(
2876
+ (outcome) => (0, import_core6.isExecutionCancelled)(outcome.reason) || (0, import_core6.isExecutionLimitReached)(outcome.reason)
2877
+ );
2878
+ if (control) {
2879
+ ctx.workflowControlError = control.reason;
2880
+ throw control.reason;
2881
+ }
2882
+ const actual = ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.id);
2883
+ if (group.record.calls.length !== actual.length || group.record.calls.some((id) => !actual.includes(id))) {
2884
+ throw new Error("Parallel call membership changed during replay.");
2885
+ }
2886
+ for (const callId of group.record.calls) {
2887
+ const record = callRecord(callId);
2888
+ if (record?.status !== "interrupted") continue;
2889
+ const index = record.interrupts.length - 1;
2890
+ if (record.interrupts[index]?.response !== void 0) continue;
2891
+ group.record.interrupts.push({ callId, index });
2892
+ await bridge(callId, index);
2893
+ throw new Error(
2894
+ "Parallel interrupt unexpectedly resumed without replay."
2895
+ );
2896
+ }
2897
+ if (rejected.length) throw new ParallelError(outcomes);
2898
+ return outcomes.map(
2899
+ (outcome) => outcome.value
2900
+ );
2901
+ } finally {
2902
+ ctx.parallelGroup = void 0;
2903
+ }
2904
+ };
2905
+ const promise = join();
2906
+ ctx.parallelJoins.push(promise);
2907
+ void promise.catch(() => {
2908
+ });
2909
+ return promise;
2910
+ }
2911
+
2665
2912
  // src/workflow.ts
2666
2913
  var import_node_crypto5 = require("crypto");
2667
- var import_core6 = require("@kortyx/core");
2914
+ var import_core7 = require("@kortyx/core");
2915
+ var import_errors6 = require("@kortyx/core/errors");
2668
2916
 
2669
2917
  // src/workflow-telemetry.ts
2670
2918
  var import_node_crypto4 = require("crypto");
@@ -2706,10 +2954,20 @@ function workflowCallContent(telemetry, side, value) {
2706
2954
  }
2707
2955
 
2708
2956
  // src/workflow.ts
2709
- var WorkflowCallError = class extends Error {
2710
- constructor(workflow, message) {
2711
- super(`Workflow '${workflow}': ${message}`);
2957
+ var WorkflowCallError = class extends import_errors6.KortyxError {
2958
+ constructor(workflow, message, cause, failure) {
2959
+ const detail = (0, import_errors6.serializeFailure)(
2960
+ failure ?? cause ?? new import_errors6.WorkflowContractError(message)
2961
+ );
2962
+ super(detail.code, `Workflow '${workflow}': ${message}`, {
2963
+ ...detail,
2964
+ safeMessage: detail.message,
2965
+ ...cause !== void 0 ? { cause } : {}
2966
+ });
2712
2967
  this.workflow = workflow;
2968
+ Object.assign(this.failure, detail, {
2969
+ context: { ...detail.context, workflow }
2970
+ });
2713
2971
  this.name = "WorkflowCallError";
2714
2972
  }
2715
2973
  workflow;
@@ -2720,10 +2978,12 @@ function workflowCallFingerprint(value) {
2720
2978
  if (v === null || typeof v === "string" || typeof v === "boolean") return v;
2721
2979
  if (typeof v === "number" && Number.isFinite(v)) return v;
2722
2980
  if (typeof v !== "object" || !v || seen.has(v)) {
2723
- throw new Error("Workflow input and output must be JSON serializable.");
2981
+ throw new import_errors6.WorkflowContractError(
2982
+ "Workflow input and output must be JSON serializable."
2983
+ );
2724
2984
  }
2725
2985
  if (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype) {
2726
- throw new Error(
2986
+ throw new import_errors6.WorkflowContractError(
2727
2987
  "Workflow input and output must contain plain JSON objects."
2728
2988
  );
2729
2989
  }
@@ -2744,29 +3004,49 @@ function createWorkflowHooks(workflows) {
2744
3004
  useWorkflow: (args) => {
2745
3005
  const workflow = workflows[args.workflow];
2746
3006
  if (!workflow || workflow.id !== args.workflow)
2747
- throw new Error("Workflow registry keys must match definition ids.");
3007
+ throw new import_errors6.WorkflowContractError(
3008
+ "Workflow registry keys must match definition ids."
3009
+ );
2748
3010
  return useWorkflow({ ...args, workflow });
2749
3011
  }
2750
3012
  };
2751
3013
  }
2752
3014
  async function useWorkflow(args) {
3015
+ const task = registerWorkflowTask(args.id);
3016
+ try {
3017
+ await Promise.resolve();
3018
+ if (task.group) await task.group.ready;
3019
+ return await runWorkflowCall(args, task);
3020
+ } catch (error) {
3021
+ if ((0, import_core7.isExecutionCancelled)(error) || (0, import_core7.isExecutionLimitReached)(error))
3022
+ getHookContext().workflowControlError = error;
3023
+ throw error;
3024
+ } finally {
3025
+ task.finish();
3026
+ }
3027
+ }
3028
+ async function runWorkflowCall(args, task) {
2753
3029
  const ctx = getHookContext();
2754
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
3030
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2755
3031
  const service = ctx.node.callWorkflow;
2756
3032
  if (!service)
2757
- throw new Error("useWorkflow requires an agent workflow registry.");
3033
+ throw new import_errors6.WorkflowContractError(
3034
+ "useWorkflow requires an agent workflow registry."
3035
+ );
2758
3036
  if (!args.id.trim())
2759
- throw new Error("useWorkflow requires a stable, nonempty id.");
2760
- if (ctx.workflowCallActive)
2761
- throw new Error(
2762
- "Concurrent workflow calls are not supported; await each call."
3037
+ throw new import_errors6.WorkflowContractError(
3038
+ "useWorkflow requires a stable, nonempty id."
3039
+ );
3040
+ if (ctx.workflowCallActive && !task.group)
3041
+ throw new import_errors6.WorkflowContractError(
3042
+ "Concurrent workflow calls require parallel([...]); otherwise await each call."
2763
3043
  );
2764
3044
  if (ctx.workflowCallIds.has(args.id))
2765
- throw new Error(
3045
+ throw new import_errors6.WorkflowContractError(
2766
3046
  `Duplicate workflow call id '${args.id}' in one node activation.`
2767
3047
  );
2768
3048
  if (ctx.workflowCallIds.size >= 64)
2769
- throw new Error(
3049
+ throw new import_errors6.WorkflowContractError(
2770
3050
  "A node may call at most 64 child workflows per activation."
2771
3051
  );
2772
3052
  ctx.workflowCallIds.add(args.id);
@@ -2780,6 +3060,11 @@ async function useWorkflow(args) {
2780
3060
  });
2781
3061
  const key = `__useWorkflow:${args.id}`;
2782
3062
  let record = ctx.currentNodeState.byKey[key];
3063
+ if (record && record.parallelKey !== task.group?.key)
3064
+ throw new WorkflowCallError(
3065
+ workflow,
3066
+ "parallel call membership changed during replay"
3067
+ );
2783
3068
  if (record && record.fingerprint !== fingerprint)
2784
3069
  throw new WorkflowCallError(
2785
3070
  workflow,
@@ -2790,11 +3075,12 @@ async function useWorkflow(args) {
2790
3075
  fingerprint,
2791
3076
  invocationId: (0, import_node_crypto5.randomUUID)(),
2792
3077
  status: "running",
2793
- interrupts: []
3078
+ interrupts: [],
3079
+ ...task.group ? { parallelKey: task.group.key } : {}
2794
3080
  };
2795
3081
  ctx.currentNodeState.byKey[key] = record;
2796
3082
  ctx.stateDirty = true;
2797
- ctx.workflowCallActive = true;
3083
+ if (!task.group) ctx.workflowCallActive = true;
2798
3084
  const telemetry = ctx.node.workflowCallTelemetry;
2799
3085
  const activationKey = "__useWorkflowActivation";
2800
3086
  ctx.currentNodeState.byKey[activationKey] ??= (0, import_node_crypto5.randomUUID)();
@@ -2830,14 +3116,21 @@ async function useWorkflow(args) {
2830
3116
  };
2831
3117
  const cached = record.status === "completed";
2832
3118
  try {
2833
- for (const interruption of record.interrupts) {
3119
+ for (const interruption of task.group ? [] : record.interrupts) {
2834
3120
  interruption.response = await awaitInterruptInternal({
2835
3121
  request: interruption.request,
2836
3122
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
2837
3123
  });
2838
3124
  }
2839
3125
  if (record.status === "failed")
2840
- throw new WorkflowCallError(workflow, record.error ?? "child failed");
3126
+ throw new WorkflowCallError(
3127
+ workflow,
3128
+ record.error ?? "child failed",
3129
+ void 0,
3130
+ record.failure ?? (0, import_errors6.serializeFailure)(void 0)
3131
+ );
3132
+ if (task.group && record.status === "interrupted" && record.interrupts.at(-1)?.response === void 0)
3133
+ throw new ParallelChildWaiting();
2841
3134
  while (record.status !== "completed") {
2842
3135
  let outcome;
2843
3136
  try {
@@ -2856,13 +3149,13 @@ async function useWorkflow(args) {
2856
3149
  ...record.interrupts.length ? { response: record.interrupts.at(-1)?.response } : {}
2857
3150
  });
2858
3151
  } catch (error) {
2859
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2860
- if ((0, import_core6.isExecutionCancelled)(error)) throw error;
2861
- if ((0, import_core6.isExecutionLimitReached)(error)) {
2862
- const child = error;
2863
- if (child.__kortyxChildSnapshot)
3152
+ const child = error;
3153
+ recordUsage(child?.__kortyxChildUsage);
3154
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
3155
+ if ((0, import_core7.isExecutionCancelled)(error)) throw error;
3156
+ if ((0, import_core7.isExecutionLimitReached)(error)) {
3157
+ if (child?.__kortyxChildSnapshot)
2864
3158
  record.snapshot = child.__kortyxChildSnapshot;
2865
- recordUsage(child.__kortyxChildUsage);
2866
3159
  report("suspended", {
2867
3160
  reason: "limit_reached",
2868
3161
  limit: error.limitReached
@@ -2870,11 +3163,17 @@ async function useWorkflow(args) {
2870
3163
  throw error;
2871
3164
  }
2872
3165
  record.status = "failed";
2873
- record.error = error instanceof Error ? error.message : String(error);
3166
+ record.failure = (0, import_errors6.serializeFailure)(error);
3167
+ record.error = record.failure.message;
2874
3168
  report("failed", { error: "Child workflow failed" });
2875
- throw new WorkflowCallError(workflow, record.error);
3169
+ throw new WorkflowCallError(
3170
+ workflow,
3171
+ record.error,
3172
+ error,
3173
+ record.failure
3174
+ );
2876
3175
  }
2877
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
3176
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2878
3177
  recordUsage(outcome.usage);
2879
3178
  if (outcome.status === "completed") {
2880
3179
  record.data = outcome.data;
@@ -2895,6 +3194,7 @@ async function useWorkflow(args) {
2895
3194
  request: outcome.request
2896
3195
  };
2897
3196
  record.interrupts.push(interruption);
3197
+ if (task.group) throw new ParallelChildWaiting();
2898
3198
  interruption.response = await awaitInterruptInternal({
2899
3199
  request: interruption.request,
2900
3200
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2908,15 +3208,17 @@ async function useWorkflow(args) {
2908
3208
  data: JSON.parse(JSON.stringify(data))
2909
3209
  };
2910
3210
  } finally {
2911
- ctx.workflowCallActive = false;
3211
+ if (!task.group) ctx.workflowCallActive = false;
2912
3212
  }
2913
3213
  }
2914
3214
  // Annotate the CommonJS export names for ESM import in node:
2915
3215
  0 && (module.exports = {
3216
+ ParallelError,
2916
3217
  WorkflowCallError,
2917
3218
  completeResponse,
2918
3219
  createWorkflowHooks,
2919
3220
  emitWorkflowCall,
3221
+ parallel,
2920
3222
  runWithHookContext,
2921
3223
  useAbortSignal,
2922
3224
  useInterrupt,