mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
fix: reconcile non-native tool-call streamed <function=...> XML delta (#16220)
This commit is contained in:
@@ -742,6 +742,41 @@ describe("handleEventForUI", () => {
|
||||
expect(result).toEqual([mockMessageEvent, delta, action]);
|
||||
});
|
||||
|
||||
// Non-native tool call: the delta is `thought` + raw `<function=...>` XML, a
|
||||
// superset of the thought, so only the marker signal reconciles it.
|
||||
it("clears the delta when streamed text is the thought plus an unstripped <function=...> block", () => {
|
||||
const thought = "Coding and executing";
|
||||
const delta = makeStreamingDelta(
|
||||
"delta-1",
|
||||
`${thought}<function=terminal>\n<parameter=command>echo hi</parameter>\n<parameter=security_risk>LOW</parameter>\n</function>`,
|
||||
);
|
||||
const action = makeThoughtAction("intermediate-1", thought);
|
||||
|
||||
const result = handleEventForUI(action, [mockMessageEvent, delta]);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, action]);
|
||||
});
|
||||
|
||||
// The planning and main sockets share this store, so the marker signal must
|
||||
// not let one agent's action strip the other's live delta.
|
||||
it("leaves a marker-bearing delta from the other agent untouched", () => {
|
||||
const delta = {
|
||||
...makeStreamingDelta(
|
||||
"delta-1",
|
||||
`Planning<function=terminal>\n<parameter=command>echo hi</parameter>\n</function>`,
|
||||
),
|
||||
isFromPlanningAgent: true,
|
||||
};
|
||||
const action = makeThoughtAction(
|
||||
"intermediate-1",
|
||||
"Coding and executing",
|
||||
);
|
||||
|
||||
const result = handleEventForUI(action, [mockMessageEvent, delta]);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, delta, action]);
|
||||
});
|
||||
|
||||
it("does not reconcile a ThinkAction (its thought renders separately)", () => {
|
||||
const thought = "A reasoning step.";
|
||||
const delta = makeStreamingDelta("delta-1", thought);
|
||||
|
||||
@@ -117,8 +117,18 @@ const getTrailingDeltas = (
|
||||
return deltas;
|
||||
};
|
||||
|
||||
const getTrailingContentDeltas = (uiEvents: OpenHandsEvent[]) =>
|
||||
getTrailingDeltas(uiEvents, (event) => (event.content?.length ?? 0) > 0);
|
||||
// Sender-scoped for the same reason as `getTrailingReasoningDeltas` (#1656):
|
||||
// a main-agent action must not strip the planning agent's live content.
|
||||
const getTrailingContentDeltas = (
|
||||
uiEvents: OpenHandsEvent[],
|
||||
finalEvent: OpenHandsEvent,
|
||||
) =>
|
||||
getTrailingDeltas(
|
||||
uiEvents,
|
||||
(event) =>
|
||||
(event.content?.length ?? 0) > 0 &&
|
||||
isSameStreamingSender(finalEvent, event),
|
||||
);
|
||||
|
||||
// Sender-scoped: the main and planning sockets share this event store, so a
|
||||
// main-agent action must not strip the planning agent's live reasoning (#1656).
|
||||
@@ -187,6 +197,12 @@ const matchStreamedSegments = (
|
||||
return findTextSegmentsInOrder(targetText, searchSegments);
|
||||
};
|
||||
|
||||
// A `<function=` marker means the delta still holds the raw prompted-tool-call
|
||||
// XML the SDK strips only after the response completes, so the streamed text is
|
||||
// a superset of the action's `thought` that `matchStreamedSegments` can't match.
|
||||
const hasUnstrippedFunctionCallMarker = (segments: string[]): boolean =>
|
||||
segments.some((segment) => segment.includes("<function="));
|
||||
|
||||
// Whether the finalized event renders its own reasoning: an ActionEvent via
|
||||
// reasoning_content/thinking_blocks, an agent MessageEvent via an inline
|
||||
// <think> block in its content. Decides if a replaced delta's reasoning must
|
||||
@@ -257,7 +273,7 @@ const supersedeStreamedThoughtWithAction = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentDeltas = getTrailingContentDeltas(uiEvents);
|
||||
const contentDeltas = getTrailingContentDeltas(uiEvents, action);
|
||||
if (contentDeltas.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -266,8 +282,13 @@ const supersedeStreamedThoughtWithAction = (
|
||||
({ event }) => event.content ?? "",
|
||||
);
|
||||
|
||||
// Only strip when the streamed text is the action's rendered thought.
|
||||
if (!matchStreamedSegments(thoughtText, streamingSegments).matched) {
|
||||
// Strip on a thought match, or on an unstripped `<function=...>` marker whose
|
||||
// streamed text is a superset of `thought` that the match can't reconcile.
|
||||
const matchedThought = matchStreamedSegments(
|
||||
thoughtText,
|
||||
streamingSegments,
|
||||
).matched;
|
||||
if (!matchedThought && !hasUnstrippedFunctionCallMarker(streamingSegments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user