@kortyx/agent 0.20.1 → 0.21.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.21.0](https://github.com/kortyx-io/kortyx/compare/agent-v0.20.1...agent-v0.21.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
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @kortyx/runtime bumped to 0.16.0
16
+ * devDependencies
17
+ * @kortyx/hooks bumped to 0.24.0
18
+
3
19
  ## [0.20.1](https://github.com/kortyx-io/kortyx/compare/agent-v0.20.0...agent-v0.20.1) (2026-09-11)
4
20
 
5
21
 
package/dist/index.js CHANGED
@@ -51,10 +51,12 @@ var require_dist = __commonJS({
51
51
  var __toCommonJS2 = (mod) => __copyProps2(__defProp2({}, "__esModule", { value: true }), mod);
52
52
  var index_exports2 = {};
53
53
  __export2(index_exports2, {
54
+ ParallelError: () => ParallelError,
54
55
  WorkflowCallError: () => WorkflowCallError,
55
56
  completeResponse: () => completeResponse,
56
57
  createWorkflowHooks: () => createWorkflowHooks,
57
58
  emitWorkflowCall: () => emitWorkflowCall2,
59
+ parallel: () => parallel,
58
60
  runWithHookContext: () => runWithHookContext,
59
61
  useAbortSignal: () => useAbortSignal,
60
62
  useInterrupt: () => useInterrupt,
@@ -118,6 +120,11 @@ var require_dist = __commonJS({
118
120
  ...ctx,
119
121
  workflowCallActive: false,
120
122
  workflowCallIds: /* @__PURE__ */ new Set(),
123
+ workflowTasks: [],
124
+ parallelJoins: [],
125
+ parallelGroup: void 0,
126
+ parallelIndex: 0,
127
+ workflowContextClosed: false,
121
128
  nodeStateIndex: 0,
122
129
  reasonCallIndex: 0,
123
130
  currentNodeState,
@@ -189,10 +196,29 @@ var require_dist = __commonJS({
189
196
  async function runWithHookContext(ctx, fn) {
190
197
  const internal = createInternalContext(ctx);
191
198
  try {
192
- const result = await storage.run(internal, fn);
199
+ const result = await storage.run(internal, async () => {
200
+ const drain = async () => {
201
+ let drained = -1;
202
+ while (drained !== internal.parallelJoins.length + internal.workflowTasks.length) {
203
+ drained = internal.parallelJoins.length + internal.workflowTasks.length;
204
+ await Promise.allSettled(internal.parallelJoins);
205
+ await Promise.all(internal.workflowTasks.map((task) => task.done));
206
+ }
207
+ internal.workflowContextClosed = true;
208
+ };
209
+ try {
210
+ const result2 = await fn();
211
+ await drain();
212
+ if (internal.workflowControlError) throw internal.workflowControlError;
213
+ return result2;
214
+ } catch (error) {
215
+ await drain();
216
+ throw internal.workflowControlError ?? error;
217
+ }
218
+ });
193
219
  cleanupCompletedReasonCheckpoints(internal);
194
220
  for (const key of Object.keys(internal.currentNodeState.byKey)) {
195
- if (key.startsWith("__useWorkflow:") || key === "__useWorkflowActivation") {
221
+ if (key.startsWith("__useWorkflow:") || key.startsWith("__parallel:") || key === "__useWorkflowActivation") {
196
222
  internal.currentNodeState.byKey[key] = null;
197
223
  internal.stateDirty = true;
198
224
  }
@@ -2647,8 +2673,158 @@ ${tryStringify2(args.response)}`
2647
2673
  };
2648
2674
  return [getValue(), setValue];
2649
2675
  }
2650
- var import_node_crypto5 = require("crypto");
2651
2676
  var import_core62 = require("@kortyx/core");
2677
+ var ParallelChildWaiting = class extends Error {
2678
+ };
2679
+ var ParallelError = class extends AggregateError {
2680
+ constructor(results) {
2681
+ super(
2682
+ results.flatMap(
2683
+ (result) => result.status === "rejected" ? [result.reason] : []
2684
+ ),
2685
+ "One or more parallel operations failed."
2686
+ );
2687
+ this.results = results;
2688
+ this.name = "ParallelError";
2689
+ }
2690
+ results;
2691
+ };
2692
+ function registerWorkflowTask(id) {
2693
+ const ctx = getHookContext();
2694
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2695
+ if (ctx.workflowContextClosed)
2696
+ throw new Error("The workflow node activation has finished.");
2697
+ const group = ctx.parallelGroup;
2698
+ if (group?.restored && !group.record.calls.includes(id)) {
2699
+ throw new Error(`Parallel call membership changed during replay: '${id}'.`);
2700
+ }
2701
+ let finish;
2702
+ const task = {
2703
+ id,
2704
+ ...group ? { group } : {},
2705
+ completed: false,
2706
+ done: new Promise((resolve2) => {
2707
+ finish = resolve2;
2708
+ }),
2709
+ finish: () => {
2710
+ task.completed = true;
2711
+ finish();
2712
+ }
2713
+ };
2714
+ ctx.workflowTasks.push(task);
2715
+ if (group && !group.record.calls.includes(id)) group.record.calls.push(id);
2716
+ return task;
2717
+ }
2718
+ function parallel(values) {
2719
+ const settled = Promise.allSettled(values);
2720
+ let ctx;
2721
+ try {
2722
+ ctx = getHookContext();
2723
+ } catch (error) {
2724
+ return Promise.reject(error);
2725
+ }
2726
+ const join = async () => {
2727
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2728
+ if (ctx.parallelGroup)
2729
+ throw new Error(
2730
+ "Await each parallel group before starting another in the same node."
2731
+ );
2732
+ const key = `__parallel:${ctx.parallelIndex++}`;
2733
+ const saved = ctx.currentNodeState.byKey[key];
2734
+ const tasks = ctx.workflowTasks.filter(
2735
+ (task) => !task.completed && !task.group
2736
+ );
2737
+ const ids = tasks.map((task) => task.id);
2738
+ if (saved && (saved.version !== 1 || ids.some((id) => !saved.calls.includes(id)))) {
2739
+ throw new Error("Parallel call membership changed during replay.");
2740
+ }
2741
+ const group = {
2742
+ key,
2743
+ record: saved ?? { version: 1, calls: ids, interrupts: [] },
2744
+ restored: Boolean(saved),
2745
+ ready: Promise.resolve()
2746
+ };
2747
+ ctx.currentNodeState.byKey[key] = group.record;
2748
+ ctx.stateDirty = true;
2749
+ ctx.parallelGroup = group;
2750
+ for (const task of tasks) task.group = group;
2751
+ const callRecord = (id) => ctx.currentNodeState.byKey[`__useWorkflow:${id}`];
2752
+ const bridge = async (callId, index) => {
2753
+ const interruption = callRecord(callId)?.interrupts[index];
2754
+ if (!interruption)
2755
+ throw new Error(
2756
+ "Parallel interrupt history does not match the saved child."
2757
+ );
2758
+ try {
2759
+ interruption.response = await awaitInterruptInternal({
2760
+ request: interruption.request,
2761
+ ...interruption.request.meta ? { meta: interruption.request.meta } : {}
2762
+ });
2763
+ } catch (error) {
2764
+ ctx.workflowControlError = error;
2765
+ throw error;
2766
+ }
2767
+ };
2768
+ group.ready = (async () => {
2769
+ for (const entry of group.record.interrupts)
2770
+ await bridge(entry.callId, entry.index);
2771
+ })();
2772
+ try {
2773
+ const [ready, outcomes] = await Promise.all([
2774
+ Promise.allSettled([group.ready]),
2775
+ settled
2776
+ ]);
2777
+ let drained = -1;
2778
+ while (drained !== ctx.workflowTasks.length) {
2779
+ drained = ctx.workflowTasks.length;
2780
+ await Promise.all(
2781
+ ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.done)
2782
+ );
2783
+ }
2784
+ if (ready[0]?.status === "rejected") throw ready[0].reason;
2785
+ (0, import_core62.throwIfExecutionAborted)(ctx.node.abortSignal);
2786
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2787
+ const rejected = outcomes.filter(
2788
+ (outcome) => outcome.status === "rejected"
2789
+ );
2790
+ const control = rejected.find(
2791
+ (outcome) => (0, import_core62.isExecutionCancelled)(outcome.reason) || (0, import_core62.isExecutionLimitReached)(outcome.reason)
2792
+ );
2793
+ if (control) {
2794
+ ctx.workflowControlError = control.reason;
2795
+ throw control.reason;
2796
+ }
2797
+ const actual = ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.id);
2798
+ if (group.record.calls.length !== actual.length || group.record.calls.some((id) => !actual.includes(id))) {
2799
+ throw new Error("Parallel call membership changed during replay.");
2800
+ }
2801
+ for (const callId of group.record.calls) {
2802
+ const record = callRecord(callId);
2803
+ if (record?.status !== "interrupted") continue;
2804
+ const index = record.interrupts.length - 1;
2805
+ if (record.interrupts[index]?.response !== void 0) continue;
2806
+ group.record.interrupts.push({ callId, index });
2807
+ await bridge(callId, index);
2808
+ throw new Error(
2809
+ "Parallel interrupt unexpectedly resumed without replay."
2810
+ );
2811
+ }
2812
+ if (rejected.length) throw new ParallelError(outcomes);
2813
+ return outcomes.map(
2814
+ (outcome) => outcome.value
2815
+ );
2816
+ } finally {
2817
+ ctx.parallelGroup = void 0;
2818
+ }
2819
+ };
2820
+ const promise = join();
2821
+ ctx.parallelJoins.push(promise);
2822
+ void promise.catch(() => {
2823
+ });
2824
+ return promise;
2825
+ }
2826
+ var import_node_crypto5 = require("crypto");
2827
+ var import_core7 = require("@kortyx/core");
2652
2828
  var import_node_crypto42 = require("crypto");
2653
2829
  function emitWorkflowCall2(telemetry, type, payload) {
2654
2830
  const base = telemetry?.correlation;
@@ -2730,16 +2906,30 @@ ${tryStringify2(args.response)}`
2730
2906
  };
2731
2907
  }
2732
2908
  async function useWorkflow(args) {
2909
+ const task = registerWorkflowTask(args.id);
2910
+ try {
2911
+ await Promise.resolve();
2912
+ if (task.group) await task.group.ready;
2913
+ return await runWorkflowCall(args, task);
2914
+ } catch (error) {
2915
+ if ((0, import_core7.isExecutionCancelled)(error) || (0, import_core7.isExecutionLimitReached)(error))
2916
+ getHookContext().workflowControlError = error;
2917
+ throw error;
2918
+ } finally {
2919
+ task.finish();
2920
+ }
2921
+ }
2922
+ async function runWorkflowCall(args, task) {
2733
2923
  const ctx = getHookContext();
2734
- (0, import_core62.throwIfExecutionAborted)(ctx.node.abortSignal);
2924
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2735
2925
  const service = ctx.node.callWorkflow;
2736
2926
  if (!service)
2737
2927
  throw new Error("useWorkflow requires an agent workflow registry.");
2738
2928
  if (!args.id.trim())
2739
2929
  throw new Error("useWorkflow requires a stable, nonempty id.");
2740
- if (ctx.workflowCallActive)
2930
+ if (ctx.workflowCallActive && !task.group)
2741
2931
  throw new Error(
2742
- "Concurrent workflow calls are not supported; await each call."
2932
+ "Concurrent workflow calls require parallel([...]); otherwise await each call."
2743
2933
  );
2744
2934
  if (ctx.workflowCallIds.has(args.id))
2745
2935
  throw new Error(
@@ -2760,6 +2950,11 @@ ${tryStringify2(args.response)}`
2760
2950
  });
2761
2951
  const key = `__useWorkflow:${args.id}`;
2762
2952
  let record = ctx.currentNodeState.byKey[key];
2953
+ if (record && record.parallelKey !== task.group?.key)
2954
+ throw new WorkflowCallError(
2955
+ workflow,
2956
+ "parallel call membership changed during replay"
2957
+ );
2763
2958
  if (record && record.fingerprint !== fingerprint)
2764
2959
  throw new WorkflowCallError(
2765
2960
  workflow,
@@ -2770,11 +2965,12 @@ ${tryStringify2(args.response)}`
2770
2965
  fingerprint,
2771
2966
  invocationId: (0, import_node_crypto5.randomUUID)(),
2772
2967
  status: "running",
2773
- interrupts: []
2968
+ interrupts: [],
2969
+ ...task.group ? { parallelKey: task.group.key } : {}
2774
2970
  };
2775
2971
  ctx.currentNodeState.byKey[key] = record;
2776
2972
  ctx.stateDirty = true;
2777
- ctx.workflowCallActive = true;
2973
+ if (!task.group) ctx.workflowCallActive = true;
2778
2974
  const telemetry = ctx.node.workflowCallTelemetry;
2779
2975
  const activationKey = "__useWorkflowActivation";
2780
2976
  ctx.currentNodeState.byKey[activationKey] ??= (0, import_node_crypto5.randomUUID)();
@@ -2810,7 +3006,7 @@ ${tryStringify2(args.response)}`
2810
3006
  };
2811
3007
  const cached = record.status === "completed";
2812
3008
  try {
2813
- for (const interruption of record.interrupts) {
3009
+ for (const interruption of task.group ? [] : record.interrupts) {
2814
3010
  interruption.response = await awaitInterruptInternal({
2815
3011
  request: interruption.request,
2816
3012
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2818,6 +3014,8 @@ ${tryStringify2(args.response)}`
2818
3014
  }
2819
3015
  if (record.status === "failed")
2820
3016
  throw new WorkflowCallError(workflow, record.error ?? "child failed");
3017
+ if (task.group && record.status === "interrupted" && record.interrupts.at(-1)?.response === void 0)
3018
+ throw new ParallelChildWaiting();
2821
3019
  while (record.status !== "completed") {
2822
3020
  let outcome;
2823
3021
  try {
@@ -2836,13 +3034,13 @@ ${tryStringify2(args.response)}`
2836
3034
  ...record.interrupts.length ? { response: record.interrupts.at(-1)?.response } : {}
2837
3035
  });
2838
3036
  } catch (error) {
2839
- (0, import_core62.throwIfExecutionAborted)(ctx.node.abortSignal);
2840
- if ((0, import_core62.isExecutionCancelled)(error)) throw error;
2841
- if ((0, import_core62.isExecutionLimitReached)(error)) {
2842
- const child = error;
2843
- if (child.__kortyxChildSnapshot)
3037
+ const child = error;
3038
+ recordUsage(child?.__kortyxChildUsage);
3039
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
3040
+ if ((0, import_core7.isExecutionCancelled)(error)) throw error;
3041
+ if ((0, import_core7.isExecutionLimitReached)(error)) {
3042
+ if (child?.__kortyxChildSnapshot)
2844
3043
  record.snapshot = child.__kortyxChildSnapshot;
2845
- recordUsage(child.__kortyxChildUsage);
2846
3044
  report("suspended", {
2847
3045
  reason: "limit_reached",
2848
3046
  limit: error.limitReached
@@ -2854,7 +3052,7 @@ ${tryStringify2(args.response)}`
2854
3052
  report("failed", { error: "Child workflow failed" });
2855
3053
  throw new WorkflowCallError(workflow, record.error);
2856
3054
  }
2857
- (0, import_core62.throwIfExecutionAborted)(ctx.node.abortSignal);
3055
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2858
3056
  recordUsage(outcome.usage);
2859
3057
  if (outcome.status === "completed") {
2860
3058
  record.data = outcome.data;
@@ -2875,6 +3073,7 @@ ${tryStringify2(args.response)}`
2875
3073
  request: outcome.request
2876
3074
  };
2877
3075
  record.interrupts.push(interruption);
3076
+ if (task.group) throw new ParallelChildWaiting();
2878
3077
  interruption.response = await awaitInterruptInternal({
2879
3078
  request: interruption.request,
2880
3079
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2888,7 +3087,7 @@ ${tryStringify2(args.response)}`
2888
3087
  data: JSON.parse(JSON.stringify(data))
2889
3088
  };
2890
3089
  } finally {
2891
- ctx.workflowCallActive = false;
3090
+ if (!task.group) ctx.workflowCallActive = false;
2892
3091
  }
2893
3092
  }
2894
3093
  }