From 5b2c1344d33fa2ecaa05c466cbeda0ba1e44732d Mon Sep 17 00:00:00 2001 From: michael Date: Sat, 4 Jul 2026 13:39:22 +0200 Subject: [PATCH] Add AutonomousCoder CLI and Core project files --- .gitignore | 8 + .../AutonomousCoder.Cli.csproj | 14 + src/AutonomousCoder.Cli/Program.cs | 357 ++++++++++++++++ src/AutonomousCoder.Core/ActEvent.cs | 40 ++ src/AutonomousCoder.Core/Agent.cs | 140 ++++++ .../AutonomousCoder.Core.csproj | 14 + src/AutonomousCoder.Core/CodingTools.cs | 397 ++++++++++++++++++ src/AutonomousCoder.Core/LmsClientHelper.cs | 75 ++++ src/AutonomousCoder.slnx | 4 + 9 files changed, 1049 insertions(+) create mode 100644 .gitignore create mode 100644 src/AutonomousCoder.Cli/AutonomousCoder.Cli.csproj create mode 100644 src/AutonomousCoder.Cli/Program.cs create mode 100644 src/AutonomousCoder.Core/ActEvent.cs create mode 100644 src/AutonomousCoder.Core/Agent.cs create mode 100644 src/AutonomousCoder.Core/AutonomousCoder.Core.csproj create mode 100644 src/AutonomousCoder.Core/CodingTools.cs create mode 100644 src/AutonomousCoder.Core/LmsClientHelper.cs create mode 100644 src/AutonomousCoder.slnx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecb4792 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.dotnet/ +dotnet-install.sh +bin/ +obj/ +*.user +*.suo +.vs/ +.idea/ diff --git a/src/AutonomousCoder.Cli/AutonomousCoder.Cli.csproj b/src/AutonomousCoder.Cli/AutonomousCoder.Cli.csproj new file mode 100644 index 0000000..cfd9e31 --- /dev/null +++ b/src/AutonomousCoder.Cli/AutonomousCoder.Cli.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/src/AutonomousCoder.Cli/Program.cs b/src/AutonomousCoder.Cli/Program.cs new file mode 100644 index 0000000..5507045 --- /dev/null +++ b/src/AutonomousCoder.Cli/Program.cs @@ -0,0 +1,357 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; +using AutonomousCoder.Core; + +namespace AutonomousCoder.Cli; + +class Program +{ + private const string DefaultLmStudioEndpoint = "http://localhost:1234/v1"; + private const string DefaultOllamaEndpoint = "http://localhost:11434/v1"; + + static async Task Main(string[] args) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + + PrintHeader(); + + // 1. Endpoint configuration & discovery + string endpointUrl = await DiscoverEndpointAsync(); + + // 2. Fetch and select model + string modelName = await SelectModelAsync(endpointUrl); + + // 3. Select workspace path + string workspacePath = SelectWorkspace(); + + // 4. Select interactive mode + bool interactiveMode = SelectExecutionMode(); + + // 5. Input prompt/goal + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("\n╔══════════════════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ ENTER THE GOAL / TASK FOR THE CODING AGENT ║"); + Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝"); + Console.ResetColor(); + Console.Write("Prompt: "); + string? prompt = Console.ReadLine(); + while (string.IsNullOrWhiteSpace(prompt)) + { + Console.Write("Goal cannot be empty. Please enter a goal: "); + prompt = Console.ReadLine(); + } + + // 6. Initialize client and agent + var chatClient = LmsClientHelper.CreateChatClient(endpointUrl, modelName); + + string systemPrompt = GetSystemPrompt(workspacePath); + var agent = new Agent(chatClient, systemPrompt); + + // Register tools from CodingTools instance + var tools = new CodingTools(workspacePath); + agent.AddTool(tools.ListDirectory); + agent.AddTool(tools.ReadFile); + agent.AddTool(tools.WriteFile); + agent.AddTool(tools.ReplaceInFile); + agent.AddTool(tools.SearchFiles); + agent.AddTool(tools.RunCommand); + + // 7. Running the agent loop + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine($"\n[Starting Autonomous Coding Agent loop (Max 15 rounds)]"); + Console.ResetColor(); + + try + { + string finalAnswer = await agent.ActAsync( + prompt, + onEvent: ev => HandleAgentEvent(ev), + approveToolCall: async (name, arguments) => + { + if (!interactiveMode) return true; + + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine("\n┌──[ TOOL APPROVAL REQUIRED ]─────────────────────────────────────────"); + Console.WriteLine($"│ Tool: {name}"); + Console.WriteLine($"│ Arguments: {JsonSerializer.Serialize(arguments, new JsonSerializerOptions { WriteIndented = false })}"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────────"); + Console.ResetColor(); + + while (true) + { + Console.Write("Approve execution? [y]es / [n]o / [a]utonomous (approve all from now on): "); + string? input = Console.ReadLine()?.Trim().ToLower(); + + if (input == "y" || input == "yes" || string.IsNullOrEmpty(input)) + { + return true; + } + if (input == "n" || input == "no") + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("x Tool execution rejected."); + Console.ResetColor(); + return false; + } + if (input == "a" || input == "autonomous") + { + interactiveMode = false; + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("✔ Switched to Autonomous Mode. Future tool calls will execute automatically."); + Console.ResetColor(); + return true; + } + } + } + ); + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("\n======================================================================="); + Console.WriteLine("AGENT COMPLETED WORK"); + Console.WriteLine("======================================================================="); + Console.ResetColor(); + Console.WriteLine(finalAnswer); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\nFatal agent execution error: {ex.Message}"); + Console.ResetColor(); + } + } + + private static void PrintHeader() + { + Console.Clear(); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(@" + █████ ██ ██ ████████ ██████ ███ ██ ██████ ███ ███ ██████ ██ ██ ███████ +██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ████ ████ ██ ██ ██ ██ ██ +███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ███████ +██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +██ ██ ██████ ██ ██████ ██ ████ ██████ ██ ██ ██████ ████ ███████ + + ██████ ██████ ██████ ███████ ██████ +██ ██ ██ ██ ██ ██ ██ ██ +██ ██ ██ ██ ██ █████ ██████ +██ ██ ██ ██ ██ ██ ██ ██ + ██████ ██████ ██████ ███████ ██ ██ +"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(" .NET 10 Autonomous Coding Agent | Powered by Microsoft.Extensions.AI"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + Console.ResetColor(); + } + + private static async Task DiscoverEndpointAsync() + { + Console.WriteLine("\n[1] Discovering Local LLM Endpoints..."); + + // Try LM Studio + var lmsModels = await LmsClientHelper.GetLoadedModelsAsync(DefaultLmStudioEndpoint); + if (lmsModels.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✔ Found active LM Studio endpoint at {DefaultLmStudioEndpoint}"); + Console.ResetColor(); + return DefaultLmStudioEndpoint; + } + + // Try Ollama + var ollamaModels = await LmsClientHelper.GetLoadedModelsAsync(DefaultOllamaEndpoint); + if (ollamaModels.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✔ Found active Ollama endpoint at {DefaultOllamaEndpoint}"); + Console.ResetColor(); + return DefaultOllamaEndpoint; + } + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("⚠ No active local model endpoints discovered at default ports."); + Console.ResetColor(); + + Console.Write($"Enter custom API Endpoint URL (Default: {DefaultLmStudioEndpoint}): "); + string? inputUrl = Console.ReadLine()?.Trim(); + return string.IsNullOrWhiteSpace(inputUrl) ? DefaultLmStudioEndpoint : inputUrl; + } + + private static async Task SelectModelAsync(string endpointUrl) + { + Console.WriteLine("\n[2] Checking Available Models..."); + var models = await LmsClientHelper.GetLoadedModelsAsync(endpointUrl); + + if (models.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("⚠ Could not automatically retrieve loaded models."); + Console.ResetColor(); + Console.Write("Please enter the name of the model to target: "); + string? model = Console.ReadLine()?.Trim(); + while (string.IsNullOrWhiteSpace(model)) + { + Console.Write("Model name cannot be empty. Enter model name: "); + model = Console.ReadLine()?.Trim(); + } + return model; + } + + if (models.Count == 1) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✔ Target Model: {models[0]}"); + Console.ResetColor(); + return models[0]; + } + + Console.WriteLine("Available Models:"); + for (int i = 0; i < models.Count; i++) + { + Console.WriteLine($" {i + 1}. {models[i]}"); + } + + Console.Write($"Select model [1-{models.Count}] (Default: 1): "); + string? selection = Console.ReadLine()?.Trim(); + if (int.TryParse(selection, out int index) && index >= 1 && index <= models.Count) + { + return models[index - 1]; + } + return models[0]; + } + + private static string SelectWorkspace() + { + Console.WriteLine("\n[3] Select Workspace Path"); + string defaultPath = Directory.GetCurrentDirectory(); + Console.WriteLine($"Current Directory: {defaultPath}"); + Console.Write($"Enter target workspace path (relative/absolute) (Press Enter for current): "); + string? input = Console.ReadLine()?.Trim(); + + string path = string.IsNullOrWhiteSpace(input) ? defaultPath : Path.GetFullPath(input); + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✔ Workspace set to: {path}"); + Console.ResetColor(); + return path; + } + + private static bool SelectExecutionMode() + { + Console.WriteLine("\n[4] Select Execution Mode"); + Console.WriteLine(" 1. Interactive / Safe Mode (Requires confirmation before tool execution) [Recommended]"); + Console.WriteLine(" 2. Autonomous Mode (Runs automatically without prompts)"); + Console.Write("Select Mode [1-2] (Default: 1): "); + + string? mode = Console.ReadLine()?.Trim(); + return mode != "2"; + } + + private static void HandleAgentEvent(ActEvent ev) + { + switch (ev) + { + case ActEvent.RoundStarted r: + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"\n┌─────────────────────────────────────────────────────────────────────"); + Console.WriteLine($"│ ROUND {r.Round}"); + Console.WriteLine($"└─────────────────────────────────────────────────────────────────────"); + Console.ResetColor(); + break; + + case ActEvent.Thinking: + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine("Agent is thinking..."); + Console.ResetColor(); + break; + + case ActEvent.MessageReceived msg: + var contents = msg.Message.Contents; + + // Show thoughts/reasoning + string text = msg.Message.Text ?? string.Empty; + if (!string.IsNullOrWhiteSpace(text)) + { + Console.ForegroundColor = ConsoleColor.Gray; + Console.WriteLine($"[Reasoning]:\n{text}"); + Console.ResetColor(); + } + + // Show tool calls + var calls = contents.OfType().ToList(); + if (calls.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine("\n[Tool Execution Requested]:"); + foreach (var call in calls) + { + Console.WriteLine($" → {call.Name} with arguments: {JsonSerializer.Serialize(call.Arguments)}"); + } + Console.ResetColor(); + } + break; + + case ActEvent.ToolExecuting exec: + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"▶ Running tool '{exec.Name}'..."); + Console.ResetColor(); + break; + + case ActEvent.ToolExecuted exec: + if (exec.Result.StartsWith("Error")) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"❌ Tool '{exec.Name}' returned an error:"); + Console.WriteLine(exec.Result); + } + else + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✔ Tool '{exec.Name}' completed successfully. Result snippet:"); + + // Print a short preview of the result (first 5 lines) + string[] lines = exec.Result.Split('\n'); + int displayLines = Math.Min(lines.Length, 5); + for (int i = 0; i < displayLines; i++) + { + Console.WriteLine($" {lines[i]}"); + } + if (lines.Length > displayLines) + { + Console.WriteLine(" ..."); + } + } + Console.ResetColor(); + break; + + case ActEvent.Completed comp: + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine("Loop finished."); + Console.ResetColor(); + break; + } + } + + private static string GetSystemPrompt(string workspacePath) + { + return $@"You are an elite, autonomous C# coding agent running inside the workspace: '{workspacePath}'. +Your goal is to solve the programming task requested by the user. + +IMPORTANT GUIDELINES: +1. EXPLORE: When beginning a task, first discover the workspace files. Use 'ListDirectory' (with pagination if it's large) or 'SearchFiles' to find relevant project files, csproj files, and source code. +2. RESEARCH: Before modifying any file, read its current content using 'ReadFile' (specify 'startLine' and 'lineCount' to paginate and view line numbers). Always read files before attempting to edit them! +3. IMPLEMENT: + - To create a new file or rewrite one entirely, use 'WriteFile'. + - To make specific, localized edits to an existing file, use 'ReplaceInFile'. This is highly preferred over rewriting entire files because it keeps edits precise. Make sure target content is unique and matches exactly (including indentation and line endings). +4. VERIFY & COMPILE: + - After any implementation or change, run compilation using 'RunCommand' with 'dotnet build'. + - Read compilation outputs. If there are compile errors or warnings, look at the error lines in the source file, use 'ReplaceInFile' to correct them, and rebuild. Repeat this loop until compilation succeeds. + - Run tests (e.g. 'dotnet test') to verify logic. +5. CONCISE & FINAL ANSWER: Once you have fully verified the changes and compiled/run successfully, explain what you accomplished. If a task cannot be done, state why clearly. +6. PAGES: If directories list too many files or files are too large, use pagination parameters ('page', 'pageSize', 'startLine', 'lineCount') in the tools. + +You are equipped to be fully autonomous. Focus on writing high quality code, conforming to standard C# styles. Good luck!"; + } +} diff --git a/src/AutonomousCoder.Core/ActEvent.cs b/src/AutonomousCoder.Core/ActEvent.cs new file mode 100644 index 0000000..b6238fd --- /dev/null +++ b/src/AutonomousCoder.Core/ActEvent.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace AutonomousCoder.Core; + +/// +/// Base class for all events emitted during the agent's autonomous execution. +/// +public abstract record ActEvent +{ + /// + /// Event emitted when a new round starts. + /// + public record RoundStarted(int Round) : ActEvent; + + /// + /// Event emitted when the agent is thinking (waiting for LLM response). + /// + public record Thinking : ActEvent; + + /// + /// Event emitted when a message is received from the model. + /// + public record MessageReceived(ChatMessage Message) : ActEvent; + + /// + /// Event emitted before a tool is executed. + /// + public record ToolExecuting(string Name, IDictionary Arguments) : ActEvent; + + /// + /// Event emitted after a tool executes, containing the output result. + /// + public record ToolExecuted(string Name, string Result) : ActEvent; + + /// + /// Event emitted when the agent has completed the execution loop. + /// + public record Completed(string FinalAnswer) : ActEvent; +} diff --git a/src/AutonomousCoder.Core/Agent.cs b/src/AutonomousCoder.Core/Agent.cs new file mode 100644 index 0000000..8849f39 --- /dev/null +++ b/src/AutonomousCoder.Core/Agent.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.AI; + +namespace AutonomousCoder.Core; + +/// +/// An autonomous coding agent that implements a multi-round execution loop (.act() behavior). +/// +public class Agent +{ + private readonly IChatClient _client; + private readonly string _systemPrompt; + private readonly List _tools = new(); + + public Agent(IChatClient client, string systemPrompt) + { + _client = client; + _systemPrompt = systemPrompt; + } + + /// + /// Adds a tool delegate to the agent. + /// + public void AddTool(Delegate toolDelegate, string? name = null, string? description = null) + { + _tools.Add(AIFunctionFactory.Create(toolDelegate, name, description)); + } + + /// + /// Executes the agentic act loop. + /// + /// The goal/instruction for the agent. + /// Callback for real-time execution events. + /// Optional callback to request user approval before running a tool. Return false to reject. + /// The maximum number of execution rounds to prevent infinite loops. + /// Cancellation token. + /// The final response text from the agent. + public async Task ActAsync( + string userPrompt, + Action onEvent, + Func, Task>? approveToolCall = null, + int maxRounds = 15, + CancellationToken cancellationToken = default) + { + var messages = new List + { + new(ChatRole.System, _systemPrompt), + new(ChatRole.User, userPrompt) + }; + + var chatOptions = new ChatOptions + { + Tools = _tools.Cast().ToList() + }; + + for (int round = 1; round <= maxRounds; round++) + { + onEvent(new ActEvent.RoundStarted(round)); + onEvent(new ActEvent.Thinking()); + + ChatResponse response; + try + { + // Ensure to cast/retrieve the result correctly + response = await _client.GetResponseAsync(messages, chatOptions, cancellationToken); + } + catch (Exception ex) + { + string errMsg = $"LLM Error: {ex.Message}"; + onEvent(new ActEvent.ToolExecuted("LLM_CALL", errMsg)); + return errMsg; + } + + var assistantMessage = response.Messages.LastOrDefault() ?? throw new InvalidOperationException("Response contained no messages."); + messages.Add(assistantMessage); + onEvent(new ActEvent.MessageReceived(assistantMessage)); + + // Extract tool calls from assistant message contents + var toolCalls = assistantMessage.Contents.OfType().ToList(); + + // If no tool calls, the model has completed the loop and returned its final response + if (toolCalls.Count == 0) + { + string finalResult = assistantMessage.Text ?? string.Empty; + onEvent(new ActEvent.Completed(finalResult)); + return finalResult; + } + + var toolResultItems = new List(); + + foreach (var toolCall in toolCalls) + { + var args = toolCall.Arguments ?? new Dictionary(); + onEvent(new ActEvent.ToolExecuting(toolCall.Name, args)); + + bool approved = true; + if (approveToolCall != null) + { + approved = await approveToolCall(toolCall.Name, args); + } + + string executionResult; + if (!approved) + { + executionResult = "Error: Execution rejected by user."; + onEvent(new ActEvent.ToolExecuted(toolCall.Name, executionResult)); + } + else + { + try + { + var toolFunction = _tools.FirstOrDefault(t => t.Name.Equals(toolCall.Name, StringComparison.OrdinalIgnoreCase)); + if (toolFunction == null) + { + executionResult = $"Error: Tool '{toolCall.Name}' is not registered."; + } + else + { + object? result = await toolFunction.InvokeAsync(new AIFunctionArguments(args), cancellationToken); + executionResult = result?.ToString() ?? "Success"; + } + } + catch (Exception ex) + { + executionResult = $"Error executing tool: {ex.Message}"; + } + onEvent(new ActEvent.ToolExecuted(toolCall.Name, executionResult)); + } + + toolResultItems.Add(new FunctionResultContent(toolCall.CallId, executionResult)); + } + + // Append all tool results in a single Tool-role message + messages.Add(new ChatMessage(ChatRole.Tool, toolResultItems.Cast().ToList())); + } + + string timeoutMessage = $"Reached maximum number of execution rounds ({maxRounds}) without completing."; + onEvent(new ActEvent.Completed(timeoutMessage)); + return timeoutMessage; + } +} diff --git a/src/AutonomousCoder.Core/AutonomousCoder.Core.csproj b/src/AutonomousCoder.Core/AutonomousCoder.Core.csproj new file mode 100644 index 0000000..e0051cf --- /dev/null +++ b/src/AutonomousCoder.Core/AutonomousCoder.Core.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/AutonomousCoder.Core/CodingTools.cs b/src/AutonomousCoder.Core/CodingTools.cs new file mode 100644 index 0000000..31ec65b --- /dev/null +++ b/src/AutonomousCoder.Core/CodingTools.cs @@ -0,0 +1,397 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace AutonomousCoder.Core; + +/// +/// Contains standard coding tools for autonomous agents. +/// All paths are resolved relative to the configured workspace root. +/// +public class CodingTools +{ + private readonly string _workspaceRoot; + + public CodingTools(string workspaceRoot) + { + _workspaceRoot = Path.GetFullPath(workspaceRoot); + } + + private string ResolvePath(string path) + { + // Handle empty or null path as workspace root + if (string.IsNullOrWhiteSpace(path) || path == "." || path == "./") + { + return _workspaceRoot; + } + + string fullPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(_workspaceRoot, path)); + + // Security check: ensure path does not escape workspace root + if (!fullPath.StartsWith(_workspaceRoot, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException($"Access denied: Path '{path}' is outside the workspace root."); + } + + return fullPath; + } + + private string GetRelativePath(string fullPath) + { + return Path.GetRelativePath(_workspaceRoot, fullPath); + } + + [Description("Lists all files and subdirectories inside a directory, with pagination support.")] + public string ListDirectory( + [Description("The directory path relative to workspace root (use '.' for root).")] string directoryPath, + [Description("Whether to list files recursively.")] bool recursive = false, + [Description("The page number to retrieve (starts at 1).")] int page = 1, + [Description("The number of items to display per page.")] int pageSize = 50) + { + try + { + string targetDir = ResolvePath(directoryPath); + if (!Directory.Exists(targetDir)) + { + return $"Error: Directory '{directoryPath}' does not exist."; + } + + var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + + var items = new List<(string RelativePath, bool IsDirectory, long Size)>(); + + // Get directories + foreach (var dir in Directory.GetDirectories(targetDir, "*", searchOption)) + { + items.Add((GetRelativePath(dir), true, 0)); + } + + // Get files + foreach (var file in Directory.GetFiles(targetDir, "*", searchOption)) + { + var info = new FileInfo(file); + items.Add((GetRelativePath(file), false, info.Length)); + } + + // Sort alphabetically by path + items = items.OrderBy(i => i.RelativePath).ToList(); + + int totalItems = items.Count; + int totalPages = (int)Math.Ceiling((double)totalItems / pageSize); + + if (page < 1) page = 1; + var paginatedItems = items.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + var sb = new StringBuilder(); + sb.AppendLine($"[Directory Listing for '{directoryPath}' | Page {page} of {Math.Max(1, totalPages)} | Total Items: {totalItems}]"); + + if (paginatedItems.Count == 0) + { + sb.AppendLine("No items found on this page."); + } + else + { + foreach (var item in paginatedItems) + { + string typeLabel = item.IsDirectory ? "[DIR]" : "[FILE]"; + string sizeLabel = item.IsDirectory ? "" : $" ({item.Size} bytes)"; + sb.AppendLine($"{typeLabel} {item.RelativePath}{sizeLabel}"); + } + } + + if (page < totalPages) + { + sb.AppendLine($"\n* Note: More items are available on page {page + 1}. Call ListDirectory with page={page + 1} to see them."); + } + + return sb.ToString(); + } + catch (Exception ex) + { + return $"Error listing directory: {ex.Message}"; + } + } + + [Description("Reads a paginated range of lines from a file to avoid context window overflow.")] + public string ReadFile( + [Description("The file path relative to workspace root.")] string filePath, + [Description("The starting line number to read (1-indexed).")] int startLine = 1, + [Description("The number of lines to read.")] int lineCount = 100) + { + try + { + string targetFile = ResolvePath(filePath); + if (!File.Exists(targetFile)) + { + return $"Error: File '{filePath}' does not exist."; + } + + string[] lines = File.ReadAllLines(targetFile); + int totalLines = lines.Length; + + if (startLine < 1) startLine = 1; + if (lineCount < 1) lineCount = 100; + + int skip = startLine - 1; + var paginatedLines = lines.Skip(skip).Take(lineCount).ToList(); + int endLine = Math.Min(totalLines, skip + paginatedLines.Count); + + var sb = new StringBuilder(); + sb.AppendLine($"[File: {filePath} | Lines {startLine} to {endLine} of {totalLines} | HasMore: {endLine < totalLines}]"); + sb.AppendLine(new string('-', 40)); + + for (int i = 0; i < paginatedLines.Count; i++) + { + int currentLineNum = startLine + i; + sb.AppendLine($"{currentLineNum:D4}: {paginatedLines[i]}"); + } + + sb.AppendLine(new string('-', 40)); + if (endLine < totalLines) + { + sb.AppendLine($"* Note: More lines are available. Call ReadFile with startLine={endLine + 1} to read further."); + } + + return sb.ToString(); + } + catch (Exception ex) + { + return $"Error reading file: {ex.Message}"; + } + } + + [Description("Creates a new file or overwrites an existing file with the specified content.")] + public string WriteFile( + [Description("The file path relative to workspace root.")] string filePath, + [Description("The full content to write to the file.")] string content) + { + try + { + string targetFile = ResolvePath(filePath); + string? directory = Path.GetDirectoryName(targetFile); + + if (directory != null && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(targetFile, content); + return $"Success: Written {content.Length} characters to file '{filePath}'."; + } + catch (Exception ex) + { + return $"Error writing file: {ex.Message}"; + } + } + + [Description("Finds a specific target content block in a file and replaces it with a replacement content block. The target content must match exactly and appear exactly once in the file.")] + public string ReplaceInFile( + [Description("The file path relative to workspace root.")] string filePath, + [Description("The exact block of code/content in the file to be replaced.")] string targetContent, + [Description("The new block of code/content to replace it with.")] string replacementContent) + { + try + { + string targetFile = ResolvePath(filePath); + if (!File.Exists(targetFile)) + { + return $"Error: File '{filePath}' does not exist."; + } + + string fileContent = File.ReadAllText(targetFile); + + // Check number of occurrences + int firstIndex = fileContent.IndexOf(targetContent, StringComparison.Ordinal); + if (firstIndex == -1) + { + return "Error: Target content block not found in the file. Ensure that whitespaces, tabs, and line endings match exactly."; + } + + int lastIndex = fileContent.LastIndexOf(targetContent, StringComparison.Ordinal); + if (firstIndex != lastIndex) + { + return "Error: Multiple occurrences of the target content block were found. Make your target content block larger and more unique to target exactly one occurrence."; + } + + // Perform single replacement + string updatedContent = fileContent.Remove(firstIndex, targetContent.Length) + .Insert(firstIndex, replacementContent); + + File.WriteAllText(targetFile, updatedContent); + return $"Success: Successfully replaced code block in '{filePath}'."; + } + catch (Exception ex) + { + return $"Error replacing in file: {ex.Message}"; + } + } + + [Description("Searches for a text pattern in all files under a path (grep), returning paginated results.")] + public string SearchFiles( + [Description("The text pattern or search term.")] string pattern, + [Description("The directory path relative to workspace root (use '.' for root).")] string searchPath = ".", + [Description("The page number of matches to retrieve (starts at 1).")] int page = 1, + [Description("The number of matches to return per page.")] int pageSize = 50) + { + try + { + string targetDir = ResolvePath(searchPath); + if (!Directory.Exists(targetDir)) + { + return $"Error: Directory '{searchPath}' does not exist."; + } + + var matches = new List<(string File, int LineNumber, string LineText)>(); + + // Get all files recursively + var files = Directory.GetFiles(targetDir, "*", SearchOption.AllDirectories); + + foreach (var file in files) + { + // Skip common binary/build directories + string relativePath = GetRelativePath(file); + if (relativePath.Contains("/bin/") || + relativePath.Contains("/obj/") || + relativePath.Contains("/.git/") || + relativePath.Contains("/.dotnet/")) + { + continue; + } + + try + { + string[] lines = File.ReadAllLines(file); + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].Contains(pattern, StringComparison.OrdinalIgnoreCase)) + { + matches.Add((relativePath, i + 1, lines[i].Trim())); + } + } + } + catch (IOException) + { + // Skip files that can't be read (e.g. binary files causing issues) + } + } + + int totalMatches = matches.Count; + int totalPages = (int)Math.Ceiling((double)totalMatches / pageSize); + + if (page < 1) page = 1; + var paginatedMatches = matches.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + var sb = new StringBuilder(); + sb.AppendLine($"[Search results for '{pattern}' under '{searchPath}' | Page {page} of {Math.Max(1, totalPages)} | Total Matches: {totalMatches}]"); + sb.AppendLine(new string('-', 40)); + + if (paginatedMatches.Count == 0) + { + sb.AppendLine("No matches found."); + } + else + { + foreach (var match in paginatedMatches) + { + sb.AppendLine($"{match.File}:{match.LineNumber}: {match.LineText}"); + } + } + + sb.AppendLine(new string('-', 40)); + if (page < totalPages) + { + sb.AppendLine($"* Note: More matches are available. Call SearchFiles with page={page + 1} to read them."); + } + + return sb.ToString(); + } + catch (Exception ex) + { + return $"Error searching files: {ex.Message}"; + } + } + + [Description("Executes a shell command in the workspace directory and returns its stdout and stderr.")] + public string RunCommand( + [Description("The command line string to run (e.g. 'dotnet build' or 'dotnet test').")] string command) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"-c \"{command.Replace("\"", "\\\"")}\"", + WorkingDirectory = _workspaceRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + // Set PATH to include local .dotnet folder so command can execute 'dotnet' + string localDotnetPath = Path.Combine(_workspaceRoot, ".dotnet"); + if (Directory.Exists(localDotnetPath)) + { + string existingPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = $"{localDotnetPath}:{existingPath}"; + } + + using var process = new Process { StartInfo = psi }; + + var stdoutBuilder = new StringBuilder(); + var stderrBuilder = new StringBuilder(); + + process.OutputDataReceived += (s, e) => { if (e.Data != null) stdoutBuilder.AppendLine(e.Data); }; + process.ErrorDataReceived += (s, e) => { if (e.Data != null) stderrBuilder.AppendLine(e.Data); }; + + process.Start(); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Wait with a 60 second timeout to prevent hanging commands + bool completed = process.WaitForExit(60000); + + if (!completed) + { + process.Kill(); + return $"Error: Command timed out after 60 seconds."; + } + + // Cap output sizes to prevent flooding model context + string stdout = stdoutBuilder.ToString(); + string stderr = stderrBuilder.ToString(); + + const int maxChars = 8000; + if (stdout.Length > maxChars) + { + stdout = stdout[..maxChars] + $"\n... [Output truncated. Total characters: {stdout.Length}]"; + } + if (stderr.Length > maxChars) + { + stderr = stderr[..maxChars] + $"\n... [Error output truncated. Total characters: {stderr.Length}]"; + } + + var sb = new StringBuilder(); + sb.AppendLine($"[Command Executed: {command}]"); + sb.AppendLine($"[Exit Code: {process.ExitCode}]"); + sb.AppendLine("[Standard Output]"); + sb.AppendLine(stdout); + if (!string.IsNullOrWhiteSpace(stderr)) + { + sb.AppendLine("[Standard Error]"); + sb.AppendLine(stderr); + } + + return sb.ToString(); + } + catch (Exception ex) + { + return $"Error running command: {ex.Message}"; + } + } +} diff --git a/src/AutonomousCoder.Core/LmsClientHelper.cs b/src/AutonomousCoder.Core/LmsClientHelper.cs new file mode 100644 index 0000000..4adc876 --- /dev/null +++ b/src/AutonomousCoder.Core/LmsClientHelper.cs @@ -0,0 +1,75 @@ +using System.ClientModel; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace AutonomousCoder.Core; + +public class LmsClientHelper +{ + private static readonly HttpClient HttpClient = new(); + + /// + /// Fetches the list of models currently loaded in LM Studio / OpenAI-compatible endpoint. + /// + public static async Task> GetLoadedModelsAsync(string endpointUrl, CancellationToken cancellationToken = default) + { + try + { + // Normalize endpoint URL + string baseUrl = endpointUrl.TrimEnd('/'); + string modelsUrl = $"{baseUrl}/models"; + + using var request = new HttpRequestMessage(HttpMethod.Get, modelsUrl); + using var response = await HttpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return Array.Empty(); + } + + string content = await response.Content.ReadAsStringAsync(cancellationToken); + var modelsResponse = JsonSerializer.Deserialize(content); + + if (modelsResponse?.Data == null) + { + return Array.Empty(); + } + + return modelsResponse.Data.Select(m => m.Id).ToList(); + } + catch + { + // Fallback when endpoint is not reachable or doesn't support /models + return Array.Empty(); + } + } + + /// + /// Creates and configures an IChatClient pointing to the custom OpenAI-compatible endpoint. + /// + public static IChatClient CreateChatClient(string endpointUrl, string modelName, string apiKey = "lm-studio") + { + var options = new OpenAIClientOptions + { + Endpoint = new Uri(endpointUrl) + }; + + var openAIClient = new OpenAIClient(new ApiKeyCredential(apiKey), options); + return openAIClient.GetChatClient(modelName).AsIChatClient(); + } + + private class ModelsListResponse + { + [JsonPropertyName("data")] + public List? Data { get; set; } + } + + private class ModelInfo + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + } +} diff --git a/src/AutonomousCoder.slnx b/src/AutonomousCoder.slnx new file mode 100644 index 0000000..b372476 --- /dev/null +++ b/src/AutonomousCoder.slnx @@ -0,0 +1,4 @@ + + + +