@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.
- package/LICENSE +176 -0
- package/README.md +2 -2
- package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
- package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/route.d.ts +3 -0
- package/dist-types/app/api/chat/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/tools.d.ts +67 -0
- package/dist-types/app/api/chat/tools.d.ts.map +1 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
- package/dist-types/app/api/config/verify/route.d.ts +3 -0
- package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/describe-image/route.d.ts +4 -0
- package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
- package/dist-types/app/api/document/sign/route.d.ts +2 -0
- package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
- package/dist-types/app/api/files/remote/route.d.ts +5 -0
- package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/route.d.ts +4 -0
- package/dist-types/app/api/mcp/route.d.ts.map +1 -0
- package/dist-types/app/api/parse-file/route.d.ts +6 -0
- package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
- package/dist-types/app/api/pdf-page/route.d.ts +4 -0
- package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
- package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
- package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/search/bing/route.d.ts +5 -0
- package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
- package/dist-types/app/api/search/brave/route.d.ts +5 -0
- package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
- package/dist-types/app/api/search/exa/route.d.ts +5 -0
- package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
- package/dist-types/app/api/search/google/route.d.ts +30 -0
- package/dist-types/app/api/search/google/route.d.ts.map +1 -0
- package/dist-types/app/api/search/route.d.ts +7 -0
- package/dist-types/app/api/search/route.d.ts.map +1 -0
- package/dist-types/app/api/search/tavily/route.d.ts +5 -0
- package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
- package/dist-types/app/api/search/verify/route.d.ts +4 -0
- package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/tabular/route.d.ts +9 -0
- package/dist-types/app/api/tabular/route.d.ts.map +1 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
- package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
- package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
- package/dist-types/lib/chat/image-description.d.ts +10 -0
- package/dist-types/lib/chat/image-description.d.ts.map +1 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
- package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/message-input.d.ts +8 -0
- package/dist-types/lib/chat/message-input.d.ts.map +1 -0
- package/dist-types/lib/chat/model-provider.d.ts +23 -0
- package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
- package/dist-types/lib/chat/response-style.d.ts +2 -0
- package/dist-types/lib/chat/response-style.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
- package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-context.d.ts +72 -0
- package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-registry.d.ts +3 -0
- package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/visual-routing.d.ts +53 -0
- package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
- package/dist-types/lib/enterprise-client.d.ts +31 -0
- package/dist-types/lib/enterprise-client.d.ts.map +1 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
- package/dist-types/lib/media/knowledge.d.ts +47 -0
- package/dist-types/lib/media/knowledge.d.ts.map +1 -0
- package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
- package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
- package/dist-types/lib/media/source-identity.d.ts +12 -0
- package/dist-types/lib/media/source-identity.d.ts.map +1 -0
- package/dist-types/lib/media/source-utils.d.ts +157 -0
- package/dist-types/lib/media/source-utils.d.ts.map +1 -0
- package/dist-types/lib/media/video/quick-look.d.ts +60 -0
- package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
- package/dist-types/lib/remote-files.d.ts +12 -0
- package/dist-types/lib/remote-files.d.ts.map +1 -0
- package/dist-types/lib/search/provider-verification.d.ts +5 -0
- package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
- package/dist-types/runtime-host.d.ts +20 -0
- package/dist-types/runtime-host.d.ts.map +1 -0
- package/package.json +338 -3
- package/src/app/api/chat/feedback/route.ts +77 -0
- package/src/app/api/chat/route.ts +2277 -0
- package/src/app/api/chat/tools.ts +3563 -0
- package/src/app/api/config/test-embedding/route.ts +39 -0
- package/src/app/api/config/verify/route.ts +309 -0
- package/src/app/api/describe-image/route.ts +108 -0
- package/src/app/api/doc-editor/edit/route.ts +87 -0
- package/src/app/api/doc-editor/export/route.ts +32 -0
- package/src/app/api/doc-editor/parse/route.ts +74 -0
- package/src/app/api/doc-editor/restore/route.ts +70 -0
- package/src/app/api/document/sign/route.ts +55 -0
- package/src/app/api/files/remote/route.ts +32 -0
- package/src/app/api/mcp/[connectionId]/route.ts +29 -0
- package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
- package/src/app/api/mcp/route.ts +22 -0
- package/src/app/api/parse-file/route.ts +86 -0
- package/src/app/api/pdf-page/route.ts +40 -0
- package/src/app/api/proxy/verify/route.ts +78 -0
- package/src/app/api/search/bing/route.ts +86 -0
- package/src/app/api/search/brave/route.ts +88 -0
- package/src/app/api/search/exa/route.ts +100 -0
- package/src/app/api/search/google/route.ts +147 -0
- package/src/app/api/search/route.ts +28 -0
- package/src/app/api/search/tavily/route.ts +93 -0
- package/src/app/api/search/verify/route.ts +27 -0
- package/src/app/api/tabular/route.ts +68 -0
- package/src/lib/chat/assistant-source-scope.ts +32 -0
- package/src/lib/chat/conversation-memory.ts +621 -0
- package/src/lib/chat/gateway-fallbacks.ts +12 -0
- package/src/lib/chat/image-description.ts +21 -0
- package/src/lib/chat/media-retrieval-routing.ts +37 -0
- package/src/lib/chat/media-source-routing.ts +100 -0
- package/src/lib/chat/message-input.ts +48 -0
- package/src/lib/chat/model-provider.ts +157 -0
- package/src/lib/chat/response-style.ts +3 -0
- package/src/lib/chat/retrieval-ranking.ts +126 -0
- package/src/lib/chat/retrieval-routing.ts +221 -0
- package/src/lib/chat/tabular-dataset-routing.ts +93 -0
- package/src/lib/chat/tabular-query-plan.ts +195 -0
- package/src/lib/chat/tabular-routing.ts +122 -0
- package/src/lib/chat/tabular-sandbox-files.ts +78 -0
- package/src/lib/chat/tabular-visualization.ts +165 -0
- package/src/lib/chat/tool-call-repair.ts +84 -0
- package/src/lib/chat/tool-context.ts +1222 -0
- package/src/lib/chat/tool-registry.ts +6 -0
- package/src/lib/chat/video-rag-routing.ts +149 -0
- package/src/lib/chat/visual-routing.ts +225 -0
- package/src/lib/enterprise-client.ts +96 -0
- package/src/lib/marketplace/tool-runtime-config.ts +155 -0
- package/src/lib/media/knowledge.ts +489 -0
- package/src/lib/media/pdf-inspection.ts +280 -0
- package/src/lib/media/source-identity.ts +50 -0
- package/src/lib/media/source-utils.ts +1156 -0
- package/src/lib/media/video/quick-look.ts +368 -0
- package/src/lib/media/video-intelligence-adapter.ts +1946 -0
- package/src/lib/media/video-intelligence-connection.ts +151 -0
- package/src/lib/remote-files.ts +182 -0
- package/src/lib/search/provider-verification.ts +95 -0
- 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
|
+
}
|