Created
June 2, 2025 18:54
-
-
Save fgilio/cecbc72d7087d900688a43431a550fd2 to your computer and use it in GitHub Desktop.
Untested DRAFT of a basic coding agent built in PHP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); // enforce strict typing so PHP catches bad stuff early | |
// Simple coding agent in a single PHP 8.4 file inspired by Radan Skorić's 94‑line Ruby agent. | |
// https://radanskoric.com/articles/coding-agent-in-ruby | |
// Gives OpenAI‑backed chat plus four tools: read_file, list_files, edit_file, run_shell_command. | |
// Every line is commented with the "why" to align with your preference. | |
$apiKey = getenv('OPENAI_API_KEY'); // Pull the API key from env vars for security and portability | |
if (!$apiKey) { // Fail fast if we can't talk to the model | |
fwrite(STDERR, "Please set OPENAI_API_KEY env var\n"); // Clear hint for the user | |
exit(1); // Stop execution | |
} | |
// ---------- Tool definitions the model will see ---------- // | |
$tools = [ // Assemble JSON‑schema tool specs for function calling | |
[ | |
'type' => 'function', // OpenAI expects wrapper object with type=function | |
'function' => [ // Actual metadata OpenAI consumes | |
'name' => 'read_file', // Tool identifier the model will reference | |
'description' => 'Read and return the contents of a text file located in the working directory.', // When to call it | |
'parameters' => [ // JSON Schema so the model knows arg structure | |
'type' => 'object', | |
'properties' => [ | |
'path' => [ | |
'type' => 'string', | |
'description' => 'Relative path of the target file.' | |
], | |
], | |
'required' => ['path'], // Path is mandatory | |
], | |
], | |
], | |
[ | |
'type' => 'function', | |
'function' => [ | |
'name' => 'list_files', | |
'description' => "List files and folders under a given relative path. Append '/' to folder names.", | |
'parameters' => [ | |
'type' => 'object', | |
'properties' => [ | |
'path' => [ | |
'type' => 'string', | |
'description' => 'Optional relative path; empty string means current directory.', | |
'default' => '' | |
], | |
], | |
'required' => [], // No required fields | |
], | |
], | |
], | |
[ | |
'type' => 'function', | |
'function' => [ | |
'name' => 'edit_file', | |
'description' => 'Replace a string in a file, creating the file if it does not exist.', | |
'parameters' => [ | |
'type' => 'object', | |
'properties' => [ | |
'path' => [ | |
'type' => 'string', | |
'description' => 'File to edit or create.' | |
], | |
'old_str' => [ | |
'type' => 'string', | |
'description' => 'Exact text to search for.' | |
], | |
'new_str' => [ | |
'type' => 'string', | |
'description' => 'Text that will replace old_str.' | |
], | |
], | |
'required' => ['path', 'old_str', 'new_str'], // All three are needed | |
], | |
], | |
], | |
[ | |
'type' => 'function', | |
'function' => [ | |
'name' => 'run_shell_command', | |
'description' => 'Execute a Unix shell command **after** the human approves.', | |
'parameters' => [ | |
'type' => 'object', | |
'properties' => [ | |
'command' => [ | |
'type' => 'string', | |
'description' => 'The command to run.' | |
], | |
], | |
'required' => ['command'], // Command string required | |
], | |
], | |
], | |
]; | |
// ---------- Helper function that calls the OpenAI chat endpoint ---------- // | |
function callOpenAI(array $messages, array $tools, string $apiKey): array | |
{ | |
$payload = [ // Body we send to https://api.openai.com/v1/chat/completions | |
'model' => 'gpt-4o-mini', // Affordable yet capable model chosen arbitrarily | |
'temperature' => 0.2, // Slight variation but mostly deterministic | |
'messages' => $messages, // Full conversation so far | |
'tools' => $tools, // Functions available to the model | |
]; | |
$ch = curl_init('https://api.openai.com/v1/chat/completions'); // Start curl session | |
curl_setopt_array($ch, [ // Common curl options bundled for clarity | |
CURLOPT_POST => true, // We POST JSON | |
CURLOPT_RETURNTRANSFER => true, // Capture response as string | |
CURLOPT_HTTPHEADER => [ | |
'Content-Type: application/json', // Tell server we send JSON | |
'Authorization: Bearer ' . $apiKey, // Bearer token auth | |
], | |
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR), // Encode body | |
]); | |
$raw = curl_exec($ch); // Fire the request | |
if ($raw === false) { // Network or curl problem | |
throw new RuntimeException('cURL error: ' . curl_error($ch)); // Bubble it up | |
} | |
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE); // Record HTTP status | |
curl_close($ch); // Free resources early | |
if ($status >= 300) { // Anything 3xx+ is an error for us | |
throw new RuntimeException("OpenAI API returned HTTP $status: $raw"); // Inform caller | |
} | |
return json_decode($raw, true, flags: JSON_THROW_ON_ERROR); // Decode JSON into array | |
} | |
// ---------- Runtime dispatch that performs the work requested by a tool ---------- // | |
function executeTool(string $name, array $args): string | |
{ | |
// match() is clean switch‑like expression; returns tool output | |
return match ($name) { | |
'read_file' => readFileTool($args), | |
'list_files' => listFilesTool($args), | |
'edit_file' => editFileTool($args), | |
'run_shell_command' => runShellCommandTool($args), | |
default => json_encode(['error' => "Unknown tool $name"]), // Defensive default | |
}; | |
} | |
// ---------- Concrete tool implementations ---------- // | |
function readFileTool(array $a): string | |
{ | |
$path = $a['path']; // Extract file path | |
if (!is_file($path)) { // Validate existence | |
return json_encode(['error' => 'No such file']); // Tell the model friendly error | |
} | |
return file_get_contents($path) ?: ''; // Return whole file or empty string | |
} | |
function listFilesTool(array $a): string | |
{ | |
$path = $a['path'] ?? ''; // Optional arg default | |
if ($path !== '' && !is_dir($path)) { // Protect against bad paths | |
return json_encode(['error' => 'No such directory']); // Graceful failure | |
} | |
$items = scandir($path ?: '.'); // Read directory entries | |
// Map entries to "thing/" if directory, otherwise plain filename; skip . and .. | |
$list = array_map( | |
fn($f) => $f === '.' || $f === '..' ? null | |
: (is_dir(($path ? $path . '/' : '') . $f) ? $f . '/' : $f), | |
$items | |
); | |
return json_encode(array_values(array_filter($list))); // Compact and encode to JSON | |
} | |
function editFileTool(array $a): string | |
{ | |
// Destructure args for readability | |
['path' => $path, 'old_str' => $old, 'new_str' => $new] = $a; | |
$content = is_file($path) ? file_get_contents($path) : ''; // Existing content or nothing | |
if ($old === $new) { // Refuse meaningless replace | |
return json_encode(['error' => 'old_str and new_str are identical']); | |
} | |
$pos = strpos($content, $old); // Find first occurrence | |
if ($pos === false && $content !== '') { // If file exists but string missing | |
return json_encode(['error' => 'old_str not found']); | |
} | |
// Replace once or write new content entirely | |
$updated = ($pos === false) ? $new : substr_replace($content, $new, $pos, strlen($old)); | |
file_put_contents($path, $updated); // Persist changes | |
return 'OK'; // Simple success signal | |
} | |
function runShellCommandTool(array $a): string | |
{ | |
$cmd = $a['command']; // Grab command string | |
echo "AI wants to run: $cmd\n"; // Display to human | |
$resp = readline("Execute? [y/N]: "); // Prompt with default No | |
if (strtolower($resp) !== 'y') { // Any response other than 'y' aborts | |
return json_encode(['error' => 'User denied command']); // Tell model | |
} | |
return shell_exec($cmd) ?? ''; // Execute and return combined stdout/stderr | |
} | |
// ---------- Main interactive chat loop ---------- // | |
echo "\u{1F47E} Chat with the coding agent. Type 'exit' to quit.\n"; // Fun UX emoji | |
$messages = [ // Seed transcript with system prompt for personality and rules | |
[ | |
'role' => 'system', | |
'content' => 'You are a concise coding agent. Use tools when helpful. Think step‑by‑step before editing files or running commands.' | |
], | |
]; | |
while (true) { // Loop until user types exit or Ctrl‑D | |
$user = readline('> '); // Read user input with prompt | |
if ($user === false || strtolower(trim($user)) === 'exit') { // Handle EOF or explicit exit | |
echo "Bye!\n"; // Friendly goodbye | |
break; // Leave outer loop | |
} | |
$messages[] = ['role' => 'user', 'content' => $user]; // Append user message | |
while (true) { // Inner loop resolves tool calls until assistant speaks plain text | |
$response = callOpenAI($messages, $tools, $apiKey); // Hit the model | |
$assistant = $response['choices'][0]['message']; // Extract first choice | |
if (isset($assistant['tool_calls'])) { // If the model wants actions | |
foreach ($assistant['tool_calls'] as $call) { // Handle each requested tool | |
$name = $call['function']['name']; // Tool identifier | |
$args = json_decode($call['function']['arguments'], true) ?? []; // Parse JSON args | |
$result = executeTool($name, $args); // Run the tool locally | |
// Record the tool call: assistant role with the function request | |
$messages[] = [ | |
'role' => 'assistant', | |
'tool_calls' => [$call], // Echo back call so transcript matches protocol | |
]; | |
// Record the tool result so the model sees output | |
$messages[] = [ | |
'role' => 'tool', | |
'name' => $name, | |
'content' => $result, | |
]; | |
} | |
continue; // Re‑query model now that it has tool outputs | |
} | |
// No tool calls => regular answer | |
echo $assistant['content'] . "\n"; // Show assistant reply to human | |
$messages[] = ['role' => 'assistant', 'content' => $assistant['content']]; // Persist reply | |
break; // Break inner loop; go back for next user prompt | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Chat loop against gpt-4o-mini
Four JSON-function tools:
Just set OPENAI_API_KEY and run: