mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
fix(lens): retain successful split content reads
This commit is contained in:
parent
477828ac49
commit
d5202e59ea
4 changed files with 99 additions and 21 deletions
|
|
@ -477,6 +477,63 @@ describe("TraceConversation", () => {
|
|||
expect(screen.queryByText("End of conversation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retains readable split results and retries only the oversized span", async () => {
|
||||
const user = userEvent.setup();
|
||||
const oversized = { ...tool, span_id: "oversized", name: "Large result", start_offset_ms: 2 };
|
||||
const later = { ...tool, span_id: "later", name: "Later result", start_offset_ms: 3 };
|
||||
const readLarge = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new ApiError("Content too large", 413, {}))
|
||||
.mockRejectedValueOnce(new ApiError("Content too large", 413, {}))
|
||||
.mockResolvedValue([{ ...toolDetail, span_id: "oversized" }]);
|
||||
vi.mocked(agentTraceSpansCall).mockImplementation(async (_token, _trace, ids) => {
|
||||
if (ids.length > 1) throw new ApiError("Content too large", 413, {});
|
||||
if (ids[0] === "oversized") return readLarge();
|
||||
return [ids[0] === "root" ? rootDetail : { ...toolDetail, span_id: ids[0] }];
|
||||
});
|
||||
renderWithProviders(
|
||||
<TraceConversation
|
||||
trace={{ ...trace, spans: [root, tool, oversized, later] } as Trace}
|
||||
accessToken="test"
|
||||
onOpenStep={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("starting at Large result");
|
||||
expect(screen.getByText("Read the release notes")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Expand read_file tool call" })).toBeVisible();
|
||||
expect(screen.queryByText("The release is ready")).not.toBeInTheDocument();
|
||||
const firstRequests = vi.mocked(agentTraceSpansCall).mock.calls.length;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry batch" }));
|
||||
await waitFor(() => expect(readLarge).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("starting at Large result");
|
||||
expect(screen.getByRole("button", { name: "Expand read_file tool call" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Retry batch" }));
|
||||
expect(await screen.findByText("The release is ready")).toBeVisible();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(
|
||||
vi
|
||||
.mocked(agentTraceSpansCall)
|
||||
.mock.calls.slice(firstRequests)
|
||||
.map((call) => call[2]),
|
||||
).toEqual([["oversized"], ["oversized"]]);
|
||||
});
|
||||
|
||||
it("shows conflicting argument evidence beside arguments even with a recorded output", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(agentTraceSpansCall).mockResolvedValue([
|
||||
rootDetail,
|
||||
{ ...toolDetail, attributes: { "lens.content.input_status": "conflicting" } },
|
||||
]);
|
||||
renderWithProviders(<TraceConversation trace={trace} accessToken="test" onOpenStep={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: "Expand read_file tool call" }));
|
||||
expect(
|
||||
screen.getByText("Conflicting tool arguments were recorded. Inspect the capture source spans."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("CHANGELOG.md")).toBeVisible();
|
||||
expect(screen.getByText("All checks passed")).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows a missing step explicitly and lets the user retry it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(agentTraceSpanCall).mockRejectedValueOnce(new Error("temporarily unavailable"));
|
||||
|
|
|
|||
|
|
@ -138,11 +138,11 @@ function ConversationFeedback({
|
|||
return (
|
||||
<>
|
||||
{entries.map(
|
||||
({ query, span }) =>
|
||||
query.isError && (
|
||||
({ failed, retry, span }) =>
|
||||
failed && (
|
||||
<div key={span.span_id} role="alert" className="rounded-md border p-3 text-sm">
|
||||
Could not load conversation content starting at {span.name}. Retry this batch to continue.
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={() => query.refetch()}>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={() => retry()}>
|
||||
Retry batch
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -345,6 +345,7 @@ function ConversationTool({ item, onOpenStep }: { item: ConversationItem; onOpen
|
|||
{open && (
|
||||
<div className="min-w-0 space-y-3 border-t px-3 py-3">
|
||||
{item.toolCall && <ToolArguments args={item.toolCall.args} />}
|
||||
{item.inputWarning && <p className="text-sm text-muted-foreground">{item.inputWarning}</p>}
|
||||
{item.span.error && item.span.error !== item.toolResult && (
|
||||
<p className="text-sm text-destructive">{item.span.error}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export interface ConversationItem {
|
|||
toolCall?: TraceToolCall;
|
||||
toolResult?: string;
|
||||
toolAttempt?: { isError: boolean };
|
||||
inputWarning?: string;
|
||||
contentWarning?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
|
|
@ -159,12 +160,16 @@ function toolItem(
|
|||
? detail.output_ui.text
|
||||
: prettyPayload(detail.output) || contentText(detail.output, detail.output_ui);
|
||||
const status = detail.attributes["lens.content.output_status"];
|
||||
const inputWarning =
|
||||
detail.attributes["lens.content.input_status"] === "conflicting"
|
||||
? "Conflicting tool arguments were recorded. Inspect the capture source spans."
|
||||
: undefined;
|
||||
const contentWarning = (() => {
|
||||
if (status === "conflicting") return "Conflicting tool outputs were recorded. Inspect the capture source spans.";
|
||||
if (status === "recorded" && !result) return "The recorded result is empty.";
|
||||
return undefined;
|
||||
})();
|
||||
return { id: span.span_id, span, messages: [], toolCall: call, toolResult: result, contentWarning };
|
||||
return { id: span.span_id, span, messages: [], toolCall: call, toolResult: result, inputWarning, contentWarning };
|
||||
}
|
||||
|
||||
interface ConversationEvent {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,24 @@ import { useTracesApi, type TracesApi } from "../../api";
|
|||
import type { Span, SpanDetail, Trace } from "../../types";
|
||||
import { CONVERSATION_PAGE_SIZE, conversationSteps } from "./conversation";
|
||||
|
||||
async function readBatch(traces: TracesApi, traceId: string, ids: string[], traceRef?: string): Promise<SpanDetail[]> {
|
||||
interface BatchResult {
|
||||
details: SpanDetail[];
|
||||
failedIds: string[];
|
||||
}
|
||||
|
||||
async function readBatch(traces: TracesApi, traceId: string, ids: string[], traceRef?: string): Promise<BatchResult> {
|
||||
try {
|
||||
return await traces.spans(traceId, ids, traceRef);
|
||||
const details = await traces.spans(traceId, ids, traceRef);
|
||||
const returned = new Set(details.map((detail) => detail.span_id));
|
||||
return { details, failedIds: ids.filter((id) => !returned.has(id)) };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 413 || ids.length <= 1) throw error;
|
||||
if (!(error instanceof ApiError) || error.status !== 413 || ids.length <= 1) {
|
||||
return { details: [], failedIds: ids };
|
||||
}
|
||||
const middle = Math.ceil(ids.length / 2);
|
||||
const first = await readBatch(traces, traceId, ids.slice(0, middle), traceRef);
|
||||
return [...first, ...(await readBatch(traces, traceId, ids.slice(middle), traceRef))];
|
||||
const second = await readBatch(traces, traceId, ids.slice(middle), traceRef);
|
||||
return { details: [...first.details, ...second.details], failedIds: [...first.failedIds, ...second.failedIds] };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -26,16 +36,18 @@ export function useConversationDetails(trace: Trace, accessToken: string) {
|
|||
const batchQuery = useCallback(
|
||||
(batch: Span[]) => {
|
||||
const ids = batch.map((span) => span.span_id);
|
||||
const queryKey = ["agentTraceContents", traceId, traceRef, ids, spanCount, accessToken];
|
||||
return {
|
||||
queryKey: ["agentTraceContents", traceId, traceRef, ids, spanCount, accessToken],
|
||||
queryFn: async (): Promise<SpanDetail[]> => {
|
||||
const details = await readBatch(traces, traceId, ids, traceRef);
|
||||
const returned = new Set(details.map((detail) => detail.span_id));
|
||||
if (ids.some((id) => !returned.has(id))) throw new Error("Some requested spans are unavailable");
|
||||
for (const detail of details) {
|
||||
queryKey,
|
||||
queryFn: async (): Promise<BatchResult> => {
|
||||
const previous = queryClient.getQueryData<BatchResult>(queryKey);
|
||||
const retained = previous?.failedIds.length ? previous.details : [];
|
||||
const pending = previous?.failedIds.length ? previous.failedIds : ids;
|
||||
const result = await readBatch(traces, traceId, pending, traceRef);
|
||||
for (const detail of result.details) {
|
||||
queryClient.setQueryData(["agentTraceSpan", traceId, traceRef, detail.span_id, accessToken], detail);
|
||||
}
|
||||
return details;
|
||||
return { details: [...retained, ...result.details], failedIds: result.failedIds };
|
||||
},
|
||||
staleTime: 30_000,
|
||||
retry: false as const,
|
||||
|
|
@ -48,10 +60,9 @@ export function useConversationDetails(trace: Trace, accessToken: string) {
|
|||
visible.slice(index * CONVERSATION_PAGE_SIZE, (index + 1) * CONVERSATION_PAGE_SIZE),
|
||||
);
|
||||
const queries = useQueries({ queries: batches.map(batchQuery) });
|
||||
const unresolvedIndex = queries.findIndex((query) => !query.isSuccess);
|
||||
const loadedCount = unresolvedIndex < 0 ? queries.length : unresolvedIndex;
|
||||
const complete = queries.every((query) => query.isSuccess && query.data.failedIds.length === 0);
|
||||
const details = new Map(
|
||||
queries.slice(0, loadedCount).flatMap((query) => query.data!.map((detail) => [detail.span_id, detail] as const)),
|
||||
queries.flatMap((query) => (query.data?.details ?? []).map((detail) => [detail.span_id, detail] as const)),
|
||||
);
|
||||
const loadMore = useCallback(
|
||||
async (signal: AbortSignal) => {
|
||||
|
|
@ -64,10 +75,14 @@ export function useConversationDetails(trace: Trace, accessToken: string) {
|
|||
);
|
||||
return {
|
||||
details,
|
||||
entries: batches.map((batch, index) => ({ span: batch[0], query: queries[index] })),
|
||||
complete: loadedCount === batches.length && visible.length === steps.length,
|
||||
entries: batches.map((batch, index) => ({
|
||||
span: batch.find((span) => queries[index].data?.failedIds.includes(span.span_id)) ?? batch[0],
|
||||
failed: queries[index].isError || Boolean(queries[index].data?.failedIds.length),
|
||||
retry: queries[index].refetch,
|
||||
})),
|
||||
complete: complete && visible.length === steps.length,
|
||||
loading: queries.some((query) => query.isPending),
|
||||
failed: queries.some((query) => query.isError),
|
||||
failed: queries.some((query) => query.isError || Boolean(query.data?.failedIds.length)),
|
||||
hasMore: visible.length < steps.length,
|
||||
loadMore,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue