@frockbot/plugin-flock 0.3.16 → 0.3.17
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/frockbot.json +8 -2
- package/package.json +8 -7
- package/src/agent.test.ts +39 -0
- package/src/agent.ts +198 -14
- package/src/quota.test.ts +71 -0
- package/src/quota.ts +153 -0
package/frockbot.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 4,
|
|
3
3
|
"id": "flock",
|
|
4
4
|
"displayName": "Flock",
|
|
5
5
|
"version": "0.0.1",
|
|
@@ -33,7 +33,13 @@
|
|
|
33
33
|
"settings": [],
|
|
34
34
|
"connectionTypes": [],
|
|
35
35
|
"capabilities": [
|
|
36
|
-
{ "id": "bot-self-management", "kind": "tool", "connectionTypes": [] }
|
|
36
|
+
{ "id": "bot-self-management", "kind": "tool", "connectionTypes": [] },
|
|
37
|
+
{
|
|
38
|
+
"id": "bot-messaging",
|
|
39
|
+
"kind": "tool",
|
|
40
|
+
"connectionTypes": [],
|
|
41
|
+
"admission": { "turnTypes": ["chat"] }
|
|
42
|
+
}
|
|
37
43
|
]
|
|
38
44
|
}
|
|
39
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-flock",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"./client/state": "./src/client/state.ts",
|
|
15
15
|
"./frockbot.json": "./frockbot.json",
|
|
16
16
|
"./manifest": "./src/manifest.ts",
|
|
17
|
+
"./quota": "./src/quota.ts",
|
|
17
18
|
"./package.json": "./package.json",
|
|
18
19
|
"./shared": "./src/shared.ts",
|
|
19
20
|
"./user": "./src/user.ts"
|
|
@@ -27,16 +28,16 @@
|
|
|
27
28
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@frockbot/client-core": "0.3.
|
|
31
|
-
"@frockbot/client-ui": "0.3.
|
|
32
|
-
"@frockbot/configuration-core": "0.3.
|
|
33
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
34
|
-
"@frockbot/plugin-shell": "0.3.
|
|
31
|
+
"@frockbot/client-core": "0.3.17",
|
|
32
|
+
"@frockbot/client-ui": "0.3.17",
|
|
33
|
+
"@frockbot/configuration-core": "0.3.17",
|
|
34
|
+
"@frockbot/kernel-contracts": "0.3.17",
|
|
35
|
+
"@frockbot/plugin-shell": "0.3.17",
|
|
35
36
|
"cordis": "4.0.0-rc.8",
|
|
36
37
|
"vue": "3.5.41"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
40
|
+
"@frockbot/plugin-testkit": "0.3.17",
|
|
40
41
|
"@types/bun": "1.4.0",
|
|
41
42
|
"@vitejs/plugin-vue": "6.0.8",
|
|
42
43
|
"css-tree": "2.3.1",
|
package/src/agent.test.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
18
18
|
import {
|
|
19
19
|
createBotCreateTool,
|
|
20
|
+
createBotMessageTool,
|
|
21
|
+
createTeammatesPromptSectionV1,
|
|
20
22
|
createBotUpdateTool,
|
|
21
23
|
createdBotIdV1,
|
|
22
24
|
decodeBotUpdateInputV1,
|
|
@@ -162,6 +164,12 @@ function harness(initial?: Partial<BotSettingsViewV1>): Harness {
|
|
|
162
164
|
};
|
|
163
165
|
},
|
|
164
166
|
listBots: () => flock.listBots(),
|
|
167
|
+
messageBot: async (request) => ({
|
|
168
|
+
targetBotId: request.targetBotId,
|
|
169
|
+
targetBotName: "Teammate",
|
|
170
|
+
runId: `agent-${request.effectId}`,
|
|
171
|
+
text: "Teammate answer",
|
|
172
|
+
}),
|
|
165
173
|
createBot: (command) =>
|
|
166
174
|
// The command crosses the User Durable Object seam, so it decodes on
|
|
167
175
|
// the way in exactly as the production RPC does.
|
|
@@ -390,3 +398,34 @@ describe("the self-management seam", () => {
|
|
|
390
398
|
expect(createBotCreateTool(host).admission).toBeUndefined();
|
|
391
399
|
});
|
|
392
400
|
});
|
|
401
|
+
|
|
402
|
+
describe("bot_message", () => {
|
|
403
|
+
test("returns the target Bot's reply as the tool result", async () => {
|
|
404
|
+
const test1 = harness();
|
|
405
|
+
const result = await createBotMessageTool(test1.host).execute(
|
|
406
|
+
{ target_id: "researcher", message: "What changed?" },
|
|
407
|
+
CONTEXT,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(result).toEqual({ content: "Teammate answer", isError: false });
|
|
411
|
+
expect(createBotMessageTool(test1.host).idempotent).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("the teammates section names the other Bots and their descriptions", async () => {
|
|
415
|
+
const test1 = harness();
|
|
416
|
+
await createBotCreateTool(test1.host).execute(
|
|
417
|
+
{ name: "Researcher", description: "Finds primary sources." },
|
|
418
|
+
CONTEXT,
|
|
419
|
+
);
|
|
420
|
+
const prompt = await createTeammatesPromptSectionV1(test1.host).render({
|
|
421
|
+
sessionId: "user-1:bot-1",
|
|
422
|
+
provider: "test",
|
|
423
|
+
model: "test",
|
|
424
|
+
turnType: "chat",
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(prompt).toContain("<teammates>");
|
|
428
|
+
expect(prompt).toContain("Researcher");
|
|
429
|
+
expect(prompt).toContain("Finds primary sources.");
|
|
430
|
+
});
|
|
431
|
+
});
|
package/src/agent.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// The Flock runtime Contribution: a Bot's
|
|
1
|
+
// The Flock runtime Contribution: a Bot's flock tools and prompt context.
|
|
2
2
|
//
|
|
3
|
-
// GrokBot
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// GrokBot's self-management surface (§2.12) has `UpdateAgent`, where only the
|
|
4
|
+
// fields the call carries change, and `CreateAgent`, which makes a new agent
|
|
5
|
+
// in the same user's flock. **There is no delete tool** — deletion is a
|
|
6
6
|
// user-only action — and this Package matches that: `bot_update` cannot
|
|
7
|
-
// archive, restore, or remove anything
|
|
7
|
+
// archive, restore, or remove anything. Direct Bot messaging is the separate
|
|
8
|
+
// `bot_message` capability and does not mutate either Bot.
|
|
8
9
|
//
|
|
9
|
-
// AUTHORITY. "Self-modification never widens authority."
|
|
10
|
+
// AUTHORITY. "Self-modification never widens authority." The mutation tools run
|
|
10
11
|
// through paths the Bot's User already owns:
|
|
11
12
|
//
|
|
12
13
|
// - `bot_update` issues the same `bot/set-profile` command the settings UI
|
|
@@ -48,18 +49,23 @@ import {
|
|
|
48
49
|
type OperationReceiptV1,
|
|
49
50
|
} from "@frockbot/configuration-core";
|
|
50
51
|
import type {
|
|
52
|
+
PromptSection,
|
|
51
53
|
ToolDefinition,
|
|
52
54
|
ToolExecutionContext,
|
|
53
55
|
ToolExecutionResult,
|
|
56
|
+
TurnTypeV1,
|
|
54
57
|
} from "@frockbot/kernel-contracts";
|
|
58
|
+
import { decodeTurnTypeV1 } from "@frockbot/kernel-contracts";
|
|
55
59
|
import type { Plugin } from "cordis";
|
|
56
60
|
import {
|
|
57
61
|
FlockConflictError,
|
|
62
|
+
isFlockIdentifier,
|
|
58
63
|
randomSheepRecipeV1,
|
|
59
64
|
type BotDirectoryViewV1,
|
|
60
65
|
type CreateBotCommandV1,
|
|
61
66
|
type FlockReceiptV1,
|
|
62
67
|
} from "./shared.js";
|
|
68
|
+
import manifest from "../frockbot.json" with { type: "json" };
|
|
63
69
|
export type {
|
|
64
70
|
BotDirectoryViewV1,
|
|
65
71
|
CreateBotCommandV1,
|
|
@@ -79,10 +85,9 @@ export interface FlockSelfOwnerV1 {
|
|
|
79
85
|
* not registered at all: a Bot changes itself only inside a Turn whose Session
|
|
80
86
|
* and Turn its provenance can name.
|
|
81
87
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* here except through these four calls.
|
|
88
|
+
* This Package holds no authority: the Bot Durable Object owns profiles and
|
|
89
|
+
* Turn admission, while the User Durable Object owns the Flock directory and
|
|
90
|
+
* concurrency slots. Each is reachable only through the narrow calls below.
|
|
86
91
|
*/
|
|
87
92
|
export interface FlockSelfRuntimeHostV1 {
|
|
88
93
|
owner: FlockSelfOwnerV1;
|
|
@@ -98,8 +103,31 @@ export interface FlockSelfRuntimeHostV1 {
|
|
|
98
103
|
listBots(): Promise<BotDirectoryViewV1>;
|
|
99
104
|
/** The User's own `bot/create` path, and no wider. */
|
|
100
105
|
createBot(command: CreateBotCommandV1): Promise<FlockReceiptV1>;
|
|
106
|
+
/** Ask another Bot registered to this same User. */
|
|
107
|
+
messageBot(request: BotMessageRequestV1): Promise<BotMessageOutcomeV1>;
|
|
108
|
+
/** Sender cue for an inbound agent Turn, when this is one. */
|
|
109
|
+
inboundAgent?:
|
|
110
|
+
{ kind: "bot"; fromBotId: string; fromBotName: string } | { kind: "voice" };
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
export interface BotMessageRequestV1 {
|
|
114
|
+
targetBotId: string;
|
|
115
|
+
message: string;
|
|
116
|
+
effectId: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface BotMessageOutcomeV1 {
|
|
120
|
+
targetBotId: string;
|
|
121
|
+
targetBotName: string;
|
|
122
|
+
runId: string;
|
|
123
|
+
text: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const BOT_MESSAGE_TOOL_V1 = "bot_message";
|
|
127
|
+
export const BOT_MESSAGING_CAPABILITY_V1 = "bot-messaging";
|
|
128
|
+
export const TEAMMATES_PROMPT_SECTION_V1 = "teammates";
|
|
129
|
+
export const INBOUND_AGENT_PROMPT_SECTION_V1 = "agent-message";
|
|
130
|
+
|
|
103
131
|
/** How many times a command is re-issued after losing an optimistic race. */
|
|
104
132
|
const REVISION_RETRIES = 3;
|
|
105
133
|
|
|
@@ -159,6 +187,22 @@ const BOT_CREATE_SCHEMA = {
|
|
|
159
187
|
additionalProperties: false,
|
|
160
188
|
} as const;
|
|
161
189
|
|
|
190
|
+
const BOT_MESSAGE_SCHEMA = {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
target_id: {
|
|
194
|
+
type: "string",
|
|
195
|
+
description: "The id of another Bot listed in <teammates>.",
|
|
196
|
+
},
|
|
197
|
+
message: {
|
|
198
|
+
type: "string",
|
|
199
|
+
description: "The complete question or task to send to that Bot.",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ["target_id", "message"],
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
} as const;
|
|
205
|
+
|
|
162
206
|
interface BotUpdateInputV1 {
|
|
163
207
|
profile: BotProfilePatchV1;
|
|
164
208
|
notifyOnUpdates?: boolean;
|
|
@@ -169,6 +213,11 @@ interface BotCreateInputV1 {
|
|
|
169
213
|
description?: string;
|
|
170
214
|
}
|
|
171
215
|
|
|
216
|
+
interface BotMessageInputV1 {
|
|
217
|
+
targetId: string;
|
|
218
|
+
message: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
172
221
|
function fields(
|
|
173
222
|
input: unknown,
|
|
174
223
|
allowed: readonly string[],
|
|
@@ -266,6 +315,37 @@ export function decodeBotCreateInputV1(input: unknown): BotCreateInputV1 {
|
|
|
266
315
|
};
|
|
267
316
|
}
|
|
268
317
|
|
|
318
|
+
export function decodeBotMessageInputV1(input: unknown): BotMessageInputV1 {
|
|
319
|
+
const value = fields(input, ["target_id", "message"]);
|
|
320
|
+
if (!isFlockIdentifier(value.target_id)) {
|
|
321
|
+
throw new Error("target_id is invalid");
|
|
322
|
+
}
|
|
323
|
+
const message = patchText(value.message, "message", 32_000);
|
|
324
|
+
if (!message) throw new Error("message must not be empty");
|
|
325
|
+
return { targetId: value.target_id, message };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function flockAdmissionCeilingV1(
|
|
329
|
+
capabilityId: string,
|
|
330
|
+
): readonly TurnTypeV1[] | undefined {
|
|
331
|
+
const capabilities = (
|
|
332
|
+
manifest as {
|
|
333
|
+
configuration?: {
|
|
334
|
+
capabilities?: Array<{
|
|
335
|
+
id: string;
|
|
336
|
+
admission?: { turnTypes: string[] };
|
|
337
|
+
}>;
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
).configuration?.capabilities;
|
|
341
|
+
const turnTypes = capabilities?.find(
|
|
342
|
+
(candidate) => candidate.id === capabilityId,
|
|
343
|
+
)?.admission?.turnTypes;
|
|
344
|
+
return turnTypes?.map((value) =>
|
|
345
|
+
decodeTurnTypeV1(value, `flock capability "${capabilityId}" admission`),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
269
349
|
function refusal(reason: string): ToolExecutionResult {
|
|
270
350
|
return { content: reason, isError: true };
|
|
271
351
|
}
|
|
@@ -549,24 +629,128 @@ export function createBotCreateTool(
|
|
|
549
629
|
};
|
|
550
630
|
}
|
|
551
631
|
|
|
632
|
+
export function createBotMessageTool(
|
|
633
|
+
host: FlockSelfRuntimeHostV1,
|
|
634
|
+
): ToolDefinition {
|
|
635
|
+
return {
|
|
636
|
+
name: BOT_MESSAGE_TOOL_V1,
|
|
637
|
+
description:
|
|
638
|
+
"Ask one of your User's other Bots a question. Use a target_id from <teammates>. The other Bot runs an agent Turn and its send_to_user-style reply is returned here as this tool result. Do not message yourself or fan out speculatively.",
|
|
639
|
+
inputSchema: BOT_MESSAGE_SCHEMA as unknown as Record<string, unknown>,
|
|
640
|
+
idempotent: true,
|
|
641
|
+
validate: (input) => {
|
|
642
|
+
try {
|
|
643
|
+
decodeBotMessageInputV1(input);
|
|
644
|
+
return true;
|
|
645
|
+
} catch {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
execute: async (input: unknown, context: ToolExecutionContext) => {
|
|
650
|
+
let decoded: BotMessageInputV1;
|
|
651
|
+
try {
|
|
652
|
+
decoded = decodeBotMessageInputV1(input);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
return refusal(
|
|
655
|
+
`bot_message was refused: ${error instanceof Error ? error.message : String(error)}`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (decoded.targetId === host.owner.botId) {
|
|
659
|
+
return refusal("bot_message was refused: a Bot cannot message itself");
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
const outcome = await host.messageBot({
|
|
663
|
+
targetBotId: decoded.targetId,
|
|
664
|
+
message: decoded.message,
|
|
665
|
+
effectId: context.effectId,
|
|
666
|
+
});
|
|
667
|
+
return { content: outcome.text, isError: false };
|
|
668
|
+
} catch (error) {
|
|
669
|
+
return refusal(
|
|
670
|
+
`bot_message failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function promptText(value: string): string {
|
|
678
|
+
return value.replace(/[<>]/g, (character) =>
|
|
679
|
+
character === "<" ? "<" : ">",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function createTeammatesPromptSectionV1(
|
|
684
|
+
host: FlockSelfRuntimeHostV1,
|
|
685
|
+
): PromptSection {
|
|
686
|
+
return {
|
|
687
|
+
id: TEAMMATES_PROMPT_SECTION_V1,
|
|
688
|
+
order: 92,
|
|
689
|
+
render: async (context) => {
|
|
690
|
+
if (context.turnType !== "chat") return "";
|
|
691
|
+
const teammates = (await host.listBots()).bots.filter(
|
|
692
|
+
(bot) => bot.botId !== host.owner.botId,
|
|
693
|
+
);
|
|
694
|
+
if (teammates.length === 0) return "";
|
|
695
|
+
const lines = teammates.map((bot) => {
|
|
696
|
+
const description = bot.initialDescription?.trim();
|
|
697
|
+
return `- ${promptText(bot.botId)}: ${promptText(bot.initialName)}${
|
|
698
|
+
description ? ` — ${promptText(description)}` : ""
|
|
699
|
+
}`;
|
|
700
|
+
});
|
|
701
|
+
return [
|
|
702
|
+
"<teammates>",
|
|
703
|
+
"These are the other Bots owned by your User. Use bot_message only when another Bot's perspective or specialty is materially useful.",
|
|
704
|
+
...lines,
|
|
705
|
+
"</teammates>",
|
|
706
|
+
].join("\n");
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export function createInboundAgentPromptSectionV1(
|
|
712
|
+
host: FlockSelfRuntimeHostV1,
|
|
713
|
+
): PromptSection {
|
|
714
|
+
return {
|
|
715
|
+
id: INBOUND_AGENT_PROMPT_SECTION_V1,
|
|
716
|
+
order: 93,
|
|
717
|
+
render: (context) => {
|
|
718
|
+
if (context.turnType !== "agent" || !host.inboundAgent) return "";
|
|
719
|
+
if (host.inboundAgent.kind === "voice") {
|
|
720
|
+
return "The User's Voice session asked you the current question. Answer it directly with send_to_user; that answer returns to Voice.";
|
|
721
|
+
}
|
|
722
|
+
return `Bot ${promptText(host.inboundAgent.fromBotName)} (${promptText(host.inboundAgent.fromBotId)}) asked you the current question. Answer it directly with send_to_user; that answer returns to the asking Bot.`;
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
552
727
|
/**
|
|
553
|
-
* The runtime Contribution.
|
|
554
|
-
*
|
|
555
|
-
*
|
|
728
|
+
* The runtime Contribution. The two self-management tools remain work tools
|
|
729
|
+
* on every turn type. `bot_message` is separately bounded to chat by the
|
|
730
|
+
* manifest so an inbound agent Turn cannot recursively fan out.
|
|
556
731
|
*/
|
|
557
732
|
export function createFlockRuntimePlugin(
|
|
558
733
|
host: FlockSelfRuntimeHostV1,
|
|
559
734
|
): Plugin.Function {
|
|
560
735
|
const plugin: Plugin.Function = (ctx) => {
|
|
736
|
+
const messagingCeiling = flockAdmissionCeilingV1(
|
|
737
|
+
BOT_MESSAGING_CAPABILITY_V1,
|
|
738
|
+
);
|
|
561
739
|
const disposers = [
|
|
740
|
+
ctx.systemPrompt.register(createTeammatesPromptSectionV1(host)),
|
|
741
|
+
ctx.systemPrompt.register(createInboundAgentPromptSectionV1(host)),
|
|
562
742
|
ctx.tools.register(createBotUpdateTool(host)),
|
|
563
743
|
ctx.tools.register(createBotCreateTool(host)),
|
|
744
|
+
ctx.tools.register(
|
|
745
|
+
createBotMessageTool(host),
|
|
746
|
+
messagingCeiling ? { admissionCeiling: messagingCeiling } : undefined,
|
|
747
|
+
),
|
|
564
748
|
];
|
|
565
749
|
return () => {
|
|
566
750
|
for (const dispose of disposers.toReversed()) dispose();
|
|
567
751
|
};
|
|
568
752
|
};
|
|
569
|
-
plugin.inject = ["tools"];
|
|
753
|
+
plugin.inject = ["tools", "systemPrompt"];
|
|
570
754
|
return plugin;
|
|
571
755
|
}
|
|
572
756
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
AGENT_TURN_CONCURRENCY_PER_USER_V1,
|
|
4
|
+
releaseAgentTurnSlotV1,
|
|
5
|
+
reserveAgentTurnSlotV1,
|
|
6
|
+
type AgentTurnSlotTransactionV1,
|
|
7
|
+
} from "./quota.js";
|
|
8
|
+
|
|
9
|
+
function storage() {
|
|
10
|
+
const records = new Map<string, unknown>();
|
|
11
|
+
const transaction: AgentTurnSlotTransactionV1 = {
|
|
12
|
+
list: async <T>({ prefix }: { prefix: string }) =>
|
|
13
|
+
new Map([...records].filter(([key]) => key.startsWith(prefix))) as Map<
|
|
14
|
+
string,
|
|
15
|
+
T
|
|
16
|
+
>,
|
|
17
|
+
put: async (key, value) => void records.set(key, value),
|
|
18
|
+
delete: async (key) => records.delete(key),
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
...transaction,
|
|
22
|
+
transaction: async <T>(
|
|
23
|
+
callback: (tx: AgentTurnSlotTransactionV1) => Promise<T>,
|
|
24
|
+
) => callback(transaction),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function request(requesterId: string, runId: string) {
|
|
29
|
+
return {
|
|
30
|
+
schemaVersion: 1 as const,
|
|
31
|
+
userId: "user",
|
|
32
|
+
requesterId,
|
|
33
|
+
runId,
|
|
34
|
+
reservedAt: "2026-09-04T00:00:00.000Z",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("agent Turn concurrency", () => {
|
|
39
|
+
test("is one fixed per-User budget across requesting Bots", async () => {
|
|
40
|
+
const state = storage();
|
|
41
|
+
for (
|
|
42
|
+
let index = 0;
|
|
43
|
+
index < AGENT_TURN_CONCURRENCY_PER_USER_V1;
|
|
44
|
+
index += 1
|
|
45
|
+
) {
|
|
46
|
+
expect(
|
|
47
|
+
await reserveAgentTurnSlotV1(
|
|
48
|
+
state,
|
|
49
|
+
request(index % 2 ? "bot-a" : "bot-b", `run-${index}`),
|
|
50
|
+
),
|
|
51
|
+
).toMatchObject({ status: "reserved" });
|
|
52
|
+
}
|
|
53
|
+
expect(
|
|
54
|
+
await reserveAgentTurnSlotV1(state, request("bot-c", "run-past")),
|
|
55
|
+
).toMatchObject({ status: "refused", held: 8, limit: 8 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reserve and release are idempotent for one run", async () => {
|
|
59
|
+
const state = storage();
|
|
60
|
+
await reserveAgentTurnSlotV1(state, request("bot-a", "run-1"));
|
|
61
|
+
expect(
|
|
62
|
+
await reserveAgentTurnSlotV1(state, request("bot-a", "run-1")),
|
|
63
|
+
).toMatchObject({ status: "reserved", held: 1 });
|
|
64
|
+
expect(
|
|
65
|
+
await releaseAgentTurnSlotV1(state, {
|
|
66
|
+
requesterId: "bot-a",
|
|
67
|
+
runId: "run-1",
|
|
68
|
+
}),
|
|
69
|
+
).toMatchObject({ held: 0 });
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Durable per-User concurrency budget for agent-lane Turns. */
|
|
2
|
+
export const AGENT_TURN_CONCURRENCY_PER_USER_V1 = 8;
|
|
3
|
+
export const AGENT_TURN_SLOT_PREFIX_V1 = "agent-turn:slot:";
|
|
4
|
+
|
|
5
|
+
const ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,255}$/;
|
|
6
|
+
|
|
7
|
+
export interface AgentTurnSlotRequestV1 {
|
|
8
|
+
schemaVersion: 1;
|
|
9
|
+
userId: string;
|
|
10
|
+
requesterId: string;
|
|
11
|
+
runId: string;
|
|
12
|
+
reservedAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AgentTurnSlotReceiptV1 =
|
|
16
|
+
| {
|
|
17
|
+
schemaVersion: 1;
|
|
18
|
+
status: "reserved";
|
|
19
|
+
requesterId: string;
|
|
20
|
+
runId: string;
|
|
21
|
+
held: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
status: "refused";
|
|
27
|
+
requesterId: string;
|
|
28
|
+
runId: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
held: number;
|
|
31
|
+
limit: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface AgentTurnSlotTransactionV1 {
|
|
35
|
+
list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
|
|
36
|
+
put(key: string, value: unknown): Promise<void>;
|
|
37
|
+
delete(key: string): Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AgentTurnSlotStorageV1 extends AgentTurnSlotTransactionV1 {
|
|
41
|
+
transaction<T>(
|
|
42
|
+
callback: (storage: AgentTurnSlotTransactionV1) => Promise<T>,
|
|
43
|
+
): Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function agentTurnSlotKeyV1(requesterId: string, runId: string): string {
|
|
47
|
+
if (!ID.test(requesterId) || !ID.test(runId)) {
|
|
48
|
+
throw new Error("agent Turn slot key is invalid");
|
|
49
|
+
}
|
|
50
|
+
return `${AGENT_TURN_SLOT_PREFIX_V1}${requesterId}:${runId}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function reserveAgentTurnSlotV1(
|
|
54
|
+
storage: AgentTurnSlotStorageV1,
|
|
55
|
+
request: AgentTurnSlotRequestV1,
|
|
56
|
+
): Promise<AgentTurnSlotReceiptV1> {
|
|
57
|
+
const key = agentTurnSlotKeyV1(request.requesterId, request.runId);
|
|
58
|
+
return storage.transaction(async (transaction) => {
|
|
59
|
+
const held = await transaction.list({ prefix: AGENT_TURN_SLOT_PREFIX_V1 });
|
|
60
|
+
if (held.has(key)) {
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
status: "reserved",
|
|
64
|
+
requesterId: request.requesterId,
|
|
65
|
+
runId: request.runId,
|
|
66
|
+
held: held.size,
|
|
67
|
+
limit: AGENT_TURN_CONCURRENCY_PER_USER_V1,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (held.size >= AGENT_TURN_CONCURRENCY_PER_USER_V1) {
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
status: "refused",
|
|
74
|
+
requesterId: request.requesterId,
|
|
75
|
+
runId: request.runId,
|
|
76
|
+
reason: `this User already has ${held.size} agent Turns running; the bound is ${AGENT_TURN_CONCURRENCY_PER_USER_V1}`,
|
|
77
|
+
held: held.size,
|
|
78
|
+
limit: AGENT_TURN_CONCURRENCY_PER_USER_V1,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
await transaction.put(key, {
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
requesterId: request.requesterId,
|
|
84
|
+
runId: request.runId,
|
|
85
|
+
reservedAt: request.reservedAt,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
schemaVersion: 1,
|
|
89
|
+
status: "reserved",
|
|
90
|
+
requesterId: request.requesterId,
|
|
91
|
+
runId: request.runId,
|
|
92
|
+
held: held.size + 1,
|
|
93
|
+
limit: AGENT_TURN_CONCURRENCY_PER_USER_V1,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function releaseAgentTurnSlotV1(
|
|
99
|
+
storage: AgentTurnSlotStorageV1,
|
|
100
|
+
request: { requesterId: string; runId: string },
|
|
101
|
+
): Promise<{ schemaVersion: 1; status: "released"; held: number }> {
|
|
102
|
+
const key = agentTurnSlotKeyV1(request.requesterId, request.runId);
|
|
103
|
+
return storage.transaction(async (transaction) => {
|
|
104
|
+
await transaction.delete(key);
|
|
105
|
+
const held = await transaction.list({ prefix: AGENT_TURN_SLOT_PREFIX_V1 });
|
|
106
|
+
return { schemaVersion: 1, status: "released", held: held.size };
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function decodeAgentTurnSlotReceiptV1(
|
|
111
|
+
input: unknown,
|
|
112
|
+
): AgentTurnSlotReceiptV1 {
|
|
113
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
114
|
+
throw new Error("agent Turn slot receipt must be an object");
|
|
115
|
+
}
|
|
116
|
+
const value = input as Record<string, unknown>;
|
|
117
|
+
const requesterId = value.requesterId;
|
|
118
|
+
const runId = value.runId;
|
|
119
|
+
const held = value.held;
|
|
120
|
+
const limit = value.limit;
|
|
121
|
+
if (
|
|
122
|
+
value.schemaVersion !== 1 ||
|
|
123
|
+
(value.status !== "reserved" && value.status !== "refused") ||
|
|
124
|
+
typeof requesterId !== "string" ||
|
|
125
|
+
typeof runId !== "string" ||
|
|
126
|
+
!Number.isSafeInteger(held) ||
|
|
127
|
+
!Number.isSafeInteger(limit)
|
|
128
|
+
) {
|
|
129
|
+
throw new Error("agent Turn slot receipt is invalid");
|
|
130
|
+
}
|
|
131
|
+
if (value.status === "refused") {
|
|
132
|
+
if (typeof value.reason !== "string" || !value.reason) {
|
|
133
|
+
throw new Error("agent Turn slot refusal is invalid");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
status: "refused",
|
|
138
|
+
requesterId,
|
|
139
|
+
runId,
|
|
140
|
+
reason: value.reason,
|
|
141
|
+
held: held as number,
|
|
142
|
+
limit: limit as number,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
status: "reserved",
|
|
148
|
+
requesterId,
|
|
149
|
+
runId,
|
|
150
|
+
held: held as number,
|
|
151
|
+
limit: limit as number,
|
|
152
|
+
};
|
|
153
|
+
}
|