feat(FN-3332): add droid runtime plugin with event bridge and process manag
The merge delivers a new `fusion-plugin-droid-runtime` plugin providing a full MCP server and runtime adapter for droid-based agents, with event bridging, process management, tool mapping, and a prompt builder. It also adds agent delegation and org hierarchy tools to the pi extension, while fixing a Fusion-Task-Id: FN-3332
This commit is contained in:
5
plugins/fusion-plugin-droid-runtime/CHANGELOG.md
Normal file
5
plugins/fusion-plugin-droid-runtime/CHANGELOG.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial Droid runtime plugin package.
|
||||
9
plugins/fusion-plugin-droid-runtime/README.md
Normal file
9
plugins/fusion-plugin-droid-runtime/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Droid Runtime Plugin
|
||||
|
||||
Provides the Droid CLI runtime/provider integration for Fusion.
|
||||
|
||||
- Runtime ID: `droid`
|
||||
- Provider ID: `droid-cli`
|
||||
- Exports probe helpers and runtime adapter for dashboard + engine integration.
|
||||
|
||||
`@fusion/droid-cli` now acts as a compatibility shim and delegates to this plugin-owned implementation.
|
||||
13
plugins/fusion-plugin-droid-runtime/manifest.json
Normal file
13
plugins/fusion-plugin-droid-runtime/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "fusion-plugin-droid-runtime",
|
||||
"name": "Droid Runtime Plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Droid runtime plugin for Fusion",
|
||||
"author": "Fusion Team",
|
||||
"runtime": {
|
||||
"runtimeId": "droid",
|
||||
"name": "Droid Runtime",
|
||||
"description": "Drives the Droid CLI for Fusion agents",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
34
plugins/fusion-plugin-droid-runtime/package.json
Normal file
34
plugins/fusion-plugin-droid-runtime/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/droid-runtime",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Droid runtime plugin for Fusion",
|
||||
"keywords": ["fusion-plugin", "droid", "runtime"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./probe": {
|
||||
"types": "./src/probe.ts",
|
||||
"import": "./dist/probe.js"
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-ai": "*",
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validatePluginManifest } from "@fusion/plugin-sdk";
|
||||
import plugin, { droidRuntimeMetadata, DROID_RUNTIME_ID } from "../index.js";
|
||||
|
||||
describe("droid runtime plugin index", () => {
|
||||
it("exports expected manifest/runtime metadata", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-droid-runtime");
|
||||
expect(droidRuntimeMetadata.runtimeId).toBe("droid");
|
||||
expect(DROID_RUNTIME_ID).toBe("droid");
|
||||
});
|
||||
|
||||
it("registers required ui slots", () => {
|
||||
const slots = plugin.uiSlots?.map((s) => s.slotId) ?? [];
|
||||
expect(slots).toEqual(expect.arrayContaining([
|
||||
"settings-provider-card",
|
||||
"onboarding-provider-card",
|
||||
"onboarding-setup-help",
|
||||
"post-onboarding-recommendation",
|
||||
]));
|
||||
});
|
||||
|
||||
it("has a valid manifest", () => {
|
||||
expect(() => validatePluginManifest(plugin.manifest)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(() => {
|
||||
const proc = new EventEmitter() as any;
|
||||
proc.stdout = new PassThrough();
|
||||
proc.stderr = new PassThrough();
|
||||
queueMicrotask(() => proc.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })));
|
||||
return proc;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { probeDroidBinary } from "../probe.js";
|
||||
|
||||
describe("probeDroidBinary", () => {
|
||||
it("returns unavailable when binary is missing", async () => {
|
||||
const result = await probeDroidBinary({ timeoutMs: 10 });
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as processManager from "../process-manager.js";
|
||||
|
||||
describe("provider dependencies", () => {
|
||||
it("deduplicates discovered model ids", async () => {
|
||||
vi.spyOn(processManager, "discoverDroidModels").mockResolvedValue(["a", "b", "a"]);
|
||||
const ids = Array.from(new Set(await processManager.discoverDroidModels()));
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DroidRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
describe("DroidRuntimeAdapter", () => {
|
||||
it("creates session and describes model", async () => {
|
||||
const adapter = new DroidRuntimeAdapter({ droidModel: "droid-pro" });
|
||||
const result = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "sys", onText: vi.fn() });
|
||||
expect(result.session).toBeDefined();
|
||||
expect(adapter.describeModel(result.session)).toContain("droid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
if (process.platform === "win32") {
|
||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (match) {
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
}
|
||||
13
plugins/fusion-plugin-droid-runtime/src/cli-spawn.ts
Normal file
13
plugins/fusion-plugin-droid-runtime/src/cli-spawn.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface DroidCliSettings {
|
||||
binaryPath: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function resolveCliSettings(settings?: Record<string, unknown>): DroidCliSettings {
|
||||
const binaryPath =
|
||||
typeof settings?.droidBinaryPath === "string" && settings.droidBinaryPath.trim().length > 0
|
||||
? settings.droidBinaryPath.trim()
|
||||
: "droid";
|
||||
const model = typeof settings?.droidModel === "string" ? settings.droidModel : undefined;
|
||||
return { binaryPath, model };
|
||||
}
|
||||
82
plugins/fusion-plugin-droid-runtime/src/control-handler.ts
Normal file
82
plugins/fusion-plugin-droid-runtime/src/control-handler.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Control protocol handler for Droid CLI stream-json communication.
|
||||
*
|
||||
* Processes control_request messages from Droid CLI stdout and returns a
|
||||
* control_response decision object.
|
||||
*
|
||||
* - Custom MCP tools (mcp__custom-tools__*): DENIED — pi executes these
|
||||
* - Everything else (user MCP tools, internal tools): ALLOWED — Claude handles
|
||||
*/
|
||||
|
||||
import type { ClaudeControlRequest } from "./types.js";
|
||||
import { CUSTOM_TOOLS_MCP_PREFIX } from "./tool-mapping.js";
|
||||
|
||||
export const TOOL_EXECUTION_DENIED_MESSAGE =
|
||||
"Tool execution is unavailable in this environment.";
|
||||
|
||||
/** Prefix for MCP (Model Context Protocol) tool names. */
|
||||
export const MCP_PREFIX = "mcp__";
|
||||
|
||||
interface ControlResponse {
|
||||
type: "control_response";
|
||||
request_id: string;
|
||||
response: {
|
||||
subtype: "success";
|
||||
response: {
|
||||
behavior: "allow" | "deny";
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a control_request from the Droid CLI.
|
||||
*
|
||||
* Denies custom MCP tools (mcp__custom-tools__*) so pi can execute them.
|
||||
* Allows everything else (user MCP tools, internal Claude tools).
|
||||
*
|
||||
* Pure function: no side effects and no stdin writes.
|
||||
*
|
||||
* @returns Decision payload with allow/deny result and serialized response object
|
||||
*/
|
||||
export function handleControlRequest(
|
||||
msg: ClaudeControlRequest,
|
||||
): { allowed: boolean; response: ControlResponse } {
|
||||
if (!msg.request_id || !msg.request) {
|
||||
console.error(
|
||||
"[droid-cli] Malformed control_request: missing request_id or request object",
|
||||
msg,
|
||||
);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
response: {
|
||||
type: "control_response",
|
||||
request_id: msg.request_id ?? "",
|
||||
response: {
|
||||
subtype: "success",
|
||||
response: {
|
||||
behavior: "deny",
|
||||
message: TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const toolName = msg.request?.tool_name ?? "";
|
||||
const isCustomTool = toolName.startsWith(CUSTOM_TOOLS_MCP_PREFIX);
|
||||
|
||||
const response: ControlResponse = {
|
||||
type: "control_response",
|
||||
request_id: msg.request_id,
|
||||
response: {
|
||||
subtype: "success",
|
||||
response: isCustomTool
|
||||
? { behavior: "deny", message: TOOL_EXECUTION_DENIED_MESSAGE }
|
||||
: { behavior: "allow" },
|
||||
},
|
||||
};
|
||||
|
||||
return { allowed: !isCustomTool, response };
|
||||
}
|
||||
397
plugins/fusion-plugin-droid-runtime/src/event-bridge.ts
Normal file
397
plugins/fusion-plugin-droid-runtime/src/event-bridge.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import type { ClaudeApiEvent, TrackedContentBlock } from "./types.js";
|
||||
import { calculateCost } from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
Model,
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
mapDroidToolNameToPi,
|
||||
translateDroidArgsToPi,
|
||||
isPiKnownDroidTool,
|
||||
} from "./tool-mapping.js";
|
||||
|
||||
/**
|
||||
* Extended tracking for tool_use content blocks during streaming.
|
||||
* Stores the Claude tool name for argument translation at block_stop.
|
||||
*/
|
||||
interface TrackedToolBlock {
|
||||
type: "tool_use";
|
||||
index: number;
|
||||
id: string;
|
||||
name: string; // Already mapped to pi name
|
||||
claudeName: string; // Original Claude name for arg translation
|
||||
arguments: Record<string, unknown>;
|
||||
partialJson: string;
|
||||
}
|
||||
|
||||
/** Union of tracked block types for the blocks array. */
|
||||
type TrackedBlock = TrackedContentBlock | TrackedToolBlock;
|
||||
|
||||
/**
|
||||
* The event bridge interface returned by createEventBridge.
|
||||
* handleEvent processes each Claude API streaming event and pushes
|
||||
* the appropriate pi events to the stream.
|
||||
* getOutput returns the accumulated AssistantMessage.
|
||||
*/
|
||||
export interface EventBridge {
|
||||
handleEvent(event: ClaudeApiEvent): void;
|
||||
getOutput(): AssistantMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Claude API stop reasons to pi's stop reason format.
|
||||
*/
|
||||
function mapStopReason(
|
||||
reason: string | undefined,
|
||||
): "stop" | "length" | "toolUse" {
|
||||
switch (reason) {
|
||||
case "tool_use":
|
||||
return "toolUse";
|
||||
case "max_tokens":
|
||||
return "length";
|
||||
case "end_turn":
|
||||
default:
|
||||
return "stop";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event bridge that translates Claude API streaming events
|
||||
* into pi's AssistantMessageEventStream events.
|
||||
*
|
||||
* The bridge maintains internal state to track content blocks and
|
||||
* accumulate the final AssistantMessage. It handles:
|
||||
* - text content blocks (start/delta/stop -> text_start/text_delta/text_end)
|
||||
* - message lifecycle (message_start for usage, message_delta for stop reason, message_stop for done)
|
||||
* - unsupported block types (tool_use, thinking) with warnings
|
||||
*/
|
||||
export function createEventBridge(
|
||||
stream: AssistantMessageEventStream,
|
||||
model: Model<Api>,
|
||||
): EventBridge {
|
||||
// Tracked content blocks indexed by Claude's content_block index
|
||||
const blocks: TrackedBlock[] = [];
|
||||
|
||||
// The accumulated output message
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant" as const,
|
||||
content: [] as (TextContent | ThinkingContent | ToolCall)[],
|
||||
api: "droid-cli",
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
let started = false;
|
||||
|
||||
function handleEvent(event: ClaudeApiEvent): void {
|
||||
// Emit start event on first message — tells pi to begin incremental rendering
|
||||
if (!started) {
|
||||
stream.push({ type: "start", partial: output });
|
||||
started = true;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
handleMessageStart(event);
|
||||
break;
|
||||
case "content_block_start":
|
||||
handleContentBlockStart(event);
|
||||
break;
|
||||
case "content_block_delta":
|
||||
handleContentBlockDelta(event);
|
||||
break;
|
||||
case "content_block_stop":
|
||||
handleContentBlockStop(event);
|
||||
break;
|
||||
case "message_delta":
|
||||
handleMessageDelta(event);
|
||||
break;
|
||||
case "message_stop":
|
||||
handleMessageStop();
|
||||
break;
|
||||
// Unknown event types are silently ignored
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageStart(event: ClaudeApiEvent): void {
|
||||
const usage = event.message?.usage;
|
||||
if (usage) {
|
||||
output.usage.input = usage.input_tokens ?? 0;
|
||||
output.usage.output = usage.output_tokens ?? 0;
|
||||
output.usage.cacheRead = usage.cache_read_input_tokens ?? 0;
|
||||
output.usage.cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input +
|
||||
output.usage.output +
|
||||
output.usage.cacheRead +
|
||||
output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentBlockStart(event: ClaudeApiEvent): void {
|
||||
const blockType = event.content_block?.type;
|
||||
|
||||
if (blockType === "text") {
|
||||
const block: TrackedContentBlock = {
|
||||
type: "text",
|
||||
text: "",
|
||||
index: event.index ?? 0,
|
||||
};
|
||||
blocks.push(block);
|
||||
output.content.push({ type: "text" as const, text: "" });
|
||||
|
||||
stream.push({
|
||||
type: "text_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
partial: output,
|
||||
});
|
||||
} else if (blockType === "thinking") {
|
||||
const block: TrackedContentBlock = {
|
||||
type: "thinking",
|
||||
text: "",
|
||||
index: event.index ?? 0,
|
||||
};
|
||||
blocks.push(block);
|
||||
output.content.push({
|
||||
type: "thinking" as const,
|
||||
thinking: "",
|
||||
thinkingSignature: "",
|
||||
});
|
||||
|
||||
stream.push({
|
||||
type: "thinking_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
partial: output,
|
||||
});
|
||||
} else if (blockType === "tool_use") {
|
||||
const claudeName = event.content_block!.name!;
|
||||
|
||||
// Skip internal Claude Code tools (ToolSearch, Task, Agent, etc.)
|
||||
// that pi cannot execute — only emit pi-known tools
|
||||
if (!isPiKnownDroidTool(claudeName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const piName = mapDroidToolNameToPi(claudeName);
|
||||
const id = event.content_block!.id!;
|
||||
|
||||
const block: TrackedToolBlock = {
|
||||
type: "tool_use",
|
||||
index: event.index ?? 0,
|
||||
id,
|
||||
name: piName,
|
||||
claudeName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
};
|
||||
blocks.push(block);
|
||||
output.content.push({
|
||||
type: "toolCall" as const,
|
||||
id,
|
||||
name: piName,
|
||||
arguments: {},
|
||||
} as ToolCall);
|
||||
|
||||
stream.push({
|
||||
type: "toolcall_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
// Unknown block types silently ignored
|
||||
}
|
||||
|
||||
function handleContentBlockDelta(event: ClaudeApiEvent): void {
|
||||
const deltaType = event.delta?.type;
|
||||
|
||||
if (deltaType === "text_delta" && event.delta!.text != null) {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "text") {
|
||||
block.text += event.delta!.text;
|
||||
const contentBlock = output.content[idx] as TextContent;
|
||||
contentBlock.text = block.text;
|
||||
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.text,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
deltaType === "thinking_delta" &&
|
||||
event.delta!.thinking != null
|
||||
) {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "thinking") {
|
||||
block.text += event.delta!.thinking;
|
||||
const contentBlock = output.content[idx] as ThinkingContent;
|
||||
contentBlock.thinking = block.text;
|
||||
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
deltaType === "input_json_delta" &&
|
||||
event.delta!.partial_json != null
|
||||
) {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "tool_use") {
|
||||
block.partialJson += event.delta!.partial_json;
|
||||
|
||||
// Try to parse accumulated JSON -- on success update args, on failure keep previous
|
||||
try {
|
||||
block.arguments = JSON.parse(block.partialJson);
|
||||
(output.content[idx] as ToolCall).arguments = block.arguments as Record<string, unknown>;
|
||||
} catch {
|
||||
// Partial JSON not yet parseable -- keep previous arguments
|
||||
}
|
||||
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.partial_json,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
deltaType === "signature_delta" &&
|
||||
event.delta!.signature != null
|
||||
) {
|
||||
// Accumulate signature on the thinking block
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "thinking") {
|
||||
const contentBlock = output.content[idx] as ThinkingContent;
|
||||
contentBlock.thinkingSignature =
|
||||
(contentBlock.thinkingSignature || "") + event.delta!.signature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentBlockStop(event: ClaudeApiEvent): void {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
// Clean up the tracking index from the block (no longer needed)
|
||||
delete (block as unknown as Record<string, unknown>).index;
|
||||
|
||||
if (block.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: idx,
|
||||
content: block.text,
|
||||
partial: output,
|
||||
});
|
||||
} else if (block.type === "thinking") {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: idx,
|
||||
content: block.text,
|
||||
partial: output,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
// Final JSON parse with fallback to raw string.
|
||||
// Special case: parameterless MCP tools (e.g. fn_review_spec, schema
|
||||
// `{type:"object", properties:{}}`) emit ZERO input_json_delta events,
|
||||
// so `partialJson` stays "". Without this guard we'd JSON.parse("")
|
||||
// → throw → fall through to `finalArgs = ""` (raw string), and pi's
|
||||
// TypeBox validator then rejects with "root: must be object" because
|
||||
// an empty string is not an object. Default to `{}` so the call lands.
|
||||
let finalArgs: Record<string, unknown> | string;
|
||||
const trimmedJson = block.partialJson.trim();
|
||||
if (trimmedJson === "") {
|
||||
finalArgs = {};
|
||||
} else {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedJson);
|
||||
finalArgs = translateDroidArgsToPi(block.claudeName, parsed);
|
||||
} catch {
|
||||
finalArgs = block.partialJson;
|
||||
}
|
||||
}
|
||||
|
||||
// Update output.content with final arguments
|
||||
const contentBlock = output.content[idx] as ToolCall;
|
||||
// ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
|
||||
// intentionally emit a raw string when JSON parse fails completely.
|
||||
// Pi handles string arguments gracefully at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- finalArgs may be a raw string when JSON parse fails; pi-ai handles it at runtime
|
||||
(contentBlock as any).arguments = finalArgs;
|
||||
const toolCall = {
|
||||
type: "toolCall" as const,
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: finalArgs,
|
||||
} as ToolCall;
|
||||
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: idx,
|
||||
toolCall,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageDelta(event: ClaudeApiEvent): void {
|
||||
if (event.delta?.stop_reason) {
|
||||
output.stopReason = mapStopReason(event.delta.stop_reason);
|
||||
}
|
||||
|
||||
const usage = event.usage;
|
||||
if (usage) {
|
||||
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
|
||||
if (usage.output_tokens != null)
|
||||
output.usage.output = usage.output_tokens;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input +
|
||||
output.usage.output +
|
||||
output.usage.cacheRead +
|
||||
output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageStop(): void {
|
||||
// No-op: done event is pushed by the provider after readline closes.
|
||||
// Pushing done here (synchronously) prevents pi from executing tools.
|
||||
}
|
||||
|
||||
return {
|
||||
handleEvent,
|
||||
getOutput: () => output,
|
||||
};
|
||||
}
|
||||
58
plugins/fusion-plugin-droid-runtime/src/index.ts
Normal file
58
plugins/fusion-plugin-droid-runtime/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
|
||||
import { resolveCliSettings } from "./cli-spawn.js";
|
||||
import { DroidRuntimeAdapter } from "./runtime-adapter.js";
|
||||
|
||||
export const DROID_RUNTIME_ID = "droid";
|
||||
const DROID_RUNTIME_VERSION = "0.1.0";
|
||||
|
||||
export const droidRuntimeMetadata: PluginRuntimeManifestMetadata = {
|
||||
runtimeId: DROID_RUNTIME_ID,
|
||||
name: "Droid Runtime",
|
||||
description: "Drives the Droid CLI for Fusion agents",
|
||||
version: DROID_RUNTIME_VERSION,
|
||||
};
|
||||
|
||||
export const droidRuntimeFactory: PluginRuntimeFactory = async (ctx) =>
|
||||
new DroidRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-droid-runtime",
|
||||
name: "Droid Runtime Plugin",
|
||||
version: DROID_RUNTIME_VERSION,
|
||||
description: "Drives the Droid CLI for Fusion agents",
|
||||
runtime: droidRuntimeMetadata,
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
const settings = resolveCliSettings(ctx.settings as Record<string, unknown>);
|
||||
ctx.logger.info(`Droid Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`);
|
||||
},
|
||||
},
|
||||
uiSlots: [
|
||||
{ slotId: "settings-provider-card", label: "Droid CLI Provider", componentPath: "./components/settings-provider-card.js" },
|
||||
{ slotId: "onboarding-provider-card", label: "Droid CLI Provider", componentPath: "./components/onboarding-provider-card.js" },
|
||||
{ slotId: "onboarding-setup-help", label: "Droid CLI Setup Help", componentPath: "./components/onboarding-setup-help.js" },
|
||||
{ slotId: "post-onboarding-recommendation", label: "Droid CLI Recommendation", componentPath: "./components/post-onboarding-recommendation.js" }
|
||||
],
|
||||
runtime: {
|
||||
metadata: droidRuntimeMetadata,
|
||||
factory: droidRuntimeFactory,
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
export { DroidRuntimeAdapter };
|
||||
export { probeDroidBinary } from "./probe.js";
|
||||
export type { DroidBinaryStatus } from "./probe.js";
|
||||
export { streamViaCli } from "./provider.js";
|
||||
export {
|
||||
discoverDroidModels,
|
||||
validateCliPresenceAsync,
|
||||
validateCliAuthAsync,
|
||||
killAllProcesses,
|
||||
} from "./process-manager.js";
|
||||
export { getCustomToolDefs, toolsFromContext, writeMcpConfig } from "./mcp-config.js";
|
||||
export type { McpToolDef } from "./mcp-config.js";
|
||||
144
plugins/fusion-plugin-droid-runtime/src/mcp-config.ts
Normal file
144
plugins/fusion-plugin-droid-runtime/src/mcp-config.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Custom tool discovery and MCP config file generation.
|
||||
*
|
||||
* Discovers non-built-in tools from pi, writes their schemas to a temp file,
|
||||
* and generates an MCP config that points to the schema-only MCP server.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* A single tool descriptor returned by pi.getAllTools().
|
||||
*/
|
||||
interface PiToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal duck-type interface for the pi ExtensionAPI instance.
|
||||
* We only call getAllTools(), so we only declare that method.
|
||||
* The return type is unknown to accommodate defensive runtime checks.
|
||||
*/
|
||||
interface PiInstance {
|
||||
getAllTools(): unknown;
|
||||
}
|
||||
|
||||
/** The 6 built-in tools that pi handles natively (match pi tool names). */
|
||||
const BUILT_IN_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
]);
|
||||
|
||||
/** A custom tool definition with MCP-compatible schema. */
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom tool definitions from pi, filtering out built-in tools.
|
||||
*
|
||||
* @param pi - The pi ExtensionAPI instance
|
||||
* @returns Array of custom tool definitions (empty if all tools are built-in)
|
||||
*/
|
||||
export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
|
||||
const allTools = pi.getAllTools();
|
||||
|
||||
if (!Array.isArray(allTools)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (allTools as PiToolInfo[])
|
||||
.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Minimal pi-ai Tool shape (the subset we need from `Context.tools`). */
|
||||
interface PiAiToolLike {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the pi-ai `Context.tools` array (the authoritative per-session tool
|
||||
* list pi-coding-agent passes to streamSimple) into MCP tool defs, filtering
|
||||
* out the 6 built-ins that pi handles natively.
|
||||
*/
|
||||
export function toolsFromContext(
|
||||
contextTools: ReadonlyArray<PiAiToolLike> | undefined,
|
||||
): McpToolDef[] {
|
||||
if (!Array.isArray(contextTools)) return [];
|
||||
return contextTools
|
||||
.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write MCP config and tool schemas to temp files.
|
||||
*
|
||||
* Creates two temp files:
|
||||
* 1. Schema file: JSON array of tool definitions
|
||||
* 2. Config file: MCP config pointing to the schema-only server
|
||||
*
|
||||
* @param toolDefs - Array of custom tool definitions
|
||||
* @param cacheKey - Optional suffix appended to filenames so that distinct
|
||||
* tool sets (e.g. session-scoped tool registrations) get distinct files
|
||||
* and don't race on a single shared path.
|
||||
* @returns Path to the MCP config file
|
||||
*/
|
||||
export function writeMcpConfig(
|
||||
toolDefs: McpToolDef[],
|
||||
cacheKey?: string,
|
||||
): string {
|
||||
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
|
||||
|
||||
// Write tool schemas to temp file
|
||||
const schemaFilePath = join(
|
||||
tmpdir(),
|
||||
`droid-cli-mcp-schemas-${suffix}.json`,
|
||||
);
|
||||
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
|
||||
|
||||
// Resolve path to the schema server .cjs file (sibling of this module)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const serverPath = join(__dirname, "mcp-schema-server.cjs");
|
||||
|
||||
// Build MCP config
|
||||
const config = {
|
||||
mcpServers: {
|
||||
"custom-tools": {
|
||||
command: "node",
|
||||
args: [serverPath, schemaFilePath],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Write config to temp file
|
||||
const configFilePath = join(
|
||||
tmpdir(),
|
||||
`droid-cli-mcp-config-${suffix}.json`,
|
||||
);
|
||||
writeFileSync(configFilePath, JSON.stringify(config));
|
||||
|
||||
return configFilePath;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// Schema-only MCP server. Reads tool schemas from a JSON file.
|
||||
// Only implements initialize + tools/list. tools/call is never reached
|
||||
// because the parent process kills the Claude subprocess at message_stop
|
||||
// before tool execution (break-early pattern).
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const readline = require("readline");
|
||||
|
||||
const schemaPath = process.argv[2];
|
||||
if (!schemaPath) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let tools = [];
|
||||
try {
|
||||
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === "initialize") {
|
||||
const resp = {
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "custom-tools", version: "1.0.0" },
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(resp) + "\n");
|
||||
} else if (msg.method === "tools/list") {
|
||||
const resp = { jsonrpc: "2.0", id: msg.id, result: { tools } };
|
||||
process.stdout.write(JSON.stringify(resp) + "\n");
|
||||
}
|
||||
// notifications/initialized: no response needed (notification)
|
||||
// tools/call: never reached (break-early kills subprocess first)
|
||||
});
|
||||
57
plugins/fusion-plugin-droid-runtime/src/probe.ts
Normal file
57
plugins/fusion-plugin-droid-runtime/src/probe.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface DroidBinaryStatus {
|
||||
available: boolean;
|
||||
authenticated?: boolean;
|
||||
binaryPath?: string;
|
||||
version?: string;
|
||||
reason?: string;
|
||||
probeDurationMs: number;
|
||||
}
|
||||
|
||||
async function run(binary: string, args: string[], timeoutMs = 2000): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timer = setTimeout(() => {
|
||||
try { child.kill("SIGKILL"); } catch {
|
||||
// ignore kill errors
|
||||
}
|
||||
resolve({ code: 124, stdout, stderr });
|
||||
}, timeoutMs);
|
||||
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
|
||||
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
|
||||
child.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: 127, stdout, stderr });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeDroidBinary(options?: { binaryPath?: string; timeoutMs?: number }): Promise<DroidBinaryStatus> {
|
||||
const startedAt = Date.now();
|
||||
const binaryPath = options?.binaryPath?.trim() || "droid";
|
||||
const timeoutMs = options?.timeoutMs ?? 2000;
|
||||
|
||||
const versionRun = await run(binaryPath, ["--version"], timeoutMs);
|
||||
if (versionRun.code !== 0) {
|
||||
return {
|
||||
available: false,
|
||||
binaryPath,
|
||||
reason: versionRun.code === 124 ? `Probe timed out after ${timeoutMs}ms` : "`droid` not found on PATH",
|
||||
probeDurationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
binaryPath,
|
||||
version: versionRun.stdout.trim() || undefined,
|
||||
probeDurationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
358
plugins/fusion-plugin-droid-runtime/src/process-manager.ts
Normal file
358
plugins/fusion-plugin-droid-runtime/src/process-manager.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Process manager for spawning and managing Droid CLI subprocesses.
|
||||
*
|
||||
* Handles subprocess lifecycle: spawn with correct CLI flags, write NDJSON
|
||||
* messages to stdin, force-kill after result (CLI hangs bug), and stderr capture.
|
||||
* Also provides startup validation for CLI presence and authentication.
|
||||
*/
|
||||
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.PI_DROID_CLI_DEBUG !== "1") return;
|
||||
console.error(`[droid-cli] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a Droid CLI subprocess with all required flags for stream-json communication.
|
||||
*
|
||||
* @param modelId - The model ID to pass via --model flag
|
||||
* @param systemPrompt - Optional system prompt appended via --append-system-prompt
|
||||
* @param options - Optional cwd, AbortSignal, and effort level
|
||||
* @returns The spawned ChildProcess with piped stdin/stdout/stderr
|
||||
*/
|
||||
export function buildDroidSpawnArgs(
|
||||
modelId: string,
|
||||
systemPrompt?: string,
|
||||
options?: {
|
||||
effort?: string;
|
||||
mcpConfigPath?: string;
|
||||
resumeSessionId?: string;
|
||||
newSessionId?: string;
|
||||
},
|
||||
): string[] {
|
||||
const args = [
|
||||
"-p",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--model",
|
||||
modelId,
|
||||
];
|
||||
|
||||
if (options?.resumeSessionId) {
|
||||
// Resume an existing session — CLI loads prior conversation from disk
|
||||
args.push("--resume", options.resumeSessionId);
|
||||
} else if (options?.newSessionId) {
|
||||
// First turn: create session with this ID so subsequent turns can --resume it
|
||||
args.push("--session-id", options.newSessionId);
|
||||
}
|
||||
|
||||
if (systemPrompt) {
|
||||
// Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
|
||||
// Droid CLI's --append-system-prompt accepts a file path or literal text.
|
||||
const tmpFile = join(
|
||||
tmpdir(),
|
||||
`droid-cli-sysprompt-${process.pid}.txt`,
|
||||
);
|
||||
writeFileSync(tmpFile, systemPrompt, "utf-8");
|
||||
args.push("--append-system-prompt", tmpFile);
|
||||
}
|
||||
|
||||
if (options?.effort) {
|
||||
args.push("--effort", options.effort);
|
||||
}
|
||||
|
||||
if (options?.mcpConfigPath) {
|
||||
args.push("--mcp-config", options.mcpConfigPath);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function spawnDroid(
|
||||
modelId: string,
|
||||
systemPrompt?: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
effort?: string;
|
||||
mcpConfigPath?: string;
|
||||
resumeSessionId?: string;
|
||||
newSessionId?: string;
|
||||
},
|
||||
): ChildProcess {
|
||||
const args = buildDroidSpawnArgs(modelId, systemPrompt, {
|
||||
effort: options?.effort,
|
||||
mcpConfigPath: options?.mcpConfigPath,
|
||||
resumeSessionId: options?.resumeSessionId,
|
||||
newSessionId: options?.newSessionId,
|
||||
});
|
||||
|
||||
const proc = spawn("droid", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
});
|
||||
|
||||
debugLog(`spawnDroid: pid=${proc.pid} model=${modelId}`);
|
||||
|
||||
return proc as ChildProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the temp system prompt file created by spawnDroid.
|
||||
* Safe to call multiple times or when no file exists.
|
||||
*/
|
||||
export function cleanupSystemPromptFile(): void {
|
||||
try {
|
||||
unlinkSync(join(tmpdir(), `droid-cli-sysprompt-${process.pid}.txt`));
|
||||
} catch {
|
||||
// File doesn't exist or already deleted — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a user message to the subprocess stdin as NDJSON.
|
||||
* Calls stdin.end() after writing the user message to signal EOF, allowing
|
||||
* Droid CLI to process the input and start generating.
|
||||
*
|
||||
* Accepts both string (text-only prompt) and array (ContentBlock[] with images)
|
||||
* content. JSON.stringify handles both natively. The stream-json protocol
|
||||
* supports either format in the content field.
|
||||
*
|
||||
* @param proc - The Claude subprocess
|
||||
* @param prompt - The prompt text or ContentBlock[] to send
|
||||
*/
|
||||
export function writeUserMessage(
|
||||
proc: ChildProcess,
|
||||
prompt: string | unknown[],
|
||||
): void {
|
||||
const message = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
};
|
||||
proc.stdin!.write(JSON.stringify(message) + "\n");
|
||||
proc.stdin!.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a subprocess immediately via SIGKILL.
|
||||
* No-ops if the process is already dead (killed or exited).
|
||||
* Cross-platform safe: Node.js treats SIGKILL as forceful termination on Windows.
|
||||
*
|
||||
* @param proc - The subprocess to force-kill
|
||||
*/
|
||||
export function forceKillProcess(proc: ChildProcess): void {
|
||||
if (proc.killed || proc.exitCode !== null) return;
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
|
||||
/** Registry of active subprocesses for cleanup on teardown. */
|
||||
const activeProcesses = new Set<ChildProcess>();
|
||||
|
||||
/**
|
||||
* Register a subprocess in the global process registry.
|
||||
* The process is automatically removed from the registry when it exits.
|
||||
*
|
||||
* @param proc - The subprocess to track
|
||||
*/
|
||||
export function registerProcess(proc: ChildProcess): void {
|
||||
activeProcesses.add(proc);
|
||||
proc.on("exit", () => activeProcesses.delete(proc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill all registered subprocesses and clear the registry.
|
||||
* Safe to call multiple times -- no-ops on already-dead processes.
|
||||
*/
|
||||
export function killAllProcesses(): void {
|
||||
for (const proc of activeProcesses) {
|
||||
forceKillProcess(proc);
|
||||
}
|
||||
activeProcesses.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill the subprocess after a 500ms grace period.
|
||||
* The Droid CLI hangs after emitting the result message (known bug).
|
||||
* Brief grace period allows final stdout flushing before force-kill.
|
||||
*
|
||||
* @param proc - The Claude subprocess to clean up
|
||||
*/
|
||||
export function cleanupProcess(proc: ChildProcess): void {
|
||||
setTimeout(() => {
|
||||
forceKillProcess(proc);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a data listener to stderr and accumulate output into a buffer.
|
||||
*
|
||||
* @param proc - The Claude subprocess
|
||||
* @returns A function that returns the accumulated stderr string
|
||||
*/
|
||||
export function captureStderr(proc: ChildProcess): () => string {
|
||||
let buffer = "";
|
||||
proc.stderr!.on("data", (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
});
|
||||
return () => buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the Droid CLI is installed and on PATH.
|
||||
* Throws with install instructions if not found.
|
||||
*/
|
||||
export function validateCliPresence(): void {
|
||||
try {
|
||||
execSync("droid --version", { stdio: "pipe", timeout: 45000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Droid CLI not found on PATH. Install Droid CLI and then run: droid auth login",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the Droid CLI is authenticated.
|
||||
* Returns false and warns if not authenticated.
|
||||
*
|
||||
* @returns true if authenticated, false otherwise
|
||||
*/
|
||||
export function validateCliAuth(): boolean {
|
||||
try {
|
||||
execSync("droid auth status", { stdio: "pipe", timeout: 45000 });
|
||||
return true;
|
||||
} catch {
|
||||
console.warn(
|
||||
"[droid-cli] Droid CLI is not authenticated. " +
|
||||
"Run 'droid auth login' to authenticate.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a one-shot `droid <args>` and resolve to the exit code.
|
||||
*
|
||||
* Why: the sync execSync variants block the Node event loop for the duration
|
||||
* of a Droid CLI cold start (1–3s, occasionally longer). When droid-cli's
|
||||
* factory is invoked from a per-request createFnAgent path (Fusion dashboard
|
||||
* does this on every chat send), those sync probes freeze every other request.
|
||||
* This async variant uses spawn so the loop keeps turning while the subprocess
|
||||
* starts up.
|
||||
*/
|
||||
function runDroidProbe(args: string[], timeoutMs = 45000): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("droid", args, { stdio: "ignore" });
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
resolve(124);
|
||||
}, timeoutMs);
|
||||
proc.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(127);
|
||||
});
|
||||
proc.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Async, non-blocking variant of validateCliPresence.
|
||||
* Resolves with `{ok: true}` on success, `{ok: false, error}` on failure —
|
||||
* never rejects, so callers can fire-and-forget without unhandled rejections.
|
||||
*/
|
||||
export async function validateCliPresenceAsync(): Promise<
|
||||
{ ok: true } | { ok: false; error: Error }
|
||||
> {
|
||||
const code = await runDroidProbe(["--version"]);
|
||||
if (code === 0) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Droid CLI not found on PATH. Install Droid CLI and then run: droid auth login",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Async, non-blocking variant of validateCliAuth.
|
||||
* Returns true if authenticated. Logs a warning (does not throw) otherwise.
|
||||
*/
|
||||
export async function validateCliAuthAsync(): Promise<boolean> {
|
||||
const code = await runDroidProbe(["auth", "status"]);
|
||||
if (code === 0) return true;
|
||||
console.warn(
|
||||
"[droid-cli] Droid CLI is not authenticated. " +
|
||||
"Run 'droid auth login' to authenticate.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function discoverDroidModels(): Promise<string[]> {
|
||||
const attempts: string[][] = [["models", "--json"], ["model", "list", "--json"], ["models"]];
|
||||
|
||||
for (const args of attempts) {
|
||||
const models = await new Promise<string[] | null>((resolve) => {
|
||||
const proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let out = "";
|
||||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||
out += chunk.toString();
|
||||
});
|
||||
proc.once("error", () => resolve(null));
|
||||
proc.once("exit", (code) => {
|
||||
if (code !== 0) return resolve(null);
|
||||
const trimmed = out.trim();
|
||||
if (!trimmed) return resolve([]);
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return resolve(
|
||||
parsed
|
||||
.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? entry
|
||||
: typeof entry?.id === "string"
|
||||
? entry.id
|
||||
: typeof entry?.name === "string"
|
||||
? entry.name
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// not json, fall through to line parsing
|
||||
}
|
||||
resolve(
|
||||
trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (models && models.length > 0) {
|
||||
return Array.from(new Set(models));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
629
plugins/fusion-plugin-droid-runtime/src/prompt-builder.ts
Normal file
629
plugins/fusion-plugin-droid-runtime/src/prompt-builder.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
447
plugins/fusion-plugin-droid-runtime/src/provider.ts
Normal file
447
plugins/fusion-plugin-droid-runtime/src/provider.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Provider orchestration for bridging pi requests to the Droid CLI subprocess.
|
||||
*
|
||||
* streamViaCli is the core function that:
|
||||
* 1. Builds the prompt from conversation context
|
||||
* 2. Spawns a Droid CLI subprocess with correct flags
|
||||
* 3. Writes the user message to stdin as NDJSON
|
||||
* 4. Reads stdout line-by-line, parsing NDJSON
|
||||
* 5. Routes stream events through the event bridge to pi's stream
|
||||
* 6. Handles result/error messages and cleans up the subprocess
|
||||
* 7. Implements break-early: kills subprocess at message_stop when
|
||||
* built-in or custom-tools MCP tool_use blocks are seen
|
||||
* 8. Hardened lifecycle: inactivity timeout, subprocess exit handler,
|
||||
* streamEnded guard, abort via SIGKILL, process registry
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
AssistantMessageEventStream,
|
||||
type Api,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
type ToolCall,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
buildPrompt,
|
||||
buildSystemPrompt,
|
||||
buildResumePrompt,
|
||||
type PiContext,
|
||||
} from "./prompt-builder.js";
|
||||
import {
|
||||
spawnDroid,
|
||||
writeUserMessage,
|
||||
cleanupProcess,
|
||||
captureStderr,
|
||||
forceKillProcess,
|
||||
registerProcess,
|
||||
cleanupSystemPromptFile,
|
||||
buildDroidSpawnArgs,
|
||||
} from "./process-manager.js";
|
||||
import { parseLine } from "./stream-parser.js";
|
||||
import { createEventBridge } from "./event-bridge.js";
|
||||
import { mapThinkingEffort } from "./thinking-config.js";
|
||||
import { isPiKnownDroidTool } from "./tool-mapping.js";
|
||||
/**
|
||||
* Inactivity safety net for the Droid CLI subprocess.
|
||||
*
|
||||
* Set very high (30 minutes) because the caller is the authoritative source of
|
||||
* truth for "this session is stuck": Fusion's engine runs a `StuckTaskDetector`
|
||||
* with a configurable heartbeat (default 1 hour) and aborts the session via
|
||||
* `AbortSignal` when it decides the agent has gone quiet. droid-cli already
|
||||
* forwards that signal to the subprocess (`forceKillProcess` on `signal.abort`).
|
||||
*
|
||||
* A short timeout here was racing the engine: Sonnet 4.6 with extended thinking
|
||||
* on the triage prompt (~40k chars) routinely goes >3 minutes between thinking
|
||||
* deltas, and we were killing those subprocesses before they could write
|
||||
* PROMPT.md and call `fn_review_spec`. The half-hour ceiling is just a
|
||||
* last-resort guard for catastrophically hung processes when no abort signal
|
||||
* arrives (e.g. someone embeds droid-cli without a stuck detector).
|
||||
*/
|
||||
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
/**
|
||||
* Cold-start ceiling: kill the subprocess if it hasn't produced a single line
|
||||
* of stdout within this window. Distinct from INACTIVITY_TIMEOUT_MS so a hung
|
||||
* binary (no output ever) is reported with a clear cause instead of being
|
||||
* indistinguishable from a slow-thinking turn. Observed cold-start on a healthy
|
||||
* droid is ~20s; 60s gives 3x headroom for slow machines / cold caches.
|
||||
*/
|
||||
const FIRST_LINE_TIMEOUT_MS = 60_000;
|
||||
function isDebugStreamEnabled(): boolean {
|
||||
return process.env.PI_DROID_CLI_DEBUG === "1";
|
||||
}
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (!isDebugStreamEnabled()) return;
|
||||
console.error(`[droid-cli] ${message}`);
|
||||
}
|
||||
|
||||
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
|
||||
type StreamViaCLiOptions = SimpleStreamOptions & {
|
||||
cwd?: string;
|
||||
mcpConfigPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream a response from Droid CLI as an AssistantMessageEventStream.
|
||||
*
|
||||
* Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
|
||||
* bridge events, handle result, and clean up. Implements break-early pattern:
|
||||
* at message_stop, if any built-in or custom-tools MCP tool was seen, kills
|
||||
* the subprocess before Droid CLI can auto-execute the tools.
|
||||
*
|
||||
* Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
|
||||
* surfacing, streamEnded guard against double errors, abort via SIGKILL, and
|
||||
* process registry integration for teardown cleanup.
|
||||
*
|
||||
* @param model - The model to use (from pi's model catalog)
|
||||
* @param context - The conversation context with messages and system prompt
|
||||
* @param options - Optional cwd, abort signal, reasoning level, thinking budgets, and mcpConfigPath
|
||||
* @returns An AssistantMessageEventStream that receives bridged events
|
||||
*/
|
||||
export function streamViaCli(
|
||||
model: Model<Api>,
|
||||
context: PiContext,
|
||||
options?: StreamViaCLiOptions,
|
||||
): AssistantMessageEventStream {
|
||||
// @ts-expect-error — tsc can't verify AssistantMessageEventStream is a value
|
||||
// through pi-ai's `export *` re-export chain. The class constructor exists at runtime.
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
let proc: ReturnType<typeof spawnDroid> | undefined;
|
||||
let abortHandler: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
|
||||
// Resume if pi provides a session ID AND this isn't the first turn.
|
||||
// Pi passes sessionId on every call (including first), but we can only
|
||||
// --resume a CLI session that already exists on disk from a prior turn.
|
||||
const resumeSessionId =
|
||||
options?.sessionId && context.messages.length > 1
|
||||
? options.sessionId
|
||||
: undefined;
|
||||
|
||||
// Build prompt: if resuming, only send the latest user turn;
|
||||
// otherwise build the full flattened conversation history
|
||||
const prompt = resumeSessionId
|
||||
? buildResumePrompt(context)
|
||||
: buildPrompt(context);
|
||||
const systemPrompt = resumeSessionId
|
||||
? undefined
|
||||
: buildSystemPrompt(context, cwd);
|
||||
|
||||
// Compute effort level from reasoning options
|
||||
const effort = mapThinkingEffort(
|
||||
options?.reasoning,
|
||||
model.id,
|
||||
options?.thinkingBudgets,
|
||||
);
|
||||
|
||||
const spawnOptions = {
|
||||
cwd,
|
||||
signal: options?.signal,
|
||||
effort,
|
||||
mcpConfigPath: options?.mcpConfigPath,
|
||||
resumeSessionId,
|
||||
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
|
||||
};
|
||||
|
||||
// Spawn subprocess
|
||||
proc = spawnDroid(model.id, systemPrompt || undefined, spawnOptions);
|
||||
const getStderr = captureStderr(proc);
|
||||
|
||||
// Register in global process registry for teardown cleanup
|
||||
registerProcess(proc);
|
||||
const spawnArgs = buildDroidSpawnArgs(model.id, undefined, {
|
||||
effort,
|
||||
mcpConfigPath: options?.mcpConfigPath,
|
||||
resumeSessionId,
|
||||
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
|
||||
});
|
||||
debugLog(
|
||||
`spawned droid subprocess pid=${proc.pid ?? "unknown"} args=${JSON.stringify(spawnArgs)}`,
|
||||
);
|
||||
|
||||
// Write user message to subprocess stdin
|
||||
writeUserMessage(proc, prompt);
|
||||
debugLog("user message written to stdin, stdin.end() called");
|
||||
|
||||
// Create event bridge (before endStreamWithError so bridge is in scope)
|
||||
const bridge = createEventBridge(stream, model);
|
||||
|
||||
// Guard against double stream.end() and double error events.
|
||||
// First error path wins; subsequent ones are no-ops.
|
||||
let streamEnded = false;
|
||||
|
||||
/**
|
||||
* End the stream with an error, using a "done" event instead of "error".
|
||||
*
|
||||
* Why "done" not "error": AssistantMessageEventStream.extractResult()
|
||||
* returns event.error (a string) for error events, but agent-loop.js
|
||||
* then calls message.content.filter() on the result, crashing because
|
||||
* a string has no .content property. By pushing "done" with a valid
|
||||
* AssistantMessage (content:[]), pi gets a well-formed object.
|
||||
*/
|
||||
function endStreamWithError(errMsg: string) {
|
||||
if (streamEnded || broken) return;
|
||||
streamEnded = true;
|
||||
const output = bridge.getOutput();
|
||||
const errorMessage = {
|
||||
...output,
|
||||
content: output.content?.length
|
||||
? output.content
|
||||
: [{ type: "text" as const, text: `Error: ${errMsg}` }],
|
||||
stopReason: "stop" as const,
|
||||
};
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: errorMessage,
|
||||
});
|
||||
stream.end();
|
||||
}
|
||||
|
||||
// Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function resetInactivityTimer() {
|
||||
if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
|
||||
inactivityTimer = setTimeout(() => {
|
||||
forceKillProcess(proc!);
|
||||
endStreamWithError(
|
||||
`Droid CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
|
||||
);
|
||||
}, INACTIVITY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Set up abort signal handler -- uses SIGKILL for immediate force-kill
|
||||
if (options?.signal) {
|
||||
abortHandler = () => {
|
||||
if (proc) {
|
||||
forceKillProcess(proc);
|
||||
}
|
||||
};
|
||||
|
||||
if (options.signal.aborted) {
|
||||
abortHandler();
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Track tool_use blocks for break-early decision at message_stop
|
||||
let sawBuiltInOrCustomTool = false;
|
||||
let firstLineReceived = false;
|
||||
// Guard against buffered readline lines firing after rl.close()
|
||||
let broken = false;
|
||||
|
||||
// Set up readline for line-by-line NDJSON parsing
|
||||
const rl = createInterface({
|
||||
input: proc.stdout!,
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
// Handle process error -- use endStreamWithError for guard
|
||||
proc.on("error", (err: Error) => {
|
||||
if (broken) return; // Break-early killed the process intentionally
|
||||
const stderr = getStderr();
|
||||
endStreamWithError(stderr || err.message);
|
||||
});
|
||||
|
||||
// Handle subprocess close -- surface crashes with stderr and exit code
|
||||
proc.on("close", (code: number | null, _signal: string | null) => {
|
||||
clearTimeout(inactivityTimer);
|
||||
debugLog(`subprocess closed: code=${code} signal=${_signal}`);
|
||||
if (broken) return; // Break-early kill, expected
|
||||
const stderr = getStderr().trim();
|
||||
if (stderr) {
|
||||
console.warn(`[droid-cli] Droid CLI stderr on close: ${stderr}`);
|
||||
}
|
||||
if (code !== 0 && code !== null) {
|
||||
const message = stderr
|
||||
? `Droid CLI exited with code ${code}: ${stderr}`
|
||||
: `Droid CLI exited unexpectedly with code ${code}`;
|
||||
endStreamWithError(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Start inactivity timer after writing user message
|
||||
resetInactivityTimer();
|
||||
|
||||
// Cold-start ceiling: only fires if firstLineReceived stays false. Cleared
|
||||
// when the first line arrives, when proc closes, or on break-early. This
|
||||
// distinguishes "droid never started" from "droid is taking a long time
|
||||
// between thinking deltas" so the inactivity kill carries actionable info.
|
||||
const firstLineTimer: ReturnType<typeof setTimeout> = setTimeout(() => {
|
||||
if (firstLineReceived) return;
|
||||
forceKillProcess(proc!);
|
||||
endStreamWithError(
|
||||
`Droid CLI produced no output within ${FIRST_LINE_TIMEOUT_MS / 1000}s — likely binary hang or auth failure (try \`droid --version\` and \`droid auth status\`)`,
|
||||
);
|
||||
}, FIRST_LINE_TIMEOUT_MS);
|
||||
proc.on("close", () => clearTimeout(firstLineTimer));
|
||||
|
||||
// Process NDJSON lines from stdout using event-based callback
|
||||
// NOTE: Using 'line' event instead of `for await` because the async
|
||||
// iterator batches lines, breaking real-time streaming to pi.
|
||||
rl.on("line", (line: string) => {
|
||||
if (!firstLineReceived) {
|
||||
firstLineReceived = true;
|
||||
debugLog("first stdout line received from Droid CLI");
|
||||
}
|
||||
if (broken) return; // Guard: ignore buffered lines after break-early
|
||||
|
||||
// Reset inactivity timer on each line of output
|
||||
resetInactivityTimer();
|
||||
|
||||
const msg = parseLine(line);
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === "stream_event") {
|
||||
// Only forward top-level events to pi's event bridge.
|
||||
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
|
||||
const isTopLevel = !msg.parent_tool_use_id;
|
||||
if (isTopLevel) {
|
||||
bridge.handleEvent(msg.event);
|
||||
}
|
||||
|
||||
// Track tool_use blocks for break-early decision (top-level only)
|
||||
if (
|
||||
isTopLevel &&
|
||||
msg.event.type === "content_block_start" &&
|
||||
msg.event.content_block?.type === "tool_use"
|
||||
) {
|
||||
const toolName = msg.event.content_block.name;
|
||||
if (toolName) {
|
||||
const piKnownTool = isPiKnownDroidTool(toolName);
|
||||
debugLog(
|
||||
`top-level tool_use seen: ${toolName} (piKnown=${piKnownTool ? "yes" : "no"})`,
|
||||
);
|
||||
if (piKnownTool) {
|
||||
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
|
||||
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
||||
sawBuiltInOrCustomTool = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Break-early at message_stop: kill subprocess before CLI auto-executes tools
|
||||
// Only on top-level message_stop — sub-agent message_stop is internal
|
||||
if (
|
||||
isTopLevel &&
|
||||
msg.event.type === "message_stop" &&
|
||||
sawBuiltInOrCustomTool
|
||||
) {
|
||||
debugLog("break-early triggered at message_stop after pi-known tool_use");
|
||||
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
|
||||
clearTimeout(inactivityTimer);
|
||||
clearTimeout(firstLineTimer);
|
||||
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
|
||||
forceKillProcess(proc!);
|
||||
rl.close();
|
||||
return; // Don't process further -- done event already pushed by event bridge
|
||||
}
|
||||
} else if (msg.type === "control_request") {
|
||||
debugLog(
|
||||
`unexpected control_request received (stdin already closed): ${msg.request_id}`,
|
||||
);
|
||||
} else if (msg.type === "result") {
|
||||
if (msg.subtype === "error") {
|
||||
endStreamWithError(msg.error ?? "Unknown error from Droid CLI");
|
||||
}
|
||||
// For both success and error: clean up the subprocess
|
||||
clearTimeout(inactivityTimer);
|
||||
clearTimeout(firstLineTimer);
|
||||
cleanupProcess(proc!);
|
||||
rl.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for readline to close (result received or process ended).
|
||||
// Also resolve on subprocess close: if SIGKILL races readline (e.g. after
|
||||
// an external abort or watchdog kill), `rl` may never emit "close" because
|
||||
// its input stream was destroyed mid-buffer. Forcing rl.close() from the
|
||||
// proc close handler guarantees this await unblocks instead of hanging
|
||||
// and triggering the engine's "executor did not unwind within 60s" path.
|
||||
await new Promise<void>((resolve) => {
|
||||
rl.on("close", resolve);
|
||||
proc!.on("close", () => {
|
||||
try { rl.close(); } catch { /* already closed */ }
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Push done event after readline closes (async). Pushing synchronously
|
||||
// inside handleMessageStop prevents pi from executing tools.
|
||||
// Guard with streamEnded to avoid pushing done after an error was already pushed.
|
||||
if (!streamEnded) {
|
||||
const output = bridge.getOutput();
|
||||
const contentEvents = output.content || [];
|
||||
|
||||
if (contentEvents.length === 0) {
|
||||
console.warn(
|
||||
`[droid-cli] Droid CLI closed without content events (model=${model.id}, sessionId=${options?.sessionId ?? "none"})`,
|
||||
);
|
||||
}
|
||||
|
||||
// If stopReason is toolUse but there are no pi-known tool calls in content,
|
||||
// it means only user MCP tools were called (filtered by event bridge).
|
||||
// Override to "stop" so pi doesn't try to execute non-existent tools.
|
||||
const piToolCalls = (output.content || []).filter(
|
||||
(c: TextContent | ThinkingContent | ToolCall) => c.type === "toolCall",
|
||||
);
|
||||
const effectiveReason =
|
||||
output.stopReason === "toolUse" && piToolCalls.length === 0
|
||||
? "stop"
|
||||
: output.stopReason;
|
||||
|
||||
streamEnded = true;
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason:
|
||||
effectiveReason === "toolUse"
|
||||
? "toolUse"
|
||||
: effectiveReason === "length"
|
||||
? "length"
|
||||
: "stop",
|
||||
message: { ...output, stopReason: effectiveReason },
|
||||
});
|
||||
stream.end();
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
// Push a "done" event with a text error so pi gets a valid AssistantMessage.
|
||||
// Pushing type:"error" would require an AssistantMessage in the error field,
|
||||
// but we don't have a full AssistantMessage here.
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: `Error: ${errMsg}` }],
|
||||
api: "droid-cli",
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
stream.end();
|
||||
} finally {
|
||||
// Clean up abort listener
|
||||
if (options?.signal && abortHandler) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
cleanupSystemPromptFile();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
59
plugins/fusion-plugin-droid-runtime/src/runtime-adapter.ts
Normal file
59
plugins/fusion-plugin-droid-runtime/src/runtime-adapter.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { streamViaCli } from "./provider.js";
|
||||
import { resolveCliSettings } from "./cli-spawn.js";
|
||||
import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, DroidSession } from "./types.js";
|
||||
|
||||
export class DroidRuntimeAdapter implements AgentRuntime {
|
||||
readonly id = "droid";
|
||||
readonly name = "Droid Runtime";
|
||||
private readonly settings: ReturnType<typeof resolveCliSettings>;
|
||||
|
||||
constructor(settings?: Record<string, unknown>) {
|
||||
this.settings = resolveCliSettings(settings);
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
const model = this.settings.model ?? options.defaultModelId ?? "droid";
|
||||
const session: DroidSession = {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
messages: [],
|
||||
apiKey: undefined,
|
||||
thinkingLevel: options.defaultThinkingLevel,
|
||||
sessionId: "",
|
||||
lastModelDescription: `droid/${model}`,
|
||||
callbacks: {
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
onToolEnd: options.onToolEnd,
|
||||
},
|
||||
dispose: () => undefined,
|
||||
};
|
||||
return { session, sessionFile: undefined };
|
||||
}
|
||||
|
||||
async promptWithFallback(session: AgentSession, prompt: string, _options?: unknown): Promise<void> {
|
||||
const model = {
|
||||
id: String(session.model ?? this.settings.model ?? "droid"),
|
||||
provider: "droid-cli",
|
||||
api: "droid-cli",
|
||||
} as any;
|
||||
|
||||
const stream = streamViaCli(model, {
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
systemPrompt: session.systemPrompt,
|
||||
} as any, { sessionId: session.sessionId } as any);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const streamAny = stream as any;
|
||||
streamAny.on?.("text_delta", (event: any) => session.callbacks.onText?.(event.text ?? ""));
|
||||
streamAny.on?.("thinking_delta", (event: any) => session.callbacks.onThinking?.(event.text ?? ""));
|
||||
streamAny.on?.("done", () => resolve());
|
||||
streamAny.on?.("error", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
describeModel(session: AgentSession): string {
|
||||
return session.lastModelDescription || "droid";
|
||||
}
|
||||
}
|
||||
37
plugins/fusion-plugin-droid-runtime/src/stream-parser.ts
Normal file
37
plugins/fusion-plugin-droid-runtime/src/stream-parser.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NdjsonMessage } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse a single NDJSON line from Droid CLI stdout into a typed message.
|
||||
*
|
||||
* This function is deliberately resilient -- it never throws. Debug noise,
|
||||
* empty lines, and malformed JSON all return null so the streaming pipeline
|
||||
* can safely skip them and continue processing.
|
||||
*/
|
||||
export function parseLine(line: string): NdjsonMessage | null {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip non-JSON lines (debug output like "[SandboxDebug] ...")
|
||||
if (!trimmed.startsWith("{")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
console.error("Failed to parse NDJSON line:", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate that the parsed result is a non-null object (not array, not primitive)
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as NdjsonMessage;
|
||||
}
|
||||
83
plugins/fusion-plugin-droid-runtime/src/thinking-config.ts
Normal file
83
plugins/fusion-plugin-droid-runtime/src/thinking-config.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Thinking effort configuration for mapping pi's ThinkingLevel to Droid CLI --effort flags.
|
||||
*
|
||||
* Maps pi's reasoning levels (minimal/low/medium/high/xhigh) to the CLI's effort
|
||||
* levels (low/medium/high/max). Opus models get an elevated mapping where medium
|
||||
* becomes high and high becomes max, leveraging their superior reasoning capability.
|
||||
*
|
||||
* IMPORTANT: The CLI does NOT support --thinking-budget. Only --effort is supported.
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel, ThinkingBudgets } from "@mariozechner/pi-ai";
|
||||
|
||||
/** CLI effort levels accepted by the --effort flag */
|
||||
export type CliEffortLevel = "low" | "medium" | "high" | "max";
|
||||
|
||||
/**
|
||||
* Standard model mapping: pi ThinkingLevel -> CLI effort.
|
||||
* Non-Opus models never receive "max" (would cause CLI error).
|
||||
*/
|
||||
const STANDARD_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "high", // non-Opus: silently downgrade (max not supported)
|
||||
};
|
||||
|
||||
/**
|
||||
* Opus model mapping: shifted up for elevated reasoning.
|
||||
* Opus models get max capability at high/xhigh levels.
|
||||
*/
|
||||
const OPUS_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "high", // shifted: standard high
|
||||
high: "max", // shifted: maximum capability
|
||||
xhigh: "max", // Opus gets max
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect whether a model ID refers to an Opus model.
|
||||
* Uses includes('opus') for forward-compatibility with future Opus versions.
|
||||
*
|
||||
* @param modelId - The model identifier string
|
||||
* @returns true if the model is an Opus variant
|
||||
*/
|
||||
export function isOpusModel(modelId: string): boolean {
|
||||
return modelId.includes("opus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map pi's ThinkingLevel to a CLI effort string.
|
||||
*
|
||||
* When reasoning is undefined, returns undefined so the --effort flag is omitted
|
||||
* entirely, letting the CLI use its default behavior. When thinkingBudgets are
|
||||
* provided, a console.warn is logged because the CLI only supports effort levels,
|
||||
* not token budgets.
|
||||
*
|
||||
* @param reasoning - Pi's thinking level (undefined = omit flag)
|
||||
* @param modelId - Model ID for Opus detection
|
||||
* @param thinkingBudgets - Custom budgets (logged as unsupported, not applied)
|
||||
* @returns CLI effort level string, or undefined if flag should be omitted
|
||||
*/
|
||||
export function mapThinkingEffort(
|
||||
reasoning?: ThinkingLevel,
|
||||
modelId?: string,
|
||||
thinkingBudgets?: ThinkingBudgets,
|
||||
): CliEffortLevel | undefined {
|
||||
if (reasoning === undefined) {
|
||||
return undefined; // omit --effort flag entirely
|
||||
}
|
||||
|
||||
if (thinkingBudgets && Object.keys(thinkingBudgets).length > 0) {
|
||||
console.warn(
|
||||
"[droid-cli] Custom thinkingBudgets are not supported with CLI subprocess. " +
|
||||
"The CLI uses --effort levels instead of token budgets. Budgets will be ignored.",
|
||||
);
|
||||
}
|
||||
|
||||
const isOpus = modelId ? isOpusModel(modelId) : false;
|
||||
const map = isOpus ? OPUS_EFFORT_MAP : STANDARD_EFFORT_MAP;
|
||||
return map[reasoning];
|
||||
}
|
||||
147
plugins/fusion-plugin-droid-runtime/src/tool-mapping.ts
Normal file
147
plugins/fusion-plugin-droid-runtime/src/tool-mapping.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Single-source-of-truth tool mapping table for bidirectional translation
|
||||
* between Droid CLI tool names/arguments and pi tool names/arguments.
|
||||
*
|
||||
* All lookup tables are derived from the TOOL_MAPPINGS array.
|
||||
* Unknown tools and arguments pass through unchanged.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A mapping entry for a single tool.
|
||||
* `args` maps Claude argument names to pi argument names (only renamed args).
|
||||
*/
|
||||
export interface ToolMapping {
|
||||
claude: string;
|
||||
pi: string;
|
||||
args: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical tool mapping table. All other lookup structures are derived from this.
|
||||
*/
|
||||
export const TOOL_MAPPINGS: ToolMapping[] = [
|
||||
{ claude: "Read", pi: "read", args: { file_path: "path" } },
|
||||
{ claude: "Write", pi: "write", args: { file_path: "path" } },
|
||||
{
|
||||
claude: "Edit",
|
||||
pi: "edit",
|
||||
args: { file_path: "path", old_string: "oldText", new_string: "newText" },
|
||||
},
|
||||
{ claude: "Bash", pi: "bash", args: {} },
|
||||
{ claude: "Grep", pi: "grep", args: { head_limit: "limit" } },
|
||||
{ claude: "Glob", pi: "find", args: {} },
|
||||
];
|
||||
|
||||
/** Prefix for custom pi tools exposed via MCP. */
|
||||
export const CUSTOM_TOOLS_MCP_PREFIX = "mcp__custom-tools__";
|
||||
|
||||
/** Set of built-in pi tool names derived from TOOL_MAPPINGS for O(1) lookup. */
|
||||
const BUILT_IN_PI_NAMES = new Set(TOOL_MAPPINGS.map((m) => m.pi));
|
||||
|
||||
/**
|
||||
* Check if a pi tool name is a custom tool (not one of the 6 built-in tools).
|
||||
* Used by prompt builder to decide whether to add MCP prefix in history replay.
|
||||
*/
|
||||
export function isCustomToolName(piName: string): boolean {
|
||||
return !BUILT_IN_PI_NAMES.has(piName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Claude tool name maps to a pi-known tool.
|
||||
* Returns true for built-in tools (Read, Write, etc.) and custom MCP tools (mcp__custom-tools__*).
|
||||
* Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.) that pi cannot execute.
|
||||
* Used by event bridge to filter out internal tool calls.
|
||||
*/
|
||||
export function isPiKnownDroidTool(claudeName: string): boolean {
|
||||
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
|
||||
return claudeName.toLowerCase() in DROID_TO_PI_NAME;
|
||||
}
|
||||
|
||||
// Derived lookup maps
|
||||
|
||||
/** Lowercase Claude name -> pi name */
|
||||
const DROID_TO_PI_NAME: Record<string, string> = {};
|
||||
/** Pi name -> PascalCase Claude name */
|
||||
const PI_TO_DROID_NAME: Record<string, string> = {};
|
||||
/** Lowercase Claude name -> { claudeArgName: piArgName } */
|
||||
const DROID_TO_PI_ARGS: Record<string, Record<string, string>> = {};
|
||||
/** Pi name -> { piArgName: claudeArgName } */
|
||||
const PI_TO_DROID_ARGS: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const m of TOOL_MAPPINGS) {
|
||||
DROID_TO_PI_NAME[m.claude.toLowerCase()] = m.pi;
|
||||
PI_TO_DROID_NAME[m.pi] = m.claude;
|
||||
DROID_TO_PI_ARGS[m.claude.toLowerCase()] = m.args;
|
||||
|
||||
// Build reverse arg map
|
||||
const reverseArgs: Record<string, string> = {};
|
||||
for (const [from, to] of Object.entries(m.args)) {
|
||||
reverseArgs[to] = from;
|
||||
}
|
||||
PI_TO_DROID_ARGS[m.pi] = reverseArgs;
|
||||
}
|
||||
|
||||
// Handle glob/find asymmetry: pi's "glob" also maps back to Claude's "Glob"
|
||||
PI_TO_DROID_NAME["glob"] = "Glob";
|
||||
|
||||
/**
|
||||
* Map a Claude tool name to the corresponding pi tool name.
|
||||
* Strips the mcp__custom-tools__ prefix for custom tools first,
|
||||
* then falls back to case-insensitive built-in lookup.
|
||||
* Unknown tool names pass through unchanged.
|
||||
*/
|
||||
export function mapDroidToolNameToPi(claudeName: string): string {
|
||||
// Strip custom-tools MCP prefix first (e.g., "mcp__custom-tools__deploy" -> "deploy")
|
||||
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) {
|
||||
return claudeName.slice(CUSTOM_TOOLS_MCP_PREFIX.length);
|
||||
}
|
||||
// Standard built-in tool mapping (case-insensitive)
|
||||
return DROID_TO_PI_NAME[claudeName.toLowerCase()] ?? claudeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a pi tool name to the corresponding Claude tool name.
|
||||
* Direct lookup. Unknown tool names pass through unchanged.
|
||||
*/
|
||||
export function mapPiToolNameToDroid(piName: string): string {
|
||||
return PI_TO_DROID_NAME[piName] ?? piName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Claude tool arguments to pi format.
|
||||
* Only known renamed arguments are translated; all others pass through unchanged.
|
||||
* This prevents dropping unknown/extra arguments (Pitfall 5).
|
||||
*/
|
||||
export function translateDroidArgsToPi(
|
||||
claudeToolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const renames = DROID_TO_PI_ARGS[claudeToolName.toLowerCase()];
|
||||
if (!renames || Object.keys(renames).length === 0) return args;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
const newKey = renames[key] ?? key;
|
||||
result[newKey] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate pi tool arguments to Claude format.
|
||||
* Only known renamed arguments are translated; all others pass through unchanged.
|
||||
*/
|
||||
export function translatePiArgsToDroid(
|
||||
piToolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const renames = PI_TO_DROID_ARGS[piToolName];
|
||||
if (!renames || Object.keys(renames).length === 0) return args;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
const newKey = renames[key] ?? key;
|
||||
result[newKey] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
135
plugins/fusion-plugin-droid-runtime/src/types.ts
Normal file
135
plugins/fusion-plugin-droid-runtime/src/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Wire protocol types for Droid CLI stream-json NDJSON communication
|
||||
|
||||
// NDJSON message types from Droid CLI stdout
|
||||
|
||||
export interface ClaudeStreamEventMessage {
|
||||
type: "stream_event";
|
||||
event: ClaudeApiEvent;
|
||||
/** Present on sub-agent stream events; null/undefined for top-level events. */
|
||||
parent_tool_use_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeResultMessage {
|
||||
type: "result";
|
||||
subtype: "success" | "error";
|
||||
result?: string;
|
||||
error?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeSystemMessage {
|
||||
type: "system";
|
||||
subtype: string;
|
||||
session_id?: string;
|
||||
tools?: unknown[];
|
||||
}
|
||||
|
||||
export interface ClaudeControlRequest {
|
||||
type: "control_request";
|
||||
request_id: string;
|
||||
request: {
|
||||
subtype: "can_use_tool";
|
||||
tool_name: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export type NdjsonMessage =
|
||||
| ClaudeStreamEventMessage
|
||||
| ClaudeResultMessage
|
||||
| ClaudeSystemMessage
|
||||
| ClaudeControlRequest;
|
||||
|
||||
// Claude API event types (inside stream_event wrapper)
|
||||
|
||||
export interface ClaudeApiEvent {
|
||||
type: string; // message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop
|
||||
index?: number;
|
||||
message?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown[];
|
||||
model?: string;
|
||||
usage?: ClaudeUsage;
|
||||
};
|
||||
content_block?: {
|
||||
type: string; // "text", "tool_use", "thinking"
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: string;
|
||||
};
|
||||
delta?: {
|
||||
type?: string; // "text_delta", "input_json_delta", "thinking_delta", "signature_delta"
|
||||
text?: string;
|
||||
partial_json?: string;
|
||||
thinking?: string;
|
||||
signature?: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
usage?: ClaudeUsage;
|
||||
}
|
||||
|
||||
export interface ClaudeUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
}
|
||||
|
||||
// Content block tracking during stream processing
|
||||
|
||||
export interface TrackedContentBlock {
|
||||
type: "text" | "thinking";
|
||||
text: string;
|
||||
index: number; // Claude's content_block index
|
||||
}
|
||||
|
||||
export interface DroidCallbacks {
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DroidSession {
|
||||
model: unknown;
|
||||
systemPrompt: string;
|
||||
messages: unknown[];
|
||||
apiKey: string | undefined;
|
||||
thinkingLevel: string | undefined;
|
||||
sessionId: string;
|
||||
lastModelDescription: string;
|
||||
callbacks: DroidCallbacks;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type AgentSession = DroidSession;
|
||||
|
||||
export interface AgentRuntimeOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
defaultThinkingLevel?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionResult {
|
||||
session: AgentSession;
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
export interface AgentRuntime {
|
||||
id: string;
|
||||
name: string;
|
||||
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
}
|
||||
9
plugins/fusion-plugin-droid-runtime/tsconfig.json
Normal file
9
plugins/fusion-plugin-droid-runtime/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
22
plugins/fusion-plugin-droid-runtime/vitest.config.ts
Normal file
22
plugins/fusion-plugin-droid-runtime/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
pool: "threads",
|
||||
maxWorkers,
|
||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user