@pylonsync/functions 0.4.21 → 0.4.23

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.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Internal functions backing the `agent()` loop. Registered by the
3
+ * runtime loader only when the app declares at least one agent.
4
+ *
5
+ * Both run under the CALLING user's auth (internal fns inherit the
6
+ * wrapping handler's auth — they are not admin), so every op verifies
7
+ * run ownership itself and stamps identity from `ctx.auth`, never from
8
+ * args. `internal: true` keeps them off the HTTP surface; the
9
+ * `__pylon_` prefix keeps their timeout out of the runner's
10
+ * wedge-probe budget.
11
+ */
12
+ import type { FnDefinition } from "./types";
13
+ export declare function registerAgentInternals(registry: Map<string, FnDefinition>): void;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `agent()` — a define-type for LLM agents with durable run state.
3
+ *
4
+ * ```ts
5
+ * // functions/researcher.ts
6
+ * export default agent({
7
+ * system: "You are a research assistant.",
8
+ * tools: {
9
+ * searchDocs: {
10
+ * description: "Search the document library",
11
+ * args: { query: v.string() },
12
+ * handler: async (ctx, { query }) => ctx.runQuery("findSimilar", { query }),
13
+ * },
14
+ * },
15
+ * });
16
+ * ```
17
+ *
18
+ * An agent compiles to an ordinary streaming ACTION (named after its
19
+ * file, callable via `streamFn("researcher", { input })`), whose
20
+ * handler runs the tool loop:
21
+ *
22
+ * 1. Create an `AgentRun` row (or load one when `runId` is passed —
23
+ * that's how a conversation continues) and append the user's
24
+ * input as an `AgentMessage`.
25
+ * 2. `ctx.llm.stream` with the declared tools; text deltas flow to
26
+ * `ctx.stream` (resumable — the run row records the stream id).
27
+ * 3. On `stop_reason === "tool_use"`: validate each tool call's
28
+ * input against its validators, run the handler, record the
29
+ * call + result as messages, loop.
30
+ * 4. Terminal: run marked completed/failed.
31
+ *
32
+ * `AgentRun` and `AgentMessage` are real synced entities (injected
33
+ * into the manifest by the SDK when any agent exists), owner-scoped by
34
+ * policy — so `db.useQuery("AgentMessage", { where: { runId } })`
35
+ * shows the transcript live on every one of the user's devices,
36
+ * including tool calls, with zero extra plumbing.
37
+ */
38
+ import type { ActionCtx, AnyValidator, FnDefinition, ValidatorSchema } from "./types";
39
+ /** One tool an agent can call. */
40
+ export interface AgentTool {
41
+ /** Shown to the model — say when to use the tool, not how it works. */
42
+ description: string;
43
+ /** Argument validators (same `v.*` schema as functions). The model's
44
+ * JSON is validated before the handler runs; invalid input becomes
45
+ * a tool_result error the model can react to. Omit for no-arg tools. */
46
+ args?: ValidatorSchema;
47
+ /** 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;
52
+ }
53
+ export interface AgentDefinition {
54
+ /** System prompt. A function receives the ctx + call args for
55
+ * per-user prompts. */
56
+ system?: string | ((ctx: ActionCtx, args: AgentCallArgs) => string);
57
+ tools?: Record<string, AgentTool>;
58
+ /** Model override (subject to the server's allowlist). */
59
+ model?: string;
60
+ /** Max model↔tool round-trips per invocation (default 16). Hitting
61
+ * the cap fails the run rather than looping forever. */
62
+ maxSteps?: number;
63
+ /** max_tokens per completion (default: server default). */
64
+ maxTokens?: number;
65
+ /** Auth gate for the action (default "user" — runs are owner-scoped,
66
+ * so an authenticated caller is the natural default). */
67
+ auth?: "user" | "admin";
68
+ /** Idle timeout in seconds (default 600; activity extends it). */
69
+ timeout?: number;
70
+ }
71
+ /** The synthesized action's args. */
72
+ export interface AgentCallArgs {
73
+ /** The user's message for this turn. */
74
+ input: string;
75
+ /** Continue an existing run (must belong to the caller and this
76
+ * agent). Omit to start a new run. */
77
+ runId?: string;
78
+ /** Optional display title, stored on new runs. */
79
+ title?: string;
80
+ }
81
+ /** What the agent action resolves with (also the `event: result`
82
+ * payload on the SSE stream). */
83
+ export interface AgentResult {
84
+ runId: string;
85
+ /** Concatenated text of the final assistant message. */
86
+ text: string;
87
+ /** Round-trips consumed. */
88
+ steps: number;
89
+ usage: {
90
+ input_tokens: number;
91
+ output_tokens: number;
92
+ };
93
+ }
94
+ /** Convert one `v.*` validator to a JSON-Schema fragment. */
95
+ export declare function validatorToJsonSchema(val: AnyValidator): Record<string, unknown>;
96
+ /** Convert a validator schema (a tool's `args`) to a JSON-Schema
97
+ * object with `required` derived from non-optional fields. */
98
+ export declare function validatorSchemaToJsonSchema(schema: ValidatorSchema): Record<string, unknown>;
99
+ /** Marker so the SDK's discoverFunctions can detect agents and inject
100
+ * the AgentRun/AgentMessage entities into the manifest. */
101
+ export declare const AGENT_MARKER = "__pylonAgent";
102
+ export declare function agent(def: AgentDefinition): FnDefinition<AgentCallArgs, AgentResult>;
103
+ export declare function isAgentDefinition(value: unknown): boolean;
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  export { query, mutation, action } from "./define";
21
21
  export { v } from "./validators";
22
22
  export { workflow } from "./workflows";
23
+ export { agent, isAgentDefinition, validatorToJsonSchema, validatorSchemaToJsonSchema, } from "./agent";
24
+ export type { AgentDefinition, AgentTool, AgentCallArgs, AgentResult, } from "./agent";
23
25
  export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
24
26
  export { resetDb, installTestIsolation } from "./testing";
25
27
  export { slugifyName, availableSlug } from "./slugify";
package/dist/types.d.ts CHANGED
@@ -255,10 +255,29 @@ export interface DbWriter extends DbReader {
255
255
  */
256
256
  advisoryLock(key: string): Promise<void>;
257
257
  }
258
+ /**
259
+ * Progressive output to the calling client (SSE). Every fn stream is
260
+ * RESUMABLE: the host buffers each chunk under a server-assigned
261
+ * stream id (the `X-Pylon-Stream-Id` response header) with a
262
+ * monotonically increasing sequence, so a client that loses its
263
+ * connection reconnects to `GET /api/fn-streams/<id>` from its last
264
+ * cursor and misses nothing — including the terminal result after the
265
+ * handler already returned. The handler never blocks on (or notices)
266
+ * client disconnects; it just keeps writing.
267
+ */
258
268
  export interface Stream {
269
+ /**
270
+ * The host-assigned resumable-stream id for THIS call, present when
271
+ * the caller connected over SSE (`streamFn`). Persist it — e.g. on a
272
+ * run row — and any device can attach to the live stream (or fetch
273
+ * the buffered replay + final result) via `resumeStream(id)` /
274
+ * `GET /api/fn-streams/<id>`. Absent for non-streaming invocations
275
+ * (plain JSON calls, scheduled jobs).
276
+ */
277
+ readonly id?: string;
259
278
  /** Write a text chunk to the client (SSE). */
260
279
  write(data: string): void;
261
- /** Write a typed SSE event. */
280
+ /** Write a typed SSE event (`event: <name>` framing on the wire). */
262
281
  writeEvent(event: string, data: string): void;
263
282
  }
264
283
  export interface Scheduler {
@@ -452,11 +471,16 @@ export type LlmStreamEvent = {
452
471
  * `useRoom(roomId, userId)`, and the same delivery path a member's
453
472
  * `broadcast()` uses.
454
473
  *
455
- * This is the surface for streaming agent output that must survive a
456
- * closed tab or reach a second device: write tokens to the room, and
457
- * every watcher gets them, not just the caller holding the HTTP
458
- * response. `ctx.stream.write` reaches only the one client that made
459
- * the call.
474
+ * This is the surface for fanning agent output out to a second device
475
+ * or a second tab that is CONNECTED RIGHT NOW: write tokens to the
476
+ * room and every current watcher gets them, not just the caller
477
+ * holding the HTTP response. Delivery is live-only — a subscriber that
478
+ * reconnects does NOT replay messages sent during its gap. For output
479
+ * that must survive a closed tab or a dropped connection, rely on the
480
+ * fn stream itself: every `ctx.stream` stream is buffered server-side
481
+ * and resumable by stream id (`streamFn`'s `onStreamId` +
482
+ * `resumeStream` in the clients), including the final result after the
483
+ * handler finished.
460
484
  *
461
485
  * Not available in queries — a reactive handler re-runs on every dep
462
486
  * change, which would re-broadcast each time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
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",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Internal functions backing the `agent()` loop. Registered by the
3
+ * runtime loader only when the app declares at least one agent.
4
+ *
5
+ * Both run under the CALLING user's auth (internal fns inherit the
6
+ * wrapping handler's auth — they are not admin), so every op verifies
7
+ * run ownership itself and stamps identity from `ctx.auth`, never from
8
+ * args. `internal: true` keeps them off the HTTP surface; the
9
+ * `__pylon_` prefix keeps their timeout out of the runner's
10
+ * wedge-probe budget.
11
+ */
12
+
13
+ import type { FnDefinition, MutationCtx, QueryCtx } from "./types";
14
+
15
+ interface RunRow {
16
+ id: string;
17
+ agent: string;
18
+ status: string;
19
+ userId: string | null;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ function nowIso(): string {
24
+ return new Date().toISOString();
25
+ }
26
+
27
+ /** Ownership fence: admins pass; otherwise the run must belong to the
28
+ * caller. Missing and foreign runs are indistinguishable (NOT_FOUND). */
29
+ function requireOwnedRun(
30
+ ctx: QueryCtx | MutationCtx,
31
+ run: Record<string, unknown> | null,
32
+ ): RunRow {
33
+ const owned =
34
+ run &&
35
+ (ctx.auth.isAdmin ||
36
+ (typeof run.userId === "string" && run.userId === ctx.auth.userId));
37
+ if (!owned) {
38
+ const err = new Error("Run not found");
39
+ (err as { code?: string }).code = "RUN_NOT_FOUND";
40
+ throw err;
41
+ }
42
+ return run as unknown as RunRow;
43
+ }
44
+
45
+ export function registerAgentInternals(
46
+ registry: Map<string, FnDefinition>,
47
+ ): void {
48
+ registry.set("__pylon_agent_read", {
49
+ type: "query",
50
+ internal: true,
51
+ auth: "user",
52
+ handler: async (ctx: QueryCtx, args: Record<string, unknown>) => {
53
+ const runId = String(args.runId ?? "");
54
+ const run = await ctx.db.get("AgentRun", runId);
55
+ const owned = requireOwnedRun(ctx, run);
56
+ const messages = await ctx.db.query("AgentMessage", {
57
+ runId,
58
+ $order: { seq: "asc" },
59
+ });
60
+ return { run: owned, messages };
61
+ },
62
+ } as unknown as FnDefinition);
63
+
64
+ registry.set("__pylon_agent_write", {
65
+ type: "mutation",
66
+ internal: true,
67
+ auth: "user",
68
+ handler: async (ctx: MutationCtx, args: Record<string, unknown>) => {
69
+ const op = String(args.op ?? "");
70
+ switch (op) {
71
+ case "createRun": {
72
+ // Owner-scoped or nothing: a null-owner row would be
73
+ // invisible to everyone (policies guard with
74
+ // `auth.userId != null`), and before that guard existed it
75
+ // was readable by ANONYMOUS callers (null == null evaluates
76
+ // true in the policy engine). Refuse instead of storing an
77
+ // orphan — cron/system invocations must call the agent under
78
+ // a service user's auth.
79
+ const userId = ctx.auth.userId;
80
+ if (typeof userId !== "string" || userId === "") {
81
+ const err = new Error(
82
+ "Agent runs require a signed-in user; invoke the agent under a service user for system/cron calls",
83
+ );
84
+ (err as { code?: string }).code = "AGENT_REQUIRES_USER";
85
+ throw err;
86
+ }
87
+ const id = await ctx.db.insert("AgentRun", {
88
+ agent: String(args.agent ?? "agent"),
89
+ status: "idle",
90
+ userId,
91
+ title: args.title ?? null,
92
+ streamId: null,
93
+ error: null,
94
+ createdAt: nowIso(),
95
+ updatedAt: nowIso(),
96
+ });
97
+ return { id };
98
+ }
99
+ case "appendMessage": {
100
+ const runId = String(args.runId ?? "");
101
+ const run = requireOwnedRun(
102
+ ctx,
103
+ await ctx.db.get("AgentRun", runId),
104
+ );
105
+ // Single-writer per run (the loop rejects concurrent turns),
106
+ // so a read-then-insert seq is race-free in practice; the
107
+ // mutation's transaction covers the rest.
108
+ const last = await ctx.db.query("AgentMessage", {
109
+ runId,
110
+ $order: { seq: "desc" },
111
+ $limit: 1,
112
+ });
113
+ const seq =
114
+ last.length > 0 ? (Number(last[0].seq) || 0) + 1 : 1;
115
+ const id = await ctx.db.insert("AgentMessage", {
116
+ runId,
117
+ userId: run.userId,
118
+ seq,
119
+ role: String(args.role ?? "user"),
120
+ content: args.content ?? null,
121
+ createdAt: nowIso(),
122
+ });
123
+ await ctx.db.update("AgentRun", runId, { updatedAt: nowIso() });
124
+ return { id, seq };
125
+ }
126
+ case "setStatus": {
127
+ const runId = String(args.runId ?? "");
128
+ const run = requireOwnedRun(ctx, await ctx.db.get("AgentRun", runId));
129
+ // The loop's pre-flight RUN_BUSY check races with itself
130
+ // across concurrent turns; this in-transaction guard is the
131
+ // authoritative claim. A "running" row older than staleMs is
132
+ // a dead generation (process crash) and may be taken over.
133
+ if (args.guardNotRunning === true && run.status === "running") {
134
+ const staleMs = Number(args.staleMs) || 0;
135
+ const updatedAt = Date.parse(String(run.updatedAt ?? "")) || 0;
136
+ if (Date.now() - updatedAt < staleMs) {
137
+ const err = new Error(
138
+ "This run is already generating — wait for it to finish",
139
+ );
140
+ (err as { code?: string }).code = "RUN_BUSY";
141
+ throw err;
142
+ }
143
+ }
144
+ const patch: Record<string, unknown> = {
145
+ status: String(args.status ?? "idle"),
146
+ updatedAt: nowIso(),
147
+ };
148
+ if (args.error !== undefined) patch.error = args.error;
149
+ if (args.streamId !== undefined) patch.streamId = args.streamId;
150
+ const updated = await ctx.db.update("AgentRun", runId, patch);
151
+ return { updated };
152
+ }
153
+ default: {
154
+ const err = new Error(`Unknown agent write op "${op}"`);
155
+ (err as { code?: string }).code = "INVALID_OP";
156
+ throw err;
157
+ }
158
+ }
159
+ },
160
+ } as unknown as FnDefinition);
161
+ }