@circuitwall/jarela 1.2.0 → 1.3.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.
Files changed (90) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +3 -3
  23. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +3 -3
  25. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +3 -3
  27. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +3 -3
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +3 -3
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +218 -7
  33. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/events/route.js +3 -3
  35. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +8 -1
  37. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js.map +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +8 -1
  39. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js.map +1 -1
  40. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +8 -1
  41. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js.map +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +8 -1
  43. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js.map +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
  45. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
  46. package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
  47. package/.next/standalone/.next/server/app/page.js +0 -16
  48. package/.next/standalone/.next/server/app/page.js.map +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/chunks/210.js +1 -1
  52. package/.next/standalone/.next/server/chunks/239.js +5335 -5230
  53. package/.next/standalone/.next/server/chunks/239.js.map +1 -1
  54. package/.next/standalone/.next/server/chunks/{1683.js → 241.js} +210 -36
  55. package/.next/standalone/.next/server/chunks/241.js.map +1 -0
  56. package/.next/standalone/.next/server/chunks/{8135.js → 2539.js} +218 -36
  57. package/.next/standalone/.next/server/chunks/2539.js.map +1 -0
  58. package/.next/standalone/.next/server/chunks/4631.js +218 -7
  59. package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
  60. package/.next/standalone/.next/server/chunks/8866.js +13389 -13073
  61. package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
  62. package/.next/standalone/.next/server/chunks/9032.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  64. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  65. package/.next/standalone/.next/server/pages/404.html +1 -1
  66. package/.next/standalone/.next/server/pages/500.html +1 -1
  67. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/static/chunks/app/{page-62e0d5f2404b403b.js → page-2ab710949b62a638.js} +1 -17
  69. package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +1 -0
  70. package/.next/standalone/package.json +1 -1
  71. package/CHANGELOG.md +74 -0
  72. package/components/ui/BootScreen.tsx +0 -10
  73. package/lib/agents/agent-turn.ts +9 -0
  74. package/lib/agents/prepare/request.ts +9 -0
  75. package/lib/agents/run-thread.ts +9 -1
  76. package/lib/api/extension-turn.ts +7 -0
  77. package/lib/bridges/attachment-store.test.ts +440 -0
  78. package/lib/bridges/attachment-store.ts +184 -0
  79. package/lib/bridges/whatsapp.ts +50 -32
  80. package/lib/tools/async-results-tool.ts +114 -0
  81. package/lib/tools/async-results.test.ts +481 -0
  82. package/lib/tools/async-results.ts +165 -0
  83. package/lib/tools/builtins.ts +1 -0
  84. package/lib/tools/wallclock.ts +114 -8
  85. package/package.json +1 -1
  86. package/.next/standalone/.next/server/chunks/1683.js.map +0 -1
  87. package/.next/standalone/.next/server/chunks/8135.js.map +0 -1
  88. package/.next/standalone/.next/static/chunks/app/page-62e0d5f2404b403b.js.map +0 -1
  89. /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_buildManifest.js +0 -0
  90. /package/.next/standalone/.next/static/{2xWP8843jbntFGKLnHK6R → ZKy7LJ3KXj2TIyKOg_fBH}/_ssgManifest.js +0 -0
@@ -0,0 +1,481 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { z } from "zod";
3
+ import { tool } from "@langchain/core/tools";
4
+ import { wrapWithWallclock, DEFAULT_MAX_DEADLINE_MS, getMaxDeadlineMs } from "./wallclock";
5
+ import {
6
+ __resetStore,
7
+ getAsyncResult,
8
+ listAsyncResults,
9
+ __backdateFinished,
10
+ sweepExpired,
11
+ startAsyncCall,
12
+ completeAsyncCall,
13
+ failAsyncCall,
14
+ consumeAsyncResult,
15
+ MAX_ENTRIES,
16
+ } from "./async-results";
17
+ import { toolResultGetTool, toolResultListTool } from "./async-results-tool";
18
+
19
+ function makeSlowTool(name: string, delayMs: number, throws = false) {
20
+ return tool(
21
+ async ({ value }: { value: string }) => {
22
+ await new Promise((r) => setTimeout(r, delayMs));
23
+ if (throws) throw new Error(`boom: ${value}`);
24
+ return JSON.stringify({ ok: true, value });
25
+ },
26
+ { name, description: "test", schema: z.object({ value: z.string() }) },
27
+ );
28
+ }
29
+
30
+ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
31
+ const start = Date.now();
32
+ while (Date.now() - start < timeoutMs) {
33
+ if (predicate()) return;
34
+ await new Promise((r) => setTimeout(r, 10));
35
+ }
36
+ throw new Error("waitFor timed out");
37
+ }
38
+
39
+ beforeEach(() => { __resetStore(); });
40
+
41
+ describe("wallclock async_run", () => {
42
+ it("returns immediately with a key when async_run is true", async () => {
43
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 200));
44
+ const t0 = Date.now();
45
+ const out = await wrapped.invoke({ value: "hi", async_run: true });
46
+ const elapsed = Date.now() - t0;
47
+ expect(elapsed).toBeLessThan(100);
48
+ const parsed = JSON.parse(out as string);
49
+ expect(parsed.async).toBe(true);
50
+ expect(parsed.ok).toBe(true);
51
+ expect(parsed.tool).toBe("slow");
52
+ expect(parsed.key).toMatch(/^async_[0-9a-f]{16}$/);
53
+ expect(parsed.hint).toContain("tool_result_get");
54
+ // Underlying tool is still pending.
55
+ const rec = getAsyncResult(parsed.key);
56
+ expect(rec?.status).toBe("pending");
57
+ });
58
+
59
+ it("eventually marks the slot done with the tool's result", async () => {
60
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 30));
61
+ const out = await wrapped.invoke({ value: "x", async_run: true });
62
+ const { key } = JSON.parse(out as string);
63
+ await waitFor(() => getAsyncResult(key)?.status === "done");
64
+ const rec = getAsyncResult(key)!;
65
+ expect(rec.status).toBe("done");
66
+ expect(JSON.parse(rec.result!)).toEqual({ ok: true, value: "x" });
67
+ });
68
+
69
+ it("marks the slot errored when the tool throws", async () => {
70
+ const wrapped = wrapWithWallclock(makeSlowTool("slow-throw", 20, true));
71
+ const out = await wrapped.invoke({ value: "x", async_run: true });
72
+ const { key } = JSON.parse(out as string);
73
+ await waitFor(() => getAsyncResult(key)?.status === "error");
74
+ const rec = getAsyncResult(key)!;
75
+ expect(rec.status).toBe("error");
76
+ expect(rec.error).toContain("boom: x");
77
+ });
78
+
79
+ it("marks the slot errored when the deadline fires before completion", async () => {
80
+ const wrapped = wrapWithWallclock(makeSlowTool("very-slow", 1000));
81
+ const out = await wrapped.invoke({ value: "x", async_run: true, deadline_ms: 30 });
82
+ const { key } = JSON.parse(out as string);
83
+ await waitFor(() => getAsyncResult(key)?.status === "error");
84
+ const rec = getAsyncResult(key)!;
85
+ expect(rec.error).toMatch(/background wall-clock budget/);
86
+ });
87
+
88
+ it("does not leak wrapper fields into the inner tool args", async () => {
89
+ let seen: unknown = null;
90
+ const inner = tool(
91
+ async (args: Record<string, unknown>) => {
92
+ seen = args;
93
+ return JSON.stringify({ ok: true });
94
+ },
95
+ { name: "echo-async", description: "echo", schema: z.object({ value: z.string() }).passthrough() },
96
+ );
97
+ const wrapped = wrapWithWallclock(inner);
98
+ const out = await wrapped.invoke({ value: "x", async_run: true, deadline_ms: 1000 });
99
+ const { key } = JSON.parse(out as string);
100
+ await waitFor(() => getAsyncResult(key)?.status === "done");
101
+ expect(seen).toEqual({ value: "x" });
102
+ });
103
+ });
104
+
105
+ describe("tool_result_get", () => {
106
+ it("returns pending when the call is still running", async () => {
107
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 500));
108
+ const start = await wrapped.invoke({ value: "x", async_run: true });
109
+ const { key } = JSON.parse(start as string);
110
+ const got = await toolResultGetTool.invoke({ key });
111
+ const parsed = JSON.parse(got as string);
112
+ expect(parsed.ok).toBe(true);
113
+ expect(parsed.status).toBe("pending");
114
+ expect(parsed.key).toBe(key);
115
+ });
116
+
117
+ it("returns the result once done", async () => {
118
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 20));
119
+ const start = await wrapped.invoke({ value: "hello", async_run: true });
120
+ const { key } = JSON.parse(start as string);
121
+ await waitFor(() => getAsyncResult(key)?.status === "done");
122
+ const got = await toolResultGetTool.invoke({ key });
123
+ const parsed = JSON.parse(got as string);
124
+ expect(parsed.status).toBe("done");
125
+ expect(JSON.parse(parsed.result)).toEqual({ ok: true, value: "hello" });
126
+ expect(typeof parsed.elapsed_ms).toBe("number");
127
+ });
128
+
129
+ it("short-polls via wait_ms when the call finishes inside the budget", async () => {
130
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 50));
131
+ const start = await wrapped.invoke({ value: "x", async_run: true });
132
+ const { key } = JSON.parse(start as string);
133
+ const t0 = Date.now();
134
+ const got = await toolResultGetTool.invoke({ key, wait_ms: 500 });
135
+ const elapsed = Date.now() - t0;
136
+ const parsed = JSON.parse(got as string);
137
+ expect(parsed.status).toBe("done");
138
+ expect(elapsed).toBeLessThan(450); // resolved well before the 500ms budget
139
+ });
140
+
141
+ it("returns the error envelope when the call failed", async () => {
142
+ const wrapped = wrapWithWallclock(makeSlowTool("slow-throw", 10, true));
143
+ const start = await wrapped.invoke({ value: "x", async_run: true });
144
+ const { key } = JSON.parse(start as string);
145
+ await waitFor(() => getAsyncResult(key)?.status === "error");
146
+ const got = await toolResultGetTool.invoke({ key });
147
+ const parsed = JSON.parse(got as string);
148
+ expect(parsed.status).toBe("error");
149
+ expect(parsed.error).toContain("boom");
150
+ });
151
+
152
+ it("returns status=unknown for a missing key", async () => {
153
+ const got = await toolResultGetTool.invoke({ key: "async_does_not_exist" });
154
+ const parsed = JSON.parse(got as string);
155
+ expect(parsed.ok).toBe(false);
156
+ expect(parsed.status).toBe("unknown");
157
+ });
158
+
159
+ it("deletes the entry when consume=true on a finished call", async () => {
160
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 15));
161
+ const start = await wrapped.invoke({ value: "x", async_run: true });
162
+ const { key } = JSON.parse(start as string);
163
+ await waitFor(() => getAsyncResult(key)?.status === "done");
164
+ await toolResultGetTool.invoke({ key, consume: true });
165
+ expect(getAsyncResult(key)).toBeNull();
166
+ });
167
+
168
+ it("does NOT delete a pending entry when consume=true", async () => {
169
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 500));
170
+ const start = await wrapped.invoke({ value: "x", async_run: true });
171
+ const { key } = JSON.parse(start as string);
172
+ await toolResultGetTool.invoke({ key, consume: true });
173
+ expect(getAsyncResult(key)).not.toBeNull();
174
+ });
175
+ });
176
+
177
+ describe("tool_result_list", () => {
178
+ it("returns a summary of every entry", async () => {
179
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 15));
180
+ await wrapped.invoke({ value: "a", async_run: true });
181
+ await wrapped.invoke({ value: "b", async_run: true });
182
+ const out = await toolResultListTool.invoke({});
183
+ const parsed = JSON.parse(out as string);
184
+ expect(parsed.count).toBe(2);
185
+ // No `result` field leaks in the list summary.
186
+ for (const r of parsed.results) {
187
+ expect(r).not.toHaveProperty("result");
188
+ expect(r).not.toHaveProperty("error");
189
+ }
190
+ });
191
+
192
+ it("filters by status", async () => {
193
+ const fast = wrapWithWallclock(makeSlowTool("fast", 10));
194
+ const slow = wrapWithWallclock(makeSlowTool("slow", 1000));
195
+ const a = await fast.invoke({ value: "a", async_run: true });
196
+ await slow.invoke({ value: "b", async_run: true });
197
+ const aKey = JSON.parse(a as string).key;
198
+ await waitFor(() => getAsyncResult(aKey)?.status === "done");
199
+ const done = JSON.parse(await toolResultListTool.invoke({ status: "done" }) as string);
200
+ const pending = JSON.parse(await toolResultListTool.invoke({ status: "pending" }) as string);
201
+ expect(done.count).toBe(1);
202
+ expect(pending.count).toBe(1);
203
+ });
204
+ });
205
+
206
+ describe("sweepExpired", () => {
207
+ it("drops finished entries older than the TTL but keeps pending ones", async () => {
208
+ const fast = wrapWithWallclock(makeSlowTool("fast", 10));
209
+ const slow = wrapWithWallclock(makeSlowTool("slow", 5000));
210
+ const a = JSON.parse(await fast.invoke({ value: "a", async_run: true }) as string).key;
211
+ const b = JSON.parse(await slow.invoke({ value: "b", async_run: true }) as string).key;
212
+ await waitFor(() => getAsyncResult(a)?.status === "done");
213
+ __backdateFinished(a, Date.now() - 60_000);
214
+ const removed = sweepExpired(1_000);
215
+ expect(removed).toBe(1);
216
+ expect(getAsyncResult(a)).toBeNull();
217
+ expect(getAsyncResult(b)).not.toBeNull();
218
+ expect(listAsyncResults()).toHaveLength(1);
219
+ });
220
+
221
+ it("does not remove entries that are still within the TTL", async () => {
222
+ const fast = wrapWithWallclock(makeSlowTool("fast", 10));
223
+ const k = JSON.parse(await fast.invoke({ value: "x", async_run: true }) as string).key;
224
+ await waitFor(() => getAsyncResult(k)?.status === "done");
225
+ const removed = sweepExpired(60_000);
226
+ expect(removed).toBe(0);
227
+ expect(getAsyncResult(k)).not.toBeNull();
228
+ });
229
+
230
+ it("does not remove pending entries even with a tiny TTL", async () => {
231
+ const slow = wrapWithWallclock(makeSlowTool("slow", 5000));
232
+ const k = JSON.parse(await slow.invoke({ value: "x", async_run: true }) as string).key;
233
+ const removed = sweepExpired(0);
234
+ expect(removed).toBe(0);
235
+ expect(getAsyncResult(k)?.status).toBe("pending");
236
+ });
237
+ });
238
+
239
+ describe("low-level store API", () => {
240
+ it("startAsyncCall returns unique keys per call", () => {
241
+ const keys = new Set(Array.from({ length: 50 }, () => startAsyncCall("t")));
242
+ expect(keys.size).toBe(50);
243
+ for (const k of keys) {
244
+ expect(k).toMatch(/^async_[0-9a-f]{16}$/);
245
+ }
246
+ });
247
+
248
+ it("completeAsyncCall is a no-op for an unknown key", () => {
249
+ expect(() => completeAsyncCall("nope", "x")).not.toThrow();
250
+ });
251
+
252
+ it("failAsyncCall is a no-op for an unknown key", () => {
253
+ expect(() => failAsyncCall("nope", "x")).not.toThrow();
254
+ });
255
+
256
+ it("consumeAsyncResult returns the record and deletes it", () => {
257
+ const k = startAsyncCall("t");
258
+ completeAsyncCall(k, "result-body");
259
+ const rec = consumeAsyncResult(k);
260
+ expect(rec?.result).toBe("result-body");
261
+ expect(getAsyncResult(k)).toBeNull();
262
+ // Second consume returns null.
263
+ expect(consumeAsyncResult(k)).toBeNull();
264
+ });
265
+
266
+ it("listAsyncResults returns newest-first", async () => {
267
+ const a = startAsyncCall("t");
268
+ await new Promise((r) => setTimeout(r, 5));
269
+ const b = startAsyncCall("t");
270
+ await new Promise((r) => setTimeout(r, 5));
271
+ const c = startAsyncCall("t");
272
+ const list = listAsyncResults();
273
+ expect(list.map((r) => r.key)).toEqual([c, b, a]);
274
+ });
275
+ });
276
+
277
+ describe("store capacity cap (MAX_ENTRIES)", () => {
278
+ it("evicts a finished entry first when the cap is hit", () => {
279
+ // Fill the store: first entry is finished, rest are pending.
280
+ const finishedKey = startAsyncCall("t");
281
+ completeAsyncCall(finishedKey, "old");
282
+ const pendingKeys: string[] = [];
283
+ for (let i = 0; i < MAX_ENTRIES - 1; i++) {
284
+ pendingKeys.push(startAsyncCall("t"));
285
+ }
286
+ expect(listAsyncResults()).toHaveLength(MAX_ENTRIES);
287
+ // Adding one more should evict the finished entry (oldest finished).
288
+ const overflow = startAsyncCall("t");
289
+ expect(getAsyncResult(finishedKey)).toBeNull();
290
+ for (const k of pendingKeys) {
291
+ expect(getAsyncResult(k)).not.toBeNull();
292
+ }
293
+ expect(getAsyncResult(overflow)).not.toBeNull();
294
+ expect(listAsyncResults()).toHaveLength(MAX_ENTRIES);
295
+ });
296
+
297
+ it("falls back to evicting the oldest pending when every entry is pending", () => {
298
+ const keys: string[] = [];
299
+ for (let i = 0; i < MAX_ENTRIES; i++) {
300
+ keys.push(startAsyncCall("t"));
301
+ }
302
+ const oldest = keys[0];
303
+ const overflow = startAsyncCall("t");
304
+ expect(getAsyncResult(oldest)).toBeNull();
305
+ expect(getAsyncResult(overflow)).not.toBeNull();
306
+ expect(listAsyncResults()).toHaveLength(MAX_ENTRIES);
307
+ });
308
+ });
309
+
310
+ describe("non-string tool results", () => {
311
+ it("JSON.stringify-ifies object results when storing", async () => {
312
+ const objectTool = tool(
313
+ async () => ({ a: 1, b: [2, 3] }) as unknown as string,
314
+ { name: "obj", description: "x", schema: z.object({}).passthrough() },
315
+ );
316
+ const wrapped = wrapWithWallclock(objectTool);
317
+ const start = await wrapped.invoke({ async_run: true });
318
+ const { key } = JSON.parse(start as string);
319
+ await waitFor(() => getAsyncResult(key)?.status === "done");
320
+ const rec = getAsyncResult(key)!;
321
+ expect(typeof rec.result).toBe("string");
322
+ expect(JSON.parse(rec.result!)).toEqual({ a: 1, b: [2, 3] });
323
+ });
324
+ });
325
+
326
+ describe("concurrent async invocations don't collide", () => {
327
+ it("20 concurrent async fires all settle with distinct keys", async () => {
328
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 30));
329
+ const starts = await Promise.all(
330
+ Array.from({ length: 20 }, (_, i) =>
331
+ wrapped.invoke({ value: `v${i}`, async_run: true }),
332
+ ),
333
+ );
334
+ const keys = starts.map((s) => JSON.parse(s as string).key);
335
+ expect(new Set(keys).size).toBe(20);
336
+ await waitFor(
337
+ () => keys.every((k) => getAsyncResult(k)?.status === "done"),
338
+ 3_000,
339
+ );
340
+ for (const k of keys) {
341
+ const rec = getAsyncResult(k)!;
342
+ expect(rec.status).toBe("done");
343
+ expect(rec.result).toBeTruthy();
344
+ }
345
+ });
346
+ });
347
+
348
+ describe("tool_result_get edge cases", () => {
349
+ it("ignores a wait_ms of 0 (treated as a non-blocking peek)", async () => {
350
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 500));
351
+ const start = await wrapped.invoke({ value: "x", async_run: true });
352
+ const { key } = JSON.parse(start as string);
353
+ const t0 = Date.now();
354
+ const out = await toolResultGetTool.invoke({ key, wait_ms: 0 });
355
+ const elapsed = Date.now() - t0;
356
+ expect(elapsed).toBeLessThan(50);
357
+ const parsed = JSON.parse(out as string);
358
+ expect(parsed.status).toBe("pending");
359
+ });
360
+
361
+ it("returns status=pending without short-poll if wait_ms is omitted", async () => {
362
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 500));
363
+ const start = await wrapped.invoke({ value: "x", async_run: true });
364
+ const { key } = JSON.parse(start as string);
365
+ const out = await toolResultGetTool.invoke({ key });
366
+ expect(JSON.parse(out as string).status).toBe("pending");
367
+ });
368
+
369
+ it("rejects out-of-range wait_ms via schema validation", async () => {
370
+ // Schema caps at 60_000.
371
+ let threw = false;
372
+ try {
373
+ await toolResultGetTool.invoke({ key: "x", wait_ms: 999_999 });
374
+ } catch {
375
+ threw = true;
376
+ }
377
+ expect(threw).toBe(true);
378
+ });
379
+
380
+ it("elapsed_ms is computed from started_at to now while pending", async () => {
381
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 500));
382
+ const start = await wrapped.invoke({ value: "x", async_run: true });
383
+ const { key } = JSON.parse(start as string);
384
+ await new Promise((r) => setTimeout(r, 30));
385
+ const out = await toolResultGetTool.invoke({ key });
386
+ const parsed = JSON.parse(out as string);
387
+ expect(parsed.elapsed_ms).toBeGreaterThanOrEqual(20);
388
+ });
389
+ });
390
+
391
+ describe("schema extension surface", () => {
392
+ it("wrapped tools advertise both deadline_ms and async_run on a zod-object schema", () => {
393
+ const inner = makeSlowTool("with-schema", 5);
394
+ const wrapped = wrapWithWallclock(inner);
395
+ const schema = (wrapped as unknown as { schema: z.ZodObject<z.ZodRawShape> }).schema;
396
+ expect(schema instanceof z.ZodObject).toBe(true);
397
+ const keys = Object.keys(schema.shape);
398
+ expect(keys).toContain("value");
399
+ expect(keys).toContain("deadline_ms");
400
+ expect(keys).toContain("async_run");
401
+ });
402
+
403
+ it("tool_result_get and tool_result_list themselves accept async_run (they're wrapped too at registry time)", () => {
404
+ // The tools imported here are the *unwrapped* ones; the registry
405
+ // wraps them with wallclock. We just sanity-check that their inner
406
+ // schema is a zod-object so the wrap would succeed.
407
+ const getSchema = (toolResultGetTool as unknown as { schema: z.ZodObject<z.ZodRawShape> }).schema;
408
+ const listSchema = (toolResultListTool as unknown as { schema: z.ZodObject<z.ZodRawShape> }).schema;
409
+ expect(getSchema instanceof z.ZodObject).toBe(true);
410
+ expect(listSchema instanceof z.ZodObject).toBe(true);
411
+ });
412
+ });
413
+
414
+ describe("deadline_ms ceiling", () => {
415
+ beforeEach(() => { delete process.env.JARELA_TOOL_MAX_DEADLINE_MS; });
416
+
417
+ it("defaults to 30 minutes", () => {
418
+ expect(DEFAULT_MAX_DEADLINE_MS).toBe(30 * 60 * 1000);
419
+ expect(getMaxDeadlineMs()).toBe(DEFAULT_MAX_DEADLINE_MS);
420
+ });
421
+
422
+ it("honours JARELA_TOOL_MAX_DEADLINE_MS override", () => {
423
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "5000";
424
+ expect(getMaxDeadlineMs()).toBe(5000);
425
+ });
426
+
427
+ it("falls back to default when override is non-numeric or non-positive", () => {
428
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "not-a-number";
429
+ expect(getMaxDeadlineMs()).toBe(DEFAULT_MAX_DEADLINE_MS);
430
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "0";
431
+ expect(getMaxDeadlineMs()).toBe(DEFAULT_MAX_DEADLINE_MS);
432
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "-10";
433
+ expect(getMaxDeadlineMs()).toBe(DEFAULT_MAX_DEADLINE_MS);
434
+ });
435
+
436
+ it("clamps a requested deadline above the ceiling and times out at the ceiling", async () => {
437
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "50";
438
+ const warns: string[] = [];
439
+ const origWarn = console.warn;
440
+ console.warn = (msg: unknown) => { warns.push(String(msg)); };
441
+ try {
442
+ const wrapped = wrapWithWallclock(makeSlowTool("slow", 500));
443
+ const t0 = Date.now();
444
+ const out = await wrapped.invoke({ value: "x", deadline_ms: 60_000 });
445
+ const elapsed = Date.now() - t0;
446
+ expect(elapsed).toBeLessThan(400);
447
+ const parsed = JSON.parse(out as string);
448
+ expect(parsed.ok).toBe(false);
449
+ expect(parsed.error_code).toBe("tool_timeout");
450
+ expect(parsed.deadline_ms).toBe(50);
451
+ expect(warns.some((w) => w.includes("exceeds ceiling"))).toBe(true);
452
+ } finally {
453
+ console.warn = origWarn;
454
+ }
455
+ });
456
+
457
+ it("does not warn when the requested deadline is within the ceiling", async () => {
458
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "10000";
459
+ const warns: string[] = [];
460
+ const origWarn = console.warn;
461
+ console.warn = (msg: unknown) => { warns.push(String(msg)); };
462
+ try {
463
+ const wrapped = wrapWithWallclock(makeSlowTool("fast", 5));
464
+ await wrapped.invoke({ value: "x", deadline_ms: 500 });
465
+ expect(warns.filter((w) => w.includes("exceeds ceiling"))).toHaveLength(0);
466
+ } finally {
467
+ console.warn = origWarn;
468
+ }
469
+ });
470
+
471
+ it("applies the ceiling to async_run invocations too", async () => {
472
+ process.env.JARELA_TOOL_MAX_DEADLINE_MS = "30";
473
+ const wrapped = wrapWithWallclock(makeSlowTool("slow-async", 300));
474
+ const start = await wrapped.invoke({ value: "x", deadline_ms: 60_000, async_run: true });
475
+ const { key, deadline_ms } = JSON.parse(start as string);
476
+ expect(deadline_ms).toBe(30);
477
+ await waitFor(() => getAsyncResult(key)?.status === "error");
478
+ const rec = getAsyncResult(key)!;
479
+ expect(rec.error).toContain("budget of 30ms");
480
+ });
481
+ });
@@ -0,0 +1,165 @@
1
+ // In-process keyed store for async tool results.
2
+ //
3
+ // When the wallclock wrapper sees `async_run: true` on a tool call it
4
+ // returns immediately with a key, kicks the real invocation off in the
5
+ // background, and parks the eventual result here. The agent later
6
+ // retrieves the result via the `tool_result_get` built-in.
7
+ //
8
+ // Scope is deliberately per-process, in-memory:
9
+ // - The data lives only as long as the Next.js server process. A
10
+ // restart wipes both the agent's in-context keys and these results
11
+ // simultaneously, so we can't end up with the LLM holding a key
12
+ // that survived the value.
13
+ // - No on-disk persistence means no schema migration, no PII spill,
14
+ // no risk of leaking long-running secrets between sessions.
15
+ //
16
+ // Memory hygiene:
17
+ // - TTL (DEFAULT_TTL_MS) caps how long a finished result hangs around
18
+ // unread. A background sweeper runs on a slow interval.
19
+ // - Cap on concurrent entries (MAX_ENTRIES). When exceeded, the
20
+ // oldest *finished* entry is evicted first; if none, the oldest
21
+ // pending entry is dropped (with a console warn).
22
+
23
+ import crypto from "node:crypto";
24
+
25
+ export type AsyncStatus = "pending" | "done" | "error";
26
+
27
+ export interface AsyncResultRecord {
28
+ key: string;
29
+ tool: string;
30
+ status: AsyncStatus;
31
+ started_at: number;
32
+ finished_at: number | null;
33
+ /** Stringified tool result. Tools return JSON strings; we keep that. */
34
+ result: string | null;
35
+ /** Plain message when the underlying call threw. */
36
+ error: string | null;
37
+ }
38
+
39
+ /** How long a finished result stays around if nobody reads it. */
40
+ export const DEFAULT_TTL_MS = 10 * 60 * 1000;
41
+
42
+ /** Soft cap on total entries (pending + finished). */
43
+ export const MAX_ENTRIES = 256;
44
+
45
+ /** How often the background sweeper runs. */
46
+ const SWEEP_INTERVAL_MS = 60 * 1000;
47
+
48
+ const STORE = new Map<string, AsyncResultRecord>();
49
+
50
+ let sweeper: ReturnType<typeof setInterval> | null = null;
51
+
52
+ function ensureSweeper(): void {
53
+ if (sweeper) return;
54
+ sweeper = setInterval(() => {
55
+ sweepExpired(DEFAULT_TTL_MS);
56
+ }, SWEEP_INTERVAL_MS);
57
+ (sweeper as unknown as { unref?: () => void }).unref?.();
58
+ }
59
+
60
+ /**
61
+ * Carve out a slot for a new async tool call and return its key.
62
+ * The key is opaque and URL-safe — the agent treats it as a token.
63
+ */
64
+ export function startAsyncCall(tool: string): string {
65
+ ensureSweeper();
66
+ enforceCap();
67
+ const key = `async_${crypto.randomBytes(8).toString("hex")}`;
68
+ STORE.set(key, {
69
+ key,
70
+ tool,
71
+ status: "pending",
72
+ started_at: Date.now(),
73
+ finished_at: null,
74
+ result: null,
75
+ error: null,
76
+ });
77
+ return key;
78
+ }
79
+
80
+ /** Mark a pending call as completed successfully. */
81
+ export function completeAsyncCall(key: string, result: string): void {
82
+ const rec = STORE.get(key);
83
+ if (!rec) return;
84
+ rec.status = "done";
85
+ rec.result = result;
86
+ rec.finished_at = Date.now();
87
+ }
88
+
89
+ /** Mark a pending call as failed. */
90
+ export function failAsyncCall(key: string, err: unknown): void {
91
+ const rec = STORE.get(key);
92
+ if (!rec) return;
93
+ rec.status = "error";
94
+ rec.error = err instanceof Error ? err.message : String(err);
95
+ rec.finished_at = Date.now();
96
+ }
97
+
98
+ /** Read a record without consuming it. */
99
+ export function getAsyncResult(key: string): AsyncResultRecord | null {
100
+ return STORE.get(key) ?? null;
101
+ }
102
+
103
+ /** Read and immediately delete a record. */
104
+ export function consumeAsyncResult(key: string): AsyncResultRecord | null {
105
+ const rec = STORE.get(key);
106
+ if (!rec) return null;
107
+ STORE.delete(key);
108
+ return rec;
109
+ }
110
+
111
+ /** Snapshot of all current records (newest first). For tool_result_list. */
112
+ export function listAsyncResults(): AsyncResultRecord[] {
113
+ return [...STORE.values()].sort((a, b) => b.started_at - a.started_at);
114
+ }
115
+
116
+ /**
117
+ * Drop finished entries older than `ttlMs` (measured from `finished_at`).
118
+ * Pending entries are never expired here — a stuck tool would otherwise
119
+ * vanish out from under the agent.
120
+ */
121
+ export function sweepExpired(ttlMs: number): number {
122
+ const now = Date.now();
123
+ let removed = 0;
124
+ for (const [k, r] of STORE) {
125
+ if (r.status === "pending") continue;
126
+ if (r.finished_at == null) continue;
127
+ if (now - r.finished_at >= ttlMs) {
128
+ STORE.delete(k);
129
+ removed++;
130
+ }
131
+ }
132
+ return removed;
133
+ }
134
+
135
+ function enforceCap(): void {
136
+ if (STORE.size < MAX_ENTRIES) return;
137
+ // Prefer evicting finished entries (oldest first). Only if every entry
138
+ // is pending do we drop a pending one.
139
+ const sorted = [...STORE.values()].sort((a, b) => a.started_at - b.started_at);
140
+ const finished = sorted.find((r) => r.status !== "pending");
141
+ const victim = finished ?? sorted[0];
142
+ if (!victim) return;
143
+ STORE.delete(victim.key);
144
+ if (!finished) {
145
+ console.warn(
146
+ `[async-results] evicted pending entry ${victim.key} (tool=${victim.tool}) ` +
147
+ `to make room — STORE cap of ${MAX_ENTRIES} hit.`,
148
+ );
149
+ }
150
+ }
151
+
152
+ /** Test-only helper. */
153
+ export function __resetStore(): void {
154
+ STORE.clear();
155
+ if (sweeper) {
156
+ clearInterval(sweeper);
157
+ sweeper = null;
158
+ }
159
+ }
160
+
161
+ /** Test-only helper. */
162
+ export function __backdateFinished(key: string, finishedAt: number): void {
163
+ const rec = STORE.get(key);
164
+ if (rec) rec.finished_at = finishedAt;
165
+ }
@@ -33,3 +33,4 @@ import "./list-tools";
33
33
  import "./providers-info";
34
34
  import "./mcp-servers-info";
35
35
  import "./extension-surfaces";
36
+ import "./async-results-tool";