@kortyx/agent 0.39.0 → 0.40.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
@@ -8,6 +8,30 @@
8
8
  event replay, explicit cancellation and optional PostgreSQL recovery.
9
9
 
10
10
 
11
+ ## [0.40.0](https://github.com/kortyx-io/kortyx/compare/agent-v0.39.0...agent-v0.40.0) (2026-10-18)
12
+
13
+
14
+ ### Features
15
+
16
+ * **studio:** drain evaluation workers and consumer endpoints safely ([#365](https://github.com/kortyx-io/kortyx/issues/365)) ([0bcd853](https://github.com/kortyx-io/kortyx/commit/0bcd85310f5ccf59772b2fa9ed4dd28d03394546))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * retain workflow catalogs and text interrupt questions ([#369](https://github.com/kortyx-io/kortyx/issues/369)) ([7608f65](https://github.com/kortyx-io/kortyx/commit/7608f6503167ed94feda30a1f63db50f475557d6))
22
+
23
+
24
+ ### Dependencies
25
+
26
+ * The following workspace dependencies were updated
27
+ * dependencies
28
+ * @kortyx/runtime bumped to 0.26.0
29
+ * @kortyx/prompts bumped to 0.2.3
30
+ * @kortyx/usage-telemetry bumped to 0.5.0
31
+ * @kortyx/telemetry-contracts bumped to 0.19.0
32
+ * devDependencies
33
+ * @kortyx/hooks bumped to 0.35.1
34
+
11
35
  ## [0.39.0](https://github.com/kortyx-io/kortyx/compare/agent-v0.38.1...agent-v0.39.0) (2026-10-16)
12
36
 
13
37
 
package/README.md CHANGED
@@ -9,6 +9,13 @@ Agent creation, chat route handlers, HTTP helpers, and stream orchestration for
9
9
 
10
10
  Most application code should import these APIs from `kortyx`. Use `@kortyx/agent` directly when you are building framework adapters or want the lower-level agent package without the full facade.
11
11
 
12
+ Topology identity excludes handoffs inferred from JavaScript function source, so
13
+ source publication and bundled execution share a revision even when bundling hides
14
+ a target. Inferred handoffs and source-discovered child links remain catalog
15
+ metadata. After upgrading the SDK and CLI, rebuild the application and republish
16
+ its source catalog. Previously inferred handoffs can receive a new topology hash
17
+ once; historical revisions and run references remain readable without migration.
18
+
12
19
  ## Install
13
20
 
14
21
  ```bash
@@ -112,6 +119,16 @@ The default memory store only survives within its process. See
112
119
  [execution resilience](../../docs/evals/execution-resilience.md) for configuration,
113
120
  retention, legacy compatibility and upgrade order.
114
121
 
122
+ For planned retirement, `await handleEvals.drain()` stops new admissions and
123
+ finishes accepted local/queued executions without aborting them. Keep HTTP and
124
+ the store open for observation/cancellation; `close()` remains explicit abort.
125
+ With one consumer, drain Studio workers first while the consumer still accepts
126
+ later cases, then drain the consumer after Studio persists results. Overlapping
127
+ consumers require a compatible replacement and shared durable execution storage.
128
+ The built-in stores support this; custom `EvalExecutionStore` implementations need
129
+ `queued` and `claimQueued` for drain. Upgrade agent and runtime together. See the
130
+ linked guide for ordering, failure handling and resource lifetime.
131
+
115
132
  ### Evaluation settings and errors
116
133
 
117
134
  `createEvals` supports `defaults: { repetitions, concurrency, caseTimeoutMs }`,
package/dist/index.d.ts CHANGED
@@ -294,6 +294,9 @@ declare function createEvalRouteHandler({ evals, serviceKey, maxCases, maxActive
294
294
  executionStore?: EvalExecutionStore;
295
295
  onExecution?: (completion: Promise<void>) => void;
296
296
  }): ((request: Request) => Promise<Response>) & {
297
+ /** Stop admissions and finish accepted jobs; keep HTTP/store alive for observers. */
298
+ drain: () => Promise<void>;
299
+ /** Explicitly abort local execution, then wait for cleanup. */
297
300
  close: () => Promise<void>;
298
301
  };
299
302
 
package/dist/index.js CHANGED
@@ -165,7 +165,7 @@ var require_internal = __commonJS({
165
165
  var import_node2 = require("@kortyx/usage-telemetry/node");
166
166
  var package_default2 = {
167
167
  name: "@kortyx/hooks",
168
- version: "0.35.0",
168
+ version: "0.35.1",
169
169
  private: false,
170
170
  description: "Lifecycle hooks and extension points for Kortyx.",
171
171
  keywords: [
@@ -801,7 +801,7 @@ var require_dist = __commonJS({
801
801
  var import_node2 = require("@kortyx/usage-telemetry/node");
802
802
  var package_default2 = {
803
803
  name: "@kortyx/hooks",
804
- version: "0.35.0",
804
+ version: "0.35.1",
805
805
  private: false,
806
806
  description: "Lifecycle hooks and extension points for Kortyx.",
807
807
  keywords: [
@@ -5768,7 +5768,10 @@ var projectWorkflowTopology = (args) => {
5768
5768
  workflowId: args.workflow.id,
5769
5769
  nodes: nodes.map((node) => ({ id: node.id, ...node._execution })),
5770
5770
  edges,
5771
- transitions
5771
+ // Handoffs inferred from function source are catalog enrichment. Bundlers
5772
+ // can hide their targets without changing the executable graph. Keep the
5773
+ // legacy empty list so graphs without inferred handoffs retain their hash.
5774
+ transitions: []
5772
5775
  });
5773
5776
  const topologyHash = (0, import_node_crypto2.createHash)("sha256").update(JSON.stringify(hashInput)).digest("hex");
5774
5777
  return {
@@ -5847,7 +5850,7 @@ var import_node = require("@kortyx/usage-telemetry/node");
5847
5850
  // package.json
5848
5851
  var package_default = {
5849
5852
  name: "@kortyx/agent",
5850
- version: "0.39.0",
5853
+ version: "0.40.0",
5851
5854
  private: false,
5852
5855
  description: "Agent composition utilities for Kortyx.",
5853
5856
  keywords: [
@@ -10875,8 +10878,32 @@ function createEvalExecutionTransport({
10875
10878
  }) {
10876
10879
  const owner = (0, import_node_crypto6.randomUUID)();
10877
10880
  const active = /* @__PURE__ */ new Map();
10878
- let ticking = false;
10881
+ let ticking;
10882
+ let draining = false;
10879
10883
  let stopped = false;
10884
+ let drain;
10885
+ let closing;
10886
+ let retirementFailure;
10887
+ const unsupportedRetirement = new Error(
10888
+ "Execution store requires queued and claimQueued for graceful retirement."
10889
+ );
10890
+ const admissions = /* @__PURE__ */ new Set();
10891
+ const heartbeats = /* @__PURE__ */ new Set();
10892
+ const failedRetirement = () => {
10893
+ retirementFailure ??= new Error(
10894
+ "Consumer evaluation retirement could not be confirmed."
10895
+ );
10896
+ };
10897
+ const finish = async (id3, state) => {
10898
+ try {
10899
+ await store.finish(id3, owner, state);
10900
+ if ((await store.status(id3))?.state !== state)
10901
+ throw new Error("Consumer completion was not confirmed.");
10902
+ } catch (error) {
10903
+ failedRetirement();
10904
+ throw error;
10905
+ }
10906
+ };
10880
10907
  const key = (id3) => (0, import_node_crypto6.createHash)("sha256").update(`${scope}\0${id3}`).digest("hex");
10881
10908
  const fail = async (job, error) => {
10882
10909
  const issue2 = createEvalIssue("reporting", error);
@@ -10893,7 +10920,11 @@ function createEvalExecutionTransport({
10893
10920
  const timer2 = setInterval(() => {
10894
10921
  if (renewing) return;
10895
10922
  renewing = true;
10896
- void store.heartbeat(job.id, owner).then(
10923
+ const pending = Promise.resolve().then(
10924
+ () => store.heartbeat(job.id, owner)
10925
+ );
10926
+ heartbeats.add(pending);
10927
+ void pending.then(
10897
10928
  (current) => {
10898
10929
  if (!current) controller.abort("lease-lost");
10899
10930
  else if (current.cancelRequested)
@@ -10901,6 +10932,7 @@ function createEvalExecutionTransport({
10901
10932
  },
10902
10933
  () => controller.abort("lease-lost")
10903
10934
  ).finally(() => {
10935
+ heartbeats.delete(pending);
10904
10936
  renewing = false;
10905
10937
  });
10906
10938
  }, store.heartbeatIntervalMs);
@@ -10935,9 +10967,8 @@ function createEvalExecutionTransport({
10935
10967
  onProgress: (event) => store.append(job.id, owner, { type: "progress", event })
10936
10968
  });
10937
10969
  await store.append(job.id, owner, { type: "result", result });
10938
- await store.finish(
10970
+ await finish(
10939
10971
  job.id,
10940
- owner,
10941
10972
  result.status === "cancelled" ? "cancelled" : "completed"
10942
10973
  );
10943
10974
  } catch (error) {
@@ -10954,12 +10985,12 @@ function createEvalExecutionTransport({
10954
10985
  }
10955
10986
  );
10956
10987
  await fail(job, error);
10957
- await store.finish(
10988
+ await finish(
10958
10989
  job.id,
10959
- owner,
10960
10990
  controller.signal.reason === "user-cancelled" ? "cancelled" : "completed"
10961
10991
  );
10962
10992
  } catch {
10993
+ failedRetirement();
10963
10994
  }
10964
10995
  } finally {
10965
10996
  clearInterval(timer2);
@@ -10967,25 +10998,30 @@ function createEvalExecutionTransport({
10967
10998
  active.delete(job.id);
10968
10999
  }
10969
11000
  };
10970
- const tick = async () => {
10971
- if (stopped || ticking) return;
10972
- ticking = true;
10973
- try {
10974
- while (!stopped) {
10975
- const job = await store.claim(owner, scope, limits);
10976
- if (!job) break;
10977
- const controller = new AbortController();
10978
- const completion = run(job, controller);
10979
- active.set(job.id, { controller, completion });
10980
- try {
10981
- onExecution?.(completion);
10982
- } catch {
11001
+ const tick = (selected) => {
11002
+ if (ticking) return Promise.resolve();
11003
+ if (stopped || draining && !selected) return Promise.resolve();
11004
+ ticking = Promise.resolve().then(async () => {
11005
+ try {
11006
+ while (!stopped && (!draining || selected)) {
11007
+ const job = selected ? await store.claimQueued(owner, scope, limits, selected) : await store.claim(owner, scope, limits);
11008
+ if (!job) break;
11009
+ if (stopped) break;
11010
+ const controller = new AbortController();
11011
+ const completion = run(job, controller);
11012
+ active.set(job.id, { controller, completion });
11013
+ try {
11014
+ onExecution?.(completion);
11015
+ } catch {
11016
+ }
10983
11017
  }
11018
+ } catch {
11019
+ failedRetirement();
11020
+ } finally {
11021
+ ticking = void 0;
10984
11022
  }
10985
- } catch {
10986
- } finally {
10987
- ticking = false;
10988
- }
11023
+ });
11024
+ return ticking;
10989
11025
  };
10990
11026
  const timer = setInterval(() => {
10991
11027
  void tick();
@@ -11148,20 +11184,21 @@ function createEvalExecutionTransport({
11148
11184
  },
11149
11185
  async submit(request, body) {
11150
11186
  const negotiated = request.headers.get("x-kortyx-eval-protocol") === protocol;
11151
- if (stopped)
11152
- return problem(
11153
- new import_errors13.KortyxError(
11154
- "EVAL_EXECUTOR_BUSY",
11155
- "Eval executor is draining; execution was not accepted.",
11156
- {
11157
- category: "request",
11158
- status: 503,
11159
- retryable: true,
11160
- safeMessage: "Eval executor is draining; execution was not accepted."
11161
- }
11162
- ),
11163
- negotiated
11164
- );
11187
+ const retiring = draining;
11188
+ const refused = () => problem(
11189
+ new import_errors13.KortyxError(
11190
+ "EVAL_EXECUTOR_BUSY",
11191
+ "Eval executor is draining; execution was not accepted.",
11192
+ {
11193
+ category: "request",
11194
+ status: 503,
11195
+ retryable: true,
11196
+ safeMessage: "Eval executor is draining; execution was not accepted."
11197
+ }
11198
+ ),
11199
+ negotiated
11200
+ );
11201
+ if (stopped || retiring && !negotiated) return refused();
11165
11202
  const id3 = negotiated ? request.headers.get("idempotency-key") : (0, import_node_crypto6.randomUUID)();
11166
11203
  if (!id3 || !uuid.test(id3))
11167
11204
  return Response.json(
@@ -11176,31 +11213,75 @@ function createEvalExecutionTransport({
11176
11213
  },
11177
11214
  { status: 410 }
11178
11215
  );
11216
+ const fingerprint = (0, import_node_crypto6.createHash)("sha256").update(canonical2(body)).digest("hex");
11179
11217
  try {
11180
- if (negotiated && request.headers.get("x-kortyx-eval-generation") !== await store.generation())
11181
- return Response.json(
11182
- {
11183
- error: "Execution storage changed; execution status is unknown.",
11184
- code: "EVAL_EXECUTION_STORE_CHANGED"
11185
- },
11186
- { status: 409 }
11187
- );
11188
- await store.submit(
11189
- {
11190
- id: key(id3),
11191
- scope,
11192
- fingerprint: (0, import_node_crypto6.createHash)("sha256").update(canonical2(body)).digest("hex"),
11193
- group: key(body.attempt?.evaluationId ?? id3),
11194
- concurrency: body.concurrency,
11195
- weight: body.attempt ? 1 : body.concurrency,
11196
- payload: body,
11197
- queued: negotiated
11198
- },
11199
- limits
11200
- );
11218
+ let admitted;
11219
+ const pending = new Promise((resolve2) => {
11220
+ admitted = resolve2;
11221
+ });
11222
+ admissions.add(pending);
11223
+ try {
11224
+ if (negotiated && request.headers.get("x-kortyx-eval-generation") !== await store.generation())
11225
+ return Response.json(
11226
+ {
11227
+ error: "Execution storage changed; execution status is unknown.",
11228
+ code: "EVAL_EXECUTION_STORE_CHANGED"
11229
+ },
11230
+ { status: 409 }
11231
+ );
11232
+ if (retiring) {
11233
+ const existing = await store.get(key(id3));
11234
+ if (!existing) return refused();
11235
+ if (existing.fingerprint !== fingerprint)
11236
+ throw new import_errors13.KortyxError(
11237
+ "EVAL_EXECUTION_CONFLICT",
11238
+ "Execution identity was reused with a different request.",
11239
+ {
11240
+ category: "request",
11241
+ status: 409,
11242
+ retryable: false,
11243
+ safeMessage: "Execution identity was reused with a different request."
11244
+ }
11245
+ );
11246
+ return await observe(request, id3);
11247
+ }
11248
+ try {
11249
+ await store.submit(
11250
+ {
11251
+ id: key(id3),
11252
+ scope,
11253
+ fingerprint,
11254
+ group: key(body.attempt?.evaluationId ?? id3),
11255
+ concurrency: body.concurrency,
11256
+ weight: body.attempt ? 1 : body.concurrency,
11257
+ payload: body,
11258
+ queued: negotiated
11259
+ },
11260
+ limits
11261
+ );
11262
+ } catch (error) {
11263
+ if (!(error instanceof import_errors13.KortyxError) || ![
11264
+ "EVAL_EXECUTOR_BUSY",
11265
+ "EVAL_EXECUTION_CONFLICT",
11266
+ "EVAL_CONCURRENCY_UNSUPPORTED",
11267
+ "EVAL_CONCURRENCY_CHANGED",
11268
+ "EVAL_EVIDENCE_LIMIT"
11269
+ ].includes(error.code))
11270
+ failedRetirement();
11271
+ throw error;
11272
+ }
11273
+ } finally {
11274
+ admissions.delete(pending);
11275
+ admitted();
11276
+ }
11277
+ if (stopped && !negotiated) {
11278
+ throw new Error("Consumer execution was explicitly closed.");
11279
+ }
11201
11280
  await tick();
11202
11281
  if (!negotiated) {
11203
11282
  while ((await store.status(key(id3)))?.state === "queued") {
11283
+ if (stopped)
11284
+ throw new Error("Consumer execution was explicitly closed.");
11204
11285
  request.signal.throwIfAborted();
11205
11286
  await wait(10, request.signal);
11206
11287
  await tick();
@@ -11208,7 +11289,7 @@ function createEvalExecutionTransport({
11208
11289
  }
11209
11290
  return await observe(request, id3, 0, negotiated);
11210
11291
  } catch (error) {
11211
- if (!negotiated && request.signal.aborted) {
11292
+ if (!negotiated && (request.signal.aborted || stopped)) {
11212
11293
  active.get(key(id3))?.controller.abort("user-cancelled");
11213
11294
  await store.cancel(key(id3)).catch(() => {
11214
11295
  });
@@ -11222,11 +11303,53 @@ function createEvalExecutionTransport({
11222
11303
  "x-kortyx-eval-generation": await store.generation()
11223
11304
  };
11224
11305
  },
11225
- async close() {
11306
+ drain() {
11307
+ draining = true;
11308
+ clearInterval(timer);
11309
+ drain ??= (async () => {
11310
+ try {
11311
+ await Promise.all(admissions);
11312
+ await ticking;
11313
+ if (!store.queued || !store.claimQueued) throw unsupportedRetirement;
11314
+ const selected = new Set(await store.queued(scope));
11315
+ if (selected.size > limits.maxQueuedExecutions)
11316
+ throw new Error("Retirement queue exceeds the configured bound.");
11317
+ while (!stopped) {
11318
+ if (selected.size) await tick([...selected]);
11319
+ for (const id3 of selected) {
11320
+ const status2 = await store.status(id3);
11321
+ if (!status2 || status2.state === "unknown") failedRetirement();
11322
+ if (status2?.state !== "queued") selected.delete(id3);
11323
+ }
11324
+ await Promise.all(
11325
+ [...active.values()].map((job) => job.completion)
11326
+ );
11327
+ if (!selected.size || retirementFailure) break;
11328
+ await new Promise((resolve2) => setTimeout(resolve2, 50));
11329
+ }
11330
+ await Promise.all([...active.values()].map((job) => job.completion));
11331
+ await Promise.allSettled(heartbeats);
11332
+ if (stopped)
11333
+ throw new Error("Consumer retirement was explicitly aborted.");
11334
+ if (retirementFailure) throw retirementFailure;
11335
+ } catch (error) {
11336
+ failedRetirement();
11337
+ throw error === unsupportedRetirement ? unsupportedRetirement : retirementFailure;
11338
+ }
11339
+ })();
11340
+ return drain;
11341
+ },
11342
+ close() {
11226
11343
  stopped = true;
11227
11344
  clearInterval(timer);
11228
- for (const job of active.values()) job.controller.abort("host-stopped");
11229
- await Promise.all([...active.values()].map((job) => job.completion));
11345
+ closing ??= (async () => {
11346
+ for (const job of active.values()) job.controller.abort("host-stopped");
11347
+ await Promise.all(admissions);
11348
+ await ticking;
11349
+ await Promise.all([...active.values()].map((job) => job.completion));
11350
+ await Promise.allSettled(heartbeats);
11351
+ })();
11352
+ return closing;
11230
11353
  }
11231
11354
  };
11232
11355
  }
@@ -11360,7 +11483,12 @@ function createEvalRouteHandler({
11360
11483
  return Response.json({ error: "Request cancelled." }, { status: 409 });
11361
11484
  return execution.submit(request, body);
11362
11485
  };
11363
- return Object.assign(handler3, { close: execution.close });
11486
+ return Object.assign(handler3, {
11487
+ /** Stop admissions and finish accepted jobs; keep HTTP/store alive for observers. */
11488
+ drain: execution.drain,
11489
+ /** Explicitly abort local execution, then wait for cleanup. */
11490
+ close: execution.close
11491
+ });
11364
11492
  }
11365
11493
 
11366
11494
  // src/evals/studio-judge.ts