@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.
package/src/agent.ts ADDED
@@ -0,0 +1,430 @@
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
+
39
+ import { action } from "./define";
40
+ import { v, validateArgs } from "./validators";
41
+ import type {
42
+ ActionCtx,
43
+ AnyValidator,
44
+ FnDefinition,
45
+ LlmContentBlock,
46
+ LlmMessage,
47
+ LlmTool,
48
+ ValidatorSchema,
49
+ } from "./types";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Public types
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** One tool an agent can call. */
56
+ export interface AgentTool {
57
+ /** Shown to the model — say when to use the tool, not how it works. */
58
+ description: string;
59
+ /** Argument validators (same `v.*` schema as functions). The model's
60
+ * JSON is validated before the handler runs; invalid input becomes
61
+ * a tool_result error the model can react to. Omit for no-arg tools. */
62
+ args?: ValidatorSchema;
63
+ /** 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;
68
+ }
69
+
70
+ export interface AgentDefinition {
71
+ /** System prompt. A function receives the ctx + call args for
72
+ * per-user prompts. */
73
+ system?: string | ((ctx: ActionCtx, args: AgentCallArgs) => string);
74
+ tools?: Record<string, AgentTool>;
75
+ /** Model override (subject to the server's allowlist). */
76
+ model?: string;
77
+ /** Max model↔tool round-trips per invocation (default 16). Hitting
78
+ * the cap fails the run rather than looping forever. */
79
+ maxSteps?: number;
80
+ /** max_tokens per completion (default: server default). */
81
+ maxTokens?: number;
82
+ /** Auth gate for the action (default "user" — runs are owner-scoped,
83
+ * so an authenticated caller is the natural default). */
84
+ auth?: "user" | "admin";
85
+ /** Idle timeout in seconds (default 600; activity extends it). */
86
+ timeout?: number;
87
+ }
88
+
89
+ /** The synthesized action's args. */
90
+ export interface AgentCallArgs {
91
+ /** The user's message for this turn. */
92
+ input: string;
93
+ /** Continue an existing run (must belong to the caller and this
94
+ * agent). Omit to start a new run. */
95
+ runId?: string;
96
+ /** Optional display title, stored on new runs. */
97
+ title?: string;
98
+ }
99
+
100
+ /** What the agent action resolves with (also the `event: result`
101
+ * payload on the SSE stream). */
102
+ export interface AgentResult {
103
+ runId: string;
104
+ /** Concatenated text of the final assistant message. */
105
+ text: string;
106
+ /** Round-trips consumed. */
107
+ steps: number;
108
+ usage: { input_tokens: number; output_tokens: number };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Validators → JSON Schema (for LlmTool.input_schema)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Convert one `v.*` validator to a JSON-Schema fragment. */
116
+ export function validatorToJsonSchema(val: AnyValidator): Record<string, unknown> {
117
+ const t = (val as { type: string }).type;
118
+ switch (t) {
119
+ case "string":
120
+ return { type: "string" };
121
+ case "int":
122
+ return { type: "integer" };
123
+ case "number":
124
+ return { type: "number" };
125
+ case "boolean":
126
+ return { type: "boolean" };
127
+ case "null":
128
+ return { type: "null" };
129
+ case "id":
130
+ return { type: "string" };
131
+ case "literal":
132
+ return { const: (val as { value: unknown }).value };
133
+ case "array":
134
+ return {
135
+ type: "array",
136
+ items: validatorToJsonSchema((val as { items: AnyValidator }).items),
137
+ };
138
+ case "object": {
139
+ const fields = (val as { fields: ValidatorSchema }).fields ?? {};
140
+ return validatorSchemaToJsonSchema(fields);
141
+ }
142
+ case "union": {
143
+ const variants = (val as { variants: AnyValidator[] }).variants ?? [];
144
+ return { anyOf: variants.map(validatorToJsonSchema) };
145
+ }
146
+ // json / any — anything goes; the empty schema is JSON Schema's
147
+ // "any value".
148
+ default:
149
+ return {};
150
+ }
151
+ }
152
+
153
+ /** Convert a validator schema (a tool's `args`) to a JSON-Schema
154
+ * object with `required` derived from non-optional fields. */
155
+ export function validatorSchemaToJsonSchema(
156
+ schema: ValidatorSchema,
157
+ ): Record<string, unknown> {
158
+ const properties: Record<string, unknown> = {};
159
+ const required: string[] = [];
160
+ for (const [name, val] of Object.entries(schema)) {
161
+ properties[name] = validatorToJsonSchema(val as AnyValidator);
162
+ if (!(val as { optional?: boolean }).optional) {
163
+ required.push(name);
164
+ }
165
+ }
166
+ const out: Record<string, unknown> = { type: "object", properties };
167
+ if (required.length > 0) out.required = required;
168
+ return out;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // The define-type
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** Marker so the SDK's discoverFunctions can detect agents and inject
176
+ * the AgentRun/AgentMessage entities into the manifest. */
177
+ export const AGENT_MARKER = "__pylonAgent";
178
+
179
+ export function agent(def: AgentDefinition): FnDefinition<AgentCallArgs, AgentResult> {
180
+ const fnDef = action({
181
+ args: {
182
+ input: v.string(),
183
+ runId: v.optional(v.string()),
184
+ title: v.optional(v.string()),
185
+ },
186
+ auth: def.auth ?? "user",
187
+ timeout: def.timeout ?? 600,
188
+ handler: (ctx: ActionCtx, args: AgentCallArgs) =>
189
+ runAgentLoop(def, ctx, args),
190
+ } as never) as FnDefinition<AgentCallArgs, AgentResult>;
191
+ (fnDef as unknown as Record<string, unknown>)[AGENT_MARKER] = true;
192
+ return fnDef;
193
+ }
194
+
195
+ export function isAgentDefinition(value: unknown): boolean {
196
+ return (
197
+ typeof value === "object" &&
198
+ value !== null &&
199
+ (value as Record<string, unknown>)[AGENT_MARKER] === true
200
+ );
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // The loop
205
+ // ---------------------------------------------------------------------------
206
+
207
+ const DEFAULT_MAX_STEPS = 16;
208
+
209
+ /** Tool results persist into AgentMessage rows and replay into every
210
+ * later completion's context — cap them so one oversized return can't
211
+ * bloat the transcript (and the sync payloads) unboundedly. */
212
+ const MAX_TOOL_RESULT_CHARS = 64 * 1024;
213
+
214
+ function truncateToolResult(s: string): string {
215
+ if (s.length <= MAX_TOOL_RESULT_CHARS) return s;
216
+ return `${s.slice(0, MAX_TOOL_RESULT_CHARS)}\n[truncated]`;
217
+ }
218
+
219
+ interface StoredMessage {
220
+ id: string;
221
+ seq: number;
222
+ role: string;
223
+ content: unknown;
224
+ }
225
+
226
+ async function runAgentLoop(
227
+ def: AgentDefinition,
228
+ ctx: ActionCtx,
229
+ args: AgentCallArgs,
230
+ ): Promise<AgentResult> {
231
+ // The action's file-inferred name isn't visible here; the internal
232
+ // write mutation derives it from this marker arg set by the registry
233
+ // loader (see registerAgentInternals). Fallback "agent".
234
+ const agentName =
235
+ (args as unknown as Record<string, unknown>).__agentName?.toString() ??
236
+ "agent";
237
+
238
+ // A run left "running" longer than the agent's timeout is a dead
239
+ // generation (the process died before the terminal status write) —
240
+ // continuations may take it over instead of being RUN_BUSY forever.
241
+ const staleMs = Math.max(def.timeout ?? 600, 60) * 1000;
242
+
243
+ // 1. Create or load the run (ownership enforced inside the internal
244
+ // fns, which run under this caller's auth).
245
+ let runId: string;
246
+ let history: LlmMessage[] = [];
247
+ if (args.runId) {
248
+ const loaded = await ctx.runQuery<{
249
+ run: { id: string; agent: string; status: string; updatedAt?: string };
250
+ messages: StoredMessage[];
251
+ }>("__pylon_agent_read", { runId: args.runId });
252
+ if (loaded.run.agent !== agentName) {
253
+ throw ctx.error(
254
+ "AGENT_MISMATCH",
255
+ `Run ${args.runId} belongs to agent "${loaded.run.agent}"`,
256
+ );
257
+ }
258
+ if (loaded.run.status === "running") {
259
+ const updatedAt = Date.parse(String(loaded.run.updatedAt ?? "")) || 0;
260
+ if (Date.now() - updatedAt < staleMs) {
261
+ throw ctx.error(
262
+ "RUN_BUSY",
263
+ "This run is already generating — wait for it to finish",
264
+ );
265
+ }
266
+ }
267
+ runId = loaded.run.id;
268
+ history = loaded.messages.map(storedToLlmMessage);
269
+ } else {
270
+ const created = await ctx.runMutation<{ id: string }>(
271
+ "__pylon_agent_write",
272
+ { op: "createRun", agent: agentName, title: args.title ?? null },
273
+ );
274
+ runId = created.id;
275
+ }
276
+
277
+ const write = (op: Record<string, unknown>) =>
278
+ ctx.runMutation<Record<string, unknown>>("__pylon_agent_write", {
279
+ ...op,
280
+ runId,
281
+ });
282
+
283
+ // 2. Claim the run FIRST (guarded inside the mutation's transaction —
284
+ // the pre-flight check above races between concurrent turns), then
285
+ // write. The stream id lets other devices attach mid-generation.
286
+ await write({
287
+ op: "setStatus",
288
+ status: "running",
289
+ streamId: ctx.stream.id ?? null,
290
+ guardNotRunning: true,
291
+ staleMs,
292
+ });
293
+
294
+ // A crash between persisting an assistant tool_use turn and its
295
+ // tool_results leaves a transcript the LLM API rejects on replay.
296
+ // Repair with synthetic error results before the new user turn.
297
+ const lastMsg = history[history.length - 1];
298
+ if (lastMsg?.role === "assistant" && Array.isArray(lastMsg.content)) {
299
+ const dangling = lastMsg.content.filter(
300
+ (b): b is Extract<LlmContentBlock, { type: "tool_use" }> =>
301
+ (b as { type?: string }).type === "tool_use",
302
+ );
303
+ if (dangling.length > 0) {
304
+ const repairs: LlmContentBlock[] = dangling.map((b) => ({
305
+ type: "tool_result",
306
+ tool_use_id: b.id,
307
+ content: "tool execution was interrupted",
308
+ is_error: true,
309
+ }));
310
+ await write({ op: "appendMessage", role: "user", content: repairs });
311
+ history.push({ role: "user", content: repairs });
312
+ }
313
+ }
314
+
315
+ await write({ op: "appendMessage", role: "user", content: args.input });
316
+ history.push({ role: "user", content: args.input });
317
+
318
+ // Tool declarations for the model.
319
+ const tools: LlmTool[] = Object.entries(def.tools ?? {}).map(
320
+ ([name, tool]) => ({
321
+ name,
322
+ description: tool.description,
323
+ input_schema: validatorSchemaToJsonSchema(tool.args ?? {}),
324
+ }),
325
+ );
326
+
327
+ const maxSteps = def.maxSteps ?? DEFAULT_MAX_STEPS;
328
+ const usage = { input_tokens: 0, output_tokens: 0 };
329
+ let finalText = "";
330
+ let steps = 0;
331
+
332
+ try {
333
+ for (;;) {
334
+ steps += 1;
335
+ if (steps > maxSteps) {
336
+ throw ctx.error(
337
+ "AGENT_MAX_STEPS",
338
+ `Agent exceeded ${maxSteps} tool round-trips`,
339
+ );
340
+ }
341
+ const system =
342
+ typeof def.system === "function" ? def.system(ctx, args) : def.system;
343
+ const res = await ctx.llm.stream(
344
+ {
345
+ messages: history,
346
+ ...(system ? { system } : {}),
347
+ ...(tools.length > 0 ? { tools } : {}),
348
+ ...(def.model ? { model: def.model } : {}),
349
+ ...(def.maxTokens ? { max_tokens: def.maxTokens } : {}),
350
+ },
351
+ (e) => {
352
+ if (e.type === "text_delta") ctx.stream.write(e.text);
353
+ },
354
+ );
355
+ usage.input_tokens += res.usage.input_tokens;
356
+ usage.output_tokens += res.usage.output_tokens;
357
+
358
+ // Persist the assistant turn exactly as the model produced it
359
+ // (text + tool_use blocks) so history replays are faithful.
360
+ await write({ op: "appendMessage", role: "assistant", content: res.content });
361
+ history.push({ role: "assistant", content: res.content });
362
+ finalText = res.content
363
+ .filter((b): b is Extract<LlmContentBlock, { type: "text" }> => b.type === "text")
364
+ .map((b) => b.text)
365
+ .join("");
366
+
367
+ if (res.stop_reason !== "tool_use") break;
368
+
369
+ // 3. Execute every requested tool; failures become is_error
370
+ // results the model can react to rather than run-fatal throws.
371
+ const results: LlmContentBlock[] = [];
372
+ for (const block of res.content) {
373
+ if (block.type !== "tool_use") continue;
374
+ const tool = def.tools?.[block.name];
375
+ let content: string;
376
+ let isError = false;
377
+ if (!tool) {
378
+ content = `Unknown tool "${block.name}"`;
379
+ isError = true;
380
+ } else {
381
+ try {
382
+ if (tool.args) {
383
+ const check = validateArgs(block.input, tool.args);
384
+ if (!check.valid) {
385
+ throw new Error(`Invalid tool input: ${check.errors.join("; ")}`);
386
+ }
387
+ }
388
+ const value = await tool.handler(ctx, block.input);
389
+ content =
390
+ typeof value === "string" ? value : JSON.stringify(value ?? null);
391
+ } catch (err) {
392
+ content = err instanceof Error ? err.message : String(err);
393
+ isError = true;
394
+ }
395
+ }
396
+ content = truncateToolResult(content);
397
+ // Announce the tool call on the stream so live UIs can render
398
+ // "using searchDocs…" without polling the message rows.
399
+ ctx.stream.writeEvent(
400
+ "tool",
401
+ JSON.stringify({ name: block.name, input: block.input, isError }),
402
+ );
403
+ results.push({
404
+ type: "tool_result",
405
+ tool_use_id: block.id,
406
+ content,
407
+ ...(isError ? { is_error: true } : {}),
408
+ });
409
+ }
410
+ await write({ op: "appendMessage", role: "user", content: results });
411
+ history.push({ role: "user", content: results });
412
+ }
413
+ } catch (err) {
414
+ const message = err instanceof Error ? err.message : String(err);
415
+ // Best-effort — the failure we surface is the loop's, not the
416
+ // bookkeeping write's.
417
+ await write({ op: "setStatus", status: "failed", error: message }).catch(
418
+ () => {},
419
+ );
420
+ throw err;
421
+ }
422
+
423
+ await write({ op: "setStatus", status: "completed" });
424
+ return { runId, text: finalText, steps, usage };
425
+ }
426
+
427
+ function storedToLlmMessage(m: StoredMessage): LlmMessage {
428
+ const role = m.role === "assistant" ? "assistant" : "user";
429
+ return { role, content: m.content as LlmMessage["content"] };
430
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,18 @@
21
21
  export { query, mutation, action } from "./define";
22
22
  export { v } from "./validators";
23
23
  export { workflow } from "./workflows";
24
+ export {
25
+ agent,
26
+ isAgentDefinition,
27
+ validatorToJsonSchema,
28
+ validatorSchemaToJsonSchema,
29
+ } from "./agent";
30
+ export type {
31
+ AgentDefinition,
32
+ AgentTool,
33
+ AgentCallArgs,
34
+ AgentResult,
35
+ } from "./agent";
24
36
  export type {
25
37
  WorkflowDefinition,
26
38
  WorkflowRun,
package/src/runtime.ts CHANGED
@@ -788,8 +788,11 @@ function buildWriterOps(callId: string, unsafeOp: boolean): Omit<DbWriter, "unsa
788
788
  };
789
789
  }
790
790
 
791
- function buildStream(callId: string): Stream {
791
+ function buildStream(callId: string, streamId?: string): Stream {
792
792
  return {
793
+ // Host-assigned resumable-stream id (SSE fn path only). Persist it
794
+ // to let other devices attach via GET /api/fn-streams/<id>.
795
+ id: streamId,
793
796
  write(data: string) {
794
797
  // Stream messages are fire-and-forget; they don't get a `result` reply.
795
798
  send({ type: "stream", call_id: callId, data });
@@ -1192,7 +1195,10 @@ async function handleCall(msg: CallMessage): Promise<void> {
1192
1195
  const abort = new AbortController();
1193
1196
  callAborts.set(msg.call_id, abort);
1194
1197
 
1195
- const stream = buildStream(msg.call_id);
1198
+ const stream = buildStream(
1199
+ msg.call_id,
1200
+ (msg as { stream_id?: string }).stream_id,
1201
+ );
1196
1202
  const scheduler = buildScheduler(msg.call_id);
1197
1203
  const email = buildEmail(msg.call_id);
1198
1204
  const llm = buildLlm(msg.call_id);
@@ -1405,6 +1411,8 @@ async function main() {
1405
1411
  files = [];
1406
1412
  }
1407
1413
 
1414
+ const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
1415
+ let agentsPresent = false;
1408
1416
  for (const file of files) {
1409
1417
  const name = basename(file, file.endsWith(".ts") ? ".ts" : ".js");
1410
1418
  try {
@@ -1420,6 +1428,19 @@ async function main() {
1420
1428
  typeof anyDef.type === "string" &&
1421
1429
  typeof anyDef.handler === "function"
1422
1430
  ) {
1431
+ if (isAgentDefinition(def)) {
1432
+ // The agent loop needs its own registered name (for the
1433
+ // AgentRun.agent column + continuation checks) but handlers
1434
+ // don't know their filename — inject it post-validation.
1435
+ agentsPresent = true;
1436
+ const orig = anyDef.handler as (
1437
+ ctx: unknown,
1438
+ args: Record<string, unknown>,
1439
+ ) => unknown;
1440
+ anyDef.handler = (ctx: unknown, args: Record<string, unknown>) =>
1441
+ orig(ctx, { ...args, __agentName: name });
1442
+ void AGENT_MARKER;
1443
+ }
1423
1444
  registry.set(name, def as FnDefinition);
1424
1445
  }
1425
1446
  } catch (err) {
@@ -1427,6 +1448,11 @@ async function main() {
1427
1448
  }
1428
1449
  }
1429
1450
 
1451
+ if (agentsPresent) {
1452
+ const { registerAgentInternals } = await import("./agent-internals");
1453
+ registerAgentInternals(registry);
1454
+ }
1455
+
1430
1456
  // Workflows: scan the app's workflows/ dir (sibling of functions/).
1431
1457
  // Each file default-exports a `workflow(...)`. Declared names ride the
1432
1458
  // ready handshake so the host registers them with its WorkflowEngine;
package/src/types.ts CHANGED
@@ -312,11 +312,31 @@ export interface DbWriter extends DbReader {
312
312
  // Streaming
313
313
  // ---------------------------------------------------------------------------
314
314
 
315
+ /**
316
+ * Progressive output to the calling client (SSE). Every fn stream is
317
+ * RESUMABLE: the host buffers each chunk under a server-assigned
318
+ * stream id (the `X-Pylon-Stream-Id` response header) with a
319
+ * monotonically increasing sequence, so a client that loses its
320
+ * connection reconnects to `GET /api/fn-streams/<id>` from its last
321
+ * cursor and misses nothing — including the terminal result after the
322
+ * handler already returned. The handler never blocks on (or notices)
323
+ * client disconnects; it just keeps writing.
324
+ */
315
325
  export interface Stream {
326
+ /**
327
+ * The host-assigned resumable-stream id for THIS call, present when
328
+ * the caller connected over SSE (`streamFn`). Persist it — e.g. on a
329
+ * run row — and any device can attach to the live stream (or fetch
330
+ * the buffered replay + final result) via `resumeStream(id)` /
331
+ * `GET /api/fn-streams/<id>`. Absent for non-streaming invocations
332
+ * (plain JSON calls, scheduled jobs).
333
+ */
334
+ readonly id?: string;
335
+
316
336
  /** Write a text chunk to the client (SSE). */
317
337
  write(data: string): void;
318
338
 
319
- /** Write a typed SSE event. */
339
+ /** Write a typed SSE event (`event: <name>` framing on the wire). */
320
340
  writeEvent(event: string, data: string): void;
321
341
  }
322
342
 
@@ -529,11 +549,16 @@ export type LlmStreamEvent =
529
549
  * `useRoom(roomId, userId)`, and the same delivery path a member's
530
550
  * `broadcast()` uses.
531
551
  *
532
- * This is the surface for streaming agent output that must survive a
533
- * closed tab or reach a second device: write tokens to the room, and
534
- * every watcher gets them, not just the caller holding the HTTP
535
- * response. `ctx.stream.write` reaches only the one client that made
536
- * the call.
552
+ * This is the surface for fanning agent output out to a second device
553
+ * or a second tab that is CONNECTED RIGHT NOW: write tokens to the
554
+ * room and every current watcher gets them, not just the caller
555
+ * holding the HTTP response. Delivery is live-only — a subscriber that
556
+ * reconnects does NOT replay messages sent during its gap. For output
557
+ * that must survive a closed tab or a dropped connection, rely on the
558
+ * fn stream itself: every `ctx.stream` stream is buffered server-side
559
+ * and resumable by stream id (`streamFn`'s `onStreamId` +
560
+ * `resumeStream` in the clients), including the final result after the
561
+ * handler finished.
537
562
  *
538
563
  * Not available in queries — a reactive handler re-runs on every dep
539
564
  * change, which would re-broadcast each time.