@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/dist/agent.d.ts +27 -6
- package/dist/define.d.ts +2 -1
- package/dist/index.d.ts +3 -2
- package/dist/response.d.ts +39 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/ssr-runtime.d.ts +7 -0
- package/dist/types.d.ts +260 -13
- package/dist/workflows.d.ts +19 -2
- package/package.json +1 -1
- package/src/agent-internals.ts +5 -0
- package/src/agent.test.ts +205 -0
- package/src/agent.ts +220 -99
- package/src/define.ts +2 -1
- package/src/index.ts +10 -0
- package/src/response.test.ts +42 -0
- package/src/response.ts +109 -0
- package/src/runtime-llm-stream.test.ts +85 -0
- package/src/runtime.ts +206 -10
- package/src/ssr-runtime.test.ts +25 -0
- package/src/ssr-runtime.ts +35 -3
- package/src/types.ts +266 -13
- package/src/workflows.test.ts +74 -7
- package/src/workflows.ts +81 -19
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, …).
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
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
|
|
105
|
-
*
|
|
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
|
|
227
|
-
*
|
|
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
|
-
{
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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
|
|
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.
|