@pylonsync/functions 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +27 -6
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +58 -5
- package/package.json +1 -1
- package/src/agent-internals.ts +5 -0
- package/src/agent.test.ts +205 -0
- package/src/agent.ts +220 -99
- package/src/index.ts +3 -0
- package/src/runtime-llm-stream.test.ts +85 -0
- package/src/runtime.ts +136 -5
- package/src/types.ts +55 -4
package/dist/agent.d.ts
CHANGED
|
@@ -36,6 +36,20 @@
|
|
|
36
36
|
* including tool calls, with zero extra plumbing.
|
|
37
37
|
*/
|
|
38
38
|
import type { ActionCtx, AnyValidator, FnDefinition, ValidatorSchema } from "./types";
|
|
39
|
+
/** The run a tool call belongs to: the third argument of every tool
|
|
40
|
+
* handler. */
|
|
41
|
+
export interface AgentToolRun {
|
|
42
|
+
runId: string;
|
|
43
|
+
/** The agent's name (its file name in `functions/`). */
|
|
44
|
+
agent: string;
|
|
45
|
+
/** The user the run belongs to. */
|
|
46
|
+
userId: string | null;
|
|
47
|
+
/** The `context` server code passed to `ctx.agents.run`, or the one
|
|
48
|
+
* stored on the run by an earlier `ctx.agents.run`. `null` when the
|
|
49
|
+
* run has none (a run a client started with `streamFn`). Set only by
|
|
50
|
+
* server code, so a tool can trust it to identify what it acts on. */
|
|
51
|
+
context: Record<string, unknown> | null;
|
|
52
|
+
}
|
|
39
53
|
/** One tool an agent can call. */
|
|
40
54
|
export interface AgentTool {
|
|
41
55
|
/** Shown to the model — say when to use the tool, not how it works. */
|
|
@@ -45,10 +59,11 @@ export interface AgentTool {
|
|
|
45
59
|
* a tool_result error the model can react to. Omit for no-arg tools. */
|
|
46
60
|
args?: ValidatorSchema;
|
|
47
61
|
/** Runs with the agent action's ctx (runQuery/runMutation, llm,
|
|
48
|
-
* email, …).
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
62
|
+
* email, …). `run` identifies the run, including the server-set
|
|
63
|
+
* `context` from `ctx.agents.run`. The return value is
|
|
64
|
+
* JSON-serialized into the tool_result the model sees. Throwing marks
|
|
65
|
+
* the result is_error — the model sees the message and can recover. */
|
|
66
|
+
handler: (ctx: ActionCtx, input: Record<string, unknown>, run: AgentToolRun) => unknown;
|
|
52
67
|
}
|
|
53
68
|
export interface AgentDefinition {
|
|
54
69
|
/** System prompt. A function receives the ctx + call args for
|
|
@@ -83,8 +98,10 @@ export interface AgentCallArgs {
|
|
|
83
98
|
/** Optional display title, stored on new runs. */
|
|
84
99
|
title?: string;
|
|
85
100
|
/** Ask the run to stop. Requires `runId`, ignores `input`, and
|
|
86
|
-
* returns as soon as the request is recorded
|
|
87
|
-
*
|
|
101
|
+
* returns as soon as the request is recorded. The running loop
|
|
102
|
+
* notices within a few seconds: a model response in progress is
|
|
103
|
+
* aborted (the provider stops generating), and a running tool sees
|
|
104
|
+
* `ctx.signal` abort. */
|
|
88
105
|
cancel?: boolean;
|
|
89
106
|
}
|
|
90
107
|
/** What the agent action resolves with (also the `event: result`
|
|
@@ -115,5 +132,9 @@ export declare function validatorSchemaToJsonSchema(schema: ValidatorSchema): Re
|
|
|
115
132
|
/** Marker so the SDK's discoverFunctions can detect agents and inject
|
|
116
133
|
* the AgentRun/AgentMessage entities into the manifest. */
|
|
117
134
|
export declare const AGENT_MARKER = "__pylonAgent";
|
|
135
|
+
/** ctx key the runtime sets when server code started this call with
|
|
136
|
+
* `ctx.agents.run`: `{ context }`. A symbol, so no JSON a client sends
|
|
137
|
+
* can set it. */
|
|
138
|
+
export declare const AGENT_INVOCATION: unique symbol;
|
|
118
139
|
export declare function agent(def: AgentDefinition): FnDefinition<AgentCallArgs, AgentResult>;
|
|
119
140
|
export declare function isAgentDefinition(value: unknown): boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -22,9 +22,9 @@ export { v } from "./validators";
|
|
|
22
22
|
export { workflow } from "./workflows";
|
|
23
23
|
export type { RawResponse, RawResponseInit } from "./response";
|
|
24
24
|
export { agent, isAgentDefinition, validatorToJsonSchema, validatorSchemaToJsonSchema, } from "./agent";
|
|
25
|
-
export type { AgentDefinition, AgentTool, AgentCallArgs, AgentResult, } from "./agent";
|
|
25
|
+
export type { AgentDefinition, AgentTool, AgentToolRun, AgentCallArgs, AgentResult, } from "./agent";
|
|
26
26
|
export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
|
|
27
27
|
export { resetDb, installTestIsolation } from "./testing";
|
|
28
28
|
export { slugifyName, availableSlug } from "./slugify";
|
|
29
29
|
export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
|
|
30
|
-
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
|
|
30
|
+
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
|
package/dist/types.d.ts
CHANGED
|
@@ -427,16 +427,24 @@ export interface Llm {
|
|
|
427
427
|
* if (res.stop_reason === "tool_use") { ...run tools, loop... }
|
|
428
428
|
* ```
|
|
429
429
|
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
430
|
+
* The function's call timeout (PYLON_FN_CALL_TIMEOUT, 30s default, or
|
|
431
|
+
* the function's own `timeout`) is an idle timeout. A running stream
|
|
432
|
+
* counts as activity, but no call outlives 10× its timeout, so a long
|
|
433
|
+
* agent run should still declare its own `timeout`.
|
|
434
|
+
*
|
|
435
|
+
* Pass `{ signal }` to stop a stream early. When the signal aborts,
|
|
436
|
+
* the promise rejects with `err.code === "LLM_CANCELLED"` and the
|
|
437
|
+
* server closes the provider connection, so the model stops
|
|
438
|
+
* generating (and billing) tokens. A stream still running when the
|
|
439
|
+
* function returns or times out is cancelled the same way.
|
|
434
440
|
*
|
|
435
441
|
* Same errors and same gating as {@link Llm.complete} — including
|
|
436
442
|
* the model allowlist, so streaming can't be used to reach a model
|
|
437
443
|
* `complete` would refuse.
|
|
438
444
|
*/
|
|
439
|
-
stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void
|
|
445
|
+
stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void, options?: {
|
|
446
|
+
signal?: AbortSignal;
|
|
447
|
+
}): Promise<LlmCompleteResponse>;
|
|
440
448
|
/**
|
|
441
449
|
* Batch-embed texts via the configured embeddings provider. One
|
|
442
450
|
* embedding per input, in input order. Pair with a
|
|
@@ -1237,6 +1245,49 @@ export interface Domains {
|
|
|
1237
1245
|
* itself serve the app — attach a host with `add()` afterward. */
|
|
1238
1246
|
register(domainName: string, opts?: RegisterDomainOptions): Promise<RegisteredDomainResult>;
|
|
1239
1247
|
}
|
|
1248
|
+
/** Options for {@link Agents.run}. */
|
|
1249
|
+
export interface AgentRunOptions {
|
|
1250
|
+
/** The message for this turn. */
|
|
1251
|
+
input: string;
|
|
1252
|
+
/** Continue this run. It must belong to `as.userId` and this agent. */
|
|
1253
|
+
runId?: string;
|
|
1254
|
+
/** Display title, stored on a new run. */
|
|
1255
|
+
title?: string;
|
|
1256
|
+
/**
|
|
1257
|
+
* The user the run belongs to and runs as. The run shows up in that
|
|
1258
|
+
* user's synced `AgentRun`/`AgentMessage` rows. `admin: true` also
|
|
1259
|
+
* gives the run admin rights (needed for an agent declared
|
|
1260
|
+
* `auth: "admin"`), and is allowed only when the caller is an admin.
|
|
1261
|
+
*/
|
|
1262
|
+
as: {
|
|
1263
|
+
userId: string;
|
|
1264
|
+
admin?: boolean;
|
|
1265
|
+
};
|
|
1266
|
+
/**
|
|
1267
|
+
* JSON the agent's tool handlers receive as `run.context` (their third
|
|
1268
|
+
* argument), for example the conversation a webhook turn belongs to.
|
|
1269
|
+
* Stored on the run as `AgentRun.context`, so later turns of the run
|
|
1270
|
+
* see it too. The run's owner can read it; do not put secrets in it.
|
|
1271
|
+
*/
|
|
1272
|
+
context?: Record<string, unknown>;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Run an `agent()` from server code. See {@link ActionCtx.agents}.
|
|
1276
|
+
*/
|
|
1277
|
+
export interface Agents {
|
|
1278
|
+
/**
|
|
1279
|
+
* Run agent `name` (its file name in `functions/`) as `options.as`.
|
|
1280
|
+
* Resolves with the agent's result once the turn finishes, like a
|
|
1281
|
+
* `streamFn` call's final `result` event. The agent's tokens are not
|
|
1282
|
+
* streamed to this caller; they are in the run's `AgentMessage` rows.
|
|
1283
|
+
*
|
|
1284
|
+
* Only server code can do this: the calling action must be
|
|
1285
|
+
* `internal: true` (jobs, workflows, scheduled work) or run with admin
|
|
1286
|
+
* rights (an admin session, or a webhook after `ctx.auth.elevate`).
|
|
1287
|
+
* Otherwise it throws `AGENT_RUN_FORBIDDEN`.
|
|
1288
|
+
*/
|
|
1289
|
+
run(name: string, options: AgentRunOptions): Promise<import("./agent").AgentResult>;
|
|
1290
|
+
}
|
|
1240
1291
|
export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
1241
1292
|
auth: AuthInfo<R>;
|
|
1242
1293
|
stream: Stream;
|
|
@@ -1253,6 +1304,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
1253
1304
|
workflows: Workflows;
|
|
1254
1305
|
/** Application audit log — see {@link Audit}. */
|
|
1255
1306
|
audit: Audit;
|
|
1307
|
+
/** Run an `agent()` as a named user from server code — see {@link Agents}. */
|
|
1308
|
+
agents: Agents;
|
|
1256
1309
|
/** Attach / register custom domains for your app's OWN end-customers
|
|
1257
1310
|
* (platform domains) — see {@link Domains}. Cloud-only. */
|
|
1258
1311
|
domains: Domains;
|
package/package.json
CHANGED
package/src/agent-internals.ts
CHANGED
|
@@ -128,6 +128,10 @@ export function registerAgentInternals(
|
|
|
128
128
|
pendingInput: [],
|
|
129
129
|
cancelRequested: false,
|
|
130
130
|
steps: 0,
|
|
131
|
+
// Only written when present, so an app that declares its own
|
|
132
|
+
// AgentRun without a `context` column keeps working until it
|
|
133
|
+
// uses ctx.agents.run with a context.
|
|
134
|
+
...(args.context != null ? { context: args.context } : {}),
|
|
131
135
|
createdAt: nowIso(),
|
|
132
136
|
updatedAt: nowIso(),
|
|
133
137
|
});
|
|
@@ -188,6 +192,7 @@ export function registerAgentInternals(
|
|
|
188
192
|
if (args.error !== undefined) patch.error = args.error;
|
|
189
193
|
if (args.streamId !== undefined) patch.streamId = args.streamId;
|
|
190
194
|
if (args.steps !== undefined) patch.steps = Number(args.steps) || 0;
|
|
195
|
+
if (args.context != null) patch.context = args.context;
|
|
191
196
|
// Claiming the run starts a new generation, which supersedes
|
|
192
197
|
// a cancel left set by one that died before acting on it.
|
|
193
198
|
// Terminal statuses clear it because it has been honoured.
|
package/src/agent.test.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
6
|
import {
|
|
7
|
+
AGENT_INVOCATION,
|
|
7
8
|
agent,
|
|
8
9
|
isAgentDefinition,
|
|
9
10
|
validatorSchemaToJsonSchema,
|
|
@@ -793,6 +794,210 @@ describe("agent loop", () => {
|
|
|
793
794
|
expect(state.status).toBe("cancelled");
|
|
794
795
|
}, 10_000);
|
|
795
796
|
|
|
797
|
+
test("a cancel raised while the model streams aborts the stream and keeps the partial text", async () => {
|
|
798
|
+
const def = agent({});
|
|
799
|
+
const { ctx, state, writes, streamed } = mockCtx([]);
|
|
800
|
+
let aborted = false;
|
|
801
|
+
let deltasAfterAbort = 0;
|
|
802
|
+
// A provider that keeps generating until the signal aborts, the way
|
|
803
|
+
// the host's ctx.llm.stream behaves.
|
|
804
|
+
(ctx.llm as { stream: unknown }).stream = (
|
|
805
|
+
_req: LlmCompleteRequest,
|
|
806
|
+
onEvent: (e: LlmStreamEvent) => void,
|
|
807
|
+
options?: { signal?: AbortSignal },
|
|
808
|
+
) =>
|
|
809
|
+
new Promise<LlmCompleteResponse>((_resolve, reject) => {
|
|
810
|
+
const timer = setInterval(() => {
|
|
811
|
+
if (aborted) deltasAfterAbort += 1;
|
|
812
|
+
onEvent({ type: "text_delta", text: "tok " });
|
|
813
|
+
}, 20);
|
|
814
|
+
options?.signal?.addEventListener("abort", () => {
|
|
815
|
+
aborted = true;
|
|
816
|
+
clearInterval(timer);
|
|
817
|
+
const err = new Error("ctx.llm.stream was cancelled");
|
|
818
|
+
(err as { code?: string }).code = "LLM_CANCELLED";
|
|
819
|
+
reject(err);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
setTimeout(() => {
|
|
823
|
+
state.cancelRequested = true;
|
|
824
|
+
state.pendingInput.push("queued while generating");
|
|
825
|
+
}, 50);
|
|
826
|
+
|
|
827
|
+
const result = await (def.handler as unknown as (
|
|
828
|
+
c: ActionCtx,
|
|
829
|
+
a: Record<string, unknown>,
|
|
830
|
+
) => Promise<Record<string, unknown>>)(ctx, {
|
|
831
|
+
input: "write a long essay",
|
|
832
|
+
__agentName: "helper",
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
expect(result.cancelled).toBe(true);
|
|
836
|
+
expect(aborted).toBe(true);
|
|
837
|
+
expect(deltasAfterAbort).toBe(0);
|
|
838
|
+
expect(state.status).toBe("cancelled");
|
|
839
|
+
const tokens = streamed.filter((s) => s.event === undefined).length;
|
|
840
|
+
expect(tokens).toBeGreaterThan(0);
|
|
841
|
+
// The text the user saw is persisted as the assistant turn, and the
|
|
842
|
+
// queued message is not lost.
|
|
843
|
+
const appended = writes.filter((w) => w.op === "appendMessage");
|
|
844
|
+
expect(appended.map((w) => w.role)).toEqual(["user", "assistant", "user"]);
|
|
845
|
+
expect(appended[1].content).toEqual([
|
|
846
|
+
{ type: "text", text: "tok ".repeat(tokens) },
|
|
847
|
+
]);
|
|
848
|
+
expect(appended[2].content).toBe("queued while generating");
|
|
849
|
+
expect(result.text).toBe("tok ".repeat(tokens));
|
|
850
|
+
}, 10_000);
|
|
851
|
+
|
|
852
|
+
test("ctx.agents.run context reaches every tool and is stored on the new run", async () => {
|
|
853
|
+
const seen: unknown[] = [];
|
|
854
|
+
const def = agent({
|
|
855
|
+
tools: {
|
|
856
|
+
whoami: {
|
|
857
|
+
description: "who",
|
|
858
|
+
handler: async (_ctx, _input, run) => {
|
|
859
|
+
seen.push(run);
|
|
860
|
+
return "ok";
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
const { ctx, writes } = mockCtx([wantsTool("whoami", {}), done("bye")]);
|
|
866
|
+
(ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
|
|
867
|
+
context: { conversationId: "conv-1" },
|
|
868
|
+
};
|
|
869
|
+
await (def.handler as unknown as (
|
|
870
|
+
c: ActionCtx,
|
|
871
|
+
a: Record<string, unknown>,
|
|
872
|
+
) => Promise<unknown>)(ctx, { input: "hi", __agentName: "helper" });
|
|
873
|
+
|
|
874
|
+
expect(seen).toEqual([
|
|
875
|
+
{
|
|
876
|
+
runId: "run_1",
|
|
877
|
+
agent: "helper",
|
|
878
|
+
userId: "u1",
|
|
879
|
+
context: { conversationId: "conv-1" },
|
|
880
|
+
},
|
|
881
|
+
]);
|
|
882
|
+
expect(writes.find((w) => w.op === "createRun")?.context).toEqual({
|
|
883
|
+
conversationId: "conv-1",
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test("a continued run uses its stored context; a new invocation context replaces it", async () => {
|
|
888
|
+
const runs: unknown[] = [];
|
|
889
|
+
const def = agent({
|
|
890
|
+
tools: {
|
|
891
|
+
whoami: {
|
|
892
|
+
description: "who",
|
|
893
|
+
handler: async (_ctx, _input, run) => {
|
|
894
|
+
runs.push(run.context);
|
|
895
|
+
return "ok";
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
const call = (ctx: ActionCtx) =>
|
|
901
|
+
(def.handler as unknown as (
|
|
902
|
+
c: ActionCtx,
|
|
903
|
+
a: Record<string, unknown>,
|
|
904
|
+
) => Promise<unknown>)(ctx, {
|
|
905
|
+
input: "again",
|
|
906
|
+
runId: "run_7",
|
|
907
|
+
__agentName: "helper",
|
|
908
|
+
});
|
|
909
|
+
const stored = { run: { userId: "owner", context: { conversationId: "old" } } };
|
|
910
|
+
|
|
911
|
+
// A client continuing over HTTP: no invocation, the stored context.
|
|
912
|
+
const first = mockCtx([wantsTool("whoami", {}), done("bye")], stored);
|
|
913
|
+
await call(first.ctx);
|
|
914
|
+
expect(
|
|
915
|
+
first.writes.find((w) => w.op === "setStatus" && w.status === "running")
|
|
916
|
+
?.context,
|
|
917
|
+
).toBeUndefined();
|
|
918
|
+
|
|
919
|
+
// Server code continuing with a new context: it wins and is stored.
|
|
920
|
+
const second = mockCtx([wantsTool("whoami", {}), done("bye")], stored);
|
|
921
|
+
(second.ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
|
|
922
|
+
context: { conversationId: "new" },
|
|
923
|
+
};
|
|
924
|
+
await call(second.ctx);
|
|
925
|
+
expect(
|
|
926
|
+
second.writes.find((w) => w.op === "setStatus" && w.status === "running")
|
|
927
|
+
?.context,
|
|
928
|
+
).toEqual({ conversationId: "new" });
|
|
929
|
+
|
|
930
|
+
expect(runs).toEqual([{ conversationId: "old" }, { conversationId: "new" }]);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("ctx.agents.run refuses a live run instead of queueing, and ignores a non-object context", async () => {
|
|
934
|
+
const seen: unknown[] = [];
|
|
935
|
+
const def = agent({
|
|
936
|
+
tools: {
|
|
937
|
+
whoami: {
|
|
938
|
+
description: "who",
|
|
939
|
+
handler: async (_ctx, _input, run) => {
|
|
940
|
+
seen.push(run.context);
|
|
941
|
+
return "ok";
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
const call = (ctx: ActionCtx, runId?: string) =>
|
|
947
|
+
(def.handler as unknown as (
|
|
948
|
+
c: ActionCtx,
|
|
949
|
+
a: Record<string, unknown>,
|
|
950
|
+
) => Promise<unknown>)(ctx, {
|
|
951
|
+
input: "hi",
|
|
952
|
+
...(runId ? { runId } : {}),
|
|
953
|
+
__agentName: "helper",
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const busy = mockCtx([], {
|
|
957
|
+
run: { status: "running", updatedAt: new Date().toISOString() },
|
|
958
|
+
});
|
|
959
|
+
(busy.ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
|
|
960
|
+
context: { conversationId: "c" },
|
|
961
|
+
};
|
|
962
|
+
await expect(call(busy.ctx, "run_7")).rejects.toMatchObject({ code: "RUN_BUSY" });
|
|
963
|
+
expect(busy.writes.some((w) => w.op === "enqueueInput")).toBe(false);
|
|
964
|
+
|
|
965
|
+
const odd = mockCtx([wantsTool("whoami", {}), done("bye")]);
|
|
966
|
+
(odd.ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
|
|
967
|
+
context: ["not", "an", "object"],
|
|
968
|
+
};
|
|
969
|
+
await call(odd.ctx);
|
|
970
|
+
expect(seen).toEqual([null]);
|
|
971
|
+
expect(odd.writes.find((w) => w.op === "createRun")?.context).toBeUndefined();
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("a run a client started has no context, and args cannot supply one", async () => {
|
|
975
|
+
let context: unknown = "unset";
|
|
976
|
+
const def = agent({
|
|
977
|
+
tools: {
|
|
978
|
+
whoami: {
|
|
979
|
+
description: "who",
|
|
980
|
+
handler: async (_ctx, _input, run) => {
|
|
981
|
+
context = run.context;
|
|
982
|
+
return "ok";
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
const { ctx, writes } = mockCtx([wantsTool("whoami", {}), done("bye")]);
|
|
988
|
+
await (def.handler as unknown as (
|
|
989
|
+
c: ActionCtx,
|
|
990
|
+
a: Record<string, unknown>,
|
|
991
|
+
) => Promise<unknown>)(ctx, {
|
|
992
|
+
input: "hi",
|
|
993
|
+
__agentName: "helper",
|
|
994
|
+
context: { conversationId: "forged" },
|
|
995
|
+
__agentContext: { conversationId: "forged" },
|
|
996
|
+
});
|
|
997
|
+
expect(context).toBeNull();
|
|
998
|
+
expect(writes.find((w) => w.op === "createRun")?.context).toBeUndefined();
|
|
999
|
+
});
|
|
1000
|
+
|
|
796
1001
|
test("the default step budget is high enough for a real tool loop", async () => {
|
|
797
1002
|
// 16 was the old default and is nowhere near enough for an agent
|
|
798
1003
|
// that reads files before answering.
|
package/src/agent.ts
CHANGED
|
@@ -52,6 +52,21 @@ import type {
|
|
|
52
52
|
// Public types
|
|
53
53
|
// ---------------------------------------------------------------------------
|
|
54
54
|
|
|
55
|
+
/** The run a tool call belongs to: the third argument of every tool
|
|
56
|
+
* handler. */
|
|
57
|
+
export interface AgentToolRun {
|
|
58
|
+
runId: string;
|
|
59
|
+
/** The agent's name (its file name in `functions/`). */
|
|
60
|
+
agent: string;
|
|
61
|
+
/** The user the run belongs to. */
|
|
62
|
+
userId: string | null;
|
|
63
|
+
/** The `context` server code passed to `ctx.agents.run`, or the one
|
|
64
|
+
* stored on the run by an earlier `ctx.agents.run`. `null` when the
|
|
65
|
+
* run has none (a run a client started with `streamFn`). Set only by
|
|
66
|
+
* server code, so a tool can trust it to identify what it acts on. */
|
|
67
|
+
context: Record<string, unknown> | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
/** One tool an agent can call. */
|
|
56
71
|
export interface AgentTool {
|
|
57
72
|
/** Shown to the model — say when to use the tool, not how it works. */
|
|
@@ -61,10 +76,15 @@ export interface AgentTool {
|
|
|
61
76
|
* a tool_result error the model can react to. Omit for no-arg tools. */
|
|
62
77
|
args?: ValidatorSchema;
|
|
63
78
|
/** Runs with the agent action's ctx (runQuery/runMutation, llm,
|
|
64
|
-
* email, …).
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
79
|
+
* email, …). `run` identifies the run, including the server-set
|
|
80
|
+
* `context` from `ctx.agents.run`. The return value is
|
|
81
|
+
* JSON-serialized into the tool_result the model sees. Throwing marks
|
|
82
|
+
* the result is_error — the model sees the message and can recover. */
|
|
83
|
+
handler: (
|
|
84
|
+
ctx: ActionCtx,
|
|
85
|
+
input: Record<string, unknown>,
|
|
86
|
+
run: AgentToolRun,
|
|
87
|
+
) => unknown;
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
export interface AgentDefinition {
|
|
@@ -101,8 +121,10 @@ export interface AgentCallArgs {
|
|
|
101
121
|
/** Optional display title, stored on new runs. */
|
|
102
122
|
title?: string;
|
|
103
123
|
/** Ask the run to stop. Requires `runId`, ignores `input`, and
|
|
104
|
-
* returns as soon as the request is recorded
|
|
105
|
-
*
|
|
124
|
+
* returns as soon as the request is recorded. The running loop
|
|
125
|
+
* notices within a few seconds: a model response in progress is
|
|
126
|
+
* aborted (the provider stops generating), and a running tool sees
|
|
127
|
+
* `ctx.signal` abort. */
|
|
106
128
|
cancel?: boolean;
|
|
107
129
|
}
|
|
108
130
|
|
|
@@ -192,6 +214,26 @@ export function validatorSchemaToJsonSchema(
|
|
|
192
214
|
* the AgentRun/AgentMessage entities into the manifest. */
|
|
193
215
|
export const AGENT_MARKER = "__pylonAgent";
|
|
194
216
|
|
|
217
|
+
/** ctx key the runtime sets when server code started this call with
|
|
218
|
+
* `ctx.agents.run`: `{ context }`. A symbol, so no JSON a client sends
|
|
219
|
+
* can set it. */
|
|
220
|
+
export const AGENT_INVOCATION = Symbol.for("pylon.agentInvocation");
|
|
221
|
+
|
|
222
|
+
interface AgentInvocation {
|
|
223
|
+
context: Record<string, unknown> | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function invocationOf(ctx: ActionCtx): AgentInvocation | null {
|
|
227
|
+
const value = (ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION];
|
|
228
|
+
return (value as AgentInvocation | undefined) ?? null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function asContext(value: unknown): Record<string, unknown> | null {
|
|
232
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
233
|
+
? (value as Record<string, unknown>)
|
|
234
|
+
: null;
|
|
235
|
+
}
|
|
236
|
+
|
|
195
237
|
export function agent(def: AgentDefinition): FnDefinition<AgentCallArgs, AgentResult> {
|
|
196
238
|
const fnDef = action({
|
|
197
239
|
args: {
|
|
@@ -223,10 +265,8 @@ export function isAgentDefinition(value: unknown): boolean {
|
|
|
223
265
|
|
|
224
266
|
const DEFAULT_MAX_STEPS = 64;
|
|
225
267
|
|
|
226
|
-
/** How often the loop asks whether cancel was requested while a
|
|
227
|
-
*
|
|
228
|
-
* its per-call read loop for the whole of `ctx.llm.stream`, so no RPC
|
|
229
|
-
* the child issues during a generation would be serviced anyway. */
|
|
268
|
+
/** How often the loop asks whether cancel was requested while a model
|
|
269
|
+
* response streams or a tool handler runs. */
|
|
230
270
|
const CANCEL_POLL_MS = 2000;
|
|
231
271
|
|
|
232
272
|
/** Tool results persist into AgentMessage rows and replay into every
|
|
@@ -258,10 +298,7 @@ interface DrainResult {
|
|
|
258
298
|
* run. Prototype-based rather than a spread: ctx's methods keep
|
|
259
299
|
* working when invoked on the derived object. */
|
|
260
300
|
function ctxWithSignal(ctx: ActionCtx, signal: AbortSignal): ActionCtx {
|
|
261
|
-
const combined =
|
|
262
|
-
ctx.signal && typeof AbortSignal.any === "function"
|
|
263
|
-
? AbortSignal.any([ctx.signal, signal])
|
|
264
|
-
: signal;
|
|
301
|
+
const combined = anySignal(ctx.signal, signal);
|
|
265
302
|
const derived = Object.create(ctx) as ActionCtx;
|
|
266
303
|
Object.defineProperty(derived, "signal", {
|
|
267
304
|
value: combined,
|
|
@@ -270,6 +307,56 @@ function ctxWithSignal(ctx: ActionCtx, signal: AbortSignal): ActionCtx {
|
|
|
270
307
|
return derived;
|
|
271
308
|
}
|
|
272
309
|
|
|
310
|
+
/** Watches the run row for a cancel request while the loop is busy.
|
|
311
|
+
*
|
|
312
|
+
* A poll rather than a push because a cancel can be requested on a
|
|
313
|
+
* different machine than the loop runs on, and the run row is the only
|
|
314
|
+
* thing both sides share. `signal` aborts once a cancel is seen; the
|
|
315
|
+
* loop passes it to `ctx.llm.stream` (which then drops the provider
|
|
316
|
+
* request) and to tool handlers through `ctx.signal`. */
|
|
317
|
+
interface CancelWatch {
|
|
318
|
+
readonly signal: AbortSignal;
|
|
319
|
+
readonly cancelled: boolean;
|
|
320
|
+
stop(): void;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function watchCancel(ctx: ActionCtx, runId: string): CancelWatch {
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
let polling = false;
|
|
326
|
+
const timer = setInterval(() => {
|
|
327
|
+
// Skip rather than stack: a slow poll must not queue more.
|
|
328
|
+
if (polling || controller.signal.aborted) return;
|
|
329
|
+
polling = true;
|
|
330
|
+
void ctx
|
|
331
|
+
.runQuery<{ cancelRequested: boolean }>("__pylon_agent_poll", { runId })
|
|
332
|
+
.then((s) => {
|
|
333
|
+
if (s.cancelRequested && !controller.signal.aborted) {
|
|
334
|
+
controller.abort(new Error("agent run cancelled"));
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
.catch(() => {})
|
|
338
|
+
.finally(() => {
|
|
339
|
+
polling = false;
|
|
340
|
+
});
|
|
341
|
+
}, CANCEL_POLL_MS);
|
|
342
|
+
return {
|
|
343
|
+
signal: controller.signal,
|
|
344
|
+
get cancelled() {
|
|
345
|
+
return controller.signal.aborted;
|
|
346
|
+
},
|
|
347
|
+
stop() {
|
|
348
|
+
clearInterval(timer);
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** `a` and `b` combined, for runtimes without `AbortSignal.any`. */
|
|
354
|
+
function anySignal(a: AbortSignal | undefined, b: AbortSignal): AbortSignal {
|
|
355
|
+
if (!a) return b;
|
|
356
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
|
|
357
|
+
return b;
|
|
358
|
+
}
|
|
359
|
+
|
|
273
360
|
async function runAgentLoop(
|
|
274
361
|
def: AgentDefinition,
|
|
275
362
|
ctx: ActionCtx,
|
|
@@ -321,6 +408,13 @@ async function runAgentLoop(
|
|
|
321
408
|
// continuations may take it over instead of waiting forever.
|
|
322
409
|
const staleMs = Math.max(def.timeout ?? 600, 60) * 1000;
|
|
323
410
|
|
|
411
|
+
// Server code that started this run with ctx.agents.run may pass a
|
|
412
|
+
// context for the tools. It wins over one stored on the run.
|
|
413
|
+
const invocation = invocationOf(ctx);
|
|
414
|
+
const invocationContext = asContext(invocation?.context);
|
|
415
|
+
let runContext = invocationContext;
|
|
416
|
+
let runUserId: string | null = ctx.auth.userId ?? null;
|
|
417
|
+
|
|
324
418
|
// 1. Create or load the run (ownership enforced inside the internal
|
|
325
419
|
// fns, which run under this caller's auth).
|
|
326
420
|
let runId: string;
|
|
@@ -334,6 +428,8 @@ async function runAgentLoop(
|
|
|
334
428
|
status: string;
|
|
335
429
|
updatedAt?: string;
|
|
336
430
|
steps?: number;
|
|
431
|
+
userId?: string | null;
|
|
432
|
+
context?: unknown;
|
|
337
433
|
};
|
|
338
434
|
messages: StoredMessage[];
|
|
339
435
|
}>("__pylon_agent_read", { runId: args.runId });
|
|
@@ -345,6 +441,15 @@ async function runAgentLoop(
|
|
|
345
441
|
}
|
|
346
442
|
if (loaded.run.status === "running") {
|
|
347
443
|
const updatedAt = Date.parse(String(loaded.run.updatedAt ?? "")) || 0;
|
|
444
|
+
if (Date.now() - updatedAt < staleMs && invocation) {
|
|
445
|
+
// Server code (ctx.agents.run) waits for this turn's reply and
|
|
446
|
+
// brings its own context; queueing would return an empty result
|
|
447
|
+
// and answer the input later under the running turn's context.
|
|
448
|
+
throw ctx.error(
|
|
449
|
+
"RUN_BUSY",
|
|
450
|
+
"This run is already generating — wait for it to finish",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
348
453
|
if (Date.now() - updatedAt < staleMs) {
|
|
349
454
|
// The generation is alive. Hand it the message rather than
|
|
350
455
|
// refusing the call: the loop drains the queue at its next
|
|
@@ -369,10 +474,17 @@ async function runAgentLoop(
|
|
|
369
474
|
runId = loaded.run.id;
|
|
370
475
|
priorSteps = Number(loaded.run.steps) || 0;
|
|
371
476
|
history = loaded.messages.map(storedToLlmMessage);
|
|
477
|
+
runUserId = loaded.run.userId ?? runUserId;
|
|
478
|
+
runContext ??= asContext(loaded.run.context);
|
|
372
479
|
} else {
|
|
373
480
|
const created = await ctx.runMutation<{ id: string }>(
|
|
374
481
|
"__pylon_agent_write",
|
|
375
|
-
{
|
|
482
|
+
{
|
|
483
|
+
op: "createRun",
|
|
484
|
+
agent: agentName,
|
|
485
|
+
title: args.title ?? null,
|
|
486
|
+
...(invocationContext ? { context: invocationContext } : {}),
|
|
487
|
+
},
|
|
376
488
|
);
|
|
377
489
|
runId = created.id;
|
|
378
490
|
}
|
|
@@ -392,6 +504,7 @@ async function runAgentLoop(
|
|
|
392
504
|
streamId: ctx.stream.id ?? null,
|
|
393
505
|
guardNotRunning: true,
|
|
394
506
|
staleMs,
|
|
507
|
+
...(args.runId && invocationContext ? { context: invocationContext } : {}),
|
|
395
508
|
});
|
|
396
509
|
|
|
397
510
|
// A crash between persisting an assistant tool_use turn and its
|
|
@@ -445,6 +558,10 @@ async function runAgentLoop(
|
|
|
445
558
|
unknown
|
|
446
559
|
> as Promise<DrainResult>;
|
|
447
560
|
|
|
561
|
+
const watch = watchCancel(ctx, runId);
|
|
562
|
+
const streamSignal = anySignal(ctx.signal, watch.signal);
|
|
563
|
+
const toolCtx = ctxWithSignal(ctx, watch.signal);
|
|
564
|
+
|
|
448
565
|
try {
|
|
449
566
|
for (;;) {
|
|
450
567
|
steps += 1;
|
|
@@ -456,18 +573,44 @@ async function runAgentLoop(
|
|
|
456
573
|
}
|
|
457
574
|
const system =
|
|
458
575
|
typeof def.system === "function" ? def.system(ctx, args) : def.system;
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
576
|
+
let partial = "";
|
|
577
|
+
let res: Awaited<ReturnType<ActionCtx["llm"]["stream"]>>;
|
|
578
|
+
try {
|
|
579
|
+
res = await ctx.llm.stream(
|
|
580
|
+
{
|
|
581
|
+
messages: history,
|
|
582
|
+
...(system ? { system } : {}),
|
|
583
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
584
|
+
...(def.model ? { model: def.model } : {}),
|
|
585
|
+
...(def.maxTokens ? { max_tokens: def.maxTokens } : {}),
|
|
586
|
+
},
|
|
587
|
+
(e) => {
|
|
588
|
+
if (e.type === "text_delta") {
|
|
589
|
+
partial += e.text;
|
|
590
|
+
ctx.stream.write(e.text);
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
{ signal: streamSignal },
|
|
594
|
+
);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (!watch.cancelled) throw err;
|
|
597
|
+
// Stopped mid-response. Keep the text the user already saw, then
|
|
598
|
+
// take whatever was queued so it is not lost with the run.
|
|
599
|
+
if (partial !== "") {
|
|
600
|
+
const content: LlmContentBlock[] = [{ type: "text", text: partial }];
|
|
601
|
+
await write({ op: "appendMessage", role: "assistant", content });
|
|
602
|
+
history.push({ role: "assistant", content });
|
|
603
|
+
finalText = partial;
|
|
604
|
+
}
|
|
605
|
+
const pending = await drain({});
|
|
606
|
+
const text = pending.input.join("\n\n");
|
|
607
|
+
if (text !== "") {
|
|
608
|
+
await write({ op: "appendMessage", role: "user", content: text });
|
|
609
|
+
history.push({ role: "user", content: text });
|
|
610
|
+
}
|
|
611
|
+
cancelled = true;
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
471
614
|
usage.input_tokens += res.usage.input_tokens;
|
|
472
615
|
usage.output_tokens += res.usage.output_tokens;
|
|
473
616
|
|
|
@@ -508,7 +651,12 @@ async function runAgentLoop(
|
|
|
508
651
|
|
|
509
652
|
// 3. Execute every requested tool; failures become is_error
|
|
510
653
|
// results the model can react to rather than run-fatal throws.
|
|
511
|
-
const batch = await runToolBatch(def, ctx,
|
|
654
|
+
const batch = await runToolBatch(def, ctx, toolCtx, watch, res.content, {
|
|
655
|
+
runId,
|
|
656
|
+
agent: agentName,
|
|
657
|
+
userId: runUserId,
|
|
658
|
+
context: runContext,
|
|
659
|
+
});
|
|
512
660
|
|
|
513
661
|
// Tool boundary. Every tool_use needs its tool_result even when
|
|
514
662
|
// the batch stopped early, or the transcript can't be replayed.
|
|
@@ -536,6 +684,8 @@ async function runAgentLoop(
|
|
|
536
684
|
steps: priorSteps + steps,
|
|
537
685
|
}).catch(() => {});
|
|
538
686
|
throw err;
|
|
687
|
+
} finally {
|
|
688
|
+
watch.stop();
|
|
539
689
|
}
|
|
540
690
|
|
|
541
691
|
if (cancelled) {
|
|
@@ -558,18 +708,15 @@ async function runAgentLoop(
|
|
|
558
708
|
return { runId, text: finalText, steps, usage };
|
|
559
709
|
}
|
|
560
710
|
|
|
561
|
-
/** Run one turn's tool calls
|
|
562
|
-
*
|
|
563
|
-
* The watch is a poll rather than a push because a cancel can be
|
|
564
|
-
* requested on a different machine than the loop runs on — the run
|
|
565
|
-
* row is the only thing both sides share. It runs only for the
|
|
566
|
-
* duration of the batch: during `ctx.llm.stream` the host blocks its
|
|
567
|
-
* per-call read loop, so an RPC issued then would not be answered. */
|
|
711
|
+
/** Run one turn's tool calls. Stops starting new ones once `watch`
|
|
712
|
+
* sees a cancel; a handler already running sees `ctx.signal` abort. */
|
|
568
713
|
async function runToolBatch(
|
|
569
714
|
def: AgentDefinition,
|
|
570
715
|
ctx: ActionCtx,
|
|
571
|
-
|
|
716
|
+
toolCtx: ActionCtx,
|
|
717
|
+
watch: CancelWatch,
|
|
572
718
|
turn: LlmContentBlock[],
|
|
719
|
+
run: AgentToolRun,
|
|
573
720
|
): Promise<{ results: LlmContentBlock[]; cancelled: boolean }> {
|
|
574
721
|
const calls = turn.filter(
|
|
575
722
|
(b): b is Extract<LlmContentBlock, { type: "tool_use" }> =>
|
|
@@ -578,77 +725,51 @@ async function runToolBatch(
|
|
|
578
725
|
const results: LlmContentBlock[] = [];
|
|
579
726
|
if (calls.length === 0) return { results, cancelled: false };
|
|
580
727
|
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
cancelled = true;
|
|
594
|
-
controller.abort(new Error("agent run cancelled"));
|
|
595
|
-
}
|
|
596
|
-
})
|
|
597
|
-
.catch(() => {})
|
|
598
|
-
.finally(() => {
|
|
599
|
-
polling = false;
|
|
600
|
-
});
|
|
601
|
-
}, CANCEL_POLL_MS);
|
|
602
|
-
|
|
603
|
-
try {
|
|
604
|
-
for (const block of calls) {
|
|
605
|
-
let content: string;
|
|
606
|
-
let isError = false;
|
|
607
|
-
if (cancelled) {
|
|
608
|
-
// Stop starting new work, but still answer the call so the
|
|
609
|
-
// model sees why it has no result.
|
|
610
|
-
content = "cancelled by user";
|
|
728
|
+
for (const block of calls) {
|
|
729
|
+
let content: string;
|
|
730
|
+
let isError = false;
|
|
731
|
+
if (watch.cancelled) {
|
|
732
|
+
// Stop starting new work, but still answer the call so the
|
|
733
|
+
// model sees why it has no result.
|
|
734
|
+
content = "cancelled by user";
|
|
735
|
+
isError = true;
|
|
736
|
+
} else {
|
|
737
|
+
const tool = def.tools?.[block.name];
|
|
738
|
+
if (!tool) {
|
|
739
|
+
content = `Unknown tool "${block.name}"`;
|
|
611
740
|
isError = true;
|
|
612
741
|
} else {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
try {
|
|
619
|
-
if (tool.args) {
|
|
620
|
-
const check = validateArgs(block.input, tool.args);
|
|
621
|
-
if (!check.valid) {
|
|
622
|
-
throw new Error(`Invalid tool input: ${check.errors.join("; ")}`);
|
|
623
|
-
}
|
|
742
|
+
try {
|
|
743
|
+
if (tool.args) {
|
|
744
|
+
const check = validateArgs(block.input, tool.args);
|
|
745
|
+
if (!check.valid) {
|
|
746
|
+
throw new Error(`Invalid tool input: ${check.errors.join("; ")}`);
|
|
624
747
|
}
|
|
625
|
-
const value = await tool.handler(toolCtx, block.input);
|
|
626
|
-
content =
|
|
627
|
-
typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
628
|
-
} catch (err) {
|
|
629
|
-
content = err instanceof Error ? err.message : String(err);
|
|
630
|
-
isError = true;
|
|
631
748
|
}
|
|
749
|
+
const value = await tool.handler(toolCtx, block.input, run);
|
|
750
|
+
content =
|
|
751
|
+
typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
content = err instanceof Error ? err.message : String(err);
|
|
754
|
+
isError = true;
|
|
632
755
|
}
|
|
633
756
|
}
|
|
634
|
-
content = truncateToolResult(content);
|
|
635
|
-
// Announce the tool call on the stream so live UIs can render
|
|
636
|
-
// "using searchDocs…" without polling the message rows.
|
|
637
|
-
ctx.stream.writeEvent(
|
|
638
|
-
"tool",
|
|
639
|
-
JSON.stringify({ name: block.name, input: block.input, isError }),
|
|
640
|
-
);
|
|
641
|
-
results.push({
|
|
642
|
-
type: "tool_result",
|
|
643
|
-
tool_use_id: block.id,
|
|
644
|
-
content,
|
|
645
|
-
...(isError ? { is_error: true } : {}),
|
|
646
|
-
});
|
|
647
757
|
}
|
|
648
|
-
|
|
649
|
-
|
|
758
|
+
content = truncateToolResult(content);
|
|
759
|
+
// Announce the tool call on the stream so live UIs can render
|
|
760
|
+
// "using searchDocs…" without polling the message rows.
|
|
761
|
+
ctx.stream.writeEvent(
|
|
762
|
+
"tool",
|
|
763
|
+
JSON.stringify({ name: block.name, input: block.input, isError }),
|
|
764
|
+
);
|
|
765
|
+
results.push({
|
|
766
|
+
type: "tool_result",
|
|
767
|
+
tool_use_id: block.id,
|
|
768
|
+
content,
|
|
769
|
+
...(isError ? { is_error: true } : {}),
|
|
770
|
+
});
|
|
650
771
|
}
|
|
651
|
-
return { results, cancelled };
|
|
772
|
+
return { results, cancelled: watch.cancelled };
|
|
652
773
|
}
|
|
653
774
|
|
|
654
775
|
function storedToLlmMessage(m: StoredMessage): LlmMessage {
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export {
|
|
|
31
31
|
export type {
|
|
32
32
|
AgentDefinition,
|
|
33
33
|
AgentTool,
|
|
34
|
+
AgentToolRun,
|
|
34
35
|
AgentCallArgs,
|
|
35
36
|
AgentResult,
|
|
36
37
|
} from "./agent";
|
|
@@ -86,6 +87,8 @@ export type {
|
|
|
86
87
|
Audit,
|
|
87
88
|
AuditLog,
|
|
88
89
|
AuditEntry,
|
|
90
|
+
Agents,
|
|
91
|
+
AgentRunOptions,
|
|
89
92
|
VectorSearchQuery,
|
|
90
93
|
VectorSearchResult,
|
|
91
94
|
SearchResult,
|
|
@@ -184,3 +184,88 @@ test("llm.stream routes events by op_id, resolves on result; rooms.broadcast rou
|
|
|
184
184
|
// 6. rooms.broadcast surfaced the host's verdict.
|
|
185
185
|
expect(out.delivered).toBe(true);
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
const ABORT_SCRIPT = `
|
|
189
|
+
import { buildLlm } from ${JSON.stringify(RUNTIME)};
|
|
190
|
+
|
|
191
|
+
const llm = buildLlm("c_9");
|
|
192
|
+
const seen = [];
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
|
|
195
|
+
// Aborted before it starts: rejects without sending a frame.
|
|
196
|
+
const early = await llm
|
|
197
|
+
.stream({ messages: [] }, () => {}, { signal: AbortSignal.abort("stopped") })
|
|
198
|
+
.then(() => "resolved", (e) => e.code);
|
|
199
|
+
|
|
200
|
+
const running = llm
|
|
201
|
+
.stream({ messages: [] }, (e) => {
|
|
202
|
+
seen.push(e.text);
|
|
203
|
+
if (seen.length === 2) controller.abort(new Error("user pressed stop"));
|
|
204
|
+
}, { signal: controller.signal })
|
|
205
|
+
.then(() => ({ code: "resolved" }), (e) => ({ code: e.code, message: e.message }));
|
|
206
|
+
|
|
207
|
+
const out = await running;
|
|
208
|
+
// Give late host frames time to arrive; they must be ignored.
|
|
209
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
210
|
+
console.error("RESULT " + JSON.stringify({ early, out, seen }));
|
|
211
|
+
process.exit(0);
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
test("an aborted llm.stream rejects with LLM_CANCELLED and sends llm_cancel", async () => {
|
|
215
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-llm-abort-"));
|
|
216
|
+
const scriptPath = join(dir, "probe.ts");
|
|
217
|
+
writeFileSync(scriptPath, ABORT_SCRIPT);
|
|
218
|
+
const proc = Bun.spawn([process.execPath, scriptPath], {
|
|
219
|
+
stdin: "pipe",
|
|
220
|
+
stdout: "pipe",
|
|
221
|
+
stderr: "pipe",
|
|
222
|
+
});
|
|
223
|
+
const reader = proc.stdout.getReader();
|
|
224
|
+
const decoder = new TextDecoder();
|
|
225
|
+
let buffered = "";
|
|
226
|
+
const frames = () => parseFrames(buffered);
|
|
227
|
+
const readUntil = async (
|
|
228
|
+
pred: (fs: Record<string, unknown>[]) => boolean,
|
|
229
|
+
) => {
|
|
230
|
+
while (!pred(frames())) {
|
|
231
|
+
const { done, value } = await reader.read();
|
|
232
|
+
if (done) break;
|
|
233
|
+
buffered += decoder.decode(value, { stream: true });
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const send = (msg: Record<string, unknown>) =>
|
|
237
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
238
|
+
|
|
239
|
+
await readUntil((fs) => fs.some((f) => f.type === "llm_stream"));
|
|
240
|
+
const streams = frames().filter((f) => f.type === "llm_stream");
|
|
241
|
+
// The pre-aborted stream never reached the host.
|
|
242
|
+
expect(streams).toHaveLength(1);
|
|
243
|
+
const opId = streams[0].op_id as string;
|
|
244
|
+
|
|
245
|
+
send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "a" } });
|
|
246
|
+
send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "b" } });
|
|
247
|
+
await proc.stdin.flush();
|
|
248
|
+
await readUntil((fs) => fs.some((f) => f.type === "llm_cancel"));
|
|
249
|
+
const cancel = frames().find((f) => f.type === "llm_cancel");
|
|
250
|
+
expect(cancel).toEqual({ type: "llm_cancel", call_id: "c_9", op_id: opId });
|
|
251
|
+
|
|
252
|
+
// Frames the host sent before it saw the cancel are dropped.
|
|
253
|
+
send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "c" } });
|
|
254
|
+
send({
|
|
255
|
+
type: "result",
|
|
256
|
+
call_id: "c_9",
|
|
257
|
+
op_id: opId,
|
|
258
|
+
data: { model: "m", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 } },
|
|
259
|
+
});
|
|
260
|
+
await proc.stdin.flush();
|
|
261
|
+
|
|
262
|
+
const stderr = await new Response(proc.stderr).text();
|
|
263
|
+
await proc.exited;
|
|
264
|
+
const line = stderr.split("\n").find((l) => l.includes("RESULT "));
|
|
265
|
+
expect(line).toBeDefined();
|
|
266
|
+
const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
|
|
267
|
+
expect(out.early).toBe("LLM_CANCELLED");
|
|
268
|
+
expect(out.out.code).toBe("LLM_CANCELLED");
|
|
269
|
+
expect(out.out.message).toContain("user pressed stop");
|
|
270
|
+
expect(out.seen).toEqual(["a", "b"]);
|
|
271
|
+
});
|
package/src/runtime.ts
CHANGED
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
AuthInfo,
|
|
50
50
|
} from "./types";
|
|
51
51
|
import { normalizeAuthClaims } from "./auth";
|
|
52
|
+
import { AGENT_INVOCATION, isAgentDefinition } from "./agent";
|
|
52
53
|
import { makeRequireMember } from "./member";
|
|
53
54
|
import { response } from "./response";
|
|
54
55
|
import { isDevMode } from "./ssr-runtime";
|
|
@@ -56,7 +57,8 @@ import { validateArgs } from "./validators";
|
|
|
56
57
|
import { serverBundle } from "./server-bundle";
|
|
57
58
|
import { fenceStdout } from "./stdout-fence";
|
|
58
59
|
import { readdirSync } from "fs";
|
|
59
|
-
import { join, basename } from "path";
|
|
60
|
+
import { join, basename, resolve } from "path";
|
|
61
|
+
import { pathToFileURL } from "url";
|
|
60
62
|
|
|
61
63
|
// Bun runtime globals this process uses. The runtime executes under Bun, but
|
|
62
64
|
// consuming apps type-check this source under node/DOM where the `Bun` global
|
|
@@ -84,6 +86,9 @@ interface CallMessage {
|
|
|
84
86
|
fn_type: "query" | "mutation" | "action";
|
|
85
87
|
args: Record<string, unknown>;
|
|
86
88
|
auth: AuthInfo;
|
|
89
|
+
/** Present only when server code started this call with
|
|
90
|
+
* `ctx.agents.run`; the host sets it, never a client. */
|
|
91
|
+
agent?: { context?: Record<string, unknown> | null };
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
interface ResultMessage {
|
|
@@ -392,19 +397,37 @@ function rpcDb(
|
|
|
392
397
|
* is alive, however long it runs in total, while one whose provider
|
|
393
398
|
* goes silent still trips the safety net. The host's own call deadline
|
|
394
399
|
* remains the real upper bound.
|
|
400
|
+
*
|
|
401
|
+
* When `signal` aborts, the promise rejects with `LLM_CANCELLED` at
|
|
402
|
+
* once and an `llm_cancel` frame tells the host to drop the provider
|
|
403
|
+
* request. Events and the result that arrive after that are ignored.
|
|
395
404
|
*/
|
|
396
405
|
function rpcStreaming(
|
|
397
406
|
callId: string,
|
|
398
407
|
msg: Record<string, unknown>,
|
|
399
408
|
onEvent: (event: unknown) => void,
|
|
409
|
+
signal?: AbortSignal,
|
|
400
410
|
): Promise<unknown> {
|
|
401
411
|
throwIfCancelled(callId);
|
|
412
|
+
if (signal?.aborted) return Promise.reject(streamCancelledError(signal));
|
|
402
413
|
const opId = nextOpId(callId);
|
|
403
414
|
return new Promise((resolve, reject) => {
|
|
415
|
+
const onAbort = () => {
|
|
416
|
+
const pending = pendingRpcs.get(opId);
|
|
417
|
+
if (!pending) return;
|
|
418
|
+
pendingRpcs.delete(opId);
|
|
419
|
+
streamSinks.delete(opId);
|
|
420
|
+
clearTimeout(pending.timeout);
|
|
421
|
+
send({ type: "llm_cancel", call_id: callId, op_id: opId });
|
|
422
|
+
reject(streamCancelledError(signal));
|
|
423
|
+
};
|
|
404
424
|
const fail = () => {
|
|
405
425
|
if (pendingRpcs.has(opId)) {
|
|
406
426
|
pendingRpcs.delete(opId);
|
|
407
427
|
streamSinks.delete(opId);
|
|
428
|
+
signal?.removeEventListener("abort", onAbort);
|
|
429
|
+
// Nothing waits for this stream any more; stop the provider.
|
|
430
|
+
send({ type: "llm_cancel", call_id: callId, op_id: opId });
|
|
408
431
|
reject(
|
|
409
432
|
new Error(
|
|
410
433
|
`RPC timed out after ${RPC_TIMEOUT_MS}ms with no stream activity (call_id=${callId} op_id=${opId})`,
|
|
@@ -415,14 +438,17 @@ function rpcStreaming(
|
|
|
415
438
|
const entry = {
|
|
416
439
|
resolve: (data: unknown) => {
|
|
417
440
|
streamSinks.delete(opId);
|
|
441
|
+
signal?.removeEventListener("abort", onAbort);
|
|
418
442
|
resolve(data);
|
|
419
443
|
},
|
|
420
444
|
reject: (err: Error) => {
|
|
421
445
|
streamSinks.delete(opId);
|
|
446
|
+
signal?.removeEventListener("abort", onAbort);
|
|
422
447
|
reject(err);
|
|
423
448
|
},
|
|
424
449
|
timeout: setTimeout(fail, RPC_TIMEOUT_MS),
|
|
425
450
|
};
|
|
451
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
426
452
|
pendingRpcs.set(opId, entry);
|
|
427
453
|
streamSinks.set(opId, (event) => {
|
|
428
454
|
clearTimeout(entry.timeout);
|
|
@@ -440,6 +466,17 @@ function rpcStreaming(
|
|
|
440
466
|
});
|
|
441
467
|
}
|
|
442
468
|
|
|
469
|
+
function streamCancelledError(signal?: AbortSignal): Error {
|
|
470
|
+
const reason = signal?.reason;
|
|
471
|
+
const detail =
|
|
472
|
+
reason instanceof Error ? reason.message : reason ? String(reason) : "";
|
|
473
|
+
const err = new Error(
|
|
474
|
+
detail ? `ctx.llm.stream was cancelled: ${detail}` : "ctx.llm.stream was cancelled",
|
|
475
|
+
);
|
|
476
|
+
(err as { code?: string }).code = "LLM_CANCELLED";
|
|
477
|
+
return err;
|
|
478
|
+
}
|
|
479
|
+
|
|
443
480
|
/**
|
|
444
481
|
* Tail of the in-order queue of legacy (call_id-keyed) RPCs, per call.
|
|
445
482
|
* See {@link rpc} for why these serialize.
|
|
@@ -941,11 +978,13 @@ export function buildLlm(callId: string): Llm {
|
|
|
941
978
|
async stream(
|
|
942
979
|
request: LlmCompleteRequest,
|
|
943
980
|
onEvent: (event: LlmStreamEvent) => void,
|
|
981
|
+
options?: { signal?: AbortSignal },
|
|
944
982
|
): Promise<LlmCompleteResponse> {
|
|
945
983
|
return (await rpcStreaming(
|
|
946
984
|
callId,
|
|
947
985
|
{ type: "llm_stream", request },
|
|
948
986
|
(event) => onEvent(event as LlmStreamEvent),
|
|
987
|
+
options?.signal,
|
|
949
988
|
)) as LlmCompleteResponse;
|
|
950
989
|
},
|
|
951
990
|
|
|
@@ -1242,6 +1281,38 @@ function buildActionCtx(
|
|
|
1242
1281
|
connections,
|
|
1243
1282
|
workflows: buildWorkflows(callId),
|
|
1244
1283
|
audit: buildAudit(callId),
|
|
1284
|
+
agents: {
|
|
1285
|
+
async run(name, options) {
|
|
1286
|
+
if (!options || typeof options.as?.userId !== "string") {
|
|
1287
|
+
const err = new Error(
|
|
1288
|
+
"ctx.agents.run needs as.userId: the user the run belongs to",
|
|
1289
|
+
);
|
|
1290
|
+
(err as { code?: string }).code = "INVALID_ARGS";
|
|
1291
|
+
throw err;
|
|
1292
|
+
}
|
|
1293
|
+
const context = options.context;
|
|
1294
|
+
if (
|
|
1295
|
+
context !== undefined &&
|
|
1296
|
+
context !== null &&
|
|
1297
|
+
(typeof context !== "object" || Array.isArray(context))
|
|
1298
|
+
) {
|
|
1299
|
+
const err = new Error("ctx.agents.run: context must be a plain object");
|
|
1300
|
+
(err as { code?: string }).code = "INVALID_ARGS";
|
|
1301
|
+
throw err;
|
|
1302
|
+
}
|
|
1303
|
+
const args: Record<string, unknown> = { input: options.input };
|
|
1304
|
+
if (options.runId !== undefined) args.runId = options.runId;
|
|
1305
|
+
if (options.title !== undefined) args.title = options.title;
|
|
1306
|
+
return rpc(callId, {
|
|
1307
|
+
type: "run_agent",
|
|
1308
|
+
agent: name,
|
|
1309
|
+
args,
|
|
1310
|
+
user_id: options.as.userId,
|
|
1311
|
+
admin: options.as.admin === true,
|
|
1312
|
+
context: options.context ?? null,
|
|
1313
|
+
}) as Promise<any>;
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1245
1316
|
domains: buildDomains(),
|
|
1246
1317
|
env: process.env as Record<string, string>,
|
|
1247
1318
|
async runQuery(fnName, args) {
|
|
@@ -1304,6 +1375,32 @@ registry.set("__pylonMemberLookup", {
|
|
|
1304
1375
|
// Handler
|
|
1305
1376
|
// ---------------------------------------------------------------------------
|
|
1306
1377
|
|
|
1378
|
+
/** Why a `ctx.agents.run` target may not run, or null when it may. */
|
|
1379
|
+
function agentInvocationRefusal(
|
|
1380
|
+
def: FnDefinition,
|
|
1381
|
+
rawAuth: AuthInfo,
|
|
1382
|
+
): { code: string; message: string } | null {
|
|
1383
|
+
if (!isAgentDefinition(def)) {
|
|
1384
|
+
return {
|
|
1385
|
+
code: "AGENT_NOT_FOUND",
|
|
1386
|
+
message: "ctx.agents.run can only run a function defined with agent()",
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const auth = normalizeAuthClaims(rawAuth as unknown as Record<string, unknown>);
|
|
1390
|
+
const mode = (def as { auth?: string }).auth ?? "user";
|
|
1391
|
+
if (mode === "admin" && !auth.isAdmin) {
|
|
1392
|
+
return {
|
|
1393
|
+
code: "FORBIDDEN",
|
|
1394
|
+
message:
|
|
1395
|
+
"this agent is declared auth: \"admin\"; pass as: { userId, admin: true } from an admin caller",
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
if ((mode === "user" || mode === "admin") && !auth.userId) {
|
|
1399
|
+
return { code: "UNAUTHENTICATED", message: "the agent needs a user" };
|
|
1400
|
+
}
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1307
1404
|
async function handleCall(msg: CallMessage): Promise<void> {
|
|
1308
1405
|
const def = registry.get(msg.fn_name);
|
|
1309
1406
|
|
|
@@ -1332,6 +1429,18 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1332
1429
|
return;
|
|
1333
1430
|
}
|
|
1334
1431
|
|
|
1432
|
+
// `ctx.agents.run`: the host checked who may start this and as whom.
|
|
1433
|
+
// What is left is the target: it must be an agent, and the identity
|
|
1434
|
+
// must satisfy the agent's own auth mode, exactly as an HTTP call to
|
|
1435
|
+
// it would have to.
|
|
1436
|
+
if (msg.agent) {
|
|
1437
|
+
const refusal = agentInvocationRefusal(def, msg.auth);
|
|
1438
|
+
if (refusal) {
|
|
1439
|
+
send({ type: "error", call_id: msg.call_id, ...refusal });
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1335
1444
|
if (def.args) {
|
|
1336
1445
|
const { valid, errors } = validateArgs(msg.args, def.args);
|
|
1337
1446
|
if (!valid) {
|
|
@@ -1469,6 +1578,13 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1469
1578
|
// the switch so all three ctx shapes get it without threading another
|
|
1470
1579
|
// parameter through each builder.
|
|
1471
1580
|
(ctx as { signal?: AbortSignal }).signal = abort.signal;
|
|
1581
|
+
if (msg.agent) {
|
|
1582
|
+
// Read by the agent loop only. A symbol key, so nothing a client
|
|
1583
|
+
// sends (args are plain JSON) can set it.
|
|
1584
|
+
(ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
|
|
1585
|
+
context: msg.agent.context ?? null,
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1472
1588
|
|
|
1473
1589
|
try {
|
|
1474
1590
|
const result = await def.handler(ctx, msg.args);
|
|
@@ -1561,19 +1677,29 @@ function firstStackFrame(err: unknown): string {
|
|
|
1561
1677
|
*/
|
|
1562
1678
|
function listModuleFiles(
|
|
1563
1679
|
dir: string,
|
|
1680
|
+
opts: { reportMissing?: boolean } = {},
|
|
1564
1681
|
): Array<{ name: string; file: string; load: () => Promise<any> }> {
|
|
1565
1682
|
let files: string[];
|
|
1566
1683
|
try {
|
|
1567
1684
|
files = readdirSync(dir).filter(
|
|
1568
1685
|
(f) => f.endsWith(".ts") || f.endsWith(".js"),
|
|
1569
1686
|
);
|
|
1570
|
-
} catch {
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
// A missing default directory is normal (see above). Anything else, or
|
|
1689
|
+
// a directory the app named explicitly, means its functions would
|
|
1690
|
+
// silently not register, so say why.
|
|
1691
|
+
const missing = (err as NodeJS.ErrnoException)?.code === "ENOENT";
|
|
1692
|
+
if (!missing || opts.reportMissing) {
|
|
1693
|
+
console.error(`[functions] Cannot read ${dir}:`, err);
|
|
1694
|
+
}
|
|
1571
1695
|
return [];
|
|
1572
1696
|
}
|
|
1573
1697
|
return files.map((file) => ({
|
|
1574
1698
|
name: basename(file, file.endsWith(".ts") ? ".ts" : ".js"),
|
|
1575
1699
|
file,
|
|
1576
|
-
|
|
1700
|
+
// A file URL: a bare Windows path ("D:\\app\\x.ts") is not a module
|
|
1701
|
+
// specifier everywhere.
|
|
1702
|
+
load: () => import(pathToFileURL(join(dir, file)).href),
|
|
1577
1703
|
}));
|
|
1578
1704
|
}
|
|
1579
1705
|
|
|
@@ -1584,6 +1710,9 @@ async function main() {
|
|
|
1584
1710
|
fenceStdout();
|
|
1585
1711
|
|
|
1586
1712
|
const fnDir = process.argv[2] || "./functions";
|
|
1713
|
+
// `resolve`, not `join`: an absolute PYLON_FUNCTIONS_DIR (including a
|
|
1714
|
+
// Windows drive path) must be used as is, not appended to the cwd.
|
|
1715
|
+
const fnDirIsDefault = fnDir === "functions" || fnDir === "./functions";
|
|
1587
1716
|
const bundle = serverBundle();
|
|
1588
1717
|
|
|
1589
1718
|
const fnSources = bundle
|
|
@@ -1592,7 +1721,9 @@ async function main() {
|
|
|
1592
1721
|
file: `${name} (bundled)`,
|
|
1593
1722
|
load: bundle.functions[name],
|
|
1594
1723
|
}))
|
|
1595
|
-
: listModuleFiles(
|
|
1724
|
+
: listModuleFiles(resolve(process.cwd(), fnDir), {
|
|
1725
|
+
reportMissing: !fnDirIsDefault,
|
|
1726
|
+
});
|
|
1596
1727
|
|
|
1597
1728
|
const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
|
|
1598
1729
|
let agentsPresent = false;
|
|
@@ -1650,7 +1781,7 @@ async function main() {
|
|
|
1650
1781
|
file: `${name} (bundled)`,
|
|
1651
1782
|
load: bundle.workflows[name],
|
|
1652
1783
|
}))
|
|
1653
|
-
: listModuleFiles(
|
|
1784
|
+
: listModuleFiles(resolve(process.cwd(), "workflows"));
|
|
1654
1785
|
for (const { file, load } of wfSources) {
|
|
1655
1786
|
try {
|
|
1656
1787
|
const mod = await load();
|
package/src/types.ts
CHANGED
|
@@ -508,10 +508,16 @@ export interface Llm {
|
|
|
508
508
|
* if (res.stop_reason === "tool_use") { ...run tools, loop... }
|
|
509
509
|
* ```
|
|
510
510
|
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
511
|
+
* The function's call timeout (PYLON_FN_CALL_TIMEOUT, 30s default, or
|
|
512
|
+
* the function's own `timeout`) is an idle timeout. A running stream
|
|
513
|
+
* counts as activity, but no call outlives 10× its timeout, so a long
|
|
514
|
+
* agent run should still declare its own `timeout`.
|
|
515
|
+
*
|
|
516
|
+
* Pass `{ signal }` to stop a stream early. When the signal aborts,
|
|
517
|
+
* the promise rejects with `err.code === "LLM_CANCELLED"` and the
|
|
518
|
+
* server closes the provider connection, so the model stops
|
|
519
|
+
* generating (and billing) tokens. A stream still running when the
|
|
520
|
+
* function returns or times out is cancelled the same way.
|
|
515
521
|
*
|
|
516
522
|
* Same errors and same gating as {@link Llm.complete} — including
|
|
517
523
|
* the model allowlist, so streaming can't be used to reach a model
|
|
@@ -520,6 +526,7 @@ export interface Llm {
|
|
|
520
526
|
stream(
|
|
521
527
|
request: LlmCompleteRequest,
|
|
522
528
|
onEvent: (event: LlmStreamEvent) => void,
|
|
529
|
+
options?: { signal?: AbortSignal },
|
|
523
530
|
): Promise<LlmCompleteResponse>;
|
|
524
531
|
|
|
525
532
|
/**
|
|
@@ -1363,6 +1370,48 @@ export interface Domains {
|
|
|
1363
1370
|
): Promise<RegisteredDomainResult>;
|
|
1364
1371
|
}
|
|
1365
1372
|
|
|
1373
|
+
/** Options for {@link Agents.run}. */
|
|
1374
|
+
export interface AgentRunOptions {
|
|
1375
|
+
/** The message for this turn. */
|
|
1376
|
+
input: string;
|
|
1377
|
+
/** Continue this run. It must belong to `as.userId` and this agent. */
|
|
1378
|
+
runId?: string;
|
|
1379
|
+
/** Display title, stored on a new run. */
|
|
1380
|
+
title?: string;
|
|
1381
|
+
/**
|
|
1382
|
+
* The user the run belongs to and runs as. The run shows up in that
|
|
1383
|
+
* user's synced `AgentRun`/`AgentMessage` rows. `admin: true` also
|
|
1384
|
+
* gives the run admin rights (needed for an agent declared
|
|
1385
|
+
* `auth: "admin"`), and is allowed only when the caller is an admin.
|
|
1386
|
+
*/
|
|
1387
|
+
as: { userId: string; admin?: boolean };
|
|
1388
|
+
/**
|
|
1389
|
+
* JSON the agent's tool handlers receive as `run.context` (their third
|
|
1390
|
+
* argument), for example the conversation a webhook turn belongs to.
|
|
1391
|
+
* Stored on the run as `AgentRun.context`, so later turns of the run
|
|
1392
|
+
* see it too. The run's owner can read it; do not put secrets in it.
|
|
1393
|
+
*/
|
|
1394
|
+
context?: Record<string, unknown>;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Run an `agent()` from server code. See {@link ActionCtx.agents}.
|
|
1399
|
+
*/
|
|
1400
|
+
export interface Agents {
|
|
1401
|
+
/**
|
|
1402
|
+
* Run agent `name` (its file name in `functions/`) as `options.as`.
|
|
1403
|
+
* Resolves with the agent's result once the turn finishes, like a
|
|
1404
|
+
* `streamFn` call's final `result` event. The agent's tokens are not
|
|
1405
|
+
* streamed to this caller; they are in the run's `AgentMessage` rows.
|
|
1406
|
+
*
|
|
1407
|
+
* Only server code can do this: the calling action must be
|
|
1408
|
+
* `internal: true` (jobs, workflows, scheduled work) or run with admin
|
|
1409
|
+
* rights (an admin session, or a webhook after `ctx.auth.elevate`).
|
|
1410
|
+
* Otherwise it throws `AGENT_RUN_FORBIDDEN`.
|
|
1411
|
+
*/
|
|
1412
|
+
run(name: string, options: AgentRunOptions): Promise<import("./agent").AgentResult>;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1366
1415
|
export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
1367
1416
|
auth: AuthInfo<R>;
|
|
1368
1417
|
stream: Stream;
|
|
@@ -1379,6 +1428,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
1379
1428
|
workflows: Workflows;
|
|
1380
1429
|
/** Application audit log — see {@link Audit}. */
|
|
1381
1430
|
audit: Audit;
|
|
1431
|
+
/** Run an `agent()` as a named user from server code — see {@link Agents}. */
|
|
1432
|
+
agents: Agents;
|
|
1382
1433
|
/** Attach / register custom domains for your app's OWN end-customers
|
|
1383
1434
|
* (platform domains) — see {@link Domains}. Cloud-only. */
|
|
1384
1435
|
domains: Domains;
|