opencode-cursor-proxy 1.0.1
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 +21 -0
- package/README.md +139 -0
- package/README.zh-CN.md +136 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api/agent-service.d.ts +136 -0
- package/dist/lib/api/agent-service.js +938 -0
- package/dist/lib/api/agent-service.js.map +1 -0
- package/dist/lib/api/ai-service.d.ts +26 -0
- package/dist/lib/api/ai-service.js +38 -0
- package/dist/lib/api/ai-service.js.map +1 -0
- package/dist/lib/api/cursor-client.d.ts +119 -0
- package/dist/lib/api/cursor-client.js +511 -0
- package/dist/lib/api/cursor-client.js.map +1 -0
- package/dist/lib/api/cursor-models.d.ts +13 -0
- package/dist/lib/api/cursor-models.js +34 -0
- package/dist/lib/api/cursor-models.js.map +1 -0
- package/dist/lib/api/openai-compat.d.ts +10 -0
- package/dist/lib/api/openai-compat.js +262 -0
- package/dist/lib/api/openai-compat.js.map +1 -0
- package/dist/lib/api/proto/agent-messages.d.ts +25 -0
- package/dist/lib/api/proto/agent-messages.js +132 -0
- package/dist/lib/api/proto/agent-messages.js.map +1 -0
- package/dist/lib/api/proto/bidi.d.ts +17 -0
- package/dist/lib/api/proto/bidi.js +24 -0
- package/dist/lib/api/proto/bidi.js.map +1 -0
- package/dist/lib/api/proto/decoding.d.ts +19 -0
- package/dist/lib/api/proto/decoding.js +118 -0
- package/dist/lib/api/proto/decoding.js.map +1 -0
- package/dist/lib/api/proto/encoding.d.ts +64 -0
- package/dist/lib/api/proto/encoding.js +180 -0
- package/dist/lib/api/proto/encoding.js.map +1 -0
- package/dist/lib/api/proto/exec.d.ts +12 -0
- package/dist/lib/api/proto/exec.js +383 -0
- package/dist/lib/api/proto/exec.js.map +1 -0
- package/dist/lib/api/proto/index.d.ts +13 -0
- package/dist/lib/api/proto/index.js +10 -0
- package/dist/lib/api/proto/index.js.map +1 -0
- package/dist/lib/api/proto/interaction.d.ts +15 -0
- package/dist/lib/api/proto/interaction.js +99 -0
- package/dist/lib/api/proto/interaction.js.map +1 -0
- package/dist/lib/api/proto/kv.d.ts +52 -0
- package/dist/lib/api/proto/kv.js +156 -0
- package/dist/lib/api/proto/kv.js.map +1 -0
- package/dist/lib/api/proto/tool-calls.d.ts +9 -0
- package/dist/lib/api/proto/tool-calls.js +144 -0
- package/dist/lib/api/proto/tool-calls.js.map +1 -0
- package/dist/lib/api/proto/types.d.ts +201 -0
- package/dist/lib/api/proto/types.js +10 -0
- package/dist/lib/api/proto/types.js.map +1 -0
- package/dist/lib/auth/helpers.d.ts +40 -0
- package/dist/lib/auth/helpers.js +103 -0
- package/dist/lib/auth/helpers.js.map +1 -0
- package/dist/lib/auth/index.d.ts +7 -0
- package/dist/lib/auth/index.js +10 -0
- package/dist/lib/auth/index.js.map +1 -0
- package/dist/lib/auth/login.d.ts +55 -0
- package/dist/lib/auth/login.js +184 -0
- package/dist/lib/auth/login.js.map +1 -0
- package/dist/lib/config.d.ts +153 -0
- package/dist/lib/config.js +182 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/openai-compat/handler.d.ts +40 -0
- package/dist/lib/openai-compat/handler.js +808 -0
- package/dist/lib/openai-compat/handler.js.map +1 -0
- package/dist/lib/openai-compat/index.d.ts +9 -0
- package/dist/lib/openai-compat/index.js +13 -0
- package/dist/lib/openai-compat/index.js.map +1 -0
- package/dist/lib/openai-compat/types.d.ts +127 -0
- package/dist/lib/openai-compat/types.js +6 -0
- package/dist/lib/openai-compat/types.js.map +1 -0
- package/dist/lib/openai-compat/utils.d.ts +143 -0
- package/dist/lib/openai-compat/utils.js +348 -0
- package/dist/lib/openai-compat/utils.js.map +1 -0
- package/dist/lib/session-reuse.d.ts +88 -0
- package/dist/lib/session-reuse.js +198 -0
- package/dist/lib/session-reuse.js.map +1 -0
- package/dist/lib/storage.d.ts +55 -0
- package/dist/lib/storage.js +159 -0
- package/dist/lib/storage.js.map +1 -0
- package/dist/lib/utils/cache.d.ts +131 -0
- package/dist/lib/utils/cache.js +297 -0
- package/dist/lib/utils/cache.js.map +1 -0
- package/dist/lib/utils/fetch.d.ts +84 -0
- package/dist/lib/utils/fetch.js +261 -0
- package/dist/lib/utils/fetch.js.map +1 -0
- package/dist/lib/utils/index.d.ts +13 -0
- package/dist/lib/utils/index.js +22 -0
- package/dist/lib/utils/index.js.map +1 -0
- package/dist/lib/utils/jwt.d.ts +40 -0
- package/dist/lib/utils/jwt.js +102 -0
- package/dist/lib/utils/jwt.js.map +1 -0
- package/dist/lib/utils/logger.d.ts +107 -0
- package/dist/lib/utils/logger.js +227 -0
- package/dist/lib/utils/logger.js.map +1 -0
- package/dist/lib/utils/model-resolver.d.ts +49 -0
- package/dist/lib/utils/model-resolver.js +503 -0
- package/dist/lib/utils/model-resolver.js.map +1 -0
- package/dist/lib/utils/request-pool.d.ts +38 -0
- package/dist/lib/utils/request-pool.js +105 -0
- package/dist/lib/utils/request-pool.js.map +1 -0
- package/dist/lib/utils/request-transformer.d.ts +87 -0
- package/dist/lib/utils/request-transformer.js +154 -0
- package/dist/lib/utils/request-transformer.js.map +1 -0
- package/dist/lib/utils/tokenizer.d.ts +14 -0
- package/dist/lib/utils/tokenizer.js +76 -0
- package/dist/lib/utils/tokenizer.js.map +1 -0
- package/dist/plugin/index.d.ts +8 -0
- package/dist/plugin/index.js +9 -0
- package/dist/plugin/index.js.map +1 -0
- package/dist/plugin/plugin.d.ts +21 -0
- package/dist/plugin/plugin.js +309 -0
- package/dist/plugin/plugin.js.map +1 -0
- package/dist/plugin/types.d.ts +120 -0
- package/dist/plugin/types.js +7 -0
- package/dist/plugin/types.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +95 -0
- package/dist/server.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Agent Service Client
|
|
3
|
+
*
|
|
4
|
+
* Implements the AgentService API for chat functionality.
|
|
5
|
+
* Uses the BidiSse pattern:
|
|
6
|
+
* - RunSSE (server-streaming) to receive responses
|
|
7
|
+
* - BidiAppend (unary) to send client messages
|
|
8
|
+
*
|
|
9
|
+
* Proto structure:
|
|
10
|
+
* AgentClientMessage:
|
|
11
|
+
* field 1: run_request (AgentRunRequest)
|
|
12
|
+
* field 2: exec_client_message (ExecClientMessage)
|
|
13
|
+
* field 3: kv_client_message (KvClientMessage)
|
|
14
|
+
* field 4: conversation_action (ConversationAction)
|
|
15
|
+
* field 5: exec_client_control_message
|
|
16
|
+
* field 6: interaction_response
|
|
17
|
+
*
|
|
18
|
+
* AgentServerMessage:
|
|
19
|
+
* field 1: interaction_update (InteractionUpdate)
|
|
20
|
+
* field 2: exec_server_message (ExecServerMessage)
|
|
21
|
+
* field 3: conversation_checkpoint_update (completion signal)
|
|
22
|
+
* field 4: kv_server_message (KvServerMessage)
|
|
23
|
+
* field 5: exec_server_control_message
|
|
24
|
+
* field 7: interaction_query
|
|
25
|
+
*
|
|
26
|
+
* InteractionUpdate.message:
|
|
27
|
+
* field 1: text_delta
|
|
28
|
+
* field 4: thinking_delta
|
|
29
|
+
* field 8: token_delta
|
|
30
|
+
* field 13: heartbeat
|
|
31
|
+
* field 14: turn_ended
|
|
32
|
+
*/
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import { readdirSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { generateChecksum, addConnectEnvelope } from "./cursor-client";
|
|
38
|
+
import { encodeMessageField, parseProtoFields, parseExecServerMessage, buildExecClientMessageWithMcpResult, buildExecClientMessageWithShellResult, buildExecClientMessageWithLsResult, buildExecClientMessageWithRequestContextResult, buildExecClientMessageWithReadResult, buildExecClientMessageWithGrepResult, buildExecClientMessageWithWriteResult, buildAgentClientMessageWithExec, buildExecClientControlMessage, buildAgentClientMessageWithExecControl, parseKvServerMessage, buildKvClientMessage, buildAgentClientMessageWithKv, AgentMode, encodeBidiRequestId, encodeBidiAppendRequest, buildRequestContext, encodeUserMessage, encodeUserMessageAction, encodeConversationAction, encodeConversationActionWithResume, encodeAgentClientMessageWithConversationAction, encodeModelDetails, encodeAgentRunRequest, encodeAgentClientMessage, parseInteractionUpdate, analyzeBlobData, extractAssistantContent, } from "./proto";
|
|
39
|
+
// Re-export types that external code may need
|
|
40
|
+
export { AgentMode };
|
|
41
|
+
import { config, isDebugEnabled, isTimingEnabled } from "../config";
|
|
42
|
+
import { apiLogger, createTimer } from "../utils/logger";
|
|
43
|
+
// Create specialized loggers
|
|
44
|
+
const debugLog = isDebugEnabled() ? (msg) => apiLogger.debug(msg) : () => { };
|
|
45
|
+
const timingLog = isTimingEnabled() ? (msg) => apiLogger.info(msg) : () => { };
|
|
46
|
+
function createTimingMetrics() {
|
|
47
|
+
return {
|
|
48
|
+
requestStart: Date.now(),
|
|
49
|
+
chunkCount: 0,
|
|
50
|
+
textChunks: 0,
|
|
51
|
+
toolCalls: 0,
|
|
52
|
+
execRequests: 0,
|
|
53
|
+
kvMessages: 0,
|
|
54
|
+
heartbeats: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function logTimingMetrics(metrics) {
|
|
58
|
+
const total = Date.now() - metrics.requestStart;
|
|
59
|
+
metrics.totalMs = total;
|
|
60
|
+
timingLog("[TIMING] ═══════════════════════════════════════════════════════");
|
|
61
|
+
timingLog("[TIMING] Request Performance Summary");
|
|
62
|
+
timingLog("[TIMING] ───────────────────────────────────────────────────────");
|
|
63
|
+
timingLog(`[TIMING] Message build: ${metrics.messageBuildMs ?? "-"}ms`);
|
|
64
|
+
timingLog(`[TIMING] SSE connection: ${metrics.sseConnectionMs ?? "-"}ms`);
|
|
65
|
+
timingLog(`[TIMING] First BidiAppend: ${metrics.firstBidiAppendMs ?? "-"}ms`);
|
|
66
|
+
timingLog(`[TIMING] First chunk: ${metrics.firstChunkMs ?? "-"}ms`);
|
|
67
|
+
timingLog(`[TIMING] First text: ${metrics.firstTextMs ?? "-"}ms`);
|
|
68
|
+
timingLog(`[TIMING] First tool call: ${metrics.firstToolCallMs ?? "-"}ms`);
|
|
69
|
+
timingLog(`[TIMING] Turn ended: ${metrics.turnEndedMs ?? "-"}ms`);
|
|
70
|
+
timingLog(`[TIMING] Total: ${total}ms`);
|
|
71
|
+
timingLog("[TIMING] ───────────────────────────────────────────────────────");
|
|
72
|
+
timingLog(`[TIMING] Chunks: ${metrics.chunkCount} (text: ${metrics.textChunks}, tools: ${metrics.toolCalls})`);
|
|
73
|
+
timingLog(`[TIMING] Exec requests: ${metrics.execRequests}, KV messages: ${metrics.kvMessages}`);
|
|
74
|
+
timingLog(`[TIMING] Heartbeats: ${metrics.heartbeats}`);
|
|
75
|
+
timingLog("[TIMING] ═══════════════════════════════════════════════════════");
|
|
76
|
+
}
|
|
77
|
+
// Cursor API URLs from config
|
|
78
|
+
export const CURSOR_API_URL = config.api.baseUrl;
|
|
79
|
+
export const AGENT_PRIVACY_URL = config.api.agentPrivacyUrl;
|
|
80
|
+
export const AGENT_NON_PRIVACY_URL = config.api.agentNonPrivacyUrl;
|
|
81
|
+
function detectLatestInstalledAgentVersion() {
|
|
82
|
+
try {
|
|
83
|
+
const versionsDir = join(homedir(), ".local", "share", "cursor-agent", "versions");
|
|
84
|
+
const entries = readdirSync(versionsDir, { withFileTypes: true })
|
|
85
|
+
.filter((d) => d.isDirectory())
|
|
86
|
+
.map((d) => d.name)
|
|
87
|
+
.sort();
|
|
88
|
+
const latest = entries.at(-1);
|
|
89
|
+
return latest ? `cli-${latest}` : undefined;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function resolveClientVersionHeader() {
|
|
96
|
+
return config.api.clientVersion || detectLatestInstalledAgentVersion() || "cli-unknown";
|
|
97
|
+
}
|
|
98
|
+
function parseTrailerMetadata(trailer) {
|
|
99
|
+
const meta = {};
|
|
100
|
+
for (const rawLine of trailer.split(/\r?\n/)) {
|
|
101
|
+
const line = rawLine.trim();
|
|
102
|
+
if (!line)
|
|
103
|
+
continue;
|
|
104
|
+
const idx = line.indexOf(":");
|
|
105
|
+
if (idx === -1)
|
|
106
|
+
continue;
|
|
107
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
108
|
+
const value = line.slice(idx + 1).trim();
|
|
109
|
+
if (!key)
|
|
110
|
+
continue;
|
|
111
|
+
meta[key] = value;
|
|
112
|
+
}
|
|
113
|
+
return meta;
|
|
114
|
+
}
|
|
115
|
+
function decodeGrpcStatusDetailsBin(detailsB64) {
|
|
116
|
+
try {
|
|
117
|
+
const decoded = Buffer.from(detailsB64.trim(), "base64");
|
|
118
|
+
const statusFields = parseProtoFields(decoded);
|
|
119
|
+
let statusMessage;
|
|
120
|
+
const extracted = [];
|
|
121
|
+
const collectStrings = (bytes, depth) => {
|
|
122
|
+
if (depth > 5)
|
|
123
|
+
return;
|
|
124
|
+
if (bytes.length === 0 || bytes.length > 20000)
|
|
125
|
+
return;
|
|
126
|
+
for (const pf of parseProtoFields(bytes)) {
|
|
127
|
+
if (pf.wireType === 2 && pf.value instanceof Uint8Array) {
|
|
128
|
+
const maybeText = new TextDecoder().decode(pf.value).trim();
|
|
129
|
+
if (maybeText.length >= 6 &&
|
|
130
|
+
/[A-Za-z]/.test(maybeText) &&
|
|
131
|
+
!maybeText.includes("\u0000")) {
|
|
132
|
+
extracted.push(maybeText);
|
|
133
|
+
}
|
|
134
|
+
collectStrings(pf.value, depth + 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
for (const field of statusFields) {
|
|
139
|
+
// google.rpc.Status.message = field 2
|
|
140
|
+
if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
141
|
+
const text = new TextDecoder().decode(field.value).trim();
|
|
142
|
+
if (text)
|
|
143
|
+
statusMessage = text;
|
|
144
|
+
}
|
|
145
|
+
// google.rpc.Status.details (repeated Any) = field 3
|
|
146
|
+
if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
147
|
+
const anyFields = parseProtoFields(field.value);
|
|
148
|
+
const valueField = anyFields.find((f) => f.fieldNumber === 2 && f.wireType === 2);
|
|
149
|
+
if (!valueField || !(valueField.value instanceof Uint8Array))
|
|
150
|
+
continue;
|
|
151
|
+
// Try to extract human-readable strings from the Any.value payload
|
|
152
|
+
collectStrings(valueField.value, 0);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const unique = Array.from(new Set(extracted));
|
|
156
|
+
const details = unique.length > 0 ? unique.join(" | ") : undefined;
|
|
157
|
+
if (details && statusMessage)
|
|
158
|
+
return `${statusMessage} | ${details}`;
|
|
159
|
+
return details ?? statusMessage;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// --- Types are now imported from ./proto ---
|
|
166
|
+
// ExecRequest types, ToolCallInfo, AgentStreamChunk, AgentServiceOptions, AgentChatRequest
|
|
167
|
+
// are all imported from ./proto/types via the barrel export
|
|
168
|
+
// Local aliases for the buildExecClientMessageWithMcpResult function which needs a slightly different signature
|
|
169
|
+
function buildExecClientMessage(id, execId, result) {
|
|
170
|
+
return buildExecClientMessageWithMcpResult(id, execId, result);
|
|
171
|
+
}
|
|
172
|
+
export class AgentServiceClient {
|
|
173
|
+
baseUrl;
|
|
174
|
+
accessToken;
|
|
175
|
+
workspacePath;
|
|
176
|
+
blobStore;
|
|
177
|
+
privacyMode = true;
|
|
178
|
+
clientVersionHeader = "cli-unknown";
|
|
179
|
+
baseUrlAttempts = null;
|
|
180
|
+
// For tool result submission during streaming
|
|
181
|
+
currentRequestId = null;
|
|
182
|
+
currentAppendSeqno = 0n;
|
|
183
|
+
// For session reuse - track assistant responses stored in KV blobs
|
|
184
|
+
// When Cursor stores model responses in blobs instead of streaming, we need to extract them
|
|
185
|
+
pendingAssistantBlobs = [];
|
|
186
|
+
constructor(accessToken, options = {}) {
|
|
187
|
+
this.accessToken = accessToken;
|
|
188
|
+
this.privacyMode = options.privacyMode ?? config.api.privacyMode;
|
|
189
|
+
this.clientVersionHeader = resolveClientVersionHeader();
|
|
190
|
+
// Default to api2, but allow fallback to agent backends if needed
|
|
191
|
+
this.baseUrl = options.baseUrl ?? CURSOR_API_URL;
|
|
192
|
+
this.workspacePath = options.workspacePath ?? process.cwd();
|
|
193
|
+
this.blobStore = new Map();
|
|
194
|
+
debugLog(`[DEBUG] AgentServiceClient using baseUrl: ${this.baseUrl}, privacyMode=${this.privacyMode}, clientVersion=${this.clientVersionHeader}`);
|
|
195
|
+
}
|
|
196
|
+
getHeaders(requestId) {
|
|
197
|
+
const checksum = generateChecksum(this.accessToken);
|
|
198
|
+
const headers = {
|
|
199
|
+
"authorization": `Bearer ${this.accessToken}`,
|
|
200
|
+
"content-type": "application/grpc-web+proto",
|
|
201
|
+
"user-agent": "connect-es/1.4.0",
|
|
202
|
+
"x-cursor-checksum": checksum,
|
|
203
|
+
"x-cursor-client-version": this.clientVersionHeader,
|
|
204
|
+
"x-cursor-client-type": "cli",
|
|
205
|
+
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
206
|
+
"x-ghost-mode": this.privacyMode ? "true" : "false",
|
|
207
|
+
// Signal to backend that we can receive SSE text/event-stream responses
|
|
208
|
+
// Without this, server may store responses in KV blobs instead of streaming
|
|
209
|
+
"x-cursor-streaming": "true",
|
|
210
|
+
};
|
|
211
|
+
if (requestId) {
|
|
212
|
+
headers["x-request-id"] = requestId;
|
|
213
|
+
}
|
|
214
|
+
return headers;
|
|
215
|
+
}
|
|
216
|
+
getBaseUrlAttempts() {
|
|
217
|
+
if (this.baseUrlAttempts)
|
|
218
|
+
return this.baseUrlAttempts;
|
|
219
|
+
const orderedCandidates = [this.baseUrl];
|
|
220
|
+
// Cursor can route AgentService/BidiService to agent.api5.cursor.sh, but those
|
|
221
|
+
// backends often require HTTP/2. Bun's fetch currently struggles with that,
|
|
222
|
+
// so only attempt api5 fallbacks when explicitly enabled.
|
|
223
|
+
const allowApi5Fallback = config.api.tryApi5Fallback;
|
|
224
|
+
const isBunRuntime = typeof globalThis.Bun !== "undefined";
|
|
225
|
+
if (allowApi5Fallback && !isBunRuntime) {
|
|
226
|
+
if (this.baseUrl === CURSOR_API_URL) {
|
|
227
|
+
orderedCandidates.push(this.privacyMode ? AGENT_PRIVACY_URL : AGENT_NON_PRIVACY_URL);
|
|
228
|
+
orderedCandidates.push(this.privacyMode ? AGENT_NON_PRIVACY_URL : AGENT_PRIVACY_URL);
|
|
229
|
+
}
|
|
230
|
+
else if (this.baseUrl === AGENT_PRIVACY_URL ||
|
|
231
|
+
this.baseUrl === AGENT_NON_PRIVACY_URL) {
|
|
232
|
+
orderedCandidates.push(CURSOR_API_URL);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.baseUrlAttempts = Array.from(new Set(orderedCandidates));
|
|
236
|
+
return this.baseUrlAttempts;
|
|
237
|
+
}
|
|
238
|
+
blobIdToKey(blobId) {
|
|
239
|
+
return Buffer.from(blobId).toString('hex');
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Build the AgentClientMessage for a chat request
|
|
243
|
+
*/
|
|
244
|
+
buildChatMessage(request) {
|
|
245
|
+
const messageId = randomUUID();
|
|
246
|
+
const conversationId = request.conversationId ?? randomUUID();
|
|
247
|
+
const model = request.model ?? "gpt-4o";
|
|
248
|
+
const mode = request.mode ?? AgentMode.AGENT;
|
|
249
|
+
// Build RequestContext (REQUIRED for agent to work)
|
|
250
|
+
// Include tools in RequestContext.tools (field 7) - CRITICAL for tool calling!
|
|
251
|
+
const requestContext = buildRequestContext(this.workspacePath, request.tools);
|
|
252
|
+
// Build the message hierarchy
|
|
253
|
+
const userMessage = encodeUserMessage(request.message, messageId, mode);
|
|
254
|
+
const userMessageAction = encodeUserMessageAction(userMessage, requestContext);
|
|
255
|
+
const conversationAction = encodeConversationAction(userMessageAction);
|
|
256
|
+
const modelDetails = encodeModelDetails(model);
|
|
257
|
+
// Pass tools to AgentRunRequest (field 4: mcp_tools) and workspace path (for field 6: mcp_file_system_options)
|
|
258
|
+
const agentRunRequest = encodeAgentRunRequest(conversationAction, modelDetails, conversationId, request.tools, this.workspacePath);
|
|
259
|
+
const agentClientMessage = encodeAgentClientMessage(agentRunRequest);
|
|
260
|
+
return agentClientMessage;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Call BidiAppend to send a client message
|
|
264
|
+
*/
|
|
265
|
+
async bidiAppend(requestId, appendSeqno, data) {
|
|
266
|
+
const startTime = Date.now();
|
|
267
|
+
const hexData = Buffer.from(data).toString("hex");
|
|
268
|
+
const appendRequest = encodeBidiAppendRequest(hexData, requestId, appendSeqno);
|
|
269
|
+
const envelope = addConnectEnvelope(appendRequest);
|
|
270
|
+
debugLog(`[TIMING] bidiAppend: data=${data.length}bytes, hex=${hexData.length}chars, envelope=${envelope.length}bytes, encode=${Date.now() - startTime}ms`);
|
|
271
|
+
const url = `${this.baseUrl}/aiserver.v1.BidiService/BidiAppend`;
|
|
272
|
+
const fetchStart = Date.now();
|
|
273
|
+
const response = await fetch(url, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: this.getHeaders(requestId),
|
|
276
|
+
body: Buffer.from(envelope),
|
|
277
|
+
});
|
|
278
|
+
debugLog(`[TIMING] bidiAppend fetch took ${Date.now() - fetchStart}ms, status=${response.status}`);
|
|
279
|
+
if (!response.ok) {
|
|
280
|
+
const errorText = await response.text();
|
|
281
|
+
throw new Error(`BidiAppend failed: ${response.status} - ${errorText}`);
|
|
282
|
+
}
|
|
283
|
+
// Read the response body to see if there's any useful information
|
|
284
|
+
const responseBody = await response.arrayBuffer();
|
|
285
|
+
if (responseBody.byteLength > 0) {
|
|
286
|
+
debugLog(`[DEBUG] BidiAppend response: ${responseBody.byteLength} bytes`);
|
|
287
|
+
const bytes = new Uint8Array(responseBody);
|
|
288
|
+
// Parse as gRPC-Web envelope
|
|
289
|
+
if (bytes.length >= 5) {
|
|
290
|
+
const flags = bytes[0] ?? 0;
|
|
291
|
+
const b1 = bytes[1] ?? 0;
|
|
292
|
+
const b2 = bytes[2] ?? 0;
|
|
293
|
+
const b3 = bytes[3] ?? 0;
|
|
294
|
+
const b4 = bytes[4] ?? 0;
|
|
295
|
+
const length = (b1 << 24) | (b2 << 16) | (b3 << 8) | b4;
|
|
296
|
+
debugLog(`[DEBUG] BidiAppend response: flags=${flags}, length=${length}, totalBytes=${bytes.length}`);
|
|
297
|
+
if (length > 0 && bytes.length >= 5 + length) {
|
|
298
|
+
const payload = bytes.slice(5, 5 + length);
|
|
299
|
+
debugLog(`[DEBUG] BidiAppend payload hex: ${Buffer.from(payload).toString('hex')}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async handleKvMessage(kvMsg, requestId, appendSeqno) {
|
|
305
|
+
if (kvMsg.messageType === 'get_blob_args' && kvMsg.blobId) {
|
|
306
|
+
const key = this.blobIdToKey(kvMsg.blobId);
|
|
307
|
+
const data = this.blobStore.get(key);
|
|
308
|
+
const result = data ? encodeMessageField(1, data) : new Uint8Array(0);
|
|
309
|
+
const kvClientMsg = buildKvClientMessage(kvMsg.id, 'get_blob_result', result);
|
|
310
|
+
const responseMsg = buildAgentClientMessageWithKv(kvClientMsg);
|
|
311
|
+
await this.bidiAppend(requestId, appendSeqno, responseMsg);
|
|
312
|
+
return appendSeqno + 1n;
|
|
313
|
+
}
|
|
314
|
+
if (kvMsg.messageType === 'set_blob_args' && kvMsg.blobId && kvMsg.blobData) {
|
|
315
|
+
const key = this.blobIdToKey(kvMsg.blobId);
|
|
316
|
+
this.blobStore.set(key, kvMsg.blobData);
|
|
317
|
+
const blobAnalysis = analyzeBlobData(kvMsg.blobData);
|
|
318
|
+
debugLog(`[KV-BLOB] SET id=${kvMsg.id}, key=${key.slice(0, 16)}..., size=${kvMsg.blobData.length}b, type=${blobAnalysis.type}`);
|
|
319
|
+
if (blobAnalysis.type === 'json' && blobAnalysis.json) {
|
|
320
|
+
const json = blobAnalysis.json;
|
|
321
|
+
debugLog(`[KV-BLOB] JSON keys: ${Object.keys(json).join(', ')}`);
|
|
322
|
+
if (json.role)
|
|
323
|
+
debugLog(`[KV-BLOB] JSON role: ${json.role}`);
|
|
324
|
+
if (json.content)
|
|
325
|
+
debugLog(`[KV-BLOB] JSON content type: ${typeof json.content}, isArray: ${Array.isArray(json.content)}`);
|
|
326
|
+
}
|
|
327
|
+
const extractedContent = extractAssistantContent(blobAnalysis, key);
|
|
328
|
+
for (const item of extractedContent) {
|
|
329
|
+
debugLog(`[KV-BLOB] ✓ Assistant content found: ${item.content.slice(0, 100)}...`);
|
|
330
|
+
this.pendingAssistantBlobs.push(item);
|
|
331
|
+
}
|
|
332
|
+
const result = new Uint8Array(0);
|
|
333
|
+
const kvClientMsg = buildKvClientMessage(kvMsg.id, 'set_blob_result', result);
|
|
334
|
+
const responseMsg = buildAgentClientMessageWithKv(kvClientMsg);
|
|
335
|
+
await this.bidiAppend(requestId, appendSeqno, responseMsg);
|
|
336
|
+
return appendSeqno + 1n;
|
|
337
|
+
}
|
|
338
|
+
return appendSeqno;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Send a tool result back to the server (for MCP tools only)
|
|
342
|
+
* This must be called during an active chat stream when an exec_request chunk is received
|
|
343
|
+
*/
|
|
344
|
+
async sendToolResult(execRequest, result) {
|
|
345
|
+
if (!this.currentRequestId) {
|
|
346
|
+
throw new Error("No active chat stream - cannot send tool result");
|
|
347
|
+
}
|
|
348
|
+
debugLog(`[DEBUG] Sending tool result for exec id: ${execRequest.id}, result: ${result.success ? "success" : "error"}`);
|
|
349
|
+
// Build ExecClientMessage with mcp_result
|
|
350
|
+
const execClientMsg = buildExecClientMessage(execRequest.id, execRequest.execId, result);
|
|
351
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
352
|
+
// Send the result
|
|
353
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
354
|
+
this.currentAppendSeqno++;
|
|
355
|
+
debugLog(`[DEBUG] Tool result sent, new seqno: ${this.currentAppendSeqno}`);
|
|
356
|
+
// Send stream close control message
|
|
357
|
+
const controlMsg = buildExecClientControlMessage(execRequest.id);
|
|
358
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
359
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
360
|
+
this.currentAppendSeqno++;
|
|
361
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${execRequest.id}`);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Send a shell execution result back to the server
|
|
365
|
+
*/
|
|
366
|
+
async sendShellResult(id, execId, command, cwd, stdout, stderr, exitCode, executionTimeMs) {
|
|
367
|
+
if (!this.currentRequestId) {
|
|
368
|
+
throw new Error("No active chat stream - cannot send shell result");
|
|
369
|
+
}
|
|
370
|
+
debugLog(`[DEBUG] Sending shell result for id: ${id}, exitCode: ${exitCode}`);
|
|
371
|
+
const execClientMsg = buildExecClientMessageWithShellResult(id, execId, command, cwd, stdout, stderr, exitCode, executionTimeMs);
|
|
372
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
373
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
374
|
+
this.currentAppendSeqno++;
|
|
375
|
+
// Send stream close control message
|
|
376
|
+
const controlMsg = buildExecClientControlMessage(id);
|
|
377
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
378
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
379
|
+
this.currentAppendSeqno++;
|
|
380
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${id}`);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Send an LS result back to the server
|
|
384
|
+
*/
|
|
385
|
+
async sendLsResult(id, execId, filesString) {
|
|
386
|
+
if (!this.currentRequestId) {
|
|
387
|
+
throw new Error("No active chat stream - cannot send ls result");
|
|
388
|
+
}
|
|
389
|
+
debugLog(`[DEBUG] Sending ls result for id: ${id}`);
|
|
390
|
+
const execClientMsg = buildExecClientMessageWithLsResult(id, execId, filesString);
|
|
391
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
392
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
393
|
+
this.currentAppendSeqno++;
|
|
394
|
+
// Send stream close control message
|
|
395
|
+
const controlMsg = buildExecClientControlMessage(id);
|
|
396
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
397
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
398
|
+
this.currentAppendSeqno++;
|
|
399
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${id}`);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Send a request context result back to the server
|
|
403
|
+
*/
|
|
404
|
+
async sendRequestContextResult(id, execId) {
|
|
405
|
+
if (!this.currentRequestId) {
|
|
406
|
+
throw new Error("No active chat stream - cannot send request context result");
|
|
407
|
+
}
|
|
408
|
+
debugLog(`[DEBUG] Sending request context result for id: ${id}`);
|
|
409
|
+
const execClientMsg = buildExecClientMessageWithRequestContextResult(id, execId);
|
|
410
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
411
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
412
|
+
this.currentAppendSeqno++;
|
|
413
|
+
// Send stream close control message
|
|
414
|
+
const controlMsg = buildExecClientControlMessage(id);
|
|
415
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
416
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
417
|
+
this.currentAppendSeqno++;
|
|
418
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${id}`);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Send a file read result back to the server
|
|
422
|
+
*/
|
|
423
|
+
async sendReadResult(id, execId, content, path, totalLines, fileSize, truncated) {
|
|
424
|
+
if (!this.currentRequestId) {
|
|
425
|
+
throw new Error("No active chat stream - cannot send read result");
|
|
426
|
+
}
|
|
427
|
+
debugLog(`[DEBUG] Sending read result for id: ${id}, path: ${path}, contentLength: ${content.length}`);
|
|
428
|
+
const execClientMsg = buildExecClientMessageWithReadResult(id, execId, content, path, totalLines, fileSize, truncated);
|
|
429
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
430
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
431
|
+
this.currentAppendSeqno++;
|
|
432
|
+
// Send stream close control message
|
|
433
|
+
const controlMsg = buildExecClientControlMessage(id);
|
|
434
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
435
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
436
|
+
this.currentAppendSeqno++;
|
|
437
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${id}`);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Send a grep/glob result back to the server
|
|
441
|
+
*/
|
|
442
|
+
async sendGrepResult(id, execId, pattern, path, files) {
|
|
443
|
+
if (!this.currentRequestId) {
|
|
444
|
+
throw new Error("No active chat stream - cannot send grep result");
|
|
445
|
+
}
|
|
446
|
+
debugLog(`[DEBUG] Sending grep result for id: ${id}, pattern: ${pattern}, files: ${files.length}`);
|
|
447
|
+
const execClientMsg = buildExecClientMessageWithGrepResult(id, execId, pattern, path, files);
|
|
448
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
449
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
450
|
+
this.currentAppendSeqno++;
|
|
451
|
+
// Send stream close control message
|
|
452
|
+
const controlMsg = buildExecClientControlMessage(id);
|
|
453
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
454
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
455
|
+
this.currentAppendSeqno++;
|
|
456
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${id}`);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Send a file write result back to the server
|
|
460
|
+
*/
|
|
461
|
+
async sendWriteResult(id, execId, result) {
|
|
462
|
+
if (!this.currentRequestId) {
|
|
463
|
+
throw new Error("No active chat stream - cannot send write result");
|
|
464
|
+
}
|
|
465
|
+
debugLog(`[DEBUG] Sending write result for id: ${id}, result: ${result.success ? "success" : "error"}`);
|
|
466
|
+
const execClientMsg = buildExecClientMessageWithWriteResult(id, execId, result);
|
|
467
|
+
const responseMsg = buildAgentClientMessageWithExec(execClientMsg);
|
|
468
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg);
|
|
469
|
+
this.currentAppendSeqno++;
|
|
470
|
+
// Send stream close control message
|
|
471
|
+
const controlMsg = buildExecClientControlMessage(id);
|
|
472
|
+
const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg);
|
|
473
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg);
|
|
474
|
+
this.currentAppendSeqno++;
|
|
475
|
+
debugLog(`[DEBUG] Stream close sent for exec id: ${id}`);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Send a ResumeAction to signal the server to continue after tool results
|
|
479
|
+
* Based on Cursor CLI analysis: after sending tool results, send ConversationAction with resume_action
|
|
480
|
+
* to tell the server to resume streaming instead of storing responses in KV blobs
|
|
481
|
+
*/
|
|
482
|
+
async sendResumeAction() {
|
|
483
|
+
if (!this.currentRequestId) {
|
|
484
|
+
throw new Error("No active chat stream - cannot send resume action");
|
|
485
|
+
}
|
|
486
|
+
debugLog(`[DEBUG] sendResumeAction called: requestId=${this.currentRequestId}, currentSeqno=${String(this.currentAppendSeqno)}`);
|
|
487
|
+
const conversationAction = encodeConversationActionWithResume();
|
|
488
|
+
const agentClientMessage = encodeAgentClientMessageWithConversationAction(conversationAction);
|
|
489
|
+
debugLog("[DEBUG] Sending ResumeAction with bidiAppend...");
|
|
490
|
+
await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, agentClientMessage);
|
|
491
|
+
this.currentAppendSeqno++;
|
|
492
|
+
debugLog(`[DEBUG] ResumeAction sent successfully, new seqno: ${String(this.currentAppendSeqno)}`);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Send a streaming chat request.
|
|
496
|
+
*
|
|
497
|
+
* Cursor periodically migrates AgentService/BidiService to different backends.
|
|
498
|
+
* If a request fails before any meaningful output, retry against known agent
|
|
499
|
+
* backends before surfacing the error.
|
|
500
|
+
*/
|
|
501
|
+
async *chatStream(request) {
|
|
502
|
+
const baseUrlAttempts = this.getBaseUrlAttempts();
|
|
503
|
+
let lastError = null;
|
|
504
|
+
for (let attemptIndex = 0; attemptIndex < baseUrlAttempts.length; attemptIndex++) {
|
|
505
|
+
const baseUrl = baseUrlAttempts[attemptIndex] ?? this.baseUrl;
|
|
506
|
+
this.baseUrl = baseUrl;
|
|
507
|
+
let sawMeaningfulOutput = false;
|
|
508
|
+
let shouldRetry = false;
|
|
509
|
+
for await (const chunk of this.chatStreamOnce(request)) {
|
|
510
|
+
if (chunk.type === "error") {
|
|
511
|
+
lastError = chunk;
|
|
512
|
+
if (!sawMeaningfulOutput && attemptIndex < baseUrlAttempts.length - 1) {
|
|
513
|
+
debugLog(`[DEBUG] chatStream retrying with next baseUrl after error: ${chunk.error}`);
|
|
514
|
+
shouldRetry = true;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
yield chunk;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (chunk.type === "text" ||
|
|
521
|
+
chunk.type === "exec_request" ||
|
|
522
|
+
chunk.type === "tool_call_started" ||
|
|
523
|
+
chunk.type === "tool_call_completed" ||
|
|
524
|
+
chunk.type === "partial_tool_call" ||
|
|
525
|
+
chunk.type === "interaction_query" ||
|
|
526
|
+
chunk.type === "kv_blob_assistant") {
|
|
527
|
+
sawMeaningfulOutput = true;
|
|
528
|
+
}
|
|
529
|
+
yield chunk;
|
|
530
|
+
}
|
|
531
|
+
if (!shouldRetry)
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (lastError) {
|
|
535
|
+
yield lastError;
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
yield { type: "error", error: "Unknown error" };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async *chatStreamOnce(request) {
|
|
542
|
+
const metrics = createTimingMetrics();
|
|
543
|
+
const requestId = randomUUID();
|
|
544
|
+
const messageBody = this.buildChatMessage(request);
|
|
545
|
+
metrics.messageBuildMs = Date.now() - metrics.requestStart;
|
|
546
|
+
let appendSeqno = 0n;
|
|
547
|
+
// Heartbeats are frequent; be generous to avoid premature turn cuts
|
|
548
|
+
const HEARTBEAT_IDLE_MS_PROGRESS = config.heartbeat.idleAfterProgressMs;
|
|
549
|
+
const HEARTBEAT_MAX_PROGRESS = config.heartbeat.maxAfterProgress;
|
|
550
|
+
const HEARTBEAT_IDLE_MS_NOPROGRESS = config.heartbeat.idleBeforeProgressMs;
|
|
551
|
+
const HEARTBEAT_MAX_NOPROGRESS = config.heartbeat.maxBeforeProgress;
|
|
552
|
+
let lastProgressAt = Date.now();
|
|
553
|
+
let heartbeatSinceProgress = 0;
|
|
554
|
+
let hasProgress = false;
|
|
555
|
+
const markProgress = () => {
|
|
556
|
+
heartbeatSinceProgress = 0;
|
|
557
|
+
lastProgressAt = Date.now();
|
|
558
|
+
hasProgress = true;
|
|
559
|
+
};
|
|
560
|
+
// Store for tool result submission
|
|
561
|
+
this.currentRequestId = requestId;
|
|
562
|
+
this.currentAppendSeqno = 0n;
|
|
563
|
+
// Build BidiRequestId message for RunSSE
|
|
564
|
+
const bidiRequestId = encodeBidiRequestId(requestId);
|
|
565
|
+
const envelope = addConnectEnvelope(bidiRequestId);
|
|
566
|
+
// Start the SSE stream
|
|
567
|
+
const sseUrl = `${this.baseUrl}/agent.v1.AgentService/RunSSE`;
|
|
568
|
+
const controller = new AbortController();
|
|
569
|
+
const timeout = setTimeout(() => controller.abort(), config.network.requestTimeoutMs);
|
|
570
|
+
try {
|
|
571
|
+
const ssePromise = fetch(sseUrl, {
|
|
572
|
+
method: "POST",
|
|
573
|
+
headers: this.getHeaders(requestId),
|
|
574
|
+
body: Buffer.from(envelope),
|
|
575
|
+
signal: controller.signal,
|
|
576
|
+
});
|
|
577
|
+
// Send initial message
|
|
578
|
+
await this.bidiAppend(requestId, appendSeqno++, messageBody);
|
|
579
|
+
metrics.firstBidiAppendMs = Date.now() - metrics.requestStart;
|
|
580
|
+
this.currentAppendSeqno = appendSeqno;
|
|
581
|
+
const sseResponse = await ssePromise;
|
|
582
|
+
metrics.sseConnectionMs = Date.now() - metrics.requestStart;
|
|
583
|
+
debugLog(`[TIMING] Request sent: build=${metrics.messageBuildMs}ms, append=${metrics.firstBidiAppendMs}ms, response=${metrics.sseConnectionMs}ms`);
|
|
584
|
+
if (!sseResponse.ok) {
|
|
585
|
+
clearTimeout(timeout);
|
|
586
|
+
const errorText = await sseResponse.text();
|
|
587
|
+
yield { type: "error", error: `SSE stream failed: ${sseResponse.status} - ${errorText}` };
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (!sseResponse.body) {
|
|
591
|
+
clearTimeout(timeout);
|
|
592
|
+
yield { type: "error", error: "No response body from SSE stream" };
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const reader = sseResponse.body.getReader();
|
|
596
|
+
let buffer = new Uint8Array(0);
|
|
597
|
+
let turnEnded = false;
|
|
598
|
+
let firstContentLogged = false;
|
|
599
|
+
let hasStreamedText = false; // Track if we received any text via streaming
|
|
600
|
+
// Clear any pending assistant blobs from previous requests
|
|
601
|
+
this.pendingAssistantBlobs = [];
|
|
602
|
+
try {
|
|
603
|
+
while (!turnEnded) {
|
|
604
|
+
const { done, value } = await reader.read();
|
|
605
|
+
if (done) {
|
|
606
|
+
yield { type: "done" };
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
if (!firstContentLogged) {
|
|
610
|
+
metrics.firstChunkMs = Date.now() - metrics.requestStart;
|
|
611
|
+
debugLog(`[TIMING] First chunk received in ${metrics.firstChunkMs}ms`);
|
|
612
|
+
firstContentLogged = true;
|
|
613
|
+
}
|
|
614
|
+
// Append to buffer
|
|
615
|
+
const newBuffer = new Uint8Array(buffer.length + value.length);
|
|
616
|
+
newBuffer.set(buffer);
|
|
617
|
+
newBuffer.set(value, buffer.length);
|
|
618
|
+
buffer = newBuffer;
|
|
619
|
+
// Parse frames
|
|
620
|
+
let offset = 0;
|
|
621
|
+
while (offset + 5 <= buffer.length) {
|
|
622
|
+
const flags = buffer[offset] ?? 0;
|
|
623
|
+
const b1 = buffer[offset + 1] ?? 0;
|
|
624
|
+
const b2 = buffer[offset + 2] ?? 0;
|
|
625
|
+
const b3 = buffer[offset + 3] ?? 0;
|
|
626
|
+
const b4 = buffer[offset + 4] ?? 0;
|
|
627
|
+
const length = (b1 << 24) | (b2 << 16) | (b3 << 8) | b4;
|
|
628
|
+
if (offset + 5 + length > buffer.length)
|
|
629
|
+
break;
|
|
630
|
+
const frameData = buffer.slice(offset + 5, offset + 5 + length);
|
|
631
|
+
offset += 5 + length;
|
|
632
|
+
// Check for trailer frame
|
|
633
|
+
if ((flags ?? 0) & 0x80) {
|
|
634
|
+
const trailer = new TextDecoder().decode(frameData);
|
|
635
|
+
debugLog(`Received trailer frame: ${trailer.slice(0, 200)}`);
|
|
636
|
+
const meta = parseTrailerMetadata(trailer);
|
|
637
|
+
const grpcStatus = Number(meta["grpc-status"] ?? "0");
|
|
638
|
+
if (grpcStatus !== 0) {
|
|
639
|
+
const grpcMessage = meta["grpc-message"]
|
|
640
|
+
? decodeURIComponent(meta["grpc-message"])
|
|
641
|
+
: "Unknown gRPC error";
|
|
642
|
+
const detailsBin = meta["grpc-status-details-bin"];
|
|
643
|
+
const decodedDetails = detailsBin
|
|
644
|
+
? decodeGrpcStatusDetailsBin(detailsBin)
|
|
645
|
+
: undefined;
|
|
646
|
+
const fullError = decodedDetails
|
|
647
|
+
? `${grpcMessage} (grpc-status ${grpcStatus}): ${decodedDetails}`
|
|
648
|
+
: `${grpcMessage} (grpc-status ${grpcStatus})`;
|
|
649
|
+
console.error("gRPC error:", fullError);
|
|
650
|
+
yield { type: "error", error: fullError };
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
// Parse AgentServerMessage
|
|
655
|
+
metrics.chunkCount++;
|
|
656
|
+
const serverMsgFields = parseProtoFields(frameData);
|
|
657
|
+
debugLog(`[DEBUG] Server message fields: ${serverMsgFields.map(f => `field${f.fieldNumber}:${f.wireType}`).join(", ")}`);
|
|
658
|
+
for (const field of serverMsgFields) {
|
|
659
|
+
try {
|
|
660
|
+
// field 1 = interaction_update
|
|
661
|
+
if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
662
|
+
debugLog(`[DEBUG] Received interaction_update, length: ${field.value.length}`);
|
|
663
|
+
const parsed = parseInteractionUpdate(field.value);
|
|
664
|
+
// Yield text content
|
|
665
|
+
if (parsed.text) {
|
|
666
|
+
if (metrics.firstTextMs === undefined) {
|
|
667
|
+
metrics.firstTextMs = Date.now() - metrics.requestStart;
|
|
668
|
+
}
|
|
669
|
+
metrics.textChunks++;
|
|
670
|
+
yield { type: "text", content: parsed.text };
|
|
671
|
+
hasStreamedText = true;
|
|
672
|
+
markProgress();
|
|
673
|
+
}
|
|
674
|
+
// Yield thinking content (for reasoning/thinking models)
|
|
675
|
+
if (parsed.thinking) {
|
|
676
|
+
metrics.textChunks++;
|
|
677
|
+
yield { type: "thinking", content: parsed.thinking };
|
|
678
|
+
markProgress();
|
|
679
|
+
}
|
|
680
|
+
// Yield tool call started
|
|
681
|
+
if (parsed.toolCallStarted) {
|
|
682
|
+
if (metrics.firstToolCallMs === undefined) {
|
|
683
|
+
metrics.firstToolCallMs = Date.now() - metrics.requestStart;
|
|
684
|
+
}
|
|
685
|
+
metrics.toolCalls++;
|
|
686
|
+
yield {
|
|
687
|
+
type: "tool_call_started",
|
|
688
|
+
toolCall: {
|
|
689
|
+
callId: parsed.toolCallStarted.callId,
|
|
690
|
+
modelCallId: parsed.toolCallStarted.modelCallId,
|
|
691
|
+
toolType: parsed.toolCallStarted.toolType,
|
|
692
|
+
name: parsed.toolCallStarted.name,
|
|
693
|
+
arguments: parsed.toolCallStarted.arguments,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
markProgress();
|
|
697
|
+
}
|
|
698
|
+
// Yield tool call completed
|
|
699
|
+
if (parsed.toolCallCompleted) {
|
|
700
|
+
yield {
|
|
701
|
+
type: "tool_call_completed",
|
|
702
|
+
toolCall: {
|
|
703
|
+
callId: parsed.toolCallCompleted.callId,
|
|
704
|
+
modelCallId: parsed.toolCallCompleted.modelCallId,
|
|
705
|
+
toolType: parsed.toolCallCompleted.toolType,
|
|
706
|
+
name: parsed.toolCallCompleted.name,
|
|
707
|
+
arguments: parsed.toolCallCompleted.arguments,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
markProgress();
|
|
711
|
+
}
|
|
712
|
+
// Yield partial tool call updates
|
|
713
|
+
if (parsed.partialToolCall) {
|
|
714
|
+
yield {
|
|
715
|
+
type: "partial_tool_call",
|
|
716
|
+
toolCall: {
|
|
717
|
+
callId: parsed.partialToolCall.callId,
|
|
718
|
+
modelCallId: undefined,
|
|
719
|
+
toolType: "partial",
|
|
720
|
+
name: "partial",
|
|
721
|
+
arguments: "",
|
|
722
|
+
},
|
|
723
|
+
partialArgs: parsed.partialToolCall.argsTextDelta,
|
|
724
|
+
};
|
|
725
|
+
markProgress();
|
|
726
|
+
}
|
|
727
|
+
if (parsed.isComplete) {
|
|
728
|
+
metrics.turnEndedMs = Date.now() - metrics.requestStart;
|
|
729
|
+
turnEnded = true;
|
|
730
|
+
}
|
|
731
|
+
// Yield heartbeat events for the server to track
|
|
732
|
+
if (parsed.isHeartbeat) {
|
|
733
|
+
metrics.heartbeats++;
|
|
734
|
+
heartbeatSinceProgress++;
|
|
735
|
+
const idleMs = Date.now() - lastProgressAt;
|
|
736
|
+
const idleLimit = hasProgress ? HEARTBEAT_IDLE_MS_PROGRESS : HEARTBEAT_IDLE_MS_NOPROGRESS;
|
|
737
|
+
const beatLimit = hasProgress ? HEARTBEAT_MAX_PROGRESS : HEARTBEAT_MAX_NOPROGRESS;
|
|
738
|
+
if (heartbeatSinceProgress >= beatLimit || idleMs >= idleLimit) {
|
|
739
|
+
console.warn(`[DEBUG] Heartbeat idle for ${idleMs}ms (${heartbeatSinceProgress} beats) - closing stream`);
|
|
740
|
+
turnEnded = true;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
yield { type: "heartbeat" };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// field 3 = conversation_checkpoint_update (completion signal)
|
|
748
|
+
// NOTE: Checkpoint does NOT mean we're done! exec_server_message can come AFTER checkpoint.
|
|
749
|
+
// Only end on turn_ended (field 14 in interaction_update) or stream close.
|
|
750
|
+
if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
751
|
+
debugLog(`[DEBUG] Received checkpoint, data length: ${field.value.length}`);
|
|
752
|
+
// Try to parse checkpoint to see what it contains
|
|
753
|
+
const checkpointFields = parseProtoFields(field.value);
|
|
754
|
+
debugLog(`[DEBUG] Checkpoint fields: ${checkpointFields.map(f => `field${f.fieldNumber}:${f.wireType}`).join(", ")}`);
|
|
755
|
+
for (const cf of checkpointFields) {
|
|
756
|
+
if (cf.wireType === 2 && cf.value instanceof Uint8Array) {
|
|
757
|
+
try {
|
|
758
|
+
const text = new TextDecoder().decode(cf.value);
|
|
759
|
+
if (text.length < 200) {
|
|
760
|
+
debugLog(`[DEBUG] Checkpoint field ${cf.fieldNumber}: ${text}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
catch { }
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
yield { type: "checkpoint" };
|
|
767
|
+
markProgress();
|
|
768
|
+
// DO NOT set turnEnded here - exec messages may follow!
|
|
769
|
+
}
|
|
770
|
+
// field 2 = exec_server_message (tool execution request)
|
|
771
|
+
if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
772
|
+
debugLog(`[DEBUG] Received exec_server_message (field 2), length: ${field.value.length}`);
|
|
773
|
+
// Parse the ExecServerMessage
|
|
774
|
+
const execRequest = parseExecServerMessage(field.value);
|
|
775
|
+
if (execRequest) {
|
|
776
|
+
// Log based on type
|
|
777
|
+
if (execRequest.type === 'mcp') {
|
|
778
|
+
debugLog(`[DEBUG] Parsed MCP exec request: id=${execRequest.id}, name=${execRequest.name}, toolName=${execRequest.toolName}, providerIdentifier=${execRequest.providerIdentifier}, toolCallId=${execRequest.toolCallId}`);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
debugLog(`[DEBUG] Parsed ${execRequest.type} exec request: id=${execRequest.id}`);
|
|
782
|
+
}
|
|
783
|
+
// Yield exec_request chunk for the server to handle
|
|
784
|
+
metrics.execRequests++;
|
|
785
|
+
yield {
|
|
786
|
+
type: "exec_request",
|
|
787
|
+
execRequest,
|
|
788
|
+
};
|
|
789
|
+
markProgress();
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
// Log other exec types we don't handle yet
|
|
793
|
+
const execFields = parseProtoFields(field.value);
|
|
794
|
+
debugLog(`[DEBUG] exec_server_message fields (unhandled): ${execFields.map(f => `field${f.fieldNumber}`).join(", ")}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// field 4 = kv_server_message
|
|
798
|
+
if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
799
|
+
metrics.kvMessages++;
|
|
800
|
+
const kvMsg = parseKvServerMessage(field.value);
|
|
801
|
+
debugLog(`[DEBUG] KV message: id=${kvMsg.id}, type=${kvMsg.messageType}, blobId=${kvMsg.blobId ? Buffer.from(kvMsg.blobId).toString('hex').slice(0, 20) : 'none'}...`);
|
|
802
|
+
appendSeqno = await this.handleKvMessage(kvMsg, requestId, appendSeqno);
|
|
803
|
+
this.currentAppendSeqno = appendSeqno;
|
|
804
|
+
}
|
|
805
|
+
// field 5 = exec_server_control_message (abort signal from server)
|
|
806
|
+
if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
807
|
+
debugLog("[DEBUG] Received exec_server_control_message (field 5)!");
|
|
808
|
+
const controlFields = parseProtoFields(field.value);
|
|
809
|
+
debugLog(`[DEBUG] exec_server_control_message fields: ${controlFields.map(f => `field${f.fieldNumber}:${f.wireType}`).join(", ")}`);
|
|
810
|
+
// ExecServerControlMessage has field 1 = abort (ExecServerAbort)
|
|
811
|
+
for (const cf of controlFields) {
|
|
812
|
+
if (cf.fieldNumber === 1 && cf.wireType === 2 && cf.value instanceof Uint8Array) {
|
|
813
|
+
debugLog("[DEBUG] Server sent abort signal!");
|
|
814
|
+
// Parse ExecServerAbort - it has field 1 = id (string)
|
|
815
|
+
const abortFields = parseProtoFields(cf.value);
|
|
816
|
+
for (const af of abortFields) {
|
|
817
|
+
if (af.fieldNumber === 1 && af.wireType === 2 && af.value instanceof Uint8Array) {
|
|
818
|
+
const abortId = new TextDecoder().decode(af.value);
|
|
819
|
+
debugLog(`[DEBUG] Abort id: ${abortId}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
yield { type: "exec_server_abort" };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
markProgress();
|
|
826
|
+
}
|
|
827
|
+
// field 7 = interaction_query (server asking for user approval/input)
|
|
828
|
+
if (field.fieldNumber === 7 && field.wireType === 2 && field.value instanceof Uint8Array) {
|
|
829
|
+
debugLog("[DEBUG] Received interaction_query (field 7)!");
|
|
830
|
+
const queryFields = parseProtoFields(field.value);
|
|
831
|
+
debugLog(`[DEBUG] interaction_query fields: ${queryFields.map(f => `field${f.fieldNumber}:${f.wireType}`).join(", ")}`);
|
|
832
|
+
// InteractionQuery structure:
|
|
833
|
+
// field 1 = id (uint32)
|
|
834
|
+
// field 2 = web_search_request_query (oneof)
|
|
835
|
+
// field 3 = ask_question_interaction_query (oneof)
|
|
836
|
+
// field 4 = switch_mode_request_query (oneof)
|
|
837
|
+
// field 5 = exa_search_request_query (oneof)
|
|
838
|
+
// field 6 = exa_fetch_request_query (oneof)
|
|
839
|
+
let queryId = 0;
|
|
840
|
+
let queryType = 'unknown';
|
|
841
|
+
for (const qf of queryFields) {
|
|
842
|
+
if (qf.fieldNumber === 1 && qf.wireType === 0) {
|
|
843
|
+
queryId = Number(qf.value);
|
|
844
|
+
}
|
|
845
|
+
else if (qf.fieldNumber === 2 && qf.wireType === 2) {
|
|
846
|
+
queryType = 'web_search';
|
|
847
|
+
}
|
|
848
|
+
else if (qf.fieldNumber === 3 && qf.wireType === 2) {
|
|
849
|
+
queryType = 'ask_question';
|
|
850
|
+
}
|
|
851
|
+
else if (qf.fieldNumber === 4 && qf.wireType === 2) {
|
|
852
|
+
queryType = 'switch_mode';
|
|
853
|
+
}
|
|
854
|
+
else if (qf.fieldNumber === 5 && qf.wireType === 2) {
|
|
855
|
+
queryType = 'exa_search';
|
|
856
|
+
}
|
|
857
|
+
else if (qf.fieldNumber === 6 && qf.wireType === 2) {
|
|
858
|
+
queryType = 'exa_fetch';
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
debugLog(`[DEBUG] InteractionQuery: id=${queryId}, type=${queryType}`);
|
|
862
|
+
// Yield the interaction query for the server to handle
|
|
863
|
+
yield {
|
|
864
|
+
type: "interaction_query",
|
|
865
|
+
queryId,
|
|
866
|
+
queryType,
|
|
867
|
+
};
|
|
868
|
+
markProgress();
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch (parseErr) {
|
|
872
|
+
const error = parseErr instanceof Error ? parseErr : new Error(String(parseErr));
|
|
873
|
+
console.error("Error parsing field:", field.fieldNumber, error);
|
|
874
|
+
yield { type: "error", error: `Parse error in field ${field.fieldNumber}: ${error.message}` };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (turnEnded) {
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
buffer = buffer.slice(offset);
|
|
882
|
+
}
|
|
883
|
+
// Clean exit - check for KV blob assistant responses if no text was streamed
|
|
884
|
+
if (turnEnded) {
|
|
885
|
+
controller.abort(); // Clean up the connection
|
|
886
|
+
logTimingMetrics(metrics);
|
|
887
|
+
// Session reuse: If no text was streamed but we have pending assistant blobs,
|
|
888
|
+
// emit them as kv_blob_assistant chunks so the server can use the content
|
|
889
|
+
if (!hasStreamedText && this.pendingAssistantBlobs.length > 0) {
|
|
890
|
+
debugLog(`[DEBUG] No streamed text but found ${this.pendingAssistantBlobs.length} assistant blob(s) - emitting`);
|
|
891
|
+
for (const blob of this.pendingAssistantBlobs) {
|
|
892
|
+
yield { type: "kv_blob_assistant", blobContent: blob.content };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
yield { type: "done" };
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
finally {
|
|
899
|
+
reader.releaseLock();
|
|
900
|
+
clearTimeout(timeout);
|
|
901
|
+
this.currentRequestId = null;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
clearTimeout(timeout);
|
|
906
|
+
this.currentRequestId = null;
|
|
907
|
+
const error = err;
|
|
908
|
+
if (error.name === 'AbortError') {
|
|
909
|
+
// Normal termination after turn ended
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
console.error("Agent stream error:", error.name, error.message, err.stack);
|
|
913
|
+
yield { type: "error", error: error.message || String(err) };
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Send a non-streaming chat request (collects all chunks)
|
|
918
|
+
*/
|
|
919
|
+
async chat(request) {
|
|
920
|
+
let result = "";
|
|
921
|
+
for await (const chunk of this.chatStream(request)) {
|
|
922
|
+
if (chunk.type === "error") {
|
|
923
|
+
throw new Error(chunk.error ?? "Unknown error");
|
|
924
|
+
}
|
|
925
|
+
if (chunk.type === "text" && chunk.content) {
|
|
926
|
+
result += chunk.content;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Create an Agent Service client
|
|
934
|
+
*/
|
|
935
|
+
export function createAgentServiceClient(accessToken, options) {
|
|
936
|
+
return new AgentServiceClient(accessToken, options);
|
|
937
|
+
}
|
|
938
|
+
//# sourceMappingURL=agent-service.js.map
|