fix: suppress <think> leakage + split reasoning output (#614) (thanks @zknicker)

This commit is contained in:
Peter Steinberger
2026-01-10 00:02:13 +01:00
parent 2d0ca67c21
commit 51ec578cec
6 changed files with 49 additions and 46 deletions

View File

@@ -1604,23 +1604,21 @@ export async function runEmbeddedPiAgent(params: {
}
}
const fallbackText = lastAssistant
? (() => {
const base = extractAssistantText(lastAssistant);
if (params.reasoningLevel !== "on") return base;
const thinking = extractAssistantThinking(lastAssistant);
const formatted = thinking
? formatReasoningMarkdown(thinking)
: "";
if (!formatted) return base;
return base ? `${formatted}\n\n${base}` : formatted;
})()
const reasoningText =
lastAssistant && params.reasoningLevel === "on"
? formatReasoningMarkdown(extractAssistantThinking(lastAssistant))
: "";
if (reasoningText) replyItems.push({ text: reasoningText });
const fallbackAnswerText = lastAssistant
? extractAssistantText(lastAssistant)
: "";
for (const text of assistantTexts.length
const answerTexts = assistantTexts.length
? assistantTexts
: fallbackText
? [fallbackText]
: []) {
: fallbackAnswerText
? [fallbackAnswerText]
: [];
for (const text of answerTexts) {
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0))
continue;

View File

@@ -129,7 +129,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payload.text).toBe("Hello block");
});
it("prepends reasoning before text when enabled", () => {
it("emits reasoning as a separate message when enabled", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -160,11 +160,11 @@ describe("subscribeEmbeddedPiSession", () => {
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
const payload = onBlockReply.mock.calls[0][0];
expect(payload.text).toBe(
"_Reasoning:_\n_Because it helps_\n\nFinal answer",
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0][0].text).toBe(
"Reasoning:\nBecause it helps",
);
expect(onBlockReply.mock.calls[1][0].text).toBe("Final answer");
});
it("promotes <think> tags to thinking blocks at write-time", () => {
@@ -200,10 +200,11 @@ describe("subscribeEmbeddedPiSession", () => {
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0][0].text).toBe(
"_Reasoning:_\n_Because it helps_\n\nFinal answer",
"Reasoning:\nBecause it helps",
);
expect(onBlockReply.mock.calls[1][0].text).toBe("Final answer");
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "Because it helps" },

View File

@@ -936,11 +936,7 @@ export function subscribeEmbeddedPiSession(params: {
const formattedReasoning = rawThinking
? formatReasoningMarkdown(rawThinking)
: "";
const text = includeReasoning
? baseText && formattedReasoning
? `${formattedReasoning}\n\n${baseText}`
: formattedReasoning || baseText
: baseText;
const text = baseText;
const addedDuringMessage =
assistantTexts.length > assistantTextBaseline;
@@ -953,13 +949,28 @@ export function subscribeEmbeddedPiSession(params: {
}
assistantTextBaseline = assistantTexts.length;
const onBlockReply = params.onBlockReply;
const shouldEmitReasoning =
includeReasoning &&
Boolean(formattedReasoning) &&
Boolean(onBlockReply) &&
formattedReasoning !== lastReasoningSent;
const shouldEmitReasoningBeforeAnswer =
shouldEmitReasoning &&
blockReplyBreak === "message_end" &&
!addedDuringMessage;
if (shouldEmitReasoningBeforeAnswer && formattedReasoning) {
lastReasoningSent = formattedReasoning;
void onBlockReply?.({ text: formattedReasoning });
}
if (
(blockReplyBreak === "message_end" ||
(blockChunker
? blockChunker.hasBuffered()
: blockBuffer.length > 0)) &&
text &&
params.onBlockReply
onBlockReply
) {
if (blockChunker?.hasBuffered()) {
blockChunker.drain({ force: true, emit: emitBlockChunk });
@@ -975,7 +986,7 @@ export function subscribeEmbeddedPiSession(params: {
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(text);
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
void params.onBlockReply({
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
@@ -983,16 +994,13 @@ export function subscribeEmbeddedPiSession(params: {
}
}
}
const onBlockReply = params.onBlockReply;
const shouldEmitReasoningBlock =
includeReasoning &&
Boolean(formattedReasoning) &&
Boolean(onBlockReply) &&
formattedReasoning !== lastReasoningSent &&
(blockReplyBreak === "text_end" || Boolean(blockChunker));
if (shouldEmitReasoningBlock && formattedReasoning && onBlockReply) {
if (
shouldEmitReasoning &&
!shouldEmitReasoningBeforeAnswer &&
formattedReasoning
) {
lastReasoningSent = formattedReasoning;
void onBlockReply({ text: formattedReasoning });
void onBlockReply?.({ text: formattedReasoning });
}
if (streamReasoning && rawThinking) {
emitReasoningStream(rawThinking);

View File

@@ -37,12 +37,7 @@ export function extractAssistantThinking(msg: AssistantMessage): string {
export function formatReasoningMarkdown(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
const lines = trimmed.split(/\r?\n/);
const wrapped = lines
.map((line) => line.trim())
.map((line) => (line ? `_${line}_` : ""))
.filter((line) => line.length > 0);
return wrapped.length > 0 ? [`_Reasoning:_`, ...wrapped].join("\n") : "";
return `Reasoning:\n${trimmed}`;
}
export function inferToolMetaFromArgs(