@buoy-gg/agent-core 7.0.33

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.
Files changed (138) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +42 -0
  3. package/lib/commonjs/blocks/receipts.js +702 -0
  4. package/lib/commonjs/blocks/receipts.js.map +1 -0
  5. package/lib/commonjs/blocks/types.js +119 -0
  6. package/lib/commonjs/blocks/types.js.map +1 -0
  7. package/lib/commonjs/blocks/uiTool.js +994 -0
  8. package/lib/commonjs/blocks/uiTool.js.map +1 -0
  9. package/lib/commonjs/catalog/catalog.g.js +4044 -0
  10. package/lib/commonjs/catalog/catalog.g.js.map +1 -0
  11. package/lib/commonjs/catalog/catalog.source.json +4956 -0
  12. package/lib/commonjs/catalog/snapshotReads.js +256 -0
  13. package/lib/commonjs/catalog/snapshotReads.js.map +1 -0
  14. package/lib/commonjs/catalog/toProviderTools.js +107 -0
  15. package/lib/commonjs/catalog/toProviderTools.js.map +1 -0
  16. package/lib/commonjs/catalog/validateParams.js +129 -0
  17. package/lib/commonjs/catalog/validateParams.js.map +1 -0
  18. package/lib/commonjs/context/buildContextPack.js +141 -0
  19. package/lib/commonjs/context/buildContextPack.js.map +1 -0
  20. package/lib/commonjs/effects/ledger.js +229 -0
  21. package/lib/commonjs/effects/ledger.js.map +1 -0
  22. package/lib/commonjs/engine/effectFor.js +95 -0
  23. package/lib/commonjs/engine/effectFor.js.map +1 -0
  24. package/lib/commonjs/engine/runAgentTurn.js +667 -0
  25. package/lib/commonjs/engine/runAgentTurn.js.map +1 -0
  26. package/lib/commonjs/engine/systemPrompt.js +86 -0
  27. package/lib/commonjs/engine/systemPrompt.js.map +1 -0
  28. package/lib/commonjs/index.js +288 -0
  29. package/lib/commonjs/index.js.map +1 -0
  30. package/lib/commonjs/package.json +1 -0
  31. package/lib/commonjs/policy/labels.js +303 -0
  32. package/lib/commonjs/policy/labels.js.map +1 -0
  33. package/lib/commonjs/policy/policy.js +143 -0
  34. package/lib/commonjs/policy/policy.js.map +1 -0
  35. package/lib/commonjs/policy/redact.js +135 -0
  36. package/lib/commonjs/policy/redact.js.map +1 -0
  37. package/lib/commonjs/providers/anthropic.js +272 -0
  38. package/lib/commonjs/providers/anthropic.js.map +1 -0
  39. package/lib/commonjs/providers/openai.js +193 -0
  40. package/lib/commonjs/providers/openai.js.map +1 -0
  41. package/lib/commonjs/providers/sse.js +104 -0
  42. package/lib/commonjs/providers/sse.js.map +1 -0
  43. package/lib/commonjs/providers/types.js +6 -0
  44. package/lib/commonjs/providers/types.js.map +1 -0
  45. package/lib/commonjs/session.js +173 -0
  46. package/lib/commonjs/session.js.map +1 -0
  47. package/lib/commonjs/types.js +2 -0
  48. package/lib/commonjs/types.js.map +1 -0
  49. package/lib/module/blocks/receipts.js +696 -0
  50. package/lib/module/blocks/receipts.js.map +1 -0
  51. package/lib/module/blocks/types.js +113 -0
  52. package/lib/module/blocks/types.js.map +1 -0
  53. package/lib/module/blocks/uiTool.js +987 -0
  54. package/lib/module/blocks/uiTool.js.map +1 -0
  55. package/lib/module/catalog/catalog.g.js +4040 -0
  56. package/lib/module/catalog/catalog.g.js.map +1 -0
  57. package/lib/module/catalog/catalog.source.json +4956 -0
  58. package/lib/module/catalog/snapshotReads.js +252 -0
  59. package/lib/module/catalog/snapshotReads.js.map +1 -0
  60. package/lib/module/catalog/toProviderTools.js +101 -0
  61. package/lib/module/catalog/toProviderTools.js.map +1 -0
  62. package/lib/module/catalog/validateParams.js +124 -0
  63. package/lib/module/catalog/validateParams.js.map +1 -0
  64. package/lib/module/context/buildContextPack.js +138 -0
  65. package/lib/module/context/buildContextPack.js.map +1 -0
  66. package/lib/module/effects/ledger.js +223 -0
  67. package/lib/module/effects/ledger.js.map +1 -0
  68. package/lib/module/engine/effectFor.js +91 -0
  69. package/lib/module/engine/effectFor.js.map +1 -0
  70. package/lib/module/engine/runAgentTurn.js +663 -0
  71. package/lib/module/engine/runAgentTurn.js.map +1 -0
  72. package/lib/module/engine/systemPrompt.js +82 -0
  73. package/lib/module/engine/systemPrompt.js.map +1 -0
  74. package/lib/module/index.js +30 -0
  75. package/lib/module/index.js.map +1 -0
  76. package/lib/module/policy/labels.js +296 -0
  77. package/lib/module/policy/labels.js.map +1 -0
  78. package/lib/module/policy/policy.js +137 -0
  79. package/lib/module/policy/policy.js.map +1 -0
  80. package/lib/module/policy/redact.js +128 -0
  81. package/lib/module/policy/redact.js.map +1 -0
  82. package/lib/module/providers/anthropic.js +268 -0
  83. package/lib/module/providers/anthropic.js.map +1 -0
  84. package/lib/module/providers/openai.js +189 -0
  85. package/lib/module/providers/openai.js.map +1 -0
  86. package/lib/module/providers/sse.js +99 -0
  87. package/lib/module/providers/sse.js.map +1 -0
  88. package/lib/module/providers/types.js +4 -0
  89. package/lib/module/providers/types.js.map +1 -0
  90. package/lib/module/session.js +168 -0
  91. package/lib/module/session.js.map +1 -0
  92. package/lib/module/types.js +2 -0
  93. package/lib/module/types.js.map +1 -0
  94. package/lib/typescript/blocks/receipts.d.ts +32 -0
  95. package/lib/typescript/blocks/receipts.d.ts.map +1 -0
  96. package/lib/typescript/blocks/types.d.ts +576 -0
  97. package/lib/typescript/blocks/types.d.ts.map +1 -0
  98. package/lib/typescript/blocks/uiTool.d.ts +15 -0
  99. package/lib/typescript/blocks/uiTool.d.ts.map +1 -0
  100. package/lib/typescript/catalog/catalog.g.d.ts +11 -0
  101. package/lib/typescript/catalog/catalog.g.d.ts.map +1 -0
  102. package/lib/typescript/catalog/snapshotReads.d.ts +37 -0
  103. package/lib/typescript/catalog/snapshotReads.d.ts.map +1 -0
  104. package/lib/typescript/catalog/toProviderTools.d.ts +52 -0
  105. package/lib/typescript/catalog/toProviderTools.d.ts.map +1 -0
  106. package/lib/typescript/catalog/validateParams.d.ts +33 -0
  107. package/lib/typescript/catalog/validateParams.d.ts.map +1 -0
  108. package/lib/typescript/context/buildContextPack.d.ts +32 -0
  109. package/lib/typescript/context/buildContextPack.d.ts.map +1 -0
  110. package/lib/typescript/effects/ledger.d.ts +119 -0
  111. package/lib/typescript/effects/ledger.d.ts.map +1 -0
  112. package/lib/typescript/engine/effectFor.d.ts +21 -0
  113. package/lib/typescript/engine/effectFor.d.ts.map +1 -0
  114. package/lib/typescript/engine/runAgentTurn.d.ts +140 -0
  115. package/lib/typescript/engine/runAgentTurn.d.ts.map +1 -0
  116. package/lib/typescript/engine/systemPrompt.d.ts +49 -0
  117. package/lib/typescript/engine/systemPrompt.d.ts.map +1 -0
  118. package/lib/typescript/index.d.ts +29 -0
  119. package/lib/typescript/index.d.ts.map +1 -0
  120. package/lib/typescript/policy/labels.d.ts +10 -0
  121. package/lib/typescript/policy/labels.d.ts.map +1 -0
  122. package/lib/typescript/policy/policy.d.ts +106 -0
  123. package/lib/typescript/policy/policy.d.ts.map +1 -0
  124. package/lib/typescript/policy/redact.d.ts +37 -0
  125. package/lib/typescript/policy/redact.d.ts.map +1 -0
  126. package/lib/typescript/providers/anthropic.d.ts +22 -0
  127. package/lib/typescript/providers/anthropic.d.ts.map +1 -0
  128. package/lib/typescript/providers/openai.d.ts +19 -0
  129. package/lib/typescript/providers/openai.d.ts.map +1 -0
  130. package/lib/typescript/providers/sse.d.ts +18 -0
  131. package/lib/typescript/providers/sse.d.ts.map +1 -0
  132. package/lib/typescript/providers/types.d.ts +129 -0
  133. package/lib/typescript/providers/types.d.ts.map +1 -0
  134. package/lib/typescript/session.d.ts +58 -0
  135. package/lib/typescript/session.d.ts.map +1 -0
  136. package/lib/typescript/types.d.ts +119 -0
  137. package/lib/typescript/types.d.ts.map +1 -0
  138. package/package.json +58 -0
@@ -0,0 +1,663 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * One turn: user sentence in, tool calls and an answer out.
5
+ *
6
+ * The loop is deliberately explicit rather than clever, because every step of
7
+ * it encodes something that bites otherwise:
8
+ *
9
+ * - Validation happens BEFORE dispatch and a failure comes back as a readable
10
+ * tool_result, not an exception. Buoy's adapters validate nothing, so a
11
+ * wrong shape becomes an undefined read deep inside a tool — and an LLM
12
+ * omitting `replace` on `zustand.setState` replaces the entire store.
13
+ * - `app.reloadApp` is TERMINAL. It returns before the reload fires and then
14
+ * kills the JS realm; anything queued behind it dies with the conversation.
15
+ * - Results are truncated before they are sent. `dispatchToolAction` bypasses
16
+ * the 8MB wire cap that protects the broker path, so an in-app agent can
17
+ * receive a 50MB object and then try to feed it to a token-metered model.
18
+ * - Steps and wall-clock are capped, because a model that misreads a result
19
+ * will otherwise retry the same call until the user closes the sheet.
20
+ */
21
+
22
+ import { CATALOG } from "../catalog/catalog.g";
23
+ import { applyDefaults, validateParams } from "../catalog/validateParams";
24
+ import { fromProviderToolName, toProviderTools } from "../catalog/toProviderTools";
25
+ import { projectSnapshot, SNAPSHOT_ACTION } from "../catalog/snapshotReads";
26
+ import { isReversible } from "../effects/ledger";
27
+ import { describeCall, decide } from "../policy/policy";
28
+ import { redact, stripSelfTraffic } from "../policy/redact";
29
+ import { effectFor } from "./effectFor";
30
+ import { buildUiBlock, UI_TOOL_NAME, uiProviderTool } from "../blocks/uiTool";
31
+ import { projectReceipt } from "../blocks/receipts";
32
+ import { isAskBlock } from "../blocks/types";
33
+
34
+ /** Beyond this a tool result costs more in tokens than it can possibly be worth. */
35
+ const MAX_RESULT_CHARS = 24_000;
36
+ const DEFAULT_MAX_STEPS = 12;
37
+ const DEFAULT_TURN_MS = 180_000;
38
+ function findDescriptor(catalog, toolId, action) {
39
+ return catalog.find(t => t.toolId === toolId)?.actions.find(a => a.action === action);
40
+ }
41
+ function encodeResult(value) {
42
+ let text;
43
+ try {
44
+ text = JSON.stringify(value ?? null);
45
+ } catch {
46
+ text = String(value);
47
+ }
48
+ if (text.length <= MAX_RESULT_CHARS) return text;
49
+ return `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated — ${text.length} characters total. Ask for a narrower slice if you need more.]`;
50
+ }
51
+
52
+ /** A short line for the transcript row, so the UI never renders raw payloads. */
53
+ /**
54
+ * The trailing status word on a transcript row — "12 items", "Not found",
55
+ * "Done". Written for the person watching, not the model (which gets the full
56
+ * result): a list of JSON key names ("found, storeName, …") told a QA tester
57
+ * nothing, so this reads the few result shapes that carry meaning and says
58
+ * "Done" for the rest.
59
+ */
60
+ function summarise(value) {
61
+ if (value === null || value === undefined) return "Done";
62
+ if (Array.isArray(value)) return countOf(value.length);
63
+ if (typeof value === "boolean") return value ? "Done" : "Failed";
64
+ if (typeof value === "object") {
65
+ const rec = value;
66
+ if (rec.ok === false) return typeof rec.error === "string" ? rec.error : "Failed";
67
+ if (rec.found === false) return "Not found";
68
+ if (rec.tapped === false) return "Nothing to tap";
69
+ if (rec.active === false) return "Saved, but inactive in this build";
70
+ for (const key of ["returned", "count", "total"]) {
71
+ const n = rec[key];
72
+ if (typeof n === "number") return countOf(n);
73
+ }
74
+ // `{requests:[…]}` → "12 items", but a snapshot with several lists
75
+ // (`{stack, routes, recentNavigations}`) has no single honest count.
76
+ const lists = Object.values(rec).filter(Array.isArray);
77
+ if (lists.length === 1) return countOf(lists[0].length);
78
+ return "Done";
79
+ }
80
+ return String(value).slice(0, 80);
81
+ }
82
+ function countOf(n) {
83
+ return n === 1 ? "1 item" : `${n} items`;
84
+ }
85
+ export async function* runAgentTurn(input) {
86
+ const {
87
+ provider,
88
+ catalog,
89
+ system,
90
+ model,
91
+ maxTokens,
92
+ dispatch,
93
+ readSnapshot,
94
+ storeKeyOwners,
95
+ ledger,
96
+ policy,
97
+ isRelease,
98
+ requestApproval,
99
+ signal
100
+ } = input;
101
+ const messages = [...input.messages];
102
+ const tools = toProviderTools(catalog, {
103
+ availableToolIds: input.availableToolIds,
104
+ availableActions: input.availableActions,
105
+ isRelease
106
+ });
107
+ tools.push(uiProviderTool());
108
+ const maxSteps = policy.maxSteps ?? DEFAULT_MAX_STEPS;
109
+ const deadline = Date.now() + DEFAULT_TURN_MS;
110
+
111
+ // Every http(s) URL that appeared in a tool result THIS turn. The prompt
112
+ // tells the model image URLs must come from here; this Set is the
113
+ // enforcement — a model-remembered or injected URL in an image block is
114
+ // both a fabrication vector and a data-exfil beacon (the GET carries
115
+ // whatever is encoded into the URL).
116
+ const seenUrls = new Set();
117
+ const URL_RE = /https?:\/\/[^\s"'\\)\]}>]+/g;
118
+ for (let step = 0; step < maxSteps; step++) {
119
+ if (signal?.aborted) {
120
+ yield {
121
+ type: "done"
122
+ };
123
+ return messages;
124
+ }
125
+ if (Date.now() > deadline) {
126
+ yield {
127
+ type: "error",
128
+ message: "This is taking too long — stopping here."
129
+ };
130
+ yield {
131
+ type: "done"
132
+ };
133
+ return messages;
134
+ }
135
+ let text = "";
136
+ const calls = [];
137
+ let failed = false;
138
+ for await (const ev of provider.send({
139
+ messages,
140
+ system,
141
+ tools,
142
+ model,
143
+ maxTokens,
144
+ signal
145
+ })) {
146
+ if (ev.type === "text") {
147
+ text += ev.delta;
148
+ yield {
149
+ type: "text",
150
+ delta: ev.delta
151
+ };
152
+ } else if (ev.type === "tool-call") {
153
+ calls.push(ev.call);
154
+ } else if (ev.type === "error") {
155
+ // A mid-stream error can arrive on an already-committed 200.
156
+ yield {
157
+ type: "error",
158
+ message: ev.message
159
+ };
160
+ failed = true;
161
+ } else if (ev.type === "done" && ev.usage) {
162
+ // Surfaced so a host can meter cost per seat. Parsed for a long time;
163
+ // went nowhere anyone could see.
164
+ yield {
165
+ type: "usage",
166
+ input: ev.usage.input,
167
+ output: ev.usage.output
168
+ };
169
+ }
170
+ }
171
+ if (failed) {
172
+ yield {
173
+ type: "done"
174
+ };
175
+ return messages;
176
+ }
177
+ messages.push({
178
+ role: "assistant",
179
+ text,
180
+ toolCalls: calls.length ? calls : undefined
181
+ });
182
+ if (calls.length === 0) {
183
+ yield {
184
+ type: "done"
185
+ };
186
+ return messages;
187
+ }
188
+ const results = [];
189
+ let realmWillDie = false;
190
+ let awaiting = null;
191
+ for (const call of calls) {
192
+ // `buoy_ui`: show or ask. Validated like any action; an Ask block ends
193
+ // the turn after this batch — the answer comes back as a user message.
194
+ if (call.name === UI_TOOL_NAME) {
195
+ const built = buildUiBlock(call.input);
196
+ if (!built.ok || !built.block) {
197
+ results.push({
198
+ toolCallId: call.id,
199
+ content: `Invalid ${UI_TOOL_NAME} block:\n${built.errors.join("\n")}`,
200
+ isError: true
201
+ });
202
+ continue;
203
+ }
204
+ // Provenance for pictures, ENFORCED: an image URL the model did not
205
+ // read from a tool result this turn does not render. Live or nothing.
206
+ if (built.block.kind === "imageGrid") {
207
+ const kept = built.block.images.filter(img => seenUrls.has(img.url));
208
+ if (kept.length === 0) {
209
+ results.push({
210
+ toolCallId: call.id,
211
+ content: "Rejected: every image URL must be one you read from a tool result THIS turn (images.list, a storage value, a response body). Never invent or remember URLs — read them first.",
212
+ isError: true
213
+ });
214
+ continue;
215
+ }
216
+ built.block = {
217
+ ...built.block,
218
+ images: kept
219
+ };
220
+ }
221
+ if (built.block.kind === "list") {
222
+ built.block = {
223
+ ...built.block,
224
+ items: built.block.items.map(item => item.image && !seenUrls.has(item.image) ? {
225
+ ...item,
226
+ image: undefined
227
+ } : item)
228
+ };
229
+ }
230
+ yield {
231
+ type: "block",
232
+ block: built.block,
233
+ forToolCallId: call.id
234
+ };
235
+ if (isAskBlock(built.block)) {
236
+ awaiting = built.block.id;
237
+ results.push({
238
+ toolCallId: call.id,
239
+ content: "Shown. Waiting for the user's answer — it will arrive as their next message. Do not guess it."
240
+ });
241
+ } else {
242
+ results.push({
243
+ toolCallId: call.id,
244
+ content: "Shown to the user. Don't repeat its contents in text."
245
+ });
246
+ }
247
+ continue;
248
+ }
249
+ const toolId = fromProviderToolName(call.name, catalog);
250
+ const action = call.input.action;
251
+
252
+ // Two different mistakes, kept apart on purpose. Merging them tells a
253
+ // model that forgot `action` that the TOOL does not exist, and it stops
254
+ // reaching for a tool that was fine — the same distinction
255
+ // `dispatchToolAction` draws between an unknown tool and an unknown
256
+ // action, for the same reason.
257
+ if (!toolId) {
258
+ results.push({
259
+ toolCallId: call.id,
260
+ content: `There is no tool called "${call.name}". Use one of the tools you were given.`,
261
+ isError: true
262
+ });
263
+ continue;
264
+ }
265
+ if (!action) {
266
+ results.push({
267
+ toolCallId: call.id,
268
+ content: `"${call.name}" needs an "action" — it is required, and it names which of this tool's actions to run. See the action list in the tool's description.`,
269
+ isError: true
270
+ });
271
+ continue;
272
+ }
273
+ const descriptor = findDescriptor(catalog, toolId, action);
274
+ if (!descriptor) {
275
+ // Name the alternatives. Without them a model that guessed an action
276
+ // has nowhere to go but guess again, and the next guess is no better
277
+ // informed than the last.
278
+ const known = catalog.find(t => t.toolId === toolId)?.actions.map(a => a.action).join(", ");
279
+ results.push({
280
+ toolCallId: call.id,
281
+ content: `"${action}" is not an action on ${toolId}.${known ? ` Valid actions: ${known}.` : ""}`,
282
+ isError: true
283
+ });
284
+ continue;
285
+ }
286
+ const raw = call.input.params ?? {};
287
+ const params = applyDefaults(raw, descriptor.params);
288
+ const check = validateParams(params, descriptor.params);
289
+ if (!check.ok) {
290
+ results.push({
291
+ toolCallId: call.id,
292
+ content: `Invalid parameters for ${toolId}.${action}:\n${check.errors.join("\n")}\n\nExpected schema:\n${JSON.stringify(descriptor.params)}`,
293
+ isError: true
294
+ });
295
+ continue;
296
+ }
297
+ const label = describeCall(toolId, descriptor, params);
298
+ const verdict = decide({
299
+ descriptor,
300
+ toolId,
301
+ policy,
302
+ isRelease
303
+ });
304
+ if (verdict.verdict === "refuse") {
305
+ yield {
306
+ type: "tool-end",
307
+ id: call.id,
308
+ ok: false,
309
+ summary: "refused"
310
+ };
311
+ results.push({
312
+ toolCallId: call.id,
313
+ content: verdict.reason,
314
+ isError: true
315
+ });
316
+ continue;
317
+ }
318
+ if (verdict.verdict === "needs-approval") {
319
+ yield {
320
+ type: "approval-required",
321
+ id: call.id,
322
+ toolId,
323
+ action,
324
+ label,
325
+ description: descriptor.summary,
326
+ reason: verdict.reason
327
+ };
328
+ const approved = requestApproval ? await requestApproval({
329
+ id: call.id,
330
+ toolId,
331
+ action,
332
+ label,
333
+ description: descriptor.summary,
334
+ reason: verdict.reason
335
+ }) : false;
336
+ if (!approved) {
337
+ results.push({
338
+ toolCallId: call.id,
339
+ content: "The user declined this change. Do not retry it; ask what they would prefer.",
340
+ isError: true
341
+ });
342
+ continue;
343
+ }
344
+ }
345
+ yield {
346
+ type: "tool-start",
347
+ id: call.id,
348
+ toolId,
349
+ action,
350
+ label,
351
+ description: descriptor.summary
352
+ };
353
+
354
+ // Capture the prior value BEFORE a storage write, so undo can restore it
355
+ // exactly. This is the one thing that makes storage reversible here when
356
+ // the Scenarios engine has to treat it as permanent.
357
+ const before = await captureBefore(descriptor, toolId, params, dispatch);
358
+ const stateBefore = await captureStateBefore(descriptor, toolId, params, dispatch);
359
+ try {
360
+ // `getSnapshot` is reserved: it is not an adapter action, so it goes to
361
+ // the snapshot reader and is trimmed to the fields worth sending. See
362
+ // catalog/snapshotReads.ts. `ask-buoy.*` is the session itself — the
363
+ // ledger the model asks about lives HERE, not behind an adapter, so
364
+ // routing it through dispatch would only work on a device and would
365
+ // record the undo as a fresh effect.
366
+ const result = action === SNAPSHOT_ACTION ? projectSnapshot(toolId, await requireSnapshot(readSnapshot, toolId), params) : toolId === "ask-buoy" ? await runAskBuoyAction(action, ledger, dispatch) : await dispatch(toolId, action, params);
367
+ const cleaned = redact(stripSelfTraffic(result));
368
+ if (descriptor.effect !== "read" && toolId !== "ask-buoy") {
369
+ // A delete of something we created IS the undo, whoever asked for
370
+ // it — mark the matching entry undone instead of leaving the bar
371
+ // promising an undo against a rule that no longer exists.
372
+ ledger.noteExternalRevert(toolId, action, params);
373
+ const fx = effectFor(toolId, descriptor, params, result, before);
374
+ if (fx) ledger.record(toolId, action, fx);
375
+ }
376
+ yield {
377
+ type: "tool-end",
378
+ id: call.id,
379
+ ok: true,
380
+ summary: summarise(result)
381
+ };
382
+
383
+ // The receipt: what the user sees of this result, built from the
384
+ // same redacted data the model gets. See blocks/receipts.ts.
385
+ const receipt = projectReceipt({
386
+ toolId,
387
+ action,
388
+ params,
389
+ result: cleaned,
390
+ before: stateBefore ?? before,
391
+ effect: descriptor.effect
392
+ });
393
+ if (receipt) yield {
394
+ type: "block",
395
+ block: receipt,
396
+ forToolCallId: call.id
397
+ };
398
+ const encoded = encodeResult(cleaned);
399
+ for (const url of encoded.match(URL_RE) ?? []) seenUrls.add(url);
400
+ results.push({
401
+ toolCallId: call.id,
402
+ content: encoded + ownedKeyNote(toolId, descriptor, params, storeKeyOwners)
403
+ });
404
+ if (toolId === "app" && (action === "reloadApp" || action === "reload")) {
405
+ realmWillDie = true;
406
+ }
407
+ } catch (err) {
408
+ const message = err instanceof Error ? err.message : String(err);
409
+ yield {
410
+ type: "tool-end",
411
+ id: call.id,
412
+ ok: false,
413
+ summary: message
414
+ };
415
+ results.push({
416
+ toolCallId: call.id,
417
+ content: `Failed: ${message}`,
418
+ isError: true
419
+ });
420
+ }
421
+ }
422
+ messages.push({
423
+ role: "tool-results",
424
+ results
425
+ });
426
+ if (awaiting) {
427
+ yield {
428
+ type: "awaiting-user",
429
+ blockId: awaiting
430
+ };
431
+ yield {
432
+ type: "done"
433
+ };
434
+ return messages;
435
+ }
436
+ if (realmWillDie) {
437
+ // Anything after this dies with the JS realm. Stop cleanly instead of
438
+ // sending a request whose answer can never arrive.
439
+ yield {
440
+ type: "done"
441
+ };
442
+ return messages;
443
+ }
444
+ }
445
+ yield {
446
+ type: "block",
447
+ block: {
448
+ id: `cap${Date.now()}`,
449
+ kind: "notice",
450
+ tone: "warning",
451
+ text: `Stopped after ${maxSteps} steps to avoid looping.`,
452
+ actions: [{
453
+ label: "Continue",
454
+ primary: true,
455
+ send: "Continue where you left off."
456
+ }]
457
+ }
458
+ };
459
+ yield {
460
+ type: "done"
461
+ };
462
+ return messages;
463
+ }
464
+
465
+ /**
466
+ * A factual postscript for a storage write that landed on a store's saved copy.
467
+ *
468
+ * This is a STATEMENT ABOUT WHAT HAPPENED, not a policy reminder — the same
469
+ * kind of thing the release refusals say, and the reason it belongs in the
470
+ * result rather than the system prompt. The write really did succeed and the
471
+ * app really did not change, and only the engine is in a position to know
472
+ * both at once.
473
+ *
474
+ * Deliberately not a refusal. Writing the on-disk value on purpose — "make it
475
+ * this on next launch" — is legitimate, and a hard block would make that
476
+ * impossible. Saying so leaves the model free to continue, correct itself with
477
+ * `zustand.rehydrate`, or explain the distinction to the user.
478
+ */
479
+ function ownedKeyNote(toolId, descriptor, params, storeKeyOwners) {
480
+ if (toolId !== "storage" || descriptor.effect === "read") return "";
481
+ if (!storeKeyOwners || typeof params.key !== "string") return "";
482
+ const storeName = storeKeyOwners[params.key];
483
+ if (!storeName) return "";
484
+ return `\n\n[Buoy] This wrote to disk, but "${params.key}" is the saved copy of the live "${storeName}" store, so nothing on screen has changed — the app read that key once at startup and has held the state in memory since. Call zustand.rehydrate({"storeName":"${storeName}"}) to make the app pick this up, or use zustand.setState next time to change the store directly. If you meant to set the value for the app's next launch, this is already done and no further action is needed.`;
485
+ }
486
+ /**
487
+ * One human-initiated action through EVERY gate the model's calls go through:
488
+ * catalog lookup, param validation, policy, pre-read, effect ledger.
489
+ *
490
+ * This exists for block buttons. The `buoy_ui` schema promises the model that
491
+ * a dispatch button runs "with the same policy and undo as if you called it",
492
+ * and the first implementation called `dispatchToolAction` raw — no deny list,
493
+ * no release refusal, no ledger entry. One tap on a model-authored button was
494
+ * an invisible, ungoverned, unundoable mutation. Route every button through
495
+ * here so the promise is true.
496
+ *
497
+ * `needs-approval` is treated as approved: the human's tap on the button IS
498
+ * the approval. Deny lists, read-only mode and release refusals still stand —
499
+ * a button must never reach further than the model could.
500
+ */
501
+ export async function runGatedAction(input) {
502
+ const {
503
+ toolId,
504
+ action,
505
+ dispatch,
506
+ ledger,
507
+ policy,
508
+ isRelease
509
+ } = input;
510
+ const catalog = input.catalog ?? CATALOG;
511
+ const descriptor = findDescriptor(catalog, toolId, action);
512
+ if (!descriptor) {
513
+ return {
514
+ ok: false,
515
+ error: `${toolId}.${action} is not in Buoy's catalog.`
516
+ };
517
+ }
518
+ const params = applyDefaults(input.params ?? {}, descriptor.params);
519
+ const check = validateParams(params, descriptor.params);
520
+ if (!check.ok) {
521
+ return {
522
+ ok: false,
523
+ error: check.errors.join("\n")
524
+ };
525
+ }
526
+ const verdict = decide({
527
+ descriptor,
528
+ toolId,
529
+ policy,
530
+ isRelease
531
+ });
532
+ if (verdict.verdict === "refuse") {
533
+ return {
534
+ ok: false,
535
+ error: verdict.reason
536
+ };
537
+ }
538
+ const before = await captureBefore(descriptor, toolId, params, dispatch);
539
+ try {
540
+ const result = toolId === "ask-buoy" ? await runAskBuoyAction(action, ledger, dispatch) : await dispatch(toolId, action, params);
541
+ if (descriptor.effect !== "read" && toolId !== "ask-buoy") {
542
+ ledger.noteExternalRevert(toolId, action, params);
543
+ const fx = effectFor(toolId, descriptor, params, result, before);
544
+ if (fx) ledger.record(toolId, action, fx);
545
+ }
546
+ return {
547
+ ok: true,
548
+ result
549
+ };
550
+ } catch (err) {
551
+ return {
552
+ ok: false,
553
+ error: err instanceof Error ? err.message : String(err)
554
+ };
555
+ }
556
+ }
557
+
558
+ /**
559
+ * The `ask-buoy` catalog tool, served by the engine itself. `listChanges` and
560
+ * `undoAll` operate on THIS session's ledger — the same one the changes bar
561
+ * renders — so the model's undo and the human's undo are one mechanism. The
562
+ * adapter's same-named actions remain for desktop/MCP, where the ledger is
563
+ * reached over the broker instead.
564
+ */
565
+ async function runAskBuoyAction(action, ledger, dispatch) {
566
+ if (action === "listChanges") {
567
+ const changes = ledger.list().filter(e => !e.undoneAt).map(e => ({
568
+ toolId: e.toolId,
569
+ action: e.action,
570
+ kind: e.effect.kind,
571
+ label: e.effect.label,
572
+ reversible: isReversible(e.effect)
573
+ }));
574
+ return {
575
+ changes,
576
+ returned: changes.length
577
+ };
578
+ }
579
+ if (action === "undoAll") {
580
+ const outcome = await ledger.revertAll(dispatch);
581
+ return {
582
+ ok: outcome.failed.length === 0,
583
+ ...outcome
584
+ };
585
+ }
586
+ throw new Error(`Unknown ask-buoy action "${action}"`);
587
+ }
588
+
589
+ /** Fail loudly rather than hand the model an empty object it will believe. */
590
+ async function requireSnapshot(readSnapshot, toolId) {
591
+ if (!readSnapshot) {
592
+ throw new Error(`This app cannot read ${toolId}'s snapshot — Buoy's tool registry is not published here.`);
593
+ }
594
+ return await readSnapshot(toolId);
595
+ }
596
+
597
+ /**
598
+ * Read a storage key's current value so the write can be reversed.
599
+ *
600
+ * This pre-read is the ONLY reason storage is reversible here. The adapter's
601
+ * `async.*` / `mmkv.*` proxies all return void, so neither the prior value nor
602
+ * the store event id that `timeTravel.undo` needs ever reaches a caller — and
603
+ * that event only exists while something holds capture open. Reading first is
604
+ * what turns "we don't know what was there" into a real undo.
605
+ *
606
+ * Deliberately NOT gated on `descriptor.undo`: the catalog's undo claims were
607
+ * audited down to almost nothing precisely because they were unsound. The
608
+ * ledger's reversibility comes from this read, not from a catalog promise.
609
+ */
610
+ /**
611
+ * The prior state for a store write, so the receipt can show a real diff.
612
+ * Zustand only for now: `getStoreState` is cheap and the write params carry
613
+ * the after-state, so the diff is exact without the store's cooperation.
614
+ */
615
+ async function captureStateBefore(descriptor, toolId, params, dispatch) {
616
+ if (toolId !== "zustand" || descriptor.action !== "setState") return undefined;
617
+ if (typeof params.storeName !== "string") return undefined;
618
+ try {
619
+ return await dispatch(toolId, "getStoreState", {
620
+ storeName: params.storeName
621
+ });
622
+ } catch {
623
+ return undefined;
624
+ }
625
+ }
626
+ async function captureBefore(descriptor, toolId, params, dispatch) {
627
+ if (descriptor.effect === "read") return undefined;
628
+ if (toolId !== "storage" || typeof params.key !== "string") return undefined;
629
+ const readAction = descriptor.action.startsWith("async.") ? "async.getItem" : descriptor.action.startsWith("mmkv.") ? "mmkv.get" : undefined;
630
+ // secure.* is excluded on purpose: a key registered with
631
+ // `requireAuthentication` cannot be read without a biometric prompt, so a
632
+ // pre-read would either fail or pop a Face ID sheet mid-conversation.
633
+ if (!readAction) return undefined;
634
+
635
+ // MMKV IS PER-INSTANCE, and `mmkv.get` does not throw when the id is missing
636
+ // — it returns `{found:false, reason:"missing instanceId/key"}`. Reading with
637
+ // the key alone therefore produced a `before` that LOOKED captured and was
638
+ // empty, which the ledger then read as "the key did not exist" and undid by
639
+ // deleting it. Without an instance there is nothing to capture, so say so.
640
+ const readParams = {
641
+ key: params.key
642
+ };
643
+ if (readAction === "mmkv.get") {
644
+ if (typeof params.instanceId !== "string") return undefined;
645
+ readParams.instanceId = params.instanceId;
646
+ }
647
+ try {
648
+ const current = await dispatch(toolId, readAction, readParams);
649
+ const rec = current && typeof current === "object" ? current : undefined;
650
+ // An explicit "I could not read that" is not a captured empty value.
651
+ if (rec?.found === false) return undefined;
652
+ const value = rec ? rec.value : current;
653
+ return {
654
+ had: value !== undefined && value !== null,
655
+ value: value ?? undefined
656
+ };
657
+ } catch {
658
+ // A failed pre-read must not block the write the user asked for; it just
659
+ // means this one is not reversible, which the ledger will say.
660
+ return undefined;
661
+ }
662
+ }
663
+ //# sourceMappingURL=runAgentTurn.js.map