zeitlich 0.2.49 → 0.2.50

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.
Files changed (123) hide show
  1. package/README.md +26 -23
  2. package/dist/{activities-zG_FBoY2.d.ts → activities-IuOIvPHO.d.ts} +6 -6
  3. package/dist/{activities-7OcT_vdR.d.cts → activities-cIlq1y1y.d.cts} +6 -6
  4. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  5. package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
  6. package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
  7. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  8. package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
  9. package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
  10. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  11. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  12. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  13. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  14. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  15. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  16. package/dist/adapters/thread/anthropic/index.cjs +45 -42
  17. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  18. package/dist/adapters/thread/anthropic/index.d.cts +10 -10
  19. package/dist/adapters/thread/anthropic/index.d.ts +10 -10
  20. package/dist/adapters/thread/anthropic/index.js +45 -42
  21. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  22. package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
  23. package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
  24. package/dist/adapters/thread/google-genai/index.cjs +117 -54
  25. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  26. package/dist/adapters/thread/google-genai/index.d.cts +27 -23
  27. package/dist/adapters/thread/google-genai/index.d.ts +27 -23
  28. package/dist/adapters/thread/google-genai/index.js +117 -54
  29. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  30. package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
  31. package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
  32. package/dist/adapters/thread/langchain/index.cjs +45 -42
  33. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  34. package/dist/adapters/thread/langchain/index.d.cts +10 -10
  35. package/dist/adapters/thread/langchain/index.d.ts +10 -10
  36. package/dist/adapters/thread/langchain/index.js +45 -42
  37. package/dist/adapters/thread/langchain/index.js.map +1 -1
  38. package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
  39. package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
  40. package/dist/{cold-store-CkWoNtMh.d.cts → cold-store-C0uvYTSi.d.cts} +1 -1
  41. package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-CCnZYWjx.d.ts} +1 -1
  42. package/dist/index.cjs +15050 -420
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +79 -83
  45. package/dist/index.d.ts +79 -83
  46. package/dist/index.js +15051 -417
  47. package/dist/index.js.map +1 -1
  48. package/dist/{proxy-B7CWEV-T.d.cts → proxy-BVznA2_p.d.cts} +1 -1
  49. package/dist/{proxy-ByFHMVRX.d.ts → proxy-C4J1pNUk.d.ts} +1 -1
  50. package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-BqjzWsP7.d.ts} +4 -4
  51. package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-CzIs47uG.d.cts} +4 -4
  52. package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-Dzl1fHhV.d.cts} +4 -4
  53. package/dist/{thread-manager-7AW4rhfu.d.ts → thread-manager-SkSWRPRc.d.ts} +4 -4
  54. package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
  55. package/dist/{types-DO4Tkwxo.d.ts → types-CbPnU4RM.d.ts} +3 -3
  56. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
  57. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
  58. package/dist/{types-DeVNWqlb.d.ts → types-DZnUqCAP.d.cts} +709 -709
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{types-XUUFvrJ9.d.cts → types-YNesmGKV.d.ts} +709 -709
  61. package/dist/{types-BR-k7h0e.d.cts → types-d2RvEP6v.d.cts} +3 -3
  62. package/dist/{workflow-uhOIj9D-.d.ts → workflow-B3oTe2_D.d.cts} +34 -3
  63. package/dist/{workflow-KbGsxpfh.d.cts → workflow-Bkzg0cjB.d.ts} +34 -3
  64. package/dist/workflow.cjs +15008 -377
  65. package/dist/workflow.cjs.map +1 -1
  66. package/dist/workflow.d.cts +3 -3
  67. package/dist/workflow.d.ts +3 -3
  68. package/dist/workflow.js +15009 -374
  69. package/dist/workflow.js.map +1 -1
  70. package/package.json +10 -37
  71. package/src/adapters/thread/anthropic/activities.ts +1 -1
  72. package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
  73. package/src/adapters/thread/anthropic/model-invoker.test.ts +4 -3
  74. package/src/adapters/thread/anthropic/model-invoker.ts +1 -1
  75. package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
  76. package/src/adapters/thread/anthropic/thread-manager.ts +1 -1
  77. package/src/adapters/thread/google-genai/activities.ts +1 -1
  78. package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
  79. package/src/adapters/thread/google-genai/model-invoker.test.ts +337 -0
  80. package/src/adapters/thread/google-genai/model-invoker.ts +107 -23
  81. package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
  82. package/src/adapters/thread/google-genai/thread-manager.ts +1 -1
  83. package/src/adapters/thread/langchain/activities.ts +1 -1
  84. package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
  85. package/src/adapters/thread/langchain/model-invoker.ts +1 -1
  86. package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
  87. package/src/adapters/thread/langchain/thread-manager.ts +1 -1
  88. package/src/index.ts +2 -2
  89. package/src/lib/sandbox/capability-types.test.ts +2 -2
  90. package/src/lib/sandbox/manager.ts +2 -6
  91. package/src/lib/sandbox/sandbox.test.ts +1 -1
  92. package/src/lib/sandbox/types.ts +2 -2
  93. package/src/lib/session/session.integration.test.ts +92 -0
  94. package/src/lib/session/session.ts +23 -11
  95. package/src/lib/thread/keys.test.ts +9 -9
  96. package/src/lib/thread/keys.ts +1 -1
  97. package/src/lib/thread/manager.test.ts +24 -14
  98. package/src/lib/thread/manager.ts +19 -23
  99. package/src/lib/thread/snapshot.test.ts +51 -43
  100. package/src/lib/thread/snapshot.ts +54 -32
  101. package/src/lib/thread/test-utils.ts +106 -59
  102. package/src/lib/thread/tiered.test.ts +1 -1
  103. package/src/lib/thread/types.ts +2 -2
  104. package/src/lib/tool-router/router.integration.test.ts +44 -0
  105. package/src/lib/tool-router/router.ts +140 -32
  106. package/src/lib/workflow.ts +49 -0
  107. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  108. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  109. package/src/tools/bash/bash.test.ts +1 -1
  110. package/src/tools/edit/handler.test.ts +1 -1
  111. package/tsup.config.ts +2 -4
  112. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  113. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  114. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  115. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  116. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  117. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  118. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  119. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  120. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  121. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  122. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  123. package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
@@ -7,19 +7,35 @@
7
7
  * picks it up directly.
8
8
  */
9
9
 
10
- import type Redis from "ioredis";
10
+ import type { RedisClientType } from "redis";
11
11
  import type { ColdThreadStore, ThreadSnapshot } from "./cold-store";
12
12
 
13
13
  type Value = string | string[];
14
14
 
15
+ /** node-redis `SetOptions` subset the stub understands. */
16
+ interface FakeSetOptions {
17
+ EX?: number;
18
+ NX?: boolean;
19
+ expiration?: { type: "EX" | "PX" | "EXAT" | "PXAT"; value: number } | "KEEPTTL";
20
+ condition?: "NX" | "XX";
21
+ }
22
+
23
+ /** node-redis accepts a single key or an array (`RedisVariadicArgument`). */
24
+ type Keys = string | string[];
25
+ const toKeys = (keys: Keys): string[] => (Array.isArray(keys) ? keys : [keys]);
26
+
15
27
  /**
16
- * Minimal in-memory Redis stub covering the commands the thread
28
+ * Minimal in-memory node-redis stub covering the commands the thread
17
29
  * manager + snapshot helpers use: get/set/del/exists/expire,
18
- * lrange/rpush/llen/ltrim, and the `eval`-based idempotent-append Lua
19
- * script. Behaviour matches Redis closely enough for unit tests; TTLs
20
- * are stored but never expire automatically.
30
+ * lRange/rPush/lLen/lTrim, and the `eval`-based idempotent-append Lua
31
+ * script. Mirrors the node-redis (`redis`) v4+ API surface camelCase
32
+ * commands, an options object for `set`, variadic-or-array keys for
33
+ * `del`/`exists`, and a `multi().execAsPipeline()` pipeline that rejects
34
+ * with a `MultiErrorReply`-shaped error when a queued command fails.
35
+ * Behaviour matches Redis closely enough for unit tests; TTLs are stored
36
+ * but never expire automatically.
21
37
  */
22
- export function createFakeRedis(): Redis & {
38
+ export function createFakeRedis(): RedisClientType & {
23
39
  _store: Map<string, Value>;
24
40
  _ttls: Map<string, number>;
25
41
  } {
@@ -48,56 +64,60 @@ export function createFakeRedis(): Redis & {
48
64
  async set(
49
65
  key: string,
50
66
  value: string,
51
- ..._rest: (string | number)[]
52
- ): Promise<"OK"> {
53
- // NX guard: when the args contain "NX" and the key already exists,
67
+ options?: FakeSetOptions
68
+ ): Promise<"OK" | null> {
69
+ // NX guard: when the condition is NX and the key already exists,
54
70
  // Redis returns null. We follow the same contract for tests that
55
- // need it; existing call sites use this for compare-and-set.
56
- const rest = _rest.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
57
- if (rest.includes("NX") && store.has(key)) {
58
- return null as unknown as "OK";
71
+ // need it.
72
+ const nx = options?.NX === true || options?.condition === "NX";
73
+ if (nx && store.has(key)) {
74
+ return null;
59
75
  }
60
76
  store.set(key, String(value));
61
- const exIdx = rest.indexOf("EX");
62
- if (exIdx >= 0 && typeof _rest[exIdx + 1] === "number") {
63
- ttls.set(key, _rest[exIdx + 1] as number);
77
+ const ttl =
78
+ options?.EX ??
79
+ (options?.expiration && options.expiration !== "KEEPTTL"
80
+ ? options.expiration.value
81
+ : undefined);
82
+ if (typeof ttl === "number") {
83
+ ttls.set(key, ttl);
64
84
  }
65
85
  return "OK";
66
86
  },
67
- async del(...keys: string[]): Promise<number> {
87
+ async del(keys: Keys): Promise<number> {
68
88
  let removed = 0;
69
- for (const k of keys) {
89
+ for (const k of toKeys(keys)) {
70
90
  if (store.delete(k)) removed++;
71
91
  ttls.delete(k);
72
92
  }
73
93
  return removed;
74
94
  },
75
- async exists(...keys: string[]): Promise<number> {
76
- return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
95
+ async exists(keys: Keys): Promise<number> {
96
+ return toKeys(keys).reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
77
97
  },
78
98
  async expire(key: string, ttl: number): Promise<number> {
79
99
  if (!store.has(key)) return 0;
80
100
  ttls.set(key, ttl);
81
101
  return 1;
82
102
  },
83
- async lrange(key: string, start: number, end: number): Promise<string[]> {
103
+ async lRange(key: string, start: number, end: number): Promise<string[]> {
84
104
  if (!store.has(key)) return [];
85
105
  if (!isList(key)) return [];
86
106
  const list = store.get(key) as string[];
87
107
  const last = end === -1 ? list.length - 1 : end;
88
108
  return list.slice(start, last + 1);
89
109
  },
90
- async rpush(key: string, ...values: string[]): Promise<number> {
110
+ async rPush(key: string, element: Keys): Promise<number> {
91
111
  const list = ensureList(key);
92
- list.push(...values);
112
+ list.push(...toKeys(element));
93
113
  return list.length;
94
114
  },
95
- async llen(key: string): Promise<number> {
115
+ async lLen(key: string): Promise<number> {
96
116
  if (!store.has(key)) return 0;
97
117
  const list = store.get(key) as string[];
98
118
  return list.length;
99
119
  },
100
- async ltrim(key: string, start: number, end: number): Promise<"OK"> {
120
+ async lTrim(key: string, start: number, end: number): Promise<"OK"> {
101
121
  if (!store.has(key)) return "OK";
102
122
  const list = store.get(key) as string[];
103
123
  const last = end === -1 ? list.length - 1 : end;
@@ -106,12 +126,11 @@ export function createFakeRedis(): Redis & {
106
126
  },
107
127
  async eval(
108
128
  _script: string,
109
- numKeys: number,
110
- ...args: (string | number)[]
129
+ options: { keys?: string[]; arguments?: string[] }
111
130
  ): Promise<number> {
112
131
  // Mirrors APPEND_IDEMPOTENT_SCRIPT in src/lib/thread/manager.ts.
113
- const keys = args.slice(0, numKeys) as string[];
114
- const argv = args.slice(numKeys) as string[];
132
+ const keys = options.keys ?? [];
133
+ const argv = options.arguments ?? [];
115
134
  const dedupKey = keys[0];
116
135
  const listKey = keys[1];
117
136
  const ttl = Number(argv[0]);
@@ -127,54 +146,64 @@ export function createFakeRedis(): Redis & {
127
146
  ttls.set(dedupKey, ttl);
128
147
  return 1;
129
148
  },
130
- // Chainable pipeline stub. Defers each command to the underlying
131
- // sync fake methods on `.exec()`, so TTL tracking and store
132
- // semantics stay identical to the non-pipelined path. `fake` is
133
- // typed as `Redis` after the cast below, so we narrow it back to
134
- // the concrete impl shape here to avoid Redis's callback overloads.
135
- pipeline(): FakePipeline {
149
+ // Chainable `multi()` stub. Defers each command to the underlying
150
+ // sync fake methods on `.execAsPipeline()`, so TTL tracking and store
151
+ // semantics stay identical to the non-pipelined path. Mirrors
152
+ // node-redis: per-command failures reject the pipeline with a
153
+ // `MultiErrorReply`-shaped error (`{ replies, errorIndexes }`).
154
+ multi(): FakeMulti {
136
155
  const impl = fake as unknown as {
137
- set: (key: string, value: string, ...rest: (string | number)[]) => Promise<"OK">;
138
- del: (...keys: string[]) => Promise<number>;
139
- rpush: (key: string, ...values: string[]) => Promise<number>;
156
+ set: (
157
+ key: string,
158
+ value: string,
159
+ options?: FakeSetOptions
160
+ ) => Promise<"OK" | null>;
161
+ del: (keys: Keys) => Promise<number>;
162
+ rPush: (key: string, element: Keys) => Promise<number>;
140
163
  expire: (key: string, ttl: number) => Promise<number>;
141
164
  };
142
165
  const ops: Array<() => Promise<unknown>> = [];
143
- const chain: FakePipeline = {
144
- set: (...args) => {
145
- const [key, value, ...rest] = args as [string, string, ...(string | number)[]];
146
- ops.push(() => impl.set(key, value, ...rest));
166
+ const chain: FakeMulti = {
167
+ set: (key, value, options) => {
168
+ ops.push(() => impl.set(key, value, options));
147
169
  return chain;
148
170
  },
149
- del: (...keys) => {
150
- ops.push(() => impl.del(...keys));
171
+ del: (keys) => {
172
+ ops.push(() => impl.del(keys));
151
173
  return chain;
152
174
  },
153
- rpush: (key, ...values) => {
154
- ops.push(() => impl.rpush(key, ...values));
175
+ rPush: (key, element) => {
176
+ ops.push(() => impl.rPush(key, element));
155
177
  return chain;
156
178
  },
157
179
  expire: (key, ttl) => {
158
180
  ops.push(() => impl.expire(key, ttl));
159
181
  return chain;
160
182
  },
161
- exec: async () => {
162
- const results: Array<[Error | null, unknown]> = [];
183
+ execAsPipeline: async () => {
184
+ const replies: unknown[] = [];
185
+ const errorIndexes: number[] = [];
186
+ let i = 0;
163
187
  for (const op of ops) {
164
188
  try {
165
- results.push([null, await op()]);
189
+ replies.push(await op());
166
190
  } catch (e) {
167
- results.push([e as Error, null]);
191
+ replies.push(e);
192
+ errorIndexes.push(i);
168
193
  }
194
+ i++;
195
+ }
196
+ if (errorIndexes.length > 0) {
197
+ throw makeMultiError(replies, errorIndexes);
169
198
  }
170
- return results;
199
+ return replies;
171
200
  },
172
201
  };
173
202
  return chain;
174
203
  },
175
204
  _store: store,
176
205
  _ttls: ttls,
177
- } as unknown as Redis & {
206
+ } as unknown as RedisClientType & {
178
207
  _store: Map<string, Value>;
179
208
  _ttls: Map<string, number>;
180
209
  };
@@ -182,13 +211,31 @@ export function createFakeRedis(): Redis & {
182
211
  return fake;
183
212
  }
184
213
 
185
- /** Minimal chainable surface used by the fake-redis pipeline stub. */
186
- interface FakePipeline {
187
- set: (...args: (string | number)[]) => FakePipeline;
188
- del: (...keys: string[]) => FakePipeline;
189
- rpush: (key: string, ...values: string[]) => FakePipeline;
190
- expire: (key: string, ttl: number) => FakePipeline;
191
- exec: () => Promise<Array<[Error | null, unknown]>>;
214
+ /** Minimal chainable surface used by the fake-redis `multi()` stub. */
215
+ interface FakeMulti {
216
+ set: (key: string, value: string, options?: FakeSetOptions) => FakeMulti;
217
+ del: (keys: Keys) => FakeMulti;
218
+ rPush: (key: string, element: Keys) => FakeMulti;
219
+ expire: (key: string, ttl: number) => FakeMulti;
220
+ execAsPipeline: () => Promise<unknown[]>;
221
+ }
222
+
223
+ /**
224
+ * Build a node-redis `MultiErrorReply`-shaped error: an `Error` carrying
225
+ * `replies` (per-command results, with failures as `Error`s) and
226
+ * `errorIndexes`. `applySnapshot` unwraps this to surface the first real
227
+ * error.
228
+ */
229
+ export function makeMultiError(
230
+ replies: unknown[],
231
+ errorIndexes: number[]
232
+ ): Error & { replies: unknown[]; errorIndexes: number[] } {
233
+ return Object.assign(
234
+ new Error(
235
+ `${errorIndexes.length} commands failed, see .replies and .errorIndexes for more information`
236
+ ),
237
+ { replies, errorIndexes }
238
+ );
192
239
  }
193
240
 
194
241
  /**
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
- import type Redis from "ioredis";
2
+ import type { RedisClientType as Redis } from "redis";
3
3
  import { createTieredThreadManager } from "./tiered";
4
4
  import { createThreadManager } from "./manager";
5
5
  import {
@@ -1,7 +1,7 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType } from "redis";
2
2
  import type { JsonValue, PersistedThreadState } from "../state/types";
3
3
  export interface ThreadManagerConfig<T> {
4
- redis: Redis;
4
+ redis: RedisClientType;
5
5
  threadId: string;
6
6
  /** Thread key, defaults to 'messages' */
7
7
  key?: string;
@@ -271,6 +271,50 @@ describe("createToolRouter integration", () => {
271
271
  expect(order[1]).toBe("start-echo-b");
272
272
  });
273
273
 
274
+ it("appends parallel results in original call order", async () => {
275
+ const slowEcho = defineTool({
276
+ name: "Echo" as const,
277
+ description: "slow echo with variable latency",
278
+ schema: z.object({ text: z.string(), delay: z.number() }),
279
+ handler: async (args: { text: string; delay: number }) => {
280
+ await new Promise((r) => setTimeout(r, args.delay));
281
+ return { toolResponse: args.text, data: { echoed: args.text } };
282
+ },
283
+ });
284
+
285
+ const router = createToolRouter({
286
+ tools: { Echo: slowEcho, Add: mathTool } as const,
287
+ threadId: "t-1",
288
+ appendToolResult: appendSpy.fn,
289
+ parallel: true,
290
+ });
291
+
292
+ const calls = [
293
+ router.parseToolCall({
294
+ id: "tc-1",
295
+ name: "Echo",
296
+ args: { text: "first", delay: 30 },
297
+ }),
298
+ router.parseToolCall({
299
+ id: "tc-2",
300
+ name: "Echo",
301
+ args: { text: "second", delay: 0 },
302
+ }),
303
+ router.parseToolCall({
304
+ id: "tc-3",
305
+ name: "Echo",
306
+ args: { text: "third", delay: 15 },
307
+ }),
308
+ ];
309
+
310
+ await router.processToolCalls(calls);
311
+
312
+ expect(appendSpy.calls).toHaveLength(3);
313
+ expect(at(appendSpy.calls, 0).toolCallId).toBe("tc-1");
314
+ expect(at(appendSpy.calls, 1).toolCallId).toBe("tc-2");
315
+ expect(at(appendSpy.calls, 2).toolCallId).toBe("tc-3");
316
+ });
317
+
274
318
  it("processes multiple tool calls sequentially", async () => {
275
319
  const order: string[] = [];
276
320
  const slowEcho = defineTool({
@@ -211,10 +211,20 @@ export function createToolRouter<T extends ToolMap>(
211
211
  * handler requested a session-level rewind; when present, the result is
212
212
  * not appended to the thread and siblings should be cancelled.
213
213
  */
214
+ interface PendingAppend {
215
+ toolCallId: string;
216
+ toolName: string;
217
+ content: JsonValue;
218
+ }
219
+
214
220
  type ProcessedToolCall =
215
- | { kind: "result"; value: ToolCallResultUnion<TResults> }
221
+ | {
222
+ kind: "result";
223
+ value: ToolCallResultUnion<TResults>;
224
+ pendingAppend?: PendingAppend;
225
+ }
216
226
  | { kind: "rewind"; signal: RewindSignal }
217
- | { kind: "skipped" };
227
+ | { kind: "skipped"; pendingAppend?: PendingAppend };
218
228
 
219
229
  async function processToolCall(
220
230
  toolCall: ParsedToolCallUnion<T>,
@@ -222,7 +232,8 @@ export function createToolRouter<T extends ToolMap>(
222
232
  sandboxId?: string,
223
233
  onRewindRequested?: (signal: RewindSignal) => void,
224
234
  assistantMessageId?: string,
225
- persistThreadState?: () => Promise<void>
235
+ persistThreadState?: () => Promise<void>,
236
+ deferAppend?: boolean
226
237
  ): Promise<ProcessedToolCall> {
227
238
  const startTime = Date.now();
228
239
  const tool = toolMap.get(toolCall.name);
@@ -230,15 +241,26 @@ export function createToolRouter<T extends ToolMap>(
230
241
  // --- Pre-hooks: may skip or modify args ---
231
242
  const preResult = await runPreHooks(toolCall, tool, turn);
232
243
  if (preResult.skip) {
244
+ const skipContent = JSON.stringify({
245
+ skipped: true,
246
+ reason: "Skipped by PreToolUse hook",
247
+ });
248
+ if (deferAppend) {
249
+ return {
250
+ kind: "skipped",
251
+ pendingAppend: {
252
+ toolCallId: toolCall.id,
253
+ toolName: toolCall.name,
254
+ content: skipContent,
255
+ },
256
+ };
257
+ }
233
258
  await appendToolResult(uuid4(), {
234
259
  threadId: options.threadId,
235
260
  threadKey: options.threadKey,
236
261
  toolCallId: toolCall.id,
237
262
  toolName: toolCall.name,
238
- content: JSON.stringify({
239
- skipped: true,
240
- reason: "Skipped by PreToolUse hook",
241
- }),
263
+ content: skipContent,
242
264
  });
243
265
  return { kind: "skipped" };
244
266
  }
@@ -314,19 +336,22 @@ export function createToolRouter<T extends ToolMap>(
314
336
  }
315
337
 
316
338
  // --- Append result to thread (unless handler already did) ---
317
- if (!resultAppended) {
318
- const config = {
319
- threadId: options.threadId,
320
- threadKey: options.threadKey,
321
- toolCallId: toolCall.id,
322
- toolName: toolCall.name,
323
- content,
324
- };
339
+ const needsAppend = !resultAppended;
340
+ if (needsAppend && !deferAppend) {
325
341
  await appendToolResult.executeWithOptions(
326
342
  {
327
343
  summary: `Append ${toolCall.name} result`,
328
344
  },
329
- [uuid4(), config]
345
+ [
346
+ uuid4(),
347
+ {
348
+ threadId: options.threadId,
349
+ threadKey: options.threadKey,
350
+ toolCallId: toolCall.id,
351
+ toolName: toolCall.name,
352
+ content,
353
+ },
354
+ ]
330
355
  );
331
356
  }
332
357
 
@@ -356,7 +381,18 @@ export function createToolRouter<T extends ToolMap>(
356
381
  durationMs
357
382
  );
358
383
 
359
- return { kind: "result", value: toolResult };
384
+ return {
385
+ kind: "result",
386
+ value: toolResult,
387
+ ...(needsAppend &&
388
+ deferAppend && {
389
+ pendingAppend: {
390
+ toolCallId: toolCall.id,
391
+ toolName: toolCall.name,
392
+ content,
393
+ },
394
+ }),
395
+ };
360
396
  }
361
397
 
362
398
  return {
@@ -409,7 +445,7 @@ export function createToolRouter<T extends ToolMap>(
409
445
  ): Promise<ProcessToolCallsResult<TResults>> {
410
446
  const attachRewind = (
411
447
  arr: ToolCallResultUnion<TResults>[],
412
- rewind: RewindSignal | undefined,
448
+ rewind: RewindSignal | undefined
413
449
  ): ProcessToolCallsResult<TResults> => {
414
450
  if (rewind) {
415
451
  (arr as ProcessToolCallsResult<TResults>).rewind = rewind;
@@ -447,19 +483,55 @@ export function createToolRouter<T extends ToolMap>(
447
483
  sandboxId,
448
484
  onRewindRequested,
449
485
  assistantMessageId,
450
- persistThreadState
486
+ persistThreadState,
487
+ true
451
488
  )
452
489
  )
453
490
  )
454
491
  );
455
492
 
493
+ // Fail fast on non-cancellation rejections before appending
494
+ // anything, so the thread stays clean for retry/truncation.
495
+ for (const outcome of outcomes) {
496
+ if (
497
+ outcome.status === "rejected" &&
498
+ !isCancellation(outcome.reason)
499
+ ) {
500
+ throw outcome.reason;
501
+ }
502
+ }
503
+
504
+ // Append deferred results in original call order so positional
505
+ // correlation between function calls and responses is preserved.
506
+ if (!rewindSignal) {
507
+ for (const outcome of outcomes) {
508
+ if (
509
+ outcome.status === "fulfilled" &&
510
+ outcome.value.kind !== "rewind" &&
511
+ outcome.value.pendingAppend
512
+ ) {
513
+ const pa = outcome.value.pendingAppend;
514
+ await appendToolResult.executeWithOptions(
515
+ { summary: `Append ${pa.toolName} result` },
516
+ [
517
+ uuid4(),
518
+ {
519
+ threadId: options.threadId,
520
+ threadKey: options.threadKey,
521
+ toolCallId: pa.toolCallId,
522
+ toolName: pa.toolName,
523
+ content: pa.content,
524
+ },
525
+ ]
526
+ );
527
+ }
528
+ }
529
+ }
530
+
456
531
  const results: ToolCallResultUnion<TResults>[] = [];
457
532
  for (const outcome of outcomes) {
458
533
  if (outcome.status === "rejected") {
459
- if (isCancellation(outcome.reason)) {
460
- continue;
461
- }
462
- throw outcome.reason;
534
+ continue;
463
535
  }
464
536
  if (outcome.value.kind === "result") {
465
537
  results.push(outcome.value.value);
@@ -502,8 +574,12 @@ export function createToolRouter<T extends ToolMap>(
502
574
  }
503
575
 
504
576
  const processOne = async (
505
- toolCall: ParsedToolCallUnion<T>
506
- ): Promise<ToolCallResult<TName, TResult>> => {
577
+ toolCall: ParsedToolCallUnion<T>,
578
+ deferAppend?: boolean
579
+ ): Promise<{
580
+ result: ToolCallResult<TName, TResult>;
581
+ pendingAppend?: PendingAppend;
582
+ }> => {
507
583
  const routerContext: RouterContext = {
508
584
  threadId: options.threadId,
509
585
  ...(options.threadKey && { threadKey: options.threadKey }),
@@ -524,7 +600,8 @@ export function createToolRouter<T extends ToolMap>(
524
600
  routerContext as Parameters<typeof handler>[1]
525
601
  );
526
602
 
527
- if (!response.resultAppended) {
603
+ const needsAppend = !response.resultAppended;
604
+ if (needsAppend && !deferAppend) {
528
605
  await appendToolResult.executeWithOptions(
529
606
  {
530
607
  summary: `Append ${toolCall.name} result`,
@@ -543,20 +620,51 @@ export function createToolRouter<T extends ToolMap>(
543
620
  }
544
621
 
545
622
  return {
546
- toolCallId: toolCall.id,
547
- name: toolCall.name as TName,
548
- data: response.data,
549
- ...(response.metadata && { metadata: response.metadata }),
623
+ result: {
624
+ toolCallId: toolCall.id,
625
+ name: toolCall.name as TName,
626
+ data: response.data,
627
+ ...(response.metadata && { metadata: response.metadata }),
628
+ },
629
+ ...(needsAppend &&
630
+ deferAppend && {
631
+ pendingAppend: {
632
+ toolCallId: toolCall.id,
633
+ toolName: toolCall.name,
634
+ content: response.toolResponse as JsonValue,
635
+ },
636
+ }),
550
637
  };
551
638
  };
552
639
 
553
640
  if (options.parallel) {
554
- return Promise.all(matchingCalls.map(processOne));
641
+ const outcomes = await Promise.all(
642
+ matchingCalls.map((tc) => processOne(tc, true))
643
+ );
644
+ for (const { pendingAppend } of outcomes) {
645
+ if (pendingAppend) {
646
+ await appendToolResult.executeWithOptions(
647
+ { summary: `Append ${pendingAppend.toolName} result` },
648
+ [
649
+ uuid4(),
650
+ {
651
+ threadId: options.threadId,
652
+ threadKey: options.threadKey,
653
+ toolCallId: pendingAppend.toolCallId,
654
+ toolName: pendingAppend.toolName,
655
+ content: pendingAppend.content,
656
+ },
657
+ ]
658
+ );
659
+ }
660
+ }
661
+ return outcomes.map((o) => o.result);
555
662
  }
556
663
 
557
664
  const results: ToolCallResult<TName, TResult>[] = [];
558
665
  for (const toolCall of matchingCalls) {
559
- results.push(await processOne(toolCall));
666
+ const { result } = await processOne(toolCall);
667
+ results.push(result);
560
668
  }
561
669
  return results;
562
670
  },
@@ -1,4 +1,6 @@
1
1
  import type { ThreadInit, SandboxInit, SandboxShutdown } from "./lifecycle";
2
+ import type { SandboxSnapshot } from "./sandbox/types";
3
+ import type { TokenUsage } from "./types";
2
4
 
3
5
  /**
4
6
  * Session config fields derived from a main workflow input, ready to spread
@@ -13,6 +15,25 @@ export interface WorkflowSessionInput {
13
15
  sandbox?: SandboxInit;
14
16
  /** Sandbox shutdown policy (default: "destroy") */
15
17
  sandboxShutdown?: SandboxShutdown;
18
+ /**
19
+ * Called by the session right before `runSession` returns. Installed by
20
+ * `defineWorkflow` to capture sandbox / thread / usage outputs and forward
21
+ * them to the workflow's `onSessionExit` config hook. Spread into
22
+ * `createSession` via `...sessionInput`.
23
+ */
24
+ onSessionExit?: (result: {
25
+ sandboxId?: string;
26
+ snapshot?: SandboxSnapshot;
27
+ threadId: string;
28
+ usage: {
29
+ totalInputTokens: number;
30
+ totalOutputTokens: number;
31
+ totalCachedWriteTokens: number;
32
+ totalCachedReadTokens: number;
33
+ totalReasonTokens: number;
34
+ turns: number;
35
+ };
36
+ }) => void;
16
37
  }
17
38
 
18
39
  /** Raw workflow input fields that map into `WorkflowSessionInput`. */
@@ -34,6 +55,18 @@ export interface WorkflowConfig {
34
55
  * - `"keep"` — leave the sandbox running (no-op on exit).
35
56
  */
36
57
  sandboxShutdown?: SandboxShutdown;
58
+ /**
59
+ * Called right before the underlying session exits, with the sandbox /
60
+ * thread outputs and normalized token usage. Mirrors the capture logic in
61
+ * `defineSubagentWorkflow`; useful for emitting metrics or persisting
62
+ * sandbox / thread ids without threading them through the handler result.
63
+ */
64
+ onSessionExit?: (result: {
65
+ sandboxId?: string;
66
+ snapshot?: SandboxSnapshot;
67
+ threadId: string;
68
+ usage: TokenUsage;
69
+ }) => void;
37
70
  }
38
71
 
39
72
  /**
@@ -59,6 +92,22 @@ export function defineWorkflow<TInput, TResult>(
59
92
  sandboxShutdown: config.sandboxShutdown ?? "destroy",
60
93
  ...(workflowInput.thread && { thread: workflowInput.thread }),
61
94
  ...(workflowInput.sandbox && { sandbox: workflowInput.sandbox }),
95
+ ...(config.onSessionExit && {
96
+ onSessionExit: ({ sandboxId, snapshot, threadId, usage }): void => {
97
+ config.onSessionExit?.({
98
+ ...(sandboxId !== undefined && { sandboxId }),
99
+ ...(snapshot !== undefined && { snapshot }),
100
+ threadId,
101
+ usage: {
102
+ inputTokens: usage.totalInputTokens,
103
+ outputTokens: usage.totalOutputTokens,
104
+ cachedWriteTokens: usage.totalCachedWriteTokens,
105
+ cachedReadTokens: usage.totalCachedReadTokens,
106
+ reasonTokens: usage.totalReasonTokens,
107
+ },
108
+ });
109
+ },
110
+ }),
62
111
  };
63
112
  return fn(input, sessionInput);
64
113
  };