@oh-my-pi/pi-ai 16.3.0 → 16.3.3
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/CHANGELOG.md +28 -0
- package/dist/types/auth-broker/remote-store.d.ts +3 -2
- package/dist/types/auth-storage.d.ts +7 -0
- package/dist/types/dialect/demotion.d.ts +15 -10
- package/dist/types/providers/anthropic.d.ts +10 -0
- package/dist/types/providers/cursor.d.ts +23 -2
- package/dist/types/providers/openai-responses.d.ts +2 -3
- package/dist/types/providers/openai-shared.d.ts +2 -2
- package/dist/types/usage/claude.d.ts +1 -1
- package/dist/types/utils/block-symbols.d.ts +16 -0
- package/dist/types/utils/proxy.d.ts +7 -1
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +109 -9
- package/src/auth-storage.ts +24 -10
- package/src/dialect/demotion.ts +18 -13
- package/src/providers/anthropic.ts +86 -2
- package/src/providers/azure-openai-responses.ts +1 -1
- package/src/providers/cursor.ts +108 -2
- package/src/providers/devin.ts +15 -0
- package/src/providers/gitlab-duo-workflow.ts +87 -14
- package/src/providers/openai-completions.ts +10 -7
- package/src/providers/openai-responses.ts +12 -40
- package/src/providers/openai-shared.ts +8 -30
- package/src/providers/transform-messages.ts +16 -11
- package/src/usage/claude.ts +213 -20
- package/src/utils/block-symbols.ts +16 -0
- package/src/utils/proxy.ts +104 -44
- package/src/utils/validation.ts +64 -0
- package/src/providers/openai-responses-reasoning-suppression.md +0 -1
|
@@ -329,15 +329,26 @@ const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages";
|
|
|
329
329
|
type AnthropicProviderSessionState = ProviderSessionState & {
|
|
330
330
|
strictToolsDisabled: boolean;
|
|
331
331
|
fastModeDisabled: boolean;
|
|
332
|
+
/**
|
|
333
|
+
* Runtime-learned: this endpoint returned `400 Invalid signature in
|
|
334
|
+
* thinking block` for a replayed unsigned thinking block, so it must be
|
|
335
|
+
* treated as a signing proxy from now on. All subsequent requests demote
|
|
336
|
+
* unsigned thinking to text for this (baseUrl, modelId), same behavior as
|
|
337
|
+
* an explicit `compat.replayUnsignedThinking: false`. Cleared on session
|
|
338
|
+
* close.
|
|
339
|
+
*/
|
|
340
|
+
replayUnsignedThinkingDisabled: boolean;
|
|
332
341
|
};
|
|
333
342
|
|
|
334
343
|
function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
|
|
335
344
|
const state: AnthropicProviderSessionState = {
|
|
336
345
|
strictToolsDisabled: false,
|
|
337
346
|
fastModeDisabled: false,
|
|
347
|
+
replayUnsignedThinkingDisabled: false,
|
|
338
348
|
close: () => {
|
|
339
349
|
state.strictToolsDisabled = false;
|
|
340
350
|
state.fastModeDisabled = false;
|
|
351
|
+
state.replayUnsignedThinkingDisabled = false;
|
|
341
352
|
},
|
|
342
353
|
};
|
|
343
354
|
return state;
|
|
@@ -1644,6 +1655,32 @@ function calculateFallbackTurnCost(
|
|
|
1644
1655
|
return true;
|
|
1645
1656
|
}
|
|
1646
1657
|
|
|
1658
|
+
/**
|
|
1659
|
+
* Detects the Anthropic `400 Invalid `signature` in `thinking` block` failure
|
|
1660
|
+
* a signing proxy returns when a stripped/unsigned prior thinking block is
|
|
1661
|
+
* replayed as `signature: ""`. Exported for the compat tests.
|
|
1662
|
+
*/
|
|
1663
|
+
const INVALID_THINKING_SIGNATURE_PATTERN = /invalid\s+`?signature`?\s+in\s+`?thinking`?(?:\s+block)?/i;
|
|
1664
|
+
export function isInvalidThinkingSignatureError(message: string): boolean {
|
|
1665
|
+
return INVALID_THINKING_SIGNATURE_PATTERN.test(message);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Prepend a pointed remediation to Anthropic's `Invalid signature in thinking
|
|
1670
|
+
* block` 400 when the model looks like an unmarked custom signing proxy
|
|
1671
|
+
* (opaque baseUrl, `spec.reasoning: true`, no explicit
|
|
1672
|
+
* `compat.replayUnsignedThinking` override). The default is native replay for
|
|
1673
|
+
* the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy
|
|
1674
|
+
* case into a one-line fix instead of a silent retry loop (#4297).
|
|
1675
|
+
*/
|
|
1676
|
+
export function maybeAddReplayUnsignedThinkingHint(model: Model<"anthropic-messages">, message: string): string {
|
|
1677
|
+
if (!isInvalidThinkingSignatureError(message)) return message;
|
|
1678
|
+
if (model.compat.officialEndpoint) return message;
|
|
1679
|
+
if (model.compatConfig?.replayUnsignedThinking !== undefined) return message;
|
|
1680
|
+
const hint = `Provider "${model.provider}" looks like an Anthropic-compatible signing proxy: it rejected a replayed unsigned thinking block. Set \`compat.replayUnsignedThinking: false\` under \`providers.${model.provider}\` in your models.yml and retry. See https://github.com/can1357/oh-my-pi/issues/4297.`;
|
|
1681
|
+
return `${hint}\n\n${message}`;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1647
1684
|
const streamAnthropicOnce = (
|
|
1648
1685
|
model: Model<"anthropic-messages">,
|
|
1649
1686
|
context: Context,
|
|
@@ -1698,6 +1735,7 @@ const streamAnthropicOnce = (
|
|
|
1698
1735
|
let disableStrictTools =
|
|
1699
1736
|
(providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false);
|
|
1700
1737
|
let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
|
|
1738
|
+
let forceDemoteUnsignedThinking = providerSessionState?.replayUnsignedThinkingDisabled ?? false;
|
|
1701
1739
|
const mergedCallerHeaders = mergeHeaders(model.headers, options?.headers);
|
|
1702
1740
|
const umansGatewayWebSearchHeader = getUmansWebSearchHeader(model, mergedCallerHeaders);
|
|
1703
1741
|
|
|
@@ -1808,6 +1846,7 @@ const streamAnthropicOnce = (
|
|
|
1808
1846
|
options,
|
|
1809
1847
|
disableStrictTools,
|
|
1810
1848
|
umansGatewayWebSearchHeader !== undefined,
|
|
1849
|
+
forceDemoteUnsignedThinking,
|
|
1811
1850
|
);
|
|
1812
1851
|
if (disableStrictTools) {
|
|
1813
1852
|
dropAnthropicStrictTools(nextParams);
|
|
@@ -2377,6 +2416,39 @@ const streamAnthropicOnce = (
|
|
|
2377
2416
|
firstTokenTime = undefined;
|
|
2378
2417
|
continue;
|
|
2379
2418
|
}
|
|
2419
|
+
if (
|
|
2420
|
+
!forceDemoteUnsignedThinking &&
|
|
2421
|
+
firstTokenTime === undefined &&
|
|
2422
|
+
!streamedReplayUnsafeContent &&
|
|
2423
|
+
isInvalidThinkingSignatureError(
|
|
2424
|
+
streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
|
|
2425
|
+
)
|
|
2426
|
+
) {
|
|
2427
|
+
logger.warn(
|
|
2428
|
+
"anthropic: signing proxy detected (Invalid signature in thinking block), demoting unsigned thinking and retrying",
|
|
2429
|
+
{
|
|
2430
|
+
provider: model.provider,
|
|
2431
|
+
model: model.id,
|
|
2432
|
+
baseUrl,
|
|
2433
|
+
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
|
|
2434
|
+
},
|
|
2435
|
+
);
|
|
2436
|
+
if (providerSessionState) {
|
|
2437
|
+
providerSessionState.replayUnsignedThinkingDisabled = true;
|
|
2438
|
+
}
|
|
2439
|
+
forceDemoteUnsignedThinking = true;
|
|
2440
|
+
params = await prepareParams();
|
|
2441
|
+
providerRetryAttempt = 0;
|
|
2442
|
+
output.content.length = 0;
|
|
2443
|
+
output.model = model.id;
|
|
2444
|
+
output.responseId = undefined;
|
|
2445
|
+
output.errorMessage = undefined;
|
|
2446
|
+
output.providerPayload = undefined;
|
|
2447
|
+
output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
|
|
2448
|
+
output.stopReason = "stop";
|
|
2449
|
+
firstTokenTime = undefined;
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2380
2452
|
if (
|
|
2381
2453
|
!dropFastMode &&
|
|
2382
2454
|
model.provider === "anthropic" &&
|
|
@@ -2453,6 +2525,9 @@ const streamAnthropicOnce = (
|
|
|
2453
2525
|
if (dropFastMode && model.provider === "anthropic" && options?.serviceTier === "priority") {
|
|
2454
2526
|
output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
|
|
2455
2527
|
}
|
|
2528
|
+
if (forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking) {
|
|
2529
|
+
output.disabledFeatures = [...(output.disabledFeatures ?? []), "unsigned-thinking-replay"];
|
|
2530
|
+
}
|
|
2456
2531
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
2457
2532
|
stream.end();
|
|
2458
2533
|
} catch (error) {
|
|
@@ -2468,7 +2543,7 @@ const streamAnthropicOnce = (
|
|
|
2468
2543
|
output.stopReason = result.stopReason;
|
|
2469
2544
|
output.errorStatus = result.status;
|
|
2470
2545
|
output.errorId = result.id;
|
|
2471
|
-
output.errorMessage = result.message;
|
|
2546
|
+
output.errorMessage = maybeAddReplayUnsignedThinkingHint(model, result.message);
|
|
2472
2547
|
output.duration = performance.now() - startTime;
|
|
2473
2548
|
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
2474
2549
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
@@ -3025,7 +3100,16 @@ function buildParams(
|
|
|
3025
3100
|
options?: AnthropicOptions,
|
|
3026
3101
|
disableStrictTools = false,
|
|
3027
3102
|
useUmansGatewayWebSearch = false,
|
|
3103
|
+
forceDemoteUnsignedThinking = false,
|
|
3028
3104
|
): MessageCreateParamsStreaming {
|
|
3105
|
+
// A session-scoped auto-demote (learned from a live signing 400) clones the
|
|
3106
|
+
// resolved compat with `replayUnsignedThinking: false` so every subsequent
|
|
3107
|
+
// downstream read (convertAnthropicMessages, transformMessages) sees the
|
|
3108
|
+
// demoted default without mutating the shared `model` reference.
|
|
3109
|
+
const effectiveModel =
|
|
3110
|
+
forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking
|
|
3111
|
+
? { ...model, compat: { ...model.compat, replayUnsignedThinking: false } }
|
|
3112
|
+
: model;
|
|
3029
3113
|
const { cacheControl } = getCacheControl(model, options?.cacheRetention, isOAuthToken);
|
|
3030
3114
|
|
|
3031
3115
|
// Pre-compute system blocks so they occupy the right slot in the serialized body.
|
|
@@ -3151,7 +3235,7 @@ function buildParams(
|
|
|
3151
3235
|
// metadata → max_tokens → thinking → context_management → output_config → stream.
|
|
3152
3236
|
const params: MessageCreateParamsStreaming = {
|
|
3153
3237
|
model: options?.requestModelId ?? model.requestModelId ?? model.id,
|
|
3154
|
-
messages: convertAnthropicMessages(context.messages,
|
|
3238
|
+
messages: convertAnthropicMessages(context.messages, effectiveModel, isOAuthToken, {
|
|
3155
3239
|
serverSideFallbackEnabled: !!options?.fallbacks?.length,
|
|
3156
3240
|
}),
|
|
3157
3241
|
...(systemBlocks && { system: systemBlocks }),
|
package/src/providers/cursor.ts
CHANGED
|
@@ -133,6 +133,7 @@ import type {
|
|
|
133
133
|
import { normalizeSystemPrompts } from "../utils";
|
|
134
134
|
import {
|
|
135
135
|
clearStreamingPartialJson,
|
|
136
|
+
kCursorExecResolved,
|
|
136
137
|
kStreamingBlockIndex,
|
|
137
138
|
kStreamingBlockKind,
|
|
138
139
|
kStreamingLastParseLen,
|
|
@@ -147,6 +148,8 @@ import { toolWireSchema } from "../utils/schema/wire";
|
|
|
147
148
|
export const CURSOR_API_URL = "https://api2.cursor.sh";
|
|
148
149
|
export const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
149
150
|
|
|
151
|
+
const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
|
|
152
|
+
|
|
150
153
|
const conversationStateCache = new Map<string, ConversationStateStructure>();
|
|
151
154
|
const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
|
|
152
155
|
|
|
@@ -392,7 +395,10 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
392
395
|
|
|
393
396
|
const proxyUrl = shouldBypassProxy(new URL(baseUrl)) ? undefined : getProxyForProvider(model.provider);
|
|
394
397
|
if (proxyUrl) {
|
|
395
|
-
const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl
|
|
398
|
+
const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl, {
|
|
399
|
+
signal: options?.signal,
|
|
400
|
+
timeoutMs: CURSOR_PROXY_TUNNEL_TIMEOUT_MS,
|
|
401
|
+
});
|
|
396
402
|
h2Client = http2.connect(baseUrl, {
|
|
397
403
|
createConnection: () => tlsSocket,
|
|
398
404
|
});
|
|
@@ -628,7 +634,8 @@ export type ToolCallState = ToolCall & {
|
|
|
628
634
|
[kStreamingBlockIndex]: number;
|
|
629
635
|
[kStreamingPartialJson]?: string;
|
|
630
636
|
[kStreamingLastParseLen]?: number;
|
|
631
|
-
[kStreamingBlockKind]: "mcp" | "todo";
|
|
637
|
+
[kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec";
|
|
638
|
+
[kCursorExecResolved]?: true;
|
|
632
639
|
};
|
|
633
640
|
|
|
634
641
|
export interface BlockState {
|
|
@@ -674,6 +681,9 @@ async function handleServerMessage(
|
|
|
674
681
|
execHandlers,
|
|
675
682
|
onToolResult,
|
|
676
683
|
requestContextTools,
|
|
684
|
+
output,
|
|
685
|
+
stream,
|
|
686
|
+
state,
|
|
677
687
|
);
|
|
678
688
|
} else if (msgCase === "conversationCheckpointUpdate") {
|
|
679
689
|
handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
|
|
@@ -1030,6 +1040,9 @@ async function handleExecServerMessage(
|
|
|
1030
1040
|
execHandlers: CursorExecHandlers | undefined,
|
|
1031
1041
|
onToolResult: CursorToolResultHandler | undefined,
|
|
1032
1042
|
requestContextTools: McpToolDefinition[],
|
|
1043
|
+
output: AssistantMessage,
|
|
1044
|
+
stream: AssistantMessageEventStream,
|
|
1045
|
+
state: BlockState,
|
|
1033
1046
|
): Promise<void> {
|
|
1034
1047
|
const execCase = execMsg.message.case;
|
|
1035
1048
|
log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers });
|
|
@@ -1064,6 +1077,8 @@ async function handleExecServerMessage(
|
|
|
1064
1077
|
switch (execCase) {
|
|
1065
1078
|
case "readArgs": {
|
|
1066
1079
|
const args = execMsg.message.value;
|
|
1080
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1081
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
|
|
1067
1082
|
const { execResult } = await resolveExecHandler(
|
|
1068
1083
|
args,
|
|
1069
1084
|
execHandlers?.read?.bind(execHandlers),
|
|
@@ -1077,6 +1092,11 @@ async function handleExecServerMessage(
|
|
|
1077
1092
|
}
|
|
1078
1093
|
case "lsArgs": {
|
|
1079
1094
|
const args = execMsg.message.value;
|
|
1095
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1096
|
+
// Bridge maps `ls` onto the coding-agent `read` tool (see
|
|
1097
|
+
// `CursorExecHandlers.ls` in `pi-coding-agent/src/cursor.ts`); mirror
|
|
1098
|
+
// that here so the synthesized block matches the toolResult's `toolName`.
|
|
1099
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
|
|
1080
1100
|
const { execResult } = await resolveExecHandler(
|
|
1081
1101
|
args,
|
|
1082
1102
|
execHandlers?.ls?.bind(execHandlers),
|
|
@@ -1090,6 +1110,16 @@ async function handleExecServerMessage(
|
|
|
1090
1110
|
}
|
|
1091
1111
|
case "grepArgs": {
|
|
1092
1112
|
const args = execMsg.message.value;
|
|
1113
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1114
|
+
// Mirror the coding-agent bridge's arg mapping so live UI (from
|
|
1115
|
+
// `tool_execution_start`) and rebuilt transcript (from this block)
|
|
1116
|
+
// display identical args.
|
|
1117
|
+
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
|
|
1118
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "grep", {
|
|
1119
|
+
pattern: args.pattern,
|
|
1120
|
+
path: searchPath,
|
|
1121
|
+
case: args.caseInsensitive === true ? false : undefined,
|
|
1122
|
+
});
|
|
1093
1123
|
const { execResult } = await resolveExecHandler(
|
|
1094
1124
|
args,
|
|
1095
1125
|
execHandlers?.grep?.bind(execHandlers),
|
|
@@ -1103,6 +1133,13 @@ async function handleExecServerMessage(
|
|
|
1103
1133
|
}
|
|
1104
1134
|
case "writeArgs": {
|
|
1105
1135
|
const args = execMsg.message.value;
|
|
1136
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1137
|
+
// Match the bridge: prefer `fileText`, fall back to decoded `fileBytes`.
|
|
1138
|
+
const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
|
|
1139
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "write", {
|
|
1140
|
+
path: args.path,
|
|
1141
|
+
content,
|
|
1142
|
+
});
|
|
1106
1143
|
const { execResult } = await resolveExecHandler(
|
|
1107
1144
|
args,
|
|
1108
1145
|
execHandlers?.write?.bind(execHandlers),
|
|
@@ -1125,6 +1162,8 @@ async function handleExecServerMessage(
|
|
|
1125
1162
|
}
|
|
1126
1163
|
case "deleteArgs": {
|
|
1127
1164
|
const args = execMsg.message.value;
|
|
1165
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1166
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "delete", { path: args.path });
|
|
1128
1167
|
const { execResult } = await resolveExecHandler(
|
|
1129
1168
|
args,
|
|
1130
1169
|
execHandlers?.delete?.bind(execHandlers),
|
|
@@ -1138,7 +1177,16 @@ async function handleExecServerMessage(
|
|
|
1138
1177
|
}
|
|
1139
1178
|
case "shellArgs": {
|
|
1140
1179
|
const args = execMsg.message.value;
|
|
1180
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1141
1181
|
const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
|
|
1182
|
+
// Match the bridge (`CursorExecHandlers.shell`): map `workingDirectory`
|
|
1183
|
+
// → `cwd`, drop non-positive timeouts.
|
|
1184
|
+
const shellTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
1185
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
|
|
1186
|
+
command: args.command,
|
|
1187
|
+
cwd: args.workingDirectory || undefined,
|
|
1188
|
+
timeout: shellTimeout,
|
|
1189
|
+
});
|
|
1142
1190
|
const { execResult } = await resolveExecHandler(
|
|
1143
1191
|
args,
|
|
1144
1192
|
execHandlers?.shell?.bind(execHandlers),
|
|
@@ -1153,6 +1201,13 @@ async function handleExecServerMessage(
|
|
|
1153
1201
|
}
|
|
1154
1202
|
case "shellStreamArgs": {
|
|
1155
1203
|
const args = execMsg.message.value;
|
|
1204
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1205
|
+
const shellStreamTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
1206
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
|
|
1207
|
+
command: args.command,
|
|
1208
|
+
cwd: args.workingDirectory || undefined,
|
|
1209
|
+
timeout: shellStreamTimeout,
|
|
1210
|
+
});
|
|
1156
1211
|
await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult);
|
|
1157
1212
|
return;
|
|
1158
1213
|
}
|
|
@@ -1200,6 +1255,13 @@ async function handleExecServerMessage(
|
|
|
1200
1255
|
}
|
|
1201
1256
|
case "diagnosticsArgs": {
|
|
1202
1257
|
const args = execMsg.message.value;
|
|
1258
|
+
if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
|
|
1259
|
+
// Bridge maps `diagnostics` onto the coding-agent `lsp` tool with
|
|
1260
|
+
// `action: "diagnostics"` and `file: path`.
|
|
1261
|
+
synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "lsp", {
|
|
1262
|
+
action: "diagnostics",
|
|
1263
|
+
file: args.path,
|
|
1264
|
+
});
|
|
1203
1265
|
const { execResult } = await resolveExecHandler(
|
|
1204
1266
|
args,
|
|
1205
1267
|
execHandlers?.diagnostics?.bind(execHandlers),
|
|
@@ -2016,6 +2078,50 @@ function endCurrentThinkingBlock(
|
|
|
2016
2078
|
state.setThinkingBlock(null);
|
|
2017
2079
|
}
|
|
2018
2080
|
|
|
2081
|
+
/**
|
|
2082
|
+
* Synthesize a completed `toolCall` content block for a Cursor exec-channel
|
|
2083
|
+
* native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`).
|
|
2084
|
+
*
|
|
2085
|
+
* Args arrive complete on the exec message, so the block opens and closes in
|
|
2086
|
+
* one step — no partial-JSON streaming path. Without this the persisted
|
|
2087
|
+
* assistant message carries only text/thinking blocks, and on replay the
|
|
2088
|
+
* following `toolResult` messages have no matching `toolCall.id` in
|
|
2089
|
+
* `renderSessionContext`, so they render as header-less `⎿` lines beneath the
|
|
2090
|
+
* last text block instead of proper tool components (issue #4348).
|
|
2091
|
+
*
|
|
2092
|
+
* The block is stamped with {@link kCursorExecResolved} so the shared
|
|
2093
|
+
* `agent-loop.ts` execution pass skips it — Cursor's server-driven exec
|
|
2094
|
+
* channel already ran the tool via the bridge and buffered the result, so
|
|
2095
|
+
* treating this block as runnable would re-execute the same side-effecting
|
|
2096
|
+
* tool a second time.
|
|
2097
|
+
*
|
|
2098
|
+
* Exported for tests to exercise ordering with adjacent text/thinking blocks.
|
|
2099
|
+
*/
|
|
2100
|
+
export function synthesizeCursorExecToolCall(
|
|
2101
|
+
output: AssistantMessage,
|
|
2102
|
+
stream: AssistantMessageEventStream,
|
|
2103
|
+
state: BlockState,
|
|
2104
|
+
toolCallId: string,
|
|
2105
|
+
toolName: string,
|
|
2106
|
+
args: Record<string, unknown>,
|
|
2107
|
+
): void {
|
|
2108
|
+
endCurrentTextBlock(output, stream, state);
|
|
2109
|
+
endCurrentThinkingBlock(output, stream, state);
|
|
2110
|
+
const block: ToolCallState = {
|
|
2111
|
+
type: "toolCall",
|
|
2112
|
+
id: toolCallId,
|
|
2113
|
+
name: toolName,
|
|
2114
|
+
arguments: args,
|
|
2115
|
+
[kStreamingBlockIndex]: output.content.length,
|
|
2116
|
+
[kStreamingBlockKind]: "cursor-exec",
|
|
2117
|
+
[kCursorExecResolved]: true,
|
|
2118
|
+
};
|
|
2119
|
+
output.content.push(block);
|
|
2120
|
+
const idx = output.content.length - 1;
|
|
2121
|
+
stream.push({ type: "toolcall_start", contentIndex: idx, partial: output });
|
|
2122
|
+
stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2019
2125
|
/** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
|
|
2020
2126
|
export function processInteractionUpdate(
|
|
2021
2127
|
update: any,
|
package/src/providers/devin.ts
CHANGED
|
@@ -69,6 +69,15 @@ const DEVIN_DEFAULT_STOP_PATTERNS = ["<|user|>", "<|bot|>", "<|context_request|>
|
|
|
69
69
|
/** Connect streaming framing: flag byte bit 0x01 = gzip payload, 0x02 = end-of-stream JSON trailers. */
|
|
70
70
|
const CONNECT_COMPRESSED_FLAG = 0x01;
|
|
71
71
|
const CONNECT_END_STREAM_FLAG = 0x02;
|
|
72
|
+
/**
|
|
73
|
+
* Hard upper bound on a single Connect frame payload. The 4-byte length prefix
|
|
74
|
+
* is otherwise attacker-controlled (up to `2**32 - 1`), so a malicious or buggy
|
|
75
|
+
* peer could force {@link streamDevin}'s reader to buffer gigabytes via
|
|
76
|
+
* `Buffer.concat` before the idle-timeout wrapper aborts. Well above any
|
|
77
|
+
* legitimate Cascade response but tight enough that a corrupt length prefix
|
|
78
|
+
* fails fast instead of consuming memory.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024;
|
|
72
81
|
|
|
73
82
|
export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
74
83
|
model: Model<"devin-agent">,
|
|
@@ -201,6 +210,12 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
201
210
|
while (pending.length >= 5) {
|
|
202
211
|
const flag = pending[0];
|
|
203
212
|
const len = pending.readUInt32BE(1);
|
|
213
|
+
if (len > MAX_CONNECT_FRAME_PAYLOAD) {
|
|
214
|
+
throw new AIError.ProviderResponseError(
|
|
215
|
+
`Devin Connect frame length ${len} exceeds ${MAX_CONNECT_FRAME_PAYLOAD}-byte cap`,
|
|
216
|
+
{ provider: model.provider, kind: "envelope" },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
204
219
|
if (pending.length < 5 + len) break;
|
|
205
220
|
const payload = pending.subarray(5, 5 + len);
|
|
206
221
|
pending = pending.subarray(5 + len);
|
|
@@ -47,6 +47,20 @@ const GITLAB_DUO_WORKFLOW_CLIENT_TYPE = "node-websocket";
|
|
|
47
47
|
* run reconnects once on the same `workflowID` (server-side resume).
|
|
48
48
|
*/
|
|
49
49
|
const GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS = 90_000;
|
|
50
|
+
/**
|
|
51
|
+
* Absolute deadline (ms) for each REST setup fetch (`ensureGitLabDuoWorkflowSettings`,
|
|
52
|
+
* `discoverGitLabDuoWorkflowProject`, `resolveGitLabDuoWorkflowNumericProjectId`,
|
|
53
|
+
* `requestGitLabDuoWorkflowDirectAccess`, `createGitLabDuoWorkflow`,
|
|
54
|
+
* `fetchGitLabDuoWorkflowAvailableModels`, `stopGitLabDuoWorkflow`).
|
|
55
|
+
*
|
|
56
|
+
* `streamGitLabDuoWorkflow` pushes its `start` event before these calls run and the
|
|
57
|
+
* `gitlab-duo-agent` bypass in `streamSimple` skips the `register-builtins`
|
|
58
|
+
* `iterateWithIdleTimeout` wrapper, so a stalled setup fetch would otherwise leave
|
|
59
|
+
* the stream with no terminal event. 30s covers healthy p99 for every REST endpoint
|
|
60
|
+
* the workflow touches while still surfacing a real stall as a provider error;
|
|
61
|
+
* matches the OAuth `TOKEN_REQUEST_TIMEOUT_MS` used by sibling GitLab flows.
|
|
62
|
+
*/
|
|
63
|
+
const GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS = 30_000;
|
|
50
64
|
/**
|
|
51
65
|
* How many times a single stream may restart on a FRESH workflow after the server
|
|
52
66
|
* reports its per-workflow step (graph-recursion) limit. Long OMP tool-call loops
|
|
@@ -913,6 +927,19 @@ export function gitLabDuoWorkflowErrorText(error: unknown): string {
|
|
|
913
927
|
return error instanceof Error ? error.message : String(error);
|
|
914
928
|
}
|
|
915
929
|
|
|
930
|
+
// Absolute-deadline signal for one REST setup fetch (`fetch`, `direct_access`, etc.).
|
|
931
|
+
// The caller's abort signal — when present — is folded in with `AbortSignal.any`, so
|
|
932
|
+
// either the request being cancelled OR the local timeout aborts the fetch. Called
|
|
933
|
+
// per-fetch so each REST call gets its OWN fresh budget; a shared timeout would race
|
|
934
|
+
// several fetches on the same clock and starve the later ones after the first spent
|
|
935
|
+
// the whole budget. The workflow's `start` event already streamed before any of
|
|
936
|
+
// these calls run, so an unbounded fetch would leave the assistant stream with no
|
|
937
|
+
// terminal event — see {@link GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS}.
|
|
938
|
+
function gitLabDuoWorkflowRestSignal(callerSignal?: AbortSignal): AbortSignal {
|
|
939
|
+
const timeoutSignal = AbortSignal.timeout(GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS);
|
|
940
|
+
return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
941
|
+
}
|
|
942
|
+
|
|
916
943
|
async function readGitLabDuoWorkflowResponseErrorMessage(response: Response): Promise<string | undefined> {
|
|
917
944
|
try {
|
|
918
945
|
const payload: unknown = await response.json();
|
|
@@ -1074,7 +1101,7 @@ async function runGitLabDuoWorkflow(
|
|
|
1074
1101
|
// success or 4xx). A transient network error / 5xx returns false so a later
|
|
1075
1102
|
// turn retries instead of permanently skipping the PUT on a namespace whose
|
|
1076
1103
|
// flags are still off.
|
|
1077
|
-
if (await ensureGitLabDuoWorkflowSettings(fetchImpl, baseUrl, apiKey, restNamespaceId)) {
|
|
1104
|
+
if (await ensureGitLabDuoWorkflowSettings(fetchImpl, baseUrl, apiKey, restNamespaceId, options.signal)) {
|
|
1078
1105
|
markGitLabDuoWorkflowSettingsEnsured(apiKey, baseUrl, options.cwd);
|
|
1079
1106
|
}
|
|
1080
1107
|
}
|
|
@@ -1089,7 +1116,7 @@ async function runGitLabDuoWorkflow(
|
|
|
1089
1116
|
!configuredProjectPath && !configuredProjectId && isGitLabDuoWorkflowInlineFlow(workflowDefinition)
|
|
1090
1117
|
? namespaceSelection.projectPath
|
|
1091
1118
|
? { path: namespaceSelection.projectPath }
|
|
1092
|
-
: await discoverGitLabDuoWorkflowProject(fetchImpl, baseUrl, apiKey, restNamespaceId)
|
|
1119
|
+
: await discoverGitLabDuoWorkflowProject(fetchImpl, baseUrl, apiKey, restNamespaceId, options.signal)
|
|
1093
1120
|
: undefined;
|
|
1094
1121
|
if (discoveredProject) {
|
|
1095
1122
|
traceGitLabDuoWorkflow("project.discover", {
|
|
@@ -1111,7 +1138,7 @@ async function runGitLabDuoWorkflow(
|
|
|
1111
1138
|
const webSocketProjectId =
|
|
1112
1139
|
projectId ??
|
|
1113
1140
|
(projectPath
|
|
1114
|
-
? await resolveGitLabDuoWorkflowNumericProjectId(fetchImpl, baseUrl, apiKey, projectPath)
|
|
1141
|
+
? await resolveGitLabDuoWorkflowNumericProjectId(fetchImpl, baseUrl, apiKey, projectPath, options.signal)
|
|
1115
1142
|
: undefined);
|
|
1116
1143
|
const workflowConnection: GitLabDuoWorkflowDirectAccessConnection = options.workflowToken
|
|
1117
1144
|
? { token: options.workflowToken, headers: {}, serviceEndpoint: false }
|
|
@@ -1122,6 +1149,7 @@ async function runGitLabDuoWorkflow(
|
|
|
1122
1149
|
rootNamespaceId,
|
|
1123
1150
|
restProjectId,
|
|
1124
1151
|
workflowDefinition,
|
|
1152
|
+
options.signal,
|
|
1125
1153
|
);
|
|
1126
1154
|
const workflowId =
|
|
1127
1155
|
options.workflowId ??
|
|
@@ -1135,7 +1163,13 @@ async function runGitLabDuoWorkflow(
|
|
|
1135
1163
|
workflowDefinition,
|
|
1136
1164
|
options.signal,
|
|
1137
1165
|
));
|
|
1138
|
-
const availableModels = await fetchGitLabDuoWorkflowAvailableModels(
|
|
1166
|
+
const availableModels = await fetchGitLabDuoWorkflowAvailableModels(
|
|
1167
|
+
fetchImpl,
|
|
1168
|
+
baseUrl,
|
|
1169
|
+
apiKey,
|
|
1170
|
+
rootNamespaceId,
|
|
1171
|
+
options.signal,
|
|
1172
|
+
);
|
|
1139
1173
|
const selectedModelIdentifier = selectGitLabDuoWorkflowModelRef(model.id, availableModels);
|
|
1140
1174
|
// A `toolChoice: "none"` side-request (e.g. handoff keeps live tool definitions
|
|
1141
1175
|
// in the cache prefix but disables tool use) must not advertise the tools to
|
|
@@ -1466,6 +1500,7 @@ async function fetchGitLabDuoWorkflowAvailableModels(
|
|
|
1466
1500
|
baseUrl: string,
|
|
1467
1501
|
apiKey: string,
|
|
1468
1502
|
rootNamespaceId: string,
|
|
1503
|
+
signal?: AbortSignal,
|
|
1469
1504
|
): Promise<GitLabAvailableModelsPayload | undefined> {
|
|
1470
1505
|
try {
|
|
1471
1506
|
const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/graphql"), {
|
|
@@ -1478,12 +1513,16 @@ async function fetchGitLabDuoWorkflowAvailableModels(
|
|
|
1478
1513
|
query: GITLAB_DUO_WORKFLOW_AVAILABLE_MODELS_QUERY,
|
|
1479
1514
|
variables: { rootNamespaceId: toGitLabGraphQLNamespaceId(rootNamespaceId) },
|
|
1480
1515
|
}),
|
|
1516
|
+
signal: gitLabDuoWorkflowRestSignal(signal),
|
|
1481
1517
|
});
|
|
1482
1518
|
if (!response.ok) return undefined;
|
|
1483
1519
|
const payload: unknown = await response.json();
|
|
1484
1520
|
const models = getRecord(getRecord(payload, "data"), "aiChatAvailableModels");
|
|
1485
1521
|
return parseGitLabAvailableModelsPayload(models);
|
|
1486
1522
|
} catch {
|
|
1523
|
+
// Timeout (AbortSignal.timeout) surfaces as an AbortError here; matches the pre-fix
|
|
1524
|
+
// transient-network behavior (undefined -> caller falls back to defaults), so a
|
|
1525
|
+
// stalled models fetch degrades rather than hanging the whole stream.
|
|
1487
1526
|
return undefined;
|
|
1488
1527
|
}
|
|
1489
1528
|
}
|
|
@@ -1513,6 +1552,7 @@ async function resolveGitLabDuoWorkflowNumericProjectId(
|
|
|
1513
1552
|
baseUrl: string,
|
|
1514
1553
|
apiKey: string,
|
|
1515
1554
|
projectPath: string,
|
|
1555
|
+
signal?: AbortSignal,
|
|
1516
1556
|
): Promise<string | undefined> {
|
|
1517
1557
|
try {
|
|
1518
1558
|
const response = await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(projectPath)}`), {
|
|
@@ -1521,11 +1561,15 @@ async function resolveGitLabDuoWorkflowNumericProjectId(
|
|
|
1521
1561
|
Authorization: `Bearer ${apiKey}`,
|
|
1522
1562
|
"content-type": "application/json",
|
|
1523
1563
|
},
|
|
1564
|
+
signal: gitLabDuoWorkflowRestSignal(signal),
|
|
1524
1565
|
});
|
|
1525
1566
|
if (!response.ok) return undefined;
|
|
1526
1567
|
const payload: unknown = await response.json();
|
|
1527
1568
|
return getRecordString(payload, "id");
|
|
1528
1569
|
} catch {
|
|
1570
|
+
// Timeout / abort behaves like a transient network fault: undefined leaves the
|
|
1571
|
+
// caller to fall back to the workflow's namespace-only routing rather than block
|
|
1572
|
+
// the stream on a hanging project lookup.
|
|
1529
1573
|
return undefined;
|
|
1530
1574
|
}
|
|
1531
1575
|
}
|
|
@@ -1549,6 +1593,7 @@ async function discoverGitLabDuoWorkflowProject(
|
|
|
1549
1593
|
baseUrl: string,
|
|
1550
1594
|
apiKey: string,
|
|
1551
1595
|
restNamespaceId: string,
|
|
1596
|
+
signal?: AbortSignal,
|
|
1552
1597
|
): Promise<GitLabDuoWorkflowDiscoveredProject | undefined> {
|
|
1553
1598
|
const query = "per_page=1&min_access_level=30&order_by=last_activity_at&sort=desc";
|
|
1554
1599
|
const endpoints = [
|
|
@@ -1563,6 +1608,7 @@ async function discoverGitLabDuoWorkflowProject(
|
|
|
1563
1608
|
Authorization: `Bearer ${apiKey}`,
|
|
1564
1609
|
"content-type": "application/json",
|
|
1565
1610
|
},
|
|
1611
|
+
signal: gitLabDuoWorkflowRestSignal(signal),
|
|
1566
1612
|
});
|
|
1567
1613
|
if (!response.ok) continue;
|
|
1568
1614
|
const payload: unknown = await response.json();
|
|
@@ -1570,7 +1616,10 @@ async function discoverGitLabDuoWorkflowProject(
|
|
|
1570
1616
|
const id = getRecordString(first, "id");
|
|
1571
1617
|
const path = getRecordString(first, "path_with_namespace");
|
|
1572
1618
|
if (id && path) return { id, path };
|
|
1573
|
-
} catch {
|
|
1619
|
+
} catch {
|
|
1620
|
+
// Timeout/abort on one endpoint: fall through to the next fallback rather than
|
|
1621
|
+
// aborting discovery. Each endpoint gets its own fresh REST budget.
|
|
1622
|
+
}
|
|
1574
1623
|
}
|
|
1575
1624
|
return undefined;
|
|
1576
1625
|
}
|
|
@@ -1582,7 +1631,12 @@ async function requestGitLabDuoWorkflowDirectAccess(
|
|
|
1582
1631
|
rootNamespaceId: string,
|
|
1583
1632
|
projectId?: string,
|
|
1584
1633
|
workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
|
|
1634
|
+
signal?: AbortSignal,
|
|
1585
1635
|
): Promise<GitLabDuoWorkflowDirectAccessConnection> {
|
|
1636
|
+
// A timeout here throws `AbortError`/`TimeoutError` (per `AbortSignal.timeout`),
|
|
1637
|
+
// which surfaces through the outer `streamGitLabDuoWorkflow` catch as a real
|
|
1638
|
+
// stream `error` event — matching the existing HTTP-error path rather than the
|
|
1639
|
+
// swallowed best-effort helpers (settings ensure / project discovery / models).
|
|
1586
1640
|
const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/direct_access"), {
|
|
1587
1641
|
method: "POST",
|
|
1588
1642
|
headers: {
|
|
@@ -1590,6 +1644,7 @@ async function requestGitLabDuoWorkflowDirectAccess(
|
|
|
1590
1644
|
"content-type": "application/json",
|
|
1591
1645
|
},
|
|
1592
1646
|
body: JSON.stringify(buildGitLabDuoWorkflowDirectAccessBody(rootNamespaceId, projectId, workflowDefinition)),
|
|
1647
|
+
signal: gitLabDuoWorkflowRestSignal(signal),
|
|
1593
1648
|
});
|
|
1594
1649
|
traceGitLabDuoWorkflow("direct_access.response", {
|
|
1595
1650
|
status: response.status,
|
|
@@ -1654,7 +1709,7 @@ async function createGitLabDuoWorkflow(
|
|
|
1654
1709
|
"content-type": "application/json",
|
|
1655
1710
|
},
|
|
1656
1711
|
body: JSON.stringify(body),
|
|
1657
|
-
signal,
|
|
1712
|
+
signal: gitLabDuoWorkflowRestSignal(signal),
|
|
1658
1713
|
});
|
|
1659
1714
|
traceGitLabDuoWorkflow("workflow.create.response", {
|
|
1660
1715
|
status: response.status,
|
|
@@ -1686,14 +1741,30 @@ async function stopGitLabDuoWorkflow(
|
|
|
1686
1741
|
apiKey: string,
|
|
1687
1742
|
workflowId: string,
|
|
1688
1743
|
): Promise<void> {
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1744
|
+
// Stop rides a FRESH timeout signal, deliberately decoupled from `options.signal`
|
|
1745
|
+
// (see the `finally` block in `runGitLabDuoWorkflow`): a run cancelled by the
|
|
1746
|
+
// caller must still fire the server-side stop, but a stalled PATCH here would
|
|
1747
|
+
// otherwise leave the `runGitLabDuoWorkflow` promise unresolved forever — the
|
|
1748
|
+
// bounded budget keeps cleanup best-effort in both directions.
|
|
1749
|
+
try {
|
|
1750
|
+
await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/ai/duo_workflows/workflows/${encodeURIComponent(workflowId)}`), {
|
|
1751
|
+
method: "PATCH",
|
|
1752
|
+
headers: {
|
|
1753
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1754
|
+
"content-type": "application/json",
|
|
1755
|
+
},
|
|
1756
|
+
body: JSON.stringify(buildGitLabDuoWorkflowStopBody()),
|
|
1757
|
+
signal: gitLabDuoWorkflowRestSignal(),
|
|
1758
|
+
});
|
|
1759
|
+
} catch (error) {
|
|
1760
|
+
// Server-side stop is best-effort: a timeout / network fault must not reject
|
|
1761
|
+
// the caller (the local stream already emitted its terminal event). Trace and
|
|
1762
|
+
// swallow so the enclosing `finally` never surfaces a spurious rejection.
|
|
1763
|
+
traceGitLabDuoWorkflow("workflow.stop_error", {
|
|
1764
|
+
workflowId,
|
|
1765
|
+
error: gitLabDuoWorkflowErrorText(error),
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1697
1768
|
}
|
|
1698
1769
|
|
|
1699
1770
|
// Body the group PUT carries to turn on exactly the three flags the inline MCP-only
|
|
@@ -1721,6 +1792,7 @@ async function ensureGitLabDuoWorkflowSettings(
|
|
|
1721
1792
|
baseUrl: string,
|
|
1722
1793
|
apiKey: string,
|
|
1723
1794
|
restNamespaceId: string,
|
|
1795
|
+
signal?: AbortSignal,
|
|
1724
1796
|
): Promise<boolean> {
|
|
1725
1797
|
// Returns whether the attempt was DEFINITIVE (so the caller may stop retrying):
|
|
1726
1798
|
// any HTTP response — 2xx (flags now on) or 4xx (insufficient rights / no such
|
|
@@ -1735,6 +1807,7 @@ async function ensureGitLabDuoWorkflowSettings(
|
|
|
1735
1807
|
"content-type": "application/json",
|
|
1736
1808
|
},
|
|
1737
1809
|
body: JSON.stringify(buildGitLabDuoWorkflowSettingsBody()),
|
|
1810
|
+
signal: gitLabDuoWorkflowRestSignal(signal),
|
|
1738
1811
|
});
|
|
1739
1812
|
traceGitLabDuoWorkflow("settings.ensure", { status: response.status, ok: response.ok });
|
|
1740
1813
|
return response.status < 500;
|