Skip to content

Commit 76a9bac

Browse files
bkudiessCopilot
andcommitted
Fix native tool event correlation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 862c995 commit 76a9bac

7 files changed

Lines changed: 837 additions & 183 deletions

File tree

‎src/OpenClaw.Chat/ChatModels.cs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ public enum ChatToolCallStatus
8383
Interrupted
8484
}
8585

86+
public enum ChatToolErrorTextQuality
87+
{
88+
Unspecified,
89+
SafeSummary
90+
}
91+
8692
public enum ChatToolIdentityStrength
8793
{
8894
Fallback,
@@ -150,7 +156,8 @@ public record ChatTimelineItem(
150156
System.Collections.Immutable.ImmutableHashSet<string>? ToolCorrelationIds = null,
151157
long ToolOutcomeSequence = 0,
152158
string? ToolRunId = null,
153-
long ToolLegacyTurn = 0);
159+
long ToolLegacyTurn = 0,
160+
ChatToolErrorTextQuality ToolErrorTextQuality = ChatToolErrorTextQuality.Unspecified);
154161

155162
public readonly record struct ChatToolCorrelationKey(
156163
string? RunId,
@@ -198,7 +205,8 @@ public record ChatPendingToolPresentation(
198205
public record ChatPendingToolOutcome(
199206
string Text,
200207
ChatToolCallStatus Status,
201-
long Sequence);
208+
long Sequence,
209+
ChatToolErrorTextQuality ErrorTextQuality = ChatToolErrorTextQuality.Unspecified);
202210

203211
public enum ChatQueuedMessageSendState
204212
{
@@ -255,7 +263,8 @@ public record ChatToolOutputEvent(
255263
public record ChatToolErrorEvent(
256264
string Text,
257265
string? ToolCallId = null,
258-
string? RunId = null) : ChatEvent;
266+
string? RunId = null,
267+
ChatToolErrorTextQuality ErrorTextQuality = ChatToolErrorTextQuality.Unspecified) : ChatEvent;
259268
public record ChatToolReplayResetEvent : ChatEvent;
260269
public record ChatContextChangedEvent(string? Cwd, string? GitBranch) : ChatEvent;
261270
public record ChatStatusEvent(string Text, ChatTone Tone) : ChatEvent;

‎src/OpenClaw.Chat/ChatTimelineReducer.cs‎

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,12 @@ static ChatTimelineState ApplyToolStart(ChatTimelineState state, ChatToolStartEv
372372
correlationId);
373373
if (!pendingOutcomes.TryGetValue(outcomeKey, out var outcome))
374374
continue;
375-
entry = ApplyToolOutcome(entry, outcome);
375+
entry = ApplyToolOutcome(
376+
entry,
377+
outcome,
378+
state.TerminalToolCorrelations?.ContainsKey(outcomeKey) == true
379+
? outcomeKey.RunId
380+
: null);
376381
pendingOutcomes = pendingOutcomes.Remove(outcomeKey);
377382
if (!isTerminalReplay)
378383
terminalCorrelations = terminalCorrelations.Remove(outcomeKey);
@@ -529,7 +534,12 @@ existing with
529534
var outcomeKey = CorrelationKey(entry.ToolRunId, entry.ToolLegacyTurn, correlationId);
530535
if (!pendingOutcomes.TryGetValue(outcomeKey, out var outcome))
531536
continue;
532-
entry = ApplyToolOutcome(entry, outcome);
537+
entry = ApplyToolOutcome(
538+
entry,
539+
outcome,
540+
state.TerminalToolCorrelations?.ContainsKey(outcomeKey) == true
541+
? outcomeKey.RunId
542+
: null);
533543
pendingOutcomes = pendingOutcomes.Remove(outcomeKey);
534544
}
535545

@@ -547,26 +557,32 @@ static ChatTimelineState ApplyToolOutput(ChatTimelineState state, ChatToolOutput
547557
ApplyToolOutcome(state, e.Text, e.ToolCallId, e.RunId, ChatToolCallStatus.Success);
548558

549559
static ChatTimelineState ApplyToolError(ChatTimelineState state, ChatToolErrorEvent e) =>
550-
ApplyToolOutcome(state, e.Text, e.ToolCallId, e.RunId, ChatToolCallStatus.Error);
560+
ApplyToolOutcome(
561+
state, e.Text, e.ToolCallId, e.RunId, ChatToolCallStatus.Error, e.ErrorTextQuality);
551562

552563
static ChatTimelineState ApplyToolOutcome(
553564
ChatTimelineState state,
554565
string text,
555566
string? toolCallId,
556567
string? runId,
557-
ChatToolCallStatus status)
568+
ChatToolCallStatus status,
569+
ChatToolErrorTextQuality errorTextQuality = ChatToolErrorTextQuality.Unspecified)
558570
{
559571
var sequence = state.NextToolOutcomeSequence;
560-
var incoming = new ChatPendingToolOutcome(text, status, sequence);
572+
var incoming = new ChatPendingToolOutcome(text, status, sequence, errorTextQuality);
561573
var key = toolCallId is { Length: > 0 }
562574
? ResolveBufferedCorrelationKey(state, runId, toolCallId)
563575
: default(ChatToolCorrelationKey?);
576+
var recoveryRunId = key is { } recoveryKey
577+
&& state.TerminalToolCorrelations?.ContainsKey(recoveryKey) == true
578+
? runId
579+
: null;
564580
var (entries, entryId) = ResolveToolEntry(state, key);
565581
if (entryId is { } tid)
566582
{
567583
var idx = entries.FindIndex(en => en.Id == tid);
568584
if (idx >= 0)
569-
entries = entries.SetItem(idx, ApplyToolOutcome(entries[idx], incoming));
585+
entries = entries.SetItem(idx, ApplyToolOutcome(entries[idx], incoming, recoveryRunId));
570586
}
571587

572588
var pendingOutcomes = state.PendingToolOutcomes
@@ -604,15 +620,41 @@ static ChatTimelineState ApplyToolOutcome(
604620

605621
static ChatTimelineItem ApplyToolOutcome(
606622
ChatTimelineItem entry,
607-
ChatPendingToolOutcome incoming)
623+
ChatPendingToolOutcome incoming,
624+
string? incomingRunId = null)
608625
{
609626
var currentStatus = entry.ToolResult ?? ChatToolCallStatus.InProgress;
610627
var currentPrecedence = ToolOutcomePrecedence(currentStatus);
611628
var incomingPrecedence = ToolOutcomePrecedence(incoming.Status);
629+
var currentErrorQuality = currentStatus == ChatToolCallStatus.Error
630+
? entry.ToolErrorTextQuality
631+
: ChatToolErrorTextQuality.Unspecified;
632+
var incomingErrorQuality = incoming.Status == ChatToolCallStatus.Error
633+
? incoming.ErrorTextQuality
634+
: ChatToolErrorTextQuality.Unspecified;
612635
if (incomingPrecedence < currentPrecedence
613636
|| (incomingPrecedence == currentPrecedence
614-
&& incoming.Sequence < entry.ToolOutcomeSequence))
637+
&& (incomingErrorQuality < currentErrorQuality
638+
|| (incomingErrorQuality == currentErrorQuality
639+
&& incoming.Sequence < entry.ToolOutcomeSequence))))
615640
{
641+
if (currentStatus == ChatToolCallStatus.Interrupted
642+
&& incoming.Status == ChatToolCallStatus.Success
643+
&& !string.IsNullOrWhiteSpace(incomingRunId)
644+
&& string.Equals(incomingRunId, entry.ToolRunId, StringComparison.Ordinal)
645+
&& incoming.Sequence >= entry.ToolOutcomeSequence)
646+
{
647+
return entry with
648+
{
649+
ToolResult = ChatToolCallStatus.Success,
650+
ToolOutput = string.IsNullOrEmpty(incoming.Text) && entry.ToolOutput is not null
651+
? entry.ToolOutput
652+
: incoming.Text,
653+
ToolOutcomeSequence = incoming.Sequence,
654+
ToolErrorTextQuality = incomingErrorQuality
655+
};
656+
}
657+
616658
if (currentStatus == ChatToolCallStatus.Interrupted
617659
&& incoming.Sequence >= entry.ToolOutcomeSequence
618660
&& !string.IsNullOrEmpty(incoming.Text))
@@ -632,7 +674,8 @@ static ChatTimelineItem ApplyToolOutcome(
632674
ToolOutput = string.IsNullOrEmpty(incoming.Text) && entry.ToolOutput is not null
633675
? entry.ToolOutput
634676
: incoming.Text,
635-
ToolOutcomeSequence = incoming.Sequence
677+
ToolOutcomeSequence = incoming.Sequence,
678+
ToolErrorTextQuality = incomingErrorQuality
636679
};
637680
}
638681

@@ -644,6 +687,9 @@ static ChatPendingToolOutcome SelectToolOutcome(
644687
var incomingPrecedence = ToolOutcomePrecedence(incoming.Status);
645688
if (incomingPrecedence != existingPrecedence)
646689
return incomingPrecedence > existingPrecedence ? incoming : existing;
690+
if (incoming.Status == ChatToolCallStatus.Error
691+
&& incoming.ErrorTextQuality != existing.ErrorTextQuality)
692+
return incoming.ErrorTextQuality > existing.ErrorTextQuality ? incoming : existing;
647693
return incoming.Sequence >= existing.Sequence ? incoming : existing;
648694
}
649695

‎src/OpenClaw.Tray.WinUI/Chat/NativeToolProjector.cs‎

Lines changed: 141 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text;
12
using System.Text.Json;
23
using System.Text.Json.Nodes;
34
using System.Text.RegularExpressions;
@@ -105,31 +106,42 @@ internal static bool TryGetKnownToolIdentity(string? value, out string identity)
105106
return false;
106107
}
107108

108-
internal static string ExtractParentToolCallId(JsonElement data)
109+
internal static string? ExtractToolCorrelationId(JsonElement data)
109110
{
110-
var parent = GetStringProperty(data, "parentItemId", "parentId", "toolItemId", "parentCallId");
111-
if (!string.IsNullOrWhiteSpace(parent))
112-
return parent;
111+
if (data.TryGetProperty("toolCallId", out var toolCallIdValue)
112+
&& toolCallIdValue.ValueKind == JsonValueKind.String)
113+
{
114+
var toolCallId = toolCallIdValue.GetString();
115+
if (!string.IsNullOrWhiteSpace(toolCallId))
116+
return toolCallId;
117+
}
113118

114-
foreach (var containerName in new[] { "details", "parent" })
119+
if (!data.TryGetProperty("itemId", out var itemIdValue)
120+
|| itemIdValue.ValueKind != JsonValueKind.String)
115121
{
116-
if (data.TryGetProperty(containerName, out var container)
117-
&& container.ValueKind == JsonValueKind.Object)
118-
{
119-
parent = GetStringProperty(
120-
container,
121-
"parentItemId",
122-
"parentId",
123-
"toolItemId",
124-
"itemId",
125-
"callId");
126-
if (!string.IsNullOrWhiteSpace(parent))
127-
return parent;
128-
}
122+
return null;
129123
}
130-
return string.Empty;
124+
125+
var itemId = itemIdValue.GetString();
126+
if (string.IsNullOrWhiteSpace(itemId))
127+
return null;
128+
129+
foreach (var prefix in new[] { "tool:", "command:", "patch:" })
130+
{
131+
if (!itemId.StartsWith(prefix, StringComparison.Ordinal))
132+
continue;
133+
134+
itemId = itemId[prefix.Length..];
135+
break;
136+
}
137+
138+
return string.IsNullOrWhiteSpace(itemId) ? null : itemId;
131139
}
132140

141+
internal static bool IsToolResultError(JsonElement data) =>
142+
data.TryGetProperty("isError", out var isError)
143+
&& isError.ValueKind == JsonValueKind.True;
144+
133145
internal static string GetStringProperty(JsonElement data, params string[] names)
134146
{
135147
foreach (var name in names)
@@ -215,14 +227,6 @@ internal static string ExtractCommandOutputText(JsonElement data)
215227
}
216228
}
217229

218-
if (data.TryGetProperty("title", out var title)
219-
&& title.ValueKind == JsonValueKind.String)
220-
{
221-
var text = title.GetString();
222-
if (!string.IsNullOrEmpty(text))
223-
return TruncateToolOutput(text);
224-
}
225-
226230
return string.Empty;
227231
}
228232

@@ -240,11 +244,38 @@ internal static string ExtractToolResultText(JsonElement data, string fallback)
240244
{
241245
if (result.ValueKind == JsonValueKind.String)
242246
return TruncateToolOutput(result.GetString() ?? "");
243-
if (result.ValueKind == JsonValueKind.Object
244-
&& result.TryGetProperty("content", out var resultContent)
245-
&& resultContent.ValueKind == JsonValueKind.String)
247+
if (result.ValueKind == JsonValueKind.Array)
246248
{
247-
return TruncateToolOutput(resultContent.GetString() ?? "");
249+
var arrayText = ExtractTypedTextBlocks(result);
250+
if (!string.IsNullOrEmpty(arrayText))
251+
return arrayText;
252+
}
253+
if (result.ValueKind == JsonValueKind.Object)
254+
{
255+
if (result.TryGetProperty("details", out var details)
256+
&& details.ValueKind == JsonValueKind.Object)
257+
{
258+
var aggregated = GetStringProperty(details, "aggregated");
259+
if (!string.IsNullOrEmpty(aggregated))
260+
return TruncateToolOutput(aggregated);
261+
}
262+
263+
if (result.TryGetProperty("content", out var resultContent)
264+
&& resultContent.ValueKind == JsonValueKind.String)
265+
{
266+
return TruncateToolOutput(resultContent.GetString() ?? "");
267+
}
268+
269+
if (result.TryGetProperty("content", out resultContent))
270+
{
271+
var contentText = ExtractTypedTextBlocks(resultContent);
272+
if (!string.IsNullOrEmpty(contentText))
273+
return contentText;
274+
}
275+
276+
var directText = ExtractTypedTextBlocks(result);
277+
if (!string.IsNullOrEmpty(directText))
278+
return directText;
248279
}
249280
}
250281

@@ -263,7 +294,23 @@ internal static string ExtractToolResultText(JsonElement data, string fallback)
263294

264295
internal static string ExtractToolErrorText(JsonElement data, string fallback)
265296
{
266-
foreach (var key in new[] { "error", "message", "stderr", "content" })
297+
if (data.TryGetProperty("result", out var result)
298+
&& result.ValueKind == JsonValueKind.Object)
299+
{
300+
if (result.TryGetProperty("details", out var details)
301+
&& details.ValueKind == JsonValueKind.Object)
302+
{
303+
var detailsError = GetStringProperty(details, "error", "message", "reason");
304+
if (!string.IsNullOrEmpty(detailsError))
305+
return TruncateToolOutput(detailsError);
306+
}
307+
308+
var resultError = GetStringProperty(result, "error", "message", "reason");
309+
if (!string.IsNullOrEmpty(resultError))
310+
return TruncateToolOutput(resultError);
311+
}
312+
313+
foreach (var key in new[] { "error", "message", "stderr", "summary" })
267314
{
268315
if (data.TryGetProperty(key, out var value))
269316
{
@@ -283,9 +330,71 @@ internal static string ExtractToolErrorText(JsonElement data, string fallback)
283330
}
284331
}
285332
}
333+
334+
var resultText = ExtractToolResultText(data, string.Empty);
335+
if (!string.IsNullOrEmpty(resultText))
336+
return resultText;
337+
286338
return fallback;
287339
}
288340

341+
internal static string ExtractToolResultErrorText(JsonElement data)
342+
{
343+
var safeSummary = SanitizeToolDisplayValue(
344+
GetStringProperty(data, "toolErrorSummary"));
345+
return string.IsNullOrEmpty(safeSummary)
346+
? ExtractToolErrorText(data, string.Empty)
347+
: safeSummary;
348+
}
349+
350+
internal static bool HasSafeToolErrorSummary(JsonElement data) =>
351+
!string.IsNullOrEmpty(SanitizeToolDisplayValue(
352+
GetStringProperty(data, "toolErrorSummary")));
353+
354+
internal static string ExtractPatchSummaryText(JsonElement data)
355+
{
356+
var summary = GetStringProperty(data, "summary");
357+
return string.IsNullOrEmpty(summary) ? string.Empty : TruncateToolOutput(summary);
358+
}
359+
360+
private static string ExtractTypedTextBlocks(JsonElement content)
361+
{
362+
var builder = new StringBuilder();
363+
void AppendBlock(JsonElement block)
364+
{
365+
if (builder.Length > ToolOutputMaxChars
366+
|| block.ValueKind != JsonValueKind.Object
367+
|| !string.Equals(GetStringProperty(block, "type"), "text", StringComparison.Ordinal)
368+
|| !block.TryGetProperty("text", out var textValue)
369+
|| textValue.ValueKind != JsonValueKind.String)
370+
{
371+
return;
372+
}
373+
374+
var text = textValue.GetString()?.Trim();
375+
if (string.IsNullOrEmpty(text))
376+
return;
377+
378+
if (builder.Length > 0)
379+
builder.Append('\n');
380+
var remaining = ToolOutputMaxChars + 1 - builder.Length;
381+
if (remaining > 0)
382+
builder.Append(text.AsSpan(0, Math.Min(text.Length, remaining)));
383+
}
384+
385+
if (content.ValueKind == JsonValueKind.Array)
386+
{
387+
foreach (var block in content.EnumerateArray())
388+
AppendBlock(block);
389+
}
390+
else
391+
{
392+
AppendBlock(content);
393+
}
394+
395+
return TruncateToolOutput(builder.ToString());
396+
}
397+
289398
internal static bool LooksLikeSystemControlNote(string text)
290399
{
291400
if (string.IsNullOrEmpty(text))

0 commit comments

Comments
 (0)