7 Commits
Author SHA1 Message Date
Kashyab Ambarani dc815fc15f chore: prepare pi-devin 0.2.0 release 2026-09-22 15:43:06 -07:00
Kashyab Ambarani 3b40b4020d fix: send system instructions in Devin's dedicated prompt field 2026-09-22 15:38:37 -07:00
Kashyab Ambarani f42b776b8e fix: require Pi 0.86 and test transcript context mapping 2026-09-22 15:05:44 -07:00
Kashyab Ambarani 2f94b72ee1 Merge pull request #5 from dualface/fix/transcript-context-tools
fix: read prompt and tools from the normalized transcript (pi 0.86 breaks all tool calls)
2026-09-22 15:04:45 -07:00
dualfaceandClaude Opus 5 8cf221e13f fix: read prompt and tools from the normalized transcript
pi 0.86.0 hands custom providers a TranscriptContext: `systemPrompt` and
`tools` are gone, and tool declarations live in the transcript's system
messages. Reading `context.tools` therefore sent zero tools to Devin, so
every Devin model answered that it cannot run commands, and the system
prompt was dropped with it.

Collapse the system messages first, then take the prompt and the current
tool set from the replayed transcript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:08:30 +08:00
Kashyab Ambarani e4d4ec3aae Merge pull request #1 from 1am2syman/fix/gpt-5-6-devin-desktop-identity
Fix GPT-5.6 'This model is only in Devin Local': identify as devin-desktop
2026-08-29 22:28:00 -07:00
local-patch 275cd5a493 Fix GPT-5.6 gate: identify as devin-desktop, not windsurf
With ide=windsurf, GetChatMessage rejects every GPT-5.6 variant with
'This model is only in Devin Local.' Switching the client identity to
devin-desktop makes the server serve them; response-header wire echo
confirms the exact model (gpt-5-6-sol-high -> 'GPT-5.6 Sol High Thinking').
Unknown UIDs still hard-error, so there is no silent fallback.
2026-08-29 19:09:06 +00:00
8 changed files with 266 additions and 62 deletions
+1
View File
@@ -4,3 +4,4 @@ package-lock.json
.DS_Store .DS_Store
*.log *.log
.env .env
.reference/
+7 -3
View File
@@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C
## Requirements ## Requirements
- Pi Coding Agent 0.80+ - Pi Coding Agent 0.86+
- A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`) - A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`)
- Node 18+ - Node 22.19+ (required by Pi 0.86)
The CLI binary is resolved in this order: The CLI binary is resolved in this order:
@@ -35,12 +35,16 @@ From git:
pi install git:github.com/kashyab12/pi-devin pi install git:github.com/kashyab12/pi-devin
``` ```
After npm publish: From npm:
```bash ```bash
pi install npm:pi-devin pi install npm:pi-devin
``` ```
Version 0.2.0 requires Pi 0.86+ and Node 22.19+. It restores system instructions and tools on the current Pi transcript format and sends system instructions through Devin's dedicated prompt field. Version 0.1.2 predates Pi 0.86 support.
`pi-devin-local` is a separate npm package maintained in the [mizorewww/pi-devin fork](https://github.com/mizorewww/pi-devin). Changes merged here do not update that package. Install only one: both packages register the `devin` provider, so their registrations can overwrite each other.
Local checkout: Local checkout:
```bash ```bash
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "pi-devin", "name": "pi-devin",
"version": "0.1.2", "version": "0.2.0",
"description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.", "description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.",
"type": "module", "type": "module",
"keywords": [ "keywords": [
@@ -29,17 +29,19 @@
"LICENSE" "LICENSE"
], ],
"scripts": { "scripts": {
"test": "tsx --test test/*.test.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": "*", "@earendil-works/pi-ai": ">=0.86.0",
"@earendil-works/pi-coding-agent": "*" "@earendil-works/pi-coding-agent": ">=0.86.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"tsx": "^4.23.15",
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=22.19.0"
} }
} }
+19 -10
View File
@@ -1,4 +1,12 @@
import type { Context, Message, Tool } from "@earendil-works/pi-ai"; import {
collapseSystemMessages,
getCurrentSystemPrompt,
getCurrentTools,
withoutInitialSystemMessage,
type Message,
type Tool,
type TranscriptContext,
} from "@earendil-works/pi-ai";
export interface ContentPart { export interface ContentPart {
type: "text" | "image"; type: "text" | "image";
@@ -8,7 +16,7 @@ export interface ContentPart {
} }
export interface ChatHistoryItem { export interface ChatHistoryItem {
role: "user" | "assistant" | "system" | "tool"; role: "user" | "assistant" | "tool";
content: string | ContentPart[]; content: string | ContentPart[];
tool_call_id?: string; tool_call_id?: string;
tool_calls?: Array<{ id: string; name: string; arguments: string }>; tool_calls?: Array<{ id: string; name: string; arguments: string }>;
@@ -21,6 +29,8 @@ export interface ToolDef {
} }
export interface MappedChat { export interface MappedChat {
/** Sent in GetChatMessageRequest.prompt, separately from conversation history. */
systemPrompt?: string;
messages: ChatHistoryItem[]; messages: ChatHistoryItem[];
tools: ToolDef[]; tools: ToolDef[];
} }
@@ -39,13 +49,12 @@ function userContent(content: Message["content"]): string | ContentPart[] {
return parts; return parts;
} }
export function mapContextToChat(context: Context): MappedChat { export function mapContextToChat(context: TranscriptContext): MappedChat {
// Fold later system updates into the single prompt sent separately to Devin.
const transcript = collapseSystemMessages(context);
const systemPrompt = getCurrentSystemPrompt(transcript.messages);
const messages: ChatHistoryItem[] = []; const messages: ChatHistoryItem[] = [];
if (context.systemPrompt) { for (const message of withoutInitialSystemMessage(transcript.messages)) {
messages.push({ role: "system", content: context.systemPrompt });
}
for (const message of context.messages) {
if (message.role === "user") { if (message.role === "user") {
messages.push({ role: "user", content: userContent(message.content) }); messages.push({ role: "user", content: userContent(message.content) });
continue; continue;
@@ -86,11 +95,11 @@ export function mapContextToChat(context: Context): MappedChat {
} }
} }
const tools: ToolDef[] = (context.tools ?? []).map((tool: Tool) => ({ const tools: ToolDef[] = getCurrentTools(transcript.messages).map((tool: Tool) => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: tool.parameters, parameters: tool.parameters,
})); }));
return { messages, tools }; return { systemPrompt: systemPrompt || undefined, messages, tools };
} }
+6 -5
View File
@@ -7,10 +7,11 @@ import {
} from "./wire.js"; } from "./wire.js";
/** /**
* Cognition version-gates GetChatMessage against a known Windsurf/Devin Desktop * Cognition gates Devin Local-only models (every GPT-5.6 variant: Sol, Terra,
* release. The Devin CLI build id (`3000.4.25`) is not that string and the * Luna) by client ide name. With ide="windsurf" GetChatMessage rejects them
* server answers: "Your Windsurf version is out of date." * with "This model is only in Devin Local."; with ide="devin-desktop" the
* Prefer the installed Devin.app product.json, then a current desktop release. * server serves them and the response header echoes the exact model
* (verified: "GPT-5.6 Sol High Thinking" for gpt-5-6-sol-high, 2026-08-29).
*/ */
const FALLBACK_WINDSURF_VERSION = "3.6.27"; const FALLBACK_WINDSURF_VERSION = "3.6.27";
const PRODUCT_JSON = const PRODUCT_JSON =
@@ -29,7 +30,7 @@ function desktopWindsurfVersion(): string {
} }
export const CLIENT_VERSION = desktopWindsurfVersion(); export const CLIENT_VERSION = desktopWindsurfVersion();
export const CLIENT_IDE = "windsurf"; export const CLIENT_IDE = "devin-desktop";
export interface MetadataInput { export interface MetadataInput {
apiKey: string; apiKey: string;
+10 -40
View File
@@ -4,9 +4,9 @@ import {
type Api, type Api,
type AssistantMessage, type AssistantMessage,
type AssistantMessageEventStream, type AssistantMessageEventStream,
type Context,
type Model, type Model,
type SimpleStreamOptions, type SimpleStreamOptions,
type TranscriptContext,
calculateCost, calculateCost,
createAssistantMessageEventStream, createAssistantMessageEventStream,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
@@ -23,10 +23,9 @@ import {
iterFields, iterFields,
} from "./wire.js"; } from "./wire.js";
const SOURCE_BY_ROLE: Record<string, number> = { const SOURCE_BY_ROLE: Record<ChatHistoryItem["role"], number> = {
user: 1, user: 1,
assistant: 2, assistant: 2,
system: 1,
tool: 4, tool: 4,
}; };
@@ -50,40 +49,6 @@ function normalizeContent(content: string | ContentPart[]): ContentPart[] {
return content; return content;
} }
function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] {
const out: ChatHistoryItem[] = [];
let pending: string[] = [];
const textOf = (content: string | ContentPart[]) =>
normalizeContent(content)
.filter((part) => part.type === "text" && part.text)
.map((part) => part.text as string)
.join("\n");
for (const message of messages) {
if (message.role === "system") {
const text = textOf(message.content);
if (text) pending.push(text);
continue;
}
if (message.role === "user" && pending.length > 0) {
const userParts = normalizeContent(message.content);
const userText = textOf(userParts);
const images = userParts.filter((part) => part.type === "image");
out.push({
role: "user",
content: [{ type: "text", text: `<system>\n${pending.join("\n\n")}\n</system>\n${userText}` }, ...images],
});
pending = [];
continue;
}
out.push(message);
}
if (pending.length > 0) {
out.push({ role: "user", content: `<system>\n${pending.join("\n\n")}\n</system>` });
}
return out;
}
function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer { function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
return Buffer.concat([ return Buffer.concat([
encodeString(1, img.base64Data ?? ""), encodeString(1, img.base64Data ?? ""),
@@ -144,6 +109,7 @@ function buildGetChatMessageRequest(args: {
apiKey: string; apiKey: string;
userJwt: string; userJwt: string;
modelUid: string; modelUid: string;
systemPrompt?: string;
messages: ChatHistoryItem[]; messages: ChatHistoryItem[];
tools?: ToolDef[]; tools?: ToolDef[];
cascadeId: string; cascadeId: string;
@@ -160,10 +126,10 @@ function buildGetChatMessageRequest(args: {
requestId: args.requestId, requestId: args.requestId,
triggerId: args.triggerId, triggerId: args.triggerId,
}); });
const prompts = collapseSystemIntoUser(args.messages).map((message) => const prompts = args.messages.map((message) =>
encodeMessage( encodeMessage(
3, 3,
encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role] ?? 1, { encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role], {
toolCallId: message.role === "tool" ? message.tool_call_id : undefined, toolCallId: message.role === "tool" ? message.tool_call_id : undefined,
toolCalls: message.role === "assistant" ? message.tool_calls : undefined, toolCalls: message.role === "assistant" ? message.tool_calls : undefined,
}), }),
@@ -171,6 +137,7 @@ function buildGetChatMessageRequest(args: {
); );
return Buffer.concat([ return Buffer.concat([
encodeMessage(1, metadata), encodeMessage(1, metadata),
...(args.systemPrompt ? [encodeString(2, args.systemPrompt)] : []),
...prompts, ...prompts,
encodeVarintField(7, 5), encodeVarintField(7, 5),
encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)), encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)),
@@ -271,6 +238,7 @@ async function* streamChatEvents(args: {
apiKey: string; apiKey: string;
host: string; host: string;
modelUid: string; modelUid: string;
systemPrompt?: string;
messages: ChatHistoryItem[]; messages: ChatHistoryItem[];
tools?: ToolDef[]; tools?: ToolDef[];
maxOutputTokens?: number; maxOutputTokens?: number;
@@ -283,6 +251,7 @@ async function* streamChatEvents(args: {
apiKey: args.apiKey, apiKey: args.apiKey,
userJwt, userJwt,
modelUid: args.modelUid, modelUid: args.modelUid,
systemPrompt: args.systemPrompt,
messages: args.messages, messages: args.messages,
tools: args.tools, tools: args.tools,
cascadeId: ids.cascadeId, cascadeId: ids.cascadeId,
@@ -402,7 +371,7 @@ async function* streamChatEvents(args: {
export function streamDevin( export function streamDevin(
model: Model<Api>, model: Model<Api>,
context: Context, context: TranscriptContext,
options?: SimpleStreamOptions, options?: SimpleStreamOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();
@@ -482,6 +451,7 @@ export function streamDevin(
apiKey, apiKey,
host, host,
modelUid, modelUid,
systemPrompt: mapped.systemPrompt,
messages: mapped.messages, messages: mapped.messages,
tools: mapped.tools.length > 0 ? mapped.tools : undefined, tools: mapped.tools.length > 0 ? mapped.tools : undefined,
maxOutputTokens: options?.maxTokens, maxOutputTokens: options?.maxTokens,
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeContext } from "@earendil-works/pi-ai";
import { mapContextToChat } from "../src/context-map.ts";
const tool = (name) => ({
name,
description: name,
parameters: { type: "object", properties: {} },
});
// Regression for #7: Pi 0.86 moves prompt and tools into system messages.
test("preserves the prompt and tools from a normalized transcript", () => {
const read = tool("read");
const context = normalizeContext({
systemPrompt: "Inspect the repository.",
tools: [read],
messages: [{ role: "user", content: "Read README", timestamp: 1 }],
});
assert.deepEqual(mapContextToChat(context), {
systemPrompt: "Inspect the repository.",
messages: [
{ role: "user", content: "Read README" },
],
tools: [read],
});
});
test("applies later instruction, section, and tool changes", () => {
const bash = tool("bash");
const context = {
messages: [
{ role: "system", content: "Inspect the repository.", sections: { policy: "Old policy", obsolete: "Remove me" }, toolsAdded: [tool("read")], timestamp: 0 },
{ role: "user", content: "Continue", timestamp: 1 },
{ role: "system", content: "Use bash.", sections: { policy: "Do not edit.", obsolete: null }, toolsRemoved: [{ name: "read" }], toolsAdded: [bash], timestamp: 2 },
],
};
assert.deepEqual(mapContextToChat(context), {
systemPrompt: "Inspect the repository.\n\nUse bash.\n\nDo not edit.",
messages: [
{ role: "user", content: "Continue" },
],
tools: [bash],
});
});
test("accepts an empty transcript", () => {
assert.deepEqual(mapContextToChat(normalizeContext({ messages: [] })), {
systemPrompt: undefined,
messages: [],
tools: [],
});
});
+164
View File
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import test from "node:test";
import { gunzipSync } from "node:zlib";
import { normalizeContext } from "@earendil-works/pi-ai";
import { clearCachedUserJwt } from "../src/jwt.ts";
import { streamDevin } from "../src/stream.ts";
import { encodeMessage, encodeString, encodeVarintField, frameConnectStream, iterFields } from "../src/wire.ts";
const model = {
id: "test-model",
name: "Test model",
api: "devin-local",
provider: "devin",
baseUrl: "https://devin.invalid",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262_000,
maxTokens: 1_000,
};
const readTool = {
name: "read",
description: "Read a file",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
};
const user = (content) => ({ role: "user", content, timestamp: 1 });
const fields = (buffer) => [...iterFields(buffer)];
const messages = (request) => request.filter((field) => field.num === 3).map((field) => fields(field.value));
const stringField = (fields, number) => fields.find((field) => field.num === number)?.value.toString("utf8");
function response(body) {
// Connect's end-of-stream frame contains the JSON trailer {}.
return new Response(Buffer.concat([frameConnectStream(body, false), Buffer.from([2, 0, 0, 0, 2, 123, 125])]));
}
function mockDevin(t, replies = [Buffer.concat([encodeString(3, "OK"), encodeVarintField(5, 0)])]) {
const requests = [];
clearCachedUserJwt();
t.after(clearCachedUserJwt);
t.mock.method(globalThis, "fetch", async (url, options) => {
if (url === "https://devin.invalid/exa.auth_pb.AuthService/GetUserJwt") {
return new Response(encodeMessage(1, Buffer.from("eyJtest.jwt")));
}
assert.equal(url, "https://devin.invalid/exa.api_server_pb.ApiServerService/GetChatMessage");
const frame = Buffer.from(options.body);
assert.equal(frame[0], 1, "request uses Connect gzip compression");
assert.equal(frame.readUInt32BE(1), frame.length - 5);
requests.push(fields(gunzipSync(frame.subarray(5))));
const reply = replies[requests.length - 1];
assert.ok(reply, "unexpected extra chat request");
return response(reply);
});
return requests;
}
async function complete(context) {
const stream = streamDevin(model, context, {
apiKey: "synthetic-test-key",
env: { DEVIN_API_SERVER_URL: "https://devin.invalid" },
});
for await (const event of stream) {
assert.notEqual(event.type, "error", event.error?.errorMessage);
}
return stream.result();
}
// #7: verify the normalized transcript survives the complete request-encoding path.
test("encodes the current system prompt once in field 2, with unchanged user text and images", async (t) => {
const requests = mockDevin(t);
const text = " Inspect this: <system>literal user text</system>\nこんにちは ";
const image = { type: "image", mimeType: "image/png", data: "aW1hZ2UtZml4dHVyZQ==" };
const context = normalizeContext({
systemPrompt: "Initial instructions.",
tools: [readTool],
messages: [user([{ type: "text", text }, image])],
});
const tools = Array.from({ length: 90 }, (_, i) => ({ ...readTool, name: `read_${i}` }));
context.messages.push({
role: "system",
content: "Updated instructions.",
sections: { policy: "Do not edit." },
toolsRemoved: [{ name: "read" }],
toolsAdded: tools,
timestamp: 2,
});
await complete(context);
assert.equal(requests.length, 1);
const request = requests[0];
const promptFields = request.filter((field) => field.num === 2);
assert.equal(promptFields.length, 1);
assert.equal(promptFields[0].wire, 2);
assert.equal(promptFields[0].value.toString(), "Initial instructions.\n\nUpdated instructions.\n\nDo not edit.");
const history = messages(request);
assert.equal(history.length, 1);
assert.equal(history[0].find((field) => field.num === 2).value, 1n);
assert.equal(stringField(history[0], 3), text);
const images = history[0].filter((field) => field.num === 10);
assert.equal(images.length, 1);
assert.equal(stringField(fields(images[0].value), 1), image.data);
assert.equal(stringField(fields(images[0].value), 2), image.mimeType);
assert.deepEqual(request.filter((field) => field.num === 10).map((field) => {
const definition = fields(field.value);
return { name: stringField(definition, 1), description: stringField(definition, 2), parameters: JSON.parse(stringField(definition, 3)) };
}), tools);
});
test("omits an empty system prompt while preserving tools and user content", async (t) => {
const requests = mockDevin(t);
await complete(normalizeContext({ systemPrompt: "", tools: [readTool], messages: [user("Read probe.txt")] }));
assert.equal(requests[0].filter((field) => field.num === 2).length, 0);
assert.equal(requests[0].filter((field) => field.num === 10).length, 1);
assert.deepEqual(messages(requests[0]).map((message) => stringField(message, 3)), ["Read probe.txt"]);
});
test("sends a system-only transcript without inventing a user message", async (t) => {
const requests = mockDevin(t);
await complete(normalizeContext({ systemPrompt: "System-only instructions.", messages: [] }));
assert.equal(stringField(requests[0], 2), "System-only instructions.");
assert.deepEqual(messages(requests[0]), []);
});
test("encodes an empty transcript without a prompt or history", async (t) => {
const requests = mockDevin(t);
await complete(normalizeContext({ messages: [] }));
assert.equal(stringField(requests[0], 2), undefined);
assert.deepEqual(messages(requests[0]), []);
});
test("retains the system prompt and replays assistant tool calls and results on the next request", async (t) => {
const callId = "call-read";
const args = { path: "probe.txt" };
const requests = mockDevin(t, [
Buffer.concat([
encodeMessage(6, Buffer.concat([encodeString(1, callId), encodeString(2, "read"), encodeString(3, JSON.stringify(args))])),
encodeVarintField(5, 10),
]),
Buffer.concat([encodeString(3, "file-value"), encodeVarintField(5, 0)]),
]);
const context = normalizeContext({ systemPrompt: "Use tools; never guess.", tools: [readTool], messages: [user("Read probe.txt")] });
const first = await complete(context);
assert.equal(first.stopReason, "toolUse");
assert.deepEqual(first.content, [{ type: "toolCall", id: callId, name: "read", arguments: args }]);
context.messages.push(first, {
role: "toolResult", toolCallId: callId, toolName: "read",
content: [{ type: "text", text: "file-value" }], isError: false, timestamp: 3,
});
const second = await complete(context);
assert.equal(second.stopReason, "stop");
assert.equal(second.content[0].text, "file-value");
assert.equal(requests.length, 2);
for (const request of requests) {
assert.equal(request.filter((field) => field.num === 2).length, 1);
assert.equal(stringField(request, 2), "Use tools; never guess.");
}
const history = messages(requests[1]);
assert.deepEqual(history.map((message) => message.find((field) => field.num === 2).value), [1n, 2n, 4n]);
assert.equal(stringField(history[0], 3), "Read probe.txt");
const call = fields(history[1].find((field) => field.num === 6).value);
assert.equal(stringField(call, 1), callId);
assert.equal(stringField(call, 2), "read");
assert.deepEqual(JSON.parse(stringField(call, 3)), args);
assert.equal(stringField(history[2], 7), callId);
assert.equal(stringField(history[2], 3), "file-value");
});