Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions packages/pi-synthetic-provider/extensions/autofix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* Synthetic Autofix Extension
*
* Automatically fixes malformed JSON tool calls using the Synthetic fix-json model.
* This extension works with the hook-based approach (PR #feature/hook-based-tool-parsing).
*
* Features:
* - Intercepts tool call JSON parsing
* - Fixes malformed JSON using hf:syntheticlab/fix-json
* - Provides UI notifications during fixing
* - Graceful fallback when autofix fails
*
* Setup:
* export SYNTHETIC_API_KEY="syn_..."
* pi -e ./autofix.ts
*
* Configuration (optional):
* export PI_AUTOFIX_MODEL="hf:syntheticlab/fix-json" # Default
* export PI_AUTOFIX_ENABLED="true" # Default
*/

import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { Model, AssistantMessageEventStream, Context, StreamOptions } from "@mariozechner/pi-ai";
import { streamSimple } from "@mariozechner/pi-ai";

interface AutofixConfig {
enabled: boolean;
baseUrl: string;
model: string;
apiKey?: string;
temperature: number;
}

const DEFAULT_CONFIG: AutofixConfig = {
enabled: true,
baseUrl: "https://api.synthetic.new/v1",
model: "hf:syntheticlab/fix-json",
temperature: 0,
};

/**
* Attempts to fix malformed JSON using the Synthetic fix-json model
*/
async function autofixJson(
brokenJson: string,
config: AutofixConfig,
signal?: AbortSignal,
): Promise<{ success: boolean; fixed?: unknown; error?: string }> {
if (!config.apiKey) {
return { success: false, error: "No SYNTHETIC_API_KEY available" };
}

try {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
temperature: config.temperature,
messages: [
{
role: "user",
content: `Fix this broken JSON and return ONLY valid JSON, no explanation:\n\n${brokenJson}`,
},
],
response_format: { type: "json_object" },
}),
signal,
});

if (!response.ok) {
return { success: false, error: `API error: ${response.status}` };
}

const data = await response.json();
const content = data.choices?.[0]?.message?.content;

if (!content) {
return { success: false, error: "Empty response" };
}

// Try to parse the response
try {
return { success: true, fixed: JSON.parse(content) };
} catch {
// Try to extract JSON from the response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
return { success: true, fixed: JSON.parse(jsonMatch[0]) };
} catch {
return { success: false, error: "Could not parse fixed JSON" };
}
}
return { success: false, error: "No JSON found in response" };
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}

/**
* Creates the onToolCallParse hook function
*/
function createToolCallParseHook(
config: AutofixConfig,
ui: ExtensionContext["ui"],
): StreamOptions["onToolCallParse"] {
return async (rawArgs: string, toolName: string) => {
// First, try standard parsing
try {
return JSON.parse(rawArgs);
} catch {
// Parsing failed, try autofix
// Show "Fixing..." in the working message area
ui.setWorkingMessage(`Fixing JSON for ${toolName}...`);

const result = await autofixJson(rawArgs, config);

// Restore default working message
ui.setWorkingMessage();

if (result.success) {
ui.notify(`✅ Fixed JSON for ${toolName}`, "info");
return result.fixed;
} else {
ui.notify(`⚠️ Could not fix JSON for ${toolName}: ${result.error}`, "warning");
// Return empty object as fallback
return {};
}
}
};
}

/**
* Extension factory function
*/
export default function syntheticAutofixExtension(pi: ExtensionAPI) {
// Get config from environment
const config: AutofixConfig = {
enabled: process.env.PI_AUTOFIX_ENABLED !== "false",
baseUrl: process.env.PI_AUTOFIX_BASE_URL || DEFAULT_CONFIG.baseUrl,
model: process.env.PI_AUTOFIX_MODEL || DEFAULT_CONFIG.model,
apiKey: process.env.SYNTHETIC_API_KEY,
temperature: DEFAULT_CONFIG.temperature,
};

if (!config.enabled) {
console.log("[Synthetic Autofix] Disabled via PI_AUTOFIX_ENABLED");
return;
}

if (!config.apiKey) {
console.log("[Synthetic Autofix] No SYNTHETIC_API_KEY, skipping");
return;
}

console.log("[Synthetic Autofix] Extension loaded");
console.log(`[Synthetic Autofix] Fix model: ${config.model}`);

// Hook into session start to wrap providers
pi.on("session_start", async (_event, ctx) => {
const currentModel = ctx.model;
if (!currentModel) {
console.log("[Synthetic Autofix] No current model");
return;
}

const providerName = currentModel.provider;
console.log(`[Synthetic Autofix] Active provider: ${providerName}`);

// Create the parse hook
const onToolCallParse = createToolCallParseHook(config, ctx.ui);

// Note: To actually use the hook, we would need to wrap the provider's
// streamSimple function. However, pi's extension API doesn't currently
// expose a way to wrap the stream function.
//
// For now, this extension demonstrates the concept and will work once
// the hook-based PR is merged and extensions can provide stream wrappers.
//
// The direct approach (PR #feature/autofix-malformed-tool-calls) works
// immediately without requiring extension hooks.

ctx.ui.notify("🔧 Synthetic Autofix ready", "info");
});

// Register a command to test autofix
pi.registerCommand("test-autofix", {
description: "Test the autofix functionality with sample malformed JSON",
handler: async (_args, ctx) => {
if (!config.apiKey) {
ctx.ui.notify("No SYNTHETIC_API_KEY set", "error");
return;
}

ctx.ui.notify("Testing autofix...", "info");

// Test cases
const testCases = [
'{"command": "ls -la", "timeout" 30000}', // Missing colon
'{"command": "ls -la", "timeout": }', // Missing value
'{"command": "ls -la", "timeout": 30000', // Missing closing brace
];

for (const testCase of testCases) {
console.log("\n--- Test Case ---");
console.log("Input:", testCase);

const result = await autofixJson(testCase, config);

if (result.success) {
console.log("✅ Fixed:", JSON.stringify(result.fixed));
} else {
console.log("❌ Error:", result.error);
}
}

ctx.ui.notify("Autofix test complete (see logs)", "info");
},
});
}
3 changes: 2 additions & 1 deletion packages/pi-synthetic-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
],
"pi": {
"extensions": [
"./extensions/index.ts"
"./extensions/index.ts",
"./extensions/autofix.ts"
]
},
"peerDependencies": {
Expand Down