Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,8 @@
"devin-provider-merge-migration.test.ts": "providers",
"devin-stated-reset-hardening.test.ts": "providers",
"devin-stated-reset-retry.test.ts": "providers",
"devin-anthropic-signature-fallback.test.ts": "providers",
"devin-reasoning-continuation.test.ts": "providers",
"devin-stream-deadline.test.ts": "providers",
"digitalocean-scaleway-provider.test.ts": "providers",
"docs-429-failover-claims.test.ts": "ci-workflows",
Expand Down
137 changes: 93 additions & 44 deletions src/adapters/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx
import { namespacedToolName } from "../types";
import type { IncomingMeta, ProviderAdapter } from "./base";
import { streamChatEventsWithResetRetry, devinStatedResetWaitMs, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct";
import type { ContentPart } from "./devin/cloud-direct/chat";
import type { CloudChatEvent, ContentPart } from "./devin/cloud-direct/chat";
import { getCachedCatalog, type CacheEntry } from "./devin/cloud-direct/catalog";
import { collapseDevinModelUid } from "./devin/live-models";
import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin";
import { isProviderIssuedThinkingSignature } from "../responses/reasoning-envelope";
import { devinAssistantReasoning, encodeDevinSignature, hasAnthropicSignature } from "./devin/reasoning-signature";
import { SendBudgetExhaustedError } from "../lib/upstream-retry";

/**
Expand Down Expand Up @@ -54,6 +54,22 @@ export function mergeDevinUsage(previous: OcxUsage, next: OcxUsage): OcxUsage {
*/
const DEVIN_CLIENT_CLOSED_MESSAGE = "client closed request";

/** Below the bridge's upstream stall deadline, so held reasoning never reads as a stall. */
const HELD_REASONING_HEARTBEAT_MS = 15_000;

type DevinUsageEvent = Extract<CloudChatEvent, { kind: "usage" }>;

/** The retry's cumulative usage plus the refused attempt's final counts. */
function addDevinUsage(event: DevinUsageEvent, prior: DevinUsageEvent): DevinUsageEvent {
const sum = (a?: number, b?: number) => (a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0));
const out: DevinUsageEvent = { ...event };
for (const key of ["promptTokens", "completionTokens", "totalTokens", "cachedInputTokens", "cacheCreationInputTokens", "reasoningTokens"] as const) {
const value = sum(event[key], prior[key]);
if (value !== undefined) out[key] = value;
}
return out;
}

/** Map a cloud-direct failure onto the structured fields the error event carries. */
export function devinErrorClassification(error: unknown): { status?: number; errorType?: string; retryable?: boolean } {
const status = error instanceof CloudChatError ? error.status : undefined;
Expand Down Expand Up @@ -364,45 +380,16 @@ function assistantText(message: OcxAssistantMessage): string {
// Thinking stays out of the replayed TEXT: folding chain-of-thought into
// assistant text sends it back as visible prior output, which the model
// then treats as something it said to the user. It is replayed in its own
// field instead — see assistantThinking below.
// field instead — see devinAssistantReasoning.
.map((part) => (part.type === "text" ? part.text : ""))
.filter(Boolean)
.join("\n");
}

/**
* The assistant turn's own reasoning, for replay in ChatMessagePrompt #11.
*
* This adapter previously asserted that Cognition has no reasoning-replay
* field and dropped the thinking outright, so a reasoning model restarted its
* chain on every turn of a tool loop. The field exists: two independent
* clients of the same service write #11 thinking with #12 signature and #18
* signature_type on the assistant prompt.
*
* Field #12 attests the exact text at #11, and the wire has room for one pair.
* Every block that carries text is replayed, so the chain stays intact; the
* signature rides along only when the text being replayed IS the text it
* attests, which is exactly the single-block case. Several independently signed
* blocks send an unsigned prompt rather than pairing one block's attestation
* with another block's words. A signature-only block attests encrypted thinking
* that is not being replayed at all, so it is not one of these blocks and
* cannot contribute the pair.
*/
function assistantThinking(
message: OcxAssistantMessage,
): { thinking?: string; signature?: string } {
const blocks = message.content.filter(
(part): part is Extract<typeof part, { type: "thinking" }> => part.type === "thinking",
).filter(part => Boolean(part.thinking));
if (blocks.length === 0) return {};
const signature = blocks.length === 1 ? blocks[0]!.signature : undefined;
return {
thinking: blocks.map(part => part.thinking).join("\n"),
...(isProviderIssuedThinkingSignature(signature) ? { signature } : {}),
};
}

export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] {
export function mapOcxMessagesToDevin(
parsed: OcxParsedRequest,
options: { withholdAnthropicSignatures?: boolean } = {},
): ChatHistoryItem[] {
const items: ChatHistoryItem[] = [];
// Cognition is not an OpenAI host, and this adapter does advertise a real
// client tool catalog (proto #10 via `mapOcxToolsToDevin`), so the same
Expand All @@ -421,13 +408,17 @@ export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem
if (system) items.push({ role: "system", content: system });

for (const message of parsed.context.messages) {
const mapped = mapOneMessage(message);
const mapped = mapOneMessage(message, parsed.modelId, options);
if (mapped) items.push(mapped);
}
return items;
}

function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined {
function mapOneMessage(
message: OcxMessage,
modelId: string,
options: { withholdAnthropicSignatures?: boolean },
): ChatHistoryItem | undefined {
if (message.role === "user" || message.role === "developer") {
const content = mapOcxContentToWire(message.content);
// An image with no caption text is a complete user message on its own.
Expand All @@ -439,10 +430,10 @@ function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined {
if (message.role === "assistant") {
const toolCalls = assistantToolCalls(message);
const text = assistantText(message);
const reasoning = assistantThinking(message);
const reasoning = devinAssistantReasoning(message, modelId, options.withholdAnthropicSignatures === true);
// A turn that produced only reasoning is still worth replaying: dropping it
// is what makes the next turn re-derive the same chain.
if (!text && toolCalls.length === 0 && !reasoning.thinking) return undefined;
if (!text && toolCalls.length === 0 && !reasoning.thinking && !reasoning.signature) return undefined;
return {
role: "assistant",
content: text || "",
Expand Down Expand Up @@ -648,12 +639,20 @@ export function createDevinAdapter(
// An admitted HTTP turn owns globally shared capacity until this call
// emits. Without an explicit wait allowance, preserve the typed reset
// delay in generated diagnostic wording and return immediately.
for await (const event of streamChatEventsWithResetRetry({
const signedMessages = mapOcxMessagesToDevin(parsed);
// A Claude signature is replayed because it is what carries the reasoning into this
// turn, but Cognition streams Claude's thinking as a summary the signature does not
// cover, and some replays are refused with invalid_argument before any output. That
// refusal is retried once with the Anthropic signatures withheld and the text kept.
const unsignedMessages = hasAnthropicSignature(signedMessages, parsed.modelId)
? mapOcxMessagesToDevin(parsed, { withholdAnthropicSignatures: true })
: undefined;
const request = (messages: ChatHistoryItem[]) => streamChatEventsWithResetRetry({
apiKey,
apiServerUrl: host,
modelUid,
catalog,
messages: mapOcxMessagesToDevin(parsed),
messages,
tools: mapOcxToolsToDevin(parsed.context.tools),
cascadeId,
// Input and output ceilings are separate wire fields. Omitting the
Expand All @@ -675,7 +674,57 @@ export function createDevinAdapter(
onPhysicalSend: incoming.onPhysicalSend,
onRecoveryWithheld: incoming.onRecoveryWithheld,
},
})) {
});
async function* withSignatureFallback() {
if (!unsignedMessages) {
yield* request(signedMessages);
return;
}
// Events from the signed attempt are held until its outcome is known: a refusal
// after reasoning would otherwise leave the client with the refused attempt's
// reasoning and signature, and the next turn would replay that signature against
// the retry's thinking.
const held: CloudChatEvent[] = [];
// Usage is still real: the refused attempt was processed, so its final counts are
// added to every usage frame of the retry (frames are cumulative per request).
let refusedUsage: Extract<CloudChatEvent, { kind: "usage" }> | undefined;
let visible = false;
let lastHeartbeat = Date.now();
try {
for await (const event of request(signedMessages)) {
// Only visible output makes a retry unsafe. Live, the refusal often lands after the
// model has streamed its reasoning, its signature and a finish frame, and nothing else.
if (!visible && (event.kind === "text" || event.kind === "tool_call_start" || event.kind === "tool_call_args")) {
visible = true;
yield* held.splice(0);
}
if (visible) {
yield event;
continue;
}
held.push(event);
if (event.kind === "usage") refusedUsage = event;
// Held reasoning must not look like a stalled upstream to the bridge.
if (Date.now() - lastHeartbeat >= HELD_REASONING_HEARTBEAT_MS) {
lastHeartbeat = Date.now();
emit({ type: "heartbeat" });
}
}
} catch (error) {
if (visible || !(error instanceof CloudChatError && error.code === "invalid_argument")) {
yield* held.splice(0);
throw error;
}
// Emitted first so the counts survive a retry that reports no usage or fails early.
if (refusedUsage) yield refusedUsage;
for await (const event of request(unsignedMessages)) {
yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
yield* held.splice(0);
}
for await (const event of withSignatureFallback()) {
if (incoming.abortSignal?.aborted) {
// Emitting nothing here left the bridge to synthesize adapter_eof.
// Say what happened instead, the way the other runTurn-only adapter
Expand All @@ -696,7 +745,7 @@ export function createDevinAdapter(
if (event.kind === "reasoning_signature") {
// Carried back out so the next turn can replay it in the prompt's
// signature field; an unsigned replay is what the service ignores.
emit({ type: "thinking_signature", signature: event.signature });
emit({ type: "thinking_signature", signature: encodeDevinSignature(event.signature, event.signatureType) });
continue;
}
if (event.kind === "tool_call_start") {
Expand Down
9 changes: 7 additions & 2 deletions src/adapters/devin/cloud-direct/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export type CloudChatEvent =
* turn produced. Without decoding it there is nothing to put in the prompt's
* #12 on the next turn, so the replay would always be unsigned.
*/
| { kind: 'reasoning_signature'; signature: string }
| { kind: 'reasoning_signature'; signature: string; signatureType?: string }
| { kind: 'tool_call_start'; id: string; name: string }
| {
kind: 'tool_call_args';
Expand Down Expand Up @@ -792,6 +792,10 @@ export function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
}
}
if (authoritativeUsage) yield authoritativeUsage;
let signatureType: string | undefined;
for (const f of iterFields(proto)) {
if (f.num === 21 && f.wire === 2 && Buffer.isBuffer(f.value)) signatureType = (f.value as Buffer).toString('utf8') || undefined;
}
for (const f of iterFields(proto)) {
if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) {
// Visible delta_text — what the user should SEE in the chat.
Expand All @@ -817,7 +821,8 @@ export function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
if (s) yield { kind: 'reasoning', text: s };
} else if (f.num === 10 && f.wire === 2 && Buffer.isBuffer(f.value)) {
const s = (f.value as Buffer).toString('utf8');
if (s) yield { kind: 'reasoning_signature', signature: s };
// #21 delta_signature_type arrives in the same frame; the prompt replays it as #18.
if (s) yield { kind: 'reasoning_signature', signature: s, ...(signatureType ? { signatureType } : {}) };
} else if (f.num === 6 && f.wire === 2 && Buffer.isBuffer(f.value)) {
let id: string | undefined;
let name: string | undefined;
Expand Down
94 changes: 94 additions & 0 deletions src/adapters/devin/reasoning-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Devin reasoning signatures across turns.
*
* GetChatMessage returns the turn's reasoning attestation as `delta_signature`
* (#10) together with `delta_signature_type` (#21) in the same frame, and the
* native client replays both on the assistant prompt as #12 and #18. Measured
* live on swe-2-high the pair arrives AFTER the visible answer (reasoning, text,
* then signature), so the Responses layer stores it as its own signature-only
* reasoning item behind the thinking-text item. GPT and Gemini rows stream no
* thinking text at all, only the signature.
*
* The type is carried inside the stored signature because the reasoning
* envelope that round-trips through the client keeps a single signature string.
* A signature stored before this prefix existed replays without a type.
*/
import type { OcxAssistantMessage } from "../../types";
import { isProviderIssuedThinkingSignature } from "../../responses/reasoning-envelope";

const TYPED_SIGNATURE_PREFIX = "devin-sig1:";

export function encodeDevinSignature(signature: string, signatureType: string | undefined): string {
return signatureType && !signatureType.includes(":")
? `${TYPED_SIGNATURE_PREFIX}${signatureType}:${signature}`
: signature;
}

export function decodeDevinSignature(stored: string): { signature: string; signatureType?: string } {
if (!stored.startsWith(TYPED_SIGNATURE_PREFIX)) return { signature: stored };
const rest = stored.slice(TYPED_SIGNATURE_PREFIX.length);
const colon = rest.indexOf(":");
if (colon <= 0) return { signature: stored };
return { signature: rest.slice(colon + 1), signatureType: rest.slice(0, colon) };
}

/**
* The assistant turn's reasoning for ChatMessagePrompt #11/#12/#18.
*
* All of the turn's thinking text is replayed. A signature rides along only
* when it covers exactly that text:
* - one thinking block carrying its own issued signature (a stray
* signature-only block does not displace it);
* - at most one unsigned thinking block plus exactly one signature-only block,
* which is how a single Devin turn arrives once its late #10 frame has been
* split into its own reasoning item. With no thinking block at all this is a
* GPT or Gemini row, where the signature is the only reasoning there is.
* Any other mix (two signed blocks, a signed block beside unsigned text) has no
* single attestation for the joined text, so the turn is replayed unsigned.
*
* `withholdAnthropic` drops an Anthropic signature and keeps the text: the
* fallback for a Claude turn Cognition refused (see hasAnthropicSignature).
*/
export function devinAssistantReasoning(
message: OcxAssistantMessage,
modelId = "",
withholdAnthropic = false,
): { thinking?: string; signature?: string; signature_type?: string } {
const blocks = message.content.filter(
(part): part is Extract<typeof part, { type: "thinking" }> => part.type === "thinking",
);
const textBlocks = blocks.filter(part => Boolean(part.thinking));
const signatureOnly = blocks.filter(part => !part.thinking && isProviderIssuedThinkingSignature(part.signature));
const text = textBlocks.map(part => part.thinking).join("\n");
let stored: string | undefined;
if (textBlocks.length === 1 && isProviderIssuedThinkingSignature(textBlocks[0]!.signature)) {
stored = textBlocks[0]!.signature;
} else if (textBlocks.length <= 1 && signatureOnly.length === 1) {
stored = signatureOnly[0]!.signature;
}
let decoded = stored ? decodeDevinSignature(stored) : undefined;
if (decoded && withholdAnthropic && signatureTypeFor(decoded, modelId) === "anthropic") decoded = undefined;
return {
...(text ? { thinking: text } : {}),
...(decoded ? { signature: decoded.signature } : {}),
...(decoded?.signatureType ? { signature_type: decoded.signatureType } : {}),
};
}

/** A stored signature from before its type was recorded falls back to the model being called. */
function signatureTypeFor(decoded: { signatureType?: string }, modelId: string): string | undefined {
return decoded.signatureType ?? (/claude/i.test(modelId) ? "anthropic" : undefined);
}

/**
* True when the mapped history replays a Claude signature. Cognition streams
* Claude's thinking as a summary while the signature covers the original, so
* the pair can fail validation: live on claude-opus-5-5 a signed replay of a
* visible-thinking turn was refused with `invalid_argument` in 5 of 6 tries and
* a text-only one in none, while a signed replay that is accepted is what lets
* the model recall its earlier reasoning. The adapter therefore sends the
* signature and retries a refusal once without it.
*/
export function hasAnthropicSignature(items: ReadonlyArray<{ signature?: string; signature_type?: string }>, modelId: string): boolean {
return items.some(item => Boolean(item.signature) && signatureTypeFor({ signatureType: item.signature_type }, modelId) === "anthropic");
}
Loading
Loading