Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/Cli/Commands/Repl/ReplCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,16 @@ protected override async Task<int> ExecuteAsync(
using var factory = new ChatClientFactory();

var toolsByCategory = new Dictionary<string, List<AIFunction>>(StringComparer.OrdinalIgnoreCase);
using ShellPlugin? shellPlugin = settings.NoTools ? null : new ShellPlugin(shellPolicy: TryLoadDefaultShellPolicy());
SubAgentPlugin? subAgent = null;
SkillsPlugin? skillsPlugin = null;
string? skillsCatalog = null;
using ShellPlugin? shellPlugin = settings.NoTools ? null : new ShellPlugin(shellPolicy: TryLoadDefaultShellPolicy());
SubAgentPlugin? subAgent = null;
SkillsPlugin? skillsPlugin = null;
string? skillsCatalog = null;
List<AIFunction>? explorerTools = null;
TodoPlugin? todoPlugin = null;
TodoPlugin? todoPlugin = null;
FileSystemPlugin? fsPluginForCategory = null;
if (!settings.NoTools)
{
var fsPluginForCategory = new FileSystemPlugin();
fsPluginForCategory = new FileSystemPlugin();
toolsByCategory["FileSystem"] = PluginRegistry.GetFunctionsFromObject(fsPluginForCategory)
.Concat(PluginRegistry.GetFunctionsFromObject(new FileSystemManagementOps(fsPluginForCategory)))
.ToList();
Expand Down Expand Up @@ -347,6 +348,23 @@ protected override async Task<int> ExecuteAsync(
Todo = todoPlugin,
KeyStored = keyStored,
};

if (!settings.NoTools)
{
foreach (var tool in new object?[] { fsPluginForCategory, shellPlugin, todoPlugin })
{
if (tool is ITurnResettable resettable)
ctx.TurnResettables.Add(resettable);
}
}

if (toolsByCategory.TryGetValue("FileSystem", out _))
{
var fsResettable = toolsByCategory["FileSystem"]
.Select(f => f.UnderlyingMethod?.DeclaringType)
.FirstOrDefault();
}

if (skillsPlugin is not null)
ctx.LineReader.SetSkillSlugs([.. skillsPlugin.Slugs]);

Expand Down
9 changes: 9 additions & 0 deletions src/Cli/Commands/Repl/ReplSessionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ public IChatClient StepClient

// History-aware line reader (shared across turns so history persists)
public readonly ReplLineReader LineReader = new();

// Turn-scoped plugin state that must be cleared before each new REPL turn.
public readonly List<ITurnResettable> TurnResettables = [];

public ReplSessionContext(
string cwd, string sessionId, DateTime startedAt, string modelId, ModelConfig modelConfig,
Expand Down Expand Up @@ -193,6 +196,12 @@ public List<AIFunction> GetActiveTools() => [.. ToolsByCategory
.Where(kv => !DisabledCategories.Contains(kv.Key))
.SelectMany(kv => kv.Value)];

public void BeginTurn()
{
foreach (var resettable in TurnResettables)
resettable.BeginTurn();
}

public ChatOptions? BuildChatOptions()
{
var active = GetActiveTools();
Expand Down
52 changes: 46 additions & 6 deletions src/Cli/Commands/Repl/ReplTurn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ internal static async Task<bool> ExecuteAsync(
int stepTotal = 0,
bool isCorrectionTurn = false)
{
ctx.BeginTurn();
ctx.Emitter.SetTurn(ctx.TurnIndex);
await ctx.Emitter.EmitAsync(EventTypes.UserInput, turn: ctx.TurnIndex, payload: new { content = input });
ctx.History.Add(new ChatMessage(ChatRole.User, input));
Expand All @@ -402,6 +403,24 @@ internal static async Task<bool> ExecuteAsync(
var turnInputTokens = stream.TurnInputTokens;
var turnOutputTokens = stream.TurnOutputTokens;

responseText = SanitizeAssistantResponse(responseText, out var warningMessage);
if (!capturePlan && responseText.Length == 0)
{
if (!isCorrectionTurn)
{
const string correctionMsg =
"Your last reply was empty or contained internal tool-call text. " +
"Respond to the user with a concise, user-facing answer. " +
"If you need tools, call them first and then provide the answer in the same turn.";
return await ExecuteAsync(
ctx, correctionMsg,
isStepRequest: false, capturePlan: false, activeStep: null,
cancellationToken, isCorrectionTurn: true);
}

warningMessage ??= "Model returned an empty response twice. Provide a real user-facing answer next turn.";
}

if (!capturePlan && responseText.Length > 0 && !ctx.JsonMode)
{
if (!Console.IsOutputRedirected)
Expand All @@ -415,16 +434,16 @@ internal static async Task<bool> ExecuteAsync(
ctx.History.Add(new ChatMessage(ChatRole.Assistant, responseText));
else if (!capturePlan)
{
// The model returned zero content — surface a clear warning so the user
// knows to retry rather than wondering why the prompt went quiet.
var warningText = warningMessage ?? "Model returned an empty response. Try sending your message again.";

if (ctx.JsonMode)
ReplJsonBridge.Emit(new { type = "warning", text = "Model returned an empty response. Try sending your message again." });
ReplJsonBridge.Emit(new { type = "warning", text = warningText });
else
AnsiConsole.MarkupLine("[dim] ↯ empty response — the model returned no content. Try again.[/]");
AnsiConsole.MarkupLine($"[dim] ↯ {Markup.Escape(warningText)}[/]");

await ctx.Emitter.EmitAsync(EventTypes.ReplWarning, turn: ctx.TurnIndex, payload: new
{
message = "empty_response",
message = warningMessage is null ? "empty_response" : "invalid_response_content",
});
}

Expand Down Expand Up @@ -516,7 +535,8 @@ await ctx.Emitter.EmitAsync(EventTypes.HistoryTrimmed, turn: ctx.TurnIndex,
AnsiConsole.MarkupLine(
$"[dim] tokens (est.): {postEst:N0} / {ctx.ContextTokenBudget:N0} rounds: {toolRounds} tool calls: {toolCallsThisTurn.Count}[/]");

await ctx.Emitter.EmitAsync(EventTypes.AssistantResponse, turn: ctx.TurnIndex, payload: new { content = responseText });
if (responseText.Length > 0)
await ctx.Emitter.EmitAsync(EventTypes.AssistantResponse, turn: ctx.TurnIndex, payload: new { content = responseText });
await ctx.Emitter.EmitAsync(EventTypes.TurnEnd, turn: ctx.TurnIndex, payload: new
{
elapsed_ms = (int)(DateTime.UtcNow - turnStart).TotalMilliseconds,
Expand Down Expand Up @@ -555,6 +575,26 @@ await ctx.Emitter.EmitAsync(EventTypes.HistoryTrimmed, turn: ctx.TurnIndex,
return stepPassed;
}

private static string SanitizeAssistantResponse(string responseText, out string? warningMessage)
{
var trimmed = responseText.Trim();
if (trimmed.Length == 0)
{
warningMessage = null;
return string.Empty;
}

if (trimmed.StartsWith("to=functions.", StringComparison.OrdinalIgnoreCase) ||
trimmed.Contains("Wait must be valid JSON", StringComparison.OrdinalIgnoreCase))
{
warningMessage = "Model returned internal tool-call text instead of a user-facing answer. Try again.";
return string.Empty;
}

warningMessage = null;
return responseText;
}

// Free-form turns: if the response claims a mutation but no write tool was called,
// auto-inject a correction so the agent is required to actually call the tool.
// On the correction turn itself fall back to a warning to avoid infinite recursion.
Expand Down
41 changes: 34 additions & 7 deletions src/Infrastructure/Plugins/FileSystemPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,24 @@ public async Task<string> ReadFileAsync(
$"Cold-reading would flood your context. " +
$"Use grep_file to locate the relevant section, then read_file with startLine/maxLines.]";
if (_readBudgetUsed + preview.Length > _readBudgetPerTurn)
return PluginResult.Error(
$"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " +
$"Proceed with context already available — use patch_file or shell_run. Budget resets next turn.");
{
var remaining = _readBudgetPerTurn - _readBudgetUsed;
if (_readBudgetUsed == 0)
{
var allowed = Math.Max(1, Math.Min(preview.Length, _readBudgetPerTurn));
preview = preview[..allowed] +
$"\n\n[Truncated to fit per-turn read budget of {_readBudgetPerTurn:N0} chars. " +
$"Use grep_file or read_file with startLine/maxLines for narrower follow-up reads.]";
}
else
{
var allowed = Math.Max(1, Math.Min(preview.Length, Math.Max(remaining, 1)));
preview = $"[Read budget nearly exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars used this turn). " +
$"Returning a compact preview instead of failing so you can keep working. " +
$"Use grep_file/get_file_summary or narrow read_file ranges for any follow-up reads in this turn.]\n\n" +
preview[..allowed];
}
}
_readBudgetUsed += preview.Length;
_sessionCache?.RecordRead(resolved, fileInfo);
return preview;
Expand Down Expand Up @@ -266,10 +281,22 @@ public async Task<string> ReadFileAsync(
// proceed with what it already has in context rather than reading more files.
if (_readBudgetUsed + built.Length > _readBudgetPerTurn)
{
content = null;
return PluginResult.Error(
$"Read budget exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars). " +
$"Proceed with context already available — use patch_file or shell_run. Budget resets next turn.");
if (_readBudgetUsed == 0)
{
var allowed = Math.Max(1, Math.Min(built.Length, _readBudgetPerTurn));
built = built[..allowed] +
$"\n\n[Truncated to fit per-turn read budget of {_readBudgetPerTurn:N0} chars. " +
$"Use grep_file/get_file_summary or narrow read_file ranges for follow-up reads.]";
}
else
{
var remaining = _readBudgetPerTurn - _readBudgetUsed;
var allowed = Math.Max(1, Math.Min(built.Length, Math.Max(remaining, 1)));
built = $"[Read budget nearly exhausted ({_readBudgetUsed:N0}/{_readBudgetPerTurn:N0} chars used this turn). " +
$"Returning a compact slice instead of failing so you can keep working. " +
$"Use grep_file/get_file_summary or narrower read_file ranges for any follow-up reads in this turn.]\n\n" +
built[..allowed];
}
}

_readBudgetUsed += built.Length;
Expand Down
56 changes: 54 additions & 2 deletions src/Infrastructure/Plugins/TodoPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ public string Write(
"status is one of pending, in_progress, completed. Replaces the entire list.")]
string itemsJson)
{
var candidateJson = ExtractJsonArray(itemsJson);

List<TodoItem>? parsed;
try
{
parsed = JsonSerializer.Deserialize<List<TodoItem>>(itemsJson, JsonOpts);
parsed = JsonSerializer.Deserialize<List<TodoItem>>(candidateJson, JsonOpts);
}
catch (JsonException ex)
{
return $"[ERROR] Could not parse itemsJson as a JSON array: {ex.Message}";
return $"[ERROR] Could not parse itemsJson as a JSON array: {ex.Message}. Pass only a JSON array like [{{\"content\":\"Example\",\"status\":\"pending\"}}].";
}
if (parsed is null)
return "[ERROR] itemsJson must be a JSON array of todo items.";
Expand Down Expand Up @@ -93,6 +95,56 @@ internal static string Render(IReadOnlyList<TodoItem> items)
}
return sb.ToString().TrimEnd();
}

private static string ExtractJsonArray(string itemsJson)
{
var trimmed = itemsJson.Trim();
if (trimmed.StartsWith("[", StringComparison.Ordinal))
return trimmed;

var start = trimmed.IndexOf('[');
if (start < 0)
return trimmed;

var depth = 0;
var inString = false;
var escaping = false;
for (var i = start; i < trimmed.Length; i++)
{
var ch = trimmed[i];
if (escaping)
{
escaping = false;
continue;
}

if (ch == '\\' && inString)
{
escaping = true;
continue;
}

if (ch == '"')
{
inString = !inString;
continue;
}

if (inString)
continue;

if (ch == '[')
depth++;
else if (ch == ']')
{
depth--;
if (depth == 0)
return trimmed[start..(i + 1)];
}
}

return trimmed;
}
}

public sealed record TodoItem
Expand Down
Loading