feat: enforce final tag parsing for embedded PI
This commit is contained in:
@@ -270,6 +270,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
enqueue?: typeof enqueueCommand;
|
enqueue?: typeof enqueueCommand;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
|
enforceFinalTag?: boolean;
|
||||||
}): Promise<EmbeddedPiRunResult> {
|
}): Promise<EmbeddedPiRunResult> {
|
||||||
const enqueue = params.enqueue ?? enqueueCommand;
|
const enqueue = params.enqueue ?? enqueueCommand;
|
||||||
return enqueue(async () => {
|
return enqueue(async () => {
|
||||||
@@ -333,8 +334,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
node: process.version,
|
node: process.version,
|
||||||
model: `${provider}/${modelId}`,
|
model: `${provider}/${modelId}`,
|
||||||
};
|
};
|
||||||
const reasoningTagHint =
|
const reasoningTagHint = provider === "lmstudio" || provider === "ollama";
|
||||||
provider === "lmstudio" || provider === "ollama";
|
|
||||||
const systemPrompt = buildSystemPrompt({
|
const systemPrompt = buildSystemPrompt({
|
||||||
appendPrompt: buildAgentSystemPromptAppend({
|
appendPrompt: buildAgentSystemPromptAppend({
|
||||||
workspaceDir: resolvedWorkspace,
|
workspaceDir: resolvedWorkspace,
|
||||||
@@ -403,6 +403,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
onToolResult: params.onToolResult,
|
onToolResult: params.onToolResult,
|
||||||
onPartialReply: params.onPartialReply,
|
onPartialReply: params.onPartialReply,
|
||||||
onAgentEvent: params.onAgentEvent,
|
onAgentEvent: params.onAgentEvent,
|
||||||
|
enforceFinalTag: params.enforceFinalTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortTimer = setTimeout(
|
const abortTimer = setTimeout(
|
||||||
|
|||||||
95
src/agents/pi-embedded-subscribe.test.ts
Normal file
95
src/agents/pi-embedded-subscribe.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
|
||||||
|
|
||||||
|
type StubSession = {
|
||||||
|
subscribe: (fn: (evt: unknown) => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("subscribeEmbeddedPiSession", () => {
|
||||||
|
it("filters to <final> and falls back when tags are malformed", () => {
|
||||||
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPartialReply = vi.fn();
|
||||||
|
const onAgentEvent = vi.fn();
|
||||||
|
|
||||||
|
subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<
|
||||||
|
typeof subscribeEmbeddedPiSession
|
||||||
|
>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
enforceFinalTag: true,
|
||||||
|
onPartialReply,
|
||||||
|
onAgentEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "<final>Hi there</final>",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onPartialReply).toHaveBeenCalled();
|
||||||
|
const firstPayload = onPartialReply.mock.calls[0][0];
|
||||||
|
expect(firstPayload.text).toBe("Hi there");
|
||||||
|
|
||||||
|
onPartialReply.mockReset();
|
||||||
|
|
||||||
|
handler?.({
|
||||||
|
type: "message_end",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "</final>Oops no start",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondPayload = onPartialReply.mock.calls[0][0];
|
||||||
|
expect(secondPayload.text).toContain("Oops no start");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require <final> when enforcement is off", () => {
|
||||||
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPartialReply = vi.fn();
|
||||||
|
|
||||||
|
subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<
|
||||||
|
typeof subscribeEmbeddedPiSession
|
||||||
|
>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
onPartialReply,
|
||||||
|
});
|
||||||
|
|
||||||
|
handler?.({
|
||||||
|
type: "message_update",
|
||||||
|
message: { role: "assistant" },
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "Hello world",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = onPartialReply.mock.calls[0][0];
|
||||||
|
expect(payload.text).toBe("Hello world");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from "./pi-embedded-utils.js";
|
} from "./pi-embedded-utils.js";
|
||||||
|
|
||||||
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
||||||
|
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||||
|
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||||
|
|
||||||
function stripThinkingSegments(text: string): string {
|
function stripThinkingSegments(text: string): string {
|
||||||
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
||||||
@@ -36,6 +38,16 @@ function stripThinkingSegments(text: string): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripUnpairedThinkingTags(text: string): string {
|
||||||
|
if (!text) return text;
|
||||||
|
const hasOpen = THINKING_OPEN_RE.test(text);
|
||||||
|
const hasClose = THINKING_CLOSE_RE.test(text);
|
||||||
|
if (hasOpen && hasClose) return text;
|
||||||
|
if (!hasOpen) return text.replace(THINKING_CLOSE_RE, "");
|
||||||
|
if (!hasClose) return text.replace(THINKING_OPEN_RE, "");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeEmbeddedPiSession(params: {
|
export function subscribeEmbeddedPiSession(params: {
|
||||||
session: AgentSession;
|
session: AgentSession;
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -53,12 +65,34 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
stream: string;
|
stream: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
enforceFinalTag?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const assistantTexts: string[] = [];
|
const assistantTexts: string[] = [];
|
||||||
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
|
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
|
||||||
const toolMetaById = new Map<string, string | undefined>();
|
const toolMetaById = new Map<string, string | undefined>();
|
||||||
let deltaBuffer = "";
|
let deltaBuffer = "";
|
||||||
let lastStreamedAssistant: string | undefined;
|
let lastStreamedAssistant: string | undefined;
|
||||||
|
const FINAL_START_RE = /<\s*final\s*>/i;
|
||||||
|
const FINAL_END_RE = /<\s*\/\s*final\s*>/i;
|
||||||
|
// Local providers sometimes emit malformed tags; normalize before filtering.
|
||||||
|
const sanitizeFinalText = (text: string): string => {
|
||||||
|
if (!text) return text;
|
||||||
|
const hasStart = FINAL_START_RE.test(text);
|
||||||
|
const hasEnd = FINAL_END_RE.test(text);
|
||||||
|
if (hasStart && !hasEnd) return text.replace(FINAL_START_RE, "");
|
||||||
|
if (!hasStart && hasEnd) return text.replace(FINAL_END_RE, "");
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
const extractFinalText = (text: string): string | undefined => {
|
||||||
|
const cleaned = sanitizeFinalText(text);
|
||||||
|
const startMatch = FINAL_START_RE.exec(cleaned);
|
||||||
|
if (!startMatch) return undefined;
|
||||||
|
const startIndex = startMatch.index + startMatch[0].length;
|
||||||
|
const afterStart = cleaned.slice(startIndex);
|
||||||
|
const endMatch = FINAL_END_RE.exec(afterStart);
|
||||||
|
const endIndex = endMatch ? endMatch.index : afterStart.length;
|
||||||
|
return afterStart.slice(0, endIndex);
|
||||||
|
};
|
||||||
|
|
||||||
const toolDebouncer = createToolDebouncer((toolName, metas) => {
|
const toolDebouncer = createToolDebouncer((toolName, metas) => {
|
||||||
if (!params.onPartialReply) return;
|
if (!params.onPartialReply) return;
|
||||||
@@ -182,7 +216,12 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
: "";
|
: "";
|
||||||
if (chunk) {
|
if (chunk) {
|
||||||
deltaBuffer += chunk;
|
deltaBuffer += chunk;
|
||||||
const next = stripThinkingSegments(deltaBuffer).trim();
|
const cleaned = params.enforceFinalTag
|
||||||
|
? stripThinkingSegments(stripUnpairedThinkingTags(deltaBuffer))
|
||||||
|
: stripThinkingSegments(deltaBuffer);
|
||||||
|
const next = params.enforceFinalTag
|
||||||
|
? (extractFinalText(cleaned)?.trim() ?? cleaned.trim())
|
||||||
|
: cleaned.trim();
|
||||||
if (next && next !== lastStreamedAssistant) {
|
if (next && next !== lastStreamedAssistant) {
|
||||||
lastStreamedAssistant = next;
|
lastStreamedAssistant = next;
|
||||||
const { text: cleanedText, mediaUrls } =
|
const { text: cleanedText, mediaUrls } =
|
||||||
@@ -217,9 +256,19 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
if (evt.type === "message_end") {
|
if (evt.type === "message_end") {
|
||||||
const msg = (evt as AgentEvent & { message: AppMessage }).message;
|
const msg = (evt as AgentEvent & { message: AppMessage }).message;
|
||||||
if (msg?.role === "assistant") {
|
if (msg?.role === "assistant") {
|
||||||
const text = stripThinkingSegments(
|
const cleaned = params.enforceFinalTag
|
||||||
extractAssistantText(msg as AssistantMessage),
|
? stripThinkingSegments(
|
||||||
);
|
stripUnpairedThinkingTags(
|
||||||
|
extractAssistantText(msg as AssistantMessage),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: stripThinkingSegments(
|
||||||
|
extractAssistantText(msg as AssistantMessage),
|
||||||
|
);
|
||||||
|
const text =
|
||||||
|
params.enforceFinalTag && cleaned
|
||||||
|
? (extractFinalText(cleaned)?.trim() ?? cleaned)
|
||||||
|
: cleaned;
|
||||||
if (text) assistantTexts.push(text);
|
if (text) assistantTexts.push(text);
|
||||||
deltaBuffer = "";
|
deltaBuffer = "";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user