Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
84 changes: 41 additions & 43 deletions src/adapters/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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 @@ -364,45 +364,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 +392,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 +414,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 +623,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 +658,22 @@ export function createDevinAdapter(
onPhysicalSend: incoming.onPhysicalSend,
onRecoveryWithheld: incoming.onRecoveryWithheld,
},
})) {
});
async function* withSignatureFallback() {
let produced = false;
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.
produced ||= event.kind === "text" || event.kind === "tool_call_start" || event.kind === "tool_call_args";
yield event;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
} catch (error) {
if (!unsignedMessages || produced || !(error instanceof CloudChatError && error.code === "invalid_argument")) throw error;
yield* request(unsignedMessages);
}
}
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 +694,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