feat: Introduce a /reset command to clear the chat session and token tracking, and update documentation.
This commit is contained in:
@@ -11,7 +11,7 @@ internal sealed class ChatSession
|
|||||||
public ChatSession(IChatClient innerClient)
|
public ChatSession(IChatClient innerClient)
|
||||||
{
|
{
|
||||||
Compactor = new ContextCompactor(innerClient);
|
Compactor = new ContextCompactor(innerClient);
|
||||||
|
|
||||||
var tools = ToolRegistry.GetTools();
|
var tools = ToolRegistry.GetTools();
|
||||||
_agent = new ChatClientBuilder(innerClient)
|
_agent = new ChatClientBuilder(innerClient)
|
||||||
.UseFunctionInvocation()
|
.UseFunctionInvocation()
|
||||||
@@ -34,23 +34,36 @@ internal sealed class ChatSession
|
|||||||
Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file.
|
Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
1. Always read a file before editing it.
|
1. ALWAYS call GrepFile before ReadFile on any source file.
|
||||||
2. After a mutation, verify the returned fingerprint.
|
Use patterns like "public|func|function|class|interface|enum|def|fn " to get a structural
|
||||||
|
outline of the file and identify the exact line numbers of the section you need.
|
||||||
|
Only then call ReadFile with a targeted startLine/endLine range.
|
||||||
|
❌ WRONG: ReadFile("Foo.cs") — reads blindly without knowing the structure.
|
||||||
|
✅ RIGHT: GrepFile("Foo.cs", "public|class|interface") → ReadFile("Foo.cs", 42, 90)
|
||||||
|
2. After reading, edit the file before verifying the returned fingerprint.
|
||||||
3. Edit from bottom to top so line numbers don't shift.
|
3. Edit from bottom to top so line numbers don't shift.
|
||||||
4. If an anchor fails validation, re-read the file to get fresh anchors.
|
4. If an anchor fails validation, re-read the relevant range to get fresh anchors.
|
||||||
|
|
||||||
Keep responses concise. You have access to the current working directory.
|
Keep responses concise. You have access to the current working directory.
|
||||||
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
||||||
""")
|
""")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
// Keep only the system message
|
||||||
|
var systemMessage = History[0];
|
||||||
|
History.Clear();
|
||||||
|
History.Add(systemMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = new ChatOptions { Tools = ToolRegistry.GetTools() };
|
var options = new ChatOptions { Tools = ToolRegistry.GetTools() };
|
||||||
var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken);
|
var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken);
|
||||||
|
|
||||||
await foreach (var update in stream.WithCancellation(cancellationToken))
|
await foreach (var update in stream.WithCancellation(cancellationToken))
|
||||||
{
|
{
|
||||||
yield return update;
|
yield return update;
|
||||||
|
|||||||
21
Commands/ResetCommand.cs
Normal file
21
Commands/ResetCommand.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Spectre.Console;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
|
internal class ResetCommand(ChatSession session, TokenTracker tokenTracker) : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "reset";
|
||||||
|
public string Description => "Reset the chat session (clear history and token count)";
|
||||||
|
|
||||||
|
public Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
session.Reset();
|
||||||
|
tokenTracker.Reset();
|
||||||
|
AnsiConsole.MarkupLine("[green]Chat session reset.[/]");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,205 +1,201 @@
|
|||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace AnchorCli;
|
namespace AnchorCli;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compacts the chat history when context usage grows too large.
|
/// Compacts the chat history when context usage grows too large.
|
||||||
/// Asks the same model to summarize the conversation, then replaces
|
/// Asks the same model to summarize the conversation, then replaces
|
||||||
/// the history with [system prompt, summary, last N user/assistant turns].
|
/// the history with [system prompt, summary, last N user/assistant turns].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class ContextCompactor(IChatClient client)
|
internal sealed partial class ContextCompactor(IChatClient client)
|
||||||
{
|
{
|
||||||
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
|
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
|
||||||
private const int KeepRecentTurns = 2;
|
private const int KeepRecentTurns = 2;
|
||||||
|
|
||||||
/// <summary>Minimum result length to consider for compaction.</summary>
|
/// <summary>Minimum result length to consider for compaction.</summary>
|
||||||
private const int MinResultLength = 300;
|
private const int MinResultLength = 300;
|
||||||
|
|
||||||
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
|
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
|
||||||
private static readonly Regex HashlinePattern =
|
private static readonly Regex HashlinePattern =
|
||||||
MyRegex();
|
MyRegex();
|
||||||
|
|
||||||
private readonly IChatClient _client = client;
|
private readonly IChatClient _client = client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compacts large hashline-encoded tool results from previous turns.
|
/// Compacts large hashline-encoded tool results from previous turns.
|
||||||
/// This is the cheapest and most impactful optimization — no LLM call needed.
|
/// This is the cheapest and most impactful optimization — no LLM call needed.
|
||||||
/// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens).
|
/// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="history">The chat history to compact in-place.</param>
|
/// <param name="history">The chat history to compact in-place.</param>
|
||||||
/// <param name="currentTurnStartIndex">
|
/// <returns>Number of tool results that were compacted.</returns>
|
||||||
/// Index of the first message added during the current turn.
|
public static int CompactStaleToolResults(List<ChatMessage> history)
|
||||||
/// Messages before this index are from previous turns and eligible for compaction.
|
{
|
||||||
/// </param>
|
int compacted = 0;
|
||||||
/// <returns>Number of tool results that were compacted.</returns>
|
int userTurnsSeen = 0;
|
||||||
public static int CompactStaleToolResults(List<ChatMessage> history)
|
var filesRead = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
int compacted = 0;
|
// Walk backwards: index 0 is system prompt, so we stop at 1.
|
||||||
int userTurnsSeen = 0;
|
for (int i = history.Count - 1; i >= 1; i--)
|
||||||
var filesRead = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
{
|
||||||
|
var msg = history[i];
|
||||||
// Walk backwards: index 0 is system prompt, so we stop at 1.
|
|
||||||
for (int i = history.Count - 1; i >= 1; i--)
|
if (msg.Role == ChatRole.User)
|
||||||
{
|
{
|
||||||
var msg = history[i];
|
userTurnsSeen++;
|
||||||
|
}
|
||||||
if (msg.Role == ChatRole.User)
|
else if (msg.Role == ChatRole.Assistant)
|
||||||
{
|
{
|
||||||
userTurnsSeen++;
|
// Find all FunctionCalls in this assistant message to map call IDs to file paths
|
||||||
}
|
var calls = msg.Contents.OfType<FunctionCallContent>().ToList();
|
||||||
else if (msg.Role == ChatRole.Assistant)
|
|
||||||
{
|
// If the very next message is a System/Tool role containing FunctionResults
|
||||||
// Find all FunctionCalls in this assistant message to map call IDs to file paths
|
if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool)
|
||||||
var calls = msg.Contents.OfType<FunctionCallContent>().ToList();
|
{
|
||||||
|
var resultMsg = history[i + 1];
|
||||||
// If the very next message is a System/Tool role containing FunctionResults
|
foreach (var content in resultMsg.Contents)
|
||||||
if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool)
|
{
|
||||||
{
|
if (content is FunctionResultContent frc &&
|
||||||
var resultMsg = history[i + 1];
|
frc.Result is string resultStr &&
|
||||||
foreach (var content in resultMsg.Contents)
|
resultStr.Length >= MinResultLength &&
|
||||||
{
|
HashlinePattern.IsMatch(resultStr))
|
||||||
if (content is FunctionResultContent frc &&
|
{
|
||||||
frc.Result is string resultStr &&
|
// Find the corresponding function call to check its name/arguments
|
||||||
resultStr.Length >= MinResultLength &&
|
var call = calls.FirstOrDefault(c => c.CallId == frc.CallId);
|
||||||
HashlinePattern.IsMatch(resultStr))
|
if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj))
|
||||||
{
|
{
|
||||||
// Find the corresponding function call to check its name/arguments
|
string filePath = pathObj?.ToString() ?? "";
|
||||||
var call = calls.FirstOrDefault(c => c.CallId == frc.CallId);
|
|
||||||
if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj))
|
bool shouldRedact = false;
|
||||||
{
|
string reason = "";
|
||||||
string filePath = pathObj?.ToString() ?? "";
|
|
||||||
|
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one.
|
||||||
bool shouldRedact = false;
|
if (filesRead.Contains(filePath))
|
||||||
string reason = "";
|
{
|
||||||
|
shouldRedact = true;
|
||||||
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one.
|
reason = "deduplication — you read this file again later";
|
||||||
if (filesRead.Contains(filePath))
|
}
|
||||||
{
|
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
|
||||||
shouldRedact = true;
|
else if (userTurnsSeen >= 2)
|
||||||
reason = "deduplication — you read this file again later";
|
{
|
||||||
}
|
shouldRedact = true;
|
||||||
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
|
reason = "expired — read over 2 turns ago";
|
||||||
else if (userTurnsSeen >= 2)
|
}
|
||||||
{
|
|
||||||
shouldRedact = true;
|
if (shouldRedact)
|
||||||
reason = "expired — read over 2 turns ago";
|
{
|
||||||
}
|
int lineCount = resultStr.Count(c => c == '\n');
|
||||||
|
frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]";
|
||||||
if (shouldRedact)
|
compacted++;
|
||||||
{
|
}
|
||||||
int lineCount = resultStr.Count(c => c == '\n');
|
else
|
||||||
frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]";
|
{
|
||||||
compacted++;
|
// Keep it, but mark that we've seen it so older reads of the same file are redacted.
|
||||||
}
|
filesRead.Add(filePath);
|
||||||
else
|
}
|
||||||
{
|
}
|
||||||
// Keep it, but mark that we've seen it so older reads of the same file are redacted.
|
}
|
||||||
filesRead.Add(filePath);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
return compacted;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
return compacted;
|
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
|
||||||
}
|
/// </summary>
|
||||||
|
public async Task<bool> TryCompactAsync(
|
||||||
/// <summary>
|
List<ChatMessage> history,
|
||||||
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
|
CancellationToken ct = default)
|
||||||
/// </summary>
|
{
|
||||||
public async Task<bool> TryCompactAsync(
|
// Need at least: system + some conversation to compact
|
||||||
List<ChatMessage> history,
|
if (history.Count < 5)
|
||||||
CancellationToken ct = default)
|
return false;
|
||||||
{
|
|
||||||
// Need at least: system + some conversation to compact
|
// Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim)
|
||||||
if (history.Count < 5)
|
var systemMsg = history[0];
|
||||||
return false;
|
|
||||||
|
// Find the cut point: keep the last N user+assistant pairs
|
||||||
// Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim)
|
int keepFromIndex = FindKeepIndex(history);
|
||||||
var systemMsg = history[0];
|
if (keepFromIndex <= 1)
|
||||||
|
return false; // Nothing to compact
|
||||||
// Find the cut point: keep the last N user+assistant pairs
|
|
||||||
int keepFromIndex = FindKeepIndex(history);
|
// Extract the middle section to summarize
|
||||||
if (keepFromIndex <= 1)
|
var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList();
|
||||||
return false; // Nothing to compact
|
var tail = history.Skip(keepFromIndex).ToList();
|
||||||
|
|
||||||
// Extract the middle section to summarize
|
// Build a summarization prompt
|
||||||
var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList();
|
var summaryMessages = new List<ChatMessage>
|
||||||
var tail = history.Skip(keepFromIndex).ToList();
|
{
|
||||||
|
new(ChatRole.System, """
|
||||||
// Build a summarization prompt
|
You are a conversation summarizer. Summarize the following coding conversation
|
||||||
var summaryMessages = new List<ChatMessage>
|
between a user and an AI assistant. Focus on:
|
||||||
{
|
- What files were read, created, or modified (and their paths)
|
||||||
new(ChatRole.System, """
|
- What changes were made and why
|
||||||
You are a conversation summarizer. Summarize the following coding conversation
|
- Any decisions or preferences expressed by the user
|
||||||
between a user and an AI assistant. Focus on:
|
- Current state of the work (what's done, what's pending)
|
||||||
- What files were read, created, or modified (and their paths)
|
Be concise but preserve all actionable context. Output a single summary paragraph.
|
||||||
- What changes were made and why
|
Do NOT include any hashline anchors (lineNumber:hash|) in your summary.
|
||||||
- Any decisions or preferences expressed by the user
|
""")
|
||||||
- Current state of the work (what's done, what's pending)
|
};
|
||||||
Be concise but preserve all actionable context. Output a single summary paragraph.
|
summaryMessages.AddRange(toSummarize);
|
||||||
Do NOT include any hashline anchors (lineNumber:hash|) in your summary.
|
summaryMessages.Add(new(ChatRole.User,
|
||||||
""")
|
"Summarize the above conversation concisely, preserving all important context for continuing the work."));
|
||||||
};
|
|
||||||
summaryMessages.AddRange(toSummarize);
|
string summary;
|
||||||
summaryMessages.Add(new(ChatRole.User,
|
try
|
||||||
"Summarize the above conversation concisely, preserving all important context for continuing the work."));
|
{
|
||||||
|
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct);
|
||||||
string summary;
|
summary = response.Text ?? "(summary unavailable)";
|
||||||
try
|
}
|
||||||
{
|
catch
|
||||||
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct);
|
{
|
||||||
summary = response.Text ?? "(summary unavailable)";
|
// If summarization fails, don't compact
|
||||||
}
|
return false;
|
||||||
catch
|
}
|
||||||
{
|
|
||||||
// If summarization fails, don't compact
|
// Rebuild history: system + summary + recent turns
|
||||||
return false;
|
history.Clear();
|
||||||
}
|
history.Add(systemMsg);
|
||||||
|
history.Add(new(ChatRole.User,
|
||||||
// Rebuild history: system + summary + recent turns
|
"[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
|
||||||
history.Clear();
|
history.Add(new(ChatRole.Assistant,
|
||||||
history.Add(systemMsg);
|
"Understood, I have the context from our earlier conversation. I'll continue from where we left off."));
|
||||||
history.Add(new(ChatRole.User,
|
history.AddRange(tail);
|
||||||
"[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
|
|
||||||
history.Add(new(ChatRole.Assistant,
|
return true;
|
||||||
"Understood, I have the context from our earlier conversation. I'll continue from where we left off."));
|
}
|
||||||
history.AddRange(tail);
|
|
||||||
|
/// <summary>
|
||||||
return true;
|
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
|
||||||
}
|
/// </summary>
|
||||||
|
private static int FindKeepIndex(List<ChatMessage> history)
|
||||||
/// <summary>
|
{
|
||||||
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
|
int pairsFound = 0;
|
||||||
/// </summary>
|
int idx = history.Count - 1;
|
||||||
private static int FindKeepIndex(List<ChatMessage> history)
|
|
||||||
{
|
while (idx > 0 && pairsFound < KeepRecentTurns)
|
||||||
int pairsFound = 0;
|
{
|
||||||
int idx = history.Count - 1;
|
// Walk backwards looking for user+assistant pairs
|
||||||
|
if (history[idx].Role == ChatRole.Assistant && idx > 1 &&
|
||||||
while (idx > 0 && pairsFound < KeepRecentTurns)
|
history[idx - 1].Role == ChatRole.User)
|
||||||
{
|
{
|
||||||
// Walk backwards looking for user+assistant pairs
|
pairsFound++;
|
||||||
if (history[idx].Role == ChatRole.Assistant && idx > 1 &&
|
idx -= 2;
|
||||||
history[idx - 1].Role == ChatRole.User)
|
}
|
||||||
{
|
else
|
||||||
pairsFound++;
|
{
|
||||||
idx -= 2;
|
idx--;
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
{
|
|
||||||
idx--;
|
// idx+1 is the first message to keep
|
||||||
}
|
return Math.Max(1, idx + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// idx+1 is the first message to keep
|
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||||
return Math.Max(1, idx + 1);
|
private static partial Regex MyRegex();
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
|
|
||||||
private static partial Regex MyRegex();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ internal sealed class TokenTracker
|
|||||||
LastInputTokens = inputTokens;
|
LastInputTokens = inputTokens;
|
||||||
RequestCount++;
|
RequestCount++;
|
||||||
}
|
}
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
SessionInputTokens = 0;
|
||||||
|
SessionOutputTokens = 0;
|
||||||
|
RequestCount = 0;
|
||||||
|
LastInputTokens = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private const int MaxContextReserve = 150_000;
|
private const int MaxContextReserve = 150_000;
|
||||||
|
|
||||||
|
|||||||
275
Program.cs
275
Program.cs
@@ -1,136 +1,139 @@
|
|||||||
using System.ClientModel;
|
using System.ClientModel;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using OpenAI;
|
using OpenAI;
|
||||||
using AnchorCli;
|
using AnchorCli;
|
||||||
using AnchorCli.Tools;
|
using AnchorCli.Tools;
|
||||||
using AnchorCli.Commands;
|
using AnchorCli.Commands;
|
||||||
using AnchorCli.OpenRouter;
|
using AnchorCli.OpenRouter;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
// ── Setup subcommand ─────────────────────────────────────────────────────
|
// ── Setup subcommand ─────────────────────────────────────────────────────
|
||||||
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
|
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
SetupTui.Run();
|
SetupTui.Run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────────────────────
|
// ── Config ──────────────────────────────────────────────────────────────
|
||||||
const string endpoint = "https://openrouter.ai/api/v1";
|
const string endpoint = "https://openrouter.ai/api/v1";
|
||||||
var cfg = AnchorConfig.Load();
|
var cfg = AnchorConfig.Load();
|
||||||
string apiKey = cfg.ApiKey;
|
string apiKey = cfg.ApiKey;
|
||||||
string model = cfg.Model;
|
string model = cfg.Model;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
|
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch model pricing from OpenRouter ─────────────────────────────────
|
// ── Fetch model pricing from OpenRouter ─────────────────────────────────
|
||||||
var pricingProvider = new PricingProvider();
|
var pricingProvider = new PricingProvider();
|
||||||
var tokenTracker = new TokenTracker();
|
var tokenTracker = new TokenTracker();
|
||||||
|
|
||||||
ModelInfo? modelInfo = null;
|
ModelInfo? modelInfo = null;
|
||||||
await AnsiConsole.Status()
|
await AnsiConsole.Status()
|
||||||
.Spinner(Spinner.Known.BouncingBar)
|
.Spinner(Spinner.Known.BouncingBar)
|
||||||
.SpinnerStyle(Style.Parse("cornflowerblue"))
|
.SpinnerStyle(Style.Parse("cornflowerblue"))
|
||||||
.StartAsync("Fetching model pricing...", async ctx =>
|
.StartAsync("Fetching model pricing...", async ctx =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
modelInfo = await pricingProvider.GetModelInfoAsync(model);
|
modelInfo = await pricingProvider.GetModelInfoAsync(model);
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
{
|
{
|
||||||
tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
|
tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
|
||||||
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
|
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
|
||||||
tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
|
tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* pricing is best-effort */ }
|
catch { /* pricing is best-effort */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Pretty header ───────────────────────────────────────────────────────
|
// ── Pretty header ───────────────────────────────────────────────────────
|
||||||
AnsiConsole.Write(
|
AnsiConsole.Write(
|
||||||
new FigletText("anchor")
|
new FigletText("anchor")
|
||||||
.Color(Color.CornflowerBlue));
|
.Color(Color.CornflowerBlue));
|
||||||
|
|
||||||
AnsiConsole.Write(
|
AnsiConsole.Write(
|
||||||
new Rule("[dim]AI-powered coding assistant[/]")
|
new Rule("[dim]AI-powered coding assistant[/]")
|
||||||
.RuleStyle(Style.Parse("cornflowerblue dim"))
|
.RuleStyle(Style.Parse("cornflowerblue dim"))
|
||||||
.LeftJustified());
|
.LeftJustified());
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
var infoTable = new Table()
|
var infoTable = new Table()
|
||||||
.Border(TableBorder.Rounded)
|
.Border(TableBorder.Rounded)
|
||||||
.BorderColor(Color.Grey)
|
.BorderColor(Color.Grey)
|
||||||
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
|
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
|
||||||
.AddColumn(new TableColumn("[dim]Value[/]"));
|
.AddColumn(new TableColumn("[dim]Value[/]"));
|
||||||
|
|
||||||
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
|
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
|
||||||
infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]");
|
infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]");
|
||||||
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
|
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
|
||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
{
|
{
|
||||||
var inM = tokenTracker.InputPrice * 1_000_000m;
|
var inM = tokenTracker.InputPrice * 1_000_000m;
|
||||||
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
||||||
infoTable.AddRow("[grey]Pricing[/]",
|
infoTable.AddRow("[grey]Pricing[/]",
|
||||||
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
||||||
}
|
}
|
||||||
if (modelInfo != null)
|
if (modelInfo != null)
|
||||||
{
|
{
|
||||||
infoTable.AddRow("[grey]Context[/]",
|
infoTable.AddRow("[grey]Context[/]",
|
||||||
$"[dim]{modelInfo.ContextLength:N0} tokens[/]");
|
$"[dim]{modelInfo.ContextLength:N0} tokens[/]");
|
||||||
}
|
}
|
||||||
|
|
||||||
AnsiConsole.Write(infoTable);
|
AnsiConsole.Write(infoTable);
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
// ── Build the chat client with tool-calling support ─────────────────────
|
// ── Build the chat client with tool-calling support ─────────────────────
|
||||||
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
|
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(endpoint)
|
Endpoint = new Uri(endpoint)
|
||||||
});
|
});
|
||||||
|
|
||||||
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
||||||
|
|
||||||
// ── Tool call logging via Spectre ───────────────────────────────────────
|
// ── Tool call logging via Spectre ───────────────────────────────────────
|
||||||
object consoleLock = new object();
|
object consoleLock = new object();
|
||||||
|
|
||||||
void ToolLog(string message)
|
void ToolLog(string message)
|
||||||
{
|
{
|
||||||
lock (consoleLock)
|
lock (consoleLock)
|
||||||
{
|
{
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
Console.Write("\r" + new string(' ', 40) + "\r");
|
||||||
AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
|
AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandTool.Log =
|
CommandTool.Log =
|
||||||
DirTools.Log =
|
DirTools.Log =
|
||||||
FileTools.Log =
|
FileTools.Log =
|
||||||
EditTools.Log = ToolLog;
|
EditTools.Log = ToolLog;
|
||||||
|
|
||||||
// ── Instantiate Core Components ──────────────────────────────────────────
|
// ── Instantiate Core Components ──────────────────────────────────────────
|
||||||
|
|
||||||
var session = new ChatSession(innerClient);
|
var session = new ChatSession(innerClient);
|
||||||
if (modelInfo != null)
|
if (modelInfo != null)
|
||||||
{
|
{
|
||||||
tokenTracker.ContextLength = modelInfo.ContextLength;
|
tokenTracker.ContextLength = modelInfo.ContextLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
var commandRegistry = new CommandRegistry();
|
var commandRegistry = new CommandRegistry();
|
||||||
commandRegistry.Register(new ExitCommand());
|
commandRegistry.Register(new ExitCommand());
|
||||||
commandRegistry.Register(new HelpCommand(commandRegistry));
|
commandRegistry.Register(new HelpCommand(commandRegistry));
|
||||||
commandRegistry.Register(new ClearCommand());
|
commandRegistry.Register(new ClearCommand());
|
||||||
commandRegistry.Register(new StatusCommand(model, endpoint));
|
commandRegistry.Register(new StatusCommand(model, endpoint));
|
||||||
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
||||||
commandRegistry.Register(new SetupCommand());
|
commandRegistry.Register(new SetupCommand());
|
||||||
|
commandRegistry.Register(new ResetCommand(session, tokenTracker));
|
||||||
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
|
||||||
|
|
||||||
// ── Run Repl ────────────────────────────────────────────────────────────
|
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
||||||
|
|
||||||
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
// ── Run Repl ────────────────────────────────────────────────────────────
|
||||||
await repl.RunAsync();
|
|
||||||
|
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
||||||
|
await repl.RunAsync();
|
||||||
|
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -22,7 +22,7 @@ This eliminates:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands
|
- **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands
|
||||||
- **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`
|
- **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset`
|
||||||
- **Token Tracking**: Real-time token usage and cost per response, plus session totals
|
- **Token Tracking**: Real-time token usage and cost per response, plus session totals
|
||||||
- **Model Pricing Display**: Shows current model pricing from OpenRouter in the header
|
- **Model Pricing Display**: Shows current model pricing from OpenRouter in the header
|
||||||
- **Context Compaction**: Automatic conversation history compression when approaching context limits, including stale tool result compaction
|
- **Context Compaction**: Automatic conversation history compression when approaching context limits, including stale tool result compaction
|
||||||
@@ -69,7 +69,7 @@ The resulting binary is ~12 MB, has no .NET runtime dependency, and starts insta
|
|||||||
| `/clear` | Clear the conversation history |
|
| `/clear` | Clear the conversation history |
|
||||||
| `/status` | Show session token usage and cost |
|
| `/status` | Show session token usage and cost |
|
||||||
| `/compact` | Manually trigger context compaction |
|
| `/compact` | Manually trigger context compaction |
|
||||||
|
| `/reset` | Clear session and reset token tracker |
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
**File Operations:**
|
**File Operations:**
|
||||||
@@ -98,31 +98,37 @@ The resulting binary is ~12 MB, has no .NET runtime dependency, and starts insta
|
|||||||
**Command Execution:**
|
**Command Execution:**
|
||||||
- `execute_command` - Run shell commands (with user approval)
|
- `execute_command` - Run shell commands (with user approval)
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
AnchorCli/
|
AnchorCli/
|
||||||
├── Program.cs # Entry point + REPL loop + AI client setup
|
├── Program.cs # Entry point + CLI parsing
|
||||||
├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~\anchor\config.json)
|
├── ReplLoop.cs # Main REPL loop with streaming, spinners, and cancellation
|
||||||
├── ContextCompactor.cs # Conversation history compression
|
├── ChatSession.cs # AI chat client wrapper with message history
|
||||||
├── AppJsonContext.cs # Source-generated JSON context (AOT)
|
├── ToolRegistry.cs # Centralized tool registration and dispatch
|
||||||
|
├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~/anchor/config.json)
|
||||||
|
├── ContextCompactor.cs # Conversation history compression
|
||||||
|
├── AppJsonContext.cs # Source-generated JSON context (AOT)
|
||||||
|
├── SetupTui.cs # Interactive setup TUI
|
||||||
├── Hashline/
|
├── Hashline/
|
||||||
│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing
|
│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing
|
||||||
│ └── HashlineValidator.cs # Anchor resolution + validation
|
│ └── HashlineValidator.cs # Anchor resolution + validation
|
||||||
├── Tools/
|
├── Tools/
|
||||||
│ ├── FileTools.cs # read_file, grep_file, grep_recursive, find_files, get_file_info
|
│ ├── FileTools.cs # read_file, grep_file, grep_recursive, find_files, get_file_info
|
||||||
│ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append
|
│ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append
|
||||||
│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir
|
│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir
|
||||||
│ └── CommandTool.cs # execute_command
|
│ └── CommandTool.cs # execute_command
|
||||||
├── Commands/
|
├── Commands/
|
||||||
│ ├── ExitCommand.cs # /exit command
|
│ ├── ICommand.cs # Command interface
|
||||||
│ ├── HelpCommand.cs # /help command
|
│ ├── CommandRegistry.cs # Command registration
|
||||||
│ ├── ClearCommand.cs # /clear command
|
│ ├── CommandDispatcher.cs # Command dispatch logic
|
||||||
│ ├── StatusCommand.cs # /status command
|
│ ├── ExitCommand.cs # /exit command
|
||||||
│ └── CompactCommand.cs # /compact command
|
│ ├── HelpCommand.cs # /help command
|
||||||
├── OpenRouter/
|
│ ├── ClearCommand.cs # /clear command
|
||||||
│ └── PricingProvider.cs # Fetch model pricing from OpenRouter
|
│ ├── StatusCommand.cs # /status command
|
||||||
└── SetupTui.cs # Interactive setup TUI
|
│ ├── CompactCommand.cs # /compact command
|
||||||
|
│ ├── ResetCommand.cs # /reset command
|
||||||
|
│ └── SetupCommand.cs # /setup command
|
||||||
|
└── OpenRouter/
|
||||||
|
└── PricingProvider.cs # Fetch model pricing from OpenRouter
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|||||||
37
ReplLoop.cs
37
ReplLoop.cs
@@ -64,37 +64,44 @@ internal sealed class ReplLoop
|
|||||||
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
||||||
&& raw.Usage != null)
|
&& raw.Usage != null)
|
||||||
{
|
{
|
||||||
respIn += raw.Usage.InputTokenCount;
|
respIn = raw.Usage.InputTokenCount; // last call = actual context size
|
||||||
respOut += raw.Usage.OutputTokenCount;
|
respOut += raw.Usage.OutputTokenCount; // additive — each round generates new output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object consoleLock = new();
|
object consoleLock = new();
|
||||||
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
|
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
|
||||||
bool showSpinner = true;
|
bool showSpinner = true;
|
||||||
|
|
||||||
CommandTool.PauseSpinner = () =>
|
CommandTool.PauseSpinner = () =>
|
||||||
{
|
{
|
||||||
lock (consoleLock)
|
lock (consoleLock)
|
||||||
{
|
{
|
||||||
showSpinner = false;
|
showSpinner = false;
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
Console.Write("\r" + new string(' ', 40) + "\r");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
CommandTool.ResumeSpinner = () =>
|
CommandTool.ResumeSpinner = () =>
|
||||||
{
|
{
|
||||||
lock (consoleLock)
|
lock (consoleLock)
|
||||||
{
|
{
|
||||||
showSpinner = true;
|
showSpinner = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
FileTools.OnFileRead = _ =>
|
||||||
|
{
|
||||||
|
int n = ContextCompactor.CompactStaleToolResults(_session.History);
|
||||||
|
if (n > 0)
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
|
||||||
|
};
|
||||||
|
|
||||||
var spinnerTask = Task.Run(async () =>
|
var spinnerTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
var frames = Spinner.Known.BouncingBar.Frames;
|
var frames = Spinner.Known.BouncingBar.Frames;
|
||||||
var interval = Spinner.Known.BouncingBar.Interval;
|
var interval = Spinner.Known.BouncingBar.Interval;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
Console.Write("\x1b[?25l");
|
Console.Write("\x1b[?25l");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -143,6 +150,7 @@ internal sealed class ReplLoop
|
|||||||
await Task.WhenAny(spinnerTask);
|
await Task.WhenAny(spinnerTask);
|
||||||
CommandTool.PauseSpinner = null;
|
CommandTool.PauseSpinner = null;
|
||||||
CommandTool.ResumeSpinner = null;
|
CommandTool.ResumeSpinner = null;
|
||||||
|
FileTools.OnFileRead = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstChunk != null)
|
if (firstChunk != null)
|
||||||
@@ -184,13 +192,6 @@ internal sealed class ReplLoop
|
|||||||
|
|
||||||
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
||||||
|
|
||||||
int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History);
|
|
||||||
if (compactedResults > 0)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_tokenTracker.ShouldCompact())
|
if (_tokenTracker.ShouldCompact())
|
||||||
{
|
{
|
||||||
var pct = _tokenTracker.ContextUsagePercent;
|
var pct = _tokenTracker.ContextUsagePercent;
|
||||||
|
|||||||
334
SANDBOX.md
Normal file
334
SANDBOX.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Sandbox Implementation Plan for AnchorCli
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
By default, all file and directory operations are restricted to the current working directory (CWD).
|
||||||
|
Users can bypass this restriction with the `--no-sandbox` flag.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default: sandbox enabled (operations limited to CWD)
|
||||||
|
anchor
|
||||||
|
|
||||||
|
# Disable sandbox (allow operations anywhere)
|
||||||
|
anchor --no-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The implementation leverages the existing `ResolvePath()` methods in `FileTools` and `DirTools`.
|
||||||
|
Since tools are static classes without dependency injection, we use a static `SandboxContext` class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Create `SandboxContext.cs`
|
||||||
|
|
||||||
|
Create a new file `Core/SandboxContext.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static context holding sandbox configuration.
|
||||||
|
/// Checked by ResolvePath() to validate paths are within working directory.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SandboxContext
|
||||||
|
{
|
||||||
|
private static string? _workingDirectory;
|
||||||
|
private static bool _enabled = true;
|
||||||
|
|
||||||
|
public static bool Enabled
|
||||||
|
{
|
||||||
|
get => _enabled;
|
||||||
|
set => _enabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string WorkingDirectory
|
||||||
|
{
|
||||||
|
get => _workingDirectory ?? Environment.CurrentDirectory;
|
||||||
|
set => _workingDirectory = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that a resolved path is within the working directory (if sandbox is enabled).
|
||||||
|
/// Returns the resolved path if valid, or null if outside sandbox (no exception thrown).
|
||||||
|
/// When null is returned, the calling tool should return an error message to the agent.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ValidatePath(string resolvedPath)
|
||||||
|
{
|
||||||
|
if (!_enabled)
|
||||||
|
return resolvedPath;
|
||||||
|
|
||||||
|
var workDir = WorkingDirectory;
|
||||||
|
|
||||||
|
// Normalize paths for comparison
|
||||||
|
var normalizedPath = Path.GetFullPath(resolvedPath).TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
var normalizedWorkDir = Path.GetFullPath(workDir).TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
// Check if path starts with working directory
|
||||||
|
if (!normalizedPath.StartsWith(normalizedWorkDir, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Return null to signal violation - caller handles error messaging
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Initialize(bool sandboxEnabled)
|
||||||
|
{
|
||||||
|
_enabled = sandboxEnabled;
|
||||||
|
_workingDirectory = Environment.CurrentDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Modify `Program.cs`
|
||||||
|
|
||||||
|
Add argument parsing and initialize the sandbox context:
|
||||||
|
|
||||||
|
**After line 15** (after the `setup` subcommand check), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ── Parse sandbox flag ──────────────────────────────────────────────────
|
||||||
|
bool sandboxEnabled = !args.Contains("--no-sandbox");
|
||||||
|
SandboxContext.Initialize(sandboxEnabled);
|
||||||
|
|
||||||
|
if (!sandboxEnabled)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[dim grey]Sandbox disabled (--no-sandbox)[/]");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Update `FileTools.ResolvePath()`
|
||||||
|
|
||||||
|
**Replace lines 322-323** with:
|
||||||
|
|
||||||
|
internal static string? ResolvePath(string path, out string? errorMessage)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
var resolved = Path.IsPathRooted(path)
|
||||||
|
? path
|
||||||
|
: Path.GetFullPath(path, Environment.CurrentDirectory);
|
||||||
|
|
||||||
|
var validated = SandboxContext.ValidatePath(resolved);
|
||||||
|
if (validated == null)
|
||||||
|
{
|
||||||
|
errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Update `DirTools.ResolvePath()`
|
||||||
|
|
||||||
|
**Replace lines 84-85** with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static string? ResolvePath(string path, out string? errorMessage)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
var resolved = Path.IsPathRooted(path)
|
||||||
|
? path
|
||||||
|
: Path.GetFullPath(path, Environment.CurrentDirectory);
|
||||||
|
|
||||||
|
var validated = SandboxContext.ValidatePath(resolved);
|
||||||
|
if (validated == null)
|
||||||
|
{
|
||||||
|
errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Update Tool Descriptions (Optional but Recommended)
|
||||||
|
|
||||||
|
Update the `[Description]` attributes to mention sandbox behavior:
|
||||||
|
|
||||||
|
**FileTools.cs - ReadFile** (line 23):
|
||||||
|
```csharp
|
||||||
|
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. Sandbox: restricted to working directory unless --no-sandbox is used. IMPORTANT: Call GrepFile first...")]
|
||||||
|
```
|
||||||
|
|
||||||
|
**DirTools.cs - CreateDir** (line 63):
|
||||||
|
```csharp
|
||||||
|
[Description("Create a new directory. Creates parent directories if they don't exist. Sandbox: restricted to working directory unless --no-sandbox is used. Returns OK on success...")]
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat for other tools as needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Tools Handle Sandbox Violations
|
||||||
|
|
||||||
|
Each tool that uses `ResolvePath()` must check for `null` return and handle it gracefully:
|
||||||
|
|
||||||
|
### FileTools Pattern
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Before (old code):
|
||||||
|
var resolvedPath = ResolvePath(path);
|
||||||
|
var content = File.ReadAllText(resolvedPath);
|
||||||
|
|
||||||
|
// After (new code):
|
||||||
|
var resolvedPath = ResolvePath(path, out var errorMessage);
|
||||||
|
if (resolvedPath == null)
|
||||||
|
return $"ERROR: {errorMessage}";
|
||||||
|
|
||||||
|
var content = File.ReadAllText(resolvedPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
### DirTools Pattern
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Before (old code):
|
||||||
|
var resolvedPath = ResolvePath(path);
|
||||||
|
Directory.CreateDirectory(resolvedPath);
|
||||||
|
|
||||||
|
// After (new code):
|
||||||
|
var resolvedPath = ResolvePath(path, out var errorMessage);
|
||||||
|
if (resolvedPath == null)
|
||||||
|
return $"ERROR: {errorMessage}";
|
||||||
|
|
||||||
|
Directory.CreateDirectory(resolvedPath);
|
||||||
|
return "OK";
|
||||||
|
```
|
||||||
|
|
||||||
|
### EditTools
|
||||||
|
|
||||||
|
No changes needed - it already calls `FileTools.ResolvePath()`, so the sandbox check happens there.
|
||||||
|
|
||||||
|
### Tools That Don't Use ResolvePath
|
||||||
|
|
||||||
|
- `ListDir` with no path argument (uses current directory)
|
||||||
|
- `GetFileInfo` - needs to be updated to use `ResolvePath()`
|
||||||
|
- `FindFiles` - needs to be updated to validate the search path
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling - No Crashes
|
||||||
|
|
||||||
|
When a sandbox violation occurs, the program **does not crash**. Instead:
|
||||||
|
|
||||||
|
1. `ResolvePath()` returns `null` and sets `errorMessage`
|
||||||
|
2. The tool returns the error message to the agent
|
||||||
|
3. The agent sees the error and can continue the conversation
|
||||||
|
4. The user sees a clear error message in the chat
|
||||||
|
|
||||||
|
**Example tool implementation pattern:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static async Task<string> ReadFile(string path, int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var resolvedPath = ResolvePath(path, out var errorMessage);
|
||||||
|
if (resolvedPath == null)
|
||||||
|
return $"ERROR: {errorMessage}"; // Return error, don't throw
|
||||||
|
|
||||||
|
// ... rest of the tool logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the agent sees:**
|
||||||
|
```
|
||||||
|
Tool result: ERROR: Sandbox violation: Path '/home/tomi/.ssh' is outside working directory '/home/tomi/dev/anchor'. Use --no-sandbox to disable restrictions.
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the user sees in chat:**
|
||||||
|
> The agent tried to read `/home/tomi/.ssh` but was blocked by the sandbox. The agent can now adjust its approach or ask you to run with `--no-sandbox`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases Handled
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| **Symlinks inside CWD pointing outside** | Follows symlink (user-created link = intentional) |
|
||||||
|
| **Path traversal (`../..`)** | Blocked if result is outside CWD |
|
||||||
|
| **Absolute paths** | Validated against CWD |
|
||||||
|
| **Network paths** | Blocked (not under CWD) |
|
||||||
|
| **Case sensitivity** | Uses `OrdinalIgnoreCase` for cross-platform compatibility |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
⚠️ **The sandbox is a safety feature, not a security boundary.**
|
||||||
|
|
||||||
|
- It prevents **accidental** modifications to system files
|
||||||
|
- It does **not** protect against malicious intent
|
||||||
|
- `CommandTool.ExecuteCommand()` can still run arbitrary shell commands
|
||||||
|
- A determined user can always use `--no-sandbox`
|
||||||
|
|
||||||
|
For true isolation, run anchor in a container or VM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] `ReadFile` on file inside CWD → **Success**
|
||||||
|
- [ ] `ReadFile` on file outside CWD → **Sandbox violation error**
|
||||||
|
- [ ] `ReadFile` with `../` traversal outside CWD → **Sandbox violation error**
|
||||||
|
- [ ] `CreateDir` outside CWD → **Sandbox violation error**
|
||||||
|
- [ ] `anchor --no-sandbox` then read `/etc/passwd` → **Success**
|
||||||
|
- [ ] Symlink inside CWD pointing to `/etc/passwd` → **Success** (user-created link)
|
||||||
|
- [ ] Case variations on Windows (`C:\Users` vs `c:\users`) → **Success**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Existing Workflows
|
||||||
|
|
||||||
|
If you have scripts or workflows that rely on accessing files outside the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update your scripts to use --no-sandbox
|
||||||
|
anchor --no-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Integration
|
||||||
|
|
||||||
|
For CI environments where sandbox may not be needed:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions example
|
||||||
|
- name: Run anchor
|
||||||
|
run: anchor --no-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `Core/SandboxContext.cs` | **New file** - Static sandbox state and validation |
|
||||||
|
| `Program.cs` | Add `--no-sandbox` parsing, call `SandboxContext.Initialize()` |
|
||||||
|
| `Tools/FileTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null |
|
||||||
|
| `Tools/DirTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null |
|
||||||
|
| `Tools/EditTools.cs` | No changes (uses `FileTools.ResolvePath()`, sandbox check happens there) |
|
||||||
|
| `Tools/CommandTool.cs` | **Not sandboxed** - shell commands can access any path (documented limitation) |
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Allowlist**: Let users specify additional safe directories via config
|
||||||
|
- **Per-tool sandbox**: Some tools (e.g., `GrepRecursive`) could have different rules
|
||||||
|
- **Audit mode**: Log all file operations for review
|
||||||
|
- **Interactive prompt**: Ask for confirmation before violating sandbox instead of hard fail
|
||||||
@@ -14,7 +14,13 @@ internal static class FileTools
|
|||||||
{
|
{
|
||||||
public static Action<string> Log { get; set; } = Console.WriteLine;
|
public static Action<string> Log { get; set; } = Console.WriteLine;
|
||||||
|
|
||||||
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors.")]
|
/// <summary>
|
||||||
|
/// Optional callback invoked after each successful ReadFile call, with the resolved path.
|
||||||
|
/// Set by ReplLoop to trigger deduplication compaction while the tool loop is still active.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<string>? OnFileRead { get; set; }
|
||||||
|
|
||||||
|
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. IMPORTANT: Call GrepFile first (pattern: 'public|class|func|interface|enum|def') to get a structural outline and target startLine/endLine before calling this.")]
|
||||||
public static string ReadFile(
|
public static string ReadFile(
|
||||||
[Description("Path to the file.")] string path,
|
[Description("Path to the file.")] string path,
|
||||||
[Description("First line to return (inclusive). Defaults to 1.")] int startLine = 1,
|
[Description("First line to return (inclusive). Defaults to 1.")] int startLine = 1,
|
||||||
@@ -40,7 +46,9 @@ internal static class FileTools
|
|||||||
return $"ERROR: File too large to read at once ({lines.Length} lines). Provide startLine and endLine to read a chunk of max 200 lines. Use GrepFile to get an outline (grep 'public') and find the line numbers.";
|
return $"ERROR: File too large to read at once ({lines.Length} lines). Provide startLine and endLine to read a chunk of max 200 lines. Use GrepFile to get an outline (grep 'public') and find the line numbers.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return HashlineEncoder.Encode(lines, startLine, endLine);
|
string result = HashlineEncoder.Encode(lines, startLine, endLine);
|
||||||
|
OnFileRead?.Invoke(path);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user