@kortyx/hooks 0.27.0 → 0.28.1

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.1](https://github.com/kortyx-io/kortyx/compare/hooks-v0.28.0...hooks-v0.28.1) (2026-09-21)
4
+
5
+
6
+ ### Dependencies
7
+
8
+ * The following workspace dependencies were updated
9
+ * dependencies
10
+ * @kortyx/core bumped to 0.10.1
11
+ * @kortyx/providers bumped to 0.8.2
12
+ * @kortyx/telemetry-contracts bumped to 0.11.1
13
+
14
+ ## [0.28.0](https://github.com/kortyx-io/kortyx/compare/hooks-v0.27.0...hooks-v0.28.0) (2026-09-21)
15
+
16
+
17
+ ### Features
18
+
19
+ * **hooks:** add model-driven interrupt contracts to useReason ([#224](https://github.com/kortyx-io/kortyx/issues/224)) ([e0147d5](https://github.com/kortyx-io/kortyx/commit/e0147d5234ca38d450d452af924b242f8cbdeb16))
20
+
21
+
22
+ ### Dependencies
23
+
24
+ * The following workspace dependencies were updated
25
+ * dependencies
26
+ * @kortyx/core bumped to 0.10.0
27
+ * @kortyx/providers bumped to 0.8.1
28
+ * @kortyx/telemetry-contracts bumped to 0.11.0
29
+
3
30
  ## [0.27.0](https://github.com/kortyx-io/kortyx/compare/hooks-v0.26.0...hooks-v0.27.0) (2026-09-21)
4
31
 
5
32
 
package/README.md CHANGED
@@ -27,9 +27,16 @@ npm install @kortyx/hooks
27
27
  - `useRuntimeContext(...)` for request context made available to node execution.
28
28
  - `useNodeState(...)` / `useWorkflowState(...)` for stateful node logic.
29
29
 
30
- `useReason({ interrupt })` defaults to required interrupt behavior. Use
31
- `interrupt.mode: "optional"` when the model should decide whether to continue
32
- with a single model call or pause for user input.
30
+ Define reusable model-driven human-input contracts with
31
+ `defineInterruptContract(...)`, then pass one or more through
32
+ `useReason({ interrupts: { contracts } })`. Contract calls share the ordinary
33
+ tool loop, so tools can run before and after a pause. Use `mode: "optional"`
34
+ when the model may finish without asking, and `maxRequests` to bound sequential
35
+ human turns.
36
+
37
+ The singular `useReason({ interrupt })` option and `result.interruptResponse`
38
+ are deprecated and will be removed in the next major release. Migrate to
39
+ `interrupts.contracts` and `result.interruptHistory`.
33
40
 
34
41
  ## Runtime Resume Behavior
35
42
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _kortyx_core from '@kortyx/core';
2
- import { WorkflowDefinition, TokenUsage, InterruptInput, GraphState, NodeConfig, InterruptResult } from '@kortyx/core';
2
+ import { WorkflowDefinition, TokenUsage, InterruptInput, GraphState, NodeConfig, InterruptResumeValue, InterruptResult } from '@kortyx/core';
3
3
  import { K as KortyxTelemetryConfig, R as ReasonTraceAdapter, a as KortyxTraceMetadata, b as ReportErrorOptions, c as KortyxTelemetryEventType } from './tracing-BsxYlgEw.js';
4
4
  export { E as EnsureWorkflowTopologyRequest, d as EnsureWorkflowTopologyResponse, e as KortyxErrorDetails, f as KortyxTelemetryContentCapture, g as KortyxTelemetryCorrelation, h as KortyxTelemetryEvent, i as KortyxTelemetryPrompt, j as KortyxTelemetryReporter, k as KortyxTelemetryService, l as KortyxTraceAdapter, m as KortyxTraceErrorProjection, n as ReasonTraceAttributes, o as ReasonTraceSpan, p as ReasonTraceSpanEndArgs, q as ReasonTraceSpanStartArgs } from './tracing-BsxYlgEw.js';
5
5
  import { FailureDescriptor, KortyxError } from '@kortyx/core/errors';
@@ -95,7 +95,7 @@ type HookNodeRuntimeContext = {
95
95
  };
96
96
  config: NodeConfig;
97
97
  emit: (event: string, payload: unknown) => void;
98
- awaitInterrupt: (args: InterruptInput) => InterruptResult;
98
+ awaitInterrupt: (args: InterruptInput) => InterruptResumeValue;
99
99
  callWorkflow?: WorkflowCallService;
100
100
  workflowCallTelemetry?: KortyxTelemetryConfig | undefined;
101
101
  };
@@ -120,6 +120,30 @@ type SchemaLike<T> = {
120
120
  };
121
121
  };
122
122
  };
123
+ type InterruptContract<TRequest = unknown, TResponse = unknown> = {
124
+ /** Human-readable guidance included in the model-visible control tool. */
125
+ description: string;
126
+ requestSchema: SchemaLike<TRequest>;
127
+ responseSchema: SchemaLike<TResponse>;
128
+ /** Stable UI contract identifier. */
129
+ schemaId: string;
130
+ /** Version of the UI contract payload. */
131
+ schemaVersion: string;
132
+ };
133
+ type InterruptContractMap = Record<string, InterruptContract<unknown, unknown>>;
134
+ type UseReasonInterruptsConfig<TContracts extends InterruptContractMap = InterruptContractMap> = {
135
+ mode?: "required" | "optional" | undefined;
136
+ /** Maximum number of model-requested human interactions for this operation. */
137
+ maxRequests?: number | undefined;
138
+ contracts: TContracts;
139
+ };
140
+ type InterruptHistoryEntry<TContracts extends InterruptContractMap = InterruptContractMap> = {
141
+ [TName in Extract<keyof TContracts, string>]: TContracts[TName] extends InterruptContract<infer TRequest, infer TResponse> ? {
142
+ contract: TName;
143
+ request: TRequest;
144
+ response: TResponse;
145
+ } : never;
146
+ }[Extract<keyof TContracts, string>];
123
147
  type StructuredPath = string;
124
148
  type UseReasonStructuredFieldMode = "append" | "text-delta" | "set";
125
149
  type UseReasonStructuredFieldKey = string;
@@ -171,6 +195,10 @@ type UseReasonStructuredConfig = {
171
195
  */
172
196
  fields?: UseReasonStructuredFields | undefined;
173
197
  };
198
+ /**
199
+ * @deprecated Use `UseReasonInterruptsConfig` and `defineInterruptContract`.
200
+ * Removed in the next major release.
201
+ */
174
202
  type UseReasonInterruptConfig<TRequest extends InterruptInput = InterruptInput, TResponse = InterruptResult> = {
175
203
  mode?: "required" | "optional" | undefined;
176
204
  requestSchema: SchemaLike<TRequest>;
@@ -183,7 +211,7 @@ type UseReasonToolExecution = {
183
211
  approval?: boolean | Record<string, boolean> | undefined;
184
212
  emit?: boolean | Record<string, boolean> | undefined;
185
213
  };
186
- type UseReasonArgs<TOutput = unknown, TRequest extends InterruptInput = InterruptInput, TResponse = InterruptResult> = {
214
+ type UseReasonArgs<TOutput = unknown, TRequest extends InterruptInput = InterruptInput, TResponse = InterruptResult, TContracts extends InterruptContractMap = InterruptContractMap> = {
187
215
  model: ProviderModelRef;
188
216
  input: string;
189
217
  system?: string | undefined;
@@ -200,7 +228,9 @@ type UseReasonArgs<TOutput = unknown, TRequest extends InterruptInput = Interrup
200
228
  telemetry?: KortyxTraceMetadata | undefined;
201
229
  outputSchema?: SchemaLike<TOutput> | undefined;
202
230
  structured?: UseReasonStructuredConfig | undefined;
231
+ /** @deprecated Use `interrupts`. Removed in the next major release. */
203
232
  interrupt?: UseReasonInterruptConfig<TRequest, TResponse> | undefined;
233
+ interrupts?: UseReasonInterruptsConfig<TContracts> | undefined;
204
234
  tools?: KortyxExecutableTool[] | undefined;
205
235
  toolExecution?: UseReasonToolExecution | undefined;
206
236
  };
@@ -215,7 +245,7 @@ type UseReasonStep = {
215
245
  providerMetadata?: KortyxProviderMetadata | undefined;
216
246
  warnings?: KortyxWarning[] | undefined;
217
247
  };
218
- type UseReasonResult<TOutput = unknown, TResponse = InterruptResult> = {
248
+ type UseReasonResult<TOutput = unknown, TResponse = InterruptResult, TContracts extends InterruptContractMap = InterruptContractMap> = {
219
249
  id?: string;
220
250
  opId: string;
221
251
  text: string;
@@ -225,7 +255,9 @@ type UseReasonResult<TOutput = unknown, TResponse = InterruptResult> = {
225
255
  providerMetadata?: KortyxProviderMetadata;
226
256
  warnings?: KortyxWarning[];
227
257
  output?: TOutput;
258
+ /** @deprecated Use `interruptHistory`. Removed in the next major release. */
228
259
  interruptResponse?: TResponse;
260
+ interruptHistory?: InterruptHistoryEntry<TContracts>[];
229
261
  toolCalls?: KortyxToolCall[];
230
262
  toolResults?: KortyxToolResult[];
231
263
  steps?: UseReasonStep[];
@@ -239,10 +271,19 @@ type UseInterruptArgs<TRequest extends InterruptInput = InterruptInput, TRespons
239
271
  id?: string | undefined;
240
272
  meta?: Record<string, unknown> | undefined;
241
273
  };
274
+ type UseContractInterruptArgs<TRequest, TResponse> = {
275
+ contract: InterruptContract<TRequest, TResponse>;
276
+ request: TRequest;
277
+ id?: string | undefined;
278
+ meta?: Record<string, unknown> | undefined;
279
+ };
280
+
281
+ declare const defineInterruptContract: <TRequest, TResponse>(contract: InterruptContract<TRequest, TResponse>) => InterruptContract<TRequest, TResponse>;
242
282
 
243
283
  type StateSetter<T> = (next: T | ((prev: T) => T)) => void;
244
284
  declare function useStructuredData<TData = unknown>(args: UseStructuredDataArgs<TData>): void;
245
- declare function useReason<TOutput = unknown, TRequest extends InterruptInput = InterruptInput, TResponse = InterruptResult>(args: UseReasonArgs<TOutput, TRequest, TResponse>): Promise<UseReasonResult<TOutput, TResponse>>;
285
+ declare function useReason<TOutput = unknown, TRequest extends InterruptInput = InterruptInput, TResponse = InterruptResult, TContracts extends InterruptContractMap = InterruptContractMap>(args: UseReasonArgs<TOutput, TRequest, TResponse, TContracts>): Promise<UseReasonResult<TOutput, TResponse, TContracts>>;
286
+ declare function useInterrupt<TRequest, TResponse>(args: UseContractInterruptArgs<TRequest, TResponse>): Promise<TResponse>;
246
287
  declare function useInterrupt<TRequest extends InterruptInput = InterruptInput, TResponse = InterruptResult>(args: UseInterruptArgs<TRequest, TResponse>): Promise<TResponse>;
247
288
  /** Live execution signal for cooperative I/O. Never store it in workflow state. */
248
289
  declare function useAbortSignal(): AbortSignal | undefined;
@@ -264,4 +305,4 @@ declare function useTool<T extends KortyxExecutableTool>(args: UseToolArgs<T>):
264
305
  /** Best effort lifecycle facts. Engine snapshots and interrupt tokens never leave the runtime. */
265
306
  declare function emitWorkflowCall(telemetry: KortyxTelemetryConfig | undefined, type: KortyxTelemetryEventType, payload: Record<string, unknown>): void;
266
307
 
267
- export { type CompleteResponseOptions, type HookRuntimeContext, KortyxTelemetryConfig, KortyxTelemetryEventType, KortyxTraceMetadata, ParallelError, ReasonTraceAdapter, ReportErrorOptions, type UseInterruptArgs, type UseReasonArgs, type UseReasonResult, type UseReasonStep, type UseReasonToolExecution, type UseStructuredDataArgs, type UseToolArgs, WorkflowCallError, type WorkflowCallOutcome, type WorkflowCallService, completeResponse, createWorkflowHooks, emitWorkflowCall, parallel, reportError, runWithHookContext, useAbortSignal, useInterrupt, useNodeState, useReason, useRuntimeContext, useStructuredData, useTool, useWorkflow, useWorkflowState, workflowCallFingerprint };
308
+ export { type CompleteResponseOptions, type HookRuntimeContext, type InterruptContract, type InterruptContractMap, type InterruptHistoryEntry, KortyxTelemetryConfig, KortyxTelemetryEventType, KortyxTraceMetadata, ParallelError, ReasonTraceAdapter, ReportErrorOptions, type UseContractInterruptArgs, type UseInterruptArgs, type UseReasonArgs, type UseReasonInterruptConfig, type UseReasonInterruptsConfig, type UseReasonResult, type UseReasonStep, type UseReasonToolExecution, type UseStructuredDataArgs, type UseToolArgs, WorkflowCallError, type WorkflowCallOutcome, type WorkflowCallService, completeResponse, createWorkflowHooks, defineInterruptContract, emitWorkflowCall, parallel, reportError, runWithHookContext, useAbortSignal, useInterrupt, useNodeState, useReason, useRuntimeContext, useStructuredData, useTool, useWorkflow, useWorkflowState, workflowCallFingerprint };
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  WorkflowCallError: () => WorkflowCallError,
24
24
  completeResponse: () => completeResponse,
25
25
  createWorkflowHooks: () => createWorkflowHooks,
26
+ defineInterruptContract: () => defineInterruptContract,
26
27
  emitWorkflowCall: () => emitWorkflowCall,
27
28
  parallel: () => parallel,
28
29
  reportError: () => reportError,
@@ -349,6 +350,97 @@ var awaitInterruptInternal = (args) => {
349
350
  return Promise.resolve(response);
350
351
  };
351
352
 
353
+ // src/interrupt-contract.ts
354
+ var CONTROL_TOOL_PREFIX = "kortyx_request_input__";
355
+ var LEGACY_CONTRACT_NAME = "default";
356
+ var legacyWarningEmitted = false;
357
+ var legacyConfigs = /* @__PURE__ */ new WeakSet();
358
+ var defineInterruptContract = (contract) => {
359
+ if (!contract || typeof contract !== "object")
360
+ throw new Error("defineInterruptContract requires a contract object.");
361
+ for (const [field, value] of [
362
+ ["description", contract.description],
363
+ ["schemaId", contract.schemaId],
364
+ ["schemaVersion", contract.schemaVersion]
365
+ ])
366
+ if (typeof value !== "string" || value.trim().length === 0)
367
+ throw new Error(
368
+ `Interrupt contract ${field} must be a non-empty string.`
369
+ );
370
+ if (typeof contract.requestSchema?.safeParse !== "function")
371
+ throw new Error(
372
+ "Interrupt contract requestSchema must provide safeParse(value)."
373
+ );
374
+ if (typeof contract.responseSchema?.safeParse !== "function")
375
+ throw new Error(
376
+ "Interrupt contract responseSchema must provide safeParse(value)."
377
+ );
378
+ return contract;
379
+ };
380
+ var interruptControlToolName = (name) => `${CONTROL_TOOL_PREFIX}${name}`;
381
+ var isInterruptControlToolName = (name) => name.startsWith(CONTROL_TOOL_PREFIX);
382
+ var assertInterruptContractName = (name) => {
383
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name))
384
+ throw new Error(
385
+ `Invalid useReason interrupt contract name "${name}". Use letters, numbers, underscores, or hyphens, starting with a letter.`
386
+ );
387
+ if (interruptControlToolName(name).length > 64)
388
+ throw new Error(
389
+ `useReason interrupt contract name "${name}" is too long for a provider tool name.`
390
+ );
391
+ };
392
+ var warnLegacyUseReasonInterrupt = () => {
393
+ if (legacyWarningEmitted) return;
394
+ legacyWarningEmitted = true;
395
+ process.emitWarning(
396
+ "useReason({ interrupt }) is deprecated and will be removed in the next major release. Define an interrupt contract with defineInterruptContract() and pass it through useReason({ interrupts: { contracts } }). See docs/internal/next-major-removals.md.",
397
+ {
398
+ code: "KORTYX_USE_REASON_INTERRUPT_DEPRECATED",
399
+ type: "DeprecationWarning"
400
+ }
401
+ );
402
+ };
403
+ var normalizeReasonInterrupts = (args) => {
404
+ if (args.interrupt && args.interrupts)
405
+ throw new Error(
406
+ "useReason cannot receive both deprecated `interrupt` and `interrupts`. Migrate the legacy configuration to an interrupt contract."
407
+ );
408
+ if (args.interrupts) {
409
+ for (const [name, contract] of Object.entries(args.interrupts.contracts)) {
410
+ assertInterruptContractName(name);
411
+ defineInterruptContract(contract);
412
+ }
413
+ if (Object.keys(args.interrupts.contracts).length === 0)
414
+ throw new Error("useReason interrupts.contracts cannot be empty.");
415
+ if (args.interrupts.maxRequests !== void 0 && (!Number.isInteger(args.interrupts.maxRequests) || args.interrupts.maxRequests < 1))
416
+ throw new Error("useReason interrupts.maxRequests must be at least 1.");
417
+ return args.interrupts;
418
+ }
419
+ if (!args.interrupt) return void 0;
420
+ warnLegacyUseReasonInterrupt();
421
+ const normalized = {
422
+ mode: args.interrupt.mode,
423
+ maxRequests: 1,
424
+ contracts: {
425
+ [LEGACY_CONTRACT_NAME]: {
426
+ description: "Ask the user for the additional information required to complete this request.",
427
+ requestSchema: args.interrupt.requestSchema,
428
+ responseSchema: args.interrupt.responseSchema ?? {
429
+ safeParse: (value) => ({
430
+ success: true,
431
+ data: value
432
+ })
433
+ },
434
+ schemaId: args.interrupt.schemaId ?? "kortyx.legacy-interrupt",
435
+ schemaVersion: args.interrupt.schemaVersion ?? "1"
436
+ }
437
+ }
438
+ };
439
+ legacyConfigs.add(normalized);
440
+ return normalized;
441
+ };
442
+ var isLegacyInterruptContracts = (config) => Boolean(config && legacyConfigs.has(config));
443
+
352
444
  // src/reason/use-reason.ts
353
445
  var import_core6 = require("@kortyx/core");
354
446
 
@@ -2362,6 +2454,7 @@ var emitReasonStructuredOutput = (args) => {
2362
2454
  // src/reason/tools.ts
2363
2455
  var import_core5 = require("@kortyx/core");
2364
2456
  var import_errors6 = require("@kortyx/core/errors");
2457
+ var import_zod4 = require("zod");
2365
2458
  var toToolDefinitions = (tools2) => tools2.map((tool) => ({
2366
2459
  name: tool.name,
2367
2460
  ...tool.title ? { title: tool.title } : {},
@@ -2397,7 +2490,12 @@ var normalizeToolResult = (toolCall, value) => {
2397
2490
  };
2398
2491
  };
2399
2492
  var createInitialMessages = (args) => [
2400
- ...typeof args.system === "string" && args.system.length > 0 ? [{ role: "system", content: args.system }] : [],
2493
+ ...typeof args.system === "string" || args.interruptInstructions ? [
2494
+ {
2495
+ role: "system",
2496
+ content: [args.system, args.interruptInstructions].filter((value) => Boolean(value)).join("\n\n")
2497
+ }
2498
+ ] : [],
2401
2499
  { role: "user", content: String(args.input ?? "") }
2402
2500
  ];
2403
2501
  var runReasonToolLoop = async (args) => {
@@ -2412,19 +2510,23 @@ var runReasonToolLoop = async (args) => {
2412
2510
  const checkpoint = saved?.status === "tool_loop" ? saved : void 0;
2413
2511
  const opId = checkpoint?.opId ?? args.opId;
2414
2512
  const tools2 = useReasonArgs.tools ?? [];
2513
+ const interruptConfig = normalizeReasonInterrupts({
2514
+ interrupt: useReasonArgs.interrupt,
2515
+ interrupts: useReasonArgs.interrupts
2516
+ });
2517
+ const interruptByToolName = /* @__PURE__ */ new Map();
2415
2518
  const toolByName = /* @__PURE__ */ new Map();
2416
2519
  let separateOutput = false;
2417
2520
  for (const tool of tools2) {
2521
+ if (isInterruptControlToolName(tool.name))
2522
+ throw new Error(
2523
+ `useReason tool name "${tool.name}" uses the reserved interrupt control-tool namespace.`
2524
+ );
2418
2525
  if (toolByName.has(tool.name)) {
2419
2526
  throw new Error(`useReason received duplicate tool name "${tool.name}".`);
2420
2527
  }
2421
2528
  toolByName.set(tool.name, tool);
2422
2529
  }
2423
- if (useReasonArgs.interrupt) {
2424
- throw new Error(
2425
- "useReason tools cannot be combined with useReason interrupt mode yet. Use toolExecution.approval for tool approval."
2426
- );
2427
- }
2428
2530
  separateOutput = Boolean(
2429
2531
  useReasonArgs.model.provider.getModel(useReasonArgs.model.modelId, {
2430
2532
  ...useReasonArgs.model.options,
@@ -2436,21 +2538,63 @@ var runReasonToolLoop = async (args) => {
2436
2538
  ) ?? {}
2437
2539
  }).requiresSeparateStructuredOutput
2438
2540
  );
2439
- const toolDefinitions = toToolDefinitions(tools2);
2541
+ const interruptDefinitions = [];
2542
+ for (const [name, contract] of Object.entries(
2543
+ interruptConfig?.contracts ?? {}
2544
+ )) {
2545
+ const toolName = interruptControlToolName(name);
2546
+ if (toolByName.has(toolName))
2547
+ throw new Error(
2548
+ `useReason tool name "${toolName}" is reserved for interrupt contracts.`
2549
+ );
2550
+ let inputSchema;
2551
+ try {
2552
+ inputSchema = (0, import_zod4.toJSONSchema)(contract.requestSchema);
2553
+ } catch (error) {
2554
+ throw new Error(
2555
+ `useReason interrupt contract "${name}" requestSchema must support JSON Schema conversion.`,
2556
+ { cause: error }
2557
+ );
2558
+ }
2559
+ interruptByToolName.set(toolName, { name, contract });
2560
+ interruptDefinitions.push({
2561
+ name: toolName,
2562
+ title: `Request human input: ${name}`,
2563
+ description: [
2564
+ contract.description,
2565
+ "Call this only when human input is needed. Execution pauses until the validated response is returned as this tool's result."
2566
+ ].join(" "),
2567
+ inputSchema,
2568
+ metadata: {
2569
+ kortyxControl: "interrupt",
2570
+ contract: name,
2571
+ schemaId: contract.schemaId,
2572
+ schemaVersion: contract.schemaVersion
2573
+ }
2574
+ });
2575
+ }
2576
+ const toolDefinitions = [
2577
+ ...toToolDefinitions(tools2),
2578
+ ...interruptDefinitions
2579
+ ];
2440
2580
  let finalizing = checkpoint?.finalizing ?? false;
2441
2581
  let completed = false;
2442
2582
  const maxSteps = Math.max(1, useReasonArgs.toolExecution?.maxSteps ?? 3);
2443
2583
  const messages = checkpoint?.messages ?? createInitialMessages({
2444
2584
  system: useReasonArgs.system,
2445
- input: useReasonArgs.outputSchema ? withOutputGuardrails(useReasonArgs.input, useReasonArgs.outputSchema) : useReasonArgs.input
2585
+ input: useReasonArgs.outputSchema ? withOutputGuardrails(useReasonArgs.input, useReasonArgs.outputSchema) : useReasonArgs.input,
2586
+ ...interruptConfig ? {
2587
+ interruptInstructions: [
2588
+ "Human-input rules:",
2589
+ "- The kortyx_request_input__* tools pause this reasoning operation and return the validated human response as a normal tool result.",
2590
+ "- Call at most one human-input tool in a model turn, and do not combine it with other tool calls in that turn.",
2591
+ interruptConfig.mode === "required" ? "- You must call at least one human-input tool before returning the final answer." : "- Human input is optional; call a human-input tool only when the request cannot be completed safely without it."
2592
+ ].join("\n")
2593
+ } : {}
2446
2594
  });
2447
2595
  const steps = checkpoint?.steps ?? [];
2448
- const allToolCalls = steps.flatMap(
2449
- (step) => step.toolCalls
2450
- );
2451
- const allToolResults = steps.flatMap(
2452
- (step) => step.toolResults
2453
- );
2596
+ const allToolCalls = steps.flatMap((step) => step.toolCalls).filter((call) => !interruptByToolName.has(call.name));
2597
+ const allToolResults = steps.flatMap((step) => step.toolResults).filter((result) => !interruptByToolName.has(result.name));
2454
2598
  let finalText = "";
2455
2599
  let finalRaw;
2456
2600
  let finalOutput;
@@ -2466,6 +2610,7 @@ var runReasonToolLoop = async (args) => {
2466
2610
  );
2467
2611
  let pending = checkpoint?.pending;
2468
2612
  const approvedCalls = checkpoint?.approvedCalls ?? [];
2613
+ const interruptHistory = checkpoint?.interruptHistory ?? [];
2469
2614
  const save = () => {
2470
2615
  ctx.currentNodeState.byKey[checkpointKey] = {
2471
2616
  status: "tool_loop",
@@ -2473,6 +2618,7 @@ var runReasonToolLoop = async (args) => {
2473
2618
  messages,
2474
2619
  steps,
2475
2620
  approvedCalls,
2621
+ interruptHistory,
2476
2622
  finalizing,
2477
2623
  ...pending ? { pending } : {}
2478
2624
  };
@@ -2551,6 +2697,10 @@ var runReasonToolLoop = async (args) => {
2551
2697
  ...step.warnings ? { warnings: step.warnings } : {}
2552
2698
  });
2553
2699
  if (toolCalls.length === 0) {
2700
+ if (interruptConfig?.mode === "required" && interruptHistory.length === 0)
2701
+ throw new Error(
2702
+ "useReason interrupts.mode is required, but the model returned a final answer without requesting human input."
2703
+ );
2554
2704
  if (separateOutput && !finalizing) {
2555
2705
  if (args.allowValidatedToolOutput && useReasonArgs.outputSchema) {
2556
2706
  try {
@@ -2595,7 +2745,9 @@ var runReasonToolLoop = async (args) => {
2595
2745
  "Provider requested a tool during schema-only finalization."
2596
2746
  );
2597
2747
  if (!reused) {
2598
- allToolCalls.push(...toolCalls);
2748
+ allToolCalls.push(
2749
+ ...toolCalls.filter((call) => !interruptByToolName.has(call.name))
2750
+ );
2599
2751
  messages.push({
2600
2752
  role: "assistant",
2601
2753
  content: step.text,
@@ -2605,6 +2757,84 @@ var runReasonToolLoop = async (args) => {
2605
2757
  }
2606
2758
  pending = step;
2607
2759
  save();
2760
+ const interruptCalls = toolCalls.filter(
2761
+ (call) => interruptByToolName.has(call.name)
2762
+ );
2763
+ if (interruptCalls.length > 0) {
2764
+ if (toolCalls.length !== 1)
2765
+ throw new Error(
2766
+ "A useReason interrupt contract call must be the only tool call in its model turn."
2767
+ );
2768
+ const interruptCall = interruptCalls[0];
2769
+ if (!interruptCall)
2770
+ throw new Error("Missing useReason interrupt control call.");
2771
+ if (interruptHistory.length >= (interruptConfig?.maxRequests ?? 1))
2772
+ throw new Error(
2773
+ `useReason reached interrupts.maxRequests (${interruptConfig?.maxRequests ?? 1}).`
2774
+ );
2775
+ const resolved = interruptByToolName.get(interruptCall.name);
2776
+ if (!resolved)
2777
+ throw new Error(
2778
+ `Unknown useReason interrupt contract tool "${interruptCall.name}".`
2779
+ );
2780
+ if (toolResults.some((result3) => result3.toolCallId === interruptCall.id)) {
2781
+ pending = void 0;
2782
+ save();
2783
+ continue;
2784
+ }
2785
+ const request = parseWithSchema(
2786
+ resolved.contract.requestSchema,
2787
+ interruptCall.input,
2788
+ `useReason interrupt contract "${resolved.name}" request`
2789
+ );
2790
+ traceSpan?.addEvent?.("useReason.interrupt.requested", {
2791
+ checkpointKey,
2792
+ contract: resolved.name,
2793
+ interruptIndex: interruptHistory.length
2794
+ });
2795
+ const question = request && typeof request === "object" && !Array.isArray(request) && typeof request.question === "string" ? String(request.question) : void 0;
2796
+ const response = await awaitInterruptInternal({
2797
+ request: {
2798
+ kind: "custom",
2799
+ request,
2800
+ contract: resolved.name,
2801
+ ...question ? { question } : {},
2802
+ schemaId: resolved.contract.schemaId,
2803
+ schemaVersion: resolved.contract.schemaVersion
2804
+ },
2805
+ responseSchema: resolved.contract.responseSchema,
2806
+ id: `${id ?? "reason"}:${resolved.name}:${interruptHistory.length + 1}`,
2807
+ meta: {
2808
+ __kortyxReason: {
2809
+ opId,
2810
+ stepIndex,
2811
+ interruptIndex: interruptHistory.length,
2812
+ contract: resolved.name
2813
+ }
2814
+ }
2815
+ });
2816
+ const result2 = normalizeToolResult(interruptCall, response);
2817
+ toolResults.push(result2);
2818
+ messages.push({
2819
+ role: "tool",
2820
+ content: result2.content,
2821
+ toolCallId: result2.toolCallId,
2822
+ name: result2.name
2823
+ });
2824
+ interruptHistory.push({
2825
+ contract: resolved.name,
2826
+ request,
2827
+ response
2828
+ });
2829
+ traceSpan?.addEvent?.("useReason.interrupt.resolved", {
2830
+ checkpointKey,
2831
+ contract: resolved.name,
2832
+ interruptIndex: interruptHistory.length - 1
2833
+ });
2834
+ pending = void 0;
2835
+ save();
2836
+ continue;
2837
+ }
2608
2838
  for (const toolCall of toolCalls) {
2609
2839
  if (toolResults.some((result2) => result2.toolCallId === toolCall.id)) {
2610
2840
  await replayToolObservations(
@@ -2814,7 +3044,11 @@ var runReasonToolLoop = async (args) => {
2814
3044
  ...finalOutput !== void 0 ? { output: finalOutput } : {},
2815
3045
  toolCalls: allToolCalls,
2816
3046
  toolResults: allToolResults,
2817
- steps
3047
+ steps,
3048
+ ...interruptHistory.length > 0 ? { interruptHistory } : {},
3049
+ ...isLegacyInterruptContracts(interruptConfig) && interruptHistory.length > 0 ? {
3050
+ interruptResponse: interruptHistory.at(-1)?.response
3051
+ } : {}
2818
3052
  };
2819
3053
  traceSpan?.end?.({
2820
3054
  ...aggregatedUsage !== void 0 ? { usage: aggregatedUsage } : {},
@@ -2825,7 +3059,8 @@ var runReasonToolLoop = async (args) => {
2825
3059
  textLength: finalText.length,
2826
3060
  toolCallCount: allToolCalls.length,
2827
3061
  toolResultCount: allToolResults.length,
2828
- toolStepCount: steps.length
3062
+ toolStepCount: steps.length,
3063
+ interruptCount: interruptHistory.length
2829
3064
  },
2830
3065
  telemetry: {
2831
3066
  ...useReasonArgs.telemetry ?? {},
@@ -2855,7 +3090,7 @@ var resolveEffectiveReasoningIncludeThoughts = (args) => args.reasoning?.include
2855
3090
  var resolveEffectiveResponseFormatType = (args) => args.responseFormat?.type ?? args.model.options?.responseFormat?.type;
2856
3091
  var assertReasoningThoughtsCompatibility = (args) => {
2857
3092
  if (!resolveEffectiveReasoningIncludeThoughts(args)) return;
2858
- const usesStructuredOutput = Boolean(args.outputSchema) || Boolean(args.interrupt) || Boolean(args.structured) || resolveEffectiveResponseFormatType(args) === "json";
3093
+ const usesStructuredOutput = Boolean(args.outputSchema) || Boolean(args.interrupt) || Boolean(args.interrupts) || Boolean(args.structured) || resolveEffectiveResponseFormatType(args) === "json";
2859
3094
  if (!usesStructuredOutput) return;
2860
3095
  throw new Error(
2861
3096
  "useReason does not support reasoning.includeThoughts with structured output, interrupt mode, or JSON responseFormat. Disable includeThoughts for this call."
@@ -2863,6 +3098,10 @@ var assertReasoningThoughtsCompatibility = (args) => {
2863
3098
  };
2864
3099
  async function runReason(args) {
2865
3100
  assertReasoningThoughtsCompatibility(args);
3101
+ const interruptConfig = normalizeReasonInterrupts({
3102
+ interrupt: args.interrupt,
3103
+ interrupts: args.interrupts
3104
+ });
2866
3105
  const ctx = getHookContext();
2867
3106
  const abortSignal = (0, import_core6.combineAbortSignals)(
2868
3107
  ctx.node.abortSignal,
@@ -2877,20 +3116,23 @@ async function runReason(args) {
2877
3116
  args.outputSchema,
2878
3117
  args.responseFormat ?? args.model.options?.responseFormat
2879
3118
  );
3119
+ const usesLegacyNoToolInterrupt = Boolean(
3120
+ args.interrupt && !args.interrupts && !args.tools?.length
3121
+ );
2880
3122
  args = {
2881
3123
  ...args,
2882
3124
  abortSignal,
2883
- ...!args.interrupt && inferred.responseFormat ? { responseFormat: inferred.responseFormat } : {}
3125
+ ...!usesLegacyNoToolInterrupt && inferred.responseFormat ? { responseFormat: inferred.responseFormat } : {}
2884
3126
  };
2885
3127
  const id = typeof args.id === "string" && args.id.length > 0 ? args.id : void 0;
2886
- const opId = createRuntimeId2();
3128
+ const provisionalOpId = createRuntimeId2();
2887
3129
  const checkpointKey = resolveReasonCheckpointKey({
2888
3130
  ...id ? { id } : {},
2889
3131
  autoIndex: ctx.reasonCallIndex++
2890
3132
  });
2891
- const existingCompleted = readReasonCompletedCheckpoint(
2892
- ctx.currentNodeState.byKey[checkpointKey]
2893
- );
3133
+ const checkpointValue = ctx.currentNodeState.byKey[checkpointKey];
3134
+ const opId = checkpointValue && typeof checkpointValue === "object" && "status" in checkpointValue && checkpointValue.status === "tool_loop" && "opId" in checkpointValue && typeof checkpointValue.opId === "string" ? checkpointValue.opId : provisionalOpId;
3135
+ const existingCompleted = readReasonCompletedCheckpoint(checkpointValue);
2894
3136
  if (existingCompleted) {
2895
3137
  await replayToolObservations(
2896
3138
  existingCompleted.result.steps?.flatMap(
@@ -2899,9 +3141,7 @@ async function runReason(args) {
2899
3141
  );
2900
3142
  return existingCompleted.result;
2901
3143
  }
2902
- const existingCheckpoint = readReasonCheckpoint(
2903
- ctx.currentNodeState.byKey[checkpointKey]
2904
- );
3144
+ const existingCheckpoint = readReasonCheckpoint(checkpointValue);
2905
3145
  const reasonMeta = typeof id === "string" ? { id, opId } : { opId };
2906
3146
  return withSafeTraceSpan(
2907
3147
  getReasonTraceAdapter(),
@@ -2920,7 +3160,8 @@ async function runReason(args) {
2920
3160
  ...args.telemetry?.prompt?.version !== void 0 ? { promptVersion: args.telemetry.prompt.version } : {},
2921
3161
  ...args.telemetry?.prompt?.type ? { promptType: args.telemetry.prompt.type } : {},
2922
3162
  hasOutputSchema: Boolean(args.outputSchema),
2923
- hasInterrupt: Boolean(args.interrupt),
3163
+ hasInterrupt: Boolean(interruptConfig),
3164
+ interruptContractCount: Object.keys(interruptConfig?.contracts ?? {}).length,
2924
3165
  hasStructured: Boolean(args.structured),
2925
3166
  hasTools: Boolean(args.tools?.length)
2926
3167
  },
@@ -2930,7 +3171,7 @@ async function runReason(args) {
2930
3171
  }
2931
3172
  },
2932
3173
  async (traceSpan) => {
2933
- if (args.tools?.length) {
3174
+ if ((args.tools?.length || args.interrupts) && !existingCheckpoint) {
2934
3175
  return runReasonToolLoop({
2935
3176
  useReasonArgs: args,
2936
3177
  allowValidatedToolOutput,
@@ -3199,6 +3440,24 @@ function useReason2(args) {
3199
3440
  return useReason(args);
3200
3441
  }
3201
3442
  function useInterrupt(args) {
3443
+ if ("contract" in args) {
3444
+ const contract = defineInterruptContract(args.contract);
3445
+ return awaitInterruptInternal({
3446
+ request: {
3447
+ kind: "custom",
3448
+ request: parseWithSchema(
3449
+ contract.requestSchema,
3450
+ args.request,
3451
+ "useInterrupt contract request"
3452
+ ),
3453
+ schemaId: contract.schemaId,
3454
+ schemaVersion: contract.schemaVersion,
3455
+ ...args.id ? { id: args.id } : {},
3456
+ ...args.meta ? { meta: args.meta } : {}
3457
+ },
3458
+ responseSchema: contract.responseSchema
3459
+ });
3460
+ }
3202
3461
  return awaitInterruptInternal(args);
3203
3462
  }
3204
3463
  function useAbortSignal() {
@@ -3697,6 +3956,7 @@ async function runWorkflowCall(args, task) {
3697
3956
  WorkflowCallError,
3698
3957
  completeResponse,
3699
3958
  createWorkflowHooks,
3959
+ defineInterruptContract,
3700
3960
  emitWorkflowCall,
3701
3961
  parallel,
3702
3962
  reportError,