@pylonsync/functions 0.20.0 → 0.22.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/src/agent.test.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import {
7
+ AGENT_INVOCATION,
7
8
  agent,
8
9
  isAgentDefinition,
9
10
  validatorSchemaToJsonSchema,
@@ -793,6 +794,210 @@ describe("agent loop", () => {
793
794
  expect(state.status).toBe("cancelled");
794
795
  }, 10_000);
795
796
 
797
+ test("a cancel raised while the model streams aborts the stream and keeps the partial text", async () => {
798
+ const def = agent({});
799
+ const { ctx, state, writes, streamed } = mockCtx([]);
800
+ let aborted = false;
801
+ let deltasAfterAbort = 0;
802
+ // A provider that keeps generating until the signal aborts, the way
803
+ // the host's ctx.llm.stream behaves.
804
+ (ctx.llm as { stream: unknown }).stream = (
805
+ _req: LlmCompleteRequest,
806
+ onEvent: (e: LlmStreamEvent) => void,
807
+ options?: { signal?: AbortSignal },
808
+ ) =>
809
+ new Promise<LlmCompleteResponse>((_resolve, reject) => {
810
+ const timer = setInterval(() => {
811
+ if (aborted) deltasAfterAbort += 1;
812
+ onEvent({ type: "text_delta", text: "tok " });
813
+ }, 20);
814
+ options?.signal?.addEventListener("abort", () => {
815
+ aborted = true;
816
+ clearInterval(timer);
817
+ const err = new Error("ctx.llm.stream was cancelled");
818
+ (err as { code?: string }).code = "LLM_CANCELLED";
819
+ reject(err);
820
+ });
821
+ });
822
+ setTimeout(() => {
823
+ state.cancelRequested = true;
824
+ state.pendingInput.push("queued while generating");
825
+ }, 50);
826
+
827
+ const result = await (def.handler as unknown as (
828
+ c: ActionCtx,
829
+ a: Record<string, unknown>,
830
+ ) => Promise<Record<string, unknown>>)(ctx, {
831
+ input: "write a long essay",
832
+ __agentName: "helper",
833
+ });
834
+
835
+ expect(result.cancelled).toBe(true);
836
+ expect(aborted).toBe(true);
837
+ expect(deltasAfterAbort).toBe(0);
838
+ expect(state.status).toBe("cancelled");
839
+ const tokens = streamed.filter((s) => s.event === undefined).length;
840
+ expect(tokens).toBeGreaterThan(0);
841
+ // The text the user saw is persisted as the assistant turn, and the
842
+ // queued message is not lost.
843
+ const appended = writes.filter((w) => w.op === "appendMessage");
844
+ expect(appended.map((w) => w.role)).toEqual(["user", "assistant", "user"]);
845
+ expect(appended[1].content).toEqual([
846
+ { type: "text", text: "tok ".repeat(tokens) },
847
+ ]);
848
+ expect(appended[2].content).toBe("queued while generating");
849
+ expect(result.text).toBe("tok ".repeat(tokens));
850
+ }, 10_000);
851
+
852
+ test("ctx.agents.run context reaches every tool and is stored on the new run", async () => {
853
+ const seen: unknown[] = [];
854
+ const def = agent({
855
+ tools: {
856
+ whoami: {
857
+ description: "who",
858
+ handler: async (_ctx, _input, run) => {
859
+ seen.push(run);
860
+ return "ok";
861
+ },
862
+ },
863
+ },
864
+ });
865
+ const { ctx, writes } = mockCtx([wantsTool("whoami", {}), done("bye")]);
866
+ (ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
867
+ context: { conversationId: "conv-1" },
868
+ };
869
+ await (def.handler as unknown as (
870
+ c: ActionCtx,
871
+ a: Record<string, unknown>,
872
+ ) => Promise<unknown>)(ctx, { input: "hi", __agentName: "helper" });
873
+
874
+ expect(seen).toEqual([
875
+ {
876
+ runId: "run_1",
877
+ agent: "helper",
878
+ userId: "u1",
879
+ context: { conversationId: "conv-1" },
880
+ },
881
+ ]);
882
+ expect(writes.find((w) => w.op === "createRun")?.context).toEqual({
883
+ conversationId: "conv-1",
884
+ });
885
+ });
886
+
887
+ test("a continued run uses its stored context; a new invocation context replaces it", async () => {
888
+ const runs: unknown[] = [];
889
+ const def = agent({
890
+ tools: {
891
+ whoami: {
892
+ description: "who",
893
+ handler: async (_ctx, _input, run) => {
894
+ runs.push(run.context);
895
+ return "ok";
896
+ },
897
+ },
898
+ },
899
+ });
900
+ const call = (ctx: ActionCtx) =>
901
+ (def.handler as unknown as (
902
+ c: ActionCtx,
903
+ a: Record<string, unknown>,
904
+ ) => Promise<unknown>)(ctx, {
905
+ input: "again",
906
+ runId: "run_7",
907
+ __agentName: "helper",
908
+ });
909
+ const stored = { run: { userId: "owner", context: { conversationId: "old" } } };
910
+
911
+ // A client continuing over HTTP: no invocation, the stored context.
912
+ const first = mockCtx([wantsTool("whoami", {}), done("bye")], stored);
913
+ await call(first.ctx);
914
+ expect(
915
+ first.writes.find((w) => w.op === "setStatus" && w.status === "running")
916
+ ?.context,
917
+ ).toBeUndefined();
918
+
919
+ // Server code continuing with a new context: it wins and is stored.
920
+ const second = mockCtx([wantsTool("whoami", {}), done("bye")], stored);
921
+ (second.ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
922
+ context: { conversationId: "new" },
923
+ };
924
+ await call(second.ctx);
925
+ expect(
926
+ second.writes.find((w) => w.op === "setStatus" && w.status === "running")
927
+ ?.context,
928
+ ).toEqual({ conversationId: "new" });
929
+
930
+ expect(runs).toEqual([{ conversationId: "old" }, { conversationId: "new" }]);
931
+ });
932
+
933
+ test("ctx.agents.run refuses a live run instead of queueing, and ignores a non-object context", async () => {
934
+ const seen: unknown[] = [];
935
+ const def = agent({
936
+ tools: {
937
+ whoami: {
938
+ description: "who",
939
+ handler: async (_ctx, _input, run) => {
940
+ seen.push(run.context);
941
+ return "ok";
942
+ },
943
+ },
944
+ },
945
+ });
946
+ const call = (ctx: ActionCtx, runId?: string) =>
947
+ (def.handler as unknown as (
948
+ c: ActionCtx,
949
+ a: Record<string, unknown>,
950
+ ) => Promise<unknown>)(ctx, {
951
+ input: "hi",
952
+ ...(runId ? { runId } : {}),
953
+ __agentName: "helper",
954
+ });
955
+
956
+ const busy = mockCtx([], {
957
+ run: { status: "running", updatedAt: new Date().toISOString() },
958
+ });
959
+ (busy.ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
960
+ context: { conversationId: "c" },
961
+ };
962
+ await expect(call(busy.ctx, "run_7")).rejects.toMatchObject({ code: "RUN_BUSY" });
963
+ expect(busy.writes.some((w) => w.op === "enqueueInput")).toBe(false);
964
+
965
+ const odd = mockCtx([wantsTool("whoami", {}), done("bye")]);
966
+ (odd.ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
967
+ context: ["not", "an", "object"],
968
+ };
969
+ await call(odd.ctx);
970
+ expect(seen).toEqual([null]);
971
+ expect(odd.writes.find((w) => w.op === "createRun")?.context).toBeUndefined();
972
+ });
973
+
974
+ test("a run a client started has no context, and args cannot supply one", async () => {
975
+ let context: unknown = "unset";
976
+ const def = agent({
977
+ tools: {
978
+ whoami: {
979
+ description: "who",
980
+ handler: async (_ctx, _input, run) => {
981
+ context = run.context;
982
+ return "ok";
983
+ },
984
+ },
985
+ },
986
+ });
987
+ const { ctx, writes } = mockCtx([wantsTool("whoami", {}), done("bye")]);
988
+ await (def.handler as unknown as (
989
+ c: ActionCtx,
990
+ a: Record<string, unknown>,
991
+ ) => Promise<unknown>)(ctx, {
992
+ input: "hi",
993
+ __agentName: "helper",
994
+ context: { conversationId: "forged" },
995
+ __agentContext: { conversationId: "forged" },
996
+ });
997
+ expect(context).toBeNull();
998
+ expect(writes.find((w) => w.op === "createRun")?.context).toBeUndefined();
999
+ });
1000
+
796
1001
  test("the default step budget is high enough for a real tool loop", async () => {
797
1002
  // 16 was the old default and is nowhere near enough for an agent
798
1003
  // that reads files before answering.
package/src/agent.ts CHANGED
@@ -52,6 +52,21 @@ import type {
52
52
  // Public types
53
53
  // ---------------------------------------------------------------------------
54
54
 
55
+ /** The run a tool call belongs to: the third argument of every tool
56
+ * handler. */
57
+ export interface AgentToolRun {
58
+ runId: string;
59
+ /** The agent's name (its file name in `functions/`). */
60
+ agent: string;
61
+ /** The user the run belongs to. */
62
+ userId: string | null;
63
+ /** The `context` server code passed to `ctx.agents.run`, or the one
64
+ * stored on the run by an earlier `ctx.agents.run`. `null` when the
65
+ * run has none (a run a client started with `streamFn`). Set only by
66
+ * server code, so a tool can trust it to identify what it acts on. */
67
+ context: Record<string, unknown> | null;
68
+ }
69
+
55
70
  /** One tool an agent can call. */
56
71
  export interface AgentTool {
57
72
  /** Shown to the model — say when to use the tool, not how it works. */
@@ -61,10 +76,15 @@ export interface AgentTool {
61
76
  * a tool_result error the model can react to. Omit for no-arg tools. */
62
77
  args?: ValidatorSchema;
63
78
  /** Runs with the agent action's ctx (runQuery/runMutation, llm,
64
- * email, …). The return value is JSON-serialized into the
65
- * tool_result the model sees. Throwing marks the result is_error —
66
- * the model sees the message and can recover. */
67
- handler: (ctx: ActionCtx, input: Record<string, unknown>) => unknown;
79
+ * email, …). `run` identifies the run, including the server-set
80
+ * `context` from `ctx.agents.run`. The return value is
81
+ * JSON-serialized into the tool_result the model sees. Throwing marks
82
+ * the result is_error — the model sees the message and can recover. */
83
+ handler: (
84
+ ctx: ActionCtx,
85
+ input: Record<string, unknown>,
86
+ run: AgentToolRun,
87
+ ) => unknown;
68
88
  }
69
89
 
70
90
  export interface AgentDefinition {
@@ -101,8 +121,10 @@ export interface AgentCallArgs {
101
121
  /** Optional display title, stored on new runs. */
102
122
  title?: string;
103
123
  /** Ask the run to stop. Requires `runId`, ignores `input`, and
104
- * returns as soon as the request is recorded — a live generation
105
- * stops at its next turn boundary. */
124
+ * returns as soon as the request is recorded. The running loop
125
+ * notices within a few seconds: a model response in progress is
126
+ * aborted (the provider stops generating), and a running tool sees
127
+ * `ctx.signal` abort. */
106
128
  cancel?: boolean;
107
129
  }
108
130
 
@@ -192,6 +214,26 @@ export function validatorSchemaToJsonSchema(
192
214
  * the AgentRun/AgentMessage entities into the manifest. */
193
215
  export const AGENT_MARKER = "__pylonAgent";
194
216
 
217
+ /** ctx key the runtime sets when server code started this call with
218
+ * `ctx.agents.run`: `{ context }`. A symbol, so no JSON a client sends
219
+ * can set it. */
220
+ export const AGENT_INVOCATION = Symbol.for("pylon.agentInvocation");
221
+
222
+ interface AgentInvocation {
223
+ context: Record<string, unknown> | null;
224
+ }
225
+
226
+ function invocationOf(ctx: ActionCtx): AgentInvocation | null {
227
+ const value = (ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION];
228
+ return (value as AgentInvocation | undefined) ?? null;
229
+ }
230
+
231
+ function asContext(value: unknown): Record<string, unknown> | null {
232
+ return typeof value === "object" && value !== null && !Array.isArray(value)
233
+ ? (value as Record<string, unknown>)
234
+ : null;
235
+ }
236
+
195
237
  export function agent(def: AgentDefinition): FnDefinition<AgentCallArgs, AgentResult> {
196
238
  const fnDef = action({
197
239
  args: {
@@ -223,10 +265,8 @@ export function isAgentDefinition(value: unknown): boolean {
223
265
 
224
266
  const DEFAULT_MAX_STEPS = 64;
225
267
 
226
- /** How often the loop asks whether cancel was requested while a tool
227
- * handler is running. Only runs during a tool batch: the host blocks
228
- * its per-call read loop for the whole of `ctx.llm.stream`, so no RPC
229
- * the child issues during a generation would be serviced anyway. */
268
+ /** How often the loop asks whether cancel was requested while a model
269
+ * response streams or a tool handler runs. */
230
270
  const CANCEL_POLL_MS = 2000;
231
271
 
232
272
  /** Tool results persist into AgentMessage rows and replay into every
@@ -258,10 +298,7 @@ interface DrainResult {
258
298
  * run. Prototype-based rather than a spread: ctx's methods keep
259
299
  * working when invoked on the derived object. */
260
300
  function ctxWithSignal(ctx: ActionCtx, signal: AbortSignal): ActionCtx {
261
- const combined =
262
- ctx.signal && typeof AbortSignal.any === "function"
263
- ? AbortSignal.any([ctx.signal, signal])
264
- : signal;
301
+ const combined = anySignal(ctx.signal, signal);
265
302
  const derived = Object.create(ctx) as ActionCtx;
266
303
  Object.defineProperty(derived, "signal", {
267
304
  value: combined,
@@ -270,6 +307,56 @@ function ctxWithSignal(ctx: ActionCtx, signal: AbortSignal): ActionCtx {
270
307
  return derived;
271
308
  }
272
309
 
310
+ /** Watches the run row for a cancel request while the loop is busy.
311
+ *
312
+ * A poll rather than a push because a cancel can be requested on a
313
+ * different machine than the loop runs on, and the run row is the only
314
+ * thing both sides share. `signal` aborts once a cancel is seen; the
315
+ * loop passes it to `ctx.llm.stream` (which then drops the provider
316
+ * request) and to tool handlers through `ctx.signal`. */
317
+ interface CancelWatch {
318
+ readonly signal: AbortSignal;
319
+ readonly cancelled: boolean;
320
+ stop(): void;
321
+ }
322
+
323
+ function watchCancel(ctx: ActionCtx, runId: string): CancelWatch {
324
+ const controller = new AbortController();
325
+ let polling = false;
326
+ const timer = setInterval(() => {
327
+ // Skip rather than stack: a slow poll must not queue more.
328
+ if (polling || controller.signal.aborted) return;
329
+ polling = true;
330
+ void ctx
331
+ .runQuery<{ cancelRequested: boolean }>("__pylon_agent_poll", { runId })
332
+ .then((s) => {
333
+ if (s.cancelRequested && !controller.signal.aborted) {
334
+ controller.abort(new Error("agent run cancelled"));
335
+ }
336
+ })
337
+ .catch(() => {})
338
+ .finally(() => {
339
+ polling = false;
340
+ });
341
+ }, CANCEL_POLL_MS);
342
+ return {
343
+ signal: controller.signal,
344
+ get cancelled() {
345
+ return controller.signal.aborted;
346
+ },
347
+ stop() {
348
+ clearInterval(timer);
349
+ },
350
+ };
351
+ }
352
+
353
+ /** `a` and `b` combined, for runtimes without `AbortSignal.any`. */
354
+ function anySignal(a: AbortSignal | undefined, b: AbortSignal): AbortSignal {
355
+ if (!a) return b;
356
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
357
+ return b;
358
+ }
359
+
273
360
  async function runAgentLoop(
274
361
  def: AgentDefinition,
275
362
  ctx: ActionCtx,
@@ -321,6 +408,13 @@ async function runAgentLoop(
321
408
  // continuations may take it over instead of waiting forever.
322
409
  const staleMs = Math.max(def.timeout ?? 600, 60) * 1000;
323
410
 
411
+ // Server code that started this run with ctx.agents.run may pass a
412
+ // context for the tools. It wins over one stored on the run.
413
+ const invocation = invocationOf(ctx);
414
+ const invocationContext = asContext(invocation?.context);
415
+ let runContext = invocationContext;
416
+ let runUserId: string | null = ctx.auth.userId ?? null;
417
+
324
418
  // 1. Create or load the run (ownership enforced inside the internal
325
419
  // fns, which run under this caller's auth).
326
420
  let runId: string;
@@ -334,6 +428,8 @@ async function runAgentLoop(
334
428
  status: string;
335
429
  updatedAt?: string;
336
430
  steps?: number;
431
+ userId?: string | null;
432
+ context?: unknown;
337
433
  };
338
434
  messages: StoredMessage[];
339
435
  }>("__pylon_agent_read", { runId: args.runId });
@@ -345,6 +441,15 @@ async function runAgentLoop(
345
441
  }
346
442
  if (loaded.run.status === "running") {
347
443
  const updatedAt = Date.parse(String(loaded.run.updatedAt ?? "")) || 0;
444
+ if (Date.now() - updatedAt < staleMs && invocation) {
445
+ // Server code (ctx.agents.run) waits for this turn's reply and
446
+ // brings its own context; queueing would return an empty result
447
+ // and answer the input later under the running turn's context.
448
+ throw ctx.error(
449
+ "RUN_BUSY",
450
+ "This run is already generating — wait for it to finish",
451
+ );
452
+ }
348
453
  if (Date.now() - updatedAt < staleMs) {
349
454
  // The generation is alive. Hand it the message rather than
350
455
  // refusing the call: the loop drains the queue at its next
@@ -369,10 +474,17 @@ async function runAgentLoop(
369
474
  runId = loaded.run.id;
370
475
  priorSteps = Number(loaded.run.steps) || 0;
371
476
  history = loaded.messages.map(storedToLlmMessage);
477
+ runUserId = loaded.run.userId ?? runUserId;
478
+ runContext ??= asContext(loaded.run.context);
372
479
  } else {
373
480
  const created = await ctx.runMutation<{ id: string }>(
374
481
  "__pylon_agent_write",
375
- { op: "createRun", agent: agentName, title: args.title ?? null },
482
+ {
483
+ op: "createRun",
484
+ agent: agentName,
485
+ title: args.title ?? null,
486
+ ...(invocationContext ? { context: invocationContext } : {}),
487
+ },
376
488
  );
377
489
  runId = created.id;
378
490
  }
@@ -392,6 +504,7 @@ async function runAgentLoop(
392
504
  streamId: ctx.stream.id ?? null,
393
505
  guardNotRunning: true,
394
506
  staleMs,
507
+ ...(args.runId && invocationContext ? { context: invocationContext } : {}),
395
508
  });
396
509
 
397
510
  // A crash between persisting an assistant tool_use turn and its
@@ -445,6 +558,10 @@ async function runAgentLoop(
445
558
  unknown
446
559
  > as Promise<DrainResult>;
447
560
 
561
+ const watch = watchCancel(ctx, runId);
562
+ const streamSignal = anySignal(ctx.signal, watch.signal);
563
+ const toolCtx = ctxWithSignal(ctx, watch.signal);
564
+
448
565
  try {
449
566
  for (;;) {
450
567
  steps += 1;
@@ -456,18 +573,44 @@ async function runAgentLoop(
456
573
  }
457
574
  const system =
458
575
  typeof def.system === "function" ? def.system(ctx, args) : def.system;
459
- const res = await ctx.llm.stream(
460
- {
461
- messages: history,
462
- ...(system ? { system } : {}),
463
- ...(tools.length > 0 ? { tools } : {}),
464
- ...(def.model ? { model: def.model } : {}),
465
- ...(def.maxTokens ? { max_tokens: def.maxTokens } : {}),
466
- },
467
- (e) => {
468
- if (e.type === "text_delta") ctx.stream.write(e.text);
469
- },
470
- );
576
+ let partial = "";
577
+ let res: Awaited<ReturnType<ActionCtx["llm"]["stream"]>>;
578
+ try {
579
+ res = await ctx.llm.stream(
580
+ {
581
+ messages: history,
582
+ ...(system ? { system } : {}),
583
+ ...(tools.length > 0 ? { tools } : {}),
584
+ ...(def.model ? { model: def.model } : {}),
585
+ ...(def.maxTokens ? { max_tokens: def.maxTokens } : {}),
586
+ },
587
+ (e) => {
588
+ if (e.type === "text_delta") {
589
+ partial += e.text;
590
+ ctx.stream.write(e.text);
591
+ }
592
+ },
593
+ { signal: streamSignal },
594
+ );
595
+ } catch (err) {
596
+ if (!watch.cancelled) throw err;
597
+ // Stopped mid-response. Keep the text the user already saw, then
598
+ // take whatever was queued so it is not lost with the run.
599
+ if (partial !== "") {
600
+ const content: LlmContentBlock[] = [{ type: "text", text: partial }];
601
+ await write({ op: "appendMessage", role: "assistant", content });
602
+ history.push({ role: "assistant", content });
603
+ finalText = partial;
604
+ }
605
+ const pending = await drain({});
606
+ const text = pending.input.join("\n\n");
607
+ if (text !== "") {
608
+ await write({ op: "appendMessage", role: "user", content: text });
609
+ history.push({ role: "user", content: text });
610
+ }
611
+ cancelled = true;
612
+ break;
613
+ }
471
614
  usage.input_tokens += res.usage.input_tokens;
472
615
  usage.output_tokens += res.usage.output_tokens;
473
616
 
@@ -508,7 +651,12 @@ async function runAgentLoop(
508
651
 
509
652
  // 3. Execute every requested tool; failures become is_error
510
653
  // results the model can react to rather than run-fatal throws.
511
- const batch = await runToolBatch(def, ctx, runId, res.content);
654
+ const batch = await runToolBatch(def, ctx, toolCtx, watch, res.content, {
655
+ runId,
656
+ agent: agentName,
657
+ userId: runUserId,
658
+ context: runContext,
659
+ });
512
660
 
513
661
  // Tool boundary. Every tool_use needs its tool_result even when
514
662
  // the batch stopped early, or the transcript can't be replayed.
@@ -536,6 +684,8 @@ async function runAgentLoop(
536
684
  steps: priorSteps + steps,
537
685
  }).catch(() => {});
538
686
  throw err;
687
+ } finally {
688
+ watch.stop();
539
689
  }
540
690
 
541
691
  if (cancelled) {
@@ -558,18 +708,15 @@ async function runAgentLoop(
558
708
  return { runId, text: finalText, steps, usage };
559
709
  }
560
710
 
561
- /** Run one turn's tool calls, watching for a cancel while they run.
562
- *
563
- * The watch is a poll rather than a push because a cancel can be
564
- * requested on a different machine than the loop runs on — the run
565
- * row is the only thing both sides share. It runs only for the
566
- * duration of the batch: during `ctx.llm.stream` the host blocks its
567
- * per-call read loop, so an RPC issued then would not be answered. */
711
+ /** Run one turn's tool calls. Stops starting new ones once `watch`
712
+ * sees a cancel; a handler already running sees `ctx.signal` abort. */
568
713
  async function runToolBatch(
569
714
  def: AgentDefinition,
570
715
  ctx: ActionCtx,
571
- runId: string,
716
+ toolCtx: ActionCtx,
717
+ watch: CancelWatch,
572
718
  turn: LlmContentBlock[],
719
+ run: AgentToolRun,
573
720
  ): Promise<{ results: LlmContentBlock[]; cancelled: boolean }> {
574
721
  const calls = turn.filter(
575
722
  (b): b is Extract<LlmContentBlock, { type: "tool_use" }> =>
@@ -578,77 +725,51 @@ async function runToolBatch(
578
725
  const results: LlmContentBlock[] = [];
579
726
  if (calls.length === 0) return { results, cancelled: false };
580
727
 
581
- const controller = new AbortController();
582
- const toolCtx = ctxWithSignal(ctx, controller.signal);
583
- let cancelled = false;
584
- let polling = false;
585
- const timer = setInterval(() => {
586
- // Skip rather than stack: a slow poll must not queue more.
587
- if (polling || cancelled) return;
588
- polling = true;
589
- void ctx
590
- .runQuery<{ cancelRequested: boolean }>("__pylon_agent_poll", { runId })
591
- .then((s) => {
592
- if (s.cancelRequested && !cancelled) {
593
- cancelled = true;
594
- controller.abort(new Error("agent run cancelled"));
595
- }
596
- })
597
- .catch(() => {})
598
- .finally(() => {
599
- polling = false;
600
- });
601
- }, CANCEL_POLL_MS);
602
-
603
- try {
604
- for (const block of calls) {
605
- let content: string;
606
- let isError = false;
607
- if (cancelled) {
608
- // Stop starting new work, but still answer the call so the
609
- // model sees why it has no result.
610
- content = "cancelled by user";
728
+ for (const block of calls) {
729
+ let content: string;
730
+ let isError = false;
731
+ if (watch.cancelled) {
732
+ // Stop starting new work, but still answer the call so the
733
+ // model sees why it has no result.
734
+ content = "cancelled by user";
735
+ isError = true;
736
+ } else {
737
+ const tool = def.tools?.[block.name];
738
+ if (!tool) {
739
+ content = `Unknown tool "${block.name}"`;
611
740
  isError = true;
612
741
  } else {
613
- const tool = def.tools?.[block.name];
614
- if (!tool) {
615
- content = `Unknown tool "${block.name}"`;
616
- isError = true;
617
- } else {
618
- try {
619
- if (tool.args) {
620
- const check = validateArgs(block.input, tool.args);
621
- if (!check.valid) {
622
- throw new Error(`Invalid tool input: ${check.errors.join("; ")}`);
623
- }
742
+ try {
743
+ if (tool.args) {
744
+ const check = validateArgs(block.input, tool.args);
745
+ if (!check.valid) {
746
+ throw new Error(`Invalid tool input: ${check.errors.join("; ")}`);
624
747
  }
625
- const value = await tool.handler(toolCtx, block.input);
626
- content =
627
- typeof value === "string" ? value : JSON.stringify(value ?? null);
628
- } catch (err) {
629
- content = err instanceof Error ? err.message : String(err);
630
- isError = true;
631
748
  }
749
+ const value = await tool.handler(toolCtx, block.input, run);
750
+ content =
751
+ typeof value === "string" ? value : JSON.stringify(value ?? null);
752
+ } catch (err) {
753
+ content = err instanceof Error ? err.message : String(err);
754
+ isError = true;
632
755
  }
633
756
  }
634
- content = truncateToolResult(content);
635
- // Announce the tool call on the stream so live UIs can render
636
- // "using searchDocs…" without polling the message rows.
637
- ctx.stream.writeEvent(
638
- "tool",
639
- JSON.stringify({ name: block.name, input: block.input, isError }),
640
- );
641
- results.push({
642
- type: "tool_result",
643
- tool_use_id: block.id,
644
- content,
645
- ...(isError ? { is_error: true } : {}),
646
- });
647
757
  }
648
- } finally {
649
- clearInterval(timer);
758
+ content = truncateToolResult(content);
759
+ // Announce the tool call on the stream so live UIs can render
760
+ // "using searchDocs…" without polling the message rows.
761
+ ctx.stream.writeEvent(
762
+ "tool",
763
+ JSON.stringify({ name: block.name, input: block.input, isError }),
764
+ );
765
+ results.push({
766
+ type: "tool_result",
767
+ tool_use_id: block.id,
768
+ content,
769
+ ...(isError ? { is_error: true } : {}),
770
+ });
650
771
  }
651
- return { results, cancelled };
772
+ return { results, cancelled: watch.cancelled };
652
773
  }
653
774
 
654
775
  function storedToLlmMessage(m: StoredMessage): LlmMessage {
package/src/define.ts CHANGED
@@ -59,7 +59,8 @@ interface CommonDef<
59
59
  * IDLE-timeout SECONDS for this function: how long it may go without
60
60
  * producing any activity (a stream chunk, a `ctx.db` op, an LLM
61
61
  * event) before the host cancels the call. Defaults to
62
- * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
62
+ * `PYLON_FN_CALL_TIMEOUT` (30s; Stack0 Cloud sets 300s). Activity
63
+ * restarts the budget, so a
63
64
  * streaming agent run stays alive as long as it keeps producing; a
64
65
  * silent hang is cancelled at the budget. Total lifetime is capped at
65
66
  * 10× this value however chatty the call is.