@kortyx/hooks 0.23.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0](https://github.com/kortyx-io/kortyx/compare/hooks-v0.23.1...hooks-v0.24.0) (2026-09-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **hooks:** support durable parallel child workflows ([#171](https://github.com/kortyx-io/kortyx/issues/171)) ([fdf2d56](https://github.com/kortyx-io/kortyx/commit/fdf2d564d88e921988e8f3e20da9fe0c30bbcdf9))
9
+
10
+ ## [0.23.1](https://github.com/kortyx-io/kortyx/compare/hooks-v0.23.0...hooks-v0.23.1) (2026-09-11)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **providers:** preserve reasoning across tool workflows ([#169](https://github.com/kortyx-io/kortyx/issues/169)) ([6e4e403](https://github.com/kortyx-io/kortyx/commit/6e4e403d104d83586e05aab799510bc4c35b0f49))
16
+
17
+
18
+ ### Dependencies
19
+
20
+ * The following workspace dependencies were updated
21
+ * dependencies
22
+ * @kortyx/providers bumped to 0.6.1
23
+
3
24
  ## [0.23.0](https://github.com/kortyx-io/kortyx/compare/hooks-v0.22.0...hooks-v0.23.0) (2026-09-11)
4
25
 
5
26
 
package/README.md CHANGED
@@ -78,6 +78,19 @@ string ids. Register the same definitions with the agent.
78
78
  Calls need stable ids. A child interrupt pauses the chain; resuming replays
79
79
  the enclosing node and reuses saved child results. Keep effects before calls
80
80
  idempotent. Fork/rollback retain nested checkpoints; use Redis for persistence
81
- across server restarts. Concurrent calls are unsupported.
81
+ across server restarts. Join concurrent children with `parallel`:
82
+
83
+ ```ts
84
+ const [first, second] = await parallel([
85
+ useWorkflow({ id: "first", workflow: researchWorkflow, input: { topic: "AI" } }),
86
+ useWorkflow({ id: "second", workflow: researchWorkflow, input: { topic: "Robotics" } }),
87
+ ]);
88
+ ```
89
+
90
+ The tuple follows input order. The join preserves all siblings before pausing and
91
+ presents waiting questions individually through the parent handle. Terminal group
92
+ failures throw `ParallelError`, whose `results` exposes each settled outcome.
93
+ Await groups sequentially within one node; children may contain nested groups.
94
+ Native `Promise.all` calls and parallel graph edges remain unsupported.
82
95
 
83
96
  See [the execution and replay contract](../../docs/design-specs/child-workflows.md).
package/dist/index.d.ts CHANGED
@@ -13,6 +13,16 @@ type CompleteResponseOptions = {
13
13
  /** Finish client delivery, leaving the node and workflow execution running. */
14
14
  declare function completeResponse(options?: CompleteResponseOptions): Promise<void>;
15
15
 
16
+ /** All siblings have settled; results retain the order passed to parallel(). */
17
+ declare class ParallelError extends AggregateError {
18
+ readonly results: readonly PromiseSettledResult<unknown>[];
19
+ constructor(results: readonly PromiseSettledResult<unknown>[]);
20
+ }
21
+ /** Join concurrent child calls, preserving their snapshots before suspending. */
22
+ declare function parallel<const T extends readonly unknown[]>(values: T): Promise<{
23
+ -readonly [K in keyof T]: Awaited<T[K]>;
24
+ }>;
25
+
16
26
  type WorkflowCallOutcome = {
17
27
  status: "completed";
18
28
  data: Record<string, unknown>;
@@ -236,4 +246,4 @@ declare function useWorkflowState<T>(key: string, ...rest: [] | [T]): [T, StateS
236
246
  /** Best effort lifecycle facts. Engine snapshots and interrupt tokens never leave the runtime. */
237
247
  declare function emitWorkflowCall(telemetry: KortyxTelemetryConfig | undefined, type: KortyxTelemetryEventType, payload: Record<string, unknown>): void;
238
248
 
239
- export { type CompleteResponseOptions, type HookRuntimeContext, KortyxTelemetryConfig, KortyxTelemetryEventType, KortyxTraceMetadata, ReasonTraceAdapter, type UseInterruptArgs, type UseReasonArgs, type UseReasonResult, type UseReasonStep, type UseReasonToolExecution, type UseStructuredDataArgs, WorkflowCallError, type WorkflowCallOutcome, type WorkflowCallService, completeResponse, createWorkflowHooks, emitWorkflowCall, runWithHookContext, useAbortSignal, useInterrupt, useNodeState, useReason, useRuntimeContext, useStructuredData, useWorkflow, useWorkflowState, workflowCallFingerprint };
249
+ export { type CompleteResponseOptions, type HookRuntimeContext, KortyxTelemetryConfig, KortyxTelemetryEventType, KortyxTraceMetadata, ParallelError, ReasonTraceAdapter, type UseInterruptArgs, type UseReasonArgs, type UseReasonResult, type UseReasonStep, type UseReasonToolExecution, type UseStructuredDataArgs, WorkflowCallError, type WorkflowCallOutcome, type WorkflowCallService, completeResponse, createWorkflowHooks, emitWorkflowCall, parallel, runWithHookContext, useAbortSignal, useInterrupt, useNodeState, useReason, useRuntimeContext, useStructuredData, useWorkflow, useWorkflowState, workflowCallFingerprint };
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
  }
@@ -426,6 +452,16 @@ var emitNodeEvent = (emitEvent, nodeId, event, payload) => {
426
452
  ...typeof nodeId === "string" && nodeId.length > 0 ? { node: nodeId } : {}
427
453
  });
428
454
  };
455
+ var resolveProviderOptions = (defaults, overrides) => {
456
+ const providerOptions = defaults || overrides ? { ...defaults, ...overrides } : void 0;
457
+ for (const [key, value] of Object.entries(overrides ?? {})) {
458
+ const previous = defaults?.[key];
459
+ if (previous && value && typeof previous === "object" && typeof value === "object" && !Array.isArray(previous) && !Array.isArray(value)) {
460
+ if (providerOptions) providerOptions[key] = { ...previous, ...value };
461
+ }
462
+ }
463
+ return providerOptions;
464
+ };
429
465
  async function runReasonEngine(args) {
430
466
  let stream = args.stream ?? args.model.options?.streaming ?? true;
431
467
  const emit = args.emit ?? true;
@@ -435,14 +471,10 @@ async function runReasonEngine(args) {
435
471
  const abortSignal = args.abortSignal ?? args.model.options?.abortSignal;
436
472
  const reasoning = args.reasoning ?? args.model.options?.reasoning;
437
473
  const responseFormat = args.responseFormat ?? args.model.options?.responseFormat;
438
- const defaults = args.model.options?.providerOptions;
439
- const providerOptions = defaults || args.providerOptions ? { ...defaults, ...args.providerOptions } : void 0;
440
- for (const [key, value] of Object.entries(args.providerOptions ?? {})) {
441
- const previous = defaults?.[key];
442
- if (previous && value && typeof previous === "object" && typeof value === "object" && !Array.isArray(previous) && !Array.isArray(value)) {
443
- if (providerOptions) providerOptions[key] = { ...previous, ...value };
444
- }
445
- }
474
+ const providerOptions = resolveProviderOptions(
475
+ args.model.options?.providerOptions,
476
+ args.providerOptions
477
+ );
446
478
  const tools = args.tools ?? args.model.options?.tools;
447
479
  (0, import_core2.throwIfExecutionAborted)(abortSignal);
448
480
  const model = args.model.provider.getModel(args.model.modelId, {
@@ -1871,6 +1903,7 @@ var runReasonToolLoop = async (args) => {
1871
1903
  const tools = useReasonArgs.tools ?? [];
1872
1904
  const toolByName = /* @__PURE__ */ new Map();
1873
1905
  let validationCompleted = false;
1906
+ let separateOutput = false;
1874
1907
  try {
1875
1908
  for (const tool of tools) {
1876
1909
  if (toolByName.has(tool.name)) {
@@ -1885,6 +1918,17 @@ var runReasonToolLoop = async (args) => {
1885
1918
  "useReason tools cannot be combined with useReason interrupt mode yet. Use toolExecution.approval for tool approval."
1886
1919
  );
1887
1920
  }
1921
+ separateOutput = Boolean(
1922
+ useReasonArgs.model.provider.getModel(useReasonArgs.model.modelId, {
1923
+ ...useReasonArgs.model.options,
1924
+ ...useReasonArgs.reasoning !== void 0 ? { reasoning: useReasonArgs.reasoning } : {},
1925
+ ...useReasonArgs.responseFormat ? { responseFormat: useReasonArgs.responseFormat } : {},
1926
+ providerOptions: resolveProviderOptions(
1927
+ useReasonArgs.model.options?.providerOptions,
1928
+ useReasonArgs.providerOptions
1929
+ ) ?? {}
1930
+ }).requiresSeparateStructuredOutput
1931
+ );
1888
1932
  validationCompleted = true;
1889
1933
  } finally {
1890
1934
  if (!validationCompleted) {
@@ -1892,6 +1936,8 @@ var runReasonToolLoop = async (args) => {
1892
1936
  }
1893
1937
  }
1894
1938
  const toolDefinitions = toToolDefinitions(tools);
1939
+ let finalizing = checkpoint?.finalizing ?? false;
1940
+ let completed = false;
1895
1941
  const maxSteps = Math.max(1, useReasonArgs.toolExecution?.maxSteps ?? 3);
1896
1942
  const messages = checkpoint?.messages ?? createInitialMessages({
1897
1943
  system: useReasonArgs.system,
@@ -1926,6 +1972,7 @@ var runReasonToolLoop = async (args) => {
1926
1972
  messages,
1927
1973
  steps,
1928
1974
  approvedCalls,
1975
+ finalizing,
1929
1976
  ...pending ? { pending } : {}
1930
1977
  };
1931
1978
  ctx.stateDirty = true;
@@ -1949,11 +1996,12 @@ var runReasonToolLoop = async (args) => {
1949
1996
  const stream = useReasonArgs.stream ?? useReasonArgs.model.options?.streaming ?? true;
1950
1997
  const emit = useReasonArgs.emit ?? true;
1951
1998
  let textStarted = false;
1952
- const structuredChunk = useReasonArgs.outputSchema && stream && emit && shouldStreamStructured(useReasonArgs.structured) ? createStructuredOutputStreamer(useReasonArgs, id, opId) : void 0;
1999
+ const structuredChunk = useReasonArgs.outputSchema && (!separateOutput || finalizing) && stream && emit && shouldStreamStructured(useReasonArgs.structured) ? createStructuredOutputStreamer(useReasonArgs, id, opId) : void 0;
1953
2000
  const step = pending ?? await reasonEngine(
1954
2001
  {
1955
2002
  ...useReasonArgs,
1956
- tools: toolDefinitions,
2003
+ tools: finalizing ? [] : toolDefinitions,
2004
+ ...separateOutput && !finalizing ? { responseFormat: { type: "text" } } : {},
1957
2005
  messages,
1958
2006
  emit: false,
1959
2007
  stream,
@@ -1996,8 +2044,48 @@ var runReasonToolLoop = async (args) => {
1996
2044
  ...step.warnings ? { warnings: step.warnings } : {}
1997
2045
  });
1998
2046
  if (toolCalls.length === 0) {
2047
+ if (separateOutput && !finalizing) {
2048
+ if (args.allowValidatedToolOutput && useReasonArgs.outputSchema) {
2049
+ try {
2050
+ finalOutput = parseReasonOutputWithSchema({
2051
+ text: step.text,
2052
+ schema: useReasonArgs.outputSchema,
2053
+ ...step.finishReason ? { finishReason: step.finishReason } : {},
2054
+ label: "useReason output"
2055
+ });
2056
+ aggregatedWarnings = mergeWarnings(aggregatedWarnings, [
2057
+ {
2058
+ type: "compatibility",
2059
+ feature: "responseFormat",
2060
+ details: "Reused locally validated tool-phase output without an extra native-schema request. Set responseFormat.schema explicitly to require provider schema enforcement."
2061
+ }
2062
+ ]);
2063
+ completed = true;
2064
+ break;
2065
+ } catch {
2066
+ }
2067
+ }
2068
+ messages.push({
2069
+ role: "assistant",
2070
+ content: step.text,
2071
+ ...step.continuation ? { continuation: step.continuation } : {}
2072
+ });
2073
+ messages.push({
2074
+ role: "user",
2075
+ content: "Return the final answer using the requested output schema and the tool results above."
2076
+ });
2077
+ finalizing = true;
2078
+ pending = void 0;
2079
+ save();
2080
+ continue;
2081
+ }
2082
+ completed = true;
1999
2083
  break;
2000
2084
  }
2085
+ if (finalizing)
2086
+ throw new Error(
2087
+ "Provider requested a tool during schema-only finalization."
2088
+ );
2001
2089
  if (!reused) {
2002
2090
  allToolCalls.push(...toolCalls);
2003
2091
  messages.push({
@@ -2141,26 +2229,26 @@ var runReasonToolLoop = async (args) => {
2141
2229
  message: result2.content
2142
2230
  });
2143
2231
  }
2144
- const completed = toolResults.at(-1);
2145
- if (completed)
2232
+ const completed2 = toolResults.at(-1);
2233
+ if (completed2)
2146
2234
  messages.push({
2147
2235
  role: "tool",
2148
- content: completed.content,
2149
- toolCallId: completed.toolCallId,
2150
- name: completed.name,
2151
- ...completed.isError !== void 0 ? { isError: completed.isError } : {}
2236
+ content: completed2.content,
2237
+ toolCallId: completed2.toolCallId,
2238
+ name: completed2.name,
2239
+ ...completed2.isError !== void 0 ? { isError: completed2.isError } : {}
2152
2240
  });
2153
2241
  save();
2154
2242
  }
2155
2243
  pending = void 0;
2156
2244
  save();
2157
2245
  }
2158
- if (steps.length >= maxSteps && steps.at(-1)?.toolCalls.length) {
2246
+ if (!completed) {
2159
2247
  throw new Error(
2160
2248
  `useReason tool loop reached maxSteps (${maxSteps}) before producing a final response.`
2161
2249
  );
2162
2250
  }
2163
- if (useReasonArgs.outputSchema) {
2251
+ if (useReasonArgs.outputSchema && finalOutput === void 0) {
2164
2252
  finalOutput = parseReasonOutputWithSchema({
2165
2253
  text: finalText,
2166
2254
  schema: useReasonArgs.outputSchema,
@@ -2244,6 +2332,9 @@ async function useReason(args) {
2244
2332
  args.model.options?.abortSignal
2245
2333
  );
2246
2334
  (0, import_core5.throwIfExecutionAborted)(abortSignal);
2335
+ const allowValidatedToolOutput = Boolean(
2336
+ args.outputSchema && !(args.responseFormat ?? args.model.options?.responseFormat)
2337
+ );
2247
2338
  const inferred = inferOutputFormat(
2248
2339
  args.outputSchema,
2249
2340
  args.responseFormat ?? args.model.options?.responseFormat
@@ -2296,6 +2387,7 @@ async function useReason(args) {
2296
2387
  if (args.tools?.length) {
2297
2388
  return runReasonToolLoop({
2298
2389
  useReasonArgs: args,
2390
+ allowValidatedToolOutput,
2299
2391
  checkpointKey,
2300
2392
  initialWarnings: inferred.warnings,
2301
2393
  ...id ? { id } : {},
@@ -2596,9 +2688,161 @@ function useWorkflowState(key, ...rest) {
2596
2688
  return [getValue(), setValue];
2597
2689
  }
2598
2690
 
2691
+ // src/parallel.ts
2692
+ var import_core6 = require("@kortyx/core");
2693
+ var ParallelChildWaiting = class extends Error {
2694
+ };
2695
+ var ParallelError = class extends AggregateError {
2696
+ constructor(results) {
2697
+ super(
2698
+ results.flatMap(
2699
+ (result) => result.status === "rejected" ? [result.reason] : []
2700
+ ),
2701
+ "One or more parallel operations failed."
2702
+ );
2703
+ this.results = results;
2704
+ this.name = "ParallelError";
2705
+ }
2706
+ results;
2707
+ };
2708
+ function registerWorkflowTask(id) {
2709
+ const ctx = getHookContext();
2710
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2711
+ if (ctx.workflowContextClosed)
2712
+ throw new Error("The workflow node activation has finished.");
2713
+ const group = ctx.parallelGroup;
2714
+ if (group?.restored && !group.record.calls.includes(id)) {
2715
+ throw new Error(`Parallel call membership changed during replay: '${id}'.`);
2716
+ }
2717
+ let finish;
2718
+ const task = {
2719
+ id,
2720
+ ...group ? { group } : {},
2721
+ completed: false,
2722
+ done: new Promise((resolve) => {
2723
+ finish = resolve;
2724
+ }),
2725
+ finish: () => {
2726
+ task.completed = true;
2727
+ finish();
2728
+ }
2729
+ };
2730
+ ctx.workflowTasks.push(task);
2731
+ if (group && !group.record.calls.includes(id)) group.record.calls.push(id);
2732
+ return task;
2733
+ }
2734
+ function parallel(values) {
2735
+ const settled = Promise.allSettled(values);
2736
+ let ctx;
2737
+ try {
2738
+ ctx = getHookContext();
2739
+ } catch (error) {
2740
+ return Promise.reject(error);
2741
+ }
2742
+ const join = async () => {
2743
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2744
+ if (ctx.parallelGroup)
2745
+ throw new Error(
2746
+ "Await each parallel group before starting another in the same node."
2747
+ );
2748
+ const key = `__parallel:${ctx.parallelIndex++}`;
2749
+ const saved = ctx.currentNodeState.byKey[key];
2750
+ const tasks = ctx.workflowTasks.filter(
2751
+ (task) => !task.completed && !task.group
2752
+ );
2753
+ const ids = tasks.map((task) => task.id);
2754
+ if (saved && (saved.version !== 1 || ids.some((id) => !saved.calls.includes(id)))) {
2755
+ throw new Error("Parallel call membership changed during replay.");
2756
+ }
2757
+ const group = {
2758
+ key,
2759
+ record: saved ?? { version: 1, calls: ids, interrupts: [] },
2760
+ restored: Boolean(saved),
2761
+ ready: Promise.resolve()
2762
+ };
2763
+ ctx.currentNodeState.byKey[key] = group.record;
2764
+ ctx.stateDirty = true;
2765
+ ctx.parallelGroup = group;
2766
+ for (const task of tasks) task.group = group;
2767
+ const callRecord = (id) => ctx.currentNodeState.byKey[`__useWorkflow:${id}`];
2768
+ const bridge = async (callId, index) => {
2769
+ const interruption = callRecord(callId)?.interrupts[index];
2770
+ if (!interruption)
2771
+ throw new Error(
2772
+ "Parallel interrupt history does not match the saved child."
2773
+ );
2774
+ try {
2775
+ interruption.response = await awaitInterruptInternal({
2776
+ request: interruption.request,
2777
+ ...interruption.request.meta ? { meta: interruption.request.meta } : {}
2778
+ });
2779
+ } catch (error) {
2780
+ ctx.workflowControlError = error;
2781
+ throw error;
2782
+ }
2783
+ };
2784
+ group.ready = (async () => {
2785
+ for (const entry of group.record.interrupts)
2786
+ await bridge(entry.callId, entry.index);
2787
+ })();
2788
+ try {
2789
+ const [ready, outcomes] = await Promise.all([
2790
+ Promise.allSettled([group.ready]),
2791
+ settled
2792
+ ]);
2793
+ let drained = -1;
2794
+ while (drained !== ctx.workflowTasks.length) {
2795
+ drained = ctx.workflowTasks.length;
2796
+ await Promise.all(
2797
+ ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.done)
2798
+ );
2799
+ }
2800
+ if (ready[0]?.status === "rejected") throw ready[0].reason;
2801
+ (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2802
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2803
+ const rejected = outcomes.filter(
2804
+ (outcome) => outcome.status === "rejected"
2805
+ );
2806
+ const control = rejected.find(
2807
+ (outcome) => (0, import_core6.isExecutionCancelled)(outcome.reason) || (0, import_core6.isExecutionLimitReached)(outcome.reason)
2808
+ );
2809
+ if (control) {
2810
+ ctx.workflowControlError = control.reason;
2811
+ throw control.reason;
2812
+ }
2813
+ const actual = ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.id);
2814
+ if (group.record.calls.length !== actual.length || group.record.calls.some((id) => !actual.includes(id))) {
2815
+ throw new Error("Parallel call membership changed during replay.");
2816
+ }
2817
+ for (const callId of group.record.calls) {
2818
+ const record = callRecord(callId);
2819
+ if (record?.status !== "interrupted") continue;
2820
+ const index = record.interrupts.length - 1;
2821
+ if (record.interrupts[index]?.response !== void 0) continue;
2822
+ group.record.interrupts.push({ callId, index });
2823
+ await bridge(callId, index);
2824
+ throw new Error(
2825
+ "Parallel interrupt unexpectedly resumed without replay."
2826
+ );
2827
+ }
2828
+ if (rejected.length) throw new ParallelError(outcomes);
2829
+ return outcomes.map(
2830
+ (outcome) => outcome.value
2831
+ );
2832
+ } finally {
2833
+ ctx.parallelGroup = void 0;
2834
+ }
2835
+ };
2836
+ const promise = join();
2837
+ ctx.parallelJoins.push(promise);
2838
+ void promise.catch(() => {
2839
+ });
2840
+ return promise;
2841
+ }
2842
+
2599
2843
  // src/workflow.ts
2600
2844
  var import_node_crypto5 = require("crypto");
2601
- var import_core6 = require("@kortyx/core");
2845
+ var import_core7 = require("@kortyx/core");
2602
2846
 
2603
2847
  // src/workflow-telemetry.ts
2604
2848
  var import_node_crypto4 = require("crypto");
@@ -2684,16 +2928,30 @@ function createWorkflowHooks(workflows) {
2684
2928
  };
2685
2929
  }
2686
2930
  async function useWorkflow(args) {
2931
+ const task = registerWorkflowTask(args.id);
2932
+ try {
2933
+ await Promise.resolve();
2934
+ if (task.group) await task.group.ready;
2935
+ return await runWorkflowCall(args, task);
2936
+ } catch (error) {
2937
+ if ((0, import_core7.isExecutionCancelled)(error) || (0, import_core7.isExecutionLimitReached)(error))
2938
+ getHookContext().workflowControlError = error;
2939
+ throw error;
2940
+ } finally {
2941
+ task.finish();
2942
+ }
2943
+ }
2944
+ async function runWorkflowCall(args, task) {
2687
2945
  const ctx = getHookContext();
2688
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2946
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2689
2947
  const service = ctx.node.callWorkflow;
2690
2948
  if (!service)
2691
2949
  throw new Error("useWorkflow requires an agent workflow registry.");
2692
2950
  if (!args.id.trim())
2693
2951
  throw new Error("useWorkflow requires a stable, nonempty id.");
2694
- if (ctx.workflowCallActive)
2952
+ if (ctx.workflowCallActive && !task.group)
2695
2953
  throw new Error(
2696
- "Concurrent workflow calls are not supported; await each call."
2954
+ "Concurrent workflow calls require parallel([...]); otherwise await each call."
2697
2955
  );
2698
2956
  if (ctx.workflowCallIds.has(args.id))
2699
2957
  throw new Error(
@@ -2714,6 +2972,11 @@ async function useWorkflow(args) {
2714
2972
  });
2715
2973
  const key = `__useWorkflow:${args.id}`;
2716
2974
  let record = ctx.currentNodeState.byKey[key];
2975
+ if (record && record.parallelKey !== task.group?.key)
2976
+ throw new WorkflowCallError(
2977
+ workflow,
2978
+ "parallel call membership changed during replay"
2979
+ );
2717
2980
  if (record && record.fingerprint !== fingerprint)
2718
2981
  throw new WorkflowCallError(
2719
2982
  workflow,
@@ -2724,11 +2987,12 @@ async function useWorkflow(args) {
2724
2987
  fingerprint,
2725
2988
  invocationId: (0, import_node_crypto5.randomUUID)(),
2726
2989
  status: "running",
2727
- interrupts: []
2990
+ interrupts: [],
2991
+ ...task.group ? { parallelKey: task.group.key } : {}
2728
2992
  };
2729
2993
  ctx.currentNodeState.byKey[key] = record;
2730
2994
  ctx.stateDirty = true;
2731
- ctx.workflowCallActive = true;
2995
+ if (!task.group) ctx.workflowCallActive = true;
2732
2996
  const telemetry = ctx.node.workflowCallTelemetry;
2733
2997
  const activationKey = "__useWorkflowActivation";
2734
2998
  ctx.currentNodeState.byKey[activationKey] ??= (0, import_node_crypto5.randomUUID)();
@@ -2764,7 +3028,7 @@ async function useWorkflow(args) {
2764
3028
  };
2765
3029
  const cached = record.status === "completed";
2766
3030
  try {
2767
- for (const interruption of record.interrupts) {
3031
+ for (const interruption of task.group ? [] : record.interrupts) {
2768
3032
  interruption.response = await awaitInterruptInternal({
2769
3033
  request: interruption.request,
2770
3034
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2772,6 +3036,8 @@ async function useWorkflow(args) {
2772
3036
  }
2773
3037
  if (record.status === "failed")
2774
3038
  throw new WorkflowCallError(workflow, record.error ?? "child failed");
3039
+ if (task.group && record.status === "interrupted" && record.interrupts.at(-1)?.response === void 0)
3040
+ throw new ParallelChildWaiting();
2775
3041
  while (record.status !== "completed") {
2776
3042
  let outcome;
2777
3043
  try {
@@ -2790,13 +3056,13 @@ async function useWorkflow(args) {
2790
3056
  ...record.interrupts.length ? { response: record.interrupts.at(-1)?.response } : {}
2791
3057
  });
2792
3058
  } catch (error) {
2793
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2794
- if ((0, import_core6.isExecutionCancelled)(error)) throw error;
2795
- if ((0, import_core6.isExecutionLimitReached)(error)) {
2796
- const child = error;
2797
- if (child.__kortyxChildSnapshot)
3059
+ const child = error;
3060
+ recordUsage(child?.__kortyxChildUsage);
3061
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
3062
+ if ((0, import_core7.isExecutionCancelled)(error)) throw error;
3063
+ if ((0, import_core7.isExecutionLimitReached)(error)) {
3064
+ if (child?.__kortyxChildSnapshot)
2798
3065
  record.snapshot = child.__kortyxChildSnapshot;
2799
- recordUsage(child.__kortyxChildUsage);
2800
3066
  report("suspended", {
2801
3067
  reason: "limit_reached",
2802
3068
  limit: error.limitReached
@@ -2808,7 +3074,7 @@ async function useWorkflow(args) {
2808
3074
  report("failed", { error: "Child workflow failed" });
2809
3075
  throw new WorkflowCallError(workflow, record.error);
2810
3076
  }
2811
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
3077
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2812
3078
  recordUsage(outcome.usage);
2813
3079
  if (outcome.status === "completed") {
2814
3080
  record.data = outcome.data;
@@ -2829,6 +3095,7 @@ async function useWorkflow(args) {
2829
3095
  request: outcome.request
2830
3096
  };
2831
3097
  record.interrupts.push(interruption);
3098
+ if (task.group) throw new ParallelChildWaiting();
2832
3099
  interruption.response = await awaitInterruptInternal({
2833
3100
  request: interruption.request,
2834
3101
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2842,15 +3109,17 @@ async function useWorkflow(args) {
2842
3109
  data: JSON.parse(JSON.stringify(data))
2843
3110
  };
2844
3111
  } finally {
2845
- ctx.workflowCallActive = false;
3112
+ if (!task.group) ctx.workflowCallActive = false;
2846
3113
  }
2847
3114
  }
2848
3115
  // Annotate the CommonJS export names for ESM import in node:
2849
3116
  0 && (module.exports = {
3117
+ ParallelError,
2850
3118
  WorkflowCallError,
2851
3119
  completeResponse,
2852
3120
  createWorkflowHooks,
2853
3121
  emitWorkflowCall,
3122
+ parallel,
2854
3123
  runWithHookContext,
2855
3124
  useAbortSignal,
2856
3125
  useInterrupt,