@retinue/agentkit 0.1.0 → 0.3.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.
Files changed (246) hide show
  1. package/README.md +59 -277
  2. package/dist/adapters/audio/openai.d.ts +36 -0
  3. package/dist/adapters/audio/openai.js +136 -0
  4. package/dist/adapters/bullmq/lock.d.ts +26 -2
  5. package/dist/adapters/embeddings/openai.d.ts +45 -0
  6. package/dist/adapters/embeddings/openai.js +109 -0
  7. package/dist/adapters/memory/connections.d.ts +10 -0
  8. package/dist/adapters/memory/connections.js +87 -0
  9. package/dist/adapters/memory/graph.d.ts +17 -0
  10. package/dist/adapters/memory/graph.js +267 -0
  11. package/dist/adapters/memory/index.d.ts +3 -0
  12. package/dist/adapters/memory/index.js +3 -0
  13. package/dist/adapters/memory/rate-limit.d.ts +14 -0
  14. package/dist/adapters/memory/rate-limit.js +28 -0
  15. package/dist/adapters/memory/runtime.js +0 -0
  16. package/dist/adapters/postgres/connections.d.ts +15 -0
  17. package/dist/adapters/postgres/connections.js +134 -0
  18. package/dist/adapters/postgres/graph.d.ts +24 -0
  19. package/dist/adapters/postgres/graph.js +325 -0
  20. package/dist/adapters/postgres/index.d.ts +2 -0
  21. package/dist/adapters/postgres/index.js +2 -0
  22. package/dist/adapters/postgres/migrations.js +198 -0
  23. package/dist/adapters/postgres/run-store.js +26 -2
  24. package/dist/adapters/postgres/schema.d.ts +61 -2
  25. package/dist/adapters/postgres/schema.js +95 -10
  26. package/dist/adapters/redis/index.d.ts +1 -0
  27. package/dist/adapters/redis/index.js +1 -0
  28. package/dist/adapters/redis/rate-limit.d.ts +46 -0
  29. package/dist/adapters/redis/rate-limit.js +55 -0
  30. package/dist/adapters/supabase/index.d.ts +15 -0
  31. package/dist/adapters/supabase/index.js +15 -0
  32. package/dist/adapters/supabase/rls.js +33 -0
  33. package/dist/agents/agent.d.ts +99 -1
  34. package/dist/agents/agent.js +215 -16
  35. package/dist/agents/define.js +24 -4
  36. package/dist/agents/engine.d.ts +48 -0
  37. package/dist/agents/engine.js +370 -13
  38. package/dist/audio/index.d.ts +151 -0
  39. package/dist/audio/index.js +183 -0
  40. package/dist/capabilities/index.d.ts +5 -1
  41. package/dist/capabilities/index.js +23 -0
  42. package/dist/capabilities/runtime.d.ts +8 -0
  43. package/dist/connections/cipher.d.ts +103 -0
  44. package/dist/connections/cipher.js +141 -0
  45. package/dist/connections/index.d.ts +156 -0
  46. package/dist/connections/index.js +72 -0
  47. package/dist/connections/oauth/client.d.ts +84 -0
  48. package/dist/connections/oauth/client.js +141 -0
  49. package/dist/connections/oauth/index.d.ts +166 -0
  50. package/dist/connections/oauth/index.js +226 -0
  51. package/dist/connections/oauth/service.d.ts +90 -0
  52. package/dist/connections/oauth/service.js +132 -0
  53. package/dist/connections/pause.d.ts +68 -0
  54. package/dist/connections/pause.js +97 -0
  55. package/dist/connections/resolver.d.ts +59 -0
  56. package/dist/connections/resolver.js +138 -0
  57. package/dist/connections/resume.d.ts +64 -0
  58. package/dist/connections/resume.js +99 -0
  59. package/dist/connections/vault-cipher.d.ts +95 -0
  60. package/dist/connections/vault-cipher.js +206 -0
  61. package/dist/core/budget.d.ts +55 -0
  62. package/dist/core/budget.js +56 -0
  63. package/dist/core/content-parts.d.ts +26 -2
  64. package/dist/core/content-parts.js +1 -0
  65. package/dist/core/context.d.ts +25 -0
  66. package/dist/core/errors.d.ts +1 -1
  67. package/dist/core/errors.js +15 -0
  68. package/dist/core/events.d.ts +110 -2
  69. package/dist/core/events.js +33 -2
  70. package/dist/core/index.d.ts +1 -0
  71. package/dist/core/index.js +1 -0
  72. package/dist/core/validation.js +19 -0
  73. package/dist/documents/index.d.ts +14 -0
  74. package/dist/documents/parsers/text.d.ts +16 -0
  75. package/dist/documents/parsers/text.js +54 -2
  76. package/dist/entries/connections.d.ts +10 -0
  77. package/dist/entries/connections.js +10 -0
  78. package/dist/entries/guardrails.d.ts +14 -0
  79. package/dist/entries/guardrails.js +14 -0
  80. package/dist/entries/knowledge.d.ts +17 -0
  81. package/dist/entries/knowledge.js +16 -0
  82. package/dist/entries/mcp-server.d.ts +9 -0
  83. package/dist/entries/mcp-server.js +9 -0
  84. package/dist/entries/runtime.d.ts +10 -0
  85. package/dist/entries/runtime.js +8 -0
  86. package/dist/entries/testing.d.ts +28 -0
  87. package/dist/entries/testing.js +28 -0
  88. package/dist/graphql/resolvers.d.ts +7 -1
  89. package/dist/graphql/resolvers.js +20 -0
  90. package/dist/graphql/schema.d.ts +1 -1
  91. package/dist/graphql/schema.js +44 -0
  92. package/dist/guardrails/index.d.ts +115 -0
  93. package/dist/guardrails/index.js +108 -0
  94. package/dist/guardrails/moderation.d.ts +53 -0
  95. package/dist/guardrails/moderation.js +75 -0
  96. package/dist/guardrails/pii.d.ts +75 -0
  97. package/dist/guardrails/pii.js +193 -0
  98. package/dist/knowledge/communities.d.ts +166 -0
  99. package/dist/knowledge/communities.js +377 -0
  100. package/dist/knowledge/graph-global.d.ts +153 -0
  101. package/dist/knowledge/graph-global.js +246 -0
  102. package/dist/knowledge/graph-retrieval.d.ts +150 -0
  103. package/dist/knowledge/graph-retrieval.js +303 -0
  104. package/dist/knowledge/graph.d.ts +223 -0
  105. package/dist/knowledge/graph.js +369 -0
  106. package/dist/knowledge/index.d.ts +25 -0
  107. package/dist/knowledge/index.js +37 -1
  108. package/dist/knowledge/navigate.d.ts +89 -0
  109. package/dist/knowledge/navigate.js +107 -0
  110. package/dist/knowledge/retrieval.d.ts +98 -5
  111. package/dist/knowledge/retrieval.js +158 -28
  112. package/dist/mcp-server/index.d.ts +168 -0
  113. package/dist/mcp-server/index.js +175 -0
  114. package/dist/models/extraction.d.ts +73 -0
  115. package/dist/models/extraction.js +118 -0
  116. package/dist/models/index.d.ts +40 -1
  117. package/dist/models/index.js +22 -1
  118. package/dist/models/pricing.d.ts +2 -0
  119. package/dist/models/pricing.js +17 -1
  120. package/dist/models/provider-factory.js +9 -7
  121. package/dist/models/streaming.d.ts +111 -1
  122. package/dist/models/streaming.js +184 -3
  123. package/dist/persistence/index.d.ts +230 -0
  124. package/dist/persistence/index.js +2 -0
  125. package/dist/runtime/index.d.ts +1 -1
  126. package/dist/runtime/index.js +12 -0
  127. package/dist/runtime/worker.d.ts +36 -1
  128. package/dist/runtime/worker.js +37 -4
  129. package/dist/security/checklist.js +55 -0
  130. package/dist/security/findings.js +18 -9
  131. package/dist/server/bin.d.ts +19 -0
  132. package/dist/server/bin.js +226 -0
  133. package/dist/server/boot.d.ts +20 -2
  134. package/dist/server/boot.js +29 -3
  135. package/dist/server/cli.d.ts +13 -0
  136. package/dist/server/cli.js +7 -4
  137. package/dist/server/doctor.d.ts +74 -0
  138. package/dist/server/doctor.js +280 -0
  139. package/dist/skills/catalogue.d.ts +49 -0
  140. package/dist/skills/catalogue.js +61 -0
  141. package/dist/skills/context.d.ts +61 -0
  142. package/dist/skills/context.js +107 -0
  143. package/dist/skills/index.d.ts +2 -0
  144. package/dist/skills/index.js +2 -0
  145. package/dist/telemetry/spans.js +17 -0
  146. package/dist/testing/conformance/artifact-exports.d.ts +20 -0
  147. package/dist/testing/conformance/artifact-exports.js +172 -0
  148. package/dist/testing/conformance/artifacts.d.ts +11 -0
  149. package/dist/testing/conformance/artifacts.js +316 -0
  150. package/dist/testing/conformance/audio.d.ts +38 -0
  151. package/dist/testing/conformance/audio.js +145 -0
  152. package/dist/testing/conformance/capability.d.ts +23 -0
  153. package/dist/testing/conformance/capability.js +27 -0
  154. package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
  155. package/dist/testing/conformance/checkpoint-store.js +72 -0
  156. package/dist/testing/conformance/connections.d.ts +14 -0
  157. package/dist/testing/conformance/connections.js +171 -0
  158. package/dist/testing/conformance/conversation-store.d.ts +10 -0
  159. package/dist/testing/conformance/conversation-store.js +60 -0
  160. package/dist/testing/conformance/evaluation.d.ts +10 -0
  161. package/dist/testing/conformance/evaluation.js +295 -0
  162. package/dist/testing/conformance/files.d.ts +19 -0
  163. package/dist/testing/conformance/files.js +454 -0
  164. package/dist/testing/conformance/flows.d.ts +16 -0
  165. package/dist/testing/conformance/flows.js +193 -0
  166. package/dist/testing/conformance/graph.d.ts +22 -0
  167. package/dist/testing/conformance/graph.js +500 -0
  168. package/dist/testing/conformance/hitl.d.ts +25 -0
  169. package/dist/testing/conformance/hitl.js +523 -0
  170. package/dist/testing/conformance/index.d.ts +163 -0
  171. package/dist/testing/conformance/index.js +321 -0
  172. package/dist/testing/conformance/invariants.d.ts +23 -0
  173. package/dist/testing/conformance/invariants.js +80 -0
  174. package/dist/testing/conformance/knowledge.d.ts +41 -0
  175. package/dist/testing/conformance/knowledge.js +592 -0
  176. package/dist/testing/conformance/parents.d.ts +50 -0
  177. package/dist/testing/conformance/parents.js +39 -0
  178. package/dist/testing/conformance/rate-limit.d.ts +25 -0
  179. package/dist/testing/conformance/rate-limit.js +71 -0
  180. package/dist/testing/conformance/records.d.ts +36 -0
  181. package/dist/testing/conformance/records.js +400 -0
  182. package/dist/testing/conformance/rollups.d.ts +30 -0
  183. package/dist/testing/conformance/rollups.js +425 -0
  184. package/dist/testing/conformance/run-coordinator.d.ts +27 -0
  185. package/dist/testing/conformance/run-coordinator.js +150 -0
  186. package/dist/testing/conformance/run-event-log.d.ts +9 -0
  187. package/dist/testing/conformance/run-event-log.js +138 -0
  188. package/dist/testing/conformance/run-store.d.ts +12 -0
  189. package/dist/testing/conformance/run-store.js +294 -0
  190. package/dist/testing/conformance/session-state.d.ts +38 -0
  191. package/dist/testing/conformance/session-state.js +198 -0
  192. package/dist/testing/conformance/usage-limits.d.ts +10 -0
  193. package/dist/testing/conformance/usage-limits.js +309 -0
  194. package/dist/testing/conformance.d.ts +8 -0
  195. package/dist/testing/conformance.js +8 -0
  196. package/dist/testing/memory-backend.d.ts +39 -0
  197. package/dist/testing/memory-backend.js +41 -0
  198. package/dist/testing/pglite.d.ts +55 -0
  199. package/dist/testing/pglite.js +78 -0
  200. package/dist/testing/stub-model.d.ts +58 -0
  201. package/dist/testing/stub-model.js +71 -0
  202. package/dist/testing/supabase-storage-double.d.ts +35 -0
  203. package/dist/testing/supabase-storage-double.js +101 -0
  204. package/dist/toolkit/files.d.ts +125 -0
  205. package/dist/toolkit/files.js +320 -0
  206. package/dist/toolkit/http.d.ts +27 -0
  207. package/dist/toolkit/http.js +48 -0
  208. package/dist/toolkit/index.d.ts +31 -1
  209. package/dist/toolkit/index.js +26 -1
  210. package/dist/toolkit/mime.d.ts +97 -0
  211. package/dist/toolkit/mime.js +233 -0
  212. package/dist/toolkit/sandbox.d.ts +119 -0
  213. package/dist/toolkit/sandbox.js +239 -0
  214. package/dist/toolkit/ssrf.d.ts +139 -0
  215. package/dist/toolkit/ssrf.js +349 -0
  216. package/dist/toolkit/vendor.d.ts +91 -0
  217. package/dist/toolkit/vendor.js +153 -0
  218. package/dist/toolkit/web.d.ts +13 -0
  219. package/dist/toolkit/web.js +7 -1
  220. package/dist/tools/budget.d.ts +28 -0
  221. package/dist/tools/budget.js +35 -0
  222. package/dist/tools/credentials.d.ts +316 -0
  223. package/dist/tools/credentials.js +369 -0
  224. package/dist/tools/define.d.ts +45 -0
  225. package/dist/tools/define.js +26 -0
  226. package/dist/tools/find.d.ts +109 -0
  227. package/dist/tools/find.js +210 -0
  228. package/dist/tools/index.d.ts +35 -2
  229. package/dist/tools/index.js +11 -0
  230. package/dist/tools/library/audio.d.ts +63 -0
  231. package/dist/tools/library/audio.js +148 -0
  232. package/dist/tools/library/fs.d.ts +24 -0
  233. package/dist/tools/library/fs.js +102 -0
  234. package/dist/tools/library/index.d.ts +37 -2
  235. package/dist/tools/library/index.js +47 -0
  236. package/dist/tools/library/shell.d.ts +45 -0
  237. package/dist/tools/library/shell.js +70 -0
  238. package/dist/tools/meta-tools.js +8 -0
  239. package/dist/tools/registry.d.ts +146 -0
  240. package/dist/tools/registry.js +250 -4
  241. package/dist/usage/index.d.ts +21 -0
  242. package/dist/usage/index.js +1 -0
  243. package/dist/usage/rate-limit.d.ts +122 -0
  244. package/dist/usage/rate-limit.js +131 -0
  245. package/dist/usage/recorder.js +3 -0
  246. package/package.json +33 -2
@@ -0,0 +1,206 @@
1
+ /**
2
+ * A `SecretCipher` over Supabase Vault — REQ-063 (#259), task #268.
3
+ *
4
+ * #261 shipped the seam with an app-side AES-256-GCM implementation and deliberately left this one out: there
5
+ * was no Vault to run it against, and an untested crypto implementation behind a seam that *looks* tested is
6
+ * worse than none. There is one now, and everything below has been run against it.
7
+ *
8
+ * ## The mapping, and the property it changes — AC-3
9
+ *
10
+ * Vault owns the ciphertext and hands back an **id**. The seam expects the caller to hold
11
+ * `{ keyId, algorithm, nonce, ciphertext }`. So:
12
+ *
13
+ * | Field | Holds |
14
+ * |---|---|
15
+ * | `algorithm` | `supabase-vault` — a sentinel, not a cipher name. The algorithm is Vault's business. |
16
+ * | `keyId` | `supabase-vault` — see the rotation note. Vault's own `key_id` column comes back **null**: the root key is held outside the database and Supabase does not expose an identity for it. |
17
+ * | `nonce` | A random **binding token**, not a nonce. See the tamper note. |
18
+ * | `ciphertext` | The Vault secret's **uuid**. Not ciphertext. |
19
+ *
20
+ * **A Vault-sealed row contains no secret material at all.** That is the property worth stating rather than
21
+ * discovering, and it cuts both ways:
22
+ *
23
+ * - A backup of the application's own tables carries no credentials — strictly better than app-side sealing,
24
+ * where the backup carries ciphertext that a leaked key would open.
25
+ * - The secret lives in `vault.secrets`, so **restoring the application tables without the `vault` schema
26
+ * leaves every connection pointing at nothing.** With app-side sealing the ciphertext travels with the row;
27
+ * here it does not, and a backup strategy that covers `public` and not `vault` silently loses every
28
+ * credential. That is a deployment fact, not a code one, and it belongs in a runbook.
29
+ *
30
+ * ## Deletion — AC-3, and a gap this found
31
+ *
32
+ * Deleting a connection row does **not** delete the Vault secret: the row held a pointer, and dropping a
33
+ * pointer leaves what it pointed at. `docs/18`'s deletion promises are therefore *not* satisfied by deleting
34
+ * the row alone, which is a difference from app-side sealing that nobody would notice until an audit.
35
+ *
36
+ * So `SecretCipher` gained an optional `forget`. App-side sealing has nothing to implement — the ciphertext
37
+ * dies with the row — and this implementation removes the Vault secret. A deployment on Vault that never calls
38
+ * it accumulates orphaned secrets that outlive the connections they belonged to.
39
+ *
40
+ * ## Tampering — AC-2, and where the guarantee genuinely differs
41
+ *
42
+ * AES-GCM authenticates: a flipped byte fails rather than decrypting to something else. A pointer cannot be
43
+ * authenticated by the thing it points at, so an attacker able to *write* the connections table could repoint
44
+ * one connection at another secret in the same Vault and read a credential they were not entitled to. That is
45
+ * a real weakening and it is not hypothetical — it is exactly the shape of a tenant-isolation bypass.
46
+ *
47
+ * Closed here with a **binding token**: `seal` stores a fresh 32-byte random value in the Vault secret's
48
+ * `description` and keeps it in the sealed value's `nonce` field; `open` compares them in constant time and
49
+ * refuses on mismatch. Repointing at another secret now fails, because that secret carries a different token.
50
+ * No application-side key is involved, so the "database cannot decrypt on its own" property is untouched.
51
+ *
52
+ * It is not equivalent to AEAD. An attacker who can write *both* the connections table and `vault.secrets`
53
+ * defeats it, where GCM would still fail. Stated rather than implied.
54
+ */
55
+ import { timingSafeEqual } from "node:crypto";
56
+ import { randomBytes } from "node:crypto";
57
+ import { AgentPlatformError } from "../core/errors.js";
58
+ /**
59
+ * The sentinel in `algorithm`, and the reason it is not a cipher name.
60
+ *
61
+ * A reader of a stored row needs to know *which implementation* sealed it, not which primitive. `aes-256-gcm`
62
+ * tells the app-side cipher how to open the value; `supabase-vault` tells this one that the value is a pointer.
63
+ * Putting libsodium's actual construction here would be a claim about Vault's internals that could stop being
64
+ * true without this package noticing.
65
+ */
66
+ export const SUPABASE_VAULT = "supabase-vault";
67
+ /** 32 bytes of binding token. Long enough that guessing it is not a strategy. */
68
+ const TOKEN_BYTES = 32;
69
+ const failed = (message, code = "invalid_input") => {
70
+ throw new AgentPlatformError({ code, message, retryable: false });
71
+ };
72
+ /** Constant-time compare that does not leak *which* byte differed, and tolerates unequal lengths. */
73
+ const tokensMatch = (a, b) => {
74
+ const left = Buffer.from(a, "utf8");
75
+ const right = Buffer.from(b, "utf8");
76
+ // `timingSafeEqual` throws on a length mismatch, which would itself be a length oracle. Compared against a
77
+ // padded copy so the answer is false rather than an exception.
78
+ if (left.length !== right.length) {
79
+ const padded = Buffer.alloc(Math.max(left.length, right.length));
80
+ const other = Buffer.alloc(padded.length);
81
+ left.copy(padded);
82
+ right.copy(other);
83
+ timingSafeEqual(padded, other);
84
+ return false;
85
+ }
86
+ return timingSafeEqual(left, right);
87
+ };
88
+ export const createSupabaseVaultCipher = (options) => {
89
+ const prefix = options.namePrefix ?? "retinue";
90
+ const newToken = options.randomToken ?? (() => randomBytes(TOKEN_BYTES).toString("base64"));
91
+ return {
92
+ /**
93
+ * `keyId` is a constant, and that is Vault's answer to rotation — AC-4.
94
+ *
95
+ * `resealConnections` walks `sealed.keyId` looking for rows sealed under a retired key. Under Vault that
96
+ * loop finds **nothing, forever**, because there is one key identity and it never changes.
97
+ *
98
+ * That is correct rather than broken, and the distinction matters: Supabase rotates the Vault root key
99
+ * outside the database, and re-sealing through this cipher would mean decrypting and re-encrypting with
100
+ * the *same* key — work that changes nothing. But a rotation job that silently does nothing looks
101
+ * identical to a rotation job that is wired wrong, so a deployment on Vault should not run one and expect
102
+ * output. `vaultRotationIsExternal` exists to be asserted in a test rather than remembered.
103
+ */
104
+ currentKeyId() {
105
+ return SUPABASE_VAULT;
106
+ },
107
+ async seal(plaintext) {
108
+ const token = newToken();
109
+ /**
110
+ * The name is unique per secret, so two seals of the same plaintext are two Vault rows.
111
+ *
112
+ * Vault's `create_secret` raises on a duplicate name, and a cipher that reused one would make the
113
+ * second seal of a value fail — which would look like an intermittent credential bug.
114
+ */
115
+ const name = `${prefix}:${randomBytes(16).toString("hex")}`;
116
+ const rows = await options.sql.query("select vault.create_secret($1, $2, $3) as id", [plaintext, name, token]);
117
+ const id = rows[0]?.id;
118
+ if (typeof id !== "string" || id === "") {
119
+ return failed("Supabase Vault did not return an id for the secret it was asked to store.", "internal");
120
+ }
121
+ return {
122
+ keyId: SUPABASE_VAULT,
123
+ algorithm: SUPABASE_VAULT,
124
+ // The binding token, not a nonce — see the header. Kept in this field so the shape of a stored row is
125
+ // unchanged and no migration is needed to select this implementation.
126
+ nonce: token,
127
+ // The pointer. **Not ciphertext**; see the backup note in the header.
128
+ ciphertext: id,
129
+ };
130
+ },
131
+ async open(sealed) {
132
+ if (sealed.algorithm !== SUPABASE_VAULT) {
133
+ // Refused rather than guessed: a row sealed by the app-side cipher must not be handed to this one, and
134
+ // the mistake is a misconfiguration worth naming rather than a decryption failure to debug.
135
+ return failed(`This secret was sealed with "${sealed.algorithm}", not ${SUPABASE_VAULT}. A deployment that has ` +
136
+ "switched ciphers must re-seal its existing rows; opening one with the wrong implementation " +
137
+ "cannot work and is not attempted.");
138
+ }
139
+ if (sealed.ciphertext === "")
140
+ return failed("This Vault-sealed secret carries no id.");
141
+ const rows = await options.sql.query("select decrypted_secret, description from vault.decrypted_secrets where id = $1::uuid", [sealed.ciphertext]);
142
+ const row = rows[0];
143
+ if (row === undefined) {
144
+ /**
145
+ * A pointer with nothing behind it.
146
+ *
147
+ * The likeliest cause is the one named in the header: application tables restored without the `vault`
148
+ * schema. Said explicitly, because "secret not found" against a row that plainly has an id sends an
149
+ * operator looking for a code bug.
150
+ */
151
+ return failed(`Supabase Vault has no secret ${sealed.ciphertext}. Either it was deleted, or these rows were ` +
152
+ "restored from a backup that did not include the vault schema — a Vault-sealed row holds a " +
153
+ "pointer, not the ciphertext.");
154
+ }
155
+ /**
156
+ * The binding check — AC-2.
157
+ *
158
+ * Without it, an attacker who can write the connections table repoints a row at another secret in the
159
+ * same Vault and reads a credential they were not entitled to. A pointer cannot be authenticated by
160
+ * what it points at, so the token is what makes the pair inseparable.
161
+ */
162
+ if (!tokensMatch(sealed.nonce, row.description ?? "")) {
163
+ return failed("This Vault secret does not belong to this row. The binding token does not match, which means the " +
164
+ "stored id was changed — refused rather than returning a credential from somewhere else.");
165
+ }
166
+ const plaintext = row.decrypted_secret;
167
+ if (plaintext === null) {
168
+ return failed("Supabase Vault returned no plaintext for this secret.", "internal");
169
+ }
170
+ return plaintext;
171
+ },
172
+ /**
173
+ * Removes the Vault secret — the deletion half of AC-3.
174
+ *
175
+ * Deleting a connection row drops a pointer and leaves the secret. A deployment on Vault that never calls
176
+ * this accumulates credentials that outlive the connections they belonged to, which is the opposite of
177
+ * what `docs/18` promises.
178
+ */
179
+ async forget(sealed) {
180
+ if (sealed.algorithm !== SUPABASE_VAULT || sealed.ciphertext === "")
181
+ return;
182
+ /**
183
+ * Guarded by the binding token, like `open`.
184
+ *
185
+ * A `forget` that deleted whatever the id pointed at would be a way to destroy *another* connection's
186
+ * credential by writing an id into a row — a smaller blast radius than reading it, and the same class of
187
+ * bug. The delete is scoped to a row whose description matches.
188
+ */
189
+ await options.sql.query("delete from vault.secrets where id = $1::uuid and description = $2", [sealed.ciphertext, sealed.nonce]);
190
+ },
191
+ };
192
+ };
193
+ /**
194
+ * Rotation is Vault's, not ours — AC-4, stated as a value so a test can assert it.
195
+ *
196
+ * `resealConnections` walks `sealed.keyId`; this cipher's is constant, so that walk finds nothing forever.
197
+ * Correct, and indistinguishable from a misconfigured rotation job unless somebody wrote it down.
198
+ */
199
+ export const vaultRotationIsExternal = {
200
+ cipher: SUPABASE_VAULT,
201
+ resealFinds: "nothing, by design",
202
+ why: "Supabase rotates the Vault root key outside the database. Re-sealing through this cipher would decrypt " +
203
+ "and re-encrypt under the same key identity, which changes nothing. A deployment on Vault should not run " +
204
+ "a re-seal job and expect it to report work.",
205
+ };
206
+ //# sourceMappingURL=vault-cipher.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A token ceiling on a catalogue, and the rule that truncation is never quiet — REQ-045 (#204), task #210.
3
+ *
4
+ * The tool catalogue and the skill catalogue have the same problem: a compact entry per item, resident on every
5
+ * turn, linear in how many exist. #221 measured ~35 tokens per tool entry, so 200 tools is ~7,000 tokens before
6
+ * a word of the conversation. Two-tier loading bounded the *schemas* and left the entries.
7
+ *
8
+ * ## Truncation must be loud, and this is the whole guarantee
9
+ *
10
+ * A silently shortened catalogue is **indistinguishable from correct behaviour**. The model is not told an item
11
+ * was withheld; it simply never uses it, and the transcript reads as a run where the model chose not to. Nobody
12
+ * reviewing that run has anything to notice. So dropping is never quiet: the outcome names every dropped item,
13
+ * the engine turns that into a run event, and #210's AC-7 is a test that removing the event fails the build.
14
+ *
15
+ * In `core/` because both callers need it and neither owns it — and because a second copy for skills would drift
16
+ * from the first, which is the shape this repository keeps finding defects in.
17
+ */
18
+ export type TokenBudget = {
19
+ /** The ceiling. A catalogue at or under it is untouched and no event is emitted. */
20
+ readonly maxTokens: number;
21
+ };
22
+ export type BudgetOutcome<T> = {
23
+ readonly resident: readonly T[];
24
+ /** Names, in the order they were dropped. Named rather than counted: a count is not actionable. */
25
+ readonly dropped: readonly string[];
26
+ readonly residentTokens: number;
27
+ readonly budgetTokens: number;
28
+ /**
29
+ * True when the budget could not be met even after dropping everything droppable.
30
+ *
31
+ * Distinct from "it bound", because the two need different responses: a bound budget is the mechanism working,
32
+ * while an unmeetable one is a misconfiguration — the protected set alone does not fit, and a deployment that
33
+ * believes it capped its context has not.
34
+ */
35
+ readonly overBudget: boolean;
36
+ };
37
+ /**
38
+ * Keep what fits, in the order given, and name what did not.
39
+ *
40
+ * **Order is the caller's priority**, not a ranking invented here. For tools that is the order the host's
41
+ * providers were registered in, which is a deployment's own statement about what matters; inventing a relevance
42
+ * order would mean guessing at the model's next need, and `find_tools` is the honest answer to that.
43
+ *
44
+ * `protect` names what may never be dropped. Meta-tools are the reason it exists: dropping `find_tools` or
45
+ * `learn_tools` to save 35 tokens would remove the model's only route back to everything else, turning a budget
46
+ * into a permanent amputation.
47
+ */
48
+ export declare const applyTokenBudget: <T>(input: {
49
+ readonly items: readonly T[];
50
+ readonly budget: TokenBudget;
51
+ readonly tokensOf: (item: T) => number;
52
+ readonly nameOf: (item: T) => string;
53
+ readonly protect?: (item: T) => boolean;
54
+ }) => BudgetOutcome<T>;
55
+ //# sourceMappingURL=budget.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A token ceiling on a catalogue, and the rule that truncation is never quiet — REQ-045 (#204), task #210.
3
+ *
4
+ * The tool catalogue and the skill catalogue have the same problem: a compact entry per item, resident on every
5
+ * turn, linear in how many exist. #221 measured ~35 tokens per tool entry, so 200 tools is ~7,000 tokens before
6
+ * a word of the conversation. Two-tier loading bounded the *schemas* and left the entries.
7
+ *
8
+ * ## Truncation must be loud, and this is the whole guarantee
9
+ *
10
+ * A silently shortened catalogue is **indistinguishable from correct behaviour**. The model is not told an item
11
+ * was withheld; it simply never uses it, and the transcript reads as a run where the model chose not to. Nobody
12
+ * reviewing that run has anything to notice. So dropping is never quiet: the outcome names every dropped item,
13
+ * the engine turns that into a run event, and #210's AC-7 is a test that removing the event fails the build.
14
+ *
15
+ * In `core/` because both callers need it and neither owns it — and because a second copy for skills would drift
16
+ * from the first, which is the shape this repository keeps finding defects in.
17
+ */
18
+ /**
19
+ * Keep what fits, in the order given, and name what did not.
20
+ *
21
+ * **Order is the caller's priority**, not a ranking invented here. For tools that is the order the host's
22
+ * providers were registered in, which is a deployment's own statement about what matters; inventing a relevance
23
+ * order would mean guessing at the model's next need, and `find_tools` is the honest answer to that.
24
+ *
25
+ * `protect` names what may never be dropped. Meta-tools are the reason it exists: dropping `find_tools` or
26
+ * `learn_tools` to save 35 tokens would remove the model's only route back to everything else, turning a budget
27
+ * into a permanent amputation.
28
+ */
29
+ export const applyTokenBudget = (input) => {
30
+ const protectedItems = [];
31
+ const droppable = [];
32
+ for (const item of input.items)
33
+ (input.protect?.(item) === true ? protectedItems : droppable).push(item);
34
+ // Protected first, and counted even when they overrun: a report that omitted them would understate the
35
+ // resident cost and hide precisely the misconfiguration `overBudget` exists to name.
36
+ let spent = protectedItems.reduce((total, item) => total + input.tokensOf(item), 0);
37
+ const resident = [...protectedItems];
38
+ const dropped = [];
39
+ for (const item of droppable) {
40
+ const cost = input.tokensOf(item);
41
+ if (spent + cost <= input.budget.maxTokens) {
42
+ resident.push(item);
43
+ spent += cost;
44
+ }
45
+ else
46
+ dropped.push(input.nameOf(item));
47
+ }
48
+ return {
49
+ resident,
50
+ dropped,
51
+ residentTokens: spent,
52
+ budgetTokens: input.budget.maxTokens,
53
+ overBudget: spent > input.budget.maxTokens,
54
+ };
55
+ };
56
+ //# sourceMappingURL=budget.js.map
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { ArtifactId, ArtifactVersionId, BlobRef, ConversationId, FileId, InteractionId, MessageId, MessagePartId, RunId, ToolCallId } from "./ids.js";
9
9
  import type { PlatformError } from "./errors.js";
10
- export declare const MESSAGE_PART_TYPES: readonly ["text", "reasoning", "tool-call", "tool-result", "question", "approval", "file", "image", "citation", "source", "artifact", "status", "error"];
10
+ export declare const MESSAGE_PART_TYPES: readonly ["text", "reasoning", "tool-call", "tool-result", "question", "approval", "file", "image", "citation", "source", "artifact", "status", "error", "structured"];
11
11
  export type MessagePartType = (typeof MESSAGE_PART_TYPES)[number];
12
12
  type PartBase<T extends MessagePartType> = {
13
13
  readonly id: MessagePartId;
@@ -21,6 +21,22 @@ type PartBase<T extends MessagePartType> = {
21
21
  export type TextPart = PartBase<"text"> & {
22
22
  readonly text: string;
23
23
  };
24
+ /**
25
+ * The validated answer of a structured agent — task #243.
26
+ *
27
+ * Its own part rather than a `TextPart` holding JSON, for the reason the whole task exists: a consumer that
28
+ * asked for structured output wants the object, and handing back text would leave every reader re-parsing it
29
+ * and deciding for itself whether the parse succeeded. The part carries the value **after** validation, so its
30
+ * presence is the guarantee — an agent whose model produced nothing conforming fails the run instead of
31
+ * emitting this with something unchecked in it.
32
+ *
33
+ * There is no partial form. A half-built object does not satisfy the schema the caller asked for, so streaming
34
+ * one would mean emitting values that violate the contract; the part is added once, complete, at the end of the
35
+ * turn. Tool calls still stream normally around it.
36
+ */
37
+ export type StructuredPart = PartBase<"structured"> & {
38
+ readonly value: unknown;
39
+ };
24
40
  export type ReasoningPart = PartBase<"reasoning"> & {
25
41
  readonly text: string;
26
42
  /** Reasoning is pruned before recent semantic turns when the budget is tight. */
@@ -34,6 +50,14 @@ export type ToolCallPart = PartBase<"tool-call"> & {
34
50
  export type ToolResultPart = PartBase<"tool-result"> & {
35
51
  readonly toolCallId: ToolCallId;
36
52
  readonly toolName: string;
53
+ /**
54
+ * What actually ran, when it differs from `toolName` — task #210.
55
+ *
56
+ * `execute_tool` names its target, so the call the model made and the action performed are two different
57
+ * things. Both are recorded: the first is what the model's own history says it did, and the second is what a
58
+ * reader needs.
59
+ */
60
+ readonly ranToolName?: string;
37
61
  /** Populated for inline results. Large results are spilled and referenced instead. */
38
62
  readonly output?: unknown;
39
63
  /** Authorized reference to an offloaded result, read back via `read_tool_output`. */
@@ -180,7 +204,7 @@ export type StatusPart = PartBase<"status"> & {
180
204
  export type ErrorPart = PartBase<"error"> & {
181
205
  readonly error: PlatformError;
182
206
  };
183
- export type MessagePart = TextPart | ReasoningPart | ToolCallPart | ToolResultPart | QuestionPart | ApprovalPart | FilePart | ImagePart | CitationPart | SourcePart | ArtifactPart | StatusPart | ErrorPart;
207
+ export type MessagePart = TextPart | ReasoningPart | ToolCallPart | ToolResultPart | QuestionPart | ApprovalPart | FilePart | ImagePart | CitationPart | SourcePart | ArtifactPart | StatusPart | StructuredPart | ErrorPart;
184
208
  export type MessageRole = "system" | "user" | "assistant" | "tool";
185
209
  export type Message = {
186
210
  readonly id: MessageId;
@@ -19,5 +19,6 @@ export const MESSAGE_PART_TYPES = [
19
19
  "artifact",
20
20
  "status",
21
21
  "error",
22
+ "structured",
22
23
  ];
23
24
  //# sourceMappingURL=content-parts.js.map
@@ -31,6 +31,31 @@ export type ExecutionContext = {
31
31
  * it is shadow and has nowhere to record the suppression is refused, not performed.
32
32
  */
33
33
  readonly shadow?: boolean;
34
+ /**
35
+ * The running agent's tool policy — task #244.
36
+ *
37
+ * On the context for the same reason `shadow` is, and the argument is the one at the top of this file: context
38
+ * identity is constructed by the host, and **nothing originating in a tool argument, a skill body or an MCP
39
+ * tool description may reach these fields.** A model must never be able to widen `excluded`, and the only way
40
+ * to guarantee that is for the policy to travel on the one object a model cannot write to.
41
+ *
42
+ * The engine sets it from `AgentManifest.toolPolicy` before it builds tools, so every path that reaches the
43
+ * registry — a direct call, `execute_tool`, a delegating tool — sees the same policy. That breadth is the
44
+ * point: `toolPolicy.excluded` reads as a security control, and a control enforced only where the catalogue is
45
+ * built is bypassed by the first caller who already knows the tool's name.
46
+ *
47
+ * Structural (three name lists) rather than importing `ToolPolicyView`, because `core` must not depend on
48
+ * `tools`. The same choice `ApprovalCheck` makes to avoid a tools→hitl dependency.
49
+ *
50
+ * Absent means no agent-level policy — every authorized tool is available. That is the right default here, and
51
+ * it is the *safe* direction unlike `shadow`: a missing policy grants nothing that authorization has not
52
+ * already granted, whereas a missing `shadow` flag publishes.
53
+ */
54
+ readonly agentToolPolicy?: {
55
+ readonly preloaded: readonly string[];
56
+ readonly categories: readonly string[];
57
+ readonly excluded: readonly string[];
58
+ };
34
59
  };
35
60
  /**
36
61
  * The tenant scope every store method receives explicitly. Governing principle 1:
@@ -5,7 +5,7 @@
5
5
  * is shown to a model and a user: no credentials, no connection strings, no internal
6
6
  * hostnames, no raw provider stack traces.
7
7
  */
8
- export declare const ERROR_CODES: readonly ["unauthorized", "forbidden", "not_found", "invalid_input", "conflict", "rate_limited", "provider_unavailable", "provider_error", "timeout", "cancelled", "budget_exceeded", "context_overflow", "approval_required", "approval_denied", "approval_expired", "question_pending", "idempotency_conflict", "capability_unavailable", "internal"];
8
+ export declare const ERROR_CODES: readonly ["unauthorized", "forbidden", "not_found", "invalid_input", "conflict", "rate_limited", "provider_unavailable", "provider_error", "timeout", "cancelled", "budget_exceeded", "context_overflow", "approval_required", "approval_denied", "approval_expired", "question_pending", "admission_rate_limited", "idempotency_conflict", "capability_unavailable", "internal"];
9
9
  export type ErrorCode = (typeof ERROR_CODES)[number];
10
10
  export type PlatformError = {
11
11
  readonly code: ErrorCode;
@@ -33,6 +33,21 @@ export const ERROR_CODES = [
33
33
  * Carries `details.interactionId`.
34
34
  */
35
35
  "question_pending",
36
+ /**
37
+ * A tenant asked for more runs than their rate allows — task #248.
38
+ *
39
+ * **Its own code, not `rate_limited`.** That one means *a provider* throttled us, and `decideRetry` treats it
40
+ * as retryable inside the run — which is right there and wrong here: this refusal happens at admission, before
41
+ * a run exists, so there is nothing to retry and no run event to carry it. Conflating the two would let a
42
+ * platform-level capacity refusal be handled by the engine's provider-backoff loop, which is the wrong
43
+ * mechanism for the wrong problem.
44
+ *
45
+ * Distinct from `budget_exceeded` too: that is about *spend over a period* and this is about *capacity right
46
+ * now*. A tenant can be well inside budget and still asking too fast.
47
+ *
48
+ * Carries `details.retryAfter` and `details.retryAfterMs`, so a client can wait rather than guess.
49
+ */
50
+ "admission_rate_limited",
36
51
  "idempotency_conflict",
37
52
  "capability_unavailable",
38
53
  "internal",
@@ -8,7 +8,7 @@
8
8
  import type { MessagePart } from "./content-parts.js";
9
9
  import type { PlatformError } from "./errors.js";
10
10
  import type { InteractionId, MessageId, RunId, TenantId, ToolCallId } from "./ids.js";
11
- export declare const RUN_EVENT_TYPES: readonly ["run.queued", "run.started", "run.checkpointed", "run.completed", "run.failed", "run.cancelled", "run.retry-pending", "part.added", "part.updated", "tool.started", "tool.completed", "tool.failed", "question.requested", "question.answered", "approval.requested", "approval.decided", "usage.updated", "context.compacted"];
11
+ export declare const RUN_EVENT_TYPES: readonly ["run.queued", "run.started", "run.checkpointed", "run.completed", "run.failed", "run.cancelled", "run.retry-pending", "part.added", "part.updated", "tool.started", "tool.completed", "tool.failed", "question.requested", "question.answered", "approval.requested", "approval.decided", "connection.requested", "connection.completed", "usage.updated", "context.compacted", "guardrail.verdict", "catalog.truncated"];
12
12
  export type RunEventType = (typeof RUN_EVENT_TYPES)[number];
13
13
  type EventBase<T extends RunEventType> = {
14
14
  readonly type: T;
@@ -41,10 +41,43 @@ export type PartEvent = EventBase<"part.added" | "part.updated"> & {
41
41
  export type ToolEvent = EventBase<"tool.started" | "tool.completed" | "tool.failed"> & {
42
42
  readonly toolCallId: ToolCallId;
43
43
  readonly toolName: string;
44
+ /**
45
+ * The tool that actually ran, when the model called `execute_tool` — task #210.
46
+ *
47
+ * Absent on `tool.started`, because at that point nothing has resolved the target yet. Present on completion
48
+ * and failure, where it is the difference between an audit trail that names an action and one that names a
49
+ * mechanism.
50
+ */
51
+ readonly ranToolName?: string;
44
52
  };
45
53
  export type InteractionEvent = EventBase<"question.requested" | "question.answered" | "approval.requested" | "approval.decided"> & {
46
54
  readonly interactionId: InteractionId;
47
55
  };
56
+ /**
57
+ * The run needs a connection, and here is where to get one — task #264.
58
+ *
59
+ * Its own event rather than an `InteractionEvent`, because it carries different things: an interaction id names
60
+ * a stored question or approval, and this names a *provider*, the scopes being asked for, and a URL. Folding it
61
+ * in would mean an `interactionId` for something that is not one.
62
+ *
63
+ * **It carries no secret.** Not the client secret, not a token, not the PKCE verifier. This is rendered in a UI
64
+ * and clicked by a person, so it goes wherever a screenshot goes.
65
+ */
66
+ export type ConnectionRequestedEvent = EventBase<"connection.requested"> & {
67
+ readonly provider: string;
68
+ /** Where to send the person. Single-use and TTL-bounded — see the OAuth flow's `state`. */
69
+ readonly loginUrl: string;
70
+ /** What the consent will ask for, so a UI can say what is being granted. */
71
+ readonly scopes: readonly string[];
72
+ /** Which tool stalled, so a transcript reads coherently. */
73
+ readonly toolName?: string;
74
+ /** When the login URL stops working, so a client can offer a fresh one rather than a dead link. */
75
+ readonly expiresAt: string;
76
+ };
77
+ export type ConnectionCompletedEvent = EventBase<"connection.completed"> & {
78
+ readonly provider: string;
79
+ readonly connectionId: string;
80
+ };
48
81
  export type UsageUpdatedEvent = EventBase<"usage.updated"> & {
49
82
  readonly inputTokens: number;
50
83
  readonly outputTokens: number;
@@ -52,6 +85,8 @@ export type UsageUpdatedEvent = EventBase<"usage.updated"> & {
52
85
  /** When present, the worker records a durable `UsageEvent` for this realized step. */
53
86
  readonly modelId?: string;
54
87
  readonly cachedInputTokens?: number;
88
+ /** Tokens written into a prompt cache — task #247. Absent means the provider reported no breakdown. */
89
+ readonly cacheWriteTokens?: number;
55
90
  readonly reasoningTokens?: number;
56
91
  /**
57
92
  * Non-text input this step carried — #185 AC-4.
@@ -70,7 +105,65 @@ export type ContextCompactedEvent = EventBase<"context.compacted"> & {
70
105
  readonly droppedParts: number;
71
106
  readonly tokensReclaimed: number;
72
107
  };
73
- export type RunEvent = RunLifecycleEvent | RunFailedEvent | RunRetryPendingEvent | PartEvent | ToolEvent | InteractionEvent | UsageUpdatedEvent | ContextCompactedEvent;
108
+ /**
109
+ * What a guardrail concluded — REQ-046 (#205).
110
+ *
111
+ * Emitted for every verdict, including a pass, because "no guardrail ran" and "a guardrail ran and allowed it"
112
+ * are different facts and an incident review needs to tell them apart.
113
+ *
114
+ * **Carries no inspected value, ever.** `what` names fields or entity types; the content that was redacted is
115
+ * precisely what must not travel into an event log, a trace, or a support ticket — otherwise the audit trail
116
+ * becomes the leak it exists to record.
117
+ */
118
+ export type GuardrailVerdictEvent = EventBase<"guardrail.verdict"> & {
119
+ readonly guardrail: string;
120
+ readonly subject: "input" | "message" | "tool-call" | "tool-result";
121
+ readonly outcome: "pass" | "redacted" | "refused";
122
+ /** For a redaction: the fields or entity types touched. Never their contents. */
123
+ readonly what?: readonly string[];
124
+ /** For a refusal. */
125
+ readonly code?: string;
126
+ /** True when the guardrail threw and was therefore treated as a refusal. */
127
+ readonly threw?: boolean;
128
+ };
129
+ /**
130
+ * A catalogue did not fit its budget, and what was left out — REQ-045 (#204), task #210, AC-3 and AC-5.
131
+ *
132
+ * This event *is* the guarantee. A truncated tool list is invisible from inside a run: the model is not told a
133
+ * tool was withheld, so it never calls it, and the transcript reads exactly like a run where the model chose
134
+ * not to. Every other failure at least looks like a failure; this one looks like a decision. So the names go in
135
+ * the log, in full, and #210's AC-7 is a test that deleting this event fails the build.
136
+ *
137
+ * Names, not a count. "14 tools were dropped" tells a reader that something happened and nothing about whether
138
+ * it mattered; `github_merge_pull_request` tells them immediately.
139
+ */
140
+ export type CatalogTruncatedEvent = EventBase<"catalog.truncated"> & {
141
+ /**
142
+ * Which catalogue. One event rather than two, because the fact is the same fact and a reader asking "what was
143
+ * withheld from this turn" should not have to know there are two mechanisms.
144
+ */
145
+ readonly catalog: "tools" | "skills";
146
+ readonly budgetTokens: number;
147
+ readonly residentTokens: number;
148
+ /** Every dropped tool, by name. */
149
+ readonly dropped: readonly string[];
150
+ /**
151
+ * Whether the model can still get to what was dropped.
152
+ *
153
+ * `find_tools` makes a truncated tool catalogue a *deferral*; without it the same event describes a permanent
154
+ * removal, and a reader cannot tell which from the names alone. There is no `find_skills`, so a truncated
155
+ * skill catalogue is always `false` today — which is a fact worth having in the log rather than a field to
156
+ * leave off.
157
+ */
158
+ readonly findable: boolean;
159
+ /**
160
+ * Set when the protected set alone exceeds the budget — a misconfiguration, not the mechanism working.
161
+ *
162
+ * A deployment in this state believes it capped its context and has not.
163
+ */
164
+ readonly overBudget?: boolean;
165
+ };
166
+ export type RunEvent = RunLifecycleEvent | RunFailedEvent | RunRetryPendingEvent | PartEvent | ToolEvent | InteractionEvent | ConnectionRequestedEvent | ConnectionCompletedEvent | UsageUpdatedEvent | ContextCompactedEvent | GuardrailVerdictEvent | CatalogTruncatedEvent;
74
167
  /** Fan-out port. Adapters: Supabase Realtime, Redis pub/sub, in-memory for tests. */
75
168
  export interface RealtimePublisher {
76
169
  publish(channel: string, event: RunEvent): Promise<void>;
@@ -128,6 +221,21 @@ export type RunStreamState = {
128
221
  readonly nextAttemptAt: string;
129
222
  };
130
223
  readonly error?: PlatformError;
224
+ /**
225
+ * Present while the run is waiting for somebody to connect a provider — task #264.
226
+ *
227
+ * On the stream state rather than as a message part, because it is not a thing the model said: it is the
228
+ * platform telling a client *this run is stopped and here is the way to unstick it*. Cleared by
229
+ * `connection.completed`, so a client that reconnects mid-consent still sees the button and one that
230
+ * reconnects after it does not.
231
+ */
232
+ readonly connectionRequest?: {
233
+ readonly provider: string;
234
+ readonly loginUrl: string;
235
+ readonly scopes: readonly string[];
236
+ readonly toolName?: string;
237
+ readonly expiresAt: string;
238
+ };
131
239
  readonly terminal: boolean;
132
240
  };
133
241
  export declare const EMPTY_RUN_STREAM_STATE: RunStreamState;
@@ -22,8 +22,18 @@ export const RUN_EVENT_TYPES = [
22
22
  "question.answered",
23
23
  "approval.requested",
24
24
  "approval.decided",
25
+ /**
26
+ * The run needs a connection, and here is where to go and get one — task #264.
27
+ *
28
+ * Carries the provider, the scopes being asked for and a login URL. It carries **no secret**: not the client
29
+ * secret, not a token, not the PKCE verifier. This is an event a client renders and a person clicks.
30
+ */
31
+ "connection.requested",
32
+ "connection.completed",
25
33
  "usage.updated",
26
34
  "context.compacted",
35
+ "guardrail.verdict",
36
+ "catalog.truncated",
27
37
  ];
28
38
  export const EMPTY_RUN_STREAM_STATE = {
29
39
  parts: [],
@@ -80,11 +90,32 @@ export const reduceRunEvent = (state, event) => {
80
90
  ...base,
81
91
  retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, nextAttemptAt: event.nextAttemptAt },
82
92
  };
93
+ /**
94
+ * The connection request, held until consent completes — task #264.
95
+ *
96
+ * Set and cleared here rather than derived by each client, so a reconnecting client sees the button exactly
97
+ * when the run is actually waiting: the durable log replays both events in order, and the fold gives the
98
+ * same answer as if it had been watching live.
99
+ */
100
+ case "connection.requested":
101
+ return {
102
+ ...base,
103
+ connectionRequest: {
104
+ provider: event.provider,
105
+ loginUrl: event.loginUrl,
106
+ scopes: event.scopes,
107
+ ...(event.toolName === undefined ? {} : { toolName: event.toolName }),
108
+ expiresAt: event.expiresAt,
109
+ },
110
+ };
111
+ case "connection.completed":
112
+ return { ...base, connectionRequest: undefined };
83
113
  case "run.failed":
84
- return { ...base, error: event.error, terminal: true };
114
+ // Cleared: a failed run's login URL is a button that leads nowhere.
115
+ return { ...base, connectionRequest: undefined, error: event.error, terminal: true };
85
116
  case "run.completed":
86
117
  case "run.cancelled":
87
- return { ...base, terminal: true };
118
+ return { ...base, connectionRequest: undefined, terminal: true };
88
119
  default:
89
120
  return base;
90
121
  }