@larkup/workspace-server 0.0.0-stage → 0.2.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 (180) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +2 -2
  3. package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
  4. package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
  5. package/dist-types/app/api/chat/route.d.ts +3 -0
  6. package/dist-types/app/api/chat/route.d.ts.map +1 -0
  7. package/dist-types/app/api/chat/tools.d.ts +67 -0
  8. package/dist-types/app/api/chat/tools.d.ts.map +1 -0
  9. package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
  10. package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
  11. package/dist-types/app/api/config/verify/route.d.ts +3 -0
  12. package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
  13. package/dist-types/app/api/describe-image/route.d.ts +4 -0
  14. package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
  15. package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
  16. package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
  17. package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
  18. package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
  19. package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
  20. package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
  21. package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
  22. package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
  23. package/dist-types/app/api/document/sign/route.d.ts +2 -0
  24. package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
  25. package/dist-types/app/api/files/remote/route.d.ts +5 -0
  26. package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
  27. package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
  28. package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
  29. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
  30. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
  31. package/dist-types/app/api/mcp/route.d.ts +4 -0
  32. package/dist-types/app/api/mcp/route.d.ts.map +1 -0
  33. package/dist-types/app/api/parse-file/route.d.ts +6 -0
  34. package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
  35. package/dist-types/app/api/pdf-page/route.d.ts +4 -0
  36. package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
  37. package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
  38. package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
  39. package/dist-types/app/api/search/bing/route.d.ts +5 -0
  40. package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
  41. package/dist-types/app/api/search/brave/route.d.ts +5 -0
  42. package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
  43. package/dist-types/app/api/search/exa/route.d.ts +5 -0
  44. package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
  45. package/dist-types/app/api/search/google/route.d.ts +30 -0
  46. package/dist-types/app/api/search/google/route.d.ts.map +1 -0
  47. package/dist-types/app/api/search/route.d.ts +7 -0
  48. package/dist-types/app/api/search/route.d.ts.map +1 -0
  49. package/dist-types/app/api/search/tavily/route.d.ts +5 -0
  50. package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
  51. package/dist-types/app/api/search/verify/route.d.ts +4 -0
  52. package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
  53. package/dist-types/app/api/tabular/route.d.ts +9 -0
  54. package/dist-types/app/api/tabular/route.d.ts.map +1 -0
  55. package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
  56. package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
  57. package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
  58. package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
  59. package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
  60. package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
  61. package/dist-types/lib/chat/image-description.d.ts +10 -0
  62. package/dist-types/lib/chat/image-description.d.ts.map +1 -0
  63. package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
  64. package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
  65. package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
  66. package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
  67. package/dist-types/lib/chat/message-input.d.ts +8 -0
  68. package/dist-types/lib/chat/message-input.d.ts.map +1 -0
  69. package/dist-types/lib/chat/model-provider.d.ts +23 -0
  70. package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
  71. package/dist-types/lib/chat/response-style.d.ts +2 -0
  72. package/dist-types/lib/chat/response-style.d.ts.map +1 -0
  73. package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
  74. package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
  75. package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
  76. package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
  77. package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
  78. package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
  79. package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
  80. package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
  81. package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
  82. package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
  83. package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
  84. package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
  85. package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
  86. package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
  87. package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
  88. package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
  89. package/dist-types/lib/chat/tool-context.d.ts +72 -0
  90. package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
  91. package/dist-types/lib/chat/tool-registry.d.ts +3 -0
  92. package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
  93. package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
  94. package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
  95. package/dist-types/lib/chat/visual-routing.d.ts +53 -0
  96. package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
  97. package/dist-types/lib/enterprise-client.d.ts +31 -0
  98. package/dist-types/lib/enterprise-client.d.ts.map +1 -0
  99. package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
  100. package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
  101. package/dist-types/lib/media/knowledge.d.ts +47 -0
  102. package/dist-types/lib/media/knowledge.d.ts.map +1 -0
  103. package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
  104. package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
  105. package/dist-types/lib/media/source-identity.d.ts +12 -0
  106. package/dist-types/lib/media/source-identity.d.ts.map +1 -0
  107. package/dist-types/lib/media/source-utils.d.ts +157 -0
  108. package/dist-types/lib/media/source-utils.d.ts.map +1 -0
  109. package/dist-types/lib/media/video/quick-look.d.ts +60 -0
  110. package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
  111. package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
  112. package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
  113. package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
  114. package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
  115. package/dist-types/lib/remote-files.d.ts +12 -0
  116. package/dist-types/lib/remote-files.d.ts.map +1 -0
  117. package/dist-types/lib/search/provider-verification.d.ts +5 -0
  118. package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
  119. package/dist-types/runtime-host.d.ts +20 -0
  120. package/dist-types/runtime-host.d.ts.map +1 -0
  121. package/package.json +338 -3
  122. package/src/app/api/chat/feedback/route.ts +77 -0
  123. package/src/app/api/chat/route.ts +2277 -0
  124. package/src/app/api/chat/tools.ts +3563 -0
  125. package/src/app/api/config/test-embedding/route.ts +39 -0
  126. package/src/app/api/config/verify/route.ts +309 -0
  127. package/src/app/api/describe-image/route.ts +108 -0
  128. package/src/app/api/doc-editor/edit/route.ts +87 -0
  129. package/src/app/api/doc-editor/export/route.ts +32 -0
  130. package/src/app/api/doc-editor/parse/route.ts +74 -0
  131. package/src/app/api/doc-editor/restore/route.ts +70 -0
  132. package/src/app/api/document/sign/route.ts +55 -0
  133. package/src/app/api/files/remote/route.ts +32 -0
  134. package/src/app/api/mcp/[connectionId]/route.ts +29 -0
  135. package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
  136. package/src/app/api/mcp/route.ts +22 -0
  137. package/src/app/api/parse-file/route.ts +86 -0
  138. package/src/app/api/pdf-page/route.ts +40 -0
  139. package/src/app/api/proxy/verify/route.ts +78 -0
  140. package/src/app/api/search/bing/route.ts +86 -0
  141. package/src/app/api/search/brave/route.ts +88 -0
  142. package/src/app/api/search/exa/route.ts +100 -0
  143. package/src/app/api/search/google/route.ts +147 -0
  144. package/src/app/api/search/route.ts +28 -0
  145. package/src/app/api/search/tavily/route.ts +93 -0
  146. package/src/app/api/search/verify/route.ts +27 -0
  147. package/src/app/api/tabular/route.ts +68 -0
  148. package/src/lib/chat/assistant-source-scope.ts +32 -0
  149. package/src/lib/chat/conversation-memory.ts +621 -0
  150. package/src/lib/chat/gateway-fallbacks.ts +12 -0
  151. package/src/lib/chat/image-description.ts +21 -0
  152. package/src/lib/chat/media-retrieval-routing.ts +37 -0
  153. package/src/lib/chat/media-source-routing.ts +100 -0
  154. package/src/lib/chat/message-input.ts +48 -0
  155. package/src/lib/chat/model-provider.ts +157 -0
  156. package/src/lib/chat/response-style.ts +3 -0
  157. package/src/lib/chat/retrieval-ranking.ts +126 -0
  158. package/src/lib/chat/retrieval-routing.ts +221 -0
  159. package/src/lib/chat/tabular-dataset-routing.ts +93 -0
  160. package/src/lib/chat/tabular-query-plan.ts +195 -0
  161. package/src/lib/chat/tabular-routing.ts +122 -0
  162. package/src/lib/chat/tabular-sandbox-files.ts +78 -0
  163. package/src/lib/chat/tabular-visualization.ts +165 -0
  164. package/src/lib/chat/tool-call-repair.ts +84 -0
  165. package/src/lib/chat/tool-context.ts +1222 -0
  166. package/src/lib/chat/tool-registry.ts +6 -0
  167. package/src/lib/chat/video-rag-routing.ts +149 -0
  168. package/src/lib/chat/visual-routing.ts +225 -0
  169. package/src/lib/enterprise-client.ts +96 -0
  170. package/src/lib/marketplace/tool-runtime-config.ts +155 -0
  171. package/src/lib/media/knowledge.ts +489 -0
  172. package/src/lib/media/pdf-inspection.ts +280 -0
  173. package/src/lib/media/source-identity.ts +50 -0
  174. package/src/lib/media/source-utils.ts +1156 -0
  175. package/src/lib/media/video/quick-look.ts +368 -0
  176. package/src/lib/media/video-intelligence-adapter.ts +1946 -0
  177. package/src/lib/media/video-intelligence-connection.ts +151 -0
  178. package/src/lib/remote-files.ts +182 -0
  179. package/src/lib/search/provider-verification.ts +95 -0
  180. package/src/runtime-host.ts +31 -0
@@ -0,0 +1,2277 @@
1
+ import {
2
+ generateText,
3
+ streamText,
4
+ convertToModelMessages,
5
+ createUIMessageStream,
6
+ createUIMessageStreamResponse,
7
+ stepCountIs,
8
+ type UIMessage,
9
+ } from 'ai';
10
+ import { readConfig } from '@larkup/core/config-store';
11
+ import { getModelsByType } from '@larkup/core/models-cache';
12
+ import {
13
+ toChatDescriptor,
14
+ getDefaultChatModel,
15
+ normalizeNativeChatModelId,
16
+ } from '@larkup/core/chat-models/registry';
17
+ import { listTabularDatasets, resolveTabularDatasetGroups } from '@larkup/core/tabular-store';
18
+ import { readDocuments } from '@larkup/core/documents-store';
19
+ import { readGroups } from '@larkup/core/groups-store';
20
+ import {
21
+ filterDocumentsAvailableToAssistant,
22
+ filterGroupOwnedDataAvailableToAssistant,
23
+ } from '@larkup/core/assistant-data-scope';
24
+ import { runWithProject } from '@larkup/core/project-store';
25
+ import {
26
+ deleteGroundedAnswerCacheEntry,
27
+ getGroundedAnswerCacheEntry,
28
+ } from '@larkup/core/grounded-answer-cache';
29
+ import { openMcpTools } from '@larkup/core/mcp-store';
30
+ import { createOpenAI } from '@ai-sdk/openai';
31
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
32
+ import { createCohere } from '@ai-sdk/cohere';
33
+ import { createMistral } from '@ai-sdk/mistral';
34
+ import { createDeepSeek } from '@ai-sdk/deepseek';
35
+ import { createAnthropic } from '@ai-sdk/anthropic';
36
+ import { createGateway } from '@ai-sdk/gateway';
37
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
38
+ import type { CustomModelConfig } from '@larkup/core/types';
39
+ import type { VideoInvestigationDirective } from '@larkup/core/video-knowledge/query-planner';
40
+ import { getChatTools } from './tools';
41
+ import { gatewayProviderOptions } from '../../../lib/chat/gateway-fallbacks';
42
+ import { retrievalToolsForStep } from '../../../lib/chat/retrieval-routing';
43
+ import { collectExhaustiveVideoEvidencePages } from '../../../lib/chat/video-rag-routing';
44
+ import {
45
+ extractConversationEvidence,
46
+ extractLatestTabularExportResult,
47
+ contextualizeKnowledgeFollowUpQuery,
48
+ formatConversationEvidence,
49
+ findImmediateExactGroundedAnswer,
50
+ isImagePreviewFollowUp,
51
+ isTabularFollowUp,
52
+ tabularEvidenceDatasetIsAvailable,
53
+ continuesRecentMediaTopic,
54
+ resolveParallelPreferenceQuestion,
55
+ } from '../../../lib/chat/conversation-memory';
56
+ import { PERSONALIZED_RESPONSE_STYLE } from '../../../lib/chat/response-style';
57
+ import {
58
+ compactToolContextForModel,
59
+ collectAnswerLevelMediaStatements,
60
+ collectObservedSubjectLedger,
61
+ containsAnswerLevelMediaEvidence,
62
+ collectQuestionMatchedDirectClaims,
63
+ recoverEmptyUIMessageStream,
64
+ formatDirectObservationAnswer,
65
+ formatExhaustiveMediaAnswer,
66
+ formatLocatedObservedSubjectAnswer,
67
+ formatObservedAppearanceAnswer,
68
+ mediaEvidenceRequestsOnlySpeech,
69
+ mediaClaimNeedsCorroboration,
70
+ withFinalAnswerNudge,
71
+ } from '../../../lib/chat/tool-context';
72
+ import {
73
+ createTabularVisualization,
74
+ requestsVisualization,
75
+ } from '../../../lib/chat/tabular-visualization';
76
+ import {
77
+ isLikelyTabularQuestion,
78
+ requiresTabularSandbox,
79
+ tabularToolsForStep,
80
+ } from '../../../lib/chat/tabular-routing';
81
+ import {
82
+ hasRetrievedImageEvidence,
83
+ hasRetrievedPdfEvidence,
84
+ hasNumberedDocumentReference,
85
+ findRetrievedPdfSource,
86
+ latestNumberedDocumentReferenceText,
87
+ preferredPdfPagesForInspection,
88
+ requiresPdfVisualAnalysis,
89
+ requestsImagePresentation,
90
+ shouldInspectRetrievedImage,
91
+ } from '../../../lib/chat/visual-routing';
92
+ import { executableTools } from '../../../lib/chat/tool-registry';
93
+ import {
94
+ answerScopedDataExportSource,
95
+ dataExportNeedsFreshData,
96
+ dataExportUsesAnswerText,
97
+ requestedDataExportFormat,
98
+ } from '@larkup/core/data-export';
99
+ import { assistantSourceScopeFingerprint } from '../../../lib/chat/assistant-source-scope';
100
+ import { normalizeIncomingMessages } from '../../../lib/chat/message-input';
101
+ import { repairChatToolCall } from '../../../lib/chat/tool-call-repair';
102
+ import { explicitMediaEvidenceAssetId } from '../../../lib/chat/media-retrieval-routing';
103
+ import {
104
+ authorizeEnterpriseAiRequest,
105
+ trackEnterpriseAiUsage,
106
+ } from '../../../lib/enterprise-client';
107
+
108
+ // A local CPU inspection can legitimately outlive the default server route
109
+ // budget. It is still bounded by the evidence tool's narrow time range; this
110
+ // merely lets the chat request receive the validated result instead of being
111
+ // cut off mid-analysis.
112
+ export const maxDuration = 480;
113
+
114
+ /**
115
+ * Creates an AI SDK language model instance based on the provider and model ID.
116
+ */
117
+ function createChatModel(
118
+ provider: string,
119
+ modelId: string,
120
+ apiKey?: string,
121
+ customChatModels?: CustomModelConfig[],
122
+ ) {
123
+ if (modelId.startsWith('custom:')) {
124
+ const customName = modelId.slice('custom:'.length);
125
+ const custom = (customChatModels ?? []).find((m) => m.modelName === customName);
126
+ if (custom) {
127
+ const customProvider = createOpenAICompatible({
128
+ name: 'custom_chat_provider',
129
+ baseURL: custom.baseUrl,
130
+ apiKey: custom.apiKey || apiKey || undefined,
131
+ });
132
+ return customProvider(custom.modelName);
133
+ }
134
+ }
135
+
136
+ const modelName = modelId.includes('/') ? modelId.split('/').slice(1).join('/') : modelId;
137
+
138
+ switch (provider) {
139
+ case 'google':
140
+ return createGoogleGenerativeAI({ apiKey })(modelName);
141
+ case 'cohere':
142
+ return createCohere({ apiKey })(modelName);
143
+ case 'mistral':
144
+ return createMistral({ apiKey })(modelName);
145
+ case 'deepseek':
146
+ return createDeepSeek({ apiKey })(modelName);
147
+ case 'anthropic':
148
+ return createAnthropic({ apiKey })(modelName);
149
+ case 'openai':
150
+ return createOpenAI({ apiKey })(modelName);
151
+ case 'vercel_ai_gateway':
152
+ return createGateway({ apiKey })(modelId);
153
+ default:
154
+ throw new Error(`Unsupported chat provider "${provider}".`);
155
+ }
156
+ }
157
+
158
+ const CHAT_POLICY = `
159
+ Answer only from the user's provided material.
160
+
161
+ For each new substantive question, get fresh evidence: use queryTabularData for CSV, Excel, or JSON facts; otherwise use searchKnowledgeBase. The host may reuse an immediately repeated, already-grounded answer only after verifying that its complete Assistant source scope is unchanged. A prior answer is otherwise context for resolving references, never a substitute for current source evidence. Use one focused query first. Use code analysis only when the available data tool cannot answer the calculation. For a join or statistical analysis across files or worksheets, use executeAnalysis with every needed datasetId in datasetIds; read datasets.json to identify their mounted CSV files and never try to emulate the join with a cross-dataset table filter.
162
+
163
+ Do not repeat an evidence tool in the same response. After evidence is returned, answer directly or use one appropriate refinement when the evidence action requests it.
164
+
165
+ Use only returned evidence. Do not guess beyond it, but do not turn an incomplete verification flag into a refusal when the source contains relevant instructions, statements, or a useful trail. Give the most specific answer the available source material establishes and clearly limit only the unsupported portion. Do not recommend a search engine, public website, or outside source unless the user explicitly asks you to search the web. For video/audio, name the relevant timestamp range when one was returned. Speak as someone who watched the material: never expose retrieval, transcripts, frames, visual observations, models, tools, or analysis steps unless the user explicitly asks how you determined the answer.
166
+
167
+ For video/audio, answer in the detected source language returned by the evidence action by default, including notes, speech, and lyrics. Translate only when the user explicitly asks. Never silently default to English.
168
+
169
+ ${PERSONALIZED_RESPONSE_STYLE}
170
+
171
+ Keep the answer brief and direct: give the answer first, then only the essential context. Do not repeat tool output, dump rows, or add a table unless the user asks to see data. Use a short list only when it improves clarity.
172
+
173
+ When the user asks for an Excel, CSV, or PDF export, use createDataExport. Never claim that a file was created and never write a Markdown download link unless the tool returned a real file artifact.
174
+
175
+ When the user asks for every item and the media evidence marks its continuation as exhaustive, include every returned item in chronological order. In that case completeness overrides brevity; deduplicate wording but do not summarize items away.
176
+
177
+ If the user asks for a chart, graph, or visual distribution, ALWAYS use the \`generateVisualization\` tool. Never attempt to draw ASCII charts, output Markdown tables, JSON, or tool-call syntax as a substitute for a chart. Its \`xAxisKey\` and each \`series[].dataKey\` must exactly match the fields in the supplied \`data\` rows; never use placeholder names such as EMPTY, null, or undefined. Give both axes clear human-readable labels and include units when known.
178
+
179
+ When search results identify a PDF source without indexed visuals, use inspectPdfPages before answering. It reads and ranks pages locally. Then use analyzePdfPages for visual claims or presentMedia for an explicit page preview.
180
+
181
+ For media questions, searchKnowledgeBase first, then use an installed evidence-query action with the returned mediaAssetId. Use its active evidence and include supporting time ranges. Inspect or present media only when necessary or explicitly requested.
182
+
183
+ For a media claim that requires a terminal state, comparison, aggregate, count, or change over time, ordinary retrieval is not enough. Follow claimVerification.rule from the installed evidence action -- it states what the gathered evidence supports for this particular question. Never promote a local observation into a broader conclusion without source coverage.
184
+
185
+ "Not directly established" means no single record states the answer outright. It does not mean the source is silent. When the evidence contains a trail that leads to the answer -- readings over time, a state and the change to it, two sides of a comparison -- read across it and give the answer, saying how confident you are and citing the moments it rests on. If a source provides procedures or instructions relevant to the question, state those procedures even when it cannot verify a broader conclusion. Reserve "the video does not show this" for when the evidence genuinely lacks any bearing on the question, and never use it to describe an answer you could have reasoned to.
186
+
187
+ For every video claim, distinguish a direct observation from an inference. Do not turn a reaction, mood, body language, or a summary into a factual conclusion without direct supporting evidence. If evidence is incomplete or conflicts, inspect the relevant source range and answer only what it establishes.
188
+
189
+ When a user explicitly corrects a prior answer about media, acknowledge the correction and use source evidence for later factual answers; never overwrite the source record with a conversational correction.
190
+ `;
191
+
192
+ function messageText(message: UIMessage | undefined): string {
193
+ if (!message) return '';
194
+ // `content` is retained only on legacy UI messages; current SDK messages
195
+ // store text in `parts`.
196
+ const legacyMessage = message as UIMessage & { content?: unknown };
197
+ if (typeof legacyMessage.content === 'string') return legacyMessage.content;
198
+ const parts = Array.isArray(message.parts)
199
+ ? message.parts
200
+ : Array.isArray(legacyMessage.content)
201
+ ? legacyMessage.content
202
+ : [];
203
+ if (parts.length) {
204
+ return parts
205
+ .filter((part: any) => part.type === 'text' && typeof part.text === 'string')
206
+ .map((part: any) => part.text)
207
+ .join(' ');
208
+ }
209
+ return '';
210
+ }
211
+
212
+ function latestUserText(messages: UIMessage[]): string {
213
+ return messageText([...messages].reverse().find((candidate) => candidate.role === 'user'));
214
+ }
215
+
216
+ function precedingUserText(messages: UIMessage[]): string {
217
+ const userMessages = messages.filter((candidate) => candidate.role === 'user');
218
+ return messageText(userMessages[userMessages.length - 2]);
219
+ }
220
+
221
+ function replaceLatestUserText(messages: UIMessage[], text: string): UIMessage[] {
222
+ let replaced = false;
223
+ return [...messages]
224
+ .reverse()
225
+ .map((message) => {
226
+ if (replaced || message.role !== 'user' || !Array.isArray(message.parts)) return message;
227
+ replaced = true;
228
+ let textReplaced = false;
229
+ return {
230
+ ...message,
231
+ parts: message.parts.map((part) => {
232
+ if (textReplaced || part.type !== 'text') return part;
233
+ textReplaced = true;
234
+ return { ...part, text };
235
+ }),
236
+ } as UIMessage;
237
+ })
238
+ .reverse();
239
+ }
240
+
241
+ function latestUserMessageIsPlainText(messages: UIMessage[]): boolean {
242
+ const message = [...messages].reverse().find((candidate) => candidate.role === 'user');
243
+ return Boolean(message?.parts?.length && message.parts.every((part) => part.type === 'text'));
244
+ }
245
+
246
+ /**
247
+ * A terse follow-up such as "render it" often refers to a figure/table named
248
+ * in the immediately preceding answer. Keep that human-readable reference in
249
+ * the local PDF query, without replaying prior tool payloads into the model.
250
+ */
251
+ function latestAssistantText(messages: UIMessage[], maxCharacters = 4_000): string {
252
+ return messageText(
253
+ [...messages].reverse().find((candidate) => candidate.role === 'assistant'),
254
+ ).slice(0, maxCharacters);
255
+ }
256
+
257
+ function directAssistantTextResponse(
258
+ messages: UIMessage[],
259
+ text: string,
260
+ sourceScopeFingerprint?: string,
261
+ ) {
262
+ const stream = createUIMessageStream({
263
+ originalMessages: messages,
264
+ execute: ({ writer }) => {
265
+ const answerId = `answer-${crypto.randomUUID()}`;
266
+ writer.write({
267
+ type: 'start',
268
+ ...(sourceScopeFingerprint
269
+ ? {
270
+ messageMetadata: {
271
+ groundedAnswerCache: { sourceScopeFingerprint },
272
+ },
273
+ }
274
+ : {}),
275
+ });
276
+ writer.write({ type: 'text-start', id: answerId });
277
+ writer.write({ type: 'text-delta', id: answerId, delta: text });
278
+ writer.write({ type: 'text-end', id: answerId });
279
+ },
280
+ });
281
+ return createUIMessageStreamResponse({ stream });
282
+ }
283
+
284
+ /**
285
+ * A preview follow-up can refer to an object named by the user several turns
286
+ * earlier, even if the intervening assistant answer had no usable text part.
287
+ * Search only the preceding conversational text, never a tool payload.
288
+ */
289
+ function precedingNumberedDocumentReference(messages: UIMessage[]): string | undefined {
290
+ let latestUserIndex = -1;
291
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
292
+ if (messages[index].role === 'user') {
293
+ latestUserIndex = index;
294
+ break;
295
+ }
296
+ }
297
+ if (latestUserIndex <= 0) return undefined;
298
+ return latestNumberedDocumentReferenceText(
299
+ messages.slice(0, latestUserIndex).map((message) => messageText(message)),
300
+ );
301
+ }
302
+
303
+ /**
304
+ * Drive the investigation from structured tool results, never from a list of
305
+ * domain or language-specific phrases in the user's question.
306
+ */
307
+ function mediaEvidenceFlow(
308
+ messages: unknown[],
309
+ recentMediaAssetIds: string[] = [],
310
+ evidenceQueryToolNames: string[] = [],
311
+ ) {
312
+ const serialized = JSON.stringify(messages);
313
+ return {
314
+ hasMediaAsset: recentMediaAssetIds.length > 0 || /"mediaAssetId"\s*:/.test(serialized),
315
+ hasCompletedEvidence: containsCompletedMediaEvidence(messages),
316
+ evidenceQueries: evidenceQueryToolNames.reduce(
317
+ (count, name) =>
318
+ count + (serialized.match(new RegExp(`"${escapeRegExp(name)}"`, 'g')) ?? []).length,
319
+ 0,
320
+ ),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Tool use is not equally reliable across all chat providers. The host runs
326
+ * the first retrieval deterministically, but the result remains a normal
327
+ * visible tool result and is the only source added to the answer model's
328
+ * context. This is capability- and source-driven: it knows nothing about a
329
+ * specific video, question type, language, person, or expected answer.
330
+ */
331
+ function preloadedEvidenceContext(result: unknown, question: string): string {
332
+ const compact = compactToolContextForModel([
333
+ {
334
+ role: 'tool',
335
+ content: [{ type: 'tool-result', toolName: 'searchKnowledgeBase', output: result }],
336
+ },
337
+ ]);
338
+ const output = (compact[0] as any)?.content?.[0]?.output ?? result;
339
+ const unverifiedMedia = findUnverifiedMediaEvidence(result);
340
+ // A verification result controls how narrowly a claim may be phrased; it
341
+ // must not erase the source excerpts that led to the result. Erasing those
342
+ // excerpts made a source with useful procedures look empty and forced the
343
+ // model into a generic refusal. Keep the bounded evidence available and
344
+ // make the limitation explicit instead.
345
+ const safeOutput = unverifiedMedia
346
+ ? {
347
+ evidence: output,
348
+ mediaEvidenceStatus: {
349
+ mediaAssetId: unverifiedMedia.mediaAssetId,
350
+ claimVerification: unverifiedMedia.claimVerification,
351
+ instruction:
352
+ 'The source has relevant material but the requested claim is not yet established as one direct observation. Answer from the source passages and any corroborating trail that are present. Do not invent missing details or present a broad conclusion as verified; do provide the concrete procedures, statements, or partial answer the source does establish.',
353
+ },
354
+ }
355
+ : output;
356
+ const serialized = typeof safeOutput === 'string' ? safeOutput : JSON.stringify(safeOutput);
357
+ const contextBudget = containsExhaustiveEvidence(safeOutput) ? 120_000 : 24_000;
358
+ const directClaims = collectQuestionMatchedDirectClaims(result, question);
359
+ const directObservations = collectAnswerLevelMediaStatements(result);
360
+ const observedSubjectLedger = collectObservedSubjectLedger(result);
361
+ const hasEstablishedMediaEvidence = containsAnswerLevelMediaEvidence(result);
362
+ const mediaAssetId = explicitMediaEvidenceAssetId(result);
363
+ const hasCurrentKnowledgeHits =
364
+ Boolean(result) &&
365
+ typeof result === 'object' &&
366
+ Array.isArray((result as { hits?: unknown }).hits) &&
367
+ (result as { hits: unknown[] }).hits.length > 0;
368
+ if (!hasCurrentKnowledgeHits && !mediaAssetId) {
369
+ return '\n\nNO CURRENTLY ACCESSIBLE SOURCE EVIDENCE WAS FOUND FOR THIS TURN. Source access can change after a previous answer, so do not repeat or infer facts from prior assistant messages. Explain briefly that the current knowledge base does not contain an answer.';
370
+ }
371
+ if (
372
+ mediaAssetId &&
373
+ directClaims.length === 0 &&
374
+ !hasEstablishedMediaEvidence &&
375
+ !unverifiedMedia
376
+ ) {
377
+ return `\n\nA relevant media source was located (${mediaAssetId}), but no answer-level evidence has been returned yet. Use the installed evidence-query action for that media asset before answering. Do not say that the source has no answer and do not infer an outcome from this locator alone.`;
378
+ }
379
+ return `\n\nCURRENT USER QUESTION:\n${question}\n\nVERIFIED SOURCE EVIDENCE FOR THIS TURN:\n${
380
+ directClaims.length > 0
381
+ ? `DIRECTLY ESTABLISHED ANSWER TEXT (preserve its specific identifying details rather than weakening them):\n${directClaims.join(
382
+ '\n',
383
+ )}\n\n`
384
+ : ''
385
+ }${
386
+ directObservations.length > 0
387
+ ? `BINDING SOURCE OBSERVATIONS (state every explicit time, recurrence, identity basis, and uncertainty exactly as written):\n${directObservations
388
+ .map((observation) => `- ${observation}`)
389
+ .join(
390
+ '\n',
391
+ )}\n\nDo not turn discrete observations into continuous presence. Do not say that something happened only once, did not recur, or was absent elsewhere unless that absence is directly established by the observations. Do not replace a source-described or unresolved identity with a proper name.\n\n`
392
+ : ''
393
+ }${
394
+ observedSubjectLedger.length > 0
395
+ ? `BINDING VISIBILITY LEDGER (these are discrete frame-grounded observations across the source):\n${observedSubjectLedger
396
+ .map((subject) => `- ${subject}`)
397
+ .join(
398
+ '\n',
399
+ )}\n\nWhen the request connects an observed subject or event at one position to elsewhere in the source, use this ledger for the other observed appearances. Do not replace a source-described identity with a proper name. Do not say the subject appeared only once, did not recur, or was absent elsewhere unless the supplied evidence directly establishes that absence.\n\n`
400
+ : ''
401
+ }${serialized.slice(0, contextBudget)}\n\n${
402
+ unverifiedMedia
403
+ ? 'The verification status above limits certainty, not access to the source material. Give the most useful source-grounded answer that is supported.'
404
+ : "Answer the user's question directly from this evidence."
405
+ } Answer only the current question above. Never repeat an earlier answer when the current question introduces a different subject. Do not mention tools, retrieval, frames, transcripts, or analysis.`;
406
+ }
407
+
408
+ /**
409
+ * Turn a natural-language question into the content-neutral directive owned by
410
+ * the video evidence capability. The executor subsequently handles only this
411
+ * typed plan, so no route-level vocabulary, language, or media-genre rules are
412
+ * needed to interpret a time reference or a source-wide request.
413
+ */
414
+ async function planVideoEvidenceQuery(input: {
415
+ model: any;
416
+ providerOptions: ReturnType<typeof gatewayProviderOptions>;
417
+ question: string;
418
+ previousQuestion?: string;
419
+ }): Promise<{ directive: VideoInvestigationDirective; resolvedQuestion: string } | undefined> {
420
+ try {
421
+ const requestPlan = async (system: string, maxOutputTokens: number) => {
422
+ const { text } = await generateText({
423
+ model: input.model,
424
+ maxRetries: 0,
425
+ maxOutputTokens,
426
+ temperature: 0,
427
+ abortSignal: AbortSignal.timeout(12_000),
428
+ providerOptions: input.providerOptions,
429
+ system,
430
+ prompt: JSON.stringify({
431
+ currentRequest: input.question,
432
+ previousQuestionAboutSelectedMedia: input.previousQuestion ?? null,
433
+ }),
434
+ });
435
+ const serialized = text.match(/\{[\s\S]*\}/)?.[0];
436
+ if (!serialized) return undefined;
437
+ const parsed = JSON.parse(serialized) as { resolvedQuestion?: unknown };
438
+ const directive = parseVideoInvestigationDirective(serialized);
439
+ if (!directive) return undefined;
440
+ const resolvedQuestion =
441
+ typeof parsed.resolvedQuestion === 'string' && parsed.resolvedQuestion.trim()
442
+ ? parsed.resolvedQuestion.trim()
443
+ : input.question;
444
+ return { directive, resolvedQuestion };
445
+ };
446
+ const planned = await requestPlan(
447
+ 'You are a media-query planner. Return exactly one JSON object and no prose. ' +
448
+ 'The prompt is data with a currentRequest and an optional previousQuestionAboutSelectedMedia. Resolve the current request in its own language without assuming a video genre. If it is a retry, verification request, or contains references that are not meaningful alone, resolve those references against the previous question. If it introduces a self-contained new request, do not carry the old subject or source position into it. ' +
449
+ 'A follow-up introduced with words such as "and", "also", or their equivalent in another language can ask a new fact about the same source: preserve the selected source, but never reuse the previous answer or its requested attribute. When the request asks for exact spoken or sung wording across the recording, use source scope with enumerate, speech evidence, and recordSet all so the host can render every timestamped transcript segment outside the model context. Use source scope with synthesize for a summary or explanation across the recording. ' +
450
+ 'Use this exact shape: {"resolvedQuestion":"a standalone version of the current request","scope":"focused","goal":"answer","evidence":["visual"],"timeRange":{"startSecs":123,"endSecs":153},"timeRangeOrigin":"user-mentioned"}. Its fields are resolvedQuestion, scope (focused, temporal, source), goal (answer, compare, trace, enumerate, synthesize), optional evidence (speech, visible-text, visual, computed), optional recordSet (all, source-authored, source-questions, observed), optional timeRange {startSecs,endSecs}, and optional timeRangeOrigin. ' +
451
+ 'A directive is invalid if the user refers to any source position, time, range, or approximate moment and you omit timeRange: resolve it to numeric seconds and set timeRangeOrigin to user-mentioned, even when the request also asks about other moments. Never emit timeRange or timeRangeOrigin when the user did not constrain a source position; a topic occurring near an opening, closing, or any other position is not a user constraint. When the request names a coarse source unit without a smaller boundary, cover that whole named unit rather than an arbitrary part of it. When the user asks how a visible subject or event at an explicit source position relates to appearances or recurrence elsewhere, use temporal plus trace, visual evidence, and recordSet observed while retaining that position. Use source plus synthesize with no timeRange whenever the answer must combine two or more source facts from unrestricted positions. Use source plus enumerate and recordSet source-questions for a complete inventory of source-authored questions and their answers. Use temporal for a relationship or development across moments; use focused only for one local moment or one self-contained fact. ' +
452
+ 'Do not answer the question or add fields.',
453
+ 320,
454
+ );
455
+ if (planned) return planned;
456
+ // A compact recovery request is intentionally restricted to the protocol
457
+ // choices. It rescues providers that emit conversational prose on their
458
+ // first planning attempt without introducing source- or language-specific
459
+ // routing rules in the host.
460
+ return await requestPlan(
461
+ 'Return only JSON with resolvedQuestion, scope, goal, and optional recordSet/timeRange/timeRangeOrigin. Resolve an elliptical retry or reference against previousQuestionAboutSelectedMedia; otherwise keep currentRequest standalone. Select source/enumerate/source-questions for a complete question-and-answer inventory from a recording; select source/synthesize for an unrestricted explanation; select focused/answer for one local fact. Do not include a timeRange unless the resolved request contains a source position, and then set timeRangeOrigin to user-mentioned.',
462
+ 180,
463
+ );
464
+ } catch {
465
+ // The evidence action has a source-wide fallback. A planner outage must
466
+ // not turn an otherwise answerable source question into a chat error.
467
+ return undefined;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Validate the only planner decision that can discard most of a recording.
473
+ * This deliberately asks a separate, binary question: requesting timestamps
474
+ * in an answer is not itself a request to inspect a particular timestamp.
475
+ * Keeping this language-neutral gate independent prevents a rich planner from
476
+ * turning an unrestricted explanation into an invented opening slice.
477
+ */
478
+ async function userSpecifiedSourcePosition(input: {
479
+ model: any;
480
+ providerOptions: ReturnType<typeof gatewayProviderOptions>;
481
+ question: string;
482
+ }): Promise<
483
+ | {
484
+ hasPosition: boolean;
485
+ connectsBeyondPosition: boolean;
486
+ }
487
+ | undefined
488
+ > {
489
+ try {
490
+ const { text } = await generateText({
491
+ model: input.model,
492
+ maxRetries: 0,
493
+ maxOutputTokens: 80,
494
+ temperature: 0,
495
+ abortSignal: AbortSignal.timeout(8_000),
496
+ providerOptions: input.providerOptions,
497
+ system:
498
+ 'Return exactly one JSON object and no prose: {"kind":"input-location"|"answer-timestamp"|"none","locator":string|null,"connectsBeyondPosition":boolean}. Use input-location only when the user explicitly constrains where in the provided source to look; locator must be the exact corresponding substring from the user request. Use answer-timestamp when the user asks for timestamps in the answer but does not specify where to look; locator is null. Use none when neither is present. Set connectsBeyondPosition true only when the request explicitly relates the thing at its named source position to another point or portion of the source, such as recurrence, reappearance, a later/earlier occurrence, change, or comparison; otherwise false. Example: "Explain a subject and include timestamps" => {"kind":"answer-timestamp","locator":null,"connectsBeyondPosition":false}. Example: "What happens at 14:00?" => {"kind":"input-location","locator":"14:00","connectsBeyondPosition":false}. Example: "Who is at 14:00 and where else do they appear?" => {"kind":"input-location","locator":"14:00","connectsBeyondPosition":true}. Example: "Give the full story from beginning to conclusion" => {"kind":"none","locator":null,"connectsBeyondPosition":false} unless the user asks specifically about the beginning or conclusion. Interpret every language without assuming a source genre.',
499
+ prompt: input.question,
500
+ });
501
+ const serialized = text.match(/\{[\s\S]*\}/)?.[0];
502
+ if (!serialized) return undefined;
503
+ const parsed = JSON.parse(serialized) as {
504
+ kind?: unknown;
505
+ locator?: unknown;
506
+ connectsBeyondPosition?: unknown;
507
+ };
508
+ const connectsBeyondPosition = parsed.connectsBeyondPosition === true;
509
+ if (parsed.kind === 'answer-timestamp' || parsed.kind === 'none') {
510
+ return { hasPosition: false, connectsBeyondPosition };
511
+ }
512
+ if (parsed.kind !== 'input-location' || typeof parsed.locator !== 'string') return undefined;
513
+ // The quote need only occur in the user text. Its semantic role was
514
+ // established by the structured classifier, which supports any language.
515
+ return {
516
+ hasPosition: input.question.normalize('NFKC').includes(parsed.locator.normalize('NFKC')),
517
+ connectsBeyondPosition,
518
+ };
519
+ } catch {
520
+ return undefined;
521
+ }
522
+ }
523
+
524
+ function parseVideoInvestigationDirective(value: string): VideoInvestigationDirective | undefined {
525
+ const candidate = value.match(/\{[\s\S]*\}/)?.[0];
526
+ if (!candidate) return undefined;
527
+ try {
528
+ const parsed = JSON.parse(candidate) as Partial<VideoInvestigationDirective>;
529
+ // Some providers preserve the range but omit the two default fields or
530
+ // serialize startSecs/endSecs as start/end. That is a schema-transport
531
+ // variation, not a reason to discard a model-resolved source position.
532
+ const scope = ['focused', 'temporal', 'source'].includes(String(parsed.scope))
533
+ ? (parsed.scope as VideoInvestigationDirective['scope'])
534
+ : 'focused';
535
+ const goal = ['answer', 'compare', 'trace', 'enumerate', 'synthesize'].includes(
536
+ String(parsed.goal),
537
+ )
538
+ ? (parsed.goal as VideoInvestigationDirective['goal'])
539
+ : 'answer';
540
+ const evidence = Array.isArray(parsed.evidence)
541
+ ? parsed.evidence.filter(
542
+ (item): item is NonNullable<VideoInvestigationDirective['evidence']>[number] =>
543
+ ['speech', 'visible-text', 'visual', 'computed'].includes(String(item)),
544
+ )
545
+ : undefined;
546
+ const recordSet = ['all', 'source-authored', 'source-questions', 'observed'].includes(
547
+ String(parsed.recordSet),
548
+ )
549
+ ? parsed.recordSet
550
+ : undefined;
551
+ const rawTimeRange = parsed.timeRange as
552
+ | (VideoInvestigationDirective['timeRange'] & {
553
+ start?: unknown;
554
+ end?: unknown;
555
+ startSeconds?: unknown;
556
+ endSeconds?: unknown;
557
+ })
558
+ | undefined;
559
+ const timeRangeOrigin = (parsed as { timeRangeOrigin?: unknown }).timeRangeOrigin;
560
+ const timeRange = rawTimeRange
561
+ ? {
562
+ startSecs: Number(
563
+ rawTimeRange.startSecs ?? rawTimeRange.startSeconds ?? rawTimeRange.start,
564
+ ),
565
+ endSecs: Number(rawTimeRange.endSecs ?? rawTimeRange.endSeconds ?? rawTimeRange.end),
566
+ }
567
+ : undefined;
568
+ if (
569
+ timeRange &&
570
+ (!Number.isFinite(timeRange.startSecs) ||
571
+ !Number.isFinite(timeRange.endSecs) ||
572
+ timeRange.startSecs < 0 ||
573
+ timeRange.endSecs < timeRange.startSecs)
574
+ ) {
575
+ return undefined;
576
+ }
577
+ // A numerical range must be attributable to a source position the user
578
+ // actually referenced. Otherwise it is a plausible-looking but invented
579
+ // cut of the recording; safely broaden to source synthesis rather than
580
+ // silently hiding relevant material. This rule operates on structured
581
+ // planner output only and therefore works in every user language.
582
+ const hasUnattributedRange = timeRange !== undefined && timeRangeOrigin !== 'user-mentioned';
583
+ return {
584
+ scope: hasUnattributedRange ? 'source' : scope,
585
+ goal: hasUnattributedRange ? 'synthesize' : goal,
586
+ ...(hasUnattributedRange ? {} : evidence?.length ? { evidence: [...new Set(evidence)] } : {}),
587
+ ...(hasUnattributedRange ? {} : recordSet ? { recordSet } : {}),
588
+ ...(!hasUnattributedRange && timeRange
589
+ ? { timeRange: { startSecs: timeRange.startSecs, endSecs: timeRange.endSecs } }
590
+ : {}),
591
+ };
592
+ } catch {
593
+ return undefined;
594
+ }
595
+ }
596
+
597
+ function containsExhaustiveEvidence(value: unknown): boolean {
598
+ if (typeof value === 'string') {
599
+ try {
600
+ return containsExhaustiveEvidence(JSON.parse(value));
601
+ } catch {
602
+ return false;
603
+ }
604
+ }
605
+ if (Array.isArray(value)) return value.some(containsExhaustiveEvidence);
606
+ if (!value || typeof value !== 'object') return false;
607
+ const record = value as Record<string, unknown>;
608
+ const continuation = record.continuation;
609
+ const directive = (record.investigation as { directive?: unknown } | undefined)?.directive as
610
+ { scope?: unknown; goal?: unknown } | undefined;
611
+ const structuredInventory = record.inventory !== undefined && Array.isArray(record.rows);
612
+ const explicitSourceInventory = directive?.scope === 'source' && directive.goal === 'enumerate';
613
+ if (
614
+ continuation &&
615
+ typeof continuation === 'object' &&
616
+ (continuation as { exhaustive?: unknown }).exhaustive === true &&
617
+ // Legacy evidence had no directive. Preserve its behavior, but modern
618
+ // source synthesis must remain a concise answer task rather than a dump.
619
+ (!directive || structuredInventory || explicitSourceInventory)
620
+ ) {
621
+ return true;
622
+ }
623
+ return Object.values(record).some(containsExhaustiveEvidence);
624
+ }
625
+
626
+ /**
627
+ * Detect the generic evidence-capability protocol, including results nested
628
+ * inside compacted tool payloads. This deliberately knows nothing about a
629
+ * media domain or a question type.
630
+ */
631
+ function findUnverifiedMediaEvidence(value: unknown): {
632
+ mediaAssetId?: string;
633
+ claimVerification: unknown;
634
+ } | null {
635
+ if (typeof value === 'string') {
636
+ try {
637
+ return findUnverifiedMediaEvidence(JSON.parse(value));
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+ if (Array.isArray(value)) {
643
+ for (const item of value) {
644
+ const found = findUnverifiedMediaEvidence(item);
645
+ if (found) return found;
646
+ }
647
+ return null;
648
+ }
649
+ if (!value || typeof value !== 'object') return null;
650
+ const record = value as Record<string, unknown>;
651
+ const verification = record.claimVerification;
652
+ if (
653
+ record.success === true &&
654
+ verification &&
655
+ typeof verification === 'object' &&
656
+ mediaClaimNeedsCorroboration(verification)
657
+ ) {
658
+ return {
659
+ mediaAssetId: typeof record.mediaAssetId === 'string' ? record.mediaAssetId : undefined,
660
+ claimVerification: verification,
661
+ };
662
+ }
663
+ for (const child of Object.values(record)) {
664
+ const found = findUnverifiedMediaEvidence(child);
665
+ if (found) return found;
666
+ }
667
+ return null;
668
+ }
669
+
670
+ function collectDirectReadings(value: unknown): string[] {
671
+ const readings = new Set<string>();
672
+ const visit = (candidate: unknown) => {
673
+ if (Array.isArray(candidate)) {
674
+ candidate.forEach(visit);
675
+ return;
676
+ }
677
+ if (!candidate || typeof candidate !== 'object') return;
678
+ const record = candidate as Record<string, unknown>;
679
+ if (record.directObservation && typeof record.directObservation === 'object') {
680
+ const observations = (record.directObservation as { readings?: unknown }).readings;
681
+ if (Array.isArray(observations)) {
682
+ for (const observation of observations) {
683
+ const found =
684
+ observation && typeof observation === 'object'
685
+ ? (observation as { found?: unknown }).found
686
+ : undefined;
687
+ const settlesQuestion =
688
+ observation && typeof observation === 'object'
689
+ ? (observation as { settlesQuestion?: unknown }).settlesQuestion
690
+ : undefined;
691
+ if (
692
+ typeof found === 'string' &&
693
+ settlesQuestion === true &&
694
+ found.trim() &&
695
+ !/^Observed context \(not a complete answer\):/i.test(found.trim())
696
+ ) {
697
+ readings.add(found.trim());
698
+ }
699
+ }
700
+ }
701
+ }
702
+ Object.values(record).forEach(visit);
703
+ };
704
+ visit(value);
705
+ return [...readings].slice(0, 8);
706
+ }
707
+
708
+ /**
709
+ * Some reasoning-heavy providers can finish a tool-backed turn without ever
710
+ * emitting answer text. Keep the stream structurally valid and insert only
711
+ * evidence that was already directly established; otherwise surface a clear
712
+ * retry message instead of leaving a permanent spinner or an empty answer.
713
+ */
714
+ function fallbackAnswerFromEvidence(evidence: unknown, question: string) {
715
+ if (mediaEvidenceRequestsOnlySpeech(evidence)) {
716
+ const excerpts = collectSourceExcerpts(evidence, question);
717
+ if (excerpts.length > 0) return excerpts.join('\n\n');
718
+ return 'I could not verify enough spoken wording in this recording to answer accurately.';
719
+ }
720
+ const established = [
721
+ ...collectQuestionMatchedDirectClaims(evidence, question),
722
+ ...collectDirectReadings(evidence),
723
+ ];
724
+ if (established.length > 0) return established.join('\n');
725
+ const trail = collectAnswerLevelMediaStatements(evidence);
726
+ if (trail.length > 0) return trail.join('\n');
727
+ const excerpts = collectSourceExcerpts(evidence, question);
728
+ if (excerpts.length > 0) return excerpts.join('\n\n');
729
+ return 'I could not complete the response just now. Please try the question again.';
730
+ }
731
+
732
+ /**
733
+ * Preserve a useful, source-grounded fallback when a provider closes after
734
+ * tools without generating text. This is deliberately schema-tolerant so
735
+ * every retrieval or marketplace capability can contribute its own text
736
+ * evidence without teaching the chat route about individual source types.
737
+ */
738
+ function collectSourceExcerpts(value: unknown, question: string): string[] {
739
+ const excerpts = new Set<string>();
740
+ const questionTerms = new Set(
741
+ question.toLocaleLowerCase().match(/[\p{Letter}\p{Number}]+/gu) ?? [],
742
+ );
743
+ const add = (candidate: unknown) => {
744
+ if (typeof candidate !== 'string') return;
745
+ const text = candidate.replace(/\s+/g, ' ').trim();
746
+ if (text.length < 16) return;
747
+ if (
748
+ /^Observed context \(not a complete answer\):/i.test(text) ||
749
+ /Claim verdict:\s*(?:partial|not-established)\b/i.test(text) ||
750
+ /^Verified media evidence is available\./i.test(text)
751
+ ) {
752
+ return;
753
+ }
754
+ const normalized = text.toLocaleLowerCase();
755
+ const matches = [...questionTerms].filter(
756
+ (term) => term.length > 2 && normalized.includes(term),
757
+ );
758
+ if (matches.length > 0 || excerpts.size === 0) excerpts.add(text.slice(0, 900));
759
+ };
760
+ const visit = (candidate: unknown, key?: string) => {
761
+ if (typeof candidate === 'string') {
762
+ if (key === 'text' || key === 'excerpt' || key === 'content' || key === 'summary')
763
+ add(candidate);
764
+ return;
765
+ }
766
+ if (Array.isArray(candidate)) {
767
+ candidate.forEach((item) => visit(item));
768
+ return;
769
+ }
770
+ if (!candidate || typeof candidate !== 'object') return;
771
+ for (const [childKey, child] of Object.entries(candidate)) visit(child, childKey);
772
+ };
773
+ visit(value);
774
+ return [...excerpts].slice(0, 4);
775
+ }
776
+
777
+ /**
778
+ * True when retrieval located a spreadsheet-like source that also exists in
779
+ * the structured table store. Matching by normalized file stem avoids sending
780
+ * a question about one uploaded spreadsheet to an unrelated workbook.
781
+ */
782
+ function hasRetrievedTabularEvidence(value: unknown, datasetNames: string[]): boolean {
783
+ const datasetStems = new Set(
784
+ datasetNames.map((name) => name.replace(/\.(?:csv|xlsx?|ods|json)$/i, '').toLocaleLowerCase()),
785
+ );
786
+ const matchesDataset = (text: string) => {
787
+ const fileName = text.match(/[^/\\]+\.(?:csv|xlsx?|ods|json)\b/i)?.[0];
788
+ if (!fileName) return false;
789
+ const stem = fileName.replace(/\.(?:csv|xlsx?|ods|json)$/i, '').toLocaleLowerCase();
790
+ return datasetStems.has(stem);
791
+ };
792
+ const visit = (candidate: unknown): boolean => {
793
+ if (typeof candidate === 'string') {
794
+ try {
795
+ return visit(JSON.parse(candidate));
796
+ } catch {
797
+ return matchesDataset(candidate);
798
+ }
799
+ }
800
+ if (Array.isArray(candidate)) return candidate.some(visit);
801
+ if (!candidate || typeof candidate !== 'object') return false;
802
+ return Object.values(candidate).some(visit);
803
+ };
804
+ return visit(value);
805
+ }
806
+
807
+ /** Tool output can be embedded as a compact JSON string before the next model
808
+ * step, so inspect both structured values and safely parseable strings. */
809
+ function containsCompletedMediaEvidence(value: unknown): boolean {
810
+ if (typeof value === 'string') {
811
+ try {
812
+ return containsCompletedMediaEvidence(JSON.parse(value));
813
+ } catch {
814
+ return false;
815
+ }
816
+ }
817
+ if (Array.isArray(value)) return value.some(containsCompletedMediaEvidence);
818
+ if (!value || typeof value !== 'object') return false;
819
+ const record = value as Record<string, unknown>;
820
+ const evidence = record.videoEvidence;
821
+ if (
822
+ evidence &&
823
+ typeof evidence === 'object' &&
824
+ (evidence as { success?: unknown }).success === true
825
+ )
826
+ return true;
827
+ return Object.values(record).some(containsCompletedMediaEvidence);
828
+ }
829
+
830
+ function escapeRegExp(value: string) {
831
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
832
+ }
833
+
834
+ export async function POST(req: Request) {
835
+ const parsedBody: unknown = await req.json();
836
+ const body =
837
+ parsedBody && typeof parsedBody === 'object' ? (parsedBody as Record<string, unknown>) : {};
838
+ const {
839
+ projectId,
840
+ chatModelId: requestedModelId,
841
+ docSessionId,
842
+ docFields,
843
+ } = body as {
844
+ projectId?: string;
845
+ chatModelId?: string;
846
+ docSessionId?: string;
847
+ docFields?: {
848
+ id: string;
849
+ name: string;
850
+ type: string;
851
+ value?: string;
852
+ context?: string;
853
+ placeholder?: string;
854
+ }[];
855
+ };
856
+ const messages = normalizeIncomingMessages(body?.messages) as UIMessage[];
857
+ if (!messages.some((message) => message.role === 'user')) {
858
+ return Response.json({ error: 'At least one user message is required.' }, { status: 400 });
859
+ }
860
+
861
+ const config = await readConfig();
862
+ const userText = latestUserText(messages);
863
+ const readCurrentScopeFingerprint = async () => {
864
+ const [documents, groups, activeConfig] = await Promise.all([
865
+ readDocuments(),
866
+ readGroups(),
867
+ readConfig(),
868
+ ]);
869
+ return assistantSourceScopeFingerprint(filterDocumentsAvailableToAssistant(documents, groups), {
870
+ configUpdatedAt: activeConfig.updatedAt,
871
+ });
872
+ };
873
+ const inRequestedProject = <T>(operation: () => Promise<T>) =>
874
+ projectId ? runWithProject(projectId, operation) : operation();
875
+
876
+ // A liked answer is a real project cache entry, so it remains usable after
877
+ // reload and in a new chat. Read the tiny cache file before initializing a
878
+ // model, then validate it against the complete current source scope.
879
+ const likedAnswer = latestUserMessageIsPlainText(messages)
880
+ ? await inRequestedProject(() => getGroundedAnswerCacheEntry(userText))
881
+ : undefined;
882
+ if (likedAnswer) {
883
+ const currentScopeFingerprint = await inRequestedProject(readCurrentScopeFingerprint);
884
+ const feedback = likedAnswer.feedback ?? 'liked';
885
+ if (
886
+ feedback === 'liked' &&
887
+ likedAnswer.answer &&
888
+ currentScopeFingerprint === likedAnswer.sourceScopeFingerprint
889
+ ) {
890
+ return directAssistantTextResponse(
891
+ messages,
892
+ likedAnswer.answer,
893
+ likedAnswer.sourceScopeFingerprint,
894
+ );
895
+ }
896
+ if (currentScopeFingerprint !== likedAnswer.sourceScopeFingerprint) {
897
+ await inRequestedProject(() => deleteGroundedAnswerCacheEntry(userText));
898
+ }
899
+ }
900
+ const provider = config.chatProvider || config.embeddingProvider;
901
+
902
+ // Fetch dynamic models to resolve defaults
903
+ const gatewayModels = await getModelsByType('language');
904
+ const allChatModels = gatewayModels.map(toChatDescriptor);
905
+
906
+ const configuredModelId = requestedModelId || config.chatModelId;
907
+ const chatModelId =
908
+ normalizeNativeChatModelId(provider, configuredModelId) ||
909
+ getDefaultChatModel(allChatModels, provider)?.id ||
910
+ 'openai/gpt-4o-mini';
911
+
912
+ // The configured provider is authoritative. In particular, a direct Google
913
+ // key must never be routed through the gateway because a model ID has a
914
+ // vendor prefix.
915
+ const resolvedProvider = provider;
916
+
917
+ const apiKey = config.chatApiKey || config.embeddingApiKey || undefined;
918
+ const model = createChatModel(
919
+ resolvedProvider,
920
+ chatModelId,
921
+ apiKey,
922
+ config.customChatModels,
923
+ ) as any;
924
+
925
+ // Keep only a bounded, compact conversation history.
926
+ const MAX_HISTORY_MESSAGES = 20;
927
+ // The answer model receives only the recent transcript, but follow-up
928
+ // routing can safely inspect a larger window of compact tool references.
929
+ // This preserves the active table/image after dozens of turns without
930
+ // sending those historical tool payloads to the model.
931
+ const EVIDENCE_HISTORY_MESSAGES = 80;
932
+ const messagesToProcess =
933
+ messages.length > MAX_HISTORY_MESSAGES
934
+ ? messages.slice(messages.length - MAX_HISTORY_MESSAGES)
935
+ : messages;
936
+ const evidenceMessages =
937
+ messages.length > EVIDENCE_HISTORY_MESSAGES
938
+ ? messages.slice(messages.length - EVIDENCE_HISTORY_MESSAGES)
939
+ : messages;
940
+ const safeMessages = messagesToProcess
941
+ .map((m) => {
942
+ const anyM = { ...m } as any;
943
+
944
+ // Strip IDs and reasoning from conversation history so the API provider doesn't attempt
945
+ // to strictly validate our compacted messages against its original signatures (e.g. o1/o3/Claude).
946
+ delete anyM.id;
947
+ delete anyM.reasoning;
948
+ delete anyM.providerOptions;
949
+
950
+ // Tool invocations are rendered UI/execution data, not durable conversation
951
+ // context. Fresh retrieval is required for every substantive question.
952
+ if (anyM.role === 'assistant' && anyM.toolInvocations) {
953
+ delete anyM.toolInvocations;
954
+ }
955
+
956
+ if (Array.isArray(anyM.parts)) {
957
+ anyM.parts = anyM.parts.flatMap((part: any) => {
958
+ // Tool calls/results are execution details, not conversation. The
959
+ // model retrieves fresh evidence for substantive questions, so keeping
960
+ // rendered tables, artifacts, and tool UI here only wastes context.
961
+ // Also strip reasoning parts to avoid signature mismatch errors when
962
+ // passing a compacted history back to the model.
963
+ if (
964
+ part.type === 'tool-invocation' ||
965
+ part.type === 'tool-result' ||
966
+ part.type?.startsWith('tool-') ||
967
+ part.type === 'reasoning'
968
+ ) {
969
+ return [];
970
+ }
971
+ // File parts — strip large non-image files (PDF base64)
972
+ if (part.type === 'file') {
973
+ const data = part.data || part.url || '';
974
+ if (typeof data === 'string' && data.length > 5000) {
975
+ const mimeType = (part.mimeType || part.mediaType || '').toLowerCase();
976
+ const isImage = mimeType.startsWith('image/');
977
+ if (!isImage) {
978
+ // Replace with a placeholder
979
+ return {
980
+ type: 'text',
981
+ text: `[File attachment: ${
982
+ mimeType || 'document'
983
+ } — removed from context for size]`,
984
+ };
985
+ }
986
+ }
987
+ }
988
+ return part;
989
+ });
990
+
991
+ // Tool-only assistant entries have no conversational content after
992
+ // compaction and must be omitted instead of adding dummy context.
993
+ if (anyM.role === 'assistant' && anyM.parts.length === 0) {
994
+ anyM.omitFromModelContext = true;
995
+ }
996
+ }
997
+
998
+ // Strip PDF attachments from experimental_attachments (base64 data URLs can be many MB)
999
+ if (anyM.experimental_attachments) {
1000
+ anyM.experimental_attachments = anyM.experimental_attachments.filter((att: any) => {
1001
+ if (att.url && att.url.length > 5000) {
1002
+ const isPdf =
1003
+ (att.contentType && att.contentType.toLowerCase().includes('pdf')) ||
1004
+ (att.name && att.name.toLowerCase().endsWith('.pdf')) ||
1005
+ att.url.substring(0, 50).toLowerCase().includes('pdf');
1006
+
1007
+ if (isPdf || !(att.contentType && att.contentType.toLowerCase().startsWith('image/'))) {
1008
+ return false;
1009
+ }
1010
+ }
1011
+ return true;
1012
+ });
1013
+ }
1014
+
1015
+ if (Array.isArray(anyM.content)) {
1016
+ anyM.content = anyM.content.filter((part: any) => {
1017
+ const data = part.data || part.url || part.text;
1018
+ if (typeof data === 'string' && data.length > 5000) {
1019
+ const isPdf =
1020
+ (part.mimeType && part.mimeType.toLowerCase().includes('pdf')) ||
1021
+ (part.contentType && part.contentType.toLowerCase().includes('pdf')) ||
1022
+ data.substring(0, 50).toLowerCase().includes('pdf');
1023
+
1024
+ if (
1025
+ isPdf ||
1026
+ !(part.mimeType?.startsWith('image/') || part.contentType?.startsWith('image/'))
1027
+ ) {
1028
+ return false;
1029
+ }
1030
+ }
1031
+ return true;
1032
+ });
1033
+ }
1034
+
1035
+ return anyM;
1036
+ })
1037
+ .filter((message) => !message.omitFromModelContext);
1038
+
1039
+ let tabularContext = '';
1040
+ let hasTabularData = false;
1041
+ let availableTabularDatasetIds = new Set<string>();
1042
+ let tabularColumnNames: string[] = [];
1043
+ let tabularDatasetNames: string[] = [];
1044
+ try {
1045
+ const readAvailableDatasets = async () => {
1046
+ const [allDatasets, groups, documents] = await Promise.all([
1047
+ listTabularDatasets(),
1048
+ readGroups(),
1049
+ readDocuments(),
1050
+ ]);
1051
+ return filterGroupOwnedDataAvailableToAssistant(
1052
+ resolveTabularDatasetGroups(allDatasets, documents),
1053
+ groups,
1054
+ );
1055
+ };
1056
+ const datasets = projectId
1057
+ ? await runWithProject(projectId, readAvailableDatasets)
1058
+ : await readAvailableDatasets();
1059
+ if (datasets.length > 0) {
1060
+ hasTabularData = true;
1061
+ availableTabularDatasetIds = new Set(datasets.map((dataset) => dataset.id));
1062
+ tabularColumnNames = datasets.flatMap((dataset) =>
1063
+ dataset.columns.map((column) => column.name),
1064
+ );
1065
+ tabularDatasetNames = datasets.map((dataset) => dataset.fileName);
1066
+ // Keep complete schemas for the most likely targets, then keep a compact
1067
+ // ID/name listing for the remaining uploads. Previously only eight files
1068
+ // were visible to the model, making a later spreadsheet effectively
1069
+ // impossible to select in a mixed-upload conversation.
1070
+ const detailedDatasets = datasets.slice(0, 12);
1071
+ const compactDatasets = datasets.slice(12, 40);
1072
+ tabularContext = `\n\nAvailable tabular datasets:\n${detailedDatasets
1073
+ .map((d) => {
1074
+ const visibleColumns = d.columns.slice(0, 60);
1075
+ const colDescriptions = visibleColumns
1076
+ .map((c) => {
1077
+ let desc = `${c.name} (${c.type})`;
1078
+ if (c.type === 'date' && c.dateRange) {
1079
+ desc += ` [format: ${c.dateRange.format}, range: ${c.dateRange.min} to ${c.dateRange.max}]`;
1080
+ }
1081
+ if (c.sampleValues && c.sampleValues.length > 0) {
1082
+ desc += ` [samples: ${c.sampleValues
1083
+ .slice(0, 3)
1084
+ .map((value) => value.slice(0, 80))
1085
+ .join(', ')}]`;
1086
+ }
1087
+ return desc;
1088
+ })
1089
+ .join(', ');
1090
+ const hiddenColumns = d.columns.length - visibleColumns.length;
1091
+ const sizeHint =
1092
+ d.rowCount > 10000
1093
+ ? ' ⚠️ LARGE DATASET — use focused filters and aggregations; use code only when the optional code-analysis tool is available.'
1094
+ : '';
1095
+ return `- Dataset "${d.fileName}" (ID: ${d.id}): ${d.rowCount} rows, ${d.summary.totalColumns} columns.${sizeHint}\n Columns: ${colDescriptions}${
1096
+ hiddenColumns > 0 ? ` (+${hiddenColumns} more; query focused columns only)` : ''
1097
+ }`;
1098
+ })
1099
+ .join('\n')}${
1100
+ compactDatasets.length > 0
1101
+ ? `\nAdditional datasets (use their ID when the question names one):\n${compactDatasets
1102
+ .map(
1103
+ (d) =>
1104
+ `- Dataset "${d.fileName}" (ID: ${d.id}): ${d.rowCount} rows, ${d.summary.totalColumns} columns.`,
1105
+ )
1106
+ .join('\n')}`
1107
+ : ''
1108
+ }${
1109
+ datasets.length > detailedDatasets.length + compactDatasets.length
1110
+ ? `\n- ${datasets.length - detailedDatasets.length - compactDatasets.length} further datasets are available; ask the user which file they mean if it is not named in the question.`
1111
+ : ''
1112
+ }`;
1113
+ }
1114
+ } catch {
1115
+ /* no tabular data */
1116
+ }
1117
+
1118
+ let docContext = '';
1119
+ if (docSessionId) {
1120
+ // Build a rich field listing so the LLM can correctly match semantic meaning to field IDs.
1121
+ // Include: ID, name/label, type, current value (if any), and surrounding context text.
1122
+ const fieldLines =
1123
+ docFields && docFields.length > 0
1124
+ ? (docFields as any[])
1125
+ .map((f: any) => {
1126
+ let line = `- ID: "${f.id}" | Label: "${f.name}" | Type: ${f.type}`;
1127
+ if (f.value) line += ` | Current value: "${f.value}"`;
1128
+ if (f.context) line += ` | Surrounding text: "${String(f.context).slice(0, 120)}"`;
1129
+ if (f.placeholder) line += ` | Placeholder: "${f.placeholder}"`;
1130
+ return line;
1131
+ })
1132
+ .join('\n')
1133
+ : 'None detected.';
1134
+
1135
+ docContext = `\n\n[Active Document Session: ${docSessionId}]\nYou are currently editing a document in the Canvas.
1136
+ The user may ask you to fill out form fields or edit content.
1137
+ IMPORTANT: Use the exact field IDs listed below when calling "fillDocumentForm". Do NOT invent field IDs.
1138
+ Available Form Fields (${docFields?.length ?? 0} total):
1139
+ ${fieldLines}`;
1140
+ }
1141
+
1142
+ // The generic product prompt lists tools that intentionally are not exposed
1143
+ // in this retrieval-only chat. Keeping it out of this request prevents a
1144
+ // model from attempting an unavailable sandbox/corpus action and surfacing a
1145
+ // technical failure to the user.
1146
+ const skillInstructions = (config.skills ?? [])
1147
+ .filter((skill) => skill.enabled !== false)
1148
+ .map((skill) => {
1149
+ const source =
1150
+ skill.source === 'inline'
1151
+ ? skill.content?.slice(0, 12_000)
1152
+ : `Remote skill reference: ${skill.url}`;
1153
+ return `## ${skill.name}\n${skill.description}\n${source ?? ''}`;
1154
+ })
1155
+ .join('\n\n');
1156
+ const recentAssistantText = latestAssistantText(messagesToProcess);
1157
+ const recentAssistantExportText = latestAssistantText(messagesToProcess, 50_000);
1158
+ const parallelPreferenceQuestion = resolveParallelPreferenceQuestion(
1159
+ userText,
1160
+ precedingUserText(messagesToProcess),
1161
+ );
1162
+ const answerQuestion = parallelPreferenceQuestion ?? userText;
1163
+ const exactRepeat = findImmediateExactGroundedAnswer(evidenceMessages, userText);
1164
+ if (exactRepeat) {
1165
+ const currentScopeFingerprint = await inRequestedProject(readCurrentScopeFingerprint);
1166
+ if (currentScopeFingerprint === exactRepeat.sourceScopeFingerprint) {
1167
+ return directAssistantTextResponse(
1168
+ messages,
1169
+ exactRepeat.answer,
1170
+ exactRepeat.sourceScopeFingerprint,
1171
+ );
1172
+ }
1173
+ }
1174
+ const precedingReferenceText =
1175
+ !hasNumberedDocumentReference(userText) && requestsImagePresentation(userText)
1176
+ ? precedingNumberedDocumentReference(messagesToProcess)
1177
+ : undefined;
1178
+ const reusableEvidence = extractConversationEvidence(evidenceMessages);
1179
+ const latestTabularExportResult = extractLatestTabularExportResult(evidenceMessages);
1180
+ // This compact string is used by retrieval only, not appended to model
1181
+ // history. It preserves the immediate topic while keeping the model context
1182
+ // at the existing 20-message bound.
1183
+ const contextualKnowledgeQuery =
1184
+ parallelPreferenceQuestion ?? contextualizeKnowledgeFollowUpQuery(userText, reusableEvidence);
1185
+ // A parallel subject switch is self-contained after rewriting. Exclude the
1186
+ // previous assistant answer so small models cannot copy it over the current
1187
+ // source evidence (for example, returning the anime answer for "and fruits").
1188
+ const answerMessages = parallelPreferenceQuestion
1189
+ ? replaceLatestUserText(
1190
+ safeMessages.filter((message) => message.role === 'user').slice(-1),
1191
+ parallelPreferenceQuestion,
1192
+ )
1193
+ : safeMessages;
1194
+ const tabularQuestion = isLikelyTabularQuestion({
1195
+ text: userText,
1196
+ columnNames: tabularColumnNames,
1197
+ datasetNames: tabularDatasetNames,
1198
+ });
1199
+ const requiresSandboxAnalysis =
1200
+ hasTabularData && tabularQuestion && requiresTabularSandbox(userText);
1201
+ const isExplicitVideoCorrection =
1202
+ /^(?:no|nah),\s+|\b(?:that(?:'s| is) (?:wrong|incorrect)|correction\s*:|actually\s*,|instead\s*,|should be|not .{0,80}\bbut|i meant)\b/i.test(
1203
+ userText,
1204
+ );
1205
+ const imagePreviewFollowUp = isImagePreviewFollowUp(userText, reusableEvidence);
1206
+ const tabularFollowUp = isTabularFollowUp(userText, reusableEvidence);
1207
+ const dataExportFormat = requestedDataExportFormat(userText);
1208
+ const exportNeedsFreshData = dataExportNeedsFreshData(userText);
1209
+ const directDataExport = Boolean(
1210
+ dataExportFormat &&
1211
+ !exportNeedsFreshData &&
1212
+ (reusableEvidence.tabular ||
1213
+ (/\b(?:answer|response|result|data|table|it|this|that|above|previous|same)\b/i.test(
1214
+ userText,
1215
+ ) &&
1216
+ recentAssistantText.trim())),
1217
+ );
1218
+ const directDataExportSource = directDataExport
1219
+ ? answerScopedDataExportSource({
1220
+ table: latestTabularExportResult ?? reusableEvidence.tabular,
1221
+ answerText: dataExportUsesAnswerText(userText) ? recentAssistantExportText : undefined,
1222
+ })
1223
+ : undefined;
1224
+ // A prior table is useful to resolve a follow-up, but never as fresh answer
1225
+ // evidence. A direct chart may reuse its visible rows only while the source
1226
+ // dataset still exists in the current project.
1227
+ const canAnswerFromRecentTable = false;
1228
+ const tabularFollowUpNeedsVisualization = tabularFollowUp && requestsVisualization(userText);
1229
+ const reusableTabularDatasetStillExists = tabularEvidenceDatasetIsAvailable(
1230
+ reusableEvidence.tabular,
1231
+ availableTabularDatasetIds,
1232
+ );
1233
+ const directFollowUpVisualization =
1234
+ tabularFollowUpNeedsVisualization &&
1235
+ reusableEvidence.tabular &&
1236
+ reusableTabularDatasetStillExists
1237
+ ? createTabularVisualization(userText, reusableEvidence.tabular)
1238
+ : undefined;
1239
+ const continuesMediaTopic =
1240
+ !imagePreviewFollowUp &&
1241
+ !tabularFollowUp &&
1242
+ !isExplicitVideoCorrection &&
1243
+ continuesRecentMediaTopic(userText, reusableEvidence);
1244
+ // Previous results only help resolve a reference such as "show it". They
1245
+ // are never treated as an answer cache: a new user message gets a fresh
1246
+ // source lookup so a weak model cannot silently answer document questions
1247
+ // from general knowledge or an unrelated earlier excerpt.
1248
+ const reusesPriorEvidence = false;
1249
+ let systemPrompt =
1250
+ (config.systemPrompt ? `USER INSTRUCTIONS:\n${config.systemPrompt}\n` : '') +
1251
+ (skillInstructions ? `\nAVAILABLE AGENT SKILLS:\n${skillInstructions}\n` : '') +
1252
+ 'Never print a tool name, JSON arguments, or tool-call syntax in a user-facing answer. Use tools silently, then give the result in natural language.\n' +
1253
+ CHAT_POLICY +
1254
+ tabularContext +
1255
+ docContext +
1256
+ (imagePreviewFollowUp || tabularFollowUp || reusesPriorEvidence || isExplicitVideoCorrection
1257
+ ? formatConversationEvidence(reusableEvidence)
1258
+ : '');
1259
+ const {
1260
+ tools: allTools,
1261
+ promptFragments: dynamicToolPromptFragments,
1262
+ dynamicToolNames,
1263
+ dynamicToolWorkflows,
1264
+ dynamicToolEvidenceInputs,
1265
+ } = await getChatTools({
1266
+ projectId,
1267
+ docSessionId,
1268
+ config,
1269
+ requestText: userText,
1270
+ contextualKnowledgeQuery,
1271
+ origin: new URL(req.url).origin,
1272
+ preferredMediaAssetId: continuesMediaTopic ? reusableEvidence.mediaAssetIds[0] : undefined,
1273
+ recentExportTable: directDataExportSource,
1274
+ recentVisualizationTable: directFollowUpVisualization ? reusableEvidence.tabular : undefined,
1275
+ recentAnswerText: directDataExport && !directDataExportSource ? recentAssistantText : undefined,
1276
+ });
1277
+ // Every installed marketplace/Enterprise tool the model may call this turn
1278
+ // — generic by construction (dynamicToolNames is whatever getChatTools
1279
+ // discovered, never a hardcoded list), so a newly installed tool becomes
1280
+ // callable with no change here.
1281
+ const dynamicTools = Object.fromEntries(
1282
+ dynamicToolNames
1283
+ .map((name) => [name, allTools[name]] as const)
1284
+ .filter(([, def]) => Boolean(def)),
1285
+ );
1286
+ if (dynamicToolPromptFragments.length > 0) {
1287
+ systemPrompt += `\nINSTALLED TOOLS:\n${dynamicToolPromptFragments.join('\n')}\n`;
1288
+ }
1289
+ // Provide both RAG tools and Data Analysis tools so the model can handle complex queries (e.g. Excel)
1290
+ const builtInTools = executableTools({
1291
+ searchKnowledgeBase: allTools.searchKnowledgeBase,
1292
+ presentMedia: allTools.presentMedia,
1293
+ queryTabularData: allTools.queryTabularData,
1294
+ createDataExport: allTools.createDataExport,
1295
+ generateVisualization: allTools.generateVisualization,
1296
+ ...(allTools.executeAnalysis ? { executeAnalysis: allTools.executeAnalysis } : {}),
1297
+ inspectPdfPages: allTools.inspectPdfPages,
1298
+ analyzePdfPages: allTools.analyzePdfPages,
1299
+ analyzeImageDeeply: allTools.analyzeImageDeeply,
1300
+ // Document Canvas actions — must stay available or the model can never
1301
+ // call requestDocumentSignature/fillDocumentForm/editDocument and instead
1302
+ // falls back to asking the user in plain text.
1303
+ fillDocumentForm: allTools.fillDocumentForm,
1304
+ editDocument: allTools.editDocument,
1305
+ requestDocumentSignature: allTools.requestDocumentSignature,
1306
+ });
1307
+ const isGreeting =
1308
+ /^(hi|hello|hey|thanks|thank you|ok|sure|yes|no|please|help|how are you|good morning|good afternoon|good evening|bye|goodbye)[.!\s]*$/i.test(
1309
+ userText.trim(),
1310
+ );
1311
+ // Tool calling is not equally reliable across every supported provider.
1312
+ // Make the first source lookup deterministic for ordinary chat so even a
1313
+ // lightweight model starts from indexed evidence. Tabular questions retain
1314
+ // their own authoritative structured-data path below.
1315
+ const forceKnowledgeBaseSearch = Boolean(
1316
+ userText.trim() &&
1317
+ !isGreeting &&
1318
+ !docSessionId &&
1319
+ (!hasTabularData || !tabularQuestion) &&
1320
+ builtInTools.searchKnowledgeBase,
1321
+ );
1322
+
1323
+ if (isGreeting && builtInTools.searchKnowledgeBase) {
1324
+ // Completely remove the search tool for simple greetings to guarantee no retrieval overhead
1325
+ delete (builtInTools as any).searchKnowledgeBase;
1326
+ }
1327
+
1328
+ // Remote MCP connections are opt-in per workspace and their tool names are
1329
+ // namespaced by connection. This lets Local Chat use the same connections as
1330
+ // Agents without risking collisions with Larkup's built-in tools.
1331
+ const mcp = await openMcpTools({ forLocalChat: true });
1332
+ for (const failure of mcp.failures) {
1333
+ console.warn(`[chat] MCP connection ${failure.connectionId} unavailable: ${failure.message}`);
1334
+ }
1335
+ const tools = { ...builtInTools, ...dynamicTools, ...mcp.tools };
1336
+ const evidenceRefinementTools = dynamicToolNames.filter(
1337
+ (name) => dynamicToolWorkflows[name] === 'evidence-refinement' && Boolean(allTools[name]),
1338
+ );
1339
+ const evidenceQueryTools = dynamicToolNames.filter(
1340
+ (name) =>
1341
+ dynamicToolWorkflows[name] === 'evidence-query' &&
1342
+ dynamicToolEvidenceInputs[name] === 'media-asset' &&
1343
+ Boolean(allTools[name]),
1344
+ );
1345
+ const toolNames = Object.keys(tools) as Array<keyof typeof tools & string>;
1346
+
1347
+ // Debug: log payload sizes to console in development
1348
+ if (process.env.NODE_ENV === 'development') {
1349
+ const stringifiedMsgs = JSON.stringify(safeMessages);
1350
+ }
1351
+
1352
+ try {
1353
+ if (!directDataExport && !directFollowUpVisualization) {
1354
+ await authorizeEnterpriseAiRequest(config);
1355
+ }
1356
+ const responseStream = createUIMessageStream({
1357
+ originalMessages: messages,
1358
+ execute: async ({ writer }) => {
1359
+ let preloadedEvidence: unknown;
1360
+ let preloadedVideoEvidence = false;
1361
+ let preloadedEvidenceQueryAttempted = false;
1362
+ const preflightToolCallId = `knowledge-${crypto.randomUUID()}`;
1363
+
1364
+ if (directDataExport && dataExportFormat && builtInTools.createDataExport) {
1365
+ const exportCallId = `export-${crypto.randomUUID()}`;
1366
+ const exportInput = { format: dataExportFormat, title: 'Larkup answer' };
1367
+ writer.write({ type: 'start' });
1368
+ writer.write({
1369
+ type: 'tool-input-available',
1370
+ toolCallId: exportCallId,
1371
+ toolName: 'createDataExport',
1372
+ input: exportInput,
1373
+ });
1374
+ const exportOutput = await (builtInTools.createDataExport as any).execute(exportInput, {
1375
+ toolCallId: exportCallId,
1376
+ });
1377
+ writer.write({
1378
+ type: 'tool-output-available',
1379
+ toolCallId: exportCallId,
1380
+ output: exportOutput,
1381
+ });
1382
+ const answerId = `answer-${crypto.randomUUID()}`;
1383
+ const exportText = exportOutput?.success
1384
+ ? `Your ${dataExportFormat.toUpperCase()} file is ready to download.`
1385
+ : exportOutput?.error || 'The export could not be created.';
1386
+ writer.write({ type: 'text-start', id: answerId });
1387
+ writer.write({ type: 'text-delta', id: answerId, delta: exportText });
1388
+ writer.write({ type: 'text-end', id: answerId });
1389
+ await mcp.close();
1390
+ return;
1391
+ }
1392
+
1393
+ // A direct chart follow-up is a presentation of the exact bounded rows
1394
+ // already shown to the user. Build it on the host instead of asking the
1395
+ // model to copy those rows into a second tool call; that second call was
1396
+ // the source of both empty chart payloads and required-tool failures.
1397
+ if (directFollowUpVisualization && builtInTools.generateVisualization) {
1398
+ const chartCallId = `chart-${crypto.randomUUID()}`;
1399
+ writer.write({ type: 'start' });
1400
+ writer.write({
1401
+ type: 'tool-input-available',
1402
+ toolCallId: chartCallId,
1403
+ toolName: 'generateVisualization',
1404
+ input: directFollowUpVisualization,
1405
+ });
1406
+ const chartOutput = await (builtInTools.generateVisualization as any).execute(
1407
+ directFollowUpVisualization,
1408
+ { toolCallId: chartCallId },
1409
+ );
1410
+ writer.write({
1411
+ type: 'tool-output-available',
1412
+ toolCallId: chartCallId,
1413
+ output: chartOutput,
1414
+ });
1415
+ const answerId = `answer-${crypto.randomUUID()}`;
1416
+ writer.write({ type: 'text-start', id: answerId });
1417
+ writer.write({
1418
+ type: 'text-delta',
1419
+ id: answerId,
1420
+ delta: 'Here is the requested chart based on the data checked in the previous answer.',
1421
+ });
1422
+ writer.write({ type: 'text-end', id: answerId });
1423
+ await mcp.close();
1424
+ return;
1425
+ }
1426
+
1427
+ if (forceKnowledgeBaseSearch && builtInTools.searchKnowledgeBase) {
1428
+ // Open the message ourselves since a tool part is about to stream
1429
+ // before streamText runs. Without this, the client sees a tool
1430
+ // part with no owning message yet and starts an implicit one of
1431
+ // its own, then streamText's own 'start' event opens a second,
1432
+ // real message -- rendering as two separate assistant messages
1433
+ // (and two duplicate "Searched" blocks) for one turn.
1434
+ writer.write({ type: 'start' });
1435
+ // Start with a real, visible tool call. This removes provider-specific
1436
+ // ambiguity without hiding the source/citation and media progress UI.
1437
+ writer.write({
1438
+ type: 'tool-input-available',
1439
+ toolCallId: preflightToolCallId,
1440
+ toolName: 'searchKnowledgeBase',
1441
+ input: { query: userText },
1442
+ });
1443
+ try {
1444
+ preloadedEvidence = await (builtInTools.searchKnowledgeBase as any).execute(
1445
+ { query: userText },
1446
+ { toolCallId: preflightToolCallId },
1447
+ );
1448
+ } catch (error) {
1449
+ preloadedEvidence = {
1450
+ query: userText,
1451
+ hits: [],
1452
+ error: error instanceof Error ? error.message : 'Search could not be completed.',
1453
+ };
1454
+ }
1455
+ writer.write({
1456
+ type: 'tool-output-available',
1457
+ toolCallId: preflightToolCallId,
1458
+ output: preloadedEvidence,
1459
+ });
1460
+
1461
+ // A PDF hit is a document-level locator, not necessarily the exact
1462
+ // page. Read its bounded local page evidence before asking the chat
1463
+ // model to compose an answer. This works whether or not visual
1464
+ // derivatives were enabled at upload time and avoids selecting the
1465
+ // first embedded image (often a cover) for a later preview request.
1466
+ const pdfSource = findRetrievedPdfSource(preloadedEvidence);
1467
+ if (pdfSource && builtInTools.inspectPdfPages) {
1468
+ const inspectionCallId = `pdf-pages-${crypto.randomUUID()}`;
1469
+ const pdfInspectionQuestion = [
1470
+ answerQuestion,
1471
+ contextualKnowledgeQuery,
1472
+ recentAssistantText,
1473
+ precedingReferenceText,
1474
+ ]
1475
+ .filter((text): text is string => Boolean(text?.trim()))
1476
+ .join('\n\n');
1477
+ const preferredPageNumbers = preferredPdfPagesForInspection(
1478
+ pdfSource.pageNumber,
1479
+ pdfInspectionQuestion,
1480
+ );
1481
+ const inspectionInput = {
1482
+ documentId: pdfSource.documentId,
1483
+ // Resolve a terse follow-up ("render it") against the same
1484
+ // compact source context used for the preceding retrieval and
1485
+ // the immediately prior answer. An explicit Figure/Table/Eq
1486
+ // reference intentionally lets the local PDF rank its own
1487
+ // pages instead of blindly trusting an incidental vector hit.
1488
+ question: pdfInspectionQuestion,
1489
+ ...(preferredPageNumbers ? { pageNumbers: preferredPageNumbers } : {}),
1490
+ };
1491
+ writer.write({
1492
+ type: 'tool-input-available',
1493
+ toolCallId: inspectionCallId,
1494
+ toolName: 'inspectPdfPages',
1495
+ input: inspectionInput,
1496
+ });
1497
+ let pdfInspection: unknown;
1498
+ try {
1499
+ pdfInspection = await (builtInTools.inspectPdfPages as any).execute(inspectionInput, {
1500
+ toolCallId: inspectionCallId,
1501
+ });
1502
+ } catch (error) {
1503
+ pdfInspection = {
1504
+ success: false,
1505
+ error: error instanceof Error ? error.message : 'Could not inspect the source PDF.',
1506
+ };
1507
+ }
1508
+ writer.write({
1509
+ type: 'tool-output-available',
1510
+ toolCallId: inspectionCallId,
1511
+ output: pdfInspection,
1512
+ });
1513
+ preloadedEvidence = {
1514
+ ...(preloadedEvidence &&
1515
+ typeof preloadedEvidence === 'object' &&
1516
+ !Array.isArray(preloadedEvidence)
1517
+ ? (preloadedEvidence as Record<string, unknown>)
1518
+ : { retrieval: preloadedEvidence }),
1519
+ pdfInspection,
1520
+ };
1521
+
1522
+ // Presentation is deterministic once local page selection has
1523
+ // succeeded. Returning the selected rendered page here keeps a
1524
+ // weak chat model from substituting an arbitrary indexed image.
1525
+ const inspectedPages =
1526
+ pdfInspection && typeof pdfInspection === 'object'
1527
+ ? (pdfInspection as { pages?: Array<{ pageNumber?: unknown }>; success?: unknown })
1528
+ .pages
1529
+ : undefined;
1530
+ const selectedPageNumbers = (inspectedPages ?? []).flatMap((page) =>
1531
+ typeof page.pageNumber === 'number' && Number.isInteger(page.pageNumber)
1532
+ ? [page.pageNumber]
1533
+ : [],
1534
+ );
1535
+ const selectedPage = inspectedPages?.find(
1536
+ (page) => typeof page.pageNumber === 'number' && Number.isInteger(page.pageNumber),
1537
+ )?.pageNumber;
1538
+
1539
+ // The vision request is issued by the existing, analytics-tracked
1540
+ // page-analysis tool. Running it from the deterministic evidence
1541
+ // path means GPT-3.5-class chat models receive exact rendered-page
1542
+ // readings instead of being asked to decide whether to call a
1543
+ // second tool. It stays bounded to the three locally selected
1544
+ // source pages.
1545
+ if (
1546
+ !requestsImagePresentation(userText) &&
1547
+ requiresPdfVisualAnalysis(userText) &&
1548
+ selectedPageNumbers.length > 0 &&
1549
+ builtInTools.analyzePdfPages
1550
+ ) {
1551
+ const analysisCallId = `pdf-analysis-${crypto.randomUUID()}`;
1552
+ const analysisInput = {
1553
+ documentId: pdfSource.documentId,
1554
+ pageNumbers: selectedPageNumbers,
1555
+ prompt: userText,
1556
+ };
1557
+ writer.write({
1558
+ type: 'tool-input-available',
1559
+ toolCallId: analysisCallId,
1560
+ toolName: 'analyzePdfPages',
1561
+ input: analysisInput,
1562
+ });
1563
+ let pdfVisualAnalysis: unknown;
1564
+ try {
1565
+ pdfVisualAnalysis = await (builtInTools.analyzePdfPages as any).execute(
1566
+ analysisInput,
1567
+ { toolCallId: analysisCallId },
1568
+ );
1569
+ } catch (error) {
1570
+ pdfVisualAnalysis = {
1571
+ success: false,
1572
+ error:
1573
+ error instanceof Error
1574
+ ? error.message
1575
+ : 'Could not analyze the rendered PDF pages.',
1576
+ };
1577
+ }
1578
+ writer.write({
1579
+ type: 'tool-output-available',
1580
+ toolCallId: analysisCallId,
1581
+ output: pdfVisualAnalysis,
1582
+ });
1583
+ preloadedEvidence = {
1584
+ ...(preloadedEvidence &&
1585
+ typeof preloadedEvidence === 'object' &&
1586
+ !Array.isArray(preloadedEvidence)
1587
+ ? (preloadedEvidence as Record<string, unknown>)
1588
+ : { retrieval: preloadedEvidence }),
1589
+ pdfVisualAnalysis,
1590
+ };
1591
+ }
1592
+
1593
+ if (requestsImagePresentation(userText) && selectedPage && builtInTools.presentMedia) {
1594
+ const previewCallId = `pdf-preview-${crypto.randomUUID()}`;
1595
+ const previewInput = { documentId: pdfSource.documentId, pageNumber: selectedPage };
1596
+ writer.write({
1597
+ type: 'tool-input-available',
1598
+ toolCallId: previewCallId,
1599
+ toolName: 'presentMedia',
1600
+ input: previewInput,
1601
+ });
1602
+ let previewOutput: unknown;
1603
+ try {
1604
+ previewOutput = await (builtInTools.presentMedia as any).execute(previewInput, {
1605
+ toolCallId: previewCallId,
1606
+ });
1607
+ } catch (error) {
1608
+ previewOutput = {
1609
+ success: false,
1610
+ error:
1611
+ error instanceof Error
1612
+ ? error.message
1613
+ : 'Could not render the selected PDF page.',
1614
+ };
1615
+ }
1616
+ writer.write({
1617
+ type: 'tool-output-available',
1618
+ toolCallId: previewCallId,
1619
+ output: previewOutput,
1620
+ });
1621
+ if (
1622
+ previewOutput &&
1623
+ typeof previewOutput === 'object' &&
1624
+ (previewOutput as { success?: unknown }).success === true
1625
+ ) {
1626
+ const answerId = `answer-${crypto.randomUUID()}`;
1627
+ writer.write({ type: 'text-start', id: answerId });
1628
+ writer.write({
1629
+ type: 'text-delta',
1630
+ id: answerId,
1631
+ delta: `Here is ${pdfSource.title ?? 'the selected document'} page ${selectedPage}.`,
1632
+ });
1633
+ writer.write({ type: 'text-end', id: answerId });
1634
+ await mcp.close();
1635
+ return;
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ const mediaAssetId = explicitMediaEvidenceAssetId(preloadedEvidence);
1641
+ // A completed source is already known. Invoke its one generic
1642
+ // media-evidence capability directly instead of making the selected
1643
+ // chat model construct a large tool payload before any evidence can
1644
+ // be read. This is driven by the Marketplace capability contract,
1645
+ // not a tool id, language, genre, or question word list.
1646
+ if (mediaAssetId && evidenceQueryTools.length === 1) {
1647
+ const evidenceToolName = evidenceQueryTools[0]!;
1648
+ const evidenceCallId = `media-evidence-${crypto.randomUUID()}`;
1649
+ // Planning is a small structured operation, not the answer model's
1650
+ // prose task. Gateway projects use the fast general planner so a
1651
+ // selected reasoning model cannot spend the interactive budget
1652
+ // thinking before it emits the directive; direct providers retain
1653
+ // the user's selected model and its own credentials.
1654
+ const plannerModelId =
1655
+ resolvedProvider === 'vercel_ai_gateway' ? 'openai/gpt-4o-mini' : chatModelId;
1656
+ const plannerModel =
1657
+ plannerModelId === chatModelId
1658
+ ? model
1659
+ : createChatModel(
1660
+ resolvedProvider,
1661
+ plannerModelId,
1662
+ apiKey,
1663
+ config.customChatModels,
1664
+ );
1665
+ const plannerInput = {
1666
+ model: plannerModel,
1667
+ providerOptions: gatewayProviderOptions(resolvedProvider, plannerModelId),
1668
+ question: userText,
1669
+ ...(continuesMediaTopic && reusableEvidence.mediaQuestion
1670
+ ? { previousQuestion: reusableEvidence.mediaQuestion }
1671
+ : {}),
1672
+ };
1673
+ const planned = await planVideoEvidenceQuery(plannerInput);
1674
+ const resolvedMediaQuestion = planned?.resolvedQuestion ?? userText;
1675
+ const plannedInvestigation = planned?.directive;
1676
+ const sourcePosition = await userSpecifiedSourcePosition({
1677
+ ...plannerInput,
1678
+ question: resolvedMediaQuestion,
1679
+ });
1680
+ const hasUserSpecifiedPosition = sourcePosition?.hasPosition;
1681
+ const connectsBeyondPosition = sourcePosition?.connectsBeyondPosition === true;
1682
+ // `trace` is already the planner's language-neutral declaration
1683
+ // that the answer connects moments. Preserve its supplied source
1684
+ // boundary and give the evidence capability its visibility record
1685
+ // set even if the auxiliary binary classifier was inconclusive.
1686
+ const tracesBeyondPosition =
1687
+ connectsBeyondPosition || plannedInvestigation?.goal === 'trace';
1688
+ const investigation =
1689
+ hasUserSpecifiedPosition === false && plannedInvestigation?.timeRange
1690
+ ? { scope: 'source' as const, goal: 'synthesize' as const }
1691
+ : tracesBeyondPosition && plannedInvestigation?.timeRange
1692
+ ? {
1693
+ ...plannedInvestigation,
1694
+ scope: 'temporal' as const,
1695
+ goal: 'trace' as const,
1696
+ evidence: ['visual'] as const,
1697
+ recordSet: 'observed' as const,
1698
+ }
1699
+ : plannedInvestigation;
1700
+ const evidenceInput = {
1701
+ mediaAssetId,
1702
+ query: resolvedMediaQuestion,
1703
+ ...(investigation ? { investigation } : {}),
1704
+ };
1705
+ preloadedEvidenceQueryAttempted = true;
1706
+ writer.write({
1707
+ type: 'tool-input-available',
1708
+ toolCallId: evidenceCallId,
1709
+ toolName: evidenceToolName,
1710
+ input: evidenceInput,
1711
+ });
1712
+ let evidenceOutput: unknown;
1713
+ try {
1714
+ evidenceOutput = await (allTools[evidenceToolName] as any).execute(evidenceInput, {
1715
+ toolCallId: evidenceCallId,
1716
+ });
1717
+ } catch (error) {
1718
+ evidenceOutput = {
1719
+ success: false,
1720
+ mediaAssetId,
1721
+ error:
1722
+ error instanceof Error
1723
+ ? error.message
1724
+ : 'The indexed media evidence could not be read.',
1725
+ };
1726
+ }
1727
+ // A source-wide evidence action owns an opaque chronological
1728
+ // cursor. Follow it here, before a prose model sees the result;
1729
+ // otherwise a long lecture, interview, match, or recording is
1730
+ // silently reduced to its first page. This is capability protocol
1731
+ // handling, independent of the source's subject or language.
1732
+ if (evidenceOutput && typeof evidenceOutput === 'object') {
1733
+ evidenceOutput = await collectExhaustiveVideoEvidencePages(
1734
+ (pageInput, options) =>
1735
+ (allTools[evidenceToolName] as any).execute(pageInput, options),
1736
+ evidenceInput,
1737
+ evidenceOutput,
1738
+ evidenceCallId,
1739
+ );
1740
+ }
1741
+ writer.write({
1742
+ type: 'tool-output-available',
1743
+ toolCallId: evidenceCallId,
1744
+ output: evidenceOutput,
1745
+ });
1746
+ preloadedEvidence = evidenceOutput;
1747
+ preloadedVideoEvidence = containsAnswerLevelMediaEvidence(evidenceOutput);
1748
+ }
1749
+ }
1750
+
1751
+ const useVisualDeterministicAnswer =
1752
+ preloadedVideoEvidence && !mediaEvidenceRequestsOnlySpeech(preloadedEvidence);
1753
+ const deterministicAnswer =
1754
+ formatExhaustiveMediaAnswer(preloadedEvidence, userText) ??
1755
+ (useVisualDeterministicAnswer
1756
+ ? (formatLocatedObservedSubjectAnswer(preloadedEvidence) ??
1757
+ collectQuestionMatchedDirectClaims(preloadedEvidence, userText)[0] ??
1758
+ formatDirectObservationAnswer(preloadedEvidence, userText) ??
1759
+ formatObservedAppearanceAnswer(preloadedEvidence))
1760
+ : undefined);
1761
+ if (deterministicAnswer) {
1762
+ const answerId = `answer-${crypto.randomUUID()}`;
1763
+ writer.write({ type: 'text-start', id: answerId });
1764
+ writer.write({ type: 'text-delta', id: answerId, delta: deterministicAnswer });
1765
+ writer.write({ type: 'text-end', id: answerId });
1766
+ await mcp.close();
1767
+ return;
1768
+ }
1769
+
1770
+ // Once an evidence action has completed, composition is a short,
1771
+ // grounded rendering task. Some selectable reasoning models consume a
1772
+ // whole chat step before emitting prose, which leaves the user with an
1773
+ // aborted stream despite a fast, verified index result. Gateway video
1774
+ // answers therefore use the same low-latency grounded writer as the
1775
+ // planner; non-video and direct-provider chats keep the selected model.
1776
+ const hasPreloadedMediaEvidence = Boolean(explicitMediaEvidenceAssetId(preloadedEvidence));
1777
+ const evidenceWriterModelId =
1778
+ hasPreloadedMediaEvidence && resolvedProvider === 'vercel_ai_gateway'
1779
+ ? 'openai/gpt-4o-mini'
1780
+ : chatModelId;
1781
+ const evidenceWriterModel =
1782
+ evidenceWriterModelId === chatModelId
1783
+ ? model
1784
+ : createChatModel(
1785
+ resolvedProvider,
1786
+ evidenceWriterModelId,
1787
+ apiKey,
1788
+ config.customChatModels,
1789
+ );
1790
+ // Some reasoning models return prose even when the provider request
1791
+ // requires a specific tool. Use a small, proven Gateway tool caller
1792
+ // only for the tabular evidence step, then return to the selected model
1793
+ // for the grounded prose answer on the next step.
1794
+ const tabularToolModelId = 'openai/gpt-4o-mini';
1795
+ const tabularToolStepOverrides =
1796
+ resolvedProvider === 'vercel_ai_gateway' &&
1797
+ hasTabularData &&
1798
+ (tabularQuestion || tabularFollowUp)
1799
+ ? {
1800
+ model: createChatModel(
1801
+ resolvedProvider,
1802
+ tabularToolModelId,
1803
+ apiKey,
1804
+ config.customChatModels,
1805
+ ),
1806
+ providerOptions: gatewayProviderOptions(resolvedProvider, tabularToolModelId),
1807
+ }
1808
+ : {};
1809
+ const usageByModel = new Map<
1810
+ string,
1811
+ { modelId: string; promptTokens: number; completionTokens: number; totalTokens: number }
1812
+ >();
1813
+ const result = streamText({
1814
+ model: evidenceWriterModel,
1815
+ repairToolCall: repairChatToolCall,
1816
+ // The Gateway owns model failover. Retrying the same quota-limited model
1817
+ // only makes the user wait longer and consumes their request allowance.
1818
+ maxRetries: 0,
1819
+ // The evidence action has its own bounded budget. Once evidence is
1820
+ // ready, the answer model must not leave the user waiting forever.
1821
+ timeout:
1822
+ preloadedVideoEvidence || preloadedEvidenceQueryAttempted
1823
+ ? {
1824
+ // Retrieval and any bounded source check already finished.
1825
+ // This last call only turns verified evidence into prose, but
1826
+ // cross-modal evidence can still take a provider longer than a
1827
+ // trivial text reply. Keep it bounded while allowing a normal
1828
+ // complete answer instead of prematurely exposing recovery text.
1829
+ totalMs: 45_000,
1830
+ stepMs: 35_000,
1831
+ firstChunkMs: 25_000,
1832
+ chunkMs: 20_000,
1833
+ toolMs: 20_000,
1834
+ }
1835
+ : requiresSandboxAnalysis
1836
+ ? {
1837
+ // A deep spreadsheet task needs time for the model to write
1838
+ // code, a bounded local execution, and a grounded response.
1839
+ // Keep this wider budget limited to explicit analytical
1840
+ // operations so ordinary table questions remain responsive.
1841
+ totalMs: 150_000,
1842
+ stepMs: 90_000,
1843
+ firstChunkMs: 70_000,
1844
+ chunkMs: 70_000,
1845
+ toolMs: 45_000,
1846
+ }
1847
+ : {
1848
+ totalMs: 45_000,
1849
+ stepMs: 35_000,
1850
+ firstChunkMs: 25_000,
1851
+ chunkMs: 20_000,
1852
+ toolMs: 30_000,
1853
+ },
1854
+ providerOptions: gatewayProviderOptions(resolvedProvider, evidenceWriterModelId),
1855
+ system: `${systemPrompt}${
1856
+ preloadedEvidence === undefined
1857
+ ? ''
1858
+ : preloadedEvidenceContext(preloadedEvidence, answerQuestion)
1859
+ }`,
1860
+ messages: await convertToModelMessages(answerMessages, { tools }),
1861
+ // Leave enough room for structured tool inputs (especially charts) and
1862
+ // a complete grounded answer while keeping the response bounded.
1863
+ maxOutputTokens:
1864
+ preloadedEvidence !== undefined && containsExhaustiveEvidence(preloadedEvidence)
1865
+ ? 8_000
1866
+ : hasPreloadedMediaEvidence && evidenceWriterModelId !== chatModelId
1867
+ ? 1_200
1868
+ : preloadedVideoEvidence
1869
+ ? // Reasoning-capable providers may spend part of this budget
1870
+ // before emitting visible prose. A short answer ceiling here
1871
+ // therefore produced a truncated factual reply even after the
1872
+ // evidence action had completed quickly.
1873
+ 3_600
1874
+ : 2_400,
1875
+ // Evidence loop: retrieve → verify → bounded inspect/refinement → retrieve
1876
+ // again → answer. The inspection tool itself cannot authorize a claim.
1877
+ // Most chats finish after their first answer. The upper bound leaves
1878
+ // room for retrieve → verify → inspect → re-verify when a tool result
1879
+ // reveals a video claim that needs source-level corroboration.
1880
+ stopWhen: stepCountIs(5),
1881
+ toolChoice: 'auto',
1882
+ prepareStep: ({ stepNumber, messages }) => {
1883
+ if (preloadedEvidence !== undefined) {
1884
+ const preloadedMediaAssetId = explicitMediaEvidenceAssetId(preloadedEvidence);
1885
+ if (
1886
+ !preloadedVideoEvidence &&
1887
+ !preloadedEvidenceQueryAttempted &&
1888
+ preloadedMediaAssetId &&
1889
+ evidenceQueryTools.length > 0 &&
1890
+ stepNumber === 0
1891
+ ) {
1892
+ return {
1893
+ ...(evidenceQueryTools.length === 1
1894
+ ? {
1895
+ toolChoice: {
1896
+ type: 'tool' as const,
1897
+ toolName: evidenceQueryTools[0],
1898
+ } as any,
1899
+ }
1900
+ : {}),
1901
+ activeTools: evidenceQueryTools as any,
1902
+ messages: compactToolContextForModel(messages),
1903
+ };
1904
+ }
1905
+ // The preflight above reads a bounded page set for every PDF
1906
+ // hit. It is stronger evidence than a flattened extraction and
1907
+ // applies equally to equations, captions, tables, scans, and
1908
+ // vector diagrams. Only requests that depend on visual/layout
1909
+ // fidelity need a second, rendered-page analysis pass.
1910
+ if (hasRetrievedPdfEvidence(preloadedEvidence)) {
1911
+ const inspection =
1912
+ preloadedEvidence && typeof preloadedEvidence === 'object'
1913
+ ? (preloadedEvidence as { pdfInspection?: { success?: unknown } }).pdfInspection
1914
+ : undefined;
1915
+ const visualAnalysisAttempted =
1916
+ preloadedEvidence &&
1917
+ typeof preloadedEvidence === 'object' &&
1918
+ Object.prototype.hasOwnProperty.call(preloadedEvidence, 'pdfVisualAnalysis');
1919
+ if (inspection?.success === true) {
1920
+ if (
1921
+ requiresPdfVisualAnalysis(userText) &&
1922
+ !visualAnalysisAttempted &&
1923
+ stepNumber === 0
1924
+ ) {
1925
+ return {
1926
+ toolChoice: { type: 'tool', toolName: 'analyzePdfPages' },
1927
+ activeTools: ['analyzePdfPages'],
1928
+ messages: compactToolContextForModel(messages),
1929
+ };
1930
+ }
1931
+ return {
1932
+ toolChoice: 'none' as const,
1933
+ activeTools: [],
1934
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
1935
+ };
1936
+ }
1937
+ if (stepNumber === 0) {
1938
+ return {
1939
+ toolChoice: { type: 'tool', toolName: 'inspectPdfPages' },
1940
+ activeTools: ['inspectPdfPages'],
1941
+ messages: compactToolContextForModel(messages),
1942
+ };
1943
+ }
1944
+ return {
1945
+ toolChoice: 'none' as const,
1946
+ activeTools: [],
1947
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
1948
+ };
1949
+ }
1950
+ // A vector hit from a spreadsheet is a locator, not a reliable
1951
+ // representation of the sheet. Once such a source is found,
1952
+ // route the answer through the structured dataset API so exact
1953
+ // filters and values are read from the full indexed table.
1954
+ if (
1955
+ hasTabularData &&
1956
+ hasRetrievedTabularEvidence(preloadedEvidence, tabularDatasetNames)
1957
+ ) {
1958
+ if (stepNumber === 0) {
1959
+ return {
1960
+ ...tabularToolStepOverrides,
1961
+ toolChoice: { type: 'tool' as const, toolName: 'queryTabularData' as const },
1962
+ activeTools: ['queryTabularData'],
1963
+ messages: compactToolContextForModel(messages),
1964
+ };
1965
+ }
1966
+ return {
1967
+ toolChoice: 'none' as const,
1968
+ activeTools: [],
1969
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
1970
+ };
1971
+ }
1972
+ // A retrieved PDF image is only a navigation hint. Structural
1973
+ // questions need one bounded visual read before they can be
1974
+ // answered; otherwise the model is forced to guess from captions.
1975
+ if (
1976
+ !hasRetrievedPdfEvidence(preloadedEvidence) &&
1977
+ hasRetrievedImageEvidence(preloadedEvidence) &&
1978
+ stepNumber === 0
1979
+ ) {
1980
+ if (requestsImagePresentation(userText)) {
1981
+ return {
1982
+ toolChoice: { type: 'tool', toolName: 'presentMedia' },
1983
+ activeTools: ['presentMedia'],
1984
+ messages: compactToolContextForModel(messages),
1985
+ };
1986
+ }
1987
+ if (!shouldInspectRetrievedImage(userText, preloadedEvidence)) {
1988
+ return {
1989
+ toolChoice: 'none' as const,
1990
+ activeTools: [],
1991
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
1992
+ };
1993
+ }
1994
+ return {
1995
+ activeTools: ['analyzeImageDeeply'],
1996
+ messages: compactToolContextForModel(messages),
1997
+ };
1998
+ }
1999
+ // The server already gathered the current evidence. Do not rely on
2000
+ // the selected model to make (or obey) a second tool call before it
2001
+ // can formulate the grounded answer.
2002
+ return {
2003
+ toolChoice: 'none' as const,
2004
+ activeTools: [],
2005
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2006
+ };
2007
+ }
2008
+ if (imagePreviewFollowUp && builtInTools.presentMedia) {
2009
+ return stepNumber === 0
2010
+ ? {
2011
+ toolChoice: { type: 'tool', toolName: 'presentMedia' },
2012
+ activeTools: ['presentMedia'],
2013
+ messages: compactToolContextForModel(messages),
2014
+ }
2015
+ : {
2016
+ toolChoice: 'none' as const,
2017
+ activeTools: [],
2018
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2019
+ };
2020
+ }
2021
+
2022
+ if (reusesPriorEvidence) {
2023
+ return {
2024
+ toolChoice: 'none' as const,
2025
+ activeTools: [],
2026
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2027
+ };
2028
+ }
2029
+
2030
+ if (tabularFollowUp) {
2031
+ if (dataExportFormat && stepNumber === 1 && builtInTools.createDataExport) {
2032
+ return {
2033
+ toolChoice: { type: 'tool' as const, toolName: 'createDataExport' },
2034
+ activeTools: ['createDataExport'],
2035
+ messages: compactToolContextForModel(messages),
2036
+ };
2037
+ }
2038
+ if (canAnswerFromRecentTable && !tabularFollowUpNeedsVisualization) {
2039
+ return {
2040
+ toolChoice: 'none' as const,
2041
+ activeTools: [],
2042
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2043
+ };
2044
+ }
2045
+ return stepNumber === 0
2046
+ ? {
2047
+ ...tabularToolStepOverrides,
2048
+ toolChoice: { type: 'tool' as const, toolName: 'queryTabularData' as const },
2049
+ activeTools: ['queryTabularData'],
2050
+ messages: compactToolContextForModel(messages),
2051
+ }
2052
+ : {
2053
+ toolChoice: 'none' as const,
2054
+ activeTools: [],
2055
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2056
+ };
2057
+ }
2058
+
2059
+ if (hasTabularData && tabularQuestion) {
2060
+ // Exact spreadsheet questions should always start with the
2061
+ // structured query engine. It works for large sheets without a
2062
+ // Python environment and removes model/provider variance that
2063
+ // previously sent simple "highest/lowest" questions to code.
2064
+ // Code analysis can receive multiple explicitly selected sheets
2065
+ // when a relationship cannot be answered by one bounded query.
2066
+ if (dataExportFormat && stepNumber === 1 && builtInTools.createDataExport) {
2067
+ return {
2068
+ toolChoice: { type: 'tool' as const, toolName: 'createDataExport' },
2069
+ activeTools: ['createDataExport'],
2070
+ messages: compactToolContextForModel(messages),
2071
+ };
2072
+ }
2073
+ const routing = tabularToolsForStep({
2074
+ stepNumber,
2075
+ toolNames: toolNames as string[],
2076
+ requiresSandbox: requiresTabularSandbox(userText),
2077
+ });
2078
+ return {
2079
+ ...routing,
2080
+ ...(routing.toolChoice !== 'none' ? tabularToolStepOverrides : {}),
2081
+ toolChoice: routing.toolChoice as any,
2082
+ activeTools: routing.activeTools.filter(
2083
+ (name) => !evidenceQueryTools.includes(name as any),
2084
+ ) as any,
2085
+ messages:
2086
+ routing.toolChoice === 'none'
2087
+ ? withFinalAnswerNudge(compactToolContextForModel(messages))
2088
+ : compactToolContextForModel(messages),
2089
+ };
2090
+ }
2091
+
2092
+ const mediaFlow = mediaEvidenceFlow(
2093
+ messages,
2094
+ reusableEvidence.mediaAssetIds,
2095
+ evidenceQueryTools,
2096
+ );
2097
+ if (mediaFlow.hasMediaAsset && evidenceQueryTools.length > 0) {
2098
+ if (mediaFlow.hasCompletedEvidence) {
2099
+ return {
2100
+ toolChoice: 'none' as const,
2101
+ activeTools: [],
2102
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2103
+ };
2104
+ }
2105
+ if (mediaFlow.evidenceQueries === 0) {
2106
+ return {
2107
+ ...(evidenceQueryTools.length === 1
2108
+ ? {
2109
+ toolChoice: {
2110
+ type: 'tool' as const,
2111
+ toolName: evidenceQueryTools[0],
2112
+ } as any,
2113
+ }
2114
+ : {}),
2115
+ activeTools: evidenceQueryTools as any,
2116
+ messages: compactToolContextForModel(messages),
2117
+ };
2118
+ }
2119
+ return {
2120
+ toolChoice: 'none' as const,
2121
+ activeTools: [],
2122
+ messages: withFinalAnswerNudge(compactToolContextForModel(messages)),
2123
+ };
2124
+ }
2125
+
2126
+ const step = retrievalToolsForStep({
2127
+ stepNumber,
2128
+ forceKnowledgeBaseSearch,
2129
+ forceWebSearch: false,
2130
+ toolNames,
2131
+ finalAnswerStep: 2,
2132
+ });
2133
+ const compacted = compactToolContextForModel(messages);
2134
+ return {
2135
+ ...step,
2136
+ messages: step?.toolChoice === 'none' ? withFinalAnswerNudge(compacted) : compacted,
2137
+ };
2138
+ },
2139
+ onStepFinish: ({ usage, model: stepModel }) => {
2140
+ const reportedModelId = stepModel.modelId || chatModelId;
2141
+ const modelId =
2142
+ [tabularToolModelId, evidenceWriterModelId, chatModelId].find(
2143
+ (candidate) =>
2144
+ candidate === reportedModelId || candidate.endsWith(`/${reportedModelId}`),
2145
+ ) ?? reportedModelId;
2146
+ const promptTokens = usage.inputTokens ?? 0;
2147
+ const completionTokens = usage.outputTokens ?? 0;
2148
+ const totalTokens = usage.totalTokens ?? promptTokens + completionTokens;
2149
+ const current = usageByModel.get(modelId);
2150
+ usageByModel.set(modelId, {
2151
+ modelId,
2152
+ promptTokens: (current?.promptTokens ?? 0) + promptTokens,
2153
+ completionTokens: (current?.completionTokens ?? 0) + completionTokens,
2154
+ totalTokens: (current?.totalTokens ?? 0) + totalTokens,
2155
+ });
2156
+ },
2157
+ onFinish: async () => {
2158
+ try {
2159
+ const { trackUsageEvent, estimateCost } =
2160
+ await import('@larkup/core/analytics-store');
2161
+ for (const usage of usageByModel.values()) {
2162
+ const estimatedCost = estimateCost(
2163
+ usage.modelId,
2164
+ usage.promptTokens,
2165
+ usage.completionTokens,
2166
+ );
2167
+ await trackUsageEvent({
2168
+ type: 'chat',
2169
+ modelId: usage.modelId,
2170
+ provider: resolvedProvider,
2171
+ promptTokens: usage.promptTokens,
2172
+ completionTokens: usage.completionTokens,
2173
+ totalTokens: usage.totalTokens,
2174
+ estimatedCost,
2175
+ timestamp: new Date().toISOString(),
2176
+ });
2177
+ trackEnterpriseAiUsage(config, {
2178
+ modelId: usage.modelId,
2179
+ inputTokens: usage.promptTokens,
2180
+ outputTokens: usage.completionTokens,
2181
+ costUsd: estimatedCost,
2182
+ });
2183
+ }
2184
+ } finally {
2185
+ await mcp.close();
2186
+ }
2187
+ },
2188
+ tools,
2189
+ });
2190
+
2191
+ writer.merge(
2192
+ result
2193
+ .toUIMessageStream({
2194
+ // The preflight branch above already sent the message's 'start' event
2195
+ // itself (see the comment there) -- a second one here would split the
2196
+ // response into two separate assistant messages on the client.
2197
+ sendStart: !(forceKnowledgeBaseSearch && builtInTools.searchKnowledgeBase),
2198
+ // Reasoning is execution detail; keep the chat focused on the answer
2199
+ // and the compact, inspectable evidence UI.
2200
+ sendReasoning: false,
2201
+ onError: (error: any) => {
2202
+ // Extract the deepest error message available
2203
+ const rawMessage: string =
2204
+ error?.lastError?.message ||
2205
+ error?.message ||
2206
+ error?.error?.message ||
2207
+ (typeof error === 'string' ? error : '');
2208
+
2209
+ // Only log non-trivial errors to console (skip tool-routing noise)
2210
+ const isToolRouting =
2211
+ rawMessage.includes('unavailable tool') || error?.name === 'AI_NoSuchToolError';
2212
+ if (!isToolRouting) {
2213
+ console.error('[chat] stream error:', rawMessage);
2214
+ }
2215
+
2216
+ // ── Rate limit / quota exceeded ──
2217
+ if (
2218
+ rawMessage.includes('rate-limited') ||
2219
+ rawMessage.includes('rate_limit') ||
2220
+ rawMessage.includes('RateLimitError') ||
2221
+ rawMessage.includes('429') ||
2222
+ rawMessage.includes('quota')
2223
+ ) {
2224
+ return 'Vercel AI Gateway could not serve this model because it is rate-limited. We tried compatible backup models. Try again shortly, choose another model, or add AI Gateway credits / a provider key in Settings.';
2225
+ }
2226
+
2227
+ // ── Model tried to call a tool that was not available in this step ──
2228
+ // This happens when the step-routing removes a tool but the model still
2229
+ // tries to call it. It is not a real failure — just retry.
2230
+ if (isToolRouting) {
2231
+ return 'The model tried an unavailable action. Please try your question again.';
2232
+ }
2233
+
2234
+ // ── Authentication / API key errors ──
2235
+ if (
2236
+ rawMessage.includes('401') ||
2237
+ rawMessage.includes('Unauthorized') ||
2238
+ rawMessage.includes('Invalid API Key') ||
2239
+ rawMessage.includes('authentication')
2240
+ ) {
2241
+ return 'Your API key appears to be invalid or expired. Please check your AI provider settings.';
2242
+ }
2243
+
2244
+ // ── Context length / token limit ──
2245
+ if (
2246
+ rawMessage.includes('context_length') ||
2247
+ rawMessage.includes('maximum context') ||
2248
+ rawMessage.includes('too many tokens') ||
2249
+ rawMessage.includes('max_tokens')
2250
+ ) {
2251
+ return 'The conversation is too long for this model. Try starting a new chat or switching to a model with a larger context window.';
2252
+ }
2253
+
2254
+ // ── Timeout ──
2255
+ if (rawMessage.includes('timeout') || rawMessage.includes('ETIMEDOUT')) {
2256
+ return 'The request timed out. Please try again.';
2257
+ }
2258
+
2259
+ // ── Generic fallback — never expose implementation errors ──
2260
+ return 'Something went wrong while generating a response. Please try again.';
2261
+ },
2262
+ })
2263
+ .pipeThrough(
2264
+ recoverEmptyUIMessageStream(
2265
+ fallbackAnswerFromEvidence(preloadedEvidence, answerQuestion),
2266
+ )(),
2267
+ ),
2268
+ );
2269
+ },
2270
+ });
2271
+
2272
+ return createUIMessageStreamResponse({ stream: responseStream });
2273
+ } catch (error) {
2274
+ await mcp.close();
2275
+ throw error;
2276
+ }
2277
+ }