Skip to content

Commit 8efe3f6

Browse files
authored
Preserve compacted sessions and repair split tool results (#8)
1 parent 40daab4 commit 8efe3f6

5 files changed

Lines changed: 434 additions & 72 deletions

File tree

src/DotCraft.Core/Agents/ModelRequestHistorySanitizer.cs

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,18 @@ public static IReadOnlyList<ChatMessage> Sanitize(IReadOnlyList<ChatMessage> mes
2626
{
2727
if (message.Role == ChatRole.Tool)
2828
{
29-
var repairedTool = RepairToolMessage(message, pendingCalls, out var changedTool);
29+
var toolMessages = new List<ChatMessage>();
30+
var blockStart = i;
31+
while (i < messages.Count && messages[i].Role == ChatRole.Tool)
32+
{
33+
toolMessages.Add(messages[i]);
34+
i++;
35+
}
36+
37+
i--;
38+
var repairedTool = RepairToolMessages(toolMessages, pendingCalls, out var changedTool);
3039
if (changedTool)
31-
repaired ??= CopyPrefix(messages, i);
40+
repaired ??= CopyPrefix(messages, blockStart);
3241
repaired?.Add(repairedTool);
3342
pendingCalls = null;
3443
continue;
@@ -75,22 +84,32 @@ private static List<ChatMessage> CopyPrefix(IReadOnlyList<ChatMessage> messages,
7584
return calls;
7685
}
7786

78-
private static ChatMessage RepairToolMessage(
79-
ChatMessage message,
87+
private static ChatMessage RepairToolMessages(
88+
IReadOnlyList<ChatMessage> messages,
8089
IReadOnlyList<FunctionCallContent> pendingCalls,
8190
out bool changed)
8291
{
83-
changed = false;
92+
changed = messages.Count != 1;
8493
var existingResults = new Dictionary<string, FunctionResultContent>(StringComparer.Ordinal);
85-
foreach (var content in message.Contents)
94+
var originalResults = new List<FunctionResultContent>();
95+
var pendingCallIds = pendingCalls
96+
.Select(static call => call.CallId)
97+
.ToHashSet(StringComparer.Ordinal);
98+
99+
foreach (var message in messages)
86100
{
87-
if (content is FunctionResultContent { CallId: { Length: > 0 } } result &&
88-
!existingResults.ContainsKey(result.CallId))
89-
{
90-
existingResults.Add(result.CallId, result);
91-
}
92-
else
101+
foreach (var content in message.Contents)
93102
{
103+
if (content is FunctionResultContent { CallId: { Length: > 0 } } result)
104+
{
105+
originalResults.Add(result);
106+
if (pendingCallIds.Contains(result.CallId) && !existingResults.ContainsKey(result.CallId))
107+
{
108+
existingResults.Add(result.CallId, result);
109+
continue;
110+
}
111+
}
112+
94113
changed = true;
95114
}
96115
}
@@ -108,10 +127,29 @@ private static ChatMessage RepairToolMessage(
108127
changed = true;
109128
}
110129

111-
if (!changed && contents.Count == message.Contents.Count)
112-
return message;
130+
if (!changed)
131+
{
132+
if (originalResults.Count == contents.Count)
133+
{
134+
for (var i = 0; i < contents.Count; i++)
135+
{
136+
if (!ReferenceEquals(originalResults[i], contents[i]))
137+
{
138+
changed = true;
139+
break;
140+
}
141+
}
142+
}
143+
else
144+
{
145+
changed = true;
146+
}
147+
}
148+
149+
if (!changed)
150+
return messages[0];
113151

114-
return CloneWithContents(message, contents);
152+
return CloneWithContents(messages[0], contents);
115153
}
116154

117155
private static ChatMessage CreateSyntheticToolMessage(IEnumerable<FunctionCallContent> calls) =>

src/DotCraft.Core/Protocol/SessionService.cs

Lines changed: 153 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,7 +1675,6 @@ void LogStreamDebugSessionEvent(SessionEvent evt)
16751675
var mainTraceUsageBaseline = 0;
16761676
long inputTokens = 0, outputTokens = 0, cachedInputTokens = 0, cacheWriteInputTokens = 0, reasoningOutputTokens = 0;
16771677
var llmCallCount = 0;
1678-
var optimizedSessionChanged = false;
16791678
Dictionary<int, SessionItem>? streamingToolCallItemsByIndex = null;
16801679
Dictionary<int, string>? streamingToolNameByIndex = null;
16811680
Dictionary<string, SessionItem>? streamingToolCallItemsByCallId = null;
@@ -1888,7 +1887,6 @@ async Task RestoreUndrainedGuidanceAsync()
18881887
[.. result.Messages],
18891888
jsonSerializerOptions: SessionPersistenceJsonOptions.Default);
18901889
InvalidatePromptRequestSnapshot(threadId, "auto_compaction");
1891-
optimizedSessionChanged = true;
18921890
await TrySaveSessionAsync(agent, session, threadId);
18931891
var contextUsage = await SaveContextUsageSnapshotAsync(
18941892
threadId,
@@ -1940,10 +1938,7 @@ async Task RestoreUndrainedGuidanceAsync()
19401938
}
19411939
}
19421940

1943-
async Task FailAndPersistTurnAsync(
1944-
string errorMsg,
1945-
string errorCode,
1946-
bool saveCurrentSession = false)
1941+
async Task FailAndPersistTurnAsync(string errorMsg, string errorCode)
19471942
{
19481943
FinalizeStreamingAgentMessage();
19491944
FinalizeStreamingReasoning();
@@ -1957,12 +1952,20 @@ async Task FailAndPersistTurnAsync(
19571952
FailTurn(turn, eventChannel, errorMsg);
19581953
ThreadRuntimeSignalForBroadcast?.Invoke(threadId, SessionThreadRuntimeSignal.TurnFailed);
19591954
await TrySaveThreadAsync(thread);
1960-
if (saveCurrentSession && session is not null)
1961-
await TrySaveSessionAsync(agent, session, threadId);
1962-
else if (optimizedSessionChanged && session is not null)
1963-
await TrySaveSessionAsync(agent, session, threadId);
1964-
else
1965-
await TryRebuildAndSaveSessionAsync(agent, threadId);
1955+
if (session is not null)
1956+
{
1957+
if (TryAppendFailedTurnTailToSession(session, turn) &&
1958+
await TrySaveSessionAsync(agent, session, threadId))
1959+
{
1960+
return;
1961+
}
1962+
}
1963+
else if (persistence.SessionFileExists(threadId))
1964+
{
1965+
return;
1966+
}
1967+
1968+
await TryRebuildAndSaveSessionAsync(agent, threadId);
19661969
}
19671970

19681971
try
@@ -2763,7 +2766,6 @@ await AccountGoalUsageAsync(
27632766
// already gone), but the compacted history lets the user re-send their
27642767
// prompt and succeed without any manual cleanup.
27652768
var reactiveMessage = ex.Message;
2766-
var reactiveCompactionSucceeded = false;
27672769
if (CompactionErrors.IsPromptTooLong(ex) && session is not null)
27682770
{
27692771
try
@@ -2778,7 +2780,6 @@ await AccountGoalUsageAsync(
27782780
{
27792781
tokenTracker?.Reset();
27802782
InvalidatePromptRequestSnapshot(threadId, "reactive_compaction");
2781-
optimizedSessionChanged = true;
27822783
var contextUsage = await SaveContextUsageSnapshotAsync(
27832784
threadId,
27842785
status.ThresholdAfter.Tokens,
@@ -2807,7 +2808,6 @@ await AccountGoalUsageAsync(
28072808
reactiveMessage =
28082809
"The request exceeded the model's context window. "
28092810
+ "History has been compacted; please re-send the message.";
2810-
reactiveCompactionSucceeded = true;
28112811
}
28122812
else
28132813
{
@@ -2829,8 +2829,7 @@ await AccountGoalUsageAsync(
28292829

28302830
await FailAndPersistTurnAsync(
28312831
reactiveMessage,
2832-
"agent_error",
2833-
saveCurrentSession: reactiveCompactionSucceeded);
2832+
"agent_error");
28342833
}
28352834
finally
28362835
{
@@ -4395,6 +4394,143 @@ private async Task TrySaveThreadAsync(SessionThread thread)
43954394
}
43964395
}
43974396

4397+
private static bool TryAppendFailedTurnTailToSession(AgentSession session, SessionTurn turn)
4398+
{
4399+
if (!session.TryGetInMemoryChatHistory(
4400+
out var history,
4401+
jsonSerializerOptions: SessionPersistenceJsonOptions.Default))
4402+
{
4403+
return false;
4404+
}
4405+
4406+
var turnTail = ThreadStore.BuildModelVisibleHistoryFromTurn(turn);
4407+
if (turnTail.Count == 0)
4408+
return true;
4409+
4410+
var overlap = FindHistoryTailOverlap(history, turnTail);
4411+
if (overlap >= turnTail.Count)
4412+
return true;
4413+
4414+
var merged = new List<ChatMessage>(history.Count + turnTail.Count - overlap);
4415+
merged.AddRange(history);
4416+
for (var i = overlap; i < turnTail.Count; i++)
4417+
merged.Add(turnTail[i]);
4418+
4419+
session.SetInMemoryChatHistory(merged, jsonSerializerOptions: SessionPersistenceJsonOptions.Default);
4420+
return true;
4421+
}
4422+
4423+
private static int FindHistoryTailOverlap(
4424+
IReadOnlyList<ChatMessage> history,
4425+
IReadOnlyList<ChatMessage> tail)
4426+
{
4427+
var max = Math.Min(history.Count, tail.Count);
4428+
for (var length = max; length > 0; length--)
4429+
{
4430+
var historyStart = history.Count - length;
4431+
var matched = true;
4432+
for (var i = 0; i < length; i++)
4433+
{
4434+
if (ChatMessagesEquivalent(history[historyStart + i], tail[i]))
4435+
continue;
4436+
4437+
matched = false;
4438+
break;
4439+
}
4440+
4441+
if (matched)
4442+
return length;
4443+
}
4444+
4445+
return 0;
4446+
}
4447+
4448+
private static bool ChatMessagesEquivalent(ChatMessage left, ChatMessage right)
4449+
{
4450+
if (left.Role != right.Role)
4451+
return false;
4452+
4453+
var leftContents = BuildContentSignatures(left);
4454+
var rightContents = BuildContentSignatures(right);
4455+
return leftContents.SequenceEqual(rightContents, StringComparer.Ordinal);
4456+
}
4457+
4458+
private static List<string> BuildContentSignatures(ChatMessage message)
4459+
{
4460+
var signatures = new List<string>();
4461+
foreach (var content in message.Contents)
4462+
{
4463+
switch (content)
4464+
{
4465+
case TextContent text:
4466+
{
4467+
var normalized = NormalizeTextForHistorySignature(text.Text);
4468+
if (!string.IsNullOrWhiteSpace(normalized))
4469+
signatures.Add("text:" + normalized);
4470+
break;
4471+
}
4472+
case TextReasoningContent reasoning:
4473+
{
4474+
if (ReasoningContentHelper.TryGetText(reasoning, out var reasoningText) &&
4475+
!string.IsNullOrWhiteSpace(reasoningText))
4476+
{
4477+
signatures.Add("reasoning:" + reasoningText.Trim());
4478+
}
4479+
4480+
break;
4481+
}
4482+
case FunctionCallContent call:
4483+
signatures.Add($"call:{call.CallId}:{call.Name}");
4484+
break;
4485+
case FunctionResultContent result:
4486+
signatures.Add(
4487+
$"result:{result.CallId}:{ImageContentSanitizingChatClient.DescribeResult(result.Result)}");
4488+
break;
4489+
default:
4490+
signatures.Add($"{content.GetType().FullName}:{content}");
4491+
break;
4492+
}
4493+
}
4494+
4495+
return signatures;
4496+
}
4497+
4498+
private static string NormalizeTextForHistorySignature(string? text)
4499+
{
4500+
var normalized = StripSystemReminderBlocks(text).Trim();
4501+
var runtimeContextIndex = normalized.IndexOf("\n[Runtime Context]", StringComparison.Ordinal);
4502+
if (runtimeContextIndex >= 0)
4503+
normalized = normalized[..runtimeContextIndex].Trim();
4504+
return normalized;
4505+
}
4506+
4507+
private static string StripSystemReminderBlocks(string? text)
4508+
{
4509+
if (string.IsNullOrEmpty(text))
4510+
return string.Empty;
4511+
4512+
const string startTag = "<system-reminder>";
4513+
const string endTag = "</system-reminder>";
4514+
4515+
var result = text;
4516+
var searchStart = 0;
4517+
while (searchStart < result.Length)
4518+
{
4519+
var start = result.IndexOf(startTag, searchStart, StringComparison.Ordinal);
4520+
if (start < 0)
4521+
break;
4522+
4523+
var end = result.IndexOf(endTag, start + startTag.Length, StringComparison.Ordinal);
4524+
var removeLength = end < 0
4525+
? result.Length - start
4526+
: end + endTag.Length - start;
4527+
result = result.Remove(start, removeLength);
4528+
searchStart = start;
4529+
}
4530+
4531+
return result;
4532+
}
4533+
43984534
private async Task<bool> TrySaveSessionAsync(AIAgent agent, AgentSession session, string threadId)
43994535
{
44004536
if (IsPendingPermanentDeletion(threadId))

0 commit comments

Comments
 (0)