@pylonsync/functions 0.3.368 → 0.3.371
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/index.d.ts +1 -1
- package/dist/og-fixture-GZKcjt/app/opengraph-image.d.ts +20 -0
- package/dist/og-fixture-GuniPy/app/opengraph-image.d.ts +1 -0
- package/dist/og-fixture-IqNlbN/app/opengraph-image.d.ts +20 -0
- package/dist/og-fixture-O6yotr/app/opengraph-image.d.ts +1 -0
- package/dist/og-fixture-bCvDUF/app/opengraph-image.d.ts +1 -0
- package/dist/og-fixture-fbkkcz/app/opengraph-image.d.ts +20 -0
- package/dist/og-fixture-oY8CpY/app/opengraph-image.d.ts +12 -0
- package/dist/og-fixture-osS4Ik/app/opengraph-image.d.ts +12 -0
- package/dist/og-fixture-vRTMew/app/opengraph-image.d.ts +20 -0
- package/dist/og-fixture-x2747s/app/opengraph-image.d.ts +12 -0
- package/dist/runtime.d.ts +22 -1
- package/dist/types.d.ts +82 -0
- package/package.json +1 -1
- package/src/index.ts +11 -0
- package/src/og-fixture-GZKcjt/app/opengraph-image.tsx +10 -0
- package/src/og-fixture-GuniPy/app/opengraph-image.tsx +1 -0
- package/src/og-fixture-IqNlbN/app/opengraph-image.tsx +10 -0
- package/src/og-fixture-O6yotr/app/opengraph-image.tsx +1 -0
- package/src/og-fixture-bCvDUF/app/opengraph-image.tsx +1 -0
- package/src/og-fixture-fbkkcz/app/opengraph-image.tsx +10 -0
- package/src/og-fixture-oY8CpY/app/opengraph-image.tsx +6 -0
- package/src/og-fixture-osS4Ik/app/opengraph-image.tsx +6 -0
- package/src/og-fixture-vRTMew/app/opengraph-image.tsx +10 -0
- package/src/og-fixture-x2747s/app/opengraph-image.tsx +6 -0
- package/src/runtime-llm-stream.test.ts +186 -0
- package/src/runtime.ts +110 -1
- package/src/ssr-runtime.test.ts +6 -1
- package/src/types.ts +81 -0
package/dist/index.d.ts
CHANGED
|
@@ -22,4 +22,4 @@ export { v } from "./validators";
|
|
|
22
22
|
export { resetDb, installTestIsolation } from "./testing";
|
|
23
23
|
export { slugifyName, availableSlug } from "./slugify";
|
|
24
24
|
export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
|
|
25
|
-
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, } from "./types";
|
|
25
|
+
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG(): {
|
|
3
|
+
__pylonImageResponse: boolean;
|
|
4
|
+
element: React.DetailedReactHTMLElement<{
|
|
5
|
+
style: {
|
|
6
|
+
display: "flex";
|
|
7
|
+
width: string;
|
|
8
|
+
height: string;
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
|
13
|
+
options: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
headers: {
|
|
17
|
+
"cache-control": string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OG(): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG(): {
|
|
3
|
+
__pylonImageResponse: boolean;
|
|
4
|
+
element: React.DetailedReactHTMLElement<{
|
|
5
|
+
style: {
|
|
6
|
+
display: "flex";
|
|
7
|
+
width: string;
|
|
8
|
+
height: string;
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
|
13
|
+
options: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
headers: {
|
|
17
|
+
"cache-control": string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OG(): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OG(): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG(): {
|
|
3
|
+
__pylonImageResponse: boolean;
|
|
4
|
+
element: React.DetailedReactHTMLElement<{
|
|
5
|
+
style: {
|
|
6
|
+
display: "flex";
|
|
7
|
+
width: string;
|
|
8
|
+
height: string;
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
|
13
|
+
options: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
headers: {
|
|
17
|
+
"cache-control": string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export declare const size: {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
export default function OG(): React.DetailedReactHTMLElement<{
|
|
7
|
+
style: {
|
|
8
|
+
display: "flex";
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export declare const size: {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
export default function OG(): React.DetailedReactHTMLElement<{
|
|
7
|
+
style: {
|
|
8
|
+
display: "flex";
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG(): {
|
|
3
|
+
__pylonImageResponse: boolean;
|
|
4
|
+
element: React.DetailedReactHTMLElement<{
|
|
5
|
+
style: {
|
|
6
|
+
display: "flex";
|
|
7
|
+
width: string;
|
|
8
|
+
height: string;
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
|
13
|
+
options: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
headers: {
|
|
17
|
+
"cache-control": string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export declare const size: {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
export default function OG(): React.DetailedReactHTMLElement<{
|
|
7
|
+
style: {
|
|
8
|
+
display: "flex";
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontFamily: "Inter";
|
|
11
|
+
};
|
|
12
|
+
}, HTMLElement>;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -14,6 +14,27 @@
|
|
|
14
14
|
* each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
|
|
15
15
|
* needs to queue multiple RPCs per call_id.
|
|
16
16
|
*/
|
|
17
|
-
import type { DbReader, DbWriter } from "./types";
|
|
17
|
+
import type { DbReader, DbWriter, Llm, Rooms } from "./types";
|
|
18
18
|
export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
|
|
19
19
|
export declare function buildDbWriter(callId: string): DbWriter;
|
|
20
|
+
/**
|
|
21
|
+
* Build the LLM client that round-trips through the host runtime.
|
|
22
|
+
*
|
|
23
|
+
* Each call emits an `llm_complete` protocol message; the runtime
|
|
24
|
+
* forwards to the configured provider (PYLON_LLM_PROVIDER) and replies
|
|
25
|
+
* with the parsed response. The host enforces model-allowlist gating
|
|
26
|
+
* for non-admin callers — that's why the API key never leaves the
|
|
27
|
+
* server process.
|
|
28
|
+
*
|
|
29
|
+
* Errors carry an `err.code` so handlers can branch on `LLM_NOT_CONFIGURED`
|
|
30
|
+
* vs `PROVIDER_HTTP_429` vs `MODEL_NOT_ALLOWED` without parsing message
|
|
31
|
+
* strings.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildLlm(callId: string): Llm;
|
|
34
|
+
/**
|
|
35
|
+
* Build the room broadcaster. One `room_broadcast` message per call;
|
|
36
|
+
* the host fans the event out through the same RoomManager the
|
|
37
|
+
* `/api/rooms/*` routes use, so a server push and a member push land
|
|
38
|
+
* on subscribers identically.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildRooms(callId: string): Rooms;
|
package/dist/types.d.ts
CHANGED
|
@@ -276,6 +276,84 @@ export interface Llm {
|
|
|
276
276
|
* `PROVIDER_UNREACHABLE`, `INVALID_REQUEST`.
|
|
277
277
|
*/
|
|
278
278
|
complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse>;
|
|
279
|
+
/**
|
|
280
|
+
* Streaming completion. `onEvent` fires for each event as the
|
|
281
|
+
* provider emits it; the promise resolves with the same assembled
|
|
282
|
+
* response `complete` returns, so a tool-use loop can inspect
|
|
283
|
+
* `stop_reason` after the text has already been streamed out.
|
|
284
|
+
*
|
|
285
|
+
* The typical agent shape pumps deltas straight to the client:
|
|
286
|
+
*
|
|
287
|
+
* ```ts
|
|
288
|
+
* const res = await ctx.llm.stream(
|
|
289
|
+
* { messages, tools },
|
|
290
|
+
* (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); },
|
|
291
|
+
* );
|
|
292
|
+
* if (res.stop_reason === "tool_use") { ...run tools, loop... }
|
|
293
|
+
* ```
|
|
294
|
+
*
|
|
295
|
+
* Streaming does NOT extend the function's call deadline — it is an
|
|
296
|
+
* absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
|
|
297
|
+
* default). A long agent run must declare its own `timeout` on the
|
|
298
|
+
* function def.
|
|
299
|
+
*
|
|
300
|
+
* Same errors and same gating as {@link Llm.complete} — including
|
|
301
|
+
* the model allowlist, so streaming can't be used to reach a model
|
|
302
|
+
* `complete` would refuse.
|
|
303
|
+
*/
|
|
304
|
+
stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void): Promise<LlmCompleteResponse>;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* One event from an in-flight {@link Llm.stream} call.
|
|
308
|
+
*
|
|
309
|
+
* `tool_use_start` opens a tool call; the `tool_input_delta` events
|
|
310
|
+
* that follow carry its arguments as raw JSON fragments — concatenate
|
|
311
|
+
* them and parse once, rather than parsing each fragment. `done`
|
|
312
|
+
* always fires last, including on a partial failure.
|
|
313
|
+
*/
|
|
314
|
+
export type LlmStreamEvent = {
|
|
315
|
+
type: "text_delta";
|
|
316
|
+
text: string;
|
|
317
|
+
} | {
|
|
318
|
+
type: "tool_use_start";
|
|
319
|
+
id: string;
|
|
320
|
+
name: string;
|
|
321
|
+
} | {
|
|
322
|
+
type: "tool_input_delta";
|
|
323
|
+
partial_json: string;
|
|
324
|
+
} | {
|
|
325
|
+
type: "done";
|
|
326
|
+
stop_reason: string;
|
|
327
|
+
usage: {
|
|
328
|
+
input_tokens: number;
|
|
329
|
+
output_tokens: number;
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Server-originated realtime push. Broadcasts an event to every
|
|
334
|
+
* subscriber of a presence room — the same rooms clients join with
|
|
335
|
+
* `useRoom(roomId, userId)`, and the same delivery path a member's
|
|
336
|
+
* `broadcast()` uses.
|
|
337
|
+
*
|
|
338
|
+
* This is the surface for streaming agent output that must survive a
|
|
339
|
+
* closed tab or reach a second device: write tokens to the room, and
|
|
340
|
+
* every watcher gets them, not just the caller holding the HTTP
|
|
341
|
+
* response. `ctx.stream.write` reaches only the one client that made
|
|
342
|
+
* the call.
|
|
343
|
+
*
|
|
344
|
+
* Not available in queries — a reactive handler re-runs on every dep
|
|
345
|
+
* change, which would re-broadcast each time.
|
|
346
|
+
*/
|
|
347
|
+
export interface Rooms {
|
|
348
|
+
/**
|
|
349
|
+
* Push `data` to every subscriber of `room` under `topic`.
|
|
350
|
+
* Resolves `{ delivered: false }` when the room has no members —
|
|
351
|
+
* broadcasting into an empty room is a no-op, not an error, so an
|
|
352
|
+
* agent doesn't need to know whether anyone is watching.
|
|
353
|
+
*/
|
|
354
|
+
broadcast(room: string, topic: string, data?: unknown): Promise<{
|
|
355
|
+
delivered: boolean;
|
|
356
|
+
}>;
|
|
279
357
|
}
|
|
280
358
|
export interface LlmMessage {
|
|
281
359
|
role: "user" | "assistant" | "system" | "tool";
|
|
@@ -469,6 +547,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
|
469
547
|
env: Record<string, string>;
|
|
470
548
|
/** Provider-abstracted LLM client. */
|
|
471
549
|
llm: Llm;
|
|
550
|
+
/** Server-originated realtime push — see {@link Rooms}. */
|
|
551
|
+
rooms: Rooms;
|
|
472
552
|
/** Per-user OAuth connection registry. */
|
|
473
553
|
connections: Connections;
|
|
474
554
|
/** Create a typed error that triggers rollback. */
|
|
@@ -485,6 +565,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
485
565
|
email: EmailSender;
|
|
486
566
|
/** Provider-abstracted LLM client. */
|
|
487
567
|
llm: Llm;
|
|
568
|
+
/** Server-originated realtime push — see {@link Rooms}. */
|
|
569
|
+
rooms: Rooms;
|
|
488
570
|
/** Per-user OAuth connection registry. */
|
|
489
571
|
connections: Connections;
|
|
490
572
|
/** Environment variables / secrets. */
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -54,4 +54,15 @@ export type {
|
|
|
54
54
|
RequireMember,
|
|
55
55
|
RequireMemberOptions,
|
|
56
56
|
MemberRow,
|
|
57
|
+
// LLM + realtime surfaces. A handler writing an agent tool loop
|
|
58
|
+
// builds its own message array and branches on stream events, so
|
|
59
|
+
// these have to be nameable from app code.
|
|
60
|
+
Llm,
|
|
61
|
+
LlmMessage,
|
|
62
|
+
LlmContentBlock,
|
|
63
|
+
LlmTool,
|
|
64
|
+
LlmCompleteRequest,
|
|
65
|
+
LlmCompleteResponse,
|
|
66
|
+
LlmStreamEvent,
|
|
67
|
+
Rooms,
|
|
57
68
|
} from "./types";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG() {
|
|
3
|
+
return {
|
|
4
|
+
__pylonImageResponse: true,
|
|
5
|
+
element: React.createElement("div",
|
|
6
|
+
{ style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
|
|
7
|
+
"Branded"),
|
|
8
|
+
options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OG() { throw new Error("boom"); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG() {
|
|
3
|
+
return {
|
|
4
|
+
__pylonImageResponse: true,
|
|
5
|
+
element: React.createElement("div",
|
|
6
|
+
{ style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
|
|
7
|
+
"Branded"),
|
|
8
|
+
options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OG() { throw new Error("boom"); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function OG() { throw new Error("boom"); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG() {
|
|
3
|
+
return {
|
|
4
|
+
__pylonImageResponse: true,
|
|
5
|
+
element: React.createElement("div",
|
|
6
|
+
{ style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
|
|
7
|
+
"Branded"),
|
|
8
|
+
options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export default function OG() {
|
|
3
|
+
return {
|
|
4
|
+
__pylonImageResponse: true,
|
|
5
|
+
element: React.createElement("div",
|
|
6
|
+
{ style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
|
|
7
|
+
"Branded"),
|
|
8
|
+
options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `ctx.llm.stream` and `ctx.rooms.broadcast` (runtime.ts).
|
|
3
|
+
*
|
|
4
|
+
* The contract under test:
|
|
5
|
+
*
|
|
6
|
+
* 1. `stream()` emits ONE `llm_stream` frame carrying an `op_id`.
|
|
7
|
+
* 2. `llm_event` frames addressed to that op_id reach the caller's
|
|
8
|
+
* `onEvent` in order, WITHOUT settling the promise.
|
|
9
|
+
* 3. The terminal `result` for that op_id resolves the promise with
|
|
10
|
+
* the assembled response.
|
|
11
|
+
* 4. Events for a different op_id never leak into this stream's
|
|
12
|
+
* callback — two concurrent streams stay separated.
|
|
13
|
+
* 5. A throwing `onEvent` doesn't abandon the RPC; the stream still
|
|
14
|
+
* resolves.
|
|
15
|
+
* 6. `rooms.broadcast` emits a `room_broadcast` frame and resolves
|
|
16
|
+
* with the host's `{delivered}` verdict.
|
|
17
|
+
*
|
|
18
|
+
* runtime.ts runs main() on import (it IS the bun runner entrypoint),
|
|
19
|
+
* so this drives the REAL dispatcher in a child process: we write host
|
|
20
|
+
* frames to its stdin and read what it emits on stdout, exactly as the
|
|
21
|
+
* Rust host would.
|
|
22
|
+
*/
|
|
23
|
+
import { expect, test } from "bun:test";
|
|
24
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
29
|
+
|
|
30
|
+
const SCRIPT = `
|
|
31
|
+
import { buildLlm, buildRooms } from ${JSON.stringify(RUNTIME)};
|
|
32
|
+
|
|
33
|
+
const llm = buildLlm("c_1");
|
|
34
|
+
const rooms = buildRooms("c_2");
|
|
35
|
+
|
|
36
|
+
const seen = [];
|
|
37
|
+
const other = [];
|
|
38
|
+
|
|
39
|
+
// Stream A — the one under test. Its onEvent records every event.
|
|
40
|
+
const a = llm.stream({ messages: [] }, (e) => { seen.push(e); });
|
|
41
|
+
// Stream B — concurrent, and its callback THROWS. Neither may disturb A.
|
|
42
|
+
const b = llm.stream({ messages: [] }, (e) => {
|
|
43
|
+
other.push(e);
|
|
44
|
+
throw new Error("handler callback blew up");
|
|
45
|
+
});
|
|
46
|
+
const r = rooms.broadcast("room-1", "agent.delta", { text: "hi" });
|
|
47
|
+
|
|
48
|
+
const [aRes, bRes, rRes] = await Promise.all([a, b, r]);
|
|
49
|
+
|
|
50
|
+
console.error("RESULT " + JSON.stringify({
|
|
51
|
+
seen,
|
|
52
|
+
other,
|
|
53
|
+
aStop: aRes.stop_reason,
|
|
54
|
+
bStop: bRes.stop_reason,
|
|
55
|
+
delivered: rRes.delivered,
|
|
56
|
+
}));
|
|
57
|
+
process.exit(0);
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
/** Collect NDJSON frames from a chunk of the child's stdout. */
|
|
61
|
+
function parseFrames(text: string): Record<string, unknown>[] {
|
|
62
|
+
return text
|
|
63
|
+
.split("\n")
|
|
64
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
65
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("llm.stream routes events by op_id, resolves on result; rooms.broadcast round-trips", async () => {
|
|
69
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-llm-"));
|
|
70
|
+
const scriptPath = join(dir, "probe.ts");
|
|
71
|
+
writeFileSync(scriptPath, SCRIPT);
|
|
72
|
+
|
|
73
|
+
// Spawn the bun that's already running, by absolute path — resolving
|
|
74
|
+
// "bun" off PATH depends on the process cwd, which other test files
|
|
75
|
+
// in this suite move around.
|
|
76
|
+
const proc = Bun.spawn([process.execPath, scriptPath], {
|
|
77
|
+
stdin: "pipe",
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Read the child's request frames incrementally — we need its op_ids
|
|
83
|
+
// before we can address replies back to it.
|
|
84
|
+
const reader = proc.stdout.getReader();
|
|
85
|
+
const decoder = new TextDecoder();
|
|
86
|
+
let buffered = "";
|
|
87
|
+
// Wait for the three frames we care about. main() also emits a
|
|
88
|
+
// one-shot `ready` handshake on import, so count by type rather than
|
|
89
|
+
// by total frames.
|
|
90
|
+
let requests: Record<string, unknown>[] = [];
|
|
91
|
+
const wanted = (fs: Record<string, unknown>[]) =>
|
|
92
|
+
fs.filter((f) => f.type === "llm_stream").length >= 2 &&
|
|
93
|
+
fs.some((f) => f.type === "room_broadcast");
|
|
94
|
+
while (!wanted(requests)) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
buffered += decoder.decode(value, { stream: true });
|
|
98
|
+
requests = parseFrames(buffered);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const streams = requests.filter((f) => f.type === "llm_stream");
|
|
102
|
+
const broadcasts = requests.filter((f) => f.type === "room_broadcast");
|
|
103
|
+
expect(streams).toHaveLength(2);
|
|
104
|
+
expect(broadcasts).toHaveLength(1);
|
|
105
|
+
|
|
106
|
+
// 1. Each stream frame carries a distinct op_id — that's what makes
|
|
107
|
+
// concurrent streams routable.
|
|
108
|
+
const opA = streams[0].op_id as string;
|
|
109
|
+
const opB = streams[1].op_id as string;
|
|
110
|
+
expect(opA).toBeTruthy();
|
|
111
|
+
expect(opB).toBeTruthy();
|
|
112
|
+
expect(opA).not.toBe(opB);
|
|
113
|
+
|
|
114
|
+
// 2. Broadcast frame carries the room, topic, and payload verbatim.
|
|
115
|
+
expect(broadcasts[0]).toMatchObject({
|
|
116
|
+
room: "room-1",
|
|
117
|
+
topic: "agent.delta",
|
|
118
|
+
data: { text: "hi" },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const send = (msg: Record<string, unknown>) =>
|
|
122
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
123
|
+
|
|
124
|
+
// Interleave A's and B's events so a routing bug (e.g. keying on
|
|
125
|
+
// call_id, or a single global sink) shows up as cross-talk.
|
|
126
|
+
send({ type: "llm_event", call_id: "c_1", op_id: opA, event: { type: "text_delta", text: "He" } });
|
|
127
|
+
send({ type: "llm_event", call_id: "c_1", op_id: opB, event: { type: "text_delta", text: "XX" } });
|
|
128
|
+
send({ type: "llm_event", call_id: "c_1", op_id: opA, event: { type: "text_delta", text: "llo" } });
|
|
129
|
+
send({
|
|
130
|
+
type: "llm_event",
|
|
131
|
+
call_id: "c_1",
|
|
132
|
+
op_id: opA,
|
|
133
|
+
event: { type: "tool_use_start", id: "t1", name: "search" },
|
|
134
|
+
});
|
|
135
|
+
send({
|
|
136
|
+
type: "llm_event",
|
|
137
|
+
call_id: "c_1",
|
|
138
|
+
op_id: opA,
|
|
139
|
+
event: { type: "tool_input_delta", partial_json: '{"q":' },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Terminal results settle each stream.
|
|
143
|
+
send({
|
|
144
|
+
type: "result",
|
|
145
|
+
call_id: "c_1",
|
|
146
|
+
op_id: opA,
|
|
147
|
+
data: { model: "m", content: [], stop_reason: "tool_use", usage: { input_tokens: 1, output_tokens: 2 } },
|
|
148
|
+
});
|
|
149
|
+
send({
|
|
150
|
+
type: "result",
|
|
151
|
+
call_id: "c_1",
|
|
152
|
+
op_id: opB,
|
|
153
|
+
data: { model: "m", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 } },
|
|
154
|
+
});
|
|
155
|
+
send({
|
|
156
|
+
type: "result",
|
|
157
|
+
call_id: "c_2",
|
|
158
|
+
op_id: broadcasts[0].op_id,
|
|
159
|
+
data: { delivered: true },
|
|
160
|
+
});
|
|
161
|
+
await proc.stdin.flush();
|
|
162
|
+
|
|
163
|
+
const stderr = await new Response(proc.stderr).text();
|
|
164
|
+
await proc.exited;
|
|
165
|
+
|
|
166
|
+
const line = stderr.split("\n").find((l) => l.includes("RESULT "));
|
|
167
|
+
expect(line).toBeDefined();
|
|
168
|
+
const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
|
|
169
|
+
|
|
170
|
+
// 3+4. A saw exactly its own events, in order — B's "XX" never leaked.
|
|
171
|
+
expect(out.seen).toEqual([
|
|
172
|
+
{ type: "text_delta", text: "He" },
|
|
173
|
+
{ type: "text_delta", text: "llo" },
|
|
174
|
+
{ type: "tool_use_start", id: "t1", name: "search" },
|
|
175
|
+
{ type: "tool_input_delta", partial_json: '{"q":' },
|
|
176
|
+
]);
|
|
177
|
+
expect(out.other).toEqual([{ type: "text_delta", text: "XX" }]);
|
|
178
|
+
|
|
179
|
+
// 5. Both promises resolved with their OWN response — including B,
|
|
180
|
+
// whose callback threw on every event.
|
|
181
|
+
expect(out.aStop).toBe("tool_use");
|
|
182
|
+
expect(out.bStop).toBe("end_turn");
|
|
183
|
+
|
|
184
|
+
// 6. rooms.broadcast surfaced the host's verdict.
|
|
185
|
+
expect(out.delivered).toBe(true);
|
|
186
|
+
});
|
package/src/runtime.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
|
24
24
|
Llm,
|
|
25
25
|
LlmCompleteRequest,
|
|
26
26
|
LlmCompleteResponse,
|
|
27
|
+
LlmStreamEvent,
|
|
28
|
+
Rooms,
|
|
27
29
|
Connections,
|
|
28
30
|
QueryCtx,
|
|
29
31
|
MutationCtx,
|
|
@@ -175,6 +177,13 @@ const pendingRpcs = new Map<
|
|
|
175
177
|
}
|
|
176
178
|
>();
|
|
177
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Event sinks for in-flight streaming RPCs, keyed by op_id. Separate
|
|
182
|
+
* from `pendingRpcs` because these fire many times and never settle
|
|
183
|
+
* the promise — the terminal `result` message does that.
|
|
184
|
+
*/
|
|
185
|
+
const streamSinks = new Map<string, (event: unknown) => void>();
|
|
186
|
+
|
|
178
187
|
let opSeq = 0;
|
|
179
188
|
function nextOpId(callId: string): string {
|
|
180
189
|
opSeq += 1;
|
|
@@ -307,6 +316,14 @@ function dispatch(line: string): void {
|
|
|
307
316
|
error: err?.message || String(err),
|
|
308
317
|
});
|
|
309
318
|
});
|
|
319
|
+
} else if (msg.type === "llm_event") {
|
|
320
|
+
const ev = msg as unknown as {
|
|
321
|
+
call_id: string;
|
|
322
|
+
op_id?: string;
|
|
323
|
+
event: unknown;
|
|
324
|
+
};
|
|
325
|
+
const sink = streamSinks.get(ev.op_id ?? ev.call_id);
|
|
326
|
+
if (sink) sink(ev.event);
|
|
310
327
|
} else if (msg.type === "result") {
|
|
311
328
|
const res = msg as unknown as ResultMessage & { op_id?: string };
|
|
312
329
|
// Prefer op_id when the host sent it. Fall back to call_id for replies
|
|
@@ -353,6 +370,63 @@ function rpcDb(
|
|
|
353
370
|
});
|
|
354
371
|
}
|
|
355
372
|
|
|
373
|
+
/**
|
|
374
|
+
* RPC that receives interim events before its terminal reply
|
|
375
|
+
* (`ctx.llm.stream`). Mints an op_id like {@link rpcDb}, and also
|
|
376
|
+
* registers an event sink the dispatcher routes `llm_event` messages
|
|
377
|
+
* to. The promise resolves only on the final `result`.
|
|
378
|
+
*
|
|
379
|
+
* Each event RESTARTS the idle timeout: a stream that keeps producing
|
|
380
|
+
* is alive, however long it runs in total, while one whose provider
|
|
381
|
+
* goes silent still trips the safety net. The host's own call deadline
|
|
382
|
+
* remains the real upper bound.
|
|
383
|
+
*/
|
|
384
|
+
function rpcStreaming(
|
|
385
|
+
callId: string,
|
|
386
|
+
msg: Record<string, unknown>,
|
|
387
|
+
onEvent: (event: unknown) => void,
|
|
388
|
+
): Promise<unknown> {
|
|
389
|
+
const opId = nextOpId(callId);
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const fail = () => {
|
|
392
|
+
if (pendingRpcs.has(opId)) {
|
|
393
|
+
pendingRpcs.delete(opId);
|
|
394
|
+
streamSinks.delete(opId);
|
|
395
|
+
reject(
|
|
396
|
+
new Error(
|
|
397
|
+
`RPC timed out after ${RPC_TIMEOUT_MS}ms with no stream activity (call_id=${callId} op_id=${opId})`,
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const entry = {
|
|
403
|
+
resolve: (data: unknown) => {
|
|
404
|
+
streamSinks.delete(opId);
|
|
405
|
+
resolve(data);
|
|
406
|
+
},
|
|
407
|
+
reject: (err: Error) => {
|
|
408
|
+
streamSinks.delete(opId);
|
|
409
|
+
reject(err);
|
|
410
|
+
},
|
|
411
|
+
timeout: setTimeout(fail, RPC_TIMEOUT_MS),
|
|
412
|
+
};
|
|
413
|
+
pendingRpcs.set(opId, entry);
|
|
414
|
+
streamSinks.set(opId, (event) => {
|
|
415
|
+
clearTimeout(entry.timeout);
|
|
416
|
+
entry.timeout = setTimeout(fail, RPC_TIMEOUT_MS);
|
|
417
|
+
// A throwing sink must not abandon the pending RPC — the host is
|
|
418
|
+
// still going to send a terminal result, and swallowing here
|
|
419
|
+
// keeps the handler's own error the one that surfaces.
|
|
420
|
+
try {
|
|
421
|
+
onEvent(event);
|
|
422
|
+
} catch {
|
|
423
|
+
/* handler-side callback error; the stream continues */
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
send({ ...msg, call_id: callId, op_id: opId });
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
356
430
|
/**
|
|
357
431
|
* RPC for non-db protocol replies (scheduler.runAfter, nested function
|
|
358
432
|
* calls, etc.) where at-most-one in-flight per call_id is the right
|
|
@@ -657,7 +731,7 @@ function buildEmail(callId: string): EmailSender {
|
|
|
657
731
|
* vs `PROVIDER_HTTP_429` vs `MODEL_NOT_ALLOWED` without parsing message
|
|
658
732
|
* strings.
|
|
659
733
|
*/
|
|
660
|
-
function buildLlm(callId: string): Llm {
|
|
734
|
+
export function buildLlm(callId: string): Llm {
|
|
661
735
|
return {
|
|
662
736
|
async complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse> {
|
|
663
737
|
return (await rpc(callId, {
|
|
@@ -665,6 +739,36 @@ function buildLlm(callId: string): Llm {
|
|
|
665
739
|
request,
|
|
666
740
|
})) as LlmCompleteResponse;
|
|
667
741
|
},
|
|
742
|
+
|
|
743
|
+
async stream(
|
|
744
|
+
request: LlmCompleteRequest,
|
|
745
|
+
onEvent: (event: LlmStreamEvent) => void,
|
|
746
|
+
): Promise<LlmCompleteResponse> {
|
|
747
|
+
return (await rpcStreaming(
|
|
748
|
+
callId,
|
|
749
|
+
{ type: "llm_stream", request },
|
|
750
|
+
(event) => onEvent(event as LlmStreamEvent),
|
|
751
|
+
)) as LlmCompleteResponse;
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Build the room broadcaster. One `room_broadcast` message per call;
|
|
758
|
+
* the host fans the event out through the same RoomManager the
|
|
759
|
+
* `/api/rooms/*` routes use, so a server push and a member push land
|
|
760
|
+
* on subscribers identically.
|
|
761
|
+
*/
|
|
762
|
+
export function buildRooms(callId: string): Rooms {
|
|
763
|
+
return {
|
|
764
|
+
async broadcast(room: string, topic: string, data?: unknown) {
|
|
765
|
+
return (await rpcDb(callId, {
|
|
766
|
+
type: "room_broadcast",
|
|
767
|
+
room,
|
|
768
|
+
topic,
|
|
769
|
+
data: data ?? {},
|
|
770
|
+
})) as { delivered: boolean };
|
|
771
|
+
},
|
|
668
772
|
};
|
|
669
773
|
}
|
|
670
774
|
|
|
@@ -740,6 +844,7 @@ function buildActionCtx(
|
|
|
740
844
|
scheduler: Scheduler,
|
|
741
845
|
email: EmailSender,
|
|
742
846
|
llm: Llm,
|
|
847
|
+
rooms: Rooms,
|
|
743
848
|
connections: Connections,
|
|
744
849
|
request?: unknown
|
|
745
850
|
): ActionCtx {
|
|
@@ -762,6 +867,7 @@ function buildActionCtx(
|
|
|
762
867
|
scheduler,
|
|
763
868
|
email,
|
|
764
869
|
llm,
|
|
870
|
+
rooms,
|
|
765
871
|
connections,
|
|
766
872
|
env: process.env as Record<string, string>,
|
|
767
873
|
async runQuery(fnName, args) {
|
|
@@ -866,6 +972,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
866
972
|
const scheduler = buildScheduler(msg.call_id);
|
|
867
973
|
const email = buildEmail(msg.call_id);
|
|
868
974
|
const llm = buildLlm(msg.call_id);
|
|
975
|
+
const rooms = buildRooms(msg.call_id);
|
|
869
976
|
const connections = buildConnections(msg.call_id);
|
|
870
977
|
|
|
871
978
|
// Normalize the Rust-side auth envelope (snake_case) to the camelCase
|
|
@@ -942,6 +1049,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
942
1049
|
scheduler,
|
|
943
1050
|
env,
|
|
944
1051
|
llm,
|
|
1052
|
+
rooms,
|
|
945
1053
|
connections,
|
|
946
1054
|
error(code, message) {
|
|
947
1055
|
const err = new Error(message);
|
|
@@ -962,6 +1070,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
962
1070
|
scheduler,
|
|
963
1071
|
email,
|
|
964
1072
|
llm,
|
|
1073
|
+
rooms,
|
|
965
1074
|
connections,
|
|
966
1075
|
(msg as unknown as { request?: unknown }).request,
|
|
967
1076
|
);
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -279,7 +279,12 @@ const tmpDirs: string[] = [];
|
|
|
279
279
|
function makeApp(): string {
|
|
280
280
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-og-"));
|
|
281
281
|
tmpDirs.push(dir);
|
|
282
|
-
|
|
282
|
+
// Only the FIRST call records the restore target. A second makeApp()
|
|
283
|
+
// in the same test would otherwise capture the previous temp dir,
|
|
284
|
+
// and afterEach would chdir back into it right before deleting it —
|
|
285
|
+
// leaving the whole process on a deleted cwd, which breaks every
|
|
286
|
+
// later test file that spawns a subprocess.
|
|
287
|
+
if (prevCwd === null) prevCwd = process.cwd();
|
|
283
288
|
process.chdir(dir);
|
|
284
289
|
return dir;
|
|
285
290
|
}
|
package/src/types.ts
CHANGED
|
@@ -353,6 +353,83 @@ export interface Llm {
|
|
|
353
353
|
* `PROVIDER_UNREACHABLE`, `INVALID_REQUEST`.
|
|
354
354
|
*/
|
|
355
355
|
complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse>;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Streaming completion. `onEvent` fires for each event as the
|
|
359
|
+
* provider emits it; the promise resolves with the same assembled
|
|
360
|
+
* response `complete` returns, so a tool-use loop can inspect
|
|
361
|
+
* `stop_reason` after the text has already been streamed out.
|
|
362
|
+
*
|
|
363
|
+
* The typical agent shape pumps deltas straight to the client:
|
|
364
|
+
*
|
|
365
|
+
* ```ts
|
|
366
|
+
* const res = await ctx.llm.stream(
|
|
367
|
+
* { messages, tools },
|
|
368
|
+
* (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); },
|
|
369
|
+
* );
|
|
370
|
+
* if (res.stop_reason === "tool_use") { ...run tools, loop... }
|
|
371
|
+
* ```
|
|
372
|
+
*
|
|
373
|
+
* Streaming does NOT extend the function's call deadline — it is an
|
|
374
|
+
* absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
|
|
375
|
+
* default). A long agent run must declare its own `timeout` on the
|
|
376
|
+
* function def.
|
|
377
|
+
*
|
|
378
|
+
* Same errors and same gating as {@link Llm.complete} — including
|
|
379
|
+
* the model allowlist, so streaming can't be used to reach a model
|
|
380
|
+
* `complete` would refuse.
|
|
381
|
+
*/
|
|
382
|
+
stream(
|
|
383
|
+
request: LlmCompleteRequest,
|
|
384
|
+
onEvent: (event: LlmStreamEvent) => void,
|
|
385
|
+
): Promise<LlmCompleteResponse>;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* One event from an in-flight {@link Llm.stream} call.
|
|
390
|
+
*
|
|
391
|
+
* `tool_use_start` opens a tool call; the `tool_input_delta` events
|
|
392
|
+
* that follow carry its arguments as raw JSON fragments — concatenate
|
|
393
|
+
* them and parse once, rather than parsing each fragment. `done`
|
|
394
|
+
* always fires last, including on a partial failure.
|
|
395
|
+
*/
|
|
396
|
+
export type LlmStreamEvent =
|
|
397
|
+
| { type: "text_delta"; text: string }
|
|
398
|
+
| { type: "tool_use_start"; id: string; name: string }
|
|
399
|
+
| { type: "tool_input_delta"; partial_json: string }
|
|
400
|
+
| {
|
|
401
|
+
type: "done";
|
|
402
|
+
stop_reason: string;
|
|
403
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Server-originated realtime push. Broadcasts an event to every
|
|
408
|
+
* subscriber of a presence room — the same rooms clients join with
|
|
409
|
+
* `useRoom(roomId, userId)`, and the same delivery path a member's
|
|
410
|
+
* `broadcast()` uses.
|
|
411
|
+
*
|
|
412
|
+
* This is the surface for streaming agent output that must survive a
|
|
413
|
+
* closed tab or reach a second device: write tokens to the room, and
|
|
414
|
+
* every watcher gets them, not just the caller holding the HTTP
|
|
415
|
+
* response. `ctx.stream.write` reaches only the one client that made
|
|
416
|
+
* the call.
|
|
417
|
+
*
|
|
418
|
+
* Not available in queries — a reactive handler re-runs on every dep
|
|
419
|
+
* change, which would re-broadcast each time.
|
|
420
|
+
*/
|
|
421
|
+
export interface Rooms {
|
|
422
|
+
/**
|
|
423
|
+
* Push `data` to every subscriber of `room` under `topic`.
|
|
424
|
+
* Resolves `{ delivered: false }` when the room has no members —
|
|
425
|
+
* broadcasting into an empty room is a no-op, not an error, so an
|
|
426
|
+
* agent doesn't need to know whether anyone is watching.
|
|
427
|
+
*/
|
|
428
|
+
broadcast(
|
|
429
|
+
room: string,
|
|
430
|
+
topic: string,
|
|
431
|
+
data?: unknown,
|
|
432
|
+
): Promise<{ delivered: boolean }>;
|
|
356
433
|
}
|
|
357
434
|
|
|
358
435
|
export interface LlmMessage {
|
|
@@ -563,6 +640,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
|
563
640
|
env: Record<string, string>;
|
|
564
641
|
/** Provider-abstracted LLM client. */
|
|
565
642
|
llm: Llm;
|
|
643
|
+
/** Server-originated realtime push — see {@link Rooms}. */
|
|
644
|
+
rooms: Rooms;
|
|
566
645
|
/** Per-user OAuth connection registry. */
|
|
567
646
|
connections: Connections;
|
|
568
647
|
/** Create a typed error that triggers rollback. */
|
|
@@ -580,6 +659,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
580
659
|
email: EmailSender;
|
|
581
660
|
/** Provider-abstracted LLM client. */
|
|
582
661
|
llm: Llm;
|
|
662
|
+
/** Server-originated realtime push — see {@link Rooms}. */
|
|
663
|
+
rooms: Rooms;
|
|
583
664
|
/** Per-user OAuth connection registry. */
|
|
584
665
|
connections: Connections;
|
|
585
666
|
/** Environment variables / secrets. */
|