mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +00:00
fix webui tool trace dedupe
This commit is contained in:
parent
c4794b82a9
commit
0a5606b409
@ -106,9 +106,9 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
|
|||||||
if event.get("phase") not in {"start", "end", "error"}:
|
if event.get("phase") not in {"start", "end", "error"}:
|
||||||
continue
|
continue
|
||||||
call_id = event.get("call_id")
|
call_id = event.get("call_id")
|
||||||
if call_id is not None and call_id in seen:
|
if isinstance(call_id, str) and call_id:
|
||||||
continue
|
if call_id in seen:
|
||||||
if call_id is not None:
|
continue
|
||||||
seen.add(call_id)
|
seen.add(call_id)
|
||||||
t = _format_tool_call_trace(event)
|
t = _format_tool_call_trace(event)
|
||||||
if t:
|
if t:
|
||||||
@ -116,6 +116,22 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_unique_tool_trace_lines(
|
||||||
|
previous_traces: list[str],
|
||||||
|
lines: list[str],
|
||||||
|
) -> tuple[list[str], bool]:
|
||||||
|
seen_lines = set(previous_traces)
|
||||||
|
traces = list(previous_traces)
|
||||||
|
added = False
|
||||||
|
for line in lines:
|
||||||
|
if line in seen_lines:
|
||||||
|
continue
|
||||||
|
seen_lines.add(line)
|
||||||
|
traces.append(line)
|
||||||
|
added = True
|
||||||
|
return traces, added
|
||||||
|
|
||||||
|
|
||||||
def replay_transcript_to_ui_messages(
|
def replay_transcript_to_ui_messages(
|
||||||
lines: list[dict[str, Any]],
|
lines: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
@ -448,13 +464,19 @@ def replay_transcript_to_ui_messages(
|
|||||||
and (last.get("activitySegmentId") in (None, segment))
|
and (last.get("activitySegmentId") in (None, segment))
|
||||||
):
|
):
|
||||||
prev_traces = list(last.get("traces") or [last.get("content")])
|
prev_traces = list(last.get("traces") or [last.get("content")])
|
||||||
merged_traces = prev_traces + trace_lines
|
if structured:
|
||||||
messages[-1] = {
|
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
|
||||||
|
if not added:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
merged_traces = prev_traces + trace_lines
|
||||||
|
merged = {
|
||||||
**last,
|
**last,
|
||||||
"traces": merged_traces,
|
"traces": merged_traces,
|
||||||
"content": trace_lines[-1],
|
"content": merged_traces[-1],
|
||||||
"activitySegmentId": last.get("activitySegmentId") or segment,
|
"activitySegmentId": last.get("activitySegmentId") or segment,
|
||||||
}
|
}
|
||||||
|
messages[-1] = merged
|
||||||
else:
|
else:
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -98,6 +98,53 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) ->
|
|||||||
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
|
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
||||||
|
msgs = replay_transcript_to_ui_messages([
|
||||||
|
{
|
||||||
|
"event": "message",
|
||||||
|
"chat_id": "t-tool",
|
||||||
|
"text": 'exec({"cmd":"ls"})',
|
||||||
|
"kind": "tool_hint",
|
||||||
|
"tool_events": [
|
||||||
|
{
|
||||||
|
"phase": "start",
|
||||||
|
"call_id": "call-exec",
|
||||||
|
"name": "exec",
|
||||||
|
"arguments": {"cmd": "ls"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "message",
|
||||||
|
"chat_id": "t-tool",
|
||||||
|
"text": "",
|
||||||
|
"kind": "progress",
|
||||||
|
"tool_events": [
|
||||||
|
{
|
||||||
|
"phase": "end",
|
||||||
|
"call_id": "call-exec",
|
||||||
|
"name": "exec",
|
||||||
|
"arguments": {"cmd": "ls"},
|
||||||
|
"result": "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"phase": "end",
|
||||||
|
"call_id": "call-read",
|
||||||
|
"name": "read_file",
|
||||||
|
"arguments": {"path": "notes.md"},
|
||||||
|
"result": "done",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert len(msgs) == 1
|
||||||
|
assert msgs[0]["traces"] == [
|
||||||
|
'exec({"cmd": "ls"})',
|
||||||
|
'read_file({"path": "notes.md"})',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_build_response_schema(monkeypatch, tmp_path) -> None:
|
def test_build_response_schema(monkeypatch, tmp_path) -> None:
|
||||||
from nanobot.utils.webui_transcript import build_webui_thread_response
|
from nanobot.utils.webui_transcript import build_webui_thread_response
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
@ -652,10 +652,16 @@ export function useNanobotStream(
|
|||||||
: last.content
|
: last.content
|
||||||
? [last.content]
|
? [last.content]
|
||||||
: [];
|
: [];
|
||||||
|
const mergedLines = structuredLines.length > 0
|
||||||
|
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
|
||||||
|
: null;
|
||||||
|
if (mergedLines && !mergedLines.added) return prev;
|
||||||
const merged: UIMessage = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...last,
|
||||||
traces: [...previousTraces, ...lines],
|
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
|
||||||
content: lines[lines.length - 1],
|
content: mergedLines
|
||||||
|
? mergedLines.traces[mergedLines.traces.length - 1]
|
||||||
|
: lines[lines.length - 1],
|
||||||
activitySegmentId: last.activitySegmentId ?? segmentId,
|
activitySegmentId: last.activitySegmentId ?? segmentId,
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, -1), merged];
|
return [...prev.slice(0, -1), merged];
|
||||||
|
|||||||
@ -44,20 +44,35 @@ const VALID_PHASES = new Set(["start", "end", "error"]);
|
|||||||
export function toolTraceLinesFromEvents(events: unknown): string[] {
|
export function toolTraceLinesFromEvents(events: unknown): string[] {
|
||||||
if (!Array.isArray(events)) return [];
|
if (!Array.isArray(events)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return events
|
const lines: string[] = [];
|
||||||
.filter((event) => {
|
for (const event of events) {
|
||||||
if (!event || typeof event !== "object") return false;
|
if (!event || typeof event !== "object") continue;
|
||||||
const phase = (event as { phase?: unknown }).phase;
|
const phase = (event as { phase?: unknown }).phase;
|
||||||
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) {
|
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
|
||||||
return false;
|
const callId = (event as { call_id?: unknown }).call_id;
|
||||||
}
|
if (callId && typeof callId === "string") {
|
||||||
const callId = (event as { call_id?: unknown }).call_id;
|
if (seen.has(callId)) continue;
|
||||||
if (callId && typeof callId === "string") {
|
seen.add(callId);
|
||||||
if (seen.has(callId)) return false;
|
}
|
||||||
seen.add(callId);
|
const line = formatToolCallTrace(event);
|
||||||
}
|
if (!line) continue;
|
||||||
return true;
|
lines.push(line);
|
||||||
})
|
}
|
||||||
.map(formatToolCallTrace)
|
return lines;
|
||||||
.filter((trace): trace is string => !!trace);
|
}
|
||||||
|
|
||||||
|
export function mergeUniqueToolTraceLines(
|
||||||
|
previousTraces: string[],
|
||||||
|
lines: string[],
|
||||||
|
): { traces: string[]; added: boolean } {
|
||||||
|
const seen = new Set(previousTraces);
|
||||||
|
const traces = [...previousTraces];
|
||||||
|
let added = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (seen.has(line)) continue;
|
||||||
|
seen.add(line);
|
||||||
|
traces.push(line);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
return { traces, added };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -308,6 +308,56 @@ describe("useNanobotStream", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dedupes finish-phase tool events after their start trace", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-tool-finish", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-tool-finish", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-tool-finish",
|
||||||
|
text: 'exec({"cmd":"ls"})',
|
||||||
|
kind: "tool_hint",
|
||||||
|
tool_events: [{
|
||||||
|
phase: "start",
|
||||||
|
call_id: "call-exec",
|
||||||
|
name: "exec",
|
||||||
|
arguments: { cmd: "ls" },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fake.emit("chat-tool-finish", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-tool-finish",
|
||||||
|
text: "",
|
||||||
|
kind: "progress",
|
||||||
|
tool_events: [
|
||||||
|
{
|
||||||
|
phase: "end",
|
||||||
|
call_id: "call-exec",
|
||||||
|
name: "exec",
|
||||||
|
arguments: { cmd: "ls" },
|
||||||
|
result: "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "error",
|
||||||
|
call_id: "call-read",
|
||||||
|
name: "read_file",
|
||||||
|
arguments: { path: "notes.md" },
|
||||||
|
error: "missing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(1);
|
||||||
|
expect(result.current.messages[0].traces).toEqual([
|
||||||
|
'exec({"cmd":"ls"})',
|
||||||
|
'read_file({"path":"notes.md"})',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders live file_edit events as their own activity trace", () => {
|
it("renders live file_edit events as their own activity trace", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user