unsloth/unsloth_cli/pi_subagent.ts
Daniel Han 968e6230a0
Unsloth start: add local subagents for Claude Code, Codex, OpenCode and Pi (#7326)
Bring the local-subagent support onto main. The original change (#7316) merged
into the stacked pr/daniel-unsloth-start-audit branch rather than main, and #7313
reached main via squash, so these files never landed on main.

Adds --as-subagent for claude, codex, opencode and pi: the parent agent keeps its
own cloud model while a locally served GGUF is registered as a delegated subagent,
using ephemeral per-session config that never touches the user's real agent config.
2026-07-22 04:34:58 -07:00

241 lines
7.4 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const provider = "unsloth";
const maxResultCharacters = 100_000;
const cancelGraceMilliseconds = 2_000;
const configPath = process.env.UNSLOTH_PI_SUBAGENT_CONFIG || "";
delete process.env.UNSLOTH_PI_SUBAGENT_CONFIG;
let config: Record<string, unknown> = {};
if (configPath) {
try {
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("expected a JSON object");
}
config = parsed;
} catch (error) {
throw new Error(`Could not read Unsloth subagent configuration: ${error}`);
}
}
const model = typeof config.model === "string" ? config.model : "";
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
const apiKey = typeof config.apiKey === "string" ? config.apiKey : "";
const contextWindow = positiveInt(config.contextWindow, 32768);
const maxTokens = positiveInt(config.maxTokens, Math.min(Math.floor(contextWindow / 4), 8192));
function positiveInt(value: unknown, fallback: number): number {
const parsed = Number.parseInt(typeof value === "string" ? value : String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function finalText(message: any): string {
if (message?.role !== "assistant" || !Array.isArray(message.content)) return "";
return message.content
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("\n")
.trim();
}
function boundedResult(text: string): string {
if (text.length <= maxResultCharacters) return text;
return `${text.slice(0, maxResultCharacters)}\n\n[Local agent output truncated]`;
}
function piInvocation(args: string[]): { command: string; args: string[] } {
const currentScript = process.argv[1];
const bunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
if (currentScript && !bunVirtualScript && fs.existsSync(currentScript)) {
return { command: process.execPath, args: [currentScript, ...args] };
}
const executable = path.basename(process.execPath).toLowerCase();
if (!/^(node|bun)(\.exe)?$/.test(executable)) return { command: process.execPath, args };
return { command: "pi", args };
}
function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
if (!child.pid) return;
try {
process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
// The process tree already exited.
}
}
}
async function stopChildTree(child: ChildProcess): Promise<void> {
if (!child.pid) return;
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
shell: false,
stdio: "ignore",
windowsHide: true,
});
killer.once("error", () => {
try {
child.kill("SIGKILL");
} catch {
// The child already exited.
}
resolve();
});
killer.once("close", (code) => {
if (code !== 0) {
try {
child.kill("SIGKILL");
} catch {
// The child already exited.
}
}
resolve();
});
});
return;
}
signalProcessGroup(child, "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, cancelGraceMilliseconds));
signalProcessGroup(child, "SIGKILL");
}
export default function unslothSubagent(pi: ExtensionAPI): void {
if (!model || !baseUrl || !apiKey || !configPath) {
throw new Error("Unsloth subagent configuration is incomplete.");
}
pi.registerProvider(provider, {
name: "Unsloth Studio",
baseUrl,
apiKey,
api: "openai-completions",
authHeader: true,
models: [
{
id: model,
name: `${model} via Unsloth`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens,
},
],
});
if (process.env.UNSLOTH_PI_SUBAGENT_CHILD === "1") return;
pi.registerTool({
name: "unsloth_agent",
label: "Unsloth agent",
description:
"Local coding subagent powered by Unsloth for debugging, implementation, and codebase research. Use when the user asks to spawn an Unsloth or local agent.",
parameters: Type.Object({
task: Type.String({ description: "The complete task for the local Unsloth agent." }),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const extension = fileURLToPath(import.meta.url);
const args = [
"--mode",
"json",
"--print",
"--no-session",
"--provider",
provider,
"--model",
model,
"--no-extensions",
"--extension",
extension,
`Task: ${params.task}`,
];
const invocation = piInvocation(args);
let output = "";
let stderr = "";
let lastResponse = "";
let childError = "";
let aborted = false;
const processLine = (line: string) => {
try {
const event = JSON.parse(line);
if (event.type !== "message_end") return;
const message = event.message;
// Pi reports model/API failures as message_end events while still
// exiting 0, so the exit status alone cannot surface them.
if (message?.stopReason === "error" || message?.stopReason === "aborted") {
childError =
(typeof message.errorMessage === "string" && message.errorMessage) ||
`The local Unsloth agent stopped: ${message.stopReason}.`;
return;
}
const response = finalText(message);
if (response) {
lastResponse = boundedResult(response);
childError = "";
}
} catch {
// Ignore non-JSON diagnostic lines. The exit status still reports failures.
}
};
const exitCode = await new Promise<number>((resolve, reject) => {
const child = spawn(invocation.command, invocation.args, {
cwd: ctx.cwd,
detached: process.platform !== "win32",
shell: false,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
UNSLOTH_PI_SUBAGENT_CHILD: "1",
UNSLOTH_PI_SUBAGENT_CONFIG: configPath,
},
});
let cleanup: Promise<void> | undefined;
const cancel = () => {
if (aborted) return;
aborted = true;
cleanup = stopChildTree(child);
};
child.on("error", (error) => {
signal?.removeEventListener("abort", cancel);
reject(error);
});
child.stdout.on("data", (chunk) => {
output += chunk.toString();
const lines = output.split("\n");
output = lines.pop() || "";
for (const line of lines) processLine(line);
});
child.stderr.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-100_000);
});
child.on("close", async (code) => {
signal?.removeEventListener("abort", cancel);
await cleanup;
if (output.trim()) processLine(output);
resolve(code ?? 1);
});
signal?.addEventListener("abort", cancel, { once: true });
if (signal?.aborted) cancel();
});
if (aborted) throw new Error("The local Unsloth agent was cancelled.");
if (exitCode !== 0) {
throw new Error(stderr.trim() || `The local Unsloth agent exited with code ${exitCode}.`);
}
if (childError) throw new Error(boundedResult(childError));
return {
content: [{ type: "text", text: lastResponse || "The local agent returned no text." }],
details: { provider, model },
};
},
});
}