src/TechToolbox.Agent/Execution/PowerShellBridge.cs
|
using System.Management.Automation;
using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using TechToolbox.Agent.Agent; namespace TechToolbox.Agent.Execution; public static class PowerShellBridge { private static readonly JsonSerializerOptions SummaryJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public static object? RunTool(string toolName, IDictionary<string, object?> args) { if (string.IsNullOrWhiteSpace(toolName)) throw new ArgumentException("Tool name must not be empty.", nameof(toolName)); if (TryRunBuiltInTool(toolName, args, out var builtInResult)) return builtInResult; // Safety check for destructive tools Safety.RequireDestructiveConfirmation(toolName, args); using var ps = PowerShell.Create(); // Import the TechToolbox module explicitly // (Assumes the module is discoverable via PSModulePath) ps.AddCommand("Import-Module") .AddParameter("Name", "TechToolbox") .AddParameter("ErrorAction", "Stop") .Invoke(); if (ps.HadErrors) throw new InvalidOperationException($"Failed to import TechToolbox module: {ps.Streams.Error[0]}"); ps.Commands.Clear(); // Add the tool command ps.AddCommand(toolName); // Add parameters foreach (var kv in args) { // Skip internal agent control keys if (kv.Key.StartsWith("__", StringComparison.Ordinal)) continue; ps.AddParameter(kv.Key, kv.Value); } // Execute var results = ps.Invoke(); if (ps.HadErrors) throw new InvalidOperationException($"Tool '{toolName}' failed: {ps.Streams.Error[0]}"); // Normalize output if (results is null || results.Count == 0) return null; if (results.Count == 1) return results[0].BaseObject; return results.Select(r => r.BaseObject).ToList(); } private static bool TryRunBuiltInTool(string toolName, IDictionary<string, object?> args, out object? result) { result = null; if (toolName.Equals("READ-FILE", StringComparison.OrdinalIgnoreCase)) { var path = GetRequiredStringArg(args, "path"); if (!File.Exists(path)) throw new FileNotFoundException($"File not found: {path}", path); var content = File.ReadAllText(path); result = ShouldSummarizeFile(content) ? BuildFileSummaryJson(path, content) : content; return true; } if (toolName.Equals("LIST-DIRECTORY", StringComparison.OrdinalIgnoreCase)) { var path = GetRequiredStringArg(args, "path"); if (!Directory.Exists(path)) throw new DirectoryNotFoundException($"Directory not found: {path}"); var entries = Directory .EnumerateFileSystemEntries(path) .Select(p => { var name = Path.GetFileName(p); return Directory.Exists(p) ? $"{name}/" : name; }) .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) .ToList(); result = entries; return true; } if (toolName.Equals("WRITE-FILE", StringComparison.OrdinalIgnoreCase)) { var path = GetRequiredStringArg(args, "path"); var content = GetRequiredStringArg(args, "content"); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir)) { Directory.CreateDirectory(dir); } File.WriteAllText(path, content); result = "ok"; return true; } return false; } private static string GetRequiredStringArg(IDictionary<string, object?> args, string name) { var arg = args.FirstOrDefault(kv => string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)); var value = arg.Value; string? text = value switch { null => null, string s => s, System.Text.Json.JsonElement el when el.ValueKind == System.Text.Json.JsonValueKind.String => el.GetString(), System.Text.Json.JsonElement el => el.ToString(), _ => value.ToString() }; if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException($"Missing required parameter '{name}'.", name); return text; } private static bool ShouldSummarizeFile(string content) { var threshold = GetReadFileSummaryThresholdChars(); return content.Length > threshold; } private static int GetReadFileSummaryThresholdChars() { const int defaultChars = 12000; const int minChars = 1000; const int maxChars = 200_000; var raw = Environment.GetEnvironmentVariable("TT_AGENT_READ_FILE_SUMMARY_THRESHOLD_CHARS"); if (int.TryParse(raw, out var parsed)) { return Math.Clamp(parsed, minChars, maxChars); } return defaultChars; } private static string BuildFileSummaryJson(string path, string content) { var lines = SplitLines(content); var head = lines.Take(12).ToArray(); var tail = lines.Length <= 12 ? Array.Empty<string>() : lines.Skip(Math.Max(0, lines.Length - 12)).ToArray(); var sectionHeadings = ExtractSectionHeadings(lines); var functionNames = ExtractFunctionNames(lines); var summary = new FileSummaryResult( Kind: "file-summary", Path: path, FileName: System.IO.Path.GetFileName(path), Extension: System.IO.Path.GetExtension(path), SizeBytes: Encoding.UTF8.GetByteCount(content), LineCount: lines.Length, Sections: sectionHeadings, FunctionNames: functionNames, Head: head, Tail: tail); return JsonSerializer.Serialize(summary, SummaryJsonOptions); } private static string[] SplitLines(string content) => content.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); private static string[] ExtractSectionHeadings(IEnumerable<string> lines) { var headings = new List<string>(); var sectionRegex = new Regex(@"^\s*(?:#\s*)?\.(?<name>[A-Z][A-Z0-9_-]*)\b", RegexOptions.Compiled); foreach (var line in lines) { var match = sectionRegex.Match(line); if (!match.Success) continue; var heading = match.Groups["name"].Value; if (!headings.Contains(heading, StringComparer.OrdinalIgnoreCase)) { headings.Add(heading); } } return headings.ToArray(); } private static string[] ExtractFunctionNames(IEnumerable<string> lines) { var names = new List<string>(); var functionRegex = new Regex(@"^\s*function\s+(?<name>[A-Za-z_][A-Za-z0-9_-]*)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); foreach (var line in lines) { var match = functionRegex.Match(line); if (!match.Success) continue; var name = match.Groups["name"].Value; if (!names.Contains(name, StringComparer.OrdinalIgnoreCase)) { names.Add(name); } } return names.ToArray(); } private sealed record FileSummaryResult( string Kind, string Path, string FileName, string Extension, long SizeBytes, int LineCount, string[] Sections, string[] FunctionNames, string[] Head, string[] Tail); } |