@frockbot/plugin-flock 0.3.15 → 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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 3,
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.15",
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.15",
31
- "@frockbot/client-ui": "0.3.15",
32
- "@frockbot/configuration-core": "0.3.15",
33
- "@frockbot/kernel-contracts": "0.3.15",
34
- "@frockbot/plugin-shell": "0.3.15",
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.15",
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 own self-management tools.
1
+ // The Flock runtime Contribution: a Bot's flock tools and prompt context.
2
2
  //
3
- // GrokBot exposes two of these and no more (§2.12): `UpdateAgent`, where only
4
- // the fields the call carries change, and `CreateAgent`, which makes a new
5
- // agent in the same user's flock. **There is no delete tool** — deletion is a
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, and no third tool exists to do it.
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." Both tools run
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
- * Every method is a command the User's own surfaces already issue. This
83
- * Package holds no authority: the Bot Durable Object owns the profile, the
84
- * User Durable Object owns the Flock directory, and neither is reachable from
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 === "<" ? "&lt;" : "&gt;",
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. Registers the two self-management tools; both are
554
- * work tools, offered on every turn type, because an automation or a subagent
555
- * Turn is as entitled to correct its own title as a chat Turn is.
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
+ }