@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/dist/agent-internals.d.ts +13 -0
- package/dist/agent.d.ts +103 -0
- package/dist/index.d.ts +2 -0
- package/dist/types.d.ts +30 -6
- package/package.json +1 -1
- package/src/agent-internals.ts +161 -0
- package/src/agent.test.ts +472 -0
- package/src/agent.ts +430 -0
- package/src/index.ts +12 -0
- package/src/runtime.ts +28 -2
- package/src/types.ts +31 -6
|
@@ -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;
|
package/dist/agent.d.ts
ADDED
|
@@ -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
|
|
456
|
-
*
|
|
457
|
-
* every watcher gets them, not just the caller
|
|
458
|
-
* response.
|
|
459
|
-
*
|
|
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
|
@@ -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
|
+
}
|