feat(FN-3328): refactor droid-cli into compatibility shim backed by fusion-

Merged FN-3328: Refactored the droid integration by converting `droid-cli` into a lightweight compatibility shim that delegates to `fusion-plugin-droid-runtime`, reducing droid-cli by ~5,400 lines of code while moving the actual runtime logic into the plugin scaffold. Updated the plugin loader to al

Fusion-Task-Id: FN-3328
This commit is contained in:
Fusion
2026-05-04 17:04:31 -07:00
committed by gsxdsm
parent 6ea1fb01e4
commit 7e1768b1b1
21 changed files with 310 additions and 5700 deletions

View File

@@ -1,629 +1 @@
/**
* Prompt builder for flattening pi conversation history into a labeled text prompt.
*
* Follows the reference project's buildPromptBlocks() pattern:
* - USER: / ASSISTANT: / TOOL RESULT: labels
* - Content blocks serialized by type
* - Images in the final user message are translated to Anthropic API format (HIST-02)
* - Images in non-final messages get placeholder text with console.warn
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { homedir } from "node:os";
/**
* Minimal message shape that prompt-builder accepts.
* Uses a wide `role: string` discriminant so tests can pass plain objects
* without literal type annotations. Content is typed broadly as
* `string | unknown[]` since helper functions narrow at runtime.
*/
export interface PiMessage {
role: string;
content: string | unknown[];
toolName?: string;
}
/**
* Minimal pi-ai Tool shape — the subset we read from `Context.tools` to build
* the deferred-tools system-prompt addendum.
*/
export interface PiToolLike {
name: string;
description?: string;
}
export type PiContext = {
systemPrompt?: string;
messages: PiMessage[];
tools?: ReadonlyArray<PiToolLike>;
};
import {
mapPiToolNameToDroid,
translatePiArgsToDroid,
isCustomToolName,
} from "./tool-mapping.js";
/**
* Anthropic API content block types for image passthrough.
* Used when the final user message contains images that need to be
* translated from pi-ai format to Anthropic format.
*/
type AnthropicContentBlock =
| { type: "text"; text: string }
| {
type: "image";
source: { type: "base64"; media_type: string; data: string };
};
/**
* Flattens a pi conversation context's messages array into a labeled text prompt
* suitable for sending to the Droid CLI subprocess.
*
* Each message is labeled with its role:
* - USER: for user messages
* - ASSISTANT: for assistant messages
* - TOOL RESULT ({toolName}): for tool result messages
*/
/** Module-level counter for placeholder images, reset per buildPrompt call. */
let placeholderImageCount = 0;
/**
* Translate a pi-ai image block to Anthropic API format.
* Returns null if the block is missing required data/mimeType fields.
*
* pi-ai format: { type: "image", data: string (base64), mimeType: string }
* Anthropic format: { type: "image", source: { type: "base64", media_type: string, data: string } }
*/
function translateImageBlock(piBlock: unknown): AnthropicContentBlock | null {
const block = piBlock as Record<string, unknown>;
if (typeof block.data === "string" && typeof block.mimeType === "string") {
return {
type: "image",
source: {
type: "base64",
media_type: block.mimeType,
data: block.data,
},
};
}
return null; // Invalid image block, will fall back to placeholder
}
/**
* Build content blocks for the final user message, translating images
* from pi-ai format to Anthropic API format.
*
* @returns Array of AnthropicContentBlock with text and translated images
*/
function buildFinalUserContent(
content: string | unknown[],
): AnthropicContentBlock[] {
if (typeof content === "string") {
return [{ type: "text", text: content }];
}
if (!Array.isArray(content)) {
return [{ type: "text", text: "" }];
}
const blocks: AnthropicContentBlock[] = [];
for (const rawBlock of content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") {
blocks.push({ type: "text", text: typeof block.text === "string" ? block.text : "" });
} else if (block.type === "image") {
const translated = translateImageBlock(block);
if (translated) {
blocks.push(translated);
} else {
// Invalid image block: fall back to placeholder text
blocks.push({
type: "text",
text: "[An image was shared here but could not be included]",
});
placeholderImageCount++;
}
}
// Unknown block types silently skipped
}
return blocks;
}
/**
* Check if a message content array contains image blocks.
*/
function contentHasImages(content: string | unknown[]): boolean {
if (typeof content === "string" || !Array.isArray(content)) return false;
return content.some((block) => (block as Record<string, unknown>).type === "image");
}
/**
* Check if the conversation ends with a custom tool result.
* If so, build a simplified prompt that presents the result directly
* instead of replaying the full conversation history with tool labels.
*/
function buildCustomToolResultPrompt(messages: PiMessage[]): string | null {
if (messages.length < 3) return null;
const last = messages[messages.length - 1];
if (last.role !== "toolResult") return null;
if (!last.toolName || !isCustomToolName(last.toolName)) return null;
// Find the original user message (scan backwards past assistant + toolResult)
let userMessage: string | null = null;
for (let i = messages.length - 3; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "user") {
userMessage = userContentToText(msg.content);
break;
}
}
if (!userMessage) return null;
const toolResult = toolResultContentToText(last.content);
return `${userMessage}\n\n[The ${last.toolName} tool was called and returned the following result]\n${toolResult}\n\nRespond to the user using the tool result above.`;
}
/**
* Build a prompt for a resumed session.
*
* When resuming via --resume, the CLI already has the full conversation history
* up through (and including) the most recent assistant turn that it produced.
* We only need to send the *delta* since that turn: any trailing tool results
* for the last assistant tool_use, and/or a new user message.
*
* Why anchor on the last assistant message (not the last user message)?
* Pi's tool-use loop appends `[user, assistant(toolUse), toolResult,
* assistant(toolUse), toolResult, ...]` — the only `user` entry stays at index
* 0 across many provider invocations. Anchoring on the last user message and
* walking forward (the prior implementation) re-sent the entire transcript on
* every tool-loop iteration, so each --resume turn appended a duplicate of the
* original query plus a growing stack of tool results to the on-disk session.
*
* Returns "" when there's nothing new to send (e.g. only an assistant message
* exists in the context — can happen mid-shutdown).
*/
export function buildResumePrompt(context: PiContext): string | AnthropicContentBlock[] {
const messages = context.messages;
if (messages.length === 0) return "";
let lastAssistantIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIdx = i;
break;
}
}
const newMessages = messages.slice(lastAssistantIdx + 1);
if (newMessages.length === 0) return "";
const parts: string[] = [];
for (const msg of newMessages) {
if (msg.role === "toolResult") {
if (msg.toolName && isCustomToolName(msg.toolName)) {
parts.push(`TOOL RESULT (${msg.toolName}):`);
} else {
const claudeToolName = msg.toolName
? mapPiToolNameToDroid(msg.toolName)
: "unknown";
parts.push(`TOOL RESULT (${claudeToolName}):`);
}
parts.push(toolResultContentToText(msg.content));
} else if (msg.role === "user") {
if (contentHasImages(msg.content)) {
const textSoFar = parts.join("\n");
const userContent = buildFinalUserContent(msg.content);
const result: AnthropicContentBlock[] = [];
if (textSoFar) {
result.push({ type: "text", text: textSoFar });
}
result.push(...userContent);
return result;
}
parts.push(userContentToText(msg.content));
}
}
return parts.join("\n") || "";
}
export function buildPrompt(context: PiContext): string | AnthropicContentBlock[] {
// Reset placeholder counter for each call
placeholderImageCount = 0;
// Special case: when conversation ends with a custom tool result,
// present it directly instead of complex history replay
const customToolPrompt = buildCustomToolResultPrompt(context.messages);
if (customToolPrompt) {
// customToolPrompt calls userContentToText which may increment placeholderImageCount
if (placeholderImageCount > 0) {
console.warn(
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
);
}
return customToolPrompt;
}
// Determine if any message has images worth passing through
const finalUserIndex = findFinalUserMessageIndex(context.messages);
const finalUserMsg = finalUserIndex >= 0 ? context.messages[finalUserIndex] : undefined;
const finalUserHasImages =
finalUserMsg !== undefined &&
finalUserMsg.role === "user" &&
contentHasImages(finalUserMsg.content);
const anyToolResultHasImages = context.messages.some(
(m) => m.role === "toolResult" && toolResultHasImages(m.content),
);
if (finalUserHasImages || anyToolResultHasImages) {
// Build history as text (all messages except the final user message)
const historyParts: string[] = [];
const toolResultImageBlocks: AnthropicContentBlock[] = [];
for (let i = 0; i < context.messages.length; i++) {
if (i === finalUserIndex) continue; // Skip final user message -- handled separately
const message = context.messages[i];
if (message.role === "user") {
historyParts.push("USER:");
historyParts.push(userContentToText(message.content));
} else if (message.role === "assistant") {
historyParts.push("ASSISTANT:");
historyParts.push(contentToText(message.content));
} else if (message.role === "toolResult") {
if (message.toolName && isCustomToolName(message.toolName)) {
historyParts.push(`TOOL RESULT (${message.toolName}):`);
} else {
const claudeToolName = message.toolName
? mapPiToolNameToDroid(message.toolName)
: "unknown";
historyParts.push(`TOOL RESULT (${claudeToolName}):`);
}
// Extract text portion of tool result
historyParts.push(toolResultContentToText(message.content));
// Collect image blocks from tool results for passthrough
if (Array.isArray(message.content)) {
for (const rawBlock of message.content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "image") {
const translated = translateImageBlock(block);
if (translated) {
toolResultImageBlocks.push(translated);
// Undo the placeholder count from toolResultContentToText since we're passing through
placeholderImageCount--;
}
}
}
}
}
}
// Build final user message content blocks
const finalUserContent =
finalUserMsg?.role === "user"
? buildFinalUserContent(finalUserMsg.content)
: [];
// Combine: history text + tool result images + final user content blocks
const result: AnthropicContentBlock[] = [];
const historyText = historyParts.join("\n");
if (historyText) {
result.push({ type: "text", text: historyText });
}
// Insert tool result images after history text (Claude sees them in context)
result.push(...toolResultImageBlocks);
result.push(...finalUserContent);
if (placeholderImageCount > 0) {
console.warn(
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
);
}
return result;
}
// No images in final user message: standard text-only path
const parts: string[] = [];
for (const message of context.messages) {
if (message.role === "user") {
parts.push("USER:");
parts.push(userContentToText(message.content));
} else if (message.role === "assistant") {
parts.push("ASSISTANT:");
parts.push(contentToText(message.content));
} else if (message.role === "toolResult") {
if (message.toolName && isCustomToolName(message.toolName)) {
// Custom tools: don't reference MCP tool name. Present result plainly.
parts.push(`TOOL RESULT (${message.toolName}):`);
} else {
const claudeToolName = message.toolName
? mapPiToolNameToDroid(message.toolName)
: "unknown";
parts.push(`TOOL RESULT (${claudeToolName}):`);
}
parts.push(toolResultContentToText(message.content));
}
}
if (placeholderImageCount > 0) {
console.warn(
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
);
}
return parts.join("\n") || "";
}
/**
* Find the index of the last user message in the messages array.
* Returns -1 if no user message found.
*/
function findFinalUserMessageIndex(messages: PiMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return i;
}
return -1;
}
/**
* Builds the system prompt from the context's systemPrompt field,
* appending AGENTS.md content if found (walking up from cwd, then global fallback).
* Sanitizes .pi references to .claude for Claude Code compatibility.
*/
export function buildSystemPrompt(
context: PiContext,
cwd: string,
): string {
const parts: string[] = [];
if (context.systemPrompt) {
parts.push(rewriteCustomToolReferences(context.systemPrompt, context.tools));
}
// Look for AGENTS.md
const agentsPath = resolveAgentsMdPath(cwd);
if (agentsPath) {
try {
const content = readFileSync(agentsPath, "utf-8");
const sanitized = sanitizeAgentsContent(content);
parts.push(sanitized);
} catch {
// If we can't read it, skip silently
}
}
// When conversation history has tool results, instruct Claude to use them
// instead of trying to re-call tools (which may not be available).
if (context.messages?.some((m) => m.role === "toolResult")) {
parts.push(
"IMPORTANT: The conversation history below contains tool results from previously executed tools. " +
"Use these results to answer the user's question. Do NOT attempt to re-call tools that already have results.",
);
}
const customToolsAddendum = buildCustomToolsAddendum(context.tools);
if (customToolsAddendum) {
parts.push(customToolsAddendum);
}
return parts.join("\n\n");
}
/** Pi built-in tool names — these go through pi's wrapped built-ins, not MCP. */
const BUILT_IN_PI_TOOLS = new Set([
"read",
"write",
"edit",
"bash",
"grep",
"find",
]);
/**
* Rewrite bare references to custom pi tool names (e.g. `fn_review_spec`,
* `fn_review_spec()`) in the system prompt so they appear as their
* MCP-prefixed names (`mcp__custom-tools__fn_review_spec`). Engine prompts are
* written for direct API tool calls; under droid-cli the same tools are
* reachable only through the MCP shim. Without this rewrite, models like
* Sonnet 4.6 inconsistently translate the names — sometimes calling MCP
* variants, sometimes silently skipping the call (observed in triage where
* `fn_review_spec` was never invoked even though the prompt said "MUST call").
*
* Only rewrites whole-word matches anchored to a non-identifier boundary, so
* substrings inside other identifiers stay intact. Skips already-prefixed
* occurrences (`mcp__custom-tools__fn_review_spec`) and pi built-ins.
*/
function rewriteCustomToolReferences(
prompt: string,
tools: ReadonlyArray<PiToolLike> | undefined,
): string {
if (!prompt || !tools || tools.length === 0) {
return prompt;
}
let result = prompt;
for (const tool of tools) {
if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
// \b doesn't treat `_` as a word boundary the way we want here, so anchor
// the match between either start-of-string/non-identifier-char and either
// end-of-string/non-identifier-char. Also negative-lookbehind for
// `mcp__custom-tools__` so we don't double-prefix.
const escaped = tool.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
"g",
);
result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
}
return result;
}
/**
* Build a system-prompt addendum that maps each custom pi tool to its
* MCP-exposed name (`mcp__custom-tools__<name>`) and tells Claude to call
* those names directly. We intentionally avoid a ToolSearch prerequisite:
* requiring an internal discovery step can send the model into long internal
* tool loops before it emits actionable pi tool calls.
*
* Returns an empty string when there are no custom tools so the addendum
* doesn't pollute prompts on plain chat sessions with only built-ins.
*/
function buildCustomToolsAddendum(
tools: ReadonlyArray<PiToolLike> | undefined,
): string {
if (!tools || tools.length === 0) return "";
const customNames = tools
.map((t) => t.name)
.filter((name) => !BUILT_IN_PI_TOOLS.has(name));
if (customNames.length === 0) return "";
const lines = customNames
.sort()
.map((name) => `- \`${name}\` is exposed as \`mcp__custom-tools__${name}\``);
return [
"## Custom tool naming (MCP)",
"",
"The following pi extension tools are available under MCP-prefixed",
"names. When a system prompt or task instruction asks you to call one",
"of these by its short name, call the MCP-prefixed name directly.",
"",
...lines,
].join("\n");
}
/**
* Converts user message content to text.
* Handles string content and array of content blocks.
* Image blocks are replaced with placeholder text (HIST-02).
* Increments the module-level placeholderImageCount for each image.
*/
function userContentToText(content: string | unknown[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const rawBlock of content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") {
texts.push(typeof block.text === "string" ? block.text : "");
} else if (block.type === "image") {
texts.push("[An image was shared here but could not be included]");
placeholderImageCount++;
}
// Unknown block types silently skipped
}
return texts.join("\n");
}
/**
* Converts assistant message content to text.
* Handles string content and array of content blocks (text, thinking, toolCall).
*/
function contentToText(content: string | unknown[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((rawBlock) => {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") return typeof block.text === "string" ? block.text : "";
if (block.type === "thinking") return ""; // Skip thinking — internal reasoning, not conversation
if (block.type === "toolCall") {
const name = typeof block.name === "string" ? block.name : "";
const rawArgs = block.arguments;
// A toolCall may carry either parsed args (object) or the raw unparsed
// string that pi produced — preserve the raw string verbatim so callers
// can see what the model actually sent.
const argsObject =
rawArgs && typeof rawArgs === "object" ? (rawArgs as Record<string, unknown>) : undefined;
const isCustom = isCustomToolName(name);
if (isCustom) {
// Custom tools: don't reference the MCP tool name — Claude might try to re-call it.
// Just note what was done. The result follows as a TOOL RESULT message.
const argsStr = argsObject
? JSON.stringify(argsObject)
: typeof rawArgs === "string"
? JSON.stringify(rawArgs)
: "{}";
return `[Used ${name} tool with args: ${argsStr}]`;
}
const claudeName = mapPiToolNameToDroid(name);
const claudeArgs = argsObject ? translatePiArgsToDroid(name, argsObject) : undefined;
const argsStr = claudeArgs
? JSON.stringify(claudeArgs)
: typeof rawArgs === "string"
? JSON.stringify(rawArgs)
: "{}";
return `[Prior tool call — already executed; result follows in TOOL RESULT (${claudeName}):] args=${argsStr}`;
}
// Unknown block types are represented as a placeholder
return `[${String(block.type)}]`;
})
.join("\n");
}
/**
* Converts tool result content to text.
* Handles string content and array of content blocks.
* Image blocks get placeholder text (actual image passthrough handled separately).
*/
function toolResultContentToText(content: string | unknown[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const rawBlock of content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") {
texts.push(typeof block.text === "string" ? block.text : "");
} else if (block.type === "image") {
texts.push("[An image was shared here but could not be included]");
placeholderImageCount++;
}
}
return texts.join("\n");
}
/**
* Check if a tool result content array contains image blocks.
*/
function toolResultHasImages(content: string | unknown[]): boolean {
if (typeof content === "string" || !Array.isArray(content)) return false;
return content.some((block) => (block as Record<string, unknown>).type === "image");
}
/**
* Walk up from cwd looking for AGENTS.md, fall back to ~/.pi/agent/AGENTS.md.
*/
function resolveAgentsMdPath(cwd: string): string | undefined {
let current = resolve(cwd);
while (true) {
const candidate = join(current, "AGENTS.md");
if (existsSync(candidate)) return candidate;
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
// Fall back to global path
const globalHome = process.env.HOME || process.env.USERPROFILE || homedir();
const globalPath = join(globalHome, ".pi", "agent", "AGENTS.md");
if (existsSync(globalPath)) return globalPath;
return undefined;
}
/**
* Sanitize .pi references to .claude in AGENTS.md content
* for Claude Code compatibility.
*/
function sanitizeAgentsContent(content: string): string {
let sanitized = content;
// ~/.pi -> ~/.claude
sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
// .pi/ -> .claude/ (at word boundary or after whitespace/quotes)
sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
// Remaining standalone .pi references
sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
return sanitized;
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/prompt-builder.js";