From abc5c4c94245561daf29e08585fe56fa80ad0946 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 19 Jul 2026 03:49:29 +0300 Subject: [PATCH] fix: surface empty Kimi ACP turns as failures --- Program.cs | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Program.cs b/Program.cs index c91cd81..2c91714 100644 --- a/Program.cs +++ b/Program.cs @@ -292,7 +292,7 @@ sealed class FleetHost : IDisposable { var produced = await sender.RunHiddenPromptAsync(PackagePrompt(handoff), capture: true); if (string.IsNullOrWhiteSpace(produced)) - throw new InvalidOperationException($"Source agent '{sender.Name}' produced no handoff package."); + throw new InvalidOperationException(sender.LastError ?? $"Source agent '{sender.Name}' produced no handoff package."); package = produced.Trim(); if (handoff.Message is not null) package += $"\n\nNote from {sender.Name}: {handoff.Message}"; } @@ -339,7 +339,7 @@ sealed class FleetHost : IDisposable recipient.AddHandoffEntry(handoff, package); Publish(delivery.Agent, "handoff", $"Handoff {handoff.Id} delivery started."); }); - if (completed is null) throw new InvalidOperationException("Recipient turn failed; see the agent chat for details."); + if (completed is null) throw new InvalidOperationException(recipient.LastError ?? "Recipient turn failed; see the agent chat for details."); lock (_handoffsLock) delivery.State = "delivered"; Publish(delivery.Agent, "handoff", $"Handoff {handoff.Id} from '{handoff.From}' delivered."); } @@ -565,6 +565,9 @@ sealed class KimiAgent : IDisposable private long _requestId; private long _chatId; private long _estimatedTokens; + private long _turnMeaningfulUpdateCount; + private long _turnAssistantMessageCount; + private int _isPromptTurnActive; private int _queuedPrompts; private bool _cancelSent; private string? _sessionId; @@ -582,7 +585,8 @@ sealed class KimiAgent : IDisposable public string State { get; private set; } = "starting"; public string Activity { get; private set; } = "Starting ACP session"; public DateTimeOffset ActivityUpdatedAt { get; private set; } = DateTimeOffset.UtcNow; - public bool CanReceiveHandoffs => _sessionId is not null && State is not "starting" and not "stopped" && !_resettingContext && !_disposed; + public string? LastError { get; private set; } + public bool CanReceiveHandoffs => _sessionId is not null && State is not "starting" and not "stopped" and not "error" && !_resettingContext && !_disposed; public KimiAgent(string name, string workspace, string role, IReadOnlyList scopes, FleetOptions options, Action publish) { @@ -680,6 +684,9 @@ sealed class KimiAgent : IDisposable if (captureBuffer is not null) _captureBuffer = captureBuffer; _hideTurnTranscript = hidden && capture; } + Interlocked.Exchange(ref _turnMeaningfulUpdateCount, 0); + Interlocked.Exchange(ref _turnAssistantMessageCount, 0); + Volatile.Write(ref _isPromptTurnActive, 1); var promptResult = await RequestAsync( "session/prompt", new { sessionId = _sessionId, prompt = new[] { new { type = "text", text = prompt } } }, @@ -698,7 +705,16 @@ sealed class KimiAgent : IDisposable try { cancelled = promptResult.TryGetProperty("stopReason", out var stopReason) && stopReason.GetString() == "cancelled"; + if (!cancelled && capture && Interlocked.Read(ref _turnAssistantMessageCount) == 0) + throw new InvalidOperationException( + "Kimi ended the turn without an assistant message. " + + "The provider may be out of quota or unauthenticated; verify it with `kimi --model kimi-code/k3 --prompt \"Reply: OK\"`."); + if (!cancelled && !capture && Interlocked.Read(ref _turnMeaningfulUpdateCount) == 0) + throw new InvalidOperationException( + "Kimi ended the turn without any text, thought, or tool output. " + + "The provider may be out of quota or unauthenticated; verify it with `kimi --model kimi-code/k3 --prompt \"Reply: OK\"`."); _cancelSent = false; + LastError = null; State = "ready"; SetActivity("Ready for a task"); } @@ -712,6 +728,7 @@ sealed class KimiAgent : IDisposable try { _cancelSent = false; + LastError = ex.Message; State = "error"; SetActivity("Needs attention"); } @@ -722,6 +739,7 @@ sealed class KimiAgent : IDisposable } finally { + Volatile.Write(ref _isPromptTurnActive, 0); lock (_chatLock) { if (captureBuffer is not null) _captureBuffer = null; @@ -973,11 +991,15 @@ sealed class KimiAgent : IDisposable private void UpdateChatFromSessionUpdate(JsonElement parameters) { + if (parameters.TryGetProperty("sessionId", out var sessionId) && + !string.Equals(sessionId.GetString(), _sessionId, StringComparison.Ordinal)) + return; if (!parameters.TryGetProperty("update", out var update) || !update.TryGetProperty("sessionUpdate", out var typeNode)) return; var type = typeNode.GetString(); if (type == "agent_message_chunk") { var text = ExtractText(update.TryGetProperty("content", out var content) ? content : default); + TrackMeaningfulUpdate(text, isAssistantMessage: true); bool hideTranscript; lock (_chatLock) hideTranscript = _hideTurnTranscript; if (!hideTranscript) AppendChatChunk("assistant", text, ref _assistantEntryId); @@ -997,6 +1019,7 @@ sealed class KimiAgent : IDisposable else if (type == "agent_thought_chunk") { var text = ExtractText(update.TryGetProperty("content", out var content) ? content : default); + TrackMeaningfulUpdate(text); bool hideTranscript; lock (_chatLock) hideTranscript = _hideTurnTranscript; if (!hideTranscript) AppendChatChunk("thought", text, ref _thoughtEntryId); @@ -1004,6 +1027,8 @@ sealed class KimiAgent : IDisposable } else if (type is "tool_call" or "tool_call_update") { + if (update.TryGetProperty("toolCallId", out var toolCallId) && !string.IsNullOrWhiteSpace(toolCallId.GetString())) + TrackMeaningfulUpdate(toolCallId.GetString()!); bool hideTranscript; lock (_chatLock) hideTranscript = _hideTurnTranscript; if (!hideTranscript) UpsertToolEntry(update); @@ -1012,6 +1037,14 @@ sealed class KimiAgent : IDisposable } } + private void TrackMeaningfulUpdate(string value, bool isAssistantMessage = false) + { + if (Volatile.Read(ref _isPromptTurnActive) == 0 || string.IsNullOrWhiteSpace(value)) return; + Interlocked.Increment(ref _turnMeaningfulUpdateCount); + if (isAssistantMessage) + Interlocked.Increment(ref _turnAssistantMessageCount); + } + private void UpsertToolEntry(JsonElement update) { if (!update.TryGetProperty("toolCallId", out var idNode) || string.IsNullOrWhiteSpace(idNode.GetString())) return;