@kortyx/hooks 0.34.2 → 0.34.3

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Return recoverable, bounded model feedback for invalid named interrupt requests. Preserve strict human responses, request quotas, approval gates, and tool-loop limits.
6
+ - Replace child execution snapshots natively while reading legacy object and single-element array snapshots.
7
+
8
+ ## [0.34.3](https://github.com/kortyx-io/kortyx/compare/hooks-v0.34.2...hooks-v0.34.3) (2026-10-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **runtime:** preserve interrupt state across pauses and resume ([#348](https://github.com/kortyx-io/kortyx/issues/348)) ([03b49f2](https://github.com/kortyx-io/kortyx/commit/03b49f2b8b6b44a2852d16fd4fe1b3e199316072))
14
+
3
15
  ## [0.34.2](https://github.com/kortyx-io/kortyx/compare/hooks-v0.34.1...hooks-v0.34.2) (2026-10-13)
4
16
 
5
17
 
package/README.md CHANGED
@@ -35,6 +35,8 @@ tool loop, so tools can run before and after a pause. Use `mode: "optional"`
35
35
  when the model may finish without asking, and `maxRequests` to bound sequential
36
36
  human turns.
37
37
 
38
+ Malformed model-authored requests return an `isError` tool result so the model can correct its arguments. They consume the existing model/loop allowance, but do not issue a human request or consume `maxRequests`. Required mode still requires a valid request. Human response validation and approval denial remain strict.
39
+
38
40
  The singular `useReason({ interrupt })` option and `result.interruptResponse`
39
41
  are deprecated and will be removed in the next major release. Migrate to
40
42
  `interrupts.contracts` and `result.interruptHistory`.
package/dist/index.js CHANGED
@@ -245,7 +245,7 @@ var import_node = require("@kortyx/usage-telemetry/node");
245
245
  // package.json
246
246
  var package_default = {
247
247
  name: "@kortyx/hooks",
248
- version: "0.34.2",
248
+ version: "0.34.3",
249
249
  private: false,
250
250
  description: "Lifecycle hooks and extension points for Kortyx.",
251
251
  keywords: [
@@ -2965,6 +2965,40 @@ var emitReasonStructuredOutput = (args) => {
2965
2965
  var import_core5 = require("@kortyx/core");
2966
2966
  var import_errors6 = require("@kortyx/core/errors");
2967
2967
  var import_zod5 = require("zod");
2968
+
2969
+ // src/reason/input-feedback.ts
2970
+ var record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2971
+ function schemaPath(schema, path) {
2972
+ let current = schema;
2973
+ let label = "";
2974
+ for (const part of path.slice(0, 8)) {
2975
+ if (!record(current)) break;
2976
+ if (typeof part === "number" && Number.isSafeInteger(part) && part >= 0) {
2977
+ if (!record(current.items)) break;
2978
+ current = current.items;
2979
+ label += `[${part}]`;
2980
+ } else if (typeof part === "string" && record(current.properties) && Object.hasOwn(current.properties, part)) {
2981
+ current = current.properties[part];
2982
+ label += `${label ? "." : ""}${part.slice(0, 64)}`;
2983
+ } else break;
2984
+ }
2985
+ return label || "input";
2986
+ }
2987
+ function interruptInputFeedback(error, schema, name, required) {
2988
+ const issues = Array.isArray(error.issues) ? error.issues : [];
2989
+ const problems = issues.slice(0, 5).map((issue) => {
2990
+ const path = record(issue) && Array.isArray(issue.path) ? issue.path : [];
2991
+ return `- ${schemaPath(schema, path)} must satisfy its declared input schema.`;
2992
+ });
2993
+ return [
2994
+ `Invalid input for ${name}:`,
2995
+ ...problems,
2996
+ ...issues.length > 5 ? ["- Additional input fields are invalid."] : [],
2997
+ required ? "Correct the input and call the tool again. Human input is required before finishing." : "Correct the input and call the tool again, or finish without requesting human input when none is needed."
2998
+ ].join("\n");
2999
+ }
3000
+
3001
+ // src/reason/tools.ts
2968
3002
  var toToolDefinitions = (tools2) => tools2.map((tool) => ({
2969
3003
  name: tool.name,
2970
3004
  ...tool.title ? { title: tool.title } : {},
@@ -3691,11 +3725,35 @@ var runReasonToolLoop = async (args) => {
3691
3725
  throw new Error(
3692
3726
  `useReason reached interrupts.maxRequests (${interruptConfig?.maxRequests ?? 1}).`
3693
3727
  );
3694
- const request = parseWithSchema(
3695
- resolved.contract.requestSchema,
3696
- toolCall.input,
3697
- `useReason interrupt contract "${resolved.name}" request`
3698
- );
3728
+ let request;
3729
+ try {
3730
+ request = parseWithSchema(
3731
+ resolved.contract.requestSchema,
3732
+ toolCall.input,
3733
+ `useReason interrupt contract "${resolved.name}" request`
3734
+ );
3735
+ } catch (error) {
3736
+ if (!(error instanceof import_errors6.ValidationError)) throw error;
3737
+ const result3 = {
3738
+ toolCallId: toolCall.id,
3739
+ name: toolCall.name,
3740
+ isError: true,
3741
+ content: interruptInputFeedback(
3742
+ error,
3743
+ interruptDefinitions.find((tool2) => tool2.name === toolCall.name)?.inputSchema,
3744
+ toolCall.name,
3745
+ interruptConfig?.mode === "required"
3746
+ )
3747
+ };
3748
+ toolResults.push(result3);
3749
+ messages.push({ role: "tool", ...result3 });
3750
+ traceSpan?.addEvent?.("useReason.interrupt.invalid", {
3751
+ checkpointKey,
3752
+ contract: resolved.name
3753
+ });
3754
+ save();
3755
+ continue;
3756
+ }
3699
3757
  traceSpan?.addEvent?.("useReason.interrupt.requested", {
3700
3758
  checkpointKey,
3701
3759
  contract: resolved.name,
@@ -4635,10 +4693,10 @@ function parallel(values) {
4635
4693
  throw new Error("Parallel call membership changed during replay.");
4636
4694
  }
4637
4695
  for (const callId of group.record.calls) {
4638
- const record = callRecord(callId);
4639
- if (record?.status !== "interrupted") continue;
4640
- const index = record.interrupts.length - 1;
4641
- if (record.interrupts[index]?.response !== void 0) continue;
4696
+ const record2 = callRecord(callId);
4697
+ if (record2?.status !== "interrupted") continue;
4698
+ const index = record2.interrupts.length - 1;
4699
+ if (record2.interrupts[index]?.response !== void 0) continue;
4642
4700
  group.record.interrupts.push({ callId, index });
4643
4701
  await bridge(callId, index);
4644
4702
  throw new Error(
@@ -4809,26 +4867,26 @@ async function runWorkflowCall(args, task) {
4809
4867
  version: definition?.version ?? null
4810
4868
  });
4811
4869
  const key = `__useWorkflow:${args.id}`;
4812
- let record = ctx.currentNodeState.byKey[key];
4813
- if (record && record.parallelKey !== task.group?.key)
4870
+ let record2 = ctx.currentNodeState.byKey[key];
4871
+ if (record2 && record2.parallelKey !== task.group?.key)
4814
4872
  throw new WorkflowCallError(
4815
4873
  workflow,
4816
4874
  "parallel call membership changed during replay"
4817
4875
  );
4818
- if (record && record.fingerprint !== fingerprint)
4876
+ if (record2 && record2.fingerprint !== fingerprint)
4819
4877
  throw new WorkflowCallError(
4820
4878
  workflow,
4821
4879
  "call input or target changed during replay"
4822
4880
  );
4823
- if (!record) ctx.node.consumeExecution?.("maxChildInvocations");
4824
- record ??= {
4881
+ if (!record2) ctx.node.consumeExecution?.("maxChildInvocations");
4882
+ record2 ??= {
4825
4883
  fingerprint,
4826
4884
  invocationId: (0, import_node_crypto6.randomUUID)(),
4827
4885
  status: "running",
4828
4886
  interrupts: [],
4829
4887
  ...task.group ? { parallelKey: task.group.key } : {}
4830
4888
  };
4831
- ctx.currentNodeState.byKey[key] = record;
4889
+ ctx.currentNodeState.byKey[key] = record2;
4832
4890
  ctx.stateDirty = true;
4833
4891
  if (!task.group) ctx.workflowCallActive = true;
4834
4892
  const telemetry = ctx.node.workflowCallTelemetry;
@@ -4836,7 +4894,7 @@ async function runWorkflowCall(args, task) {
4836
4894
  ctx.currentNodeState.byKey[activationKey] ??= (0, import_node_crypto6.randomUUID)();
4837
4895
  const callerNodeExecutionId = ctx.currentNodeState.byKey[activationKey];
4838
4896
  const metadata = {
4839
- invocationId: record.invocationId,
4897
+ invocationId: record2.invocationId,
4840
4898
  parentInvocationId: telemetry?.correlation?.invocationId ?? null,
4841
4899
  branchId: telemetry?.correlation?.branchId ?? telemetry?.correlation?.runId ?? "unknown",
4842
4900
  callId: args.id,
@@ -4846,46 +4904,49 @@ async function runWorkflowCall(args, task) {
4846
4904
  targetWorkflowId: workflow,
4847
4905
  targetVersion: definition?.version ?? null
4848
4906
  };
4849
- record.telemetry = metadata;
4907
+ record2.telemetry = metadata;
4850
4908
  const report = (status, content = {}) => {
4851
- record.sequence = (record.sequence ?? 0) + 1;
4909
+ record2.sequence = (record2.sequence ?? 0) + 1;
4852
4910
  emitWorkflowCall(telemetry, `workflow.call.${status}`, {
4853
4911
  ...metadata,
4854
- sequence: record.sequence,
4912
+ sequence: record2.sequence,
4855
4913
  ...content
4856
4914
  });
4857
4915
  };
4858
4916
  const recordUsage = (usage2) => {
4859
4917
  if (!usage2) return;
4860
4918
  accumulateTokenUsage({
4861
- input: usage2.input - (record.usage?.input ?? 0),
4862
- output: usage2.output - (record.usage?.output ?? 0),
4863
- total: usage2.total - (record.usage?.total ?? 0)
4919
+ input: usage2.input - (record2.usage?.input ?? 0),
4920
+ output: usage2.output - (record2.usage?.output ?? 0),
4921
+ total: usage2.total - (record2.usage?.total ?? 0)
4864
4922
  });
4865
- record.usage = usage2;
4923
+ record2.usage = usage2;
4866
4924
  };
4867
- const cached = record.status === "completed";
4925
+ const cached = record2.status === "completed";
4868
4926
  try {
4869
- for (const interruption of task.group ? [] : record.interrupts) {
4927
+ for (const interruption of task.group ? [] : record2.interrupts) {
4870
4928
  interruption.response = await awaitInterruptInternal({
4871
4929
  request: interruption.request,
4872
4930
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
4873
4931
  });
4874
4932
  }
4875
- if (record.status === "failed")
4933
+ if (record2.status === "failed")
4876
4934
  throw new WorkflowCallError(
4877
4935
  workflow,
4878
- record.error ?? "child failed",
4936
+ record2.error ?? "child failed",
4879
4937
  void 0,
4880
- record.failure ?? (0, import_errors8.serializeFailure)(void 0)
4938
+ record2.failure ?? (0, import_errors8.serializeFailure)(void 0)
4881
4939
  );
4882
- if (task.group && record.status === "interrupted" && record.interrupts.at(-1)?.response === void 0)
4940
+ if (task.group && record2.status === "interrupted" && record2.interrupts.at(-1)?.response === void 0)
4883
4941
  throw new ParallelChildWaiting();
4884
- while (record.status !== "completed") {
4942
+ while (record2.status !== "completed") {
4885
4943
  let outcome;
4886
4944
  try {
4945
+ if (Array.isArray(record2.snapshot) && record2.snapshot.length !== 1)
4946
+ throw new import_errors8.WorkflowContractError("Invalid child workflow snapshot.");
4947
+ const snapshot = Array.isArray(record2.snapshot) ? record2.snapshot[0] : record2.snapshot;
4887
4948
  report(
4888
- record.snapshot ? "resumed" : "started",
4949
+ snapshot ? "resumed" : "started",
4889
4950
  workflowCallContent(telemetry, "input", input)
4890
4951
  );
4891
4952
  outcome = await service({
@@ -4894,9 +4955,9 @@ async function runWorkflowCall(args, task) {
4894
4955
  workflow,
4895
4956
  input,
4896
4957
  definition,
4897
- invocationId: record.invocationId,
4898
- ...record.snapshot ? { snapshot: record.snapshot } : {},
4899
- ...record.interrupts.length ? { response: record.interrupts.at(-1)?.response } : {}
4958
+ invocationId: record2.invocationId,
4959
+ ...snapshot ? { snapshot } : {},
4960
+ ...record2.interrupts.length ? { response: record2.interrupts.at(-1)?.response } : {}
4900
4961
  });
4901
4962
  } catch (error) {
4902
4963
  const child = error;
@@ -4905,48 +4966,48 @@ async function runWorkflowCall(args, task) {
4905
4966
  if ((0, import_core8.isExecutionCancelled)(error)) throw error;
4906
4967
  if ((0, import_core8.isExecutionLimitReached)(error)) {
4907
4968
  if (child?.__kortyxChildSnapshot)
4908
- record.snapshot = child.__kortyxChildSnapshot;
4969
+ record2.snapshot = child.__kortyxChildSnapshot;
4909
4970
  report("suspended", {
4910
4971
  reason: "limit_reached",
4911
4972
  limit: error.limitReached
4912
4973
  });
4913
4974
  throw error;
4914
4975
  }
4915
- record.status = "failed";
4916
- record.failure = (0, import_errors8.serializeFailure)(error);
4917
- record.error = record.failure.message;
4976
+ record2.status = "failed";
4977
+ record2.failure = (0, import_errors8.serializeFailure)(error);
4978
+ record2.error = record2.failure.message;
4918
4979
  report("failed", { error: "Child workflow failed" });
4919
4980
  throw new WorkflowCallError(
4920
4981
  workflow,
4921
- record.error,
4982
+ record2.error,
4922
4983
  error,
4923
- record.failure
4984
+ record2.failure
4924
4985
  );
4925
4986
  }
4926
4987
  (0, import_core8.throwIfExecutionAborted)(ctx.node.abortSignal);
4927
4988
  recordUsage(outcome.usage);
4928
4989
  if (outcome.status === "completed") {
4929
- record.data = outcome.data;
4930
- workflowCallFingerprint(record.data);
4931
- record.status = "completed";
4932
- record.toolObservations = outcome.toolObservations ?? [];
4933
- for (const observation of record.toolObservations)
4990
+ record2.data = outcome.data;
4991
+ workflowCallFingerprint(record2.data);
4992
+ record2.status = "completed";
4993
+ record2.toolObservations = outcome.toolObservations ?? [];
4994
+ for (const observation of record2.toolObservations)
4934
4995
  rememberToolObservation(observation);
4935
- delete record.snapshot;
4996
+ delete record2.snapshot;
4936
4997
  report(
4937
4998
  "completed",
4938
- workflowCallContent(telemetry, "output", record.data)
4999
+ workflowCallContent(telemetry, "output", record2.data)
4939
5000
  );
4940
5001
  } else {
4941
- record.status = "interrupted";
4942
- record.snapshot = outcome.snapshot;
5002
+ record2.status = "interrupted";
5003
+ record2.snapshot = outcome.snapshot;
4943
5004
  report("suspended", {
4944
5005
  leaf: outcome.request.meta?.workflowCall ?? null
4945
5006
  });
4946
5007
  const interruption = {
4947
5008
  request: outcome.request
4948
5009
  };
4949
- record.interrupts.push(interruption);
5010
+ record2.interrupts.push(interruption);
4950
5011
  if (task.group) throw new ParallelChildWaiting();
4951
5012
  interruption.response = await awaitInterruptInternal({
4952
5013
  request: interruption.request,
@@ -4955,12 +5016,12 @@ async function runWorkflowCall(args, task) {
4955
5016
  }
4956
5017
  }
4957
5018
  if (cached) {
4958
- report("reused", workflowCallContent(telemetry, "output", record.data));
4959
- if (record.restoredToolBranch !== telemetry?.correlation?.branchId)
4960
- await replayToolObservations(record.toolObservations ?? [], true);
4961
- delete record.restoredToolBranch;
5019
+ report("reused", workflowCallContent(telemetry, "output", record2.data));
5020
+ if (record2.restoredToolBranch !== telemetry?.correlation?.branchId)
5021
+ await replayToolObservations(record2.toolObservations ?? [], true);
5022
+ delete record2.restoredToolBranch;
4962
5023
  }
4963
- const data = record.data ?? {};
5024
+ const data = record2.data ?? {};
4964
5025
  return {
4965
5026
  data: JSON.parse(JSON.stringify(data))
4966
5027
  };