Skip to content

Commit 8621de1

Browse files
authored
Add output truncation for large command results to preserve AI context window (#34)
* Add OutputTruncationHelper for managing large command outputs - Introduced OutputTruncationHelper class to truncate large outputs, saving full content to a temporary file for retrieval. - Updated PowerShellTools to utilize OutputTruncationHelper for response handling, ensuring outputs are truncated when exceeding the defined threshold. - Added unit tests for OutputTruncationHelper to validate functionality, including edge cases for output size and file saving. * Enhance OutputTruncationHelper with validation for truncation threshold * Move OutputTruncationHelper to shared library and apply truncation in DLL module Truncation now happens in PowerShellCommunication.NotifyResultReady() before caching, so only the truncated preview crosses the named pipe — reducing pipe transfer size and memory overhead. Full output is saved to a temp file on the DLL side. - Add PowerShell.MCP.Shared project with OutputTruncationHelper (threshold 15K) - Remove OutputTruncationHelper from Proxy/Helpers (no longer needed there) - Add NotifySilentResultReady() for small known-output internal paths - Update project references and solution file - Move tests to Tests/Unit/Shared/ * Remove PowerShell.MCP.Shared project, keep truncation in DLL only Truncation in NotifyResultReady() means the Proxy already receives truncated output, so the 12 TruncateIfNeeded calls on the Proxy side and the Shared project are unnecessary. - Move OutputTruncationHelper into PowerShell.MCP (DLL project) - Remove all TruncateIfNeeded calls from PowerShellTools.cs - Remove PowerShell.MCP.Shared project and all references - Fix truncation message: Get-Content → Show-TextFiles (avoids re-truncation loop) - Strengthen newline alignment test assertions to verify actual boundary positions instead of vacuously passing on metadata newlines
1 parent cc0ac3a commit 8621de1

6 files changed

Lines changed: 464 additions & 2 deletions

File tree

PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,5 @@
6161
<ItemGroup>
6262
<EmbeddedResource Include="Prompts\Templates\*.md" />
6363
</ItemGroup>
64+
6465
</Project>
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using System.Diagnostics;
2+
3+
namespace PowerShell.MCP;
4+
5+
/// <summary>
6+
/// Truncates large command output to preserve AI context window budget.
7+
/// Saves the full output to a temp file so the AI can retrieve it via Read tool if needed.
8+
/// </summary>
9+
public static class OutputTruncationHelper
10+
{
11+
internal const int TruncationThreshold = 15_000;
12+
internal const int PreviewHeadSize = 1000;
13+
internal const int PreviewTailSize = 1000;
14+
15+
// The threshold must exceed the combined preview sizes; otherwise the head
16+
// and tail slices overlap, producing duplicated content in the preview.
17+
private static readonly bool _ = Validate();
18+
private static bool Validate()
19+
{
20+
Debug.Assert(
21+
TruncationThreshold > PreviewHeadSize + PreviewTailSize,
22+
$"TruncationThreshold ({TruncationThreshold}) must be greater than " +
23+
$"PreviewHeadSize + PreviewTailSize ({PreviewHeadSize + PreviewTailSize}) " +
24+
"to avoid overlapping head/tail previews.");
25+
return true;
26+
}
27+
internal const string OutputDirectoryName = "PowerShell.MCP.Output";
28+
internal const int MaxFileAgeMinutes = 120;
29+
internal const int NewlineScanLimit = 200;
30+
31+
/// <summary>
32+
/// Returns the output unchanged if within threshold, otherwise saves the full content
33+
/// to a temp file and returns a head+tail preview with the file path.
34+
/// </summary>
35+
public static string TruncateIfNeeded(string output, string? outputDirectory = null)
36+
{
37+
if (output.Length <= TruncationThreshold)
38+
return output;
39+
40+
// Compute newline-aligned head boundary
41+
var headEnd = FindHeadBoundary(output, PreviewHeadSize);
42+
var head = output[..headEnd];
43+
44+
// Compute newline-aligned tail boundary
45+
var tailStart = FindTailBoundary(output, PreviewTailSize);
46+
var tail = output[tailStart..];
47+
48+
var omitted = output.Length - head.Length - tail.Length;
49+
50+
var filePath = SaveOutputToFile(output, outputDirectory);
51+
52+
var sb = new System.Text.StringBuilder();
53+
54+
if (filePath != null)
55+
{
56+
sb.AppendLine($"Output too large ({output.Length} characters). Full output saved to: {filePath}");
57+
sb.AppendLine($"Use invoke_expression('Show-TextFiles \"{filePath}\" -Contains \"search term\"') or -Pattern \"regex\" to search the output.");
58+
}
59+
else
60+
{
61+
// Disk save failed — still provide the preview without a file path
62+
sb.AppendLine($"Output too large ({output.Length} characters). Could not save full output to file.");
63+
}
64+
65+
sb.AppendLine();
66+
sb.AppendLine("--- Preview (first ~1000 chars) ---");
67+
sb.AppendLine(head);
68+
sb.AppendLine($"--- truncated ({omitted} chars omitted) ---");
69+
sb.AppendLine("--- Preview (last ~1000 chars) ---");
70+
sb.Append(tail);
71+
72+
return sb.ToString();
73+
}
74+
75+
/// <summary>
76+
/// Saves the full output to a timestamped temp file and triggers opportunistic cleanup.
77+
/// Returns the file path on success, null on failure.
78+
/// </summary>
79+
internal static string? SaveOutputToFile(string output, string? outputDirectory = null)
80+
{
81+
try
82+
{
83+
var directory = outputDirectory
84+
?? Path.Combine(Path.GetTempPath(), OutputDirectoryName);
85+
86+
Directory.CreateDirectory(directory);
87+
88+
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff");
89+
var random = Path.GetRandomFileName();
90+
var fileName = $"pwsh_output_{timestamp}_{random}.txt";
91+
var filePath = Path.Combine(directory, fileName);
92+
93+
File.WriteAllText(filePath, output);
94+
95+
// Opportunistic cleanup — never let it block or fail the save
96+
CleanupOldOutputFiles(directory);
97+
98+
return filePath;
99+
}
100+
catch
101+
{
102+
return null;
103+
}
104+
}
105+
106+
/// <summary>
107+
/// Deletes pwsh_output_*.txt files older than <see cref="MaxFileAgeMinutes"/> minutes.
108+
/// Each deletion is individually guarded so a locked file does not prevent other cleanups.
109+
/// </summary>
110+
internal static void CleanupOldOutputFiles(string? directory = null)
111+
{
112+
try
113+
{
114+
var dir = directory
115+
?? Path.Combine(Path.GetTempPath(), OutputDirectoryName);
116+
117+
if (!Directory.Exists(dir))
118+
return;
119+
120+
var cutoff = DateTime.Now.AddMinutes(-MaxFileAgeMinutes);
121+
122+
foreach (var file in Directory.EnumerateFiles(dir, "pwsh_output_*.txt"))
123+
{
124+
try
125+
{
126+
if (File.GetLastWriteTime(file) < cutoff)
127+
File.Delete(file);
128+
}
129+
catch (IOException)
130+
{
131+
// Another thread may be writing — safe to ignore
132+
}
133+
}
134+
}
135+
catch
136+
{
137+
// Directory enumeration itself failed — nothing to clean up
138+
}
139+
}
140+
141+
/// <summary>
142+
/// Finds a head cut position aligned to the nearest preceding newline within scan limit.
143+
/// </summary>
144+
private static int FindHeadBoundary(string output, int nominalSize)
145+
{
146+
if (nominalSize >= output.Length)
147+
return output.Length;
148+
149+
// Search backward from nominalSize for a newline, up to NewlineScanLimit chars
150+
var searchStart = Math.Max(0, nominalSize - NewlineScanLimit);
151+
var lastNewline = output.LastIndexOf('\n', nominalSize - 1, nominalSize - searchStart);
152+
153+
// Cut after the newline to keep complete lines in the head
154+
return lastNewline >= 0 ? lastNewline + 1 : nominalSize;
155+
}
156+
157+
/// <summary>
158+
/// Finds a tail start position aligned to the nearest following newline within scan limit.
159+
/// </summary>
160+
private static int FindTailBoundary(string output, int nominalSize)
161+
{
162+
var nominalStart = output.Length - nominalSize;
163+
if (nominalStart <= 0)
164+
return 0;
165+
166+
// Search forward from nominalStart for a newline, up to NewlineScanLimit chars
167+
var searchEnd = Math.Min(output.Length, nominalStart + NewlineScanLimit);
168+
var nextNewline = output.IndexOf('\n', nominalStart, searchEnd - nominalStart);
169+
170+
// Start at the character after the newline to begin on a fresh line
171+
return nextNewline >= 0 ? nextNewline + 1 : nominalStart;
172+
}
173+
}

PowerShell.MCP/PowerShell.MCP.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
<NoWarn>$(NoWarn);NETSDK1206</NoWarn>
99
</PropertyGroup>
1010

11+
<ItemGroup>
12+
<InternalsVisibleTo Include="PowerShell.MCP.Tests" />
13+
</ItemGroup>
14+
1115
<ItemGroup>
1216
<None Remove="Resources\MCPLocationProvider.ps1" />
1317
<None Remove="Resources\MCPPollingEngine.ps1" />

PowerShell.MCP/Resources/MCPPollingEngine.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ if (-not (Test-Path Variable:global:McpTimer)) {
534534
if ($null -eq $mcpOutput) {
535535
$mcpOutput = "Command execution completed"
536536
}
537-
[PowerShell.MCP.Services.PowerShellCommunication]::NotifyResultReady($mcpOutput)
537+
[PowerShell.MCP.Services.PowerShellCommunication]::NotifySilentResultReady($mcpOutput)
538538
}
539539
}
540540
} | Out-Null

PowerShell.MCP/Services/PowerShellCommunication.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,18 @@ public static void NotifyResultReady(string result)
1717
// Capture cache flag before AddToCache resets it
1818
_resultShouldCache = ExecutionState.ShouldCacheOutput;
1919

20-
// Always add to cache
20+
// Truncate large output before caching to reduce pipe transfer and memory overhead
21+
var output = OutputTruncationHelper.TruncateIfNeeded(result);
22+
ExecutionState.AddToCache(output);
23+
ExecutionState.CompleteExecution();
24+
_resultReadyEvent.Set();
25+
}
26+
27+
/// <summary>
28+
/// Silent command completed — no truncation (internal use, small known outputs)
29+
/// </summary>
30+
public static void NotifySilentResultReady(string result)
31+
{
2132
ExecutionState.AddToCache(result);
2233
ExecutionState.CompleteExecution();
2334
_resultReadyEvent.Set();

0 commit comments

Comments
 (0)