@athenaintel/react 0.55.0 → 0.57.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 (164) hide show
  1. package/README.md +84 -1
  2. package/dist/agent-runtime/src/answer-label.cjs +8 -2
  3. package/dist/agent-runtime/src/answer-label.cjs.map +1 -1
  4. package/dist/agent-runtime/src/answer-label.d.ts +10 -4
  5. package/dist/agent-runtime/src/answer-label.js +8 -2
  6. package/dist/agent-runtime/src/answer-label.js.map +1 -1
  7. package/dist/agent-runtime/src/deep-agent-transport-recipe.cjs +6 -3
  8. package/dist/agent-runtime/src/deep-agent-transport-recipe.cjs.map +1 -1
  9. package/dist/agent-runtime/src/deep-agent-transport-recipe.js +6 -3
  10. package/dist/agent-runtime/src/deep-agent-transport-recipe.js.map +1 -1
  11. package/dist/agent-runtime/src/edit-command.cjs +40 -19
  12. package/dist/agent-runtime/src/edit-command.cjs.map +1 -1
  13. package/dist/agent-runtime/src/edit-command.d.ts +2 -0
  14. package/dist/agent-runtime/src/edit-command.js +41 -20
  15. package/dist/agent-runtime/src/edit-command.js.map +1 -1
  16. package/dist/agent-runtime/src/generated/runconfig-contract.cjs +8 -0
  17. package/dist/agent-runtime/src/generated/runconfig-contract.cjs.map +1 -1
  18. package/dist/agent-runtime/src/generated/runconfig-contract.d.ts +9 -0
  19. package/dist/agent-runtime/src/generated/runconfig-contract.js +8 -0
  20. package/dist/agent-runtime/src/generated/runconfig-contract.js.map +1 -1
  21. package/dist/agent-runtime/src/history-store.cjs +426 -0
  22. package/dist/agent-runtime/src/history-store.cjs.map +1 -0
  23. package/dist/agent-runtime/src/history-store.d.ts +282 -0
  24. package/dist/agent-runtime/src/history-store.js +426 -0
  25. package/dist/agent-runtime/src/history-store.js.map +1 -0
  26. package/dist/agent-runtime/src/inbound-channel.cjs +96 -0
  27. package/dist/agent-runtime/src/inbound-channel.cjs.map +1 -0
  28. package/dist/agent-runtime/src/inbound-channel.d.ts +75 -0
  29. package/dist/agent-runtime/src/inbound-channel.js +96 -0
  30. package/dist/agent-runtime/src/inbound-channel.js.map +1 -0
  31. package/dist/agent-runtime/src/index.d.ts +6 -5
  32. package/dist/agent-runtime/src/interrupts.cjs +45 -3
  33. package/dist/agent-runtime/src/interrupts.cjs.map +1 -1
  34. package/dist/agent-runtime/src/interrupts.d.ts +51 -4
  35. package/dist/agent-runtime/src/interrupts.js +46 -4
  36. package/dist/agent-runtime/src/interrupts.js.map +1 -1
  37. package/dist/agent-runtime/src/message-window.cjs +43 -5
  38. package/dist/agent-runtime/src/message-window.cjs.map +1 -1
  39. package/dist/agent-runtime/src/message-window.d.ts +26 -2
  40. package/dist/agent-runtime/src/message-window.js +44 -6
  41. package/dist/agent-runtime/src/message-window.js.map +1 -1
  42. package/dist/agent-runtime/src/offline-status.cjs.map +1 -1
  43. package/dist/agent-runtime/src/offline-status.d.ts +110 -4
  44. package/dist/agent-runtime/src/offline-status.js.map +1 -1
  45. package/dist/agent-runtime/src/runtime.d.ts +1 -0
  46. package/dist/agent-runtime/src/thread-handoff-flow-machine.d.ts +8 -8
  47. package/dist/agent-runtime/src/tool-metadata.cjs +16 -0
  48. package/dist/agent-runtime/src/tool-metadata.cjs.map +1 -1
  49. package/dist/agent-runtime/src/tool-metadata.js +16 -0
  50. package/dist/agent-runtime/src/tool-metadata.js.map +1 -1
  51. package/dist/agent-runtime/src/use-deep-agent-thread.cjs +8 -16
  52. package/dist/agent-runtime/src/use-deep-agent-thread.cjs.map +1 -1
  53. package/dist/agent-runtime/src/use-deep-agent-thread.d.ts +1 -9
  54. package/dist/agent-runtime/src/use-deep-agent-thread.js +9 -17
  55. package/dist/agent-runtime/src/use-deep-agent-thread.js.map +1 -1
  56. package/dist/agent-runtime/src/use-deep-agent-window-sync.cjs +86 -79
  57. package/dist/agent-runtime/src/use-deep-agent-window-sync.cjs.map +1 -1
  58. package/dist/agent-runtime/src/use-deep-agent-window-sync.d.ts +2 -27
  59. package/dist/agent-runtime/src/use-deep-agent-window-sync.js +89 -82
  60. package/dist/agent-runtime/src/use-deep-agent-window-sync.js.map +1 -1
  61. package/dist/auth/AthenaSSOProvider.cjs +54 -12
  62. package/dist/auth/AthenaSSOProvider.cjs.map +1 -1
  63. package/dist/auth/AthenaSSOProvider.d.ts +23 -2
  64. package/dist/auth/AthenaSSOProvider.js +54 -12
  65. package/dist/auth/AthenaSSOProvider.js.map +1 -1
  66. package/dist/auth/index.cjs +2 -0
  67. package/dist/auth/index.cjs.map +1 -1
  68. package/dist/auth/index.d.ts +1 -0
  69. package/dist/auth/index.js +2 -0
  70. package/dist/auth/index.js.map +1 -1
  71. package/dist/auth/sign-back-in.cjs +141 -0
  72. package/dist/auth/sign-back-in.cjs.map +1 -0
  73. package/dist/auth/sign-back-in.d.ts +117 -0
  74. package/dist/auth/sign-back-in.js +141 -0
  75. package/dist/auth/sign-back-in.js.map +1 -0
  76. package/dist/chat/AthenaChat.cjs +7 -2
  77. package/dist/chat/AthenaChat.cjs.map +1 -1
  78. package/dist/chat/AthenaChat.js +7 -2
  79. package/dist/chat/AthenaChat.js.map +1 -1
  80. package/dist/chat/StatewireApprovalCard.cjs +6 -2
  81. package/dist/chat/StatewireApprovalCard.cjs.map +1 -1
  82. package/dist/chat/StatewireApprovalCard.d.ts +8 -1
  83. package/dist/chat/StatewireApprovalCard.js +7 -3
  84. package/dist/chat/StatewireApprovalCard.js.map +1 -1
  85. package/dist/chat/super-grouping/CollapsibleGroup.cjs +1 -1
  86. package/dist/chat/super-grouping/CollapsibleGroup.cjs.map +1 -1
  87. package/dist/chat/super-grouping/CollapsibleGroup.d.ts +6 -5
  88. package/dist/chat/super-grouping/CollapsibleGroup.js +1 -1
  89. package/dist/chat/super-grouping/CollapsibleGroup.js.map +1 -1
  90. package/dist/chat/super-grouping/earlier-steps-button.cjs +5 -6
  91. package/dist/chat/super-grouping/earlier-steps-button.cjs.map +1 -1
  92. package/dist/chat/super-grouping/earlier-steps-button.d.ts +6 -6
  93. package/dist/chat/super-grouping/earlier-steps-button.js +5 -6
  94. package/dist/chat/super-grouping/earlier-steps-button.js.map +1 -1
  95. package/dist/chat/super-grouping/toolkit-map.cjs +1 -0
  96. package/dist/chat/super-grouping/toolkit-map.cjs.map +1 -1
  97. package/dist/chat/super-grouping/toolkit-map.js +1 -0
  98. package/dist/chat/super-grouping/toolkit-map.js.map +1 -1
  99. package/dist/chat/super-grouping/use-turn-steps.cjs +3 -3
  100. package/dist/chat/super-grouping/use-turn-steps.js +1 -1
  101. package/dist/chat-ui/index.cjs +4 -0
  102. package/dist/chat-ui/index.cjs.map +1 -1
  103. package/dist/chat-ui/index.d.ts +1 -0
  104. package/dist/chat-ui/index.js +5 -1
  105. package/dist/chat-ui/index.js.map +1 -1
  106. package/dist/chat-ui/messages/inbound-channel-message.cjs +219 -0
  107. package/dist/chat-ui/messages/inbound-channel-message.cjs.map +1 -0
  108. package/dist/chat-ui/messages/inbound-channel-message.d.ts +60 -0
  109. package/dist/chat-ui/messages/inbound-channel-message.js +219 -0
  110. package/dist/chat-ui/messages/inbound-channel-message.js.map +1 -0
  111. package/dist/chat-ui/messages/inbound-channel-reader.cjs +184 -0
  112. package/dist/chat-ui/messages/inbound-channel-reader.cjs.map +1 -0
  113. package/dist/chat-ui/messages/inbound-channel-reader.d.ts +65 -0
  114. package/dist/chat-ui/messages/inbound-channel-reader.js +184 -0
  115. package/dist/chat-ui/messages/inbound-channel-reader.js.map +1 -0
  116. package/dist/chat-ui/messages/message-components.cjs +18 -14
  117. package/dist/chat-ui/messages/message-components.cjs.map +1 -1
  118. package/dist/chat-ui/messages/message-components.d.ts +7 -0
  119. package/dist/chat-ui/messages/message-components.js +19 -15
  120. package/dist/chat-ui/messages/message-components.js.map +1 -1
  121. package/dist/chat-ui/thread/statewire-history-edge.cjs +0 -4
  122. package/dist/chat-ui/thread/statewire-history-edge.cjs.map +1 -1
  123. package/dist/chat-ui/thread/statewire-history-edge.d.ts +5 -7
  124. package/dist/chat-ui/thread/statewire-history-edge.js +1 -5
  125. package/dist/chat-ui/thread/statewire-history-edge.js.map +1 -1
  126. package/dist/composer/sign-back-in-draft.cjs +62 -0
  127. package/dist/composer/sign-back-in-draft.cjs.map +1 -0
  128. package/dist/composer/sign-back-in-draft.d.ts +34 -0
  129. package/dist/composer/sign-back-in-draft.js +62 -0
  130. package/dist/composer/sign-back-in-draft.js.map +1 -0
  131. package/dist/diagnostics/athena-diagnostics.cjs.map +1 -1
  132. package/dist/diagnostics/athena-diagnostics.d.ts +6 -1
  133. package/dist/diagnostics/athena-diagnostics.js.map +1 -1
  134. package/dist/index.cjs +8 -0
  135. package/dist/index.cjs.map +1 -1
  136. package/dist/index.d.ts +2 -0
  137. package/dist/index.js +8 -0
  138. package/dist/index.js.map +1 -1
  139. package/dist/provider/AthenaProvider.cjs +60 -14
  140. package/dist/provider/AthenaProvider.cjs.map +1 -1
  141. package/dist/provider/AthenaProvider.js +60 -14
  142. package/dist/provider/AthenaProvider.js.map +1 -1
  143. package/dist/provider/statewire-window-context.cjs.map +1 -1
  144. package/dist/provider/statewire-window-context.d.ts +8 -8
  145. package/dist/provider/statewire-window-context.js.map +1 -1
  146. package/dist/react/package.json.cjs +1 -1
  147. package/dist/react/package.json.js +1 -1
  148. package/dist/runtime/auth.cjs +11 -0
  149. package/dist/runtime/auth.cjs.map +1 -1
  150. package/dist/runtime/auth.d.ts +18 -1
  151. package/dist/runtime/auth.js +11 -0
  152. package/dist/runtime/auth.js.map +1 -1
  153. package/dist/runtime/useAthenaStatewireRuntime.cjs +0 -2
  154. package/dist/runtime/useAthenaStatewireRuntime.cjs.map +1 -1
  155. package/dist/runtime/useAthenaStatewireRuntime.d.ts +5 -5
  156. package/dist/runtime/useAthenaStatewireRuntime.js +0 -2
  157. package/dist/runtime/useAthenaStatewireRuntime.js.map +1 -1
  158. package/dist/styles.css +1 -1
  159. package/package.json +1 -1
  160. package/dist/agent-runtime/src/history-pages.cjs +0 -279
  161. package/dist/agent-runtime/src/history-pages.cjs.map +0 -1
  162. package/dist/agent-runtime/src/history-pages.d.ts +0 -252
  163. package/dist/agent-runtime/src/history-pages.js +0 -279
  164. package/dist/agent-runtime/src/history-pages.js.map +0 -1
package/README.md CHANGED
@@ -567,6 +567,35 @@ only when it is an `https://github.com/<repo>/…` URL for that repository
567
567
  The full message text always reaches the model; the marker is provenance for
568
568
  rendering only.
569
569
 
570
+ ### Messages from Slack, email, texts and Teams
571
+
572
+ A message that reached an agent over a channel arrives with two things from
573
+ agora's channel routers: the `inbound_channel` envelope in
574
+ `metadata.custom` (the channel, the sender's name and address or number, an
575
+ email's subject), and a routing block at the start of its text
576
+ (`<inbound-message-context>` … `</inbound-message-context>`: the Slack ids, the
577
+ numbers a text went between, how a reply is delivered), which is how the model
578
+ knows where the message came from.
579
+
580
+ `AthenaChat`'s user message and the `chat-ui` `UserMessage` show the words that
581
+ were sent, without the block, under one line: the channel's mark (Slack's and
582
+ Teams' logos; a mail, text, API or voice-memo glyph), the sender's name and
583
+ their address or number. Hovering the line shows the channel, the place (a
584
+ DM, `#launch`, a group text) and the sender in full; clicking it opens what
585
+ the agent received, every fact of the block labelled, with the Slack thread a
586
+ mention came in. An email's subject is the bubble's first line. A typed
587
+ message is unchanged.
588
+
589
+ Only a message carrying the envelope is drawn this way. Tags someone types at
590
+ the start of an ordinary chat message show as typed.
591
+
592
+ A host with its own user message composes the same pieces:
593
+ `InboundChannelLine` (the line, for the message in scope),
594
+ `InboundChannelSubject`, and `withInboundMessageBody(Text)` (or
595
+ `UserMessageText`, which already shows the words without the block).
596
+ `isInboundChannelMessage(custom)` and `inboundMessageBody({ custom, text })`
597
+ read a message without React.
598
+
570
599
  ## Sessions page and inbox
571
600
 
572
601
  A full-page, Devin-style session list and a notifications inbox, both on top
@@ -951,6 +980,32 @@ Both use the same bearer / API key as session creation. The full recipe
951
980
  template skill `voice-session-guide.md`. `createAthenaVoiceSession` is also
952
981
  exported if you hold the bridge state yourself.
953
982
 
983
+ ## What's new in 0.57.0
984
+
985
+ **Messages from Slack, email, texts and Teams read as what was sent.** A message that reached the agent over a channel used to show, above its words, the routing block the channel writes for the agent: `<inbound-message-context>`, the Slack workspace and channel ids, the numbers a text went between, how a reply is delivered. `AthenaChat` and chat-ui's `UserMessage` now show:
986
+ - **The words that were sent.** The block leaves the bubble, and so does what a channel puts ahead of the words for the agent: the Slack thread a mention came in, a group text's "<name> said:" line. An email's subject is the bubble's first line.
987
+ - **Who sent it, from where.** A line above the bubble carries the channel's mark (Slack's and Teams' logos, a mail, text, API or voice-memo glyph), the sender's name and their email address or phone number. Hovering it shows the channel, the place (a DM, `#launch`, a group text) and the sender in full.
988
+ - **What the agent received.** Clicking the line opens every fact the agent read with the message, labelled, and the thread it quoted.
989
+
990
+ Nothing to change in your app. A message without the channel's envelope (tags someone typed) shows as typed. The hover card is plain CSS and the glyphs are icons the chat already draws, so the line adds no tooltip library to your bundle. Hosts with their own user message compose the same pieces (**Messages from Slack, email, texts and Teams**, above).
991
+
992
+ Also in this release:
993
+ - A session that ends under an open page signs back in instead of stranding the app on a sign-in screen (**When a session ends while the page is open**).
994
+ - "Approve for this session" on a send (an email, a text) covers only the recipients the approval card named, and the card says so.
995
+
996
+ ## What's new in 0.56.0
997
+
998
+ **One history store per conversation, shared by every reader.** Older history read with `history/page` now lives in a per-conversation store that the thread, the history edge, edits and reveals all join. It no longer sits in a store `AthenaProvider` hands around. The store keeps each message by its parent, not its position:
999
+ - **Kept on moves:** history you loaded survives a reconnect, a move to another server, and new messages pushing older ones out of the live view.
1000
+ - **Smaller live view:** the client says so on the attach (`x-athena-deep-agent-history: paged`). An agora that knows the header then keeps the live view to the newest 200 messages during a long run, since older ones stay in the store.
1001
+ - **Edits:** an edit of an older message pages back by command until it finds where to anchor.
1002
+
1003
+ **Hosts need to pass nothing.** `StatewireHistoryPagesContext`, `useStatewireHistoryPages`, `StatewireHistoryEdge`'s `historyPages` prop and `AthenaStatewireRuntimeConfig.historyPages` are deprecated and ignored. A summarized turn's counts come with every thread. Hosts that compose their own thread still provide the steps control through `TurnStepsControlContext` (`EarlierStepsButton` is the stock one). Hosts built directly on `@athenaintel/agent-runtime` need no store: `loadDeepAgentTurnSteps({ send, marker })` loads a summarized turn's steps.
1004
+
1005
+ **Refusals.** Only a host without the command (`unknown-command`) moves a connection back to interest pages. Any other refusal is that page's error, and loaded history stays shown.
1006
+
1007
+ **Identity.** `AthenaProvider` drops the stores when the signed-in identity or API key changes. Against an agora older than protocol 3, paging works as in 0.54.0.
1008
+
954
1009
  ## What's new in 0.55.0
955
1010
 
956
1011
  **Older history reaches the start of any conversation.** Against an agora that answers `history/page` (deep-agent protocol 3), `AthenaChat` loads older history as whole turns into a store of its own, kept outside the connection's view. The view is no longer the depth limit, so a long session reads back to its first message.
@@ -1999,7 +2054,35 @@ If you need full control, `ssoUserInfoUrl`, `ssoLoginUrl`, and `ssoLogoutUrl` re
1999
2054
  - A transient failure (5xx, 408, 429, a network error, a timeout) is retried within the read, about 0.5 s, 1 s and 2 s apart, before `displayOnError` shows.
2000
2055
  - Only `unauthenticated` means the person has no session. `request_failed` means the endpoint did not answer usably; the person may well be signed in, so offer `retry` rather than sign-in.
2001
2056
  - `onError` receives the typed `auth_sso_failed` error of a read that gave up.
2002
- - Without `displayOnError`, a person with no session is sent to `ssoLoginUrl`.
2057
+ - Without `displayOnError`, a person with no session is sent to `ssoLoginUrl`, with `return_to` set to the page.
2058
+
2059
+ ### When a session ends while the page is open
2060
+
2061
+ A deployed app's session can end under an open tab: the person signs out of Athena elsewhere (the web app, the desktop app or the phone), or the host's session for the app expires. The tab finds out on its next credential read, a 401 from `/api/sso/userinfo` or `/_athena/auth`. The SDK then sends the page through sign-in once, with `return_to` set to the page, instead of showing a sign-in screen. A person still signed in to Athena in that browser comes straight back to the same page, signed in again, in a second or two. One who is not sees Athena's sign-in page first.
2062
+
2063
+ A 403 there is the host refusing this person the app (Marathon's `not_authorized` or `unknown_asset`). Signing in again cannot change that, so a 403 never starts a trip.
2064
+
2065
+ - **`AthenaSSOProvider`** does this for a session it served on this page (`signBackInOnSessionEnd`, default `true`). The app stays on screen until the page leaves. These show `displayOnError` as before:
2066
+ - a 403;
2067
+ - a first read with no session;
2068
+ - a sign-out through `logout()`;
2069
+ - `signBackInOnSessionEnd={false}`.
2070
+ - **`AthenaProvider`** does it through the Marathon bridge, but only when the bridge's token is the chat's credential. That means no sign-in provider above it, and no `token`, `getToken` or `apiKey` of the host's own, since those keep working when the bridge's session ends.
2071
+ - The bridge goes to `/api/sso/initiate` when `/_athena/auth` refuses, with a 401, a renewal of a credential the page already minted.
2072
+ - Never on the first mint: a page served on a preview grant alone has no session.
2073
+ - Never on a 403, and never on a transient failure.
2074
+ - The typed `auth_bridge_failed` error still reaches `onError` first.
2075
+ - **At most once per tab every two minutes** (`sessionStorage`). A second ending within that time, or a tab without `sessionStorage`, shows the sign-in state instead, so a trip that did not help never loops.
2076
+ - `AthenaSSOProvider` without `displayOnError` keeps the same guard for its own redirect to `ssoLoginUrl`.
2077
+ - Within two minutes of a trip, that provider renders nothing and logs a console warning instead of sending the page again.
2078
+ - **Hash routes come back.** Marathon returns to `return_to` without its URL fragment. The SDK keeps the fragment in the tab and puts it back (`location.replace`) when the page returns to the same path within ten minutes, so a hash-routed app (`/#/chat/123`) lands on the same route.
2079
+ - **The composer's draft comes back.** `AthenaProvider` keeps the draft right before the page leaves and puts it back when the page returns.
2080
+ - Only for the same person (a sign-in provider names them), and within ten minutes. A draft kept for someone else is deleted unread.
2081
+ - It returns into the conversation it was typed in. A draft typed in a new chat goes into the new chat the page comes back to.
2082
+ - A page that comes back to a different conversation shows the draft when the person opens the original one. For example, a thread-list app that does not keep the open conversation in its URL (`threadId` or `initialThreadId`) comes back to a new chat.
2083
+ - Text only: attachments are not carried.
2084
+ - **Your own state.** `ATHENA_SIGN_BACK_IN_EVENT` (from `@athenaintel/react/auth`) is dispatched on `window` right before the page leaves, while the app is still mounted.
2085
+ - **Diagnostics.** Each ending emits `auth.session.ended` with `{ source: 'sso' | 'bridge', status, sign_back_in }`.
2003
2086
 
2004
2087
  ## Mentions and citations
2005
2088
 
@@ -85,11 +85,17 @@ function threadShowsAnswerLabels(messages) {
85
85
  function showsAnswerLabel({
86
86
  answers,
87
87
  threadShowsLabels,
88
- viewerId
88
+ viewerId,
89
+ labelsOtherAgents = false
89
90
  }) {
90
91
  if (answers.length === 0) return false;
91
92
  if (threadShowsLabels) return true;
92
- return viewerId !== null && answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId);
93
+ if (viewerId === null) return false;
94
+ if (labelsOtherAgents) {
95
+ const ownAssistant = `${ASSISTANT_AGENT_PREFIX}${viewerId}`;
96
+ if (answers.some((answer) => answer.agent !== ownAssistant)) return true;
97
+ }
98
+ return answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId);
93
99
  }
94
100
  function answererName(answer) {
95
101
  if (answer.agent_name !== null) return answer.agent_name;
@@ -1 +1 @@
1
- {"version":3,"file":"answer-label.cjs","sources":["../../../../agent-runtime/src/answer-label.ts"],"sourcesContent":["import { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\nimport type { LangChainMessage } from '@assistant-ui/react-langgraph';\n\n/**\n * `metadata.custom` key naming the agent that answered an AI message and whom\n * its run worked for (identity v7 D13: \"each answer is labelled with its\n * agent, 'for <author>'\").\n *\n * The backend stamps it on every answer a deep-agent run's model produces\n * (`agora/.../conversation_agents/answer_label.py`), under the\n * `additional_kwargs.metadata` bag the converter exposes as\n * `message.metadata.custom`, and mirrors it onto the live wire once the\n * model's turn lands. Only the server writes it. Answers written before the\n * stamp existed carry none, and get no label.\n */\nexport const ATHENA_ANSWERED_BY_KEY = 'answered_by';\n\n/** The agent that answered and whom its run worked for, as the backend stamps them. */\nexport interface DeepAgentAnsweredBy {\n /** `collab_agent:<asset id>` for a team agent, `assistant:<principal id>` for someone's own assistant. */\n agent: string;\n /** The collab agent's name when it answered; `null` for an assistant, which clients name. */\n agent_name: string | null;\n /** Whom the run worked for: a person (`user_…`), an agent's or automation's own run, or nobody (`null`). */\n for_id: string | null;\n /** That person's display name when it answered, else `null`. */\n for_name: string | null;\n}\n\n/** What a label calls someone's own assistant: the managed default agent's product name. */\nexport const ATHENA_ASSISTANT_NAME = 'Athena';\n\nconst ASSISTANT_AGENT_PREFIX = 'assistant:';\nconst PERSON_PRINCIPAL_PREFIX = 'user_';\nconst UNNAMED_AGENT_NAME = 'Agent';\nconst LABEL_SEPARATOR = ' · ';\nconst NO_ANSWERS: readonly DeepAgentAnsweredBy[] = Object.freeze([]);\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst optionalId = (value: unknown): string | null =>\n typeof value === 'string' && value.length > 0 ? value : null;\n\nconst optionalName = (value: unknown): string | null => {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n};\n\n/**\n * Parse an `answered_by` stamp off a wire value.\n *\n * A stamp without a string `agent` is no stamp at all; absent or malformed\n * optional fields read as `null`, so a partly malformed stamp still names its\n * agent and never names a person it can't.\n */\nexport function readAnsweredBy(value: unknown): DeepAgentAnsweredBy | null {\n if (!isRecord(value)) return null;\n const agent = optionalId(value.agent);\n if (agent === null) return null;\n return {\n agent,\n agent_name: optionalName(value.agent_name),\n for_id: optionalId(value.for_id),\n for_name: optionalName(value.for_name),\n };\n}\n\n/**\n * The run id an answer's row carries when the deep-agent conversion split a\n * turn several answerers wrote into one row per answerer (`answer-rows.ts`):\n * one per agent and principal.\n */\nconst ANSWER_ROW_RUN_ID_PREFIX = 'answered_by:';\n\n/** The row run id of an answer by `agent` for `forId` (see `withAnswerRows`). */\nexport function answerRowRunId(agent: string, forId: string | null): string {\n return `${ANSWER_ROW_RUN_ID_PREFIX}${agent}|${forId ?? ''}`;\n}\n\n/**\n * Whether a converted row is one of a turn the conversion split by answerer.\n * Reads `metadata` optionally, as `collectMessageAnswers` does: a message\n * another runtime (or a test) builds may carry none.\n */\nfunction isSplitAnswerRow(message: ThreadMessage): boolean {\n const runId = message.role === 'assistant' ? message.metadata?.runId : undefined;\n return typeof runId === 'string' && runId.startsWith(ANSWER_ROW_RUN_ID_PREFIX);\n}\n\nconst isSameAnswerer = (a: DeepAgentAnsweredBy, b: DeepAgentAnsweredBy): boolean =>\n a.agent === b.agent && a.for_id === b.for_id;\n\nconst isPersonPrincipal = (id: string | null): id is string =>\n id !== null && id.startsWith(PERSON_PRINCIPAL_PREFIX);\n\n/** The `answered_by` stamp an AI message as the wire carries it holds; `null` for any other message. */\nexport function readLangChainAnswer(\n source: LangChainMessage | undefined\n): DeepAgentAnsweredBy | null {\n if (source?.type !== 'ai') return null;\n const bag = source.additional_kwargs?.metadata;\n return isRecord(bag) ? readAnsweredBy(bag[ATHENA_ANSWERED_BY_KEY]) : null;\n}\n\nfunction collectMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n if (message.role !== 'assistant') return NO_ANSWERS;\n const sources = getExternalStoreMessages<LangChainMessage>(message);\n if (sources.length === 0) {\n const joined = readAnsweredBy(message.metadata?.custom?.[ATHENA_ANSWERED_BY_KEY]);\n return joined === null ? NO_ANSWERS : [joined];\n }\n let answers: DeepAgentAnsweredBy[] | null = null;\n for (const source of sources) {\n const answer = readLangChainAnswer(source);\n if (answer === null || answers?.some((seen) => isSameAnswerer(seen, answer))) continue;\n answers ??= [];\n answers.push(answer);\n }\n return answers ?? NO_ANSWERS;\n}\n\nconst answersByMessage = new WeakMap<ThreadMessage, readonly DeepAgentAnsweredBy[]>();\n\n/**\n * The answers one thread message holds: each agent that answered in it and\n * whom it worked for, in the order they answered, one entry per agent and\n * principal.\n *\n * Read off the message's bound source messages, not its joined\n * `metadata.custom`: assistant-ui joins consecutive AI messages into one\n * turn, and its joined metadata keeps only the last stamp. The deep-agent\n * conversion gives each agent that answers one message in turn (D13 several\n * agents, one message) a row of its own (`answer-rows.ts`), so a row\n * normally holds one answer; a conversion without that split (another\n * pipeline, a stamp that never landed) still names every agent of the row.\n * A message with no bound sources (another runtime) falls back to the joined\n * value. Cached per message object: a settled message keeps its object\n * across a streamed token, so a thread-wide scan per token reads only the\n * message that changed.\n */\nexport function readMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n const cached = answersByMessage.get(message);\n if (cached !== undefined) return cached;\n const answers = collectMessageAnswers(message);\n answersByMessage.set(message, answers);\n return answers;\n}\n\n/**\n * The thread-wide half of the label decision: whether the transcript's\n * answers name two or more agents, or two or more principals they worked\n * for. Then every answer is labelled; a 1:1 chat with one agent working for\n * one person looks as it did before labels. Unstamped answers count for\n * nothing.\n *\n * It decides over the messages the client holds (on a windowed wire, the\n * pages loaded so far), as the human-author avatars do: a page of older\n * history that brings another agent or person turns the labels on. Each\n * label still names its own answer's stamp, so loading history never changes\n * what an answer is attributed to, only whether the line shows.\n *\n * A turn the conversion split into one row per answerer turns the labels on\n * too, before the next agent's stamp lands: while several agents answer one\n * message, the host's record already says a second agent is answering, so\n * the first agent's answer is labelled from the moment the second one's row\n * appears.\n */\nexport function threadShowsAnswerLabels(messages: readonly ThreadMessage[]): boolean {\n let first: DeepAgentAnsweredBy | null = null;\n for (const message of messages) {\n if (isSplitAnswerRow(message)) return true;\n for (const answer of readMessageAnswers(message)) {\n if (first === null) {\n first = answer;\n } else if (!isSameAnswerer(first, answer)) {\n return true;\n }\n }\n }\n return false;\n}\n\n/**\n * Whether a message's answers are labelled: always once the thread's answers\n * name several agents or principals (`threadShowsLabels`), and otherwise when\n * one of them worked for a person other than the viewer. Without a known\n * viewer that last rule is off, so a 1:1 chat never flashes labels while the\n * viewer loads.\n */\nexport function showsAnswerLabel({\n answers,\n threadShowsLabels,\n viewerId,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n threadShowsLabels: boolean;\n viewerId: string | null;\n}): boolean {\n if (answers.length === 0) return false;\n if (threadShowsLabels) return true;\n return (\n viewerId !== null &&\n answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId)\n );\n}\n\nfunction answererName(answer: DeepAgentAnsweredBy): string {\n if (answer.agent_name !== null) return answer.agent_name;\n return answer.agent.startsWith(ASSISTANT_AGENT_PREFIX)\n ? ATHENA_ASSISTANT_NAME\n : UNNAMED_AGENT_NAME;\n}\n\nfunction forWhomName(answer: DeepAgentAnsweredBy, viewerId: string | null): string | null {\n if (answer.for_id !== null && answer.for_id === viewerId) return 'you';\n if (answer.for_name !== null && isPersonPrincipal(answer.for_id)) return answer.for_name;\n return null;\n}\n\nfunction joinNames(names: readonly string[]): string {\n if (names.length <= 1) return names[0] ?? '';\n return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;\n}\n\n/**\n * The label's words: who answered, then whom for. \"Finance · for you\",\n * \"Athena · for Priya Shah\", \"Finance\" (a run that worked for nobody, or for\n * an agent's own run). The agent is the collab agent's name, else\n * {@link ATHENA_ASSISTANT_NAME} for someone's own assistant, else \"Agent\".\n * Several agents answering for the same principal read \"Finance and Legal ·\n * for you\"; answers for different principals are listed apart, even when the\n * people share a name (\"Finance · for Alex, Legal · for Alex\"). `null` when\n * the message holds no answers.\n */\nexport function answerLabelText({\n answers,\n viewerId,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n viewerId: string | null;\n}): string | null {\n if (answers.length === 0) return null;\n // One group per principal (nobody is one too), never per displayed name: two\n // people who share a name stay two recipients.\n const groups: { forId: string | null; names: string[]; forWhom: string | null }[] = [];\n for (const answer of answers) {\n const name = answererName(answer);\n const forWhom = forWhomName(answer, viewerId);\n const group = groups.find((candidate) => candidate.forId === answer.for_id);\n if (group === undefined) {\n groups.push({ forId: answer.for_id, names: [name], forWhom });\n continue;\n }\n if (!group.names.includes(name)) group.names.push(name);\n // A recipient one answer left unnamed takes the name a later answer for\n // the same principal carries. The first name found wins: answers stamped\n // under two names for one id (a rename between runs) read as the first.\n group.forWhom ??= forWhom;\n }\n return groups\n .map(({ names, forWhom }) =>\n forWhom === null ? joinNames(names) : `${joinNames(names)}${LABEL_SEPARATOR}for ${forWhom}`\n )\n .join(', ');\n}\n"],"names":["getExternalStoreMessages"],"mappings":"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"}
1
+ {"version":3,"file":"answer-label.cjs","sources":["../../../../agent-runtime/src/answer-label.ts"],"sourcesContent":["import { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\nimport type { LangChainMessage } from '@assistant-ui/react-langgraph';\n\n/**\n * `metadata.custom` key naming the agent that answered an AI message and whom\n * its run worked for (identity v7 D13: \"each answer is labelled with its\n * agent, 'for <author>'\").\n *\n * The backend stamps it on every answer a deep-agent run's model produces\n * (`agora/.../conversation_agents/answer_label.py`), under the\n * `additional_kwargs.metadata` bag the converter exposes as\n * `message.metadata.custom`, and mirrors it onto the live wire once the\n * model's turn lands. Only the server writes it. Answers written before the\n * stamp existed carry none, and get no label.\n */\nexport const ATHENA_ANSWERED_BY_KEY = 'answered_by';\n\n/** The agent that answered and whom its run worked for, as the backend stamps them. */\nexport interface DeepAgentAnsweredBy {\n /** `collab_agent:<asset id>` for a team agent, `assistant:<principal id>` for someone's own assistant. */\n agent: string;\n /** The collab agent's name when it answered; `null` for an assistant, which clients name. */\n agent_name: string | null;\n /** Whom the run worked for: a person (`user_…`), an agent's or automation's own run, or nobody (`null`). */\n for_id: string | null;\n /** That person's display name when it answered, else `null`. */\n for_name: string | null;\n}\n\n/** What a label calls someone's own assistant: the managed default agent's product name. */\nexport const ATHENA_ASSISTANT_NAME = 'Athena';\n\nconst ASSISTANT_AGENT_PREFIX = 'assistant:';\nconst PERSON_PRINCIPAL_PREFIX = 'user_';\nconst UNNAMED_AGENT_NAME = 'Agent';\nconst LABEL_SEPARATOR = ' · ';\nconst NO_ANSWERS: readonly DeepAgentAnsweredBy[] = Object.freeze([]);\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst optionalId = (value: unknown): string | null =>\n typeof value === 'string' && value.length > 0 ? value : null;\n\nconst optionalName = (value: unknown): string | null => {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n};\n\n/**\n * Parse an `answered_by` stamp off a wire value.\n *\n * A stamp without a string `agent` is no stamp at all; absent or malformed\n * optional fields read as `null`, so a partly malformed stamp still names its\n * agent and never names a person it can't.\n */\nexport function readAnsweredBy(value: unknown): DeepAgentAnsweredBy | null {\n if (!isRecord(value)) return null;\n const agent = optionalId(value.agent);\n if (agent === null) return null;\n return {\n agent,\n agent_name: optionalName(value.agent_name),\n for_id: optionalId(value.for_id),\n for_name: optionalName(value.for_name),\n };\n}\n\n/**\n * The run id an answer's row carries when the deep-agent conversion split a\n * turn several answerers wrote into one row per answerer (`answer-rows.ts`):\n * one per agent and principal.\n */\nconst ANSWER_ROW_RUN_ID_PREFIX = 'answered_by:';\n\n/** The row run id of an answer by `agent` for `forId` (see `withAnswerRows`). */\nexport function answerRowRunId(agent: string, forId: string | null): string {\n return `${ANSWER_ROW_RUN_ID_PREFIX}${agent}|${forId ?? ''}`;\n}\n\n/**\n * Whether a converted row is one of a turn the conversion split by answerer.\n * Reads `metadata` optionally, as `collectMessageAnswers` does: a message\n * another runtime (or a test) builds may carry none.\n */\nfunction isSplitAnswerRow(message: ThreadMessage): boolean {\n const runId = message.role === 'assistant' ? message.metadata?.runId : undefined;\n return typeof runId === 'string' && runId.startsWith(ANSWER_ROW_RUN_ID_PREFIX);\n}\n\nconst isSameAnswerer = (a: DeepAgentAnsweredBy, b: DeepAgentAnsweredBy): boolean =>\n a.agent === b.agent && a.for_id === b.for_id;\n\nconst isPersonPrincipal = (id: string | null): id is string =>\n id !== null && id.startsWith(PERSON_PRINCIPAL_PREFIX);\n\n/** The `answered_by` stamp an AI message as the wire carries it holds; `null` for any other message. */\nexport function readLangChainAnswer(\n source: LangChainMessage | undefined\n): DeepAgentAnsweredBy | null {\n if (source?.type !== 'ai') return null;\n const bag = source.additional_kwargs?.metadata;\n return isRecord(bag) ? readAnsweredBy(bag[ATHENA_ANSWERED_BY_KEY]) : null;\n}\n\nfunction collectMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n if (message.role !== 'assistant') return NO_ANSWERS;\n const sources = getExternalStoreMessages<LangChainMessage>(message);\n if (sources.length === 0) {\n const joined = readAnsweredBy(message.metadata?.custom?.[ATHENA_ANSWERED_BY_KEY]);\n return joined === null ? NO_ANSWERS : [joined];\n }\n let answers: DeepAgentAnsweredBy[] | null = null;\n for (const source of sources) {\n const answer = readLangChainAnswer(source);\n if (answer === null || answers?.some((seen) => isSameAnswerer(seen, answer))) continue;\n answers ??= [];\n answers.push(answer);\n }\n return answers ?? NO_ANSWERS;\n}\n\nconst answersByMessage = new WeakMap<ThreadMessage, readonly DeepAgentAnsweredBy[]>();\n\n/**\n * The answers one thread message holds: each agent that answered in it and\n * whom it worked for, in the order they answered, one entry per agent and\n * principal.\n *\n * Read off the message's bound source messages, not its joined\n * `metadata.custom`: assistant-ui joins consecutive AI messages into one\n * turn, and its joined metadata keeps only the last stamp. The deep-agent\n * conversion gives each agent that answers one message in turn (D13 several\n * agents, one message) a row of its own (`answer-rows.ts`), so a row\n * normally holds one answer; a conversion without that split (another\n * pipeline, a stamp that never landed) still names every agent of the row.\n * A message with no bound sources (another runtime) falls back to the joined\n * value. Cached per message object: a settled message keeps its object\n * across a streamed token, so a thread-wide scan per token reads only the\n * message that changed.\n */\nexport function readMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n const cached = answersByMessage.get(message);\n if (cached !== undefined) return cached;\n const answers = collectMessageAnswers(message);\n answersByMessage.set(message, answers);\n return answers;\n}\n\n/**\n * The thread-wide half of the label decision: whether the transcript's\n * answers name two or more agents, or two or more principals they worked\n * for. Then every answer is labelled; a 1:1 chat with one agent working for\n * one person looks as it did before labels. Unstamped answers count for\n * nothing.\n *\n * It decides over the messages the client holds (on a windowed wire, the\n * pages loaded so far), as the human-author avatars do: a page of older\n * history that brings another agent or person turns the labels on. Each\n * label still names its own answer's stamp, so loading history never changes\n * what an answer is attributed to, only whether the line shows.\n *\n * A turn the conversion split into one row per answerer turns the labels on\n * too, before the next agent's stamp lands: while several agents answer one\n * message, the host's record already says a second agent is answering, so\n * the first agent's answer is labelled from the moment the second one's row\n * appears.\n */\nexport function threadShowsAnswerLabels(messages: readonly ThreadMessage[]): boolean {\n let first: DeepAgentAnsweredBy | null = null;\n for (const message of messages) {\n if (isSplitAnswerRow(message)) return true;\n for (const answer of readMessageAnswers(message)) {\n if (first === null) {\n first = answer;\n } else if (!isSameAnswerer(first, answer)) {\n return true;\n }\n }\n }\n return false;\n}\n\n/**\n * Whether a message's answers are labelled: always once the thread's answers\n * name several agents or principals (`threadShowsLabels`), and otherwise when\n * one of them worked for a person other than the viewer. With\n * `labelsOtherAgents`, also when one of them was written by any agent other\n * than the viewer's own assistant: a surface where the person names a team\n * agent for one message (Olympus's composer chips) shows who answered it even\n * in a 1:1 chat, where the answer would otherwise read as Athena's. Without a\n * known viewer both viewer rules are off, so a 1:1 chat never flashes labels\n * while the viewer loads.\n */\nexport function showsAnswerLabel({\n answers,\n threadShowsLabels,\n viewerId,\n labelsOtherAgents = false,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n threadShowsLabels: boolean;\n viewerId: string | null;\n /** Label an answer written by any agent other than the viewer's own assistant. */\n labelsOtherAgents?: boolean;\n}): boolean {\n if (answers.length === 0) return false;\n if (threadShowsLabels) return true;\n if (viewerId === null) return false;\n if (labelsOtherAgents) {\n const ownAssistant = `${ASSISTANT_AGENT_PREFIX}${viewerId}`;\n if (answers.some((answer) => answer.agent !== ownAssistant)) return true;\n }\n return answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId);\n}\n\nfunction answererName(answer: DeepAgentAnsweredBy): string {\n if (answer.agent_name !== null) return answer.agent_name;\n return answer.agent.startsWith(ASSISTANT_AGENT_PREFIX)\n ? ATHENA_ASSISTANT_NAME\n : UNNAMED_AGENT_NAME;\n}\n\nfunction forWhomName(answer: DeepAgentAnsweredBy, viewerId: string | null): string | null {\n if (answer.for_id !== null && answer.for_id === viewerId) return 'you';\n if (answer.for_name !== null && isPersonPrincipal(answer.for_id)) return answer.for_name;\n return null;\n}\n\nfunction joinNames(names: readonly string[]): string {\n if (names.length <= 1) return names[0] ?? '';\n return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;\n}\n\n/**\n * The label's words: who answered, then whom for. \"Finance · for you\",\n * \"Athena · for Priya Shah\", \"Finance\" (a run that worked for nobody, or for\n * an agent's own run). The agent is the collab agent's name, else\n * {@link ATHENA_ASSISTANT_NAME} for someone's own assistant, else \"Agent\".\n * Several agents answering for the same principal read \"Finance and Legal ·\n * for you\"; answers for different principals are listed apart, even when the\n * people share a name (\"Finance · for Alex, Legal · for Alex\"). `null` when\n * the message holds no answers.\n */\nexport function answerLabelText({\n answers,\n viewerId,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n viewerId: string | null;\n}): string | null {\n if (answers.length === 0) return null;\n // One group per principal (nobody is one too), never per displayed name: two\n // people who share a name stay two recipients.\n const groups: { forId: string | null; names: string[]; forWhom: string | null }[] = [];\n for (const answer of answers) {\n const name = answererName(answer);\n const forWhom = forWhomName(answer, viewerId);\n const group = groups.find((candidate) => candidate.forId === answer.for_id);\n if (group === undefined) {\n groups.push({ forId: answer.for_id, names: [name], forWhom });\n continue;\n }\n if (!group.names.includes(name)) group.names.push(name);\n // A recipient one answer left unnamed takes the name a later answer for\n // the same principal carries. The first name found wins: answers stamped\n // under two names for one id (a rename between runs) read as the first.\n group.forWhom ??= forWhom;\n }\n return groups\n .map(({ names, forWhom }) =>\n forWhom === null ? joinNames(names) : `${joinNames(names)}${LABEL_SEPARATOR}for ${forWhom}`\n )\n .join(', ');\n}\n"],"names":["getExternalStoreMessages"],"mappings":"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"}
@@ -79,14 +79,20 @@ export declare function threadShowsAnswerLabels(messages: readonly ThreadMessage
79
79
  /**
80
80
  * Whether a message's answers are labelled: always once the thread's answers
81
81
  * name several agents or principals (`threadShowsLabels`), and otherwise when
82
- * one of them worked for a person other than the viewer. Without a known
83
- * viewer that last rule is off, so a 1:1 chat never flashes labels while the
84
- * viewer loads.
82
+ * one of them worked for a person other than the viewer. With
83
+ * `labelsOtherAgents`, also when one of them was written by any agent other
84
+ * than the viewer's own assistant: a surface where the person names a team
85
+ * agent for one message (Olympus's composer chips) shows who answered it even
86
+ * in a 1:1 chat, where the answer would otherwise read as Athena's. Without a
87
+ * known viewer both viewer rules are off, so a 1:1 chat never flashes labels
88
+ * while the viewer loads.
85
89
  */
86
- export declare function showsAnswerLabel({ answers, threadShowsLabels, viewerId, }: {
90
+ export declare function showsAnswerLabel({ answers, threadShowsLabels, viewerId, labelsOtherAgents, }: {
87
91
  answers: readonly DeepAgentAnsweredBy[];
88
92
  threadShowsLabels: boolean;
89
93
  viewerId: string | null;
94
+ /** Label an answer written by any agent other than the viewer's own assistant. */
95
+ labelsOtherAgents?: boolean;
90
96
  }): boolean;
91
97
  /**
92
98
  * The label's words: who answered, then whom for. "Finance · for you",
@@ -83,11 +83,17 @@ function threadShowsAnswerLabels(messages) {
83
83
  function showsAnswerLabel({
84
84
  answers,
85
85
  threadShowsLabels,
86
- viewerId
86
+ viewerId,
87
+ labelsOtherAgents = false
87
88
  }) {
88
89
  if (answers.length === 0) return false;
89
90
  if (threadShowsLabels) return true;
90
- return viewerId !== null && answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId);
91
+ if (viewerId === null) return false;
92
+ if (labelsOtherAgents) {
93
+ const ownAssistant = `${ASSISTANT_AGENT_PREFIX}${viewerId}`;
94
+ if (answers.some((answer) => answer.agent !== ownAssistant)) return true;
95
+ }
96
+ return answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId);
91
97
  }
92
98
  function answererName(answer) {
93
99
  if (answer.agent_name !== null) return answer.agent_name;
@@ -1 +1 @@
1
- {"version":3,"file":"answer-label.js","sources":["../../../../agent-runtime/src/answer-label.ts"],"sourcesContent":["import { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\nimport type { LangChainMessage } from '@assistant-ui/react-langgraph';\n\n/**\n * `metadata.custom` key naming the agent that answered an AI message and whom\n * its run worked for (identity v7 D13: \"each answer is labelled with its\n * agent, 'for <author>'\").\n *\n * The backend stamps it on every answer a deep-agent run's model produces\n * (`agora/.../conversation_agents/answer_label.py`), under the\n * `additional_kwargs.metadata` bag the converter exposes as\n * `message.metadata.custom`, and mirrors it onto the live wire once the\n * model's turn lands. Only the server writes it. Answers written before the\n * stamp existed carry none, and get no label.\n */\nexport const ATHENA_ANSWERED_BY_KEY = 'answered_by';\n\n/** The agent that answered and whom its run worked for, as the backend stamps them. */\nexport interface DeepAgentAnsweredBy {\n /** `collab_agent:<asset id>` for a team agent, `assistant:<principal id>` for someone's own assistant. */\n agent: string;\n /** The collab agent's name when it answered; `null` for an assistant, which clients name. */\n agent_name: string | null;\n /** Whom the run worked for: a person (`user_…`), an agent's or automation's own run, or nobody (`null`). */\n for_id: string | null;\n /** That person's display name when it answered, else `null`. */\n for_name: string | null;\n}\n\n/** What a label calls someone's own assistant: the managed default agent's product name. */\nexport const ATHENA_ASSISTANT_NAME = 'Athena';\n\nconst ASSISTANT_AGENT_PREFIX = 'assistant:';\nconst PERSON_PRINCIPAL_PREFIX = 'user_';\nconst UNNAMED_AGENT_NAME = 'Agent';\nconst LABEL_SEPARATOR = ' · ';\nconst NO_ANSWERS: readonly DeepAgentAnsweredBy[] = Object.freeze([]);\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst optionalId = (value: unknown): string | null =>\n typeof value === 'string' && value.length > 0 ? value : null;\n\nconst optionalName = (value: unknown): string | null => {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n};\n\n/**\n * Parse an `answered_by` stamp off a wire value.\n *\n * A stamp without a string `agent` is no stamp at all; absent or malformed\n * optional fields read as `null`, so a partly malformed stamp still names its\n * agent and never names a person it can't.\n */\nexport function readAnsweredBy(value: unknown): DeepAgentAnsweredBy | null {\n if (!isRecord(value)) return null;\n const agent = optionalId(value.agent);\n if (agent === null) return null;\n return {\n agent,\n agent_name: optionalName(value.agent_name),\n for_id: optionalId(value.for_id),\n for_name: optionalName(value.for_name),\n };\n}\n\n/**\n * The run id an answer's row carries when the deep-agent conversion split a\n * turn several answerers wrote into one row per answerer (`answer-rows.ts`):\n * one per agent and principal.\n */\nconst ANSWER_ROW_RUN_ID_PREFIX = 'answered_by:';\n\n/** The row run id of an answer by `agent` for `forId` (see `withAnswerRows`). */\nexport function answerRowRunId(agent: string, forId: string | null): string {\n return `${ANSWER_ROW_RUN_ID_PREFIX}${agent}|${forId ?? ''}`;\n}\n\n/**\n * Whether a converted row is one of a turn the conversion split by answerer.\n * Reads `metadata` optionally, as `collectMessageAnswers` does: a message\n * another runtime (or a test) builds may carry none.\n */\nfunction isSplitAnswerRow(message: ThreadMessage): boolean {\n const runId = message.role === 'assistant' ? message.metadata?.runId : undefined;\n return typeof runId === 'string' && runId.startsWith(ANSWER_ROW_RUN_ID_PREFIX);\n}\n\nconst isSameAnswerer = (a: DeepAgentAnsweredBy, b: DeepAgentAnsweredBy): boolean =>\n a.agent === b.agent && a.for_id === b.for_id;\n\nconst isPersonPrincipal = (id: string | null): id is string =>\n id !== null && id.startsWith(PERSON_PRINCIPAL_PREFIX);\n\n/** The `answered_by` stamp an AI message as the wire carries it holds; `null` for any other message. */\nexport function readLangChainAnswer(\n source: LangChainMessage | undefined\n): DeepAgentAnsweredBy | null {\n if (source?.type !== 'ai') return null;\n const bag = source.additional_kwargs?.metadata;\n return isRecord(bag) ? readAnsweredBy(bag[ATHENA_ANSWERED_BY_KEY]) : null;\n}\n\nfunction collectMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n if (message.role !== 'assistant') return NO_ANSWERS;\n const sources = getExternalStoreMessages<LangChainMessage>(message);\n if (sources.length === 0) {\n const joined = readAnsweredBy(message.metadata?.custom?.[ATHENA_ANSWERED_BY_KEY]);\n return joined === null ? NO_ANSWERS : [joined];\n }\n let answers: DeepAgentAnsweredBy[] | null = null;\n for (const source of sources) {\n const answer = readLangChainAnswer(source);\n if (answer === null || answers?.some((seen) => isSameAnswerer(seen, answer))) continue;\n answers ??= [];\n answers.push(answer);\n }\n return answers ?? NO_ANSWERS;\n}\n\nconst answersByMessage = new WeakMap<ThreadMessage, readonly DeepAgentAnsweredBy[]>();\n\n/**\n * The answers one thread message holds: each agent that answered in it and\n * whom it worked for, in the order they answered, one entry per agent and\n * principal.\n *\n * Read off the message's bound source messages, not its joined\n * `metadata.custom`: assistant-ui joins consecutive AI messages into one\n * turn, and its joined metadata keeps only the last stamp. The deep-agent\n * conversion gives each agent that answers one message in turn (D13 several\n * agents, one message) a row of its own (`answer-rows.ts`), so a row\n * normally holds one answer; a conversion without that split (another\n * pipeline, a stamp that never landed) still names every agent of the row.\n * A message with no bound sources (another runtime) falls back to the joined\n * value. Cached per message object: a settled message keeps its object\n * across a streamed token, so a thread-wide scan per token reads only the\n * message that changed.\n */\nexport function readMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n const cached = answersByMessage.get(message);\n if (cached !== undefined) return cached;\n const answers = collectMessageAnswers(message);\n answersByMessage.set(message, answers);\n return answers;\n}\n\n/**\n * The thread-wide half of the label decision: whether the transcript's\n * answers name two or more agents, or two or more principals they worked\n * for. Then every answer is labelled; a 1:1 chat with one agent working for\n * one person looks as it did before labels. Unstamped answers count for\n * nothing.\n *\n * It decides over the messages the client holds (on a windowed wire, the\n * pages loaded so far), as the human-author avatars do: a page of older\n * history that brings another agent or person turns the labels on. Each\n * label still names its own answer's stamp, so loading history never changes\n * what an answer is attributed to, only whether the line shows.\n *\n * A turn the conversion split into one row per answerer turns the labels on\n * too, before the next agent's stamp lands: while several agents answer one\n * message, the host's record already says a second agent is answering, so\n * the first agent's answer is labelled from the moment the second one's row\n * appears.\n */\nexport function threadShowsAnswerLabels(messages: readonly ThreadMessage[]): boolean {\n let first: DeepAgentAnsweredBy | null = null;\n for (const message of messages) {\n if (isSplitAnswerRow(message)) return true;\n for (const answer of readMessageAnswers(message)) {\n if (first === null) {\n first = answer;\n } else if (!isSameAnswerer(first, answer)) {\n return true;\n }\n }\n }\n return false;\n}\n\n/**\n * Whether a message's answers are labelled: always once the thread's answers\n * name several agents or principals (`threadShowsLabels`), and otherwise when\n * one of them worked for a person other than the viewer. Without a known\n * viewer that last rule is off, so a 1:1 chat never flashes labels while the\n * viewer loads.\n */\nexport function showsAnswerLabel({\n answers,\n threadShowsLabels,\n viewerId,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n threadShowsLabels: boolean;\n viewerId: string | null;\n}): boolean {\n if (answers.length === 0) return false;\n if (threadShowsLabels) return true;\n return (\n viewerId !== null &&\n answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId)\n );\n}\n\nfunction answererName(answer: DeepAgentAnsweredBy): string {\n if (answer.agent_name !== null) return answer.agent_name;\n return answer.agent.startsWith(ASSISTANT_AGENT_PREFIX)\n ? ATHENA_ASSISTANT_NAME\n : UNNAMED_AGENT_NAME;\n}\n\nfunction forWhomName(answer: DeepAgentAnsweredBy, viewerId: string | null): string | null {\n if (answer.for_id !== null && answer.for_id === viewerId) return 'you';\n if (answer.for_name !== null && isPersonPrincipal(answer.for_id)) return answer.for_name;\n return null;\n}\n\nfunction joinNames(names: readonly string[]): string {\n if (names.length <= 1) return names[0] ?? '';\n return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;\n}\n\n/**\n * The label's words: who answered, then whom for. \"Finance · for you\",\n * \"Athena · for Priya Shah\", \"Finance\" (a run that worked for nobody, or for\n * an agent's own run). The agent is the collab agent's name, else\n * {@link ATHENA_ASSISTANT_NAME} for someone's own assistant, else \"Agent\".\n * Several agents answering for the same principal read \"Finance and Legal ·\n * for you\"; answers for different principals are listed apart, even when the\n * people share a name (\"Finance · for Alex, Legal · for Alex\"). `null` when\n * the message holds no answers.\n */\nexport function answerLabelText({\n answers,\n viewerId,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n viewerId: string | null;\n}): string | null {\n if (answers.length === 0) return null;\n // One group per principal (nobody is one too), never per displayed name: two\n // people who share a name stay two recipients.\n const groups: { forId: string | null; names: string[]; forWhom: string | null }[] = [];\n for (const answer of answers) {\n const name = answererName(answer);\n const forWhom = forWhomName(answer, viewerId);\n const group = groups.find((candidate) => candidate.forId === answer.for_id);\n if (group === undefined) {\n groups.push({ forId: answer.for_id, names: [name], forWhom });\n continue;\n }\n if (!group.names.includes(name)) group.names.push(name);\n // A recipient one answer left unnamed takes the name a later answer for\n // the same principal carries. The first name found wins: answers stamped\n // under two names for one id (a rename between runs) read as the first.\n group.forWhom ??= forWhom;\n }\n return groups\n .map(({ names, forWhom }) =>\n forWhom === null ? joinNames(names) : `${joinNames(names)}${LABEL_SEPARATOR}for ${forWhom}`\n )\n .join(', ');\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"answer-label.js","sources":["../../../../agent-runtime/src/answer-label.ts"],"sourcesContent":["import { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\nimport type { LangChainMessage } from '@assistant-ui/react-langgraph';\n\n/**\n * `metadata.custom` key naming the agent that answered an AI message and whom\n * its run worked for (identity v7 D13: \"each answer is labelled with its\n * agent, 'for <author>'\").\n *\n * The backend stamps it on every answer a deep-agent run's model produces\n * (`agora/.../conversation_agents/answer_label.py`), under the\n * `additional_kwargs.metadata` bag the converter exposes as\n * `message.metadata.custom`, and mirrors it onto the live wire once the\n * model's turn lands. Only the server writes it. Answers written before the\n * stamp existed carry none, and get no label.\n */\nexport const ATHENA_ANSWERED_BY_KEY = 'answered_by';\n\n/** The agent that answered and whom its run worked for, as the backend stamps them. */\nexport interface DeepAgentAnsweredBy {\n /** `collab_agent:<asset id>` for a team agent, `assistant:<principal id>` for someone's own assistant. */\n agent: string;\n /** The collab agent's name when it answered; `null` for an assistant, which clients name. */\n agent_name: string | null;\n /** Whom the run worked for: a person (`user_…`), an agent's or automation's own run, or nobody (`null`). */\n for_id: string | null;\n /** That person's display name when it answered, else `null`. */\n for_name: string | null;\n}\n\n/** What a label calls someone's own assistant: the managed default agent's product name. */\nexport const ATHENA_ASSISTANT_NAME = 'Athena';\n\nconst ASSISTANT_AGENT_PREFIX = 'assistant:';\nconst PERSON_PRINCIPAL_PREFIX = 'user_';\nconst UNNAMED_AGENT_NAME = 'Agent';\nconst LABEL_SEPARATOR = ' · ';\nconst NO_ANSWERS: readonly DeepAgentAnsweredBy[] = Object.freeze([]);\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst optionalId = (value: unknown): string | null =>\n typeof value === 'string' && value.length > 0 ? value : null;\n\nconst optionalName = (value: unknown): string | null => {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n};\n\n/**\n * Parse an `answered_by` stamp off a wire value.\n *\n * A stamp without a string `agent` is no stamp at all; absent or malformed\n * optional fields read as `null`, so a partly malformed stamp still names its\n * agent and never names a person it can't.\n */\nexport function readAnsweredBy(value: unknown): DeepAgentAnsweredBy | null {\n if (!isRecord(value)) return null;\n const agent = optionalId(value.agent);\n if (agent === null) return null;\n return {\n agent,\n agent_name: optionalName(value.agent_name),\n for_id: optionalId(value.for_id),\n for_name: optionalName(value.for_name),\n };\n}\n\n/**\n * The run id an answer's row carries when the deep-agent conversion split a\n * turn several answerers wrote into one row per answerer (`answer-rows.ts`):\n * one per agent and principal.\n */\nconst ANSWER_ROW_RUN_ID_PREFIX = 'answered_by:';\n\n/** The row run id of an answer by `agent` for `forId` (see `withAnswerRows`). */\nexport function answerRowRunId(agent: string, forId: string | null): string {\n return `${ANSWER_ROW_RUN_ID_PREFIX}${agent}|${forId ?? ''}`;\n}\n\n/**\n * Whether a converted row is one of a turn the conversion split by answerer.\n * Reads `metadata` optionally, as `collectMessageAnswers` does: a message\n * another runtime (or a test) builds may carry none.\n */\nfunction isSplitAnswerRow(message: ThreadMessage): boolean {\n const runId = message.role === 'assistant' ? message.metadata?.runId : undefined;\n return typeof runId === 'string' && runId.startsWith(ANSWER_ROW_RUN_ID_PREFIX);\n}\n\nconst isSameAnswerer = (a: DeepAgentAnsweredBy, b: DeepAgentAnsweredBy): boolean =>\n a.agent === b.agent && a.for_id === b.for_id;\n\nconst isPersonPrincipal = (id: string | null): id is string =>\n id !== null && id.startsWith(PERSON_PRINCIPAL_PREFIX);\n\n/** The `answered_by` stamp an AI message as the wire carries it holds; `null` for any other message. */\nexport function readLangChainAnswer(\n source: LangChainMessage | undefined\n): DeepAgentAnsweredBy | null {\n if (source?.type !== 'ai') return null;\n const bag = source.additional_kwargs?.metadata;\n return isRecord(bag) ? readAnsweredBy(bag[ATHENA_ANSWERED_BY_KEY]) : null;\n}\n\nfunction collectMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n if (message.role !== 'assistant') return NO_ANSWERS;\n const sources = getExternalStoreMessages<LangChainMessage>(message);\n if (sources.length === 0) {\n const joined = readAnsweredBy(message.metadata?.custom?.[ATHENA_ANSWERED_BY_KEY]);\n return joined === null ? NO_ANSWERS : [joined];\n }\n let answers: DeepAgentAnsweredBy[] | null = null;\n for (const source of sources) {\n const answer = readLangChainAnswer(source);\n if (answer === null || answers?.some((seen) => isSameAnswerer(seen, answer))) continue;\n answers ??= [];\n answers.push(answer);\n }\n return answers ?? NO_ANSWERS;\n}\n\nconst answersByMessage = new WeakMap<ThreadMessage, readonly DeepAgentAnsweredBy[]>();\n\n/**\n * The answers one thread message holds: each agent that answered in it and\n * whom it worked for, in the order they answered, one entry per agent and\n * principal.\n *\n * Read off the message's bound source messages, not its joined\n * `metadata.custom`: assistant-ui joins consecutive AI messages into one\n * turn, and its joined metadata keeps only the last stamp. The deep-agent\n * conversion gives each agent that answers one message in turn (D13 several\n * agents, one message) a row of its own (`answer-rows.ts`), so a row\n * normally holds one answer; a conversion without that split (another\n * pipeline, a stamp that never landed) still names every agent of the row.\n * A message with no bound sources (another runtime) falls back to the joined\n * value. Cached per message object: a settled message keeps its object\n * across a streamed token, so a thread-wide scan per token reads only the\n * message that changed.\n */\nexport function readMessageAnswers(message: ThreadMessage): readonly DeepAgentAnsweredBy[] {\n const cached = answersByMessage.get(message);\n if (cached !== undefined) return cached;\n const answers = collectMessageAnswers(message);\n answersByMessage.set(message, answers);\n return answers;\n}\n\n/**\n * The thread-wide half of the label decision: whether the transcript's\n * answers name two or more agents, or two or more principals they worked\n * for. Then every answer is labelled; a 1:1 chat with one agent working for\n * one person looks as it did before labels. Unstamped answers count for\n * nothing.\n *\n * It decides over the messages the client holds (on a windowed wire, the\n * pages loaded so far), as the human-author avatars do: a page of older\n * history that brings another agent or person turns the labels on. Each\n * label still names its own answer's stamp, so loading history never changes\n * what an answer is attributed to, only whether the line shows.\n *\n * A turn the conversion split into one row per answerer turns the labels on\n * too, before the next agent's stamp lands: while several agents answer one\n * message, the host's record already says a second agent is answering, so\n * the first agent's answer is labelled from the moment the second one's row\n * appears.\n */\nexport function threadShowsAnswerLabels(messages: readonly ThreadMessage[]): boolean {\n let first: DeepAgentAnsweredBy | null = null;\n for (const message of messages) {\n if (isSplitAnswerRow(message)) return true;\n for (const answer of readMessageAnswers(message)) {\n if (first === null) {\n first = answer;\n } else if (!isSameAnswerer(first, answer)) {\n return true;\n }\n }\n }\n return false;\n}\n\n/**\n * Whether a message's answers are labelled: always once the thread's answers\n * name several agents or principals (`threadShowsLabels`), and otherwise when\n * one of them worked for a person other than the viewer. With\n * `labelsOtherAgents`, also when one of them was written by any agent other\n * than the viewer's own assistant: a surface where the person names a team\n * agent for one message (Olympus's composer chips) shows who answered it even\n * in a 1:1 chat, where the answer would otherwise read as Athena's. Without a\n * known viewer both viewer rules are off, so a 1:1 chat never flashes labels\n * while the viewer loads.\n */\nexport function showsAnswerLabel({\n answers,\n threadShowsLabels,\n viewerId,\n labelsOtherAgents = false,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n threadShowsLabels: boolean;\n viewerId: string | null;\n /** Label an answer written by any agent other than the viewer's own assistant. */\n labelsOtherAgents?: boolean;\n}): boolean {\n if (answers.length === 0) return false;\n if (threadShowsLabels) return true;\n if (viewerId === null) return false;\n if (labelsOtherAgents) {\n const ownAssistant = `${ASSISTANT_AGENT_PREFIX}${viewerId}`;\n if (answers.some((answer) => answer.agent !== ownAssistant)) return true;\n }\n return answers.some((answer) => isPersonPrincipal(answer.for_id) && answer.for_id !== viewerId);\n}\n\nfunction answererName(answer: DeepAgentAnsweredBy): string {\n if (answer.agent_name !== null) return answer.agent_name;\n return answer.agent.startsWith(ASSISTANT_AGENT_PREFIX)\n ? ATHENA_ASSISTANT_NAME\n : UNNAMED_AGENT_NAME;\n}\n\nfunction forWhomName(answer: DeepAgentAnsweredBy, viewerId: string | null): string | null {\n if (answer.for_id !== null && answer.for_id === viewerId) return 'you';\n if (answer.for_name !== null && isPersonPrincipal(answer.for_id)) return answer.for_name;\n return null;\n}\n\nfunction joinNames(names: readonly string[]): string {\n if (names.length <= 1) return names[0] ?? '';\n return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;\n}\n\n/**\n * The label's words: who answered, then whom for. \"Finance · for you\",\n * \"Athena · for Priya Shah\", \"Finance\" (a run that worked for nobody, or for\n * an agent's own run). The agent is the collab agent's name, else\n * {@link ATHENA_ASSISTANT_NAME} for someone's own assistant, else \"Agent\".\n * Several agents answering for the same principal read \"Finance and Legal ·\n * for you\"; answers for different principals are listed apart, even when the\n * people share a name (\"Finance · for Alex, Legal · for Alex\"). `null` when\n * the message holds no answers.\n */\nexport function answerLabelText({\n answers,\n viewerId,\n}: {\n answers: readonly DeepAgentAnsweredBy[];\n viewerId: string | null;\n}): string | null {\n if (answers.length === 0) return null;\n // One group per principal (nobody is one too), never per displayed name: two\n // people who share a name stay two recipients.\n const groups: { forId: string | null; names: string[]; forWhom: string | null }[] = [];\n for (const answer of answers) {\n const name = answererName(answer);\n const forWhom = forWhomName(answer, viewerId);\n const group = groups.find((candidate) => candidate.forId === answer.for_id);\n if (group === undefined) {\n groups.push({ forId: answer.for_id, names: [name], forWhom });\n continue;\n }\n if (!group.names.includes(name)) group.names.push(name);\n // A recipient one answer left unnamed takes the name a later answer for\n // the same principal carries. The first name found wins: answers stamped\n // under two names for one id (a rename between runs) read as the first.\n group.forWhom ??= forWhom;\n }\n return groups\n .map(({ names, forWhom }) =>\n forWhom === null ? joinNames(names) : `${joinNames(names)}${LABEL_SEPARATOR}for ${forWhom}`\n )\n .join(', ');\n}\n"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const messageWindow = require("./message-window.cjs");
4
+ const historyStore = require("./history-store.cjs");
4
5
  const protocol = require("./protocol.cjs");
5
6
  const statewireAttachObserver = require("./statewire-attach-observer.cjs");
6
7
  const statewireTransport = require("./statewire-transport.cjs");
@@ -39,9 +40,11 @@ function createDeepAgentAttachTransport({
39
40
  await retryGate.waitForDial(url);
40
41
  onAuthResolve == null ? void 0 : onAuthResolve(ctx);
41
42
  return messageWindow.withDeclaredInterests(
42
- messageWindow.withDeclaredMessageWindow(
43
- protocol.withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),
44
- messageWindow$1
43
+ historyStore.withDeclaredPagedHistory(
44
+ messageWindow.withDeclaredMessageWindow(
45
+ protocol.withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),
46
+ messageWindow$1
47
+ )
45
48
  ),
46
49
  (declaredInterests == null ? void 0 : declaredInterests()) ?? null
47
50
  );
@@ -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 DeepAgentInterestDeclaration,\n type DeepAgentMessageWindowDeclaration,\n withDeclaredInterests,\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 * The view this thread's attach holds, read at every attach's header\n * resolve (`x-athena-deep-agent-interests`, see `message-window.ts`): a\n * re-attach's hello then carries the pages it had loaded on whichever host\n * instance it lands on. `null` (a first attach) declares nothing. Not part\n * of a pooled dial, which never held a view.\n */\n declaredInterests?: (() => DeepAgentInterestDeclaration | null) | undefined;\n /**\n * The surface pages cut lanes (see `DeepAgentProtocolDeclaration`), so its\n * attach declares the lane-cut contract version. Part of the recipe so a\n * pooled dial declares what the thread's own attach would; omitted, the\n * default version is declared.\n */\n laneHistory?: boolean | 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 the surface'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 declaredInterests,\n laneHistory = false,\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 withDeclaredInterests(\n withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),\n messageWindow,\n ),\n declaredInterests?.() ?? null,\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 /** The surface pages cut lanes: declare what its attach declares (see `DeepAgentAttachTransportOptions`). */\n laneHistory?: boolean | undefined;\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 laneHistory = false,\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 { laneHistory },\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","withDeclaredInterests","withDeclaredMessageWindow","withDeclaredProtocolVersion","statewireTransport"],"mappings":";;;;;;AA8BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AA2DO,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;AAAA,EACA,cAAc;AAChB,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,cAAAA;AAAAA,UACEC,SAAAA,4BAA4B,MAAM,QAAQ,GAAG,GAAG,EAAE,aAAa;AAAA,UAC/DL;AAAAA,QAAA;AAAA,SAEF,6DAAyB;AAAA,MAAA;AAAA,IAE7B;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,SAAOM,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 DeepAgentInterestDeclaration,\n type DeepAgentMessageWindowDeclaration,\n withDeclaredInterests,\n withDeclaredMessageWindow,\n} from \"./message-window\";\nimport { withDeclaredPagedHistory } from \"./history-store\";\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 * The view this thread's attach holds, read at every attach's header\n * resolve (`x-athena-deep-agent-interests`, see `message-window.ts`): a\n * re-attach's hello then carries the pages it had loaded on whichever host\n * instance it lands on. `null` (a first attach) declares nothing. Not part\n * of a pooled dial, which never held a view.\n */\n declaredInterests?: (() => DeepAgentInterestDeclaration | null) | undefined;\n /**\n * The surface pages cut lanes (see `DeepAgentProtocolDeclaration`), so its\n * attach declares the lane-cut contract version. Part of the recipe so a\n * pooled dial declares what the thread's own attach would; omitted, the\n * default version is declared.\n */\n laneHistory?: boolean | 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 the surface'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 declaredInterests,\n laneHistory = false,\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 withDeclaredInterests(\n withDeclaredPagedHistory(\n withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),\n messageWindow,\n ),\n ),\n declaredInterests?.() ?? null,\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 /** The surface pages cut lanes: declare what its attach declares (see `DeepAgentAttachTransportOptions`). */\n laneHistory?: boolean | undefined;\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 laneHistory = false,\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 { laneHistory },\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","withDeclaredInterests","withDeclaredPagedHistory","withDeclaredMessageWindow","withDeclaredProtocolVersion","statewireTransport"],"mappings":";;;;;;;AA+BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AA2DO,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;AAAA,EACA,cAAc;AAChB,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,aAAAA;AAAAA,UACEC,cAAAA;AAAAA,YACEC,SAAAA,4BAA4B,MAAM,QAAQ,GAAG,GAAG,EAAE,aAAa;AAAA,YAC/DN;AAAAA,UAAA;AAAA,QACF;AAAA,SAEF,6DAAyB;AAAA,MAAA;AAAA,IAE7B;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,SAAOO,mBAAAA,mBAA2B,SAAS,EAAE,KAAK,QAAQ;AAC5D;;;"}
@@ -1,4 +1,5 @@
1
1
  import { withDeclaredInterests, withDeclaredMessageWindow } from "./message-window.js";
2
+ import { withDeclaredPagedHistory } from "./history-store.js";
2
3
  import { withDeclaredProtocolVersion } from "./protocol.js";
3
4
  import { createAttachInstrumentation, attachRetryGate } from "./statewire-attach-observer.js";
4
5
  import { statewireTransport, resolveStatewireTransportKind } from "./statewire-transport.js";
@@ -37,9 +38,11 @@ function createDeepAgentAttachTransport({
37
38
  await retryGate.waitForDial(url);
38
39
  onAuthResolve == null ? void 0 : onAuthResolve(ctx);
39
40
  return withDeclaredInterests(
40
- withDeclaredMessageWindow(
41
- withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),
42
- messageWindow
41
+ withDeclaredPagedHistory(
42
+ withDeclaredMessageWindow(
43
+ withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),
44
+ messageWindow
45
+ )
43
46
  ),
44
47
  (declaredInterests == null ? void 0 : declaredInterests()) ?? null
45
48
  );
@@ -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 DeepAgentInterestDeclaration,\n type DeepAgentMessageWindowDeclaration,\n withDeclaredInterests,\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 * The view this thread's attach holds, read at every attach's header\n * resolve (`x-athena-deep-agent-interests`, see `message-window.ts`): a\n * re-attach's hello then carries the pages it had loaded on whichever host\n * instance it lands on. `null` (a first attach) declares nothing. Not part\n * of a pooled dial, which never held a view.\n */\n declaredInterests?: (() => DeepAgentInterestDeclaration | null) | undefined;\n /**\n * The surface pages cut lanes (see `DeepAgentProtocolDeclaration`), so its\n * attach declares the lane-cut contract version. Part of the recipe so a\n * pooled dial declares what the thread's own attach would; omitted, the\n * default version is declared.\n */\n laneHistory?: boolean | 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 the surface'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 declaredInterests,\n laneHistory = false,\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 withDeclaredInterests(\n withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),\n messageWindow,\n ),\n declaredInterests?.() ?? null,\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 /** The surface pages cut lanes: declare what its attach declares (see `DeepAgentAttachTransportOptions`). */\n laneHistory?: boolean | undefined;\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 laneHistory = false,\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 { laneHistory },\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":";;;;AA8BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AA2DO,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;AAAA,EACA,cAAc;AAChB,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;AAAA,UACE,4BAA4B,MAAM,QAAQ,GAAG,GAAG,EAAE,aAAa;AAAA,UAC/D;AAAA,QAAA;AAAA,SAEF,6DAAyB;AAAA,MAAA;AAAA,IAE7B;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 DeepAgentInterestDeclaration,\n type DeepAgentMessageWindowDeclaration,\n withDeclaredInterests,\n withDeclaredMessageWindow,\n} from \"./message-window\";\nimport { withDeclaredPagedHistory } from \"./history-store\";\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 * The view this thread's attach holds, read at every attach's header\n * resolve (`x-athena-deep-agent-interests`, see `message-window.ts`): a\n * re-attach's hello then carries the pages it had loaded on whichever host\n * instance it lands on. `null` (a first attach) declares nothing. Not part\n * of a pooled dial, which never held a view.\n */\n declaredInterests?: (() => DeepAgentInterestDeclaration | null) | undefined;\n /**\n * The surface pages cut lanes (see `DeepAgentProtocolDeclaration`), so its\n * attach declares the lane-cut contract version. Part of the recipe so a\n * pooled dial declares what the thread's own attach would; omitted, the\n * default version is declared.\n */\n laneHistory?: boolean | 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 the surface'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 declaredInterests,\n laneHistory = false,\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 withDeclaredInterests(\n withDeclaredPagedHistory(\n withDeclaredMessageWindow(\n withDeclaredProtocolVersion(await headers(ctx), { laneHistory }),\n messageWindow,\n ),\n ),\n declaredInterests?.() ?? null,\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 /** The surface pages cut lanes: declare what its attach declares (see `DeepAgentAttachTransportOptions`). */\n laneHistory?: boolean | undefined;\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 laneHistory = false,\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 { laneHistory },\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":";;;;;AA+BO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGW;AACT,SAAO,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,IAAI,mBAAmB,QAAQ,CAAC;AACvE;AA2DO,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;AAAA,EACA,cAAc;AAChB,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;AAAA,UACE;AAAA,YACE,4BAA4B,MAAM,QAAQ,GAAG,GAAG,EAAE,aAAa;AAAA,YAC/D;AAAA,UAAA;AAAA,QACF;AAAA,SAEF,6DAAyB;AAAA,MAAA;AAAA,IAE7B;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;"}