@pylonsync/functions 0.21.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 CHANGED
@@ -36,6 +36,20 @@
36
36
  * including tool calls, with zero extra plumbing.
37
37
  */
38
38
  import type { ActionCtx, AnyValidator, FnDefinition, ValidatorSchema } from "./types";
39
+ /** The run a tool call belongs to: the third argument of every tool
40
+ * handler. */
41
+ export interface AgentToolRun {
42
+ runId: string;
43
+ /** The agent's name (its file name in `functions/`). */
44
+ agent: string;
45
+ /** The user the run belongs to. */
46
+ userId: string | null;
47
+ /** The `context` server code passed to `ctx.agents.run`, or the one
48
+ * stored on the run by an earlier `ctx.agents.run`. `null` when the
49
+ * run has none (a run a client started with `streamFn`). Set only by
50
+ * server code, so a tool can trust it to identify what it acts on. */
51
+ context: Record<string, unknown> | null;
52
+ }
39
53
  /** One tool an agent can call. */
40
54
  export interface AgentTool {
41
55
  /** Shown to the model — say when to use the tool, not how it works. */
@@ -45,10 +59,11 @@ export interface AgentTool {
45
59
  * a tool_result error the model can react to. Omit for no-arg tools. */
46
60
  args?: ValidatorSchema;
47
61
  /** Runs with the agent action's ctx (runQuery/runMutation, llm,
48
- * email, …). The return value is JSON-serialized into the
49
- * tool_result the model sees. Throwing marks the result is_error —
50
- * the model sees the message and can recover. */
51
- handler: (ctx: ActionCtx, input: Record<string, unknown>) => unknown;
62
+ * email, …). `run` identifies the run, including the server-set
63
+ * `context` from `ctx.agents.run`. The return value is
64
+ * JSON-serialized into the tool_result the model sees. Throwing marks
65
+ * the result is_error — the model sees the message and can recover. */
66
+ handler: (ctx: ActionCtx, input: Record<string, unknown>, run: AgentToolRun) => unknown;
52
67
  }
53
68
  export interface AgentDefinition {
54
69
  /** System prompt. A function receives the ctx + call args for
@@ -83,8 +98,10 @@ export interface AgentCallArgs {
83
98
  /** Optional display title, stored on new runs. */
84
99
  title?: string;
85
100
  /** Ask the run to stop. Requires `runId`, ignores `input`, and
86
- * returns as soon as the request is recorded — a live generation
87
- * stops at its next turn boundary. */
101
+ * returns as soon as the request is recorded. The running loop
102
+ * notices within a few seconds: a model response in progress is
103
+ * aborted (the provider stops generating), and a running tool sees
104
+ * `ctx.signal` abort. */
88
105
  cancel?: boolean;
89
106
  }
90
107
  /** What the agent action resolves with (also the `event: result`
@@ -115,5 +132,9 @@ export declare function validatorSchemaToJsonSchema(schema: ValidatorSchema): Re
115
132
  /** Marker so the SDK's discoverFunctions can detect agents and inject
116
133
  * the AgentRun/AgentMessage entities into the manifest. */
117
134
  export declare const AGENT_MARKER = "__pylonAgent";
135
+ /** ctx key the runtime sets when server code started this call with
136
+ * `ctx.agents.run`: `{ context }`. A symbol, so no JSON a client sends
137
+ * can set it. */
138
+ export declare const AGENT_INVOCATION: unique symbol;
118
139
  export declare function agent(def: AgentDefinition): FnDefinition<AgentCallArgs, AgentResult>;
119
140
  export declare function isAgentDefinition(value: unknown): boolean;
package/dist/index.d.ts CHANGED
@@ -22,9 +22,9 @@ export { v } from "./validators";
22
22
  export { workflow } from "./workflows";
23
23
  export type { RawResponse, RawResponseInit } from "./response";
24
24
  export { agent, isAgentDefinition, validatorToJsonSchema, validatorSchemaToJsonSchema, } from "./agent";
25
- export type { AgentDefinition, AgentTool, AgentCallArgs, AgentResult, } from "./agent";
25
+ export type { AgentDefinition, AgentTool, AgentToolRun, AgentCallArgs, AgentResult, } from "./agent";
26
26
  export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
27
27
  export { resetDb, installTestIsolation } from "./testing";
28
28
  export { slugifyName, availableSlug } from "./slugify";
29
29
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
30
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
30
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
package/dist/types.d.ts CHANGED
@@ -427,16 +427,24 @@ export interface Llm {
427
427
  * if (res.stop_reason === "tool_use") { ...run tools, loop... }
428
428
  * ```
429
429
  *
430
- * Streaming does NOT extend the function's call deadline — it is an
431
- * absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
432
- * default). A long agent run must declare its own `timeout` on the
433
- * function def.
430
+ * The function's call timeout (PYLON_FN_CALL_TIMEOUT, 30s default, or
431
+ * the function's own `timeout`) is an idle timeout. A running stream
432
+ * counts as activity, but no call outlives 10× its timeout, so a long
433
+ * agent run should still declare its own `timeout`.
434
+ *
435
+ * Pass `{ signal }` to stop a stream early. When the signal aborts,
436
+ * the promise rejects with `err.code === "LLM_CANCELLED"` and the
437
+ * server closes the provider connection, so the model stops
438
+ * generating (and billing) tokens. A stream still running when the
439
+ * function returns or times out is cancelled the same way.
434
440
  *
435
441
  * Same errors and same gating as {@link Llm.complete} — including
436
442
  * the model allowlist, so streaming can't be used to reach a model
437
443
  * `complete` would refuse.
438
444
  */
439
- stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void): Promise<LlmCompleteResponse>;
445
+ stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void, options?: {
446
+ signal?: AbortSignal;
447
+ }): Promise<LlmCompleteResponse>;
440
448
  /**
441
449
  * Batch-embed texts via the configured embeddings provider. One
442
450
  * embedding per input, in input order. Pair with a
@@ -1237,6 +1245,49 @@ export interface Domains {
1237
1245
  * itself serve the app — attach a host with `add()` afterward. */
1238
1246
  register(domainName: string, opts?: RegisterDomainOptions): Promise<RegisteredDomainResult>;
1239
1247
  }
1248
+ /** Options for {@link Agents.run}. */
1249
+ export interface AgentRunOptions {
1250
+ /** The message for this turn. */
1251
+ input: string;
1252
+ /** Continue this run. It must belong to `as.userId` and this agent. */
1253
+ runId?: string;
1254
+ /** Display title, stored on a new run. */
1255
+ title?: string;
1256
+ /**
1257
+ * The user the run belongs to and runs as. The run shows up in that
1258
+ * user's synced `AgentRun`/`AgentMessage` rows. `admin: true` also
1259
+ * gives the run admin rights (needed for an agent declared
1260
+ * `auth: "admin"`), and is allowed only when the caller is an admin.
1261
+ */
1262
+ as: {
1263
+ userId: string;
1264
+ admin?: boolean;
1265
+ };
1266
+ /**
1267
+ * JSON the agent's tool handlers receive as `run.context` (their third
1268
+ * argument), for example the conversation a webhook turn belongs to.
1269
+ * Stored on the run as `AgentRun.context`, so later turns of the run
1270
+ * see it too. The run's owner can read it; do not put secrets in it.
1271
+ */
1272
+ context?: Record<string, unknown>;
1273
+ }
1274
+ /**
1275
+ * Run an `agent()` from server code. See {@link ActionCtx.agents}.
1276
+ */
1277
+ export interface Agents {
1278
+ /**
1279
+ * Run agent `name` (its file name in `functions/`) as `options.as`.
1280
+ * Resolves with the agent's result once the turn finishes, like a
1281
+ * `streamFn` call's final `result` event. The agent's tokens are not
1282
+ * streamed to this caller; they are in the run's `AgentMessage` rows.
1283
+ *
1284
+ * Only server code can do this: the calling action must be
1285
+ * `internal: true` (jobs, workflows, scheduled work) or run with admin
1286
+ * rights (an admin session, or a webhook after `ctx.auth.elevate`).
1287
+ * Otherwise it throws `AGENT_RUN_FORBIDDEN`.
1288
+ */
1289
+ run(name: string, options: AgentRunOptions): Promise<import("./agent").AgentResult>;
1290
+ }
1240
1291
  export interface ActionCtx<R extends AuthRequirement = "optional"> {
1241
1292
  auth: AuthInfo<R>;
1242
1293
  stream: Stream;
@@ -1253,6 +1304,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1253
1304
  workflows: Workflows;
1254
1305
  /** Application audit log — see {@link Audit}. */
1255
1306
  audit: Audit;
1307
+ /** Run an `agent()` as a named user from server code — see {@link Agents}. */
1308
+ agents: Agents;
1256
1309
  /** Attach / register custom domains for your app's OWN end-customers
1257
1310
  * (platform domains) — see {@link Domains}. Cloud-only. */
1258
1311
  domains: Domains;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -128,6 +128,10 @@ export function registerAgentInternals(
128
128
  pendingInput: [],
129
129
  cancelRequested: false,
130
130
  steps: 0,
131
+ // Only written when present, so an app that declares its own
132
+ // AgentRun without a `context` column keeps working until it
133
+ // uses ctx.agents.run with a context.
134
+ ...(args.context != null ? { context: args.context } : {}),
131
135
  createdAt: nowIso(),
132
136
  updatedAt: nowIso(),
133
137
  });
@@ -188,6 +192,7 @@ export function registerAgentInternals(
188
192
  if (args.error !== undefined) patch.error = args.error;
189
193
  if (args.streamId !== undefined) patch.streamId = args.streamId;
190
194
  if (args.steps !== undefined) patch.steps = Number(args.steps) || 0;
195
+ if (args.context != null) patch.context = args.context;
191
196
  // Claiming the run starts a new generation, which supersedes
192
197
  // a cancel left set by one that died before acting on it.
193
198
  // Terminal statuses clear it because it has been honoured.
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/index.ts CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  export type {
32
32
  AgentDefinition,
33
33
  AgentTool,
34
+ AgentToolRun,
34
35
  AgentCallArgs,
35
36
  AgentResult,
36
37
  } from "./agent";
@@ -86,6 +87,8 @@ export type {
86
87
  Audit,
87
88
  AuditLog,
88
89
  AuditEntry,
90
+ Agents,
91
+ AgentRunOptions,
89
92
  VectorSearchQuery,
90
93
  VectorSearchResult,
91
94
  SearchResult,
@@ -184,3 +184,88 @@ test("llm.stream routes events by op_id, resolves on result; rooms.broadcast rou
184
184
  // 6. rooms.broadcast surfaced the host's verdict.
185
185
  expect(out.delivered).toBe(true);
186
186
  });
187
+
188
+ const ABORT_SCRIPT = `
189
+ import { buildLlm } from ${JSON.stringify(RUNTIME)};
190
+
191
+ const llm = buildLlm("c_9");
192
+ const seen = [];
193
+ const controller = new AbortController();
194
+
195
+ // Aborted before it starts: rejects without sending a frame.
196
+ const early = await llm
197
+ .stream({ messages: [] }, () => {}, { signal: AbortSignal.abort("stopped") })
198
+ .then(() => "resolved", (e) => e.code);
199
+
200
+ const running = llm
201
+ .stream({ messages: [] }, (e) => {
202
+ seen.push(e.text);
203
+ if (seen.length === 2) controller.abort(new Error("user pressed stop"));
204
+ }, { signal: controller.signal })
205
+ .then(() => ({ code: "resolved" }), (e) => ({ code: e.code, message: e.message }));
206
+
207
+ const out = await running;
208
+ // Give late host frames time to arrive; they must be ignored.
209
+ await new Promise((r) => setTimeout(r, 200));
210
+ console.error("RESULT " + JSON.stringify({ early, out, seen }));
211
+ process.exit(0);
212
+ `;
213
+
214
+ test("an aborted llm.stream rejects with LLM_CANCELLED and sends llm_cancel", async () => {
215
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-llm-abort-"));
216
+ const scriptPath = join(dir, "probe.ts");
217
+ writeFileSync(scriptPath, ABORT_SCRIPT);
218
+ const proc = Bun.spawn([process.execPath, scriptPath], {
219
+ stdin: "pipe",
220
+ stdout: "pipe",
221
+ stderr: "pipe",
222
+ });
223
+ const reader = proc.stdout.getReader();
224
+ const decoder = new TextDecoder();
225
+ let buffered = "";
226
+ const frames = () => parseFrames(buffered);
227
+ const readUntil = async (
228
+ pred: (fs: Record<string, unknown>[]) => boolean,
229
+ ) => {
230
+ while (!pred(frames())) {
231
+ const { done, value } = await reader.read();
232
+ if (done) break;
233
+ buffered += decoder.decode(value, { stream: true });
234
+ }
235
+ };
236
+ const send = (msg: Record<string, unknown>) =>
237
+ proc.stdin.write(JSON.stringify(msg) + "\n");
238
+
239
+ await readUntil((fs) => fs.some((f) => f.type === "llm_stream"));
240
+ const streams = frames().filter((f) => f.type === "llm_stream");
241
+ // The pre-aborted stream never reached the host.
242
+ expect(streams).toHaveLength(1);
243
+ const opId = streams[0].op_id as string;
244
+
245
+ send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "a" } });
246
+ send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "b" } });
247
+ await proc.stdin.flush();
248
+ await readUntil((fs) => fs.some((f) => f.type === "llm_cancel"));
249
+ const cancel = frames().find((f) => f.type === "llm_cancel");
250
+ expect(cancel).toEqual({ type: "llm_cancel", call_id: "c_9", op_id: opId });
251
+
252
+ // Frames the host sent before it saw the cancel are dropped.
253
+ send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "c" } });
254
+ send({
255
+ type: "result",
256
+ call_id: "c_9",
257
+ op_id: opId,
258
+ data: { model: "m", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 } },
259
+ });
260
+ await proc.stdin.flush();
261
+
262
+ const stderr = await new Response(proc.stderr).text();
263
+ await proc.exited;
264
+ const line = stderr.split("\n").find((l) => l.includes("RESULT "));
265
+ expect(line).toBeDefined();
266
+ const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
267
+ expect(out.early).toBe("LLM_CANCELLED");
268
+ expect(out.out.code).toBe("LLM_CANCELLED");
269
+ expect(out.out.message).toContain("user pressed stop");
270
+ expect(out.seen).toEqual(["a", "b"]);
271
+ });
package/src/runtime.ts CHANGED
@@ -49,6 +49,7 @@ import type {
49
49
  AuthInfo,
50
50
  } from "./types";
51
51
  import { normalizeAuthClaims } from "./auth";
52
+ import { AGENT_INVOCATION, isAgentDefinition } from "./agent";
52
53
  import { makeRequireMember } from "./member";
53
54
  import { response } from "./response";
54
55
  import { isDevMode } from "./ssr-runtime";
@@ -56,7 +57,8 @@ import { validateArgs } from "./validators";
56
57
  import { serverBundle } from "./server-bundle";
57
58
  import { fenceStdout } from "./stdout-fence";
58
59
  import { readdirSync } from "fs";
59
- import { join, basename } from "path";
60
+ import { join, basename, resolve } from "path";
61
+ import { pathToFileURL } from "url";
60
62
 
61
63
  // Bun runtime globals this process uses. The runtime executes under Bun, but
62
64
  // consuming apps type-check this source under node/DOM where the `Bun` global
@@ -84,6 +86,9 @@ interface CallMessage {
84
86
  fn_type: "query" | "mutation" | "action";
85
87
  args: Record<string, unknown>;
86
88
  auth: AuthInfo;
89
+ /** Present only when server code started this call with
90
+ * `ctx.agents.run`; the host sets it, never a client. */
91
+ agent?: { context?: Record<string, unknown> | null };
87
92
  }
88
93
 
89
94
  interface ResultMessage {
@@ -392,19 +397,37 @@ function rpcDb(
392
397
  * is alive, however long it runs in total, while one whose provider
393
398
  * goes silent still trips the safety net. The host's own call deadline
394
399
  * remains the real upper bound.
400
+ *
401
+ * When `signal` aborts, the promise rejects with `LLM_CANCELLED` at
402
+ * once and an `llm_cancel` frame tells the host to drop the provider
403
+ * request. Events and the result that arrive after that are ignored.
395
404
  */
396
405
  function rpcStreaming(
397
406
  callId: string,
398
407
  msg: Record<string, unknown>,
399
408
  onEvent: (event: unknown) => void,
409
+ signal?: AbortSignal,
400
410
  ): Promise<unknown> {
401
411
  throwIfCancelled(callId);
412
+ if (signal?.aborted) return Promise.reject(streamCancelledError(signal));
402
413
  const opId = nextOpId(callId);
403
414
  return new Promise((resolve, reject) => {
415
+ const onAbort = () => {
416
+ const pending = pendingRpcs.get(opId);
417
+ if (!pending) return;
418
+ pendingRpcs.delete(opId);
419
+ streamSinks.delete(opId);
420
+ clearTimeout(pending.timeout);
421
+ send({ type: "llm_cancel", call_id: callId, op_id: opId });
422
+ reject(streamCancelledError(signal));
423
+ };
404
424
  const fail = () => {
405
425
  if (pendingRpcs.has(opId)) {
406
426
  pendingRpcs.delete(opId);
407
427
  streamSinks.delete(opId);
428
+ signal?.removeEventListener("abort", onAbort);
429
+ // Nothing waits for this stream any more; stop the provider.
430
+ send({ type: "llm_cancel", call_id: callId, op_id: opId });
408
431
  reject(
409
432
  new Error(
410
433
  `RPC timed out after ${RPC_TIMEOUT_MS}ms with no stream activity (call_id=${callId} op_id=${opId})`,
@@ -415,14 +438,17 @@ function rpcStreaming(
415
438
  const entry = {
416
439
  resolve: (data: unknown) => {
417
440
  streamSinks.delete(opId);
441
+ signal?.removeEventListener("abort", onAbort);
418
442
  resolve(data);
419
443
  },
420
444
  reject: (err: Error) => {
421
445
  streamSinks.delete(opId);
446
+ signal?.removeEventListener("abort", onAbort);
422
447
  reject(err);
423
448
  },
424
449
  timeout: setTimeout(fail, RPC_TIMEOUT_MS),
425
450
  };
451
+ signal?.addEventListener("abort", onAbort, { once: true });
426
452
  pendingRpcs.set(opId, entry);
427
453
  streamSinks.set(opId, (event) => {
428
454
  clearTimeout(entry.timeout);
@@ -440,6 +466,17 @@ function rpcStreaming(
440
466
  });
441
467
  }
442
468
 
469
+ function streamCancelledError(signal?: AbortSignal): Error {
470
+ const reason = signal?.reason;
471
+ const detail =
472
+ reason instanceof Error ? reason.message : reason ? String(reason) : "";
473
+ const err = new Error(
474
+ detail ? `ctx.llm.stream was cancelled: ${detail}` : "ctx.llm.stream was cancelled",
475
+ );
476
+ (err as { code?: string }).code = "LLM_CANCELLED";
477
+ return err;
478
+ }
479
+
443
480
  /**
444
481
  * Tail of the in-order queue of legacy (call_id-keyed) RPCs, per call.
445
482
  * See {@link rpc} for why these serialize.
@@ -941,11 +978,13 @@ export function buildLlm(callId: string): Llm {
941
978
  async stream(
942
979
  request: LlmCompleteRequest,
943
980
  onEvent: (event: LlmStreamEvent) => void,
981
+ options?: { signal?: AbortSignal },
944
982
  ): Promise<LlmCompleteResponse> {
945
983
  return (await rpcStreaming(
946
984
  callId,
947
985
  { type: "llm_stream", request },
948
986
  (event) => onEvent(event as LlmStreamEvent),
987
+ options?.signal,
949
988
  )) as LlmCompleteResponse;
950
989
  },
951
990
 
@@ -1242,6 +1281,38 @@ function buildActionCtx(
1242
1281
  connections,
1243
1282
  workflows: buildWorkflows(callId),
1244
1283
  audit: buildAudit(callId),
1284
+ agents: {
1285
+ async run(name, options) {
1286
+ if (!options || typeof options.as?.userId !== "string") {
1287
+ const err = new Error(
1288
+ "ctx.agents.run needs as.userId: the user the run belongs to",
1289
+ );
1290
+ (err as { code?: string }).code = "INVALID_ARGS";
1291
+ throw err;
1292
+ }
1293
+ const context = options.context;
1294
+ if (
1295
+ context !== undefined &&
1296
+ context !== null &&
1297
+ (typeof context !== "object" || Array.isArray(context))
1298
+ ) {
1299
+ const err = new Error("ctx.agents.run: context must be a plain object");
1300
+ (err as { code?: string }).code = "INVALID_ARGS";
1301
+ throw err;
1302
+ }
1303
+ const args: Record<string, unknown> = { input: options.input };
1304
+ if (options.runId !== undefined) args.runId = options.runId;
1305
+ if (options.title !== undefined) args.title = options.title;
1306
+ return rpc(callId, {
1307
+ type: "run_agent",
1308
+ agent: name,
1309
+ args,
1310
+ user_id: options.as.userId,
1311
+ admin: options.as.admin === true,
1312
+ context: options.context ?? null,
1313
+ }) as Promise<any>;
1314
+ },
1315
+ },
1245
1316
  domains: buildDomains(),
1246
1317
  env: process.env as Record<string, string>,
1247
1318
  async runQuery(fnName, args) {
@@ -1304,6 +1375,32 @@ registry.set("__pylonMemberLookup", {
1304
1375
  // Handler
1305
1376
  // ---------------------------------------------------------------------------
1306
1377
 
1378
+ /** Why a `ctx.agents.run` target may not run, or null when it may. */
1379
+ function agentInvocationRefusal(
1380
+ def: FnDefinition,
1381
+ rawAuth: AuthInfo,
1382
+ ): { code: string; message: string } | null {
1383
+ if (!isAgentDefinition(def)) {
1384
+ return {
1385
+ code: "AGENT_NOT_FOUND",
1386
+ message: "ctx.agents.run can only run a function defined with agent()",
1387
+ };
1388
+ }
1389
+ const auth = normalizeAuthClaims(rawAuth as unknown as Record<string, unknown>);
1390
+ const mode = (def as { auth?: string }).auth ?? "user";
1391
+ if (mode === "admin" && !auth.isAdmin) {
1392
+ return {
1393
+ code: "FORBIDDEN",
1394
+ message:
1395
+ "this agent is declared auth: \"admin\"; pass as: { userId, admin: true } from an admin caller",
1396
+ };
1397
+ }
1398
+ if ((mode === "user" || mode === "admin") && !auth.userId) {
1399
+ return { code: "UNAUTHENTICATED", message: "the agent needs a user" };
1400
+ }
1401
+ return null;
1402
+ }
1403
+
1307
1404
  async function handleCall(msg: CallMessage): Promise<void> {
1308
1405
  const def = registry.get(msg.fn_name);
1309
1406
 
@@ -1332,6 +1429,18 @@ async function handleCall(msg: CallMessage): Promise<void> {
1332
1429
  return;
1333
1430
  }
1334
1431
 
1432
+ // `ctx.agents.run`: the host checked who may start this and as whom.
1433
+ // What is left is the target: it must be an agent, and the identity
1434
+ // must satisfy the agent's own auth mode, exactly as an HTTP call to
1435
+ // it would have to.
1436
+ if (msg.agent) {
1437
+ const refusal = agentInvocationRefusal(def, msg.auth);
1438
+ if (refusal) {
1439
+ send({ type: "error", call_id: msg.call_id, ...refusal });
1440
+ return;
1441
+ }
1442
+ }
1443
+
1335
1444
  if (def.args) {
1336
1445
  const { valid, errors } = validateArgs(msg.args, def.args);
1337
1446
  if (!valid) {
@@ -1469,6 +1578,13 @@ async function handleCall(msg: CallMessage): Promise<void> {
1469
1578
  // the switch so all three ctx shapes get it without threading another
1470
1579
  // parameter through each builder.
1471
1580
  (ctx as { signal?: AbortSignal }).signal = abort.signal;
1581
+ if (msg.agent) {
1582
+ // Read by the agent loop only. A symbol key, so nothing a client
1583
+ // sends (args are plain JSON) can set it.
1584
+ (ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
1585
+ context: msg.agent.context ?? null,
1586
+ };
1587
+ }
1472
1588
 
1473
1589
  try {
1474
1590
  const result = await def.handler(ctx, msg.args);
@@ -1561,19 +1677,29 @@ function firstStackFrame(err: unknown): string {
1561
1677
  */
1562
1678
  function listModuleFiles(
1563
1679
  dir: string,
1680
+ opts: { reportMissing?: boolean } = {},
1564
1681
  ): Array<{ name: string; file: string; load: () => Promise<any> }> {
1565
1682
  let files: string[];
1566
1683
  try {
1567
1684
  files = readdirSync(dir).filter(
1568
1685
  (f) => f.endsWith(".ts") || f.endsWith(".js"),
1569
1686
  );
1570
- } catch {
1687
+ } catch (err) {
1688
+ // A missing default directory is normal (see above). Anything else, or
1689
+ // a directory the app named explicitly, means its functions would
1690
+ // silently not register, so say why.
1691
+ const missing = (err as NodeJS.ErrnoException)?.code === "ENOENT";
1692
+ if (!missing || opts.reportMissing) {
1693
+ console.error(`[functions] Cannot read ${dir}:`, err);
1694
+ }
1571
1695
  return [];
1572
1696
  }
1573
1697
  return files.map((file) => ({
1574
1698
  name: basename(file, file.endsWith(".ts") ? ".ts" : ".js"),
1575
1699
  file,
1576
- load: () => import(join(dir, file)),
1700
+ // A file URL: a bare Windows path ("D:\\app\\x.ts") is not a module
1701
+ // specifier everywhere.
1702
+ load: () => import(pathToFileURL(join(dir, file)).href),
1577
1703
  }));
1578
1704
  }
1579
1705
 
@@ -1584,6 +1710,9 @@ async function main() {
1584
1710
  fenceStdout();
1585
1711
 
1586
1712
  const fnDir = process.argv[2] || "./functions";
1713
+ // `resolve`, not `join`: an absolute PYLON_FUNCTIONS_DIR (including a
1714
+ // Windows drive path) must be used as is, not appended to the cwd.
1715
+ const fnDirIsDefault = fnDir === "functions" || fnDir === "./functions";
1587
1716
  const bundle = serverBundle();
1588
1717
 
1589
1718
  const fnSources = bundle
@@ -1592,7 +1721,9 @@ async function main() {
1592
1721
  file: `${name} (bundled)`,
1593
1722
  load: bundle.functions[name],
1594
1723
  }))
1595
- : listModuleFiles(join(process.cwd(), fnDir));
1724
+ : listModuleFiles(resolve(process.cwd(), fnDir), {
1725
+ reportMissing: !fnDirIsDefault,
1726
+ });
1596
1727
 
1597
1728
  const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
1598
1729
  let agentsPresent = false;
@@ -1650,7 +1781,7 @@ async function main() {
1650
1781
  file: `${name} (bundled)`,
1651
1782
  load: bundle.workflows[name],
1652
1783
  }))
1653
- : listModuleFiles(join(process.cwd(), "workflows"));
1784
+ : listModuleFiles(resolve(process.cwd(), "workflows"));
1654
1785
  for (const { file, load } of wfSources) {
1655
1786
  try {
1656
1787
  const mod = await load();
package/src/types.ts CHANGED
@@ -508,10 +508,16 @@ export interface Llm {
508
508
  * if (res.stop_reason === "tool_use") { ...run tools, loop... }
509
509
  * ```
510
510
  *
511
- * Streaming does NOT extend the function's call deadline — it is an
512
- * absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
513
- * default). A long agent run must declare its own `timeout` on the
514
- * function def.
511
+ * The function's call timeout (PYLON_FN_CALL_TIMEOUT, 30s default, or
512
+ * the function's own `timeout`) is an idle timeout. A running stream
513
+ * counts as activity, but no call outlives 10× its timeout, so a long
514
+ * agent run should still declare its own `timeout`.
515
+ *
516
+ * Pass `{ signal }` to stop a stream early. When the signal aborts,
517
+ * the promise rejects with `err.code === "LLM_CANCELLED"` and the
518
+ * server closes the provider connection, so the model stops
519
+ * generating (and billing) tokens. A stream still running when the
520
+ * function returns or times out is cancelled the same way.
515
521
  *
516
522
  * Same errors and same gating as {@link Llm.complete} — including
517
523
  * the model allowlist, so streaming can't be used to reach a model
@@ -520,6 +526,7 @@ export interface Llm {
520
526
  stream(
521
527
  request: LlmCompleteRequest,
522
528
  onEvent: (event: LlmStreamEvent) => void,
529
+ options?: { signal?: AbortSignal },
523
530
  ): Promise<LlmCompleteResponse>;
524
531
 
525
532
  /**
@@ -1363,6 +1370,48 @@ export interface Domains {
1363
1370
  ): Promise<RegisteredDomainResult>;
1364
1371
  }
1365
1372
 
1373
+ /** Options for {@link Agents.run}. */
1374
+ export interface AgentRunOptions {
1375
+ /** The message for this turn. */
1376
+ input: string;
1377
+ /** Continue this run. It must belong to `as.userId` and this agent. */
1378
+ runId?: string;
1379
+ /** Display title, stored on a new run. */
1380
+ title?: string;
1381
+ /**
1382
+ * The user the run belongs to and runs as. The run shows up in that
1383
+ * user's synced `AgentRun`/`AgentMessage` rows. `admin: true` also
1384
+ * gives the run admin rights (needed for an agent declared
1385
+ * `auth: "admin"`), and is allowed only when the caller is an admin.
1386
+ */
1387
+ as: { userId: string; admin?: boolean };
1388
+ /**
1389
+ * JSON the agent's tool handlers receive as `run.context` (their third
1390
+ * argument), for example the conversation a webhook turn belongs to.
1391
+ * Stored on the run as `AgentRun.context`, so later turns of the run
1392
+ * see it too. The run's owner can read it; do not put secrets in it.
1393
+ */
1394
+ context?: Record<string, unknown>;
1395
+ }
1396
+
1397
+ /**
1398
+ * Run an `agent()` from server code. See {@link ActionCtx.agents}.
1399
+ */
1400
+ export interface Agents {
1401
+ /**
1402
+ * Run agent `name` (its file name in `functions/`) as `options.as`.
1403
+ * Resolves with the agent's result once the turn finishes, like a
1404
+ * `streamFn` call's final `result` event. The agent's tokens are not
1405
+ * streamed to this caller; they are in the run's `AgentMessage` rows.
1406
+ *
1407
+ * Only server code can do this: the calling action must be
1408
+ * `internal: true` (jobs, workflows, scheduled work) or run with admin
1409
+ * rights (an admin session, or a webhook after `ctx.auth.elevate`).
1410
+ * Otherwise it throws `AGENT_RUN_FORBIDDEN`.
1411
+ */
1412
+ run(name: string, options: AgentRunOptions): Promise<import("./agent").AgentResult>;
1413
+ }
1414
+
1366
1415
  export interface ActionCtx<R extends AuthRequirement = "optional"> {
1367
1416
  auth: AuthInfo<R>;
1368
1417
  stream: Stream;
@@ -1379,6 +1428,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1379
1428
  workflows: Workflows;
1380
1429
  /** Application audit log — see {@link Audit}. */
1381
1430
  audit: Audit;
1431
+ /** Run an `agent()` as a named user from server code — see {@link Agents}. */
1432
+ agents: Agents;
1382
1433
  /** Attach / register custom domains for your app's OWN end-customers
1383
1434
  * (platform domains) — see {@link Domains}. Cloud-only. */
1384
1435
  domains: Domains;