@athenaintel/react 0.32.0 → 0.36.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 (167) hide show
  1. package/README.md +148 -9
  2. package/dist/agent-runtime/src/composer-markdown.cjs.map +1 -1
  3. package/dist/agent-runtime/src/composer-markdown.js.map +1 -1
  4. package/dist/agent-runtime/src/deep-agent-transport-recipe.cjs +2 -3
  5. package/dist/agent-runtime/src/deep-agent-transport-recipe.cjs.map +1 -1
  6. package/dist/agent-runtime/src/deep-agent-transport-recipe.js +3 -4
  7. package/dist/agent-runtime/src/deep-agent-transport-recipe.js.map +1 -1
  8. package/dist/agent-runtime/src/edit-command.cjs.map +1 -1
  9. package/dist/agent-runtime/src/edit-command.js.map +1 -1
  10. package/dist/agent-runtime/src/generated/runconfig-contract.cjs +4 -0
  11. package/dist/agent-runtime/src/generated/runconfig-contract.cjs.map +1 -1
  12. package/dist/agent-runtime/src/generated/runconfig-contract.js +4 -0
  13. package/dist/agent-runtime/src/generated/runconfig-contract.js.map +1 -1
  14. package/dist/agent-runtime/src/messages.cjs +57 -2
  15. package/dist/agent-runtime/src/messages.cjs.map +1 -1
  16. package/dist/agent-runtime/src/messages.js +57 -2
  17. package/dist/agent-runtime/src/messages.js.map +1 -1
  18. package/dist/agent-runtime/src/quota-safe-local-storage-session.cjs +24 -3
  19. package/dist/agent-runtime/src/quota-safe-local-storage-session.cjs.map +1 -1
  20. package/dist/agent-runtime/src/quota-safe-local-storage-session.js +24 -3
  21. package/dist/agent-runtime/src/quota-safe-local-storage-session.js.map +1 -1
  22. package/dist/agent-runtime/src/statewire-transport.cjs +4 -13
  23. package/dist/agent-runtime/src/statewire-transport.cjs.map +1 -1
  24. package/dist/agent-runtime/src/statewire-transport.js +5 -14
  25. package/dist/agent-runtime/src/statewire-transport.js.map +1 -1
  26. package/dist/agent-runtime/src/tool-metadata.cjs +12 -1
  27. package/dist/agent-runtime/src/tool-metadata.cjs.map +1 -1
  28. package/dist/agent-runtime/src/tool-metadata.js +12 -1
  29. package/dist/agent-runtime/src/tool-metadata.js.map +1 -1
  30. package/dist/agent-runtime/src/use-deep-agent-thread.cjs.map +1 -1
  31. package/dist/agent-runtime/src/use-deep-agent-thread.js.map +1 -1
  32. package/dist/assets/AthenaAssetEmbed.cjs +3 -1
  33. package/dist/assets/AthenaAssetEmbed.cjs.map +1 -1
  34. package/dist/assets/AthenaAssetEmbed.d.ts +7 -0
  35. package/dist/assets/AthenaAssetEmbed.js +4 -2
  36. package/dist/assets/AthenaAssetEmbed.js.map +1 -1
  37. package/dist/assets/use-asset-embed.cjs +71 -71
  38. package/dist/assets/use-asset-embed.cjs.map +1 -1
  39. package/dist/assets/use-asset-embed.d.ts +20 -7
  40. package/dist/assets/use-asset-embed.js +72 -72
  41. package/dist/assets/use-asset-embed.js.map +1 -1
  42. package/dist/catalog/agent-mode-menu.cjs +115 -0
  43. package/dist/catalog/agent-mode-menu.cjs.map +1 -0
  44. package/dist/catalog/agent-mode-menu.d.ts +101 -0
  45. package/dist/catalog/agent-mode-menu.js +115 -0
  46. package/dist/catalog/agent-mode-menu.js.map +1 -0
  47. package/dist/catalog/agent-model-options.cjs +122 -0
  48. package/dist/catalog/agent-model-options.cjs.map +1 -0
  49. package/dist/catalog/agent-model-options.d.ts +115 -0
  50. package/dist/catalog/agent-model-options.js +122 -0
  51. package/dist/catalog/agent-model-options.js.map +1 -0
  52. package/dist/catalog/chat-pickers.cjs +235 -4
  53. package/dist/catalog/chat-pickers.cjs.map +1 -1
  54. package/dist/catalog/chat-pickers.d.ts +55 -3
  55. package/dist/catalog/chat-pickers.js +238 -7
  56. package/dist/catalog/chat-pickers.js.map +1 -1
  57. package/dist/catalog/runtime-catalog.cjs +121 -3
  58. package/dist/catalog/runtime-catalog.cjs.map +1 -1
  59. package/dist/catalog/runtime-catalog.d.ts +120 -0
  60. package/dist/catalog/runtime-catalog.js +124 -6
  61. package/dist/catalog/runtime-catalog.js.map +1 -1
  62. package/dist/catalog/use-athena-chat-mode.cjs +139 -0
  63. package/dist/catalog/use-athena-chat-mode.cjs.map +1 -0
  64. package/dist/catalog/use-athena-chat-mode.d.ts +43 -0
  65. package/dist/catalog/use-athena-chat-mode.js +139 -0
  66. package/dist/catalog/use-athena-chat-mode.js.map +1 -0
  67. package/dist/chat/AthenaChat.cjs +2 -0
  68. package/dist/chat/AthenaChat.cjs.map +1 -1
  69. package/dist/chat/AthenaChat.d.ts +8 -7
  70. package/dist/chat/AthenaChat.js +2 -0
  71. package/dist/chat/AthenaChat.js.map +1 -1
  72. package/dist/chat/super-grouping/SuperGroupingCard.cjs +30 -3
  73. package/dist/chat/super-grouping/SuperGroupingCard.cjs.map +1 -1
  74. package/dist/chat/super-grouping/SuperGroupingCard.d.ts +12 -9
  75. package/dist/chat/super-grouping/SuperGroupingCard.js +31 -4
  76. package/dist/chat/super-grouping/SuperGroupingCard.js.map +1 -1
  77. package/dist/chat/super-grouping/SuperGroupingTranscript.cjs.map +1 -1
  78. package/dist/chat/super-grouping/SuperGroupingTranscript.d.ts +8 -8
  79. package/dist/chat/super-grouping/SuperGroupingTranscript.js.map +1 -1
  80. package/dist/chat/super-grouping/activity-timeline.cjs.map +1 -1
  81. package/dist/chat/super-grouping/activity-timeline.d.ts +4 -4
  82. package/dist/chat/super-grouping/activity-timeline.js.map +1 -1
  83. package/dist/chat/super-grouping/index.d.ts +6 -5
  84. package/dist/chat/super-grouping/toolkit-map.cjs +3 -1
  85. package/dist/chat/super-grouping/toolkit-map.cjs.map +1 -1
  86. package/dist/chat/super-grouping/toolkit-map.js +3 -1
  87. package/dist/chat/super-grouping/toolkit-map.js.map +1 -1
  88. package/dist/chat-ui/index.cjs +1 -0
  89. package/dist/chat-ui/index.cjs.map +1 -1
  90. package/dist/chat-ui/index.d.ts +1 -1
  91. package/dist/chat-ui/index.js +2 -1
  92. package/dist/chat-ui/mentions/suggestions/data/api-client.cjs +33 -2
  93. package/dist/chat-ui/mentions/suggestions/data/api-client.cjs.map +1 -1
  94. package/dist/chat-ui/mentions/suggestions/data/api-client.d.ts +14 -0
  95. package/dist/chat-ui/mentions/suggestions/data/api-client.js +33 -2
  96. package/dist/chat-ui/mentions/suggestions/data/api-client.js.map +1 -1
  97. package/dist/chat-ui/statewire.cjs +3 -0
  98. package/dist/chat-ui/statewire.cjs.map +1 -1
  99. package/dist/chat-ui/statewire.d.ts +4 -2
  100. package/dist/chat-ui/statewire.js +3 -0
  101. package/dist/chat-ui/statewire.js.map +1 -1
  102. package/dist/index.cjs +4 -0
  103. package/dist/index.cjs.map +1 -1
  104. package/dist/index.d.ts +7 -4
  105. package/dist/index.js +6 -2
  106. package/dist/index.js.map +1 -1
  107. package/dist/lib/use-athena-rest-query.cjs +4 -0
  108. package/dist/lib/use-athena-rest-query.cjs.map +1 -1
  109. package/dist/lib/use-athena-rest-query.d.ts +6 -0
  110. package/dist/lib/use-athena-rest-query.js +4 -0
  111. package/dist/lib/use-athena-rest-query.js.map +1 -1
  112. package/dist/provider/AthenaProvider.cjs +81 -9
  113. package/dist/provider/AthenaProvider.cjs.map +1 -1
  114. package/dist/provider/AthenaProvider.d.ts +25 -5
  115. package/dist/provider/AthenaProvider.js +83 -11
  116. package/dist/provider/AthenaProvider.js.map +1 -1
  117. package/dist/provider/agent-selection-context.cjs +6 -0
  118. package/dist/provider/agent-selection-context.cjs.map +1 -0
  119. package/dist/provider/agent-selection-context.d.ts +14 -0
  120. package/dist/provider/agent-selection-context.js +6 -0
  121. package/dist/provider/agent-selection-context.js.map +1 -0
  122. package/dist/provider/chat-mode-context.cjs +6 -0
  123. package/dist/provider/chat-mode-context.cjs.map +1 -0
  124. package/dist/provider/chat-mode-context.d.ts +35 -0
  125. package/dist/provider/chat-mode-context.js +6 -0
  126. package/dist/provider/chat-mode-context.js.map +1 -0
  127. package/dist/react/package.json.cjs +1 -1
  128. package/dist/react/package.json.js +1 -1
  129. package/dist/runtime/StatewireThreadConfigLaneSync.cjs +125 -0
  130. package/dist/runtime/StatewireThreadConfigLaneSync.cjs.map +1 -0
  131. package/dist/runtime/StatewireThreadConfigLaneSync.d.ts +54 -0
  132. package/dist/runtime/StatewireThreadConfigLaneSync.js +125 -0
  133. package/dist/runtime/StatewireThreadConfigLaneSync.js.map +1 -0
  134. package/dist/runtime/statewire-model-lane.cjs +0 -12
  135. package/dist/runtime/statewire-model-lane.cjs.map +1 -1
  136. package/dist/runtime/statewire-model-lane.d.ts +3 -1
  137. package/dist/runtime/statewire-model-lane.js +0 -12
  138. package/dist/runtime/statewire-model-lane.js.map +1 -1
  139. package/dist/runtime/statewire-run-config.cjs +10 -6
  140. package/dist/runtime/statewire-run-config.cjs.map +1 -1
  141. package/dist/runtime/statewire-run-config.d.ts +7 -0
  142. package/dist/runtime/statewire-run-config.js +10 -6
  143. package/dist/runtime/statewire-run-config.js.map +1 -1
  144. package/dist/runtime/statewire-server-lane-context.cjs.map +1 -1
  145. package/dist/runtime/statewire-server-lane-context.d.ts +2 -2
  146. package/dist/runtime/statewire-server-lane-context.js.map +1 -1
  147. package/dist/runtime/statewire-thread-config-lane.cjs +84 -0
  148. package/dist/runtime/statewire-thread-config-lane.cjs.map +1 -0
  149. package/dist/runtime/statewire-thread-config-lane.d.ts +94 -0
  150. package/dist/runtime/statewire-thread-config-lane.js +84 -0
  151. package/dist/runtime/statewire-thread-config-lane.js.map +1 -0
  152. package/dist/runtime/useAthenaStatewireRuntime.cjs +4 -2
  153. package/dist/runtime/useAthenaStatewireRuntime.cjs.map +1 -1
  154. package/dist/runtime/useAthenaStatewireRuntime.d.ts +2 -0
  155. package/dist/runtime/useAthenaStatewireRuntime.js +4 -2
  156. package/dist/runtime/useAthenaStatewireRuntime.js.map +1 -1
  157. package/dist/threads/use-athena-thread-model.cjs +11 -3
  158. package/dist/threads/use-athena-thread-model.cjs.map +1 -1
  159. package/dist/threads/use-athena-thread-model.d.ts +5 -0
  160. package/dist/threads/use-athena-thread-model.js +12 -4
  161. package/dist/threads/use-athena-thread-model.js.map +1 -1
  162. package/package.json +3 -3
  163. package/dist/runtime/StatewireModelLaneSync.cjs +0 -96
  164. package/dist/runtime/StatewireModelLaneSync.cjs.map +0 -1
  165. package/dist/runtime/StatewireModelLaneSync.d.ts +0 -45
  166. package/dist/runtime/StatewireModelLaneSync.js +0 -96
  167. package/dist/runtime/StatewireModelLaneSync.js.map +0 -1
package/README.md CHANGED
@@ -16,11 +16,10 @@ npm install @propelauth/react
16
16
 
17
17
  ## Upgrading
18
18
 
19
- SDK0.29.0 uses native Statewire wire2026-09-30 with Athena application2026-09-13
20
- when installed with public, unpatched dependencies, over HTTP/SSE: published
21
- statewire0.20.0 pings a WebSocket with empty frames that Athena's hosts reject,
22
- so the SDK opens a socket only over the patched client the monorepo's own apps
23
- install. It requires a compatible backend. Publication is temporarily withheld until production HTTP+WebSocket
19
+ SDK0.29.0 uses native Statewire wire2026-09-30 with Athena application2026-09-13.
20
+ With statewire 0.21 it opens a WebSocket on public, unpatched installs too: its
21
+ `{}` ping is answered by Athena's hosts from Python statewire 0.19.1 on. It
22
+ requires a compatible backend. Publication is temporarily withheld until production HTTP+WebSocket
24
23
  and authorization tests pass and a compatible backend rollback release is
25
24
  retained. The publish workflow requires a manual
26
25
  `PRODUCTION_NATIVE_CONTEXT_AND_ROLLBACK_QUALIFIED` acknowledgement; dry runs
@@ -188,6 +187,21 @@ refused write (a view-only collaborator, or a workspace whose sessions cannot
188
187
  hold server config) is reported as a `statewire.config` diagnostics event, and
189
188
  the session keeps running with its own model.
190
189
 
190
+ The same holds for `systemPrompt`, `tools` (with the backend-bound
191
+ `frontendTools`), `workbench` and `knowledgeBase`. On a session that has run,
192
+ the server runs every turn with the values its server config holds, so
193
+ changing one of these props while the session is open writes it there: one
194
+ `config/patch` per change, carrying exactly the changed keys (`model`,
195
+ `system_prompt`, `enabled_tools`, `workbench`, `knowledge_base`). A blank
196
+ prompt or an empty list clears its key, so the collab agent's own applies
197
+ again. The values a session is opened with are never written, and a host that
198
+ never changes these props after the first send writes nothing. The
199
+ `statewire.config` event names the refused keys other than the model in
200
+ `config_keys`, never their values.
201
+
202
+ For a collab agent whose author restricts it to its own models, see
203
+ [Offering an agent's own models](#offering-an-agents-own-models).
204
+
191
205
  If `config.appUrl` is omitted, the provider resolves it from the parent bridge or from the matching Athena environment defaults. Known Athena staging and production `apiUrl` and `backendUrl` values automatically fall back to the corresponding frontend origin.
192
206
 
193
207
  Use `trustedParentOrigins` when the SDK runs inside an iframe on a private-cloud or custom parent domain and you want to explicitly allow postMessage auth/config from that parent. Only the window that frames the app is heard. By default `athenaintel.com` hosts, localhost and your own origin are trusted, except the hosts that serve customer-written code (`*.app.athenaintel.com`, `*.code-sandpack.athenaintel.com` and friends; see "What's new in 0.28.0").
@@ -257,10 +271,10 @@ What the statewire transport wires up in `AthenaChat`:
257
271
  its `classifyToolResult`; the fallback takes host seams (`displayName`,
258
272
  `icon`, `extraAction`, `notice`, `renderResult`, `onOpenAsset`,
259
273
  `hideInternalIdArgs`) instead of being copied. `@athenaintel/react/super-grouping`
260
- exports the grouped ("conversational") view of an assistant turn —
261
- `SuperGroupingTranscript` (what `AthenaChat` renders), `SuperGroupingCard`,
262
- `SuperGroupingFinalText`, `CollapsibleGroup`, the activity-timeline
263
- derivation, and the tool-based grouping function — with
274
+ exports the collapsed view (previously called the conversational view) of
275
+ an assistant turn — `SuperGroupingTranscript` (what `AthenaChat` renders),
276
+ `SuperGroupingCard`, `SuperGroupingFinalText`, `CollapsibleGroup`, the
277
+ activity-timeline derivation, and the tool-based grouping function — with
264
278
  slots for the host's text/tool renderers and controls (`renderHeaderActions`,
265
279
  `renderRunningMeta`, `summaryDetails`, `waitingTools`, `expansionKey`), so
266
280
  Olympus and the Chrome extension mount one card instead of copies.
@@ -760,6 +774,29 @@ Both use the same bearer / API key as session creation. The full recipe
760
774
  template skill `voice-session-guide.md`. `createAthenaVoiceSession` is also
761
775
  exported if you hold the bridge state yourself.
762
776
 
777
+ ## What's new in 0.36.0
778
+
779
+ **An agent's modes in your chat.** Pass `modes` (mode ids, `base` for the agent as configured) beside `agent` and `channel`, and `AthenaChat` shows a mode menu in its composer with the ones the agent's published definition allows on that channel: its start mode, the channel itself, and the modes its author lists for it. A pick applies to the open session's next send, or starts a new session in it. It is sent as `collab_channel_id` with the channel as `collab_entry_channel_id`, so the session keeps the channel's switch rules and still shows in a thread list filtered by channel (Athena's server reads that key from this release on and ignores it before). A channel whose author locks it lets people pick a session's mode before its first message only. `useAthenaChatMode()` and `ChatModePicker` build a menu of your own. See [Switching an agent's modes](#switching-an-agents-modes).
780
+
781
+ **`StatewireRunRefusalNotice` is on the lean statewire entry** (`@athenaintel/react/chat-ui/statewire`), so a host with its own composer can show why the server refused to start a run.
782
+
783
+ ## What's new in 0.35.0
784
+
785
+ **`AthenaAssetEmbed` and `useAssetEmbed` can offer Quick Edit.** Pass `allowQuickEdit` (default `false`) with `readOnly={false}` to mint an editable embed link whose viewer can press ⌘J (Ctrl+J off a Mac), or pick **AI Quick Edit** from the right-click menu, to rewrite a selection in a spreadsheet or Word document. It applies to the `minimal` display mode, where the viewer does not sign in; in `full` mode the signed-in viewer already has Athena's own Quick Edit. The request carries `allow_quick_edit: true` only when you ask, so a backend that predates it never sees the field. Runs are billed to the asset's workspace under the account that created the link, and each link has its own hourly cap. A link minted with `embedUrl` says for itself whether it offers Quick Edit. See the embeds guide in the Athena docs.
786
+
787
+ **`useAssetEmbed` mints through TanStack Query.** It uses your `QueryClientProvider`'s client when there is one, else one client for the page. As before, embeds of the same asset, mode and identity share one minted link until it expires. A rotated token for the same session mints none, and a link is never minted again while it is valid: no refetch on window focus or reconnect, no retry. The query key carries a fingerprint of the identity, never the token or API key itself.
788
+
789
+
790
+ **A prompt, tool list, workbench or knowledge base changed on a session that has run applies.** 0.32.0 did this for `model` only. Once a session has server config, the server runs every turn with the values that config holds and ignores the ones a send carries, so a `systemPrompt`, `tools`/`frontendTools`, `workbench` or `knowledgeBase` changed after the first turn used to be dropped. Changing one while such a session is open now writes it there: one `config/patch` per change, carrying exactly the changed keys (`system_prompt`, `enabled_tools`, `workbench`, `knowledge_base`, beside `model`). A blank prompt or an empty list clears its key, so the collab agent's own applies. As with the model, the values a session is opened with are never written, a host that never changes these props after the first send sees no new writes, and a refused write is a `statewire.config` diagnostics event (`config_keys` names the keys). See [A model picker, and a session's own model](#a-model-picker-and-a-sessions-own-model).
791
+
792
+ **`ChatModelPicker` follows an agent's "Allow any workspace model" switch.** Restriction is opt-in per agent: with the switch on (the default, and every agent saved before it existed) a chosen agent's picker is the workspace catalog with "Agent's own model", as before, now naming the agent's default (its channel's override on a channel that has one). With the switch off (`allowAnyModel: false` in the published definition), the picker lists exactly the models the agent's author listed, by their labels, with the channel's override as the default there. One option is a label; picking the default hands the host `null` (the existing "Agent's own model"); a session's model outside the list is shown, never replaced. Which agent: `agent`, or `agentSelected` inside the provider, which then reads the provider's own agent and channel. Without an agent the picker is unchanged. **`useAthenaAgentModelOptions({ agent, channel })`** returns the same choices and `allowAnyModel` for a picker of your own. See [Offering an agent's own models](#offering-an-agents-own-models).
793
+
794
+ **`fetchGraphQL` failures carry their code.** It throws an `AthenaGraphQLError` (exported from `@athenaintel/react/chat-ui`) with the HTTP `status` or the first GraphQL error's `extensions.code`; the messages are unchanged.
795
+
796
+ ## What's new in 0.33.0
797
+
798
+ **Chat runs over the WebSocket on a plain npm install.** The SDK used to open a socket only over the statewire client the monorepo patched, because published statewire 0.20 pinged with empty frames that Athena's hosts closed. So every app installed from npm fell back to HTTP/SSE. The SDK now depends on statewire 0.21.0 and `@assistant-ui/react-statewire` 0.31.0. Athena's hosts answer the ping, and the SDK opens a WebSocket unless the host asks for SSE (#45143). A rejected model context (for example one over the 64 KiB cap) is reported through `onError`, and sends are held until a valid one is accepted, instead of throwing during render. Nothing to change in your app beyond taking this version; keep `statewire`, `@assistant-ui/react-statewire` and `react` deduplicated as before.
799
+
763
800
  ## What's new in 0.32.0
764
801
 
765
802
  **A model picked after mount reaches the server.** The provider registered its run config once per thread attach, so a `model` (or `channel`, `tools`, `systemPrompt`, …) changed after mount never reached the next send unless something else happened to re-sync the context. The registration is now keyed by the built run config's value: a changed value re-syncs before the next send, while fresh array literals per render still change nothing.
@@ -1285,6 +1322,105 @@ Pass them only when you deliberately want to override the agent — an explicit
1285
1322
  `model` is honoured as a caller override, with the agent's model as the default
1286
1323
  beneath it.
1287
1324
 
1325
+ ### Offering an agent's own models
1326
+
1327
+ An agent's author lists the models it may run (the agent editor's Models
1328
+ section), and decides with its **Allow any workspace model** switch whether
1329
+ people may still pick any other. Restriction is opt-in: the switch is on by
1330
+ default, and every agent saved before it existed allows any model.
1331
+
1332
+ With an agent chosen, `ChatModelPicker` reads the agent's published definition:
1333
+
1334
+ - **Allow any workspace model on** (the default; also an agent the person
1335
+ cannot view): the workspace's models and "Agent's own model" (`null`),
1336
+ exactly as before, with the agent's default (its channel's override when the
1337
+ chat runs on a channel that has one) named under "Agent's own model".
1338
+ - **Switch off** (`allowAnyModel: false`): exactly the agent's own models, not
1339
+ the workspace's.
1340
+
1341
+ ```tsx
1342
+ <ChatModelPicker
1343
+ value={modelId} // null: the agent's own model
1344
+ onChange={setModelId}
1345
+ defaultModelId="claude-opus-5-5"
1346
+ agent={agentId} // or agentSelected, inside the provider: its own agent and channel
1347
+ channel="askbob_web"
1348
+ />
1349
+ ```
1350
+
1351
+ For an agent with the switch off:
1352
+
1353
+ - The options are the agent's labeled models, by the author's labels; an
1354
+ automatic name shows the model's catalog name. A legacy agent with no list
1355
+ offers its single model.
1356
+ - The channel's model override is that channel's default, added when the list
1357
+ lacks it. Picking the default hands you `null`: pass `model={undefined}` to
1358
+ the provider, so the agent's (or the channel's) default runs and a session
1359
+ that pinned another model is unpinned. Picking it while the session pins
1360
+ nothing writes nothing. A choice of yours that names the default model is a
1361
+ pin the agent's next default would not follow, so the menu then also offers
1362
+ "Agent's own model" (`null`).
1363
+ - A model the session pins outside the list, or a choice of yours the agent
1364
+ does not list, is shown as it is, never replaced.
1365
+ - The agent is read again every minute while the picker shows, so a newly
1366
+ published list appears without a reload.
1367
+ - One option is a label, not a menu.
1368
+ - Until the agent has been read, the workspace menu shows but replaces nothing
1369
+ on your behalf, since your model may be one only the agent offers.
1370
+
1371
+ `useAthenaAgentModelOptions({ agent, channel })` is the same data for a picker
1372
+ of your own: `{ status, options: [{ model, label, isDefault, pinnedOutside }],
1373
+ defaultModel, allowAnyModel }`. Offer only `options` when `allowAnyModel` is
1374
+ `false`; it is `true` when the definition says nothing, `null` or `true`.
1375
+ `agent` is the asset id or the `collab_agent:<id>` ref. The agent is read
1376
+ through the permission-checked asset query (`getAssetById`, VIEW on the agent)
1377
+ with the provider's credentials, so `status` is `unavailable` (no options,
1378
+ `allowAnyModel: true`, not an error) for a person who cannot view it, or an
1379
+ agent never published. The session's pinned model comes from
1380
+ `useAthenaThreadModel()`.
1381
+
1382
+ ### Switching an agent's modes
1383
+
1384
+ An agent can have **modes** (its Modes tab in Athena): the same agent with a
1385
+ narrower tool set, another model, a budget, or read-only, each an override of
1386
+ the agent as configured (`base`). Pass `modes` to let people switch between
1387
+ them in your chat:
1388
+
1389
+ ```tsx
1390
+ <AthenaProvider
1391
+ agent="collab_agent:asset_432af46f-293d-480b-a518-30b1f42a9ef7"
1392
+ channel="cognition"
1393
+ modes={['base', 'ask']}
1394
+ >
1395
+ <AthenaChat />
1396
+ </AthenaProvider>
1397
+ ```
1398
+
1399
+ `AthenaChat` then shows a mode menu in its composer. It offers the listed modes
1400
+ that the agent's published definition allows on `channel`: the channel's start
1401
+ mode, the channel itself, and the modes the agent's author lists for it on the
1402
+ Channels tab ("People can switch to"). A mode the agent does not offer there is
1403
+ left out, and with fewer than two the menu does not show. A pick applies to the
1404
+ open session's next send; picked before a session's first message, the session
1405
+ starts in it. On a channel whose author turned on **Keep each session in the
1406
+ mode it starts in**, people pick the mode before the first message and it stays.
1407
+
1408
+ A pick is sent as `collab_channel_id`, with `channel` beside it as
1409
+ `collab_entry_channel_id`, so the session still counts as entered through your
1410
+ channel: its switch list and lock apply, and a thread list filtered by channel
1411
+ finds it. A session that already runs in another mode (switched in another
1412
+ browser) is read from its session asset, again whenever the chat regains focus
1413
+ and after every run, and its sends keep it there; a pick holds until the
1414
+ session runs in it, or until the menu stops offering it. agora checks every switch
1415
+ against the published definition and refuses one the agent does not offer with
1416
+ `collab_agent_channel_session_mismatch`.
1417
+
1418
+ Build your own menu with `useAthenaChatMode()` (inside the provider): it returns
1419
+ `{ status: 'off' }`, `{ status: 'loading' }`, or `{ status: 'ready', options,
1420
+ current, currentLabel, canSwitch, locked, select }`; `ChatModePicker` is the
1421
+ chip `AthenaChat` uses. Copy a channel's snippet, `modes` included, from the
1422
+ agent's Channels tab.
1423
+
1288
1424
  ### Failure codes
1289
1425
 
1290
1426
  A refused selection surfaces as an `AthenaSdkError` with
@@ -1299,6 +1435,7 @@ A refused selection surfaces as an `AthenaSdkError` with
1299
1435
  | `collab_agent_channel_unknown` | No such channel (the message lists the known ones) |
1300
1436
  | `collab_agent_channel_disabled` | The channel exists but is toggled off |
1301
1437
  | `collab_agent_channel_kind_mismatch` | A voice channel was selected for a text run |
1438
+ | `collab_agent_channel_session_mismatch` | A send named a mode the session cannot switch to, or a first send a mode its channel does not start sessions in (`allowed_channel_ids` names what is offered) |
1302
1439
  | `collab_agent_runs_disabled` | The deployment has the SDK seam turned off |
1303
1440
 
1304
1441
  ## Authentication
@@ -1425,6 +1562,8 @@ host that opens a cited place in Athena on the web writes it into the Spaces lin
1425
1562
 
1426
1563
  Use `AthenaAssetEmbed` to render a native Athena asset without chat, thread history, tabs, or the SDK split-pane layout. It defaults to the editable, full native asset experience (`readOnly={false}` and `displayMode="full"`). The embed token and the current user must still have edit permission; otherwise Athena renders the asset read-only.
1427
1564
 
1565
+ With `displayMode="minimal"`, where the viewer does not sign in, pass `allowQuickEdit` (and `readOnly={false}`) to let the viewer Quick Edit a spreadsheet or Word document with ⌘J. Its runs are billed to the asset's workspace, with an hourly cap per link.
1566
+
1428
1567
  ```tsx
1429
1568
  import { useState } from 'react';
1430
1569
  import {
@@ -1 +1 @@
1
- {"version":3,"file":"composer-markdown.cjs","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.30.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"composer-markdown.cjs","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.31.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"composer-markdown.js","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.30.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"composer-markdown.js","sources":["../../../../agent-runtime/src/composer-markdown.ts"],"sourcesContent":["/**\n * The composer markdown a chat message was sent from.\n *\n * The chat composer sends a message's plain text exactly as typed, because the\n * model reads the text literally, and writes real formatting (bold, lists,\n * code, mention chips) as markdown. The sent text alone no longer says which\n * characters are formatting and which were typed: literal `**strong**` and\n * bold `strong` both send `**strong**`, and `List<T>`, `a\\\\b` or `2*3*4` read\n * as markup when the text is rendered as markdown. So the composer also\n * attaches the draft the text was serialized from (the same document with its\n * plain text escaped for markdown) under {@link DEEP_AGENT_COMPOSER_MARKDOWN_KEY}\n * in the message's metadata, and only when the two differ.\n *\n * It rides the statewire message, the queue and steer lanes and the persisted\n * HumanMessage (`additional_kwargs.metadata`, which the converter surfaces as\n * `metadata.custom`). It is display-only: the bubble renders it, and Edit,\n * branch-and-edit and a withdrawn turn load it back into the composer. The\n * model reads the message text, never this. A message without it (older\n * messages, other clients) renders its text as before.\n *\n * agora validates it with the same rule\n * (`agora/graphs/athena_deep_agent/composer_markdown.py`).\n */\nexport const DEEP_AGENT_COMPOSER_MARKDOWN_KEY = 'composer_markdown';\n\nconst DRAFT_CHARACTER_REFERENCES: Readonly<Record<string, string>> = {\n lt: '<',\n gt: '>',\n amp: '&',\n '#32': ' ',\n '#9': '\\t',\n};\n\n/**\n * A sent text in comparable form: every backslash dropped (the draft's\n * escapes, and the `\\` before a hard break's newline, are backslashes).\n */\nconst comparableText = (text: string): string => text.replace(/\\\\/g, '');\n\n/**\n * A draft in comparable form: every backslash dropped, then the character\n * references the draft serializer writes decoded in one pass (`&lt;` and\n * `&gt;` for typed `<` and `>`, `&amp;` for a typed `&` that starts a\n * reference, so typed `&lt;` is `&amp;lt;`, and `&#32;` / `&#9;` for the space\n * or tab a line starts with, which markdown would otherwise drop).\n */\nconst comparableDraft = (markdown: string): string =>\n comparableText(markdown).replace(\n /&(lt|gt|amp|#32|#9);/g,\n (reference, name: string) => DRAFT_CHARACTER_REFERENCES[name] ?? reference\n );\n\n/**\n * Escaping at most turns one character into four (`<` → `&lt;`). A line's\n * first space becomes five (`&#32;`), but only once a line, so a whole line\n * with its line break stays under four a character; the slack covers short\n * texts. agora caps a stored draft the same way.\n */\nconst MAX_EXPANSION = 4;\nconst EXPANSION_SLACK_CHARS = 1024;\n\n/**\n * Whether `markdown` is the draft of `text`: no longer than escaping could make\n * it, and equal to it once backslashes are dropped and the draft's character\n * references decoded. A draft that would show other\n * words than the text the model read (another message's, a client's mistake) is\n * not taken; such a message renders its text as before.\n */\nexport function isComposerMarkdownOfText({\n markdown,\n text,\n}: {\n markdown: string;\n text: string;\n}): boolean {\n return (\n markdown.length <= text.length * MAX_EXPANSION + EXPANSION_SLACK_CHARS &&\n comparableDraft(markdown) === comparableText(text)\n );\n}\n\n/** The references a draft writes a line's first space or tab as. */\nconst DRAFT_WHITESPACE_REFERENCES: readonly string[] = ['&#32;', '&#9;'];\n\nconst isWhitespace = (character: string): boolean => /\\s/.test(character);\n\n/** A hard break in a draft: `\\` before the newline (the text holds the newline alone). */\nconst DRAFT_HARD_BREAK = '\\\\\\n';\n\n/** Whether an odd run of backslashes ends at `end`: its last one escapes what follows, not a typed `\\` (`\\\\`). */\nfunction endsInEscapingBackslash({\n markdown,\n start,\n end,\n}: {\n markdown: string;\n start: number;\n end: number;\n}): boolean {\n let backslashes = 0;\n while (end - backslashes > start && markdown.charAt(end - backslashes - 1) === '\\\\') {\n backslashes += 1;\n }\n return backslashes % 2 === 1;\n}\n\n/**\n * `markdown`, a draft, trimmed as its text is trimmed (`text.trim()`): a\n * send that trims its text stays a draft of it. `markdown.trim()` would keep\n * the `&#32;` a draft writes for the space its first line starts with, and the\n * `\\` of a line break whose newline it trims.\n */\nexport function trimComposerDraft(markdown: string): string {\n let start = 0;\n let end = markdown.length;\n for (;;) {\n while (start < end && isWhitespace(markdown.charAt(start))) start += 1;\n const reference = [...DRAFT_WHITESPACE_REFERENCES, DRAFT_HARD_BREAK].find(\n (candidate) => start + candidate.length <= end && markdown.startsWith(candidate, start)\n );\n if (reference === undefined) break;\n start += reference.length;\n }\n for (;;) {\n while (end > start && isWhitespace(markdown.charAt(end - 1))) end -= 1;\n const reference = DRAFT_WHITESPACE_REFERENCES.find(\n (candidate) => end - candidate.length >= start && markdown.endsWith(candidate, end)\n );\n if (reference !== undefined) {\n end -= reference.length;\n } else if (\n markdown.charAt(end) === '\\n' &&\n endsInEscapingBackslash({ markdown, start, end })\n ) {\n // The newline just trimmed was a line break's: its `\\` goes with it.\n end -= 1;\n } else {\n break;\n }\n }\n return markdown.slice(start, end);\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * The composer markdown to render or load for a message whose metadata is\n * `metadata` (a message's `metadata.custom`, or a lane entry's `metadata`) and\n * whose text is `text`; `null` when it carries none, or one that is not a\n * draft of `text`.\n */\nexport function readDeepAgentComposerMarkdown({\n metadata,\n text,\n}: {\n metadata: unknown;\n text: string;\n}): string | null {\n if (!isRecord(metadata)) return null;\n const markdown: unknown = metadata[DEEP_AGENT_COMPOSER_MARKDOWN_KEY];\n return typeof markdown === 'string' && isComposerMarkdownOfText({ markdown, text })\n ? markdown\n : null;\n}\n\n/**\n * How long a staged composer markdown waits for its submission\n * ({@link stageComposerMarkdown}).\n */\nexport const STAGED_COMPOSER_MARKDOWN_TTL_MS = 10_000;\n\ninterface StagedComposerMarkdown {\n readonly markdown: string;\n readonly stagedAt: number;\n}\n\n/**\n * The staged drafts live on `globalThis`, not in module scope, because two\n * copies of this module run in one page: the SDK bundles agent-runtime into its\n * dist, so its composer stages through the SDK's copy, while the Chrome\n * extension and the Office add-in build their runtime on agent-runtime directly\n * and take the draft through their own (see `asset-attachment.ts`).\n */\nconst STAGED_COMPOSER_MARKDOWN = Symbol.for('@athenaintel/agent-runtime/staged-composer-markdown');\n\ntype StagedComposerMarkdownHost = typeof globalThis & {\n [STAGED_COMPOSER_MARKDOWN]?: Map<string, StagedComposerMarkdown>;\n};\n\nfunction stagedComposerMarkdownByThread(): Map<string, StagedComposerMarkdown> {\n const host = globalThis as StagedComposerMarkdownHost;\n return (host[STAGED_COMPOSER_MARKDOWN] ??= new Map());\n}\n\n/**\n * Stage the draft `markdown` of `text` for `threadId`'s next submission.\n *\n * statewire captures message metadata once per new user message\n * (`getMessageMetadata`, patched into `@assistant-ui/react-statewire@0.31.0`)\n * without saying which message it is for, so a composer stages its draft right\n * before it sends and the runtime's getter takes it for the submission that\n * follows ({@link withStagedComposerMarkdown}). A draft equal to its text adds\n * nothing and clears the slot. A slot nobody takes within\n * {@link STAGED_COMPOSER_MARKDOWN_TTL_MS} expires; one a submission the\n * composer did not make takes is harmless, since a composer markdown that is\n * not a draft of the message's own text is neither kept by agora nor rendered.\n */\nexport function stageComposerMarkdown({\n threadId,\n text,\n markdown,\n now = Date.now(),\n}: {\n threadId: string;\n text: string;\n markdown: string;\n /** Test seam: the time of the send. */\n now?: number;\n}): void {\n const staged = stagedComposerMarkdownByThread();\n if (markdown === text) {\n staged.delete(threadId);\n return;\n }\n staged.set(threadId, { markdown, stagedAt: now });\n}\n\n/**\n * The message metadata for `threadId`'s submission now being journaled: the\n * staged composer markdown, if one is waiting and fresh. Takes the slot.\n */\nexport function takeStagedComposerMarkdownMetadata(\n threadId: string,\n now: number = Date.now()\n): Record<string, unknown> | undefined {\n const staged = stagedComposerMarkdownByThread();\n const entry = staged.get(threadId);\n staged.delete(threadId);\n if (entry === undefined || now - entry.stagedAt > STAGED_COMPOSER_MARKDOWN_TTL_MS) {\n return undefined;\n }\n return { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: entry.markdown };\n}\n\ntype MessageMetadataGetter = () =>\n | Record<string, unknown>\n | undefined\n | Promise<Record<string, unknown> | undefined>;\n\n/**\n * A runtime's `getMessageMetadata` that also carries the composer markdown a\n * composer staged for `threadId` ({@link stageComposerMarkdown}), on top of the\n * host's own metadata (`getMessageMetadata`, when it has one). The slot is\n * taken synchronously at submission, before the host's capture resolves, so a\n * draft never rides a later message. With nothing staged the host's own value\n * is returned as is.\n */\nexport function withStagedComposerMarkdown({\n threadId,\n getMessageMetadata,\n}: {\n threadId: string;\n getMessageMetadata?: MessageMetadataGetter | undefined;\n}): MessageMetadataGetter {\n return () => {\n const staged = takeStagedComposerMarkdownMetadata(threadId);\n const captured = getMessageMetadata?.();\n if (staged === undefined) return captured;\n const merge = (host: Record<string, unknown> | undefined): Record<string, unknown> => ({\n ...host,\n ...staged,\n });\n return captured instanceof Promise ? captured.then(merge) : merge(captured);\n };\n}\n\n/** Test seam: drop every staged composer markdown. */\nexport function resetStagedComposerMarkdown(): void {\n stagedComposerMarkdownByThread().clear();\n}\n\n/**\n * `metadata` with the composer markdown set to the draft `markdown` of `text`,\n * or without it when the draft equals the text (it adds nothing then).\n * Returns `undefined` when nothing is left.\n */\nexport function withDeepAgentComposerMarkdown({\n metadata,\n text,\n markdown,\n}: {\n metadata: Readonly<Record<string, unknown>> | undefined;\n text: string;\n markdown: string;\n}): Record<string, unknown> | undefined {\n const { [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: _previous, ...rest } = metadata ?? {};\n const next: Record<string, unknown> =\n markdown === text ? rest : { ...rest, [DEEP_AGENT_COMPOSER_MARKDOWN_KEY]: markdown };\n return Object.keys(next).length > 0 ? next : undefined;\n}\n"],"names":[],"mappings":"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"}
@@ -20,11 +20,10 @@ function createDeepAgentAttachTransport({
20
20
  onAuthResolve,
21
21
  onAttachTiming,
22
22
  retryGate = statewireAttachObserver.attachRetryGate,
23
- messageWindow: messageWindow$1,
24
- statewireClient
23
+ messageWindow: messageWindow$1
25
24
  }) {
26
25
  const url = deepAgentThreadUrl({ baseUrl, threadId });
27
- const useSse = statewireTransport.resolveStatewireTransportKind({ sse, client: statewireClient }) === "sse";
26
+ const useSse = statewireTransport.resolveStatewireTransportKind({ sse }) === "sse";
28
27
  const instrumentation = statewireAttachObserver.createAttachInstrumentation({
29
28
  url,
30
29
  sse: useSse,
@@ -1 +1 @@
1
- {"version":3,"file":"deep-agent-transport-recipe.cjs","sources":["../../../../agent-runtime/src/deep-agent-transport-recipe.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { Statewire, StatewireClient } from \"statewire\";\nimport {\n type DeepAgentMessageWindowDeclaration,\n withDeclaredMessageWindow,\n} from \"./message-window\";\nimport { withDeclaredProtocolVersion } from \"./protocol\";\nimport {\n attachRetryGate,\n type AttachRetryGate,\n createAttachInstrumentation,\n type DeepAgentAttachTimingEvent,\n type DeepAgentWakeRefusal,\n parseRetryAfterHeaderMs,\n} from \"./statewire-attach-observer\";\nimport {\n resolveStatewireTransportKind,\n statewireTransport,\n type StatewireTransportOptions,\n} from \"./statewire-transport\";\n\n/** Auth-header resolver for the transport; `staleAuth` is true right after an\n * auth rejection, so resolvers that can force-renew should honor it. */\nexport type DeepAgentHeadersResolver = (ctx: {\n staleAuth: boolean;\n}) => Record<string, string> | Promise<Record<string, string>>;\n\n/** The statewire attach URL for a thread: the host's `…/v2/threads` mount plus the id. */\nexport function deepAgentThreadUrl({\n baseUrl,\n threadId,\n}: {\n baseUrl: string;\n threadId: string;\n}): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}/${encodeURIComponent(threadId)}`;\n}\n\nexport interface DeepAgentAttachTransportOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n /**\n * SSE when true, WebSocket otherwise, over the qualified statewire client: an\n * unqualified one gets SSE either way (`resolveStatewireTransportKind`).\n */\n sse?: boolean;\n /** The statewire client class whose qualification picks the transport: the installed one unless given. */\n statewireClient?: object;\n /** A never-started chat: the transport makes no network calls until the first command send. */\n isNew?: boolean;\n /** Host session store (mobile); browser hosts mount `storage.session` on the thread instead. */\n sessionStore?: StatewireClient.SessionStorage | undefined;\n /** Observes every headers resolve (the diagnostics recorder's stale-auth seam). */\n onAuthResolve?: ((ctx: { staleAuth: boolean }) => void) | undefined;\n /** Attach timing + retry hints (see `statewire-attach-observer`). */\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n /**\n * The head window declared on the attach (`x-athena-deep-agent-window`,\n * see `message-window.ts`): the hello of a client the host does not\n * remember carries the newest `size` messages. Part of the recipe so a\n * pooled dial asks for the same shape as the thread's own attach — a pooled\n * hello then seeds the very state the attach will reconcile. Omitted, the\n * shared default is declared.\n */\n messageWindow?: DeepAgentMessageWindowDeclaration | undefined;\n}\n\n/**\n * The ONE recipe for a deep-agent thread transport — shared by the mounted\n * thread (`useDeepAgentThread`) and the prewarm pool, so a pre-dialed\n * connection is byte-for-byte the connection the thread would have opened\n * itself: same URL, same auth + wire-contract headers, same session lane, same\n * instrumentation.\n *\n * Every attach declares this build's wire-contract version alongside the\n * host's auth headers (first WS frame / SSE request headers) — the server\n * half of version negotiation reads it in `authorize`. The headers resolve\n * also waits out any server `Retry-After` floor for the thread: both statewire\n * attach paths resolve headers before they dial, so this is the seam where a\n * hint can hold the next dial without touching the library's backoff.\n */\nexport function createDeepAgentAttachTransport<TState = unknown>({\n baseUrl,\n threadId,\n headers,\n sse = false,\n isNew = false,\n sessionStore,\n onAuthResolve,\n onAttachTiming,\n retryGate = attachRetryGate,\n messageWindow,\n statewireClient,\n}: DeepAgentAttachTransportOptions): ResourceElement<\n Statewire<TState | undefined>\n> {\n const url = deepAgentThreadUrl({ baseUrl, threadId });\n // The instrumentation wraps the transport actually built: an SSE fallback\n // needs the fetch wrapper that reads a refused attach's `Retry-After`.\n const useSse =\n resolveStatewireTransportKind({ sse, client: statewireClient }) === \"sse\";\n const instrumentation = createAttachInstrumentation({\n url,\n sse: useSse,\n onEvent: onAttachTiming,\n retryGate,\n });\n const options: StatewireTransportOptions = {\n url,\n ...(isNew && { isNew: true }),\n headers: async (ctx: { staleAuth: boolean }) => {\n await retryGate.waitForDial(url);\n onAuthResolve?.(ctx);\n return withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx)),\n messageWindow,\n );\n },\n ...(sessionStore && { sessionStore }),\n // Structural types: this package compiles without the DOM lib, while\n // statewire's option types name the browser `WebSocket`/`fetch`.\n ...(instrumentation.webSocket && {\n webSocket:\n instrumentation.webSocket as unknown as StatewireTransportOptions[\"webSocket\"],\n }),\n ...(instrumentation.fetch && {\n fetch:\n instrumentation.fetch as unknown as StatewireTransportOptions[\"fetch\"],\n }),\n };\n return statewireTransport<TState>(options, { sse: useSse });\n}\n\n/** What `POST …/prewarm` answered. */\nexport interface DeepAgentWakeResult {\n status: number;\n /** The host activated the thread and its history is resident. */\n warm: boolean;\n /**\n * `'warm'` when the history was already memoized, `'fresh_load'` when this\n * call loaded it; `null` when the host did not say (a refusal, or a host\n * without the route).\n */\n history: \"fresh_load\" | \"warm\" | null;\n /**\n * The dial floor recorded for the thread off a 503/429 `Retry-After` — the\n * header's value, except a 503 `\"worker\"` refusal, capped at\n * {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\n retryAfterMs: number | null;\n /** Who refused a 503/429 (see {@link DeepAgentWakeRefusal}); `null` for any other answer. */\n refusal: DeepAgentWakeRefusal;\n durationMs: number;\n}\n\n/**\n * Dial floor for a 503 the pinned worker produced about one instance's\n * lifecycle. Pinned answers every retriable 503 with a blanket\n * `Retry-After: 1`, but what those name — an instance record mid-teardown\n * (\"instance closing; retry\": fence release + placement release, ≈100 ms), a\n * full activation queue — clears far sooner than that, and the thread's own\n * attach is the request that carries the next activation. Holding it a full\n * second traded a ≈100 ms gap for a ≈1 s stall on every refused wake. A 429\n * is rate limiting and keeps its hint whatever the body says.\n */\nexport const WAKE_WORKER_REFUSAL_FLOOR_MS = 250;\n\n/**\n * The pinboard's own placement-refusal vocabulary — the `error` strings of\n * `@assistant-ui/pinboard` (`src/refusals.ts` plus the two inline refusals in\n * `src/server.ts`). Mirrors `PINBOARD_PLACEMENT_REFUSAL_ERRORS` in\n * `iris/scaler/src/v2AccessLog.ts`, whose test pins it to the installed\n * library; keep the two lists identical. A body with any other `error`\n * string is `\"unknown\"`, never `\"placement\"`.\n */\nexport const PINBOARD_PLACEMENT_REFUSAL_ERRORS: ReadonlySet<string> = new Set([\n \"no live backends for namespace\",\n \"registry cold start, retry\",\n \"not the serving fleet\",\n \"no live workers for hostname\",\n \"hostname binding displaced, retry\",\n \"hostname bindings inconsistent, retry\",\n \"takeover in progress, retry\",\n \"placement held by another hostname\",\n \"worker inflight cap exceeded, retry\",\n \"worker refused activation, retry\",\n]);\n\n/**\n * Pinned's instance-lifecycle refusals (`pinned/hosting/directory.py`,\n * `pinned/hosting/_records.py`): each names one instance on the worker the\n * thread is already placed on and clears in the time a teardown or an\n * activation slot takes. Anything else the worker answers 503 with — \"Thread\n * history load failed\", \"The regional conversation store is temporarily\n * unavailable\", a lease that expired — is not about one instance and keeps\n * the full hint.\n */\nexport const PINNED_TRANSIENT_WORKER_REFUSALS: ReadonlySet<string> = new Set([\n \"instance closing; retry\",\n \"instance aborted\",\n \"activation queue full; retry\",\n \"host draining; retry\",\n \"instance capacity reached; retry\",\n \"instance past its wind-down deadline; retry\",\n]);\n\n/**\n * Classify a 503/429 body (see {@link DeepAgentWakeRefusal}): a known pinboard\n * `error` is `\"placement\"`, a known pinned `detail` is `\"worker\"`, everything\n * else — including unfamiliar strings in either field — is `\"unknown\"`.\n */\nexport function classifyWakeRefusal(\n body: unknown,\n): Exclude<DeepAgentWakeRefusal, null> {\n if (typeof body === \"object\" && body !== null) {\n const record = body as Record<string, unknown>;\n if (\n typeof record.error === \"string\" &&\n PINBOARD_PLACEMENT_REFUSAL_ERRORS.has(record.error)\n ) {\n return \"placement\";\n }\n if (\n typeof record.detail === \"string\" &&\n PINNED_TRANSIENT_WORKER_REFUSALS.has(record.detail)\n ) {\n return \"worker\";\n }\n }\n return \"unknown\";\n}\n\n/** Structural `fetch` for the wake call — this package compiles without the DOM lib. */\nexport type DeepAgentWakeFetch = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n keepalive?: boolean;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n headers: { get(name: string): string | null };\n json(): Promise<unknown>;\n}>;\n\nexport interface DeepAgentWakeOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n fetch?: DeepAgentWakeFetch | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n now?: () => number;\n}\n\n/**\n * Wake a thread's server instance ahead of its attach. `POST …/prewarm` runs\n * the host's `authorize` and history load — the fence adopt, graph compile and\n * checkpoint read that make a cold attach's hello take ≈1 s — and answers with\n * no thread content, only whether the history was already resident. Fire it\n * on hover intent, before a pooled attach or the click's own dial, so the\n * activation overlaps the handshake and the attach that follows finds the\n * instance warm.\n *\n * Fire-and-forget by contract: every failure resolves `null`, and a host\n * without the route (404) is reported like any other non-OK answer. A 503/429\n * `Retry-After` is recorded as a dial floor for the thread, so the attach that\n * follows waits it out instead of bouncing — in full, except a 503 that names\n * one of pinned's instance-lifecycle refusals (one instance mid-teardown on\n * the worker the thread already has; see {@link DeepAgentWakeRefusal}), whose\n * floor is capped at {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\nexport async function wakeDeepAgentThread({\n baseUrl,\n threadId,\n headers,\n fetch: fetchOverride,\n retryGate = attachRetryGate,\n onAttachTiming,\n now = () => Date.now(),\n}: DeepAgentWakeOptions): Promise<DeepAgentWakeResult | null> {\n const fetchImpl =\n fetchOverride ?? (globalThis as { fetch?: DeepAgentWakeFetch }).fetch;\n if (typeof fetchImpl !== \"function\") return null;\n const threadUrl = deepAgentThreadUrl({ baseUrl, threadId });\n const emit = (event: DeepAgentAttachTimingEvent): void => {\n try {\n onAttachTiming?.(event);\n } catch {\n // Observers are best-effort.\n }\n };\n const startedAt = now();\n try {\n const resolved = withDeclaredProtocolVersion(\n await headers({ staleAuth: false }),\n );\n const response = await fetchImpl(`${threadUrl}/prewarm`, {\n method: \"POST\",\n headers: resolved,\n keepalive: true,\n });\n const durationMs = now() - startedAt;\n let retryAfterMs: number | null = null;\n let refusal: DeepAgentWakeRefusal = null;\n if (response.status === 503 || response.status === 429) {\n let body: unknown = null;\n try {\n body = await response.json();\n } catch {\n // A refusal without a readable body is classified by nothing.\n }\n refusal = classifyWakeRefusal(body);\n const hinted = parseRetryAfterHeaderMs(\n response.headers.get(\"retry-after\"),\n now,\n );\n if (hinted !== null) {\n retryAfterMs =\n response.status === 503 && refusal === \"worker\"\n ? Math.min(hinted, WAKE_WORKER_REFUSAL_FLOOR_MS)\n : hinted;\n if (retryAfterMs > 0) {\n retryGate.noteRetryAfter(threadUrl, retryAfterMs);\n emit({\n type: \"retry-after\",\n url: threadUrl,\n delayMs: retryAfterMs,\n source: \"http-header\",\n });\n }\n }\n }\n let warm = false;\n let history: DeepAgentWakeResult[\"history\"] = null;\n if (response.ok) {\n try {\n const body = (await response.json()) as {\n warm?: unknown;\n history?: unknown;\n } | null;\n warm = body?.warm === true;\n history =\n body?.history === \"warm\" || body?.history === \"fresh_load\"\n ? body.history\n : null;\n } catch {\n // A 200 without a JSON body still means the instance is up.\n warm = true;\n }\n }\n emit({\n type: \"wake\",\n url: threadUrl,\n status: response.status,\n durationMs,\n history,\n refusal,\n });\n return {\n status: response.status,\n warm,\n history,\n retryAfterMs,\n refusal,\n durationMs,\n };\n } catch {\n return null;\n }\n}\n"],"names":["attachRetryGate","messageWindow","resolveStatewireTransportKind","createAttachInstrumentation","withDeclaredMessageWindow","withDeclaredProtocolVersion","statewireTransport"],"mappings":";;;;;;AA4BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AAiDO,SAAS,+BAAiD;AAAA,EAC/D;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAYA,wBAAAA;AAAAA,EAAA,eACZC;AAAAA,EACA;AACF,GAEE;AACA,QAAM,MAAM,mBAAmB,EAAE,SAAS,UAAU;AAGpD,QAAM,SACJC,mBAAAA,8BAA8B,EAAE,KAAK,QAAQ,gBAAA,CAAiB,MAAM;AACtE,QAAM,kBAAkBC,wBAAAA,4BAA4B;AAAA,IAClD;AAAA,IACA,KAAK;AAAA,IACL,SAAS;AAAA,IACT;AAAA,EAAA,CACD;AACD,QAAM,UAAqC;AAAA,IACzC;AAAA,IACA,GAAI,SAAS,EAAE,OAAO,KAAA;AAAA,IACtB,SAAS,OAAO,QAAgC;AAC9C,YAAM,UAAU,YAAY,GAAG;AAC/B,qDAAgB;AAChB,aAAOC,cAAAA;AAAAA,QACLC,qCAA4B,MAAM,QAAQ,GAAG,CAAC;AAAA,QAC9CJ;AAAAA,MAAA;AAAA,IAEJ;AAAA,IACA,GAAI,gBAAgB,EAAE,aAAA;AAAA;AAAA;AAAA,IAGtB,GAAI,gBAAgB,aAAa;AAAA,MAC/B,WACE,gBAAgB;AAAA,IAAA;AAAA,IAEpB,GAAI,gBAAgB,SAAS;AAAA,MAC3B,OACE,gBAAgB;AAAA,IAAA;AAAA,EACpB;AAEF,SAAOK,mBAAAA,mBAA2B,SAAS,EAAE,KAAK,QAAQ;AAC5D;;;"}
1
+ {"version":3,"file":"deep-agent-transport-recipe.cjs","sources":["../../../../agent-runtime/src/deep-agent-transport-recipe.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { Statewire, StatewireClient } from \"statewire\";\nimport {\n type DeepAgentMessageWindowDeclaration,\n withDeclaredMessageWindow,\n} from \"./message-window\";\nimport { withDeclaredProtocolVersion } from \"./protocol\";\nimport {\n attachRetryGate,\n type AttachRetryGate,\n createAttachInstrumentation,\n type DeepAgentAttachTimingEvent,\n type DeepAgentWakeRefusal,\n parseRetryAfterHeaderMs,\n} from \"./statewire-attach-observer\";\nimport {\n resolveStatewireTransportKind,\n statewireTransport,\n type StatewireTransportOptions,\n} from \"./statewire-transport\";\n\n/** Auth-header resolver for the transport; `staleAuth` is true right after an\n * auth rejection, so resolvers that can force-renew should honor it. */\nexport type DeepAgentHeadersResolver = (ctx: {\n staleAuth: boolean;\n}) => Record<string, string> | Promise<Record<string, string>>;\n\n/** The statewire attach URL for a thread: the host's `…/v2/threads` mount plus the id. */\nexport function deepAgentThreadUrl({\n baseUrl,\n threadId,\n}: {\n baseUrl: string;\n threadId: string;\n}): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}/${encodeURIComponent(threadId)}`;\n}\n\nexport interface DeepAgentAttachTransportOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n /** SSE when true, WebSocket otherwise (`resolveStatewireTransportKind`). */\n sse?: boolean;\n /** A never-started chat: the transport makes no network calls until the first command send. */\n isNew?: boolean;\n /** Host session store (mobile); browser hosts mount `storage.session` on the thread instead. */\n sessionStore?: StatewireClient.SessionStorage | undefined;\n /** Observes every headers resolve (the diagnostics recorder's stale-auth seam). */\n onAuthResolve?: ((ctx: { staleAuth: boolean }) => void) | undefined;\n /** Attach timing + retry hints (see `statewire-attach-observer`). */\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n /**\n * The head window declared on the attach (`x-athena-deep-agent-window`,\n * see `message-window.ts`): the hello of a client the host does not\n * remember carries the newest `size` messages. Part of the recipe so a\n * pooled dial asks for the same shape as the thread's own attach — a pooled\n * hello then seeds the very state the attach will reconcile. Omitted, the\n * shared default is declared.\n */\n messageWindow?: DeepAgentMessageWindowDeclaration | undefined;\n}\n\n/**\n * The ONE recipe for a deep-agent thread transport — shared by the mounted\n * thread (`useDeepAgentThread`) and the prewarm pool, so a pre-dialed\n * connection is byte-for-byte the connection the thread would have opened\n * itself: same URL, same auth + wire-contract headers, same session lane, same\n * instrumentation.\n *\n * Every attach declares this build's wire-contract version alongside the\n * host's auth headers (first WS frame / SSE request headers) — the server\n * half of version negotiation reads it in `authorize`. The headers resolve\n * also waits out any server `Retry-After` floor for the thread: both statewire\n * attach paths resolve headers before they dial, so this is the seam where a\n * hint can hold the next dial without touching the library's backoff.\n */\nexport function createDeepAgentAttachTransport<TState = unknown>({\n baseUrl,\n threadId,\n headers,\n sse = false,\n isNew = false,\n sessionStore,\n onAuthResolve,\n onAttachTiming,\n retryGate = attachRetryGate,\n messageWindow,\n}: DeepAgentAttachTransportOptions): ResourceElement<\n Statewire<TState | undefined>\n> {\n const url = deepAgentThreadUrl({ baseUrl, threadId });\n // The instrumentation wraps the transport actually built: SSE needs the\n // fetch wrapper that reads a refused attach's `Retry-After`.\n const useSse = resolveStatewireTransportKind({ sse }) === \"sse\";\n const instrumentation = createAttachInstrumentation({\n url,\n sse: useSse,\n onEvent: onAttachTiming,\n retryGate,\n });\n const options: StatewireTransportOptions = {\n url,\n ...(isNew && { isNew: true }),\n headers: async (ctx: { staleAuth: boolean }) => {\n await retryGate.waitForDial(url);\n onAuthResolve?.(ctx);\n return withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx)),\n messageWindow,\n );\n },\n ...(sessionStore && { sessionStore }),\n // Structural types: this package compiles without the DOM lib, while\n // statewire's option types name the browser `WebSocket`/`fetch`.\n ...(instrumentation.webSocket && {\n webSocket:\n instrumentation.webSocket as unknown as StatewireTransportOptions[\"webSocket\"],\n }),\n ...(instrumentation.fetch && {\n fetch:\n instrumentation.fetch as unknown as StatewireTransportOptions[\"fetch\"],\n }),\n };\n return statewireTransport<TState>(options, { sse: useSse });\n}\n\n/** What `POST …/prewarm` answered. */\nexport interface DeepAgentWakeResult {\n status: number;\n /** The host activated the thread and its history is resident. */\n warm: boolean;\n /**\n * `'warm'` when the history was already memoized, `'fresh_load'` when this\n * call loaded it; `null` when the host did not say (a refusal, or a host\n * without the route).\n */\n history: \"fresh_load\" | \"warm\" | null;\n /**\n * The dial floor recorded for the thread off a 503/429 `Retry-After` — the\n * header's value, except a 503 `\"worker\"` refusal, capped at\n * {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\n retryAfterMs: number | null;\n /** Who refused a 503/429 (see {@link DeepAgentWakeRefusal}); `null` for any other answer. */\n refusal: DeepAgentWakeRefusal;\n durationMs: number;\n}\n\n/**\n * Dial floor for a 503 the pinned worker produced about one instance's\n * lifecycle. Pinned answers every retriable 503 with a blanket\n * `Retry-After: 1`, but what those name — an instance record mid-teardown\n * (\"instance closing; retry\": fence release + placement release, ≈100 ms), a\n * full activation queue — clears far sooner than that, and the thread's own\n * attach is the request that carries the next activation. Holding it a full\n * second traded a ≈100 ms gap for a ≈1 s stall on every refused wake. A 429\n * is rate limiting and keeps its hint whatever the body says.\n */\nexport const WAKE_WORKER_REFUSAL_FLOOR_MS = 250;\n\n/**\n * The pinboard's own placement-refusal vocabulary — the `error` strings of\n * `@assistant-ui/pinboard` (`src/refusals.ts` plus the two inline refusals in\n * `src/server.ts`). Mirrors `PINBOARD_PLACEMENT_REFUSAL_ERRORS` in\n * `iris/scaler/src/v2AccessLog.ts`, whose test pins it to the installed\n * library; keep the two lists identical. A body with any other `error`\n * string is `\"unknown\"`, never `\"placement\"`.\n */\nexport const PINBOARD_PLACEMENT_REFUSAL_ERRORS: ReadonlySet<string> = new Set([\n \"no live backends for namespace\",\n \"registry cold start, retry\",\n \"not the serving fleet\",\n \"no live workers for hostname\",\n \"hostname binding displaced, retry\",\n \"hostname bindings inconsistent, retry\",\n \"takeover in progress, retry\",\n \"placement held by another hostname\",\n \"worker inflight cap exceeded, retry\",\n \"worker refused activation, retry\",\n]);\n\n/**\n * Pinned's instance-lifecycle refusals (`pinned/hosting/directory.py`,\n * `pinned/hosting/_records.py`): each names one instance on the worker the\n * thread is already placed on and clears in the time a teardown or an\n * activation slot takes. Anything else the worker answers 503 with — \"Thread\n * history load failed\", \"The regional conversation store is temporarily\n * unavailable\", a lease that expired — is not about one instance and keeps\n * the full hint.\n */\nexport const PINNED_TRANSIENT_WORKER_REFUSALS: ReadonlySet<string> = new Set([\n \"instance closing; retry\",\n \"instance aborted\",\n \"activation queue full; retry\",\n \"host draining; retry\",\n \"instance capacity reached; retry\",\n \"instance past its wind-down deadline; retry\",\n]);\n\n/**\n * Classify a 503/429 body (see {@link DeepAgentWakeRefusal}): a known pinboard\n * `error` is `\"placement\"`, a known pinned `detail` is `\"worker\"`, everything\n * else — including unfamiliar strings in either field — is `\"unknown\"`.\n */\nexport function classifyWakeRefusal(\n body: unknown,\n): Exclude<DeepAgentWakeRefusal, null> {\n if (typeof body === \"object\" && body !== null) {\n const record = body as Record<string, unknown>;\n if (\n typeof record.error === \"string\" &&\n PINBOARD_PLACEMENT_REFUSAL_ERRORS.has(record.error)\n ) {\n return \"placement\";\n }\n if (\n typeof record.detail === \"string\" &&\n PINNED_TRANSIENT_WORKER_REFUSALS.has(record.detail)\n ) {\n return \"worker\";\n }\n }\n return \"unknown\";\n}\n\n/** Structural `fetch` for the wake call — this package compiles without the DOM lib. */\nexport type DeepAgentWakeFetch = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n keepalive?: boolean;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n headers: { get(name: string): string | null };\n json(): Promise<unknown>;\n}>;\n\nexport interface DeepAgentWakeOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n fetch?: DeepAgentWakeFetch | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n now?: () => number;\n}\n\n/**\n * Wake a thread's server instance ahead of its attach. `POST …/prewarm` runs\n * the host's `authorize` and history load — the fence adopt, graph compile and\n * checkpoint read that make a cold attach's hello take ≈1 s — and answers with\n * no thread content, only whether the history was already resident. Fire it\n * on hover intent, before a pooled attach or the click's own dial, so the\n * activation overlaps the handshake and the attach that follows finds the\n * instance warm.\n *\n * Fire-and-forget by contract: every failure resolves `null`, and a host\n * without the route (404) is reported like any other non-OK answer. A 503/429\n * `Retry-After` is recorded as a dial floor for the thread, so the attach that\n * follows waits it out instead of bouncing — in full, except a 503 that names\n * one of pinned's instance-lifecycle refusals (one instance mid-teardown on\n * the worker the thread already has; see {@link DeepAgentWakeRefusal}), whose\n * floor is capped at {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\nexport async function wakeDeepAgentThread({\n baseUrl,\n threadId,\n headers,\n fetch: fetchOverride,\n retryGate = attachRetryGate,\n onAttachTiming,\n now = () => Date.now(),\n}: DeepAgentWakeOptions): Promise<DeepAgentWakeResult | null> {\n const fetchImpl =\n fetchOverride ?? (globalThis as { fetch?: DeepAgentWakeFetch }).fetch;\n if (typeof fetchImpl !== \"function\") return null;\n const threadUrl = deepAgentThreadUrl({ baseUrl, threadId });\n const emit = (event: DeepAgentAttachTimingEvent): void => {\n try {\n onAttachTiming?.(event);\n } catch {\n // Observers are best-effort.\n }\n };\n const startedAt = now();\n try {\n const resolved = withDeclaredProtocolVersion(\n await headers({ staleAuth: false }),\n );\n const response = await fetchImpl(`${threadUrl}/prewarm`, {\n method: \"POST\",\n headers: resolved,\n keepalive: true,\n });\n const durationMs = now() - startedAt;\n let retryAfterMs: number | null = null;\n let refusal: DeepAgentWakeRefusal = null;\n if (response.status === 503 || response.status === 429) {\n let body: unknown = null;\n try {\n body = await response.json();\n } catch {\n // A refusal without a readable body is classified by nothing.\n }\n refusal = classifyWakeRefusal(body);\n const hinted = parseRetryAfterHeaderMs(\n response.headers.get(\"retry-after\"),\n now,\n );\n if (hinted !== null) {\n retryAfterMs =\n response.status === 503 && refusal === \"worker\"\n ? Math.min(hinted, WAKE_WORKER_REFUSAL_FLOOR_MS)\n : hinted;\n if (retryAfterMs > 0) {\n retryGate.noteRetryAfter(threadUrl, retryAfterMs);\n emit({\n type: \"retry-after\",\n url: threadUrl,\n delayMs: retryAfterMs,\n source: \"http-header\",\n });\n }\n }\n }\n let warm = false;\n let history: DeepAgentWakeResult[\"history\"] = null;\n if (response.ok) {\n try {\n const body = (await response.json()) as {\n warm?: unknown;\n history?: unknown;\n } | null;\n warm = body?.warm === true;\n history =\n body?.history === \"warm\" || body?.history === \"fresh_load\"\n ? body.history\n : null;\n } catch {\n // A 200 without a JSON body still means the instance is up.\n warm = true;\n }\n }\n emit({\n type: \"wake\",\n url: threadUrl,\n status: response.status,\n durationMs,\n history,\n refusal,\n });\n return {\n status: response.status,\n warm,\n history,\n retryAfterMs,\n refusal,\n durationMs,\n };\n } catch {\n return null;\n }\n}\n"],"names":["attachRetryGate","messageWindow","resolveStatewireTransportKind","createAttachInstrumentation","withDeclaredMessageWindow","withDeclaredProtocolVersion","statewireTransport"],"mappings":";;;;;;AA4BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AA4CO,SAAS,+BAAiD;AAAA,EAC/D;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAYA,wBAAAA;AAAAA,EAAA,eACZC;AACF,GAEE;AACA,QAAM,MAAM,mBAAmB,EAAE,SAAS,UAAU;AAGpD,QAAM,SAASC,mBAAAA,8BAA8B,EAAE,IAAA,CAAK,MAAM;AAC1D,QAAM,kBAAkBC,wBAAAA,4BAA4B;AAAA,IAClD;AAAA,IACA,KAAK;AAAA,IACL,SAAS;AAAA,IACT;AAAA,EAAA,CACD;AACD,QAAM,UAAqC;AAAA,IACzC;AAAA,IACA,GAAI,SAAS,EAAE,OAAO,KAAA;AAAA,IACtB,SAAS,OAAO,QAAgC;AAC9C,YAAM,UAAU,YAAY,GAAG;AAC/B,qDAAgB;AAChB,aAAOC,cAAAA;AAAAA,QACLC,qCAA4B,MAAM,QAAQ,GAAG,CAAC;AAAA,QAC9CJ;AAAAA,MAAA;AAAA,IAEJ;AAAA,IACA,GAAI,gBAAgB,EAAE,aAAA;AAAA;AAAA;AAAA,IAGtB,GAAI,gBAAgB,aAAa;AAAA,MAC/B,WACE,gBAAgB;AAAA,IAAA;AAAA,IAEpB,GAAI,gBAAgB,SAAS;AAAA,MAC3B,OACE,gBAAgB;AAAA,IAAA;AAAA,EACpB;AAEF,SAAOK,mBAAAA,mBAA2B,SAAS,EAAE,KAAK,QAAQ;AAC5D;;;"}
@@ -1,7 +1,7 @@
1
1
  import { withDeclaredMessageWindow } from "./message-window.js";
2
2
  import { withDeclaredProtocolVersion } from "./protocol.js";
3
3
  import { createAttachInstrumentation, attachRetryGate } from "./statewire-attach-observer.js";
4
- import { resolveStatewireTransportKind, statewireTransport } from "./statewire-transport.js";
4
+ import { statewireTransport, resolveStatewireTransportKind } from "./statewire-transport.js";
5
5
  function deepAgentThreadUrl({
6
6
  baseUrl,
7
7
  threadId
@@ -18,11 +18,10 @@ function createDeepAgentAttachTransport({
18
18
  onAuthResolve,
19
19
  onAttachTiming,
20
20
  retryGate = attachRetryGate,
21
- messageWindow,
22
- statewireClient
21
+ messageWindow
23
22
  }) {
24
23
  const url = deepAgentThreadUrl({ baseUrl, threadId });
25
- const useSse = resolveStatewireTransportKind({ sse, client: statewireClient }) === "sse";
24
+ const useSse = resolveStatewireTransportKind({ sse }) === "sse";
26
25
  const instrumentation = createAttachInstrumentation({
27
26
  url,
28
27
  sse: useSse,
@@ -1 +1 @@
1
- {"version":3,"file":"deep-agent-transport-recipe.js","sources":["../../../../agent-runtime/src/deep-agent-transport-recipe.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { Statewire, StatewireClient } from \"statewire\";\nimport {\n type DeepAgentMessageWindowDeclaration,\n withDeclaredMessageWindow,\n} from \"./message-window\";\nimport { withDeclaredProtocolVersion } from \"./protocol\";\nimport {\n attachRetryGate,\n type AttachRetryGate,\n createAttachInstrumentation,\n type DeepAgentAttachTimingEvent,\n type DeepAgentWakeRefusal,\n parseRetryAfterHeaderMs,\n} from \"./statewire-attach-observer\";\nimport {\n resolveStatewireTransportKind,\n statewireTransport,\n type StatewireTransportOptions,\n} from \"./statewire-transport\";\n\n/** Auth-header resolver for the transport; `staleAuth` is true right after an\n * auth rejection, so resolvers that can force-renew should honor it. */\nexport type DeepAgentHeadersResolver = (ctx: {\n staleAuth: boolean;\n}) => Record<string, string> | Promise<Record<string, string>>;\n\n/** The statewire attach URL for a thread: the host's `…/v2/threads` mount plus the id. */\nexport function deepAgentThreadUrl({\n baseUrl,\n threadId,\n}: {\n baseUrl: string;\n threadId: string;\n}): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}/${encodeURIComponent(threadId)}`;\n}\n\nexport interface DeepAgentAttachTransportOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n /**\n * SSE when true, WebSocket otherwise, over the qualified statewire client: an\n * unqualified one gets SSE either way (`resolveStatewireTransportKind`).\n */\n sse?: boolean;\n /** The statewire client class whose qualification picks the transport: the installed one unless given. */\n statewireClient?: object;\n /** A never-started chat: the transport makes no network calls until the first command send. */\n isNew?: boolean;\n /** Host session store (mobile); browser hosts mount `storage.session` on the thread instead. */\n sessionStore?: StatewireClient.SessionStorage | undefined;\n /** Observes every headers resolve (the diagnostics recorder's stale-auth seam). */\n onAuthResolve?: ((ctx: { staleAuth: boolean }) => void) | undefined;\n /** Attach timing + retry hints (see `statewire-attach-observer`). */\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n /**\n * The head window declared on the attach (`x-athena-deep-agent-window`,\n * see `message-window.ts`): the hello of a client the host does not\n * remember carries the newest `size` messages. Part of the recipe so a\n * pooled dial asks for the same shape as the thread's own attach — a pooled\n * hello then seeds the very state the attach will reconcile. Omitted, the\n * shared default is declared.\n */\n messageWindow?: DeepAgentMessageWindowDeclaration | undefined;\n}\n\n/**\n * The ONE recipe for a deep-agent thread transport — shared by the mounted\n * thread (`useDeepAgentThread`) and the prewarm pool, so a pre-dialed\n * connection is byte-for-byte the connection the thread would have opened\n * itself: same URL, same auth + wire-contract headers, same session lane, same\n * instrumentation.\n *\n * Every attach declares this build's wire-contract version alongside the\n * host's auth headers (first WS frame / SSE request headers) — the server\n * half of version negotiation reads it in `authorize`. The headers resolve\n * also waits out any server `Retry-After` floor for the thread: both statewire\n * attach paths resolve headers before they dial, so this is the seam where a\n * hint can hold the next dial without touching the library's backoff.\n */\nexport function createDeepAgentAttachTransport<TState = unknown>({\n baseUrl,\n threadId,\n headers,\n sse = false,\n isNew = false,\n sessionStore,\n onAuthResolve,\n onAttachTiming,\n retryGate = attachRetryGate,\n messageWindow,\n statewireClient,\n}: DeepAgentAttachTransportOptions): ResourceElement<\n Statewire<TState | undefined>\n> {\n const url = deepAgentThreadUrl({ baseUrl, threadId });\n // The instrumentation wraps the transport actually built: an SSE fallback\n // needs the fetch wrapper that reads a refused attach's `Retry-After`.\n const useSse =\n resolveStatewireTransportKind({ sse, client: statewireClient }) === \"sse\";\n const instrumentation = createAttachInstrumentation({\n url,\n sse: useSse,\n onEvent: onAttachTiming,\n retryGate,\n });\n const options: StatewireTransportOptions = {\n url,\n ...(isNew && { isNew: true }),\n headers: async (ctx: { staleAuth: boolean }) => {\n await retryGate.waitForDial(url);\n onAuthResolve?.(ctx);\n return withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx)),\n messageWindow,\n );\n },\n ...(sessionStore && { sessionStore }),\n // Structural types: this package compiles without the DOM lib, while\n // statewire's option types name the browser `WebSocket`/`fetch`.\n ...(instrumentation.webSocket && {\n webSocket:\n instrumentation.webSocket as unknown as StatewireTransportOptions[\"webSocket\"],\n }),\n ...(instrumentation.fetch && {\n fetch:\n instrumentation.fetch as unknown as StatewireTransportOptions[\"fetch\"],\n }),\n };\n return statewireTransport<TState>(options, { sse: useSse });\n}\n\n/** What `POST …/prewarm` answered. */\nexport interface DeepAgentWakeResult {\n status: number;\n /** The host activated the thread and its history is resident. */\n warm: boolean;\n /**\n * `'warm'` when the history was already memoized, `'fresh_load'` when this\n * call loaded it; `null` when the host did not say (a refusal, or a host\n * without the route).\n */\n history: \"fresh_load\" | \"warm\" | null;\n /**\n * The dial floor recorded for the thread off a 503/429 `Retry-After` — the\n * header's value, except a 503 `\"worker\"` refusal, capped at\n * {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\n retryAfterMs: number | null;\n /** Who refused a 503/429 (see {@link DeepAgentWakeRefusal}); `null` for any other answer. */\n refusal: DeepAgentWakeRefusal;\n durationMs: number;\n}\n\n/**\n * Dial floor for a 503 the pinned worker produced about one instance's\n * lifecycle. Pinned answers every retriable 503 with a blanket\n * `Retry-After: 1`, but what those name — an instance record mid-teardown\n * (\"instance closing; retry\": fence release + placement release, ≈100 ms), a\n * full activation queue — clears far sooner than that, and the thread's own\n * attach is the request that carries the next activation. Holding it a full\n * second traded a ≈100 ms gap for a ≈1 s stall on every refused wake. A 429\n * is rate limiting and keeps its hint whatever the body says.\n */\nexport const WAKE_WORKER_REFUSAL_FLOOR_MS = 250;\n\n/**\n * The pinboard's own placement-refusal vocabulary — the `error` strings of\n * `@assistant-ui/pinboard` (`src/refusals.ts` plus the two inline refusals in\n * `src/server.ts`). Mirrors `PINBOARD_PLACEMENT_REFUSAL_ERRORS` in\n * `iris/scaler/src/v2AccessLog.ts`, whose test pins it to the installed\n * library; keep the two lists identical. A body with any other `error`\n * string is `\"unknown\"`, never `\"placement\"`.\n */\nexport const PINBOARD_PLACEMENT_REFUSAL_ERRORS: ReadonlySet<string> = new Set([\n \"no live backends for namespace\",\n \"registry cold start, retry\",\n \"not the serving fleet\",\n \"no live workers for hostname\",\n \"hostname binding displaced, retry\",\n \"hostname bindings inconsistent, retry\",\n \"takeover in progress, retry\",\n \"placement held by another hostname\",\n \"worker inflight cap exceeded, retry\",\n \"worker refused activation, retry\",\n]);\n\n/**\n * Pinned's instance-lifecycle refusals (`pinned/hosting/directory.py`,\n * `pinned/hosting/_records.py`): each names one instance on the worker the\n * thread is already placed on and clears in the time a teardown or an\n * activation slot takes. Anything else the worker answers 503 with — \"Thread\n * history load failed\", \"The regional conversation store is temporarily\n * unavailable\", a lease that expired — is not about one instance and keeps\n * the full hint.\n */\nexport const PINNED_TRANSIENT_WORKER_REFUSALS: ReadonlySet<string> = new Set([\n \"instance closing; retry\",\n \"instance aborted\",\n \"activation queue full; retry\",\n \"host draining; retry\",\n \"instance capacity reached; retry\",\n \"instance past its wind-down deadline; retry\",\n]);\n\n/**\n * Classify a 503/429 body (see {@link DeepAgentWakeRefusal}): a known pinboard\n * `error` is `\"placement\"`, a known pinned `detail` is `\"worker\"`, everything\n * else — including unfamiliar strings in either field — is `\"unknown\"`.\n */\nexport function classifyWakeRefusal(\n body: unknown,\n): Exclude<DeepAgentWakeRefusal, null> {\n if (typeof body === \"object\" && body !== null) {\n const record = body as Record<string, unknown>;\n if (\n typeof record.error === \"string\" &&\n PINBOARD_PLACEMENT_REFUSAL_ERRORS.has(record.error)\n ) {\n return \"placement\";\n }\n if (\n typeof record.detail === \"string\" &&\n PINNED_TRANSIENT_WORKER_REFUSALS.has(record.detail)\n ) {\n return \"worker\";\n }\n }\n return \"unknown\";\n}\n\n/** Structural `fetch` for the wake call — this package compiles without the DOM lib. */\nexport type DeepAgentWakeFetch = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n keepalive?: boolean;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n headers: { get(name: string): string | null };\n json(): Promise<unknown>;\n}>;\n\nexport interface DeepAgentWakeOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n fetch?: DeepAgentWakeFetch | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n now?: () => number;\n}\n\n/**\n * Wake a thread's server instance ahead of its attach. `POST …/prewarm` runs\n * the host's `authorize` and history load — the fence adopt, graph compile and\n * checkpoint read that make a cold attach's hello take ≈1 s — and answers with\n * no thread content, only whether the history was already resident. Fire it\n * on hover intent, before a pooled attach or the click's own dial, so the\n * activation overlaps the handshake and the attach that follows finds the\n * instance warm.\n *\n * Fire-and-forget by contract: every failure resolves `null`, and a host\n * without the route (404) is reported like any other non-OK answer. A 503/429\n * `Retry-After` is recorded as a dial floor for the thread, so the attach that\n * follows waits it out instead of bouncing — in full, except a 503 that names\n * one of pinned's instance-lifecycle refusals (one instance mid-teardown on\n * the worker the thread already has; see {@link DeepAgentWakeRefusal}), whose\n * floor is capped at {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\nexport async function wakeDeepAgentThread({\n baseUrl,\n threadId,\n headers,\n fetch: fetchOverride,\n retryGate = attachRetryGate,\n onAttachTiming,\n now = () => Date.now(),\n}: DeepAgentWakeOptions): Promise<DeepAgentWakeResult | null> {\n const fetchImpl =\n fetchOverride ?? (globalThis as { fetch?: DeepAgentWakeFetch }).fetch;\n if (typeof fetchImpl !== \"function\") return null;\n const threadUrl = deepAgentThreadUrl({ baseUrl, threadId });\n const emit = (event: DeepAgentAttachTimingEvent): void => {\n try {\n onAttachTiming?.(event);\n } catch {\n // Observers are best-effort.\n }\n };\n const startedAt = now();\n try {\n const resolved = withDeclaredProtocolVersion(\n await headers({ staleAuth: false }),\n );\n const response = await fetchImpl(`${threadUrl}/prewarm`, {\n method: \"POST\",\n headers: resolved,\n keepalive: true,\n });\n const durationMs = now() - startedAt;\n let retryAfterMs: number | null = null;\n let refusal: DeepAgentWakeRefusal = null;\n if (response.status === 503 || response.status === 429) {\n let body: unknown = null;\n try {\n body = await response.json();\n } catch {\n // A refusal without a readable body is classified by nothing.\n }\n refusal = classifyWakeRefusal(body);\n const hinted = parseRetryAfterHeaderMs(\n response.headers.get(\"retry-after\"),\n now,\n );\n if (hinted !== null) {\n retryAfterMs =\n response.status === 503 && refusal === \"worker\"\n ? Math.min(hinted, WAKE_WORKER_REFUSAL_FLOOR_MS)\n : hinted;\n if (retryAfterMs > 0) {\n retryGate.noteRetryAfter(threadUrl, retryAfterMs);\n emit({\n type: \"retry-after\",\n url: threadUrl,\n delayMs: retryAfterMs,\n source: \"http-header\",\n });\n }\n }\n }\n let warm = false;\n let history: DeepAgentWakeResult[\"history\"] = null;\n if (response.ok) {\n try {\n const body = (await response.json()) as {\n warm?: unknown;\n history?: unknown;\n } | null;\n warm = body?.warm === true;\n history =\n body?.history === \"warm\" || body?.history === \"fresh_load\"\n ? body.history\n : null;\n } catch {\n // A 200 without a JSON body still means the instance is up.\n warm = true;\n }\n }\n emit({\n type: \"wake\",\n url: threadUrl,\n status: response.status,\n durationMs,\n history,\n refusal,\n });\n return {\n status: response.status,\n warm,\n history,\n retryAfterMs,\n refusal,\n durationMs,\n };\n } catch {\n return null;\n }\n}\n"],"names":[],"mappings":";;;;AA4BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AAiDO,SAAS,+BAAiD;AAAA,EAC/D;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AACF,GAEE;AACA,QAAM,MAAM,mBAAmB,EAAE,SAAS,UAAU;AAGpD,QAAM,SACJ,8BAA8B,EAAE,KAAK,QAAQ,gBAAA,CAAiB,MAAM;AACtE,QAAM,kBAAkB,4BAA4B;AAAA,IAClD;AAAA,IACA,KAAK;AAAA,IACL,SAAS;AAAA,IACT;AAAA,EAAA,CACD;AACD,QAAM,UAAqC;AAAA,IACzC;AAAA,IACA,GAAI,SAAS,EAAE,OAAO,KAAA;AAAA,IACtB,SAAS,OAAO,QAAgC;AAC9C,YAAM,UAAU,YAAY,GAAG;AAC/B,qDAAgB;AAChB,aAAO;AAAA,QACL,4BAA4B,MAAM,QAAQ,GAAG,CAAC;AAAA,QAC9C;AAAA,MAAA;AAAA,IAEJ;AAAA,IACA,GAAI,gBAAgB,EAAE,aAAA;AAAA;AAAA;AAAA,IAGtB,GAAI,gBAAgB,aAAa;AAAA,MAC/B,WACE,gBAAgB;AAAA,IAAA;AAAA,IAEpB,GAAI,gBAAgB,SAAS;AAAA,MAC3B,OACE,gBAAgB;AAAA,IAAA;AAAA,EACpB;AAEF,SAAO,mBAA2B,SAAS,EAAE,KAAK,QAAQ;AAC5D;"}
1
+ {"version":3,"file":"deep-agent-transport-recipe.js","sources":["../../../../agent-runtime/src/deep-agent-transport-recipe.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { Statewire, StatewireClient } from \"statewire\";\nimport {\n type DeepAgentMessageWindowDeclaration,\n withDeclaredMessageWindow,\n} from \"./message-window\";\nimport { withDeclaredProtocolVersion } from \"./protocol\";\nimport {\n attachRetryGate,\n type AttachRetryGate,\n createAttachInstrumentation,\n type DeepAgentAttachTimingEvent,\n type DeepAgentWakeRefusal,\n parseRetryAfterHeaderMs,\n} from \"./statewire-attach-observer\";\nimport {\n resolveStatewireTransportKind,\n statewireTransport,\n type StatewireTransportOptions,\n} from \"./statewire-transport\";\n\n/** Auth-header resolver for the transport; `staleAuth` is true right after an\n * auth rejection, so resolvers that can force-renew should honor it. */\nexport type DeepAgentHeadersResolver = (ctx: {\n staleAuth: boolean;\n}) => Record<string, string> | Promise<Record<string, string>>;\n\n/** The statewire attach URL for a thread: the host's `…/v2/threads` mount plus the id. */\nexport function deepAgentThreadUrl({\n baseUrl,\n threadId,\n}: {\n baseUrl: string;\n threadId: string;\n}): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}/${encodeURIComponent(threadId)}`;\n}\n\nexport interface DeepAgentAttachTransportOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n /** SSE when true, WebSocket otherwise (`resolveStatewireTransportKind`). */\n sse?: boolean;\n /** A never-started chat: the transport makes no network calls until the first command send. */\n isNew?: boolean;\n /** Host session store (mobile); browser hosts mount `storage.session` on the thread instead. */\n sessionStore?: StatewireClient.SessionStorage | undefined;\n /** Observes every headers resolve (the diagnostics recorder's stale-auth seam). */\n onAuthResolve?: ((ctx: { staleAuth: boolean }) => void) | undefined;\n /** Attach timing + retry hints (see `statewire-attach-observer`). */\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n /**\n * The head window declared on the attach (`x-athena-deep-agent-window`,\n * see `message-window.ts`): the hello of a client the host does not\n * remember carries the newest `size` messages. Part of the recipe so a\n * pooled dial asks for the same shape as the thread's own attach — a pooled\n * hello then seeds the very state the attach will reconcile. Omitted, the\n * shared default is declared.\n */\n messageWindow?: DeepAgentMessageWindowDeclaration | undefined;\n}\n\n/**\n * The ONE recipe for a deep-agent thread transport — shared by the mounted\n * thread (`useDeepAgentThread`) and the prewarm pool, so a pre-dialed\n * connection is byte-for-byte the connection the thread would have opened\n * itself: same URL, same auth + wire-contract headers, same session lane, same\n * instrumentation.\n *\n * Every attach declares this build's wire-contract version alongside the\n * host's auth headers (first WS frame / SSE request headers) — the server\n * half of version negotiation reads it in `authorize`. The headers resolve\n * also waits out any server `Retry-After` floor for the thread: both statewire\n * attach paths resolve headers before they dial, so this is the seam where a\n * hint can hold the next dial without touching the library's backoff.\n */\nexport function createDeepAgentAttachTransport<TState = unknown>({\n baseUrl,\n threadId,\n headers,\n sse = false,\n isNew = false,\n sessionStore,\n onAuthResolve,\n onAttachTiming,\n retryGate = attachRetryGate,\n messageWindow,\n}: DeepAgentAttachTransportOptions): ResourceElement<\n Statewire<TState | undefined>\n> {\n const url = deepAgentThreadUrl({ baseUrl, threadId });\n // The instrumentation wraps the transport actually built: SSE needs the\n // fetch wrapper that reads a refused attach's `Retry-After`.\n const useSse = resolveStatewireTransportKind({ sse }) === \"sse\";\n const instrumentation = createAttachInstrumentation({\n url,\n sse: useSse,\n onEvent: onAttachTiming,\n retryGate,\n });\n const options: StatewireTransportOptions = {\n url,\n ...(isNew && { isNew: true }),\n headers: async (ctx: { staleAuth: boolean }) => {\n await retryGate.waitForDial(url);\n onAuthResolve?.(ctx);\n return withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx)),\n messageWindow,\n );\n },\n ...(sessionStore && { sessionStore }),\n // Structural types: this package compiles without the DOM lib, while\n // statewire's option types name the browser `WebSocket`/`fetch`.\n ...(instrumentation.webSocket && {\n webSocket:\n instrumentation.webSocket as unknown as StatewireTransportOptions[\"webSocket\"],\n }),\n ...(instrumentation.fetch && {\n fetch:\n instrumentation.fetch as unknown as StatewireTransportOptions[\"fetch\"],\n }),\n };\n return statewireTransport<TState>(options, { sse: useSse });\n}\n\n/** What `POST …/prewarm` answered. */\nexport interface DeepAgentWakeResult {\n status: number;\n /** The host activated the thread and its history is resident. */\n warm: boolean;\n /**\n * `'warm'` when the history was already memoized, `'fresh_load'` when this\n * call loaded it; `null` when the host did not say (a refusal, or a host\n * without the route).\n */\n history: \"fresh_load\" | \"warm\" | null;\n /**\n * The dial floor recorded for the thread off a 503/429 `Retry-After` — the\n * header's value, except a 503 `\"worker\"` refusal, capped at\n * {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\n retryAfterMs: number | null;\n /** Who refused a 503/429 (see {@link DeepAgentWakeRefusal}); `null` for any other answer. */\n refusal: DeepAgentWakeRefusal;\n durationMs: number;\n}\n\n/**\n * Dial floor for a 503 the pinned worker produced about one instance's\n * lifecycle. Pinned answers every retriable 503 with a blanket\n * `Retry-After: 1`, but what those name — an instance record mid-teardown\n * (\"instance closing; retry\": fence release + placement release, ≈100 ms), a\n * full activation queue — clears far sooner than that, and the thread's own\n * attach is the request that carries the next activation. Holding it a full\n * second traded a ≈100 ms gap for a ≈1 s stall on every refused wake. A 429\n * is rate limiting and keeps its hint whatever the body says.\n */\nexport const WAKE_WORKER_REFUSAL_FLOOR_MS = 250;\n\n/**\n * The pinboard's own placement-refusal vocabulary — the `error` strings of\n * `@assistant-ui/pinboard` (`src/refusals.ts` plus the two inline refusals in\n * `src/server.ts`). Mirrors `PINBOARD_PLACEMENT_REFUSAL_ERRORS` in\n * `iris/scaler/src/v2AccessLog.ts`, whose test pins it to the installed\n * library; keep the two lists identical. A body with any other `error`\n * string is `\"unknown\"`, never `\"placement\"`.\n */\nexport const PINBOARD_PLACEMENT_REFUSAL_ERRORS: ReadonlySet<string> = new Set([\n \"no live backends for namespace\",\n \"registry cold start, retry\",\n \"not the serving fleet\",\n \"no live workers for hostname\",\n \"hostname binding displaced, retry\",\n \"hostname bindings inconsistent, retry\",\n \"takeover in progress, retry\",\n \"placement held by another hostname\",\n \"worker inflight cap exceeded, retry\",\n \"worker refused activation, retry\",\n]);\n\n/**\n * Pinned's instance-lifecycle refusals (`pinned/hosting/directory.py`,\n * `pinned/hosting/_records.py`): each names one instance on the worker the\n * thread is already placed on and clears in the time a teardown or an\n * activation slot takes. Anything else the worker answers 503 with — \"Thread\n * history load failed\", \"The regional conversation store is temporarily\n * unavailable\", a lease that expired — is not about one instance and keeps\n * the full hint.\n */\nexport const PINNED_TRANSIENT_WORKER_REFUSALS: ReadonlySet<string> = new Set([\n \"instance closing; retry\",\n \"instance aborted\",\n \"activation queue full; retry\",\n \"host draining; retry\",\n \"instance capacity reached; retry\",\n \"instance past its wind-down deadline; retry\",\n]);\n\n/**\n * Classify a 503/429 body (see {@link DeepAgentWakeRefusal}): a known pinboard\n * `error` is `\"placement\"`, a known pinned `detail` is `\"worker\"`, everything\n * else — including unfamiliar strings in either field — is `\"unknown\"`.\n */\nexport function classifyWakeRefusal(\n body: unknown,\n): Exclude<DeepAgentWakeRefusal, null> {\n if (typeof body === \"object\" && body !== null) {\n const record = body as Record<string, unknown>;\n if (\n typeof record.error === \"string\" &&\n PINBOARD_PLACEMENT_REFUSAL_ERRORS.has(record.error)\n ) {\n return \"placement\";\n }\n if (\n typeof record.detail === \"string\" &&\n PINNED_TRANSIENT_WORKER_REFUSALS.has(record.detail)\n ) {\n return \"worker\";\n }\n }\n return \"unknown\";\n}\n\n/** Structural `fetch` for the wake call — this package compiles without the DOM lib. */\nexport type DeepAgentWakeFetch = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n keepalive?: boolean;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n headers: { get(name: string): string | null };\n json(): Promise<unknown>;\n}>;\n\nexport interface DeepAgentWakeOptions {\n /** Base URL of the deep-agent statewire host (the `…/v2/threads` mount). */\n baseUrl: string;\n threadId: string;\n headers: DeepAgentHeadersResolver;\n fetch?: DeepAgentWakeFetch | undefined;\n /** Dial floors from server retry hints; the process-wide gate by default. */\n retryGate?: AttachRetryGate;\n onAttachTiming?: ((event: DeepAgentAttachTimingEvent) => void) | undefined;\n now?: () => number;\n}\n\n/**\n * Wake a thread's server instance ahead of its attach. `POST …/prewarm` runs\n * the host's `authorize` and history load — the fence adopt, graph compile and\n * checkpoint read that make a cold attach's hello take ≈1 s — and answers with\n * no thread content, only whether the history was already resident. Fire it\n * on hover intent, before a pooled attach or the click's own dial, so the\n * activation overlaps the handshake and the attach that follows finds the\n * instance warm.\n *\n * Fire-and-forget by contract: every failure resolves `null`, and a host\n * without the route (404) is reported like any other non-OK answer. A 503/429\n * `Retry-After` is recorded as a dial floor for the thread, so the attach that\n * follows waits it out instead of bouncing — in full, except a 503 that names\n * one of pinned's instance-lifecycle refusals (one instance mid-teardown on\n * the worker the thread already has; see {@link DeepAgentWakeRefusal}), whose\n * floor is capped at {@link WAKE_WORKER_REFUSAL_FLOOR_MS}.\n */\nexport async function wakeDeepAgentThread({\n baseUrl,\n threadId,\n headers,\n fetch: fetchOverride,\n retryGate = attachRetryGate,\n onAttachTiming,\n now = () => Date.now(),\n}: DeepAgentWakeOptions): Promise<DeepAgentWakeResult | null> {\n const fetchImpl =\n fetchOverride ?? (globalThis as { fetch?: DeepAgentWakeFetch }).fetch;\n if (typeof fetchImpl !== \"function\") return null;\n const threadUrl = deepAgentThreadUrl({ baseUrl, threadId });\n const emit = (event: DeepAgentAttachTimingEvent): void => {\n try {\n onAttachTiming?.(event);\n } catch {\n // Observers are best-effort.\n }\n };\n const startedAt = now();\n try {\n const resolved = withDeclaredProtocolVersion(\n await headers({ staleAuth: false }),\n );\n const response = await fetchImpl(`${threadUrl}/prewarm`, {\n method: \"POST\",\n headers: resolved,\n keepalive: true,\n });\n const durationMs = now() - startedAt;\n let retryAfterMs: number | null = null;\n let refusal: DeepAgentWakeRefusal = null;\n if (response.status === 503 || response.status === 429) {\n let body: unknown = null;\n try {\n body = await response.json();\n } catch {\n // A refusal without a readable body is classified by nothing.\n }\n refusal = classifyWakeRefusal(body);\n const hinted = parseRetryAfterHeaderMs(\n response.headers.get(\"retry-after\"),\n now,\n );\n if (hinted !== null) {\n retryAfterMs =\n response.status === 503 && refusal === \"worker\"\n ? Math.min(hinted, WAKE_WORKER_REFUSAL_FLOOR_MS)\n : hinted;\n if (retryAfterMs > 0) {\n retryGate.noteRetryAfter(threadUrl, retryAfterMs);\n emit({\n type: \"retry-after\",\n url: threadUrl,\n delayMs: retryAfterMs,\n source: \"http-header\",\n });\n }\n }\n }\n let warm = false;\n let history: DeepAgentWakeResult[\"history\"] = null;\n if (response.ok) {\n try {\n const body = (await response.json()) as {\n warm?: unknown;\n history?: unknown;\n } | null;\n warm = body?.warm === true;\n history =\n body?.history === \"warm\" || body?.history === \"fresh_load\"\n ? body.history\n : null;\n } catch {\n // A 200 without a JSON body still means the instance is up.\n warm = true;\n }\n }\n emit({\n type: \"wake\",\n url: threadUrl,\n status: response.status,\n durationMs,\n history,\n refusal,\n });\n return {\n status: response.status,\n warm,\n history,\n retryAfterMs,\n refusal,\n durationMs,\n };\n } catch {\n return null;\n }\n}\n"],"names":[],"mappings":";;;;AA4BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AA4CO,SAAS,+BAAiD;AAAA,EAC/D;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AACF,GAEE;AACA,QAAM,MAAM,mBAAmB,EAAE,SAAS,UAAU;AAGpD,QAAM,SAAS,8BAA8B,EAAE,IAAA,CAAK,MAAM;AAC1D,QAAM,kBAAkB,4BAA4B;AAAA,IAClD;AAAA,IACA,KAAK;AAAA,IACL,SAAS;AAAA,IACT;AAAA,EAAA,CACD;AACD,QAAM,UAAqC;AAAA,IACzC;AAAA,IACA,GAAI,SAAS,EAAE,OAAO,KAAA;AAAA,IACtB,SAAS,OAAO,QAAgC;AAC9C,YAAM,UAAU,YAAY,GAAG;AAC/B,qDAAgB;AAChB,aAAO;AAAA,QACL,4BAA4B,MAAM,QAAQ,GAAG,CAAC;AAAA,QAC9C;AAAA,MAAA;AAAA,IAEJ;AAAA,IACA,GAAI,gBAAgB,EAAE,aAAA;AAAA;AAAA;AAAA,IAGtB,GAAI,gBAAgB,aAAa;AAAA,MAC/B,WACE,gBAAgB;AAAA,IAAA;AAAA,IAEpB,GAAI,gBAAgB,SAAS;AAAA,MAC3B,OACE,gBAAgB;AAAA,IAAA;AAAA,EACpB;AAEF,SAAO,mBAA2B,SAAS,EAAE,KAAK,QAAQ;AAC5D;"}