Skip to content

Instantly share code, notes, and snippets.

@fgilio
Created June 2, 2025 18:54
Show Gist options
  • Save fgilio/cecbc72d7087d900688a43431a550fd2 to your computer and use it in GitHub Desktop.
Save fgilio/cecbc72d7087d900688a43431a550fd2 to your computer and use it in GitHub Desktop.
Untested DRAFT of a basic coding agent built in PHP
<?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
}
}
@fgilio
Copy link
Author

fgilio commented Jun 2, 2025

Chat loop against gpt-4o-mini

Four JSON-function tools:

  • read_file
  • list_files
  • edit_file
  • run_shell_command (asks before it runs anything)

Just set OPENAI_API_KEY and run:

php agent.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment