50228c3656
Align isUnexpectedStopCandidate with #isEmptyAssistantStop: only a signed (non-whitespace thinkingSignature) thinking-only stop is a candidate. Unsigned thinking-only stops stay with the empty-stop retry path and its cap, so unexpected-stop classification no longer re-handles a turn the empty-stop cap already gave up on. Fixes #7499
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai";
|
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
|
|
import type { ModelRegistry } from "../config/model-registry";
|
|
import { resolveRoleSelection } from "../config/model-resolver";
|
|
import type { Settings } from "../config/settings";
|
|
import unexpectedStopClassifierPrompt from "../prompts/system/unexpected-stop-classifier.md" with { type: "text" };
|
|
import { isTinyMemoryLocalModelKey, ONLINE_MEMORY_MODEL_KEY } from "../tiny/models";
|
|
import { tinyModelClient } from "../tiny/title-client";
|
|
|
|
const CLASSIFIER_SYSTEM_PROMPT = prompt.render(unexpectedStopClassifierPrompt);
|
|
|
|
/**
|
|
* The answer is a single word. OpenAI-compatible endpoints reject values below
|
|
* 16, so 16 is the smallest portable budget for this classifier.
|
|
*/
|
|
const ANSWER_MAX_TOKENS = 16;
|
|
/**
|
|
* Online classifier budget. Sized to survive backends that ignore
|
|
* `disableReasoning` (e.g. Qwen3 via llama.cpp catalogued `reasoning: false`
|
|
* but still emitting thinking): the yes/no keyword needs to land after any
|
|
* unavoidable thinking preamble. `maxTokens` is a hard cap — non-thinking
|
|
* completions still return in a single word (issue #4355).
|
|
*/
|
|
const REASONING_SAFE_MAX_TOKENS = 1024;
|
|
|
|
export interface ClassifyUnexpectedStopDeps {
|
|
settings: Settings;
|
|
registry: ModelRegistry;
|
|
sessionId: string;
|
|
metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export function isUnexpectedStopCandidate(message: AssistantMessage): boolean {
|
|
if (message.stopReason !== "stop") return false;
|
|
let hasContent = false;
|
|
for (const content of message.content) {
|
|
if (content.type === "toolCall") return false;
|
|
if (content.type === "text" && /\S/.test(content.text)) {
|
|
hasContent = true;
|
|
}
|
|
// A signed thinking-only stop is still a candidate: reasoning models can
|
|
// trap the intended response (or a truncated fragment) in a thinking block
|
|
// with no text. #isEmptyAssistantStop treats a non-whitespace signature as
|
|
// terminal (not empty), so such stops bypass the empty-stop path entirely.
|
|
// Match that predicate here — unsigned thinking-only stops stay with the
|
|
// empty-stop retry path (and its cap) rather than being re-handled here.
|
|
if (content.type === "thinking" && /\S/.test(content.thinking) && /\S/.test(content.thinkingSignature ?? "")) {
|
|
hasContent = true;
|
|
}
|
|
}
|
|
return hasContent;
|
|
}
|
|
|
|
export async function classifyUnexpectedStop(
|
|
text: string,
|
|
deps: ClassifyUnexpectedStopDeps,
|
|
): Promise<boolean | undefined> {
|
|
const backend = deps.settings.get("providers.unexpectedStopModel");
|
|
try {
|
|
if (backend === ONLINE_MEMORY_MODEL_KEY) {
|
|
return await classifyOnline(text, deps);
|
|
}
|
|
if (isTinyMemoryLocalModelKey(backend)) {
|
|
return await classifyLocal(text, backend, deps);
|
|
}
|
|
return undefined;
|
|
} catch (error) {
|
|
logger.debug("unexpected-stop: classification failed", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
backend,
|
|
});
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
|
|
const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable());
|
|
const model = resolved?.model;
|
|
if (!model) {
|
|
throw new Error("unexpected-stop: no tiny/smol model available for classification");
|
|
}
|
|
const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
|
|
if (!apiKey) {
|
|
throw new Error(`unexpected-stop: no API key for ${model.provider}/${model.id}`);
|
|
}
|
|
const metadata = deps.metadataResolver?.(model.provider);
|
|
const maxTokens = REASONING_SAFE_MAX_TOKENS;
|
|
|
|
const response = await completeSimple(
|
|
model,
|
|
{
|
|
systemPrompt: [CLASSIFIER_SYSTEM_PROMPT],
|
|
messages: [{ role: "user", content: text, timestamp: Date.now() }],
|
|
},
|
|
{
|
|
apiKey: deps.registry.resolver(model, deps.sessionId),
|
|
maxTokens,
|
|
disableReasoning: true,
|
|
metadata,
|
|
signal: deps.signal,
|
|
},
|
|
);
|
|
|
|
if (response.stopReason === "error") {
|
|
throw new Error(`unexpected-stop: online classification failed: ${response.errorMessage ?? "unknown error"}`);
|
|
}
|
|
|
|
const outputText = response.content
|
|
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
.map(part => part.text)
|
|
.join("\n");
|
|
return parseUnexpectedStopClassification(outputText);
|
|
}
|
|
|
|
async function classifyLocal(
|
|
text: string,
|
|
modelKey: string,
|
|
deps: ClassifyUnexpectedStopDeps,
|
|
): Promise<boolean | undefined> {
|
|
if (!isTinyMemoryLocalModelKey(modelKey)) {
|
|
throw new Error(`unexpected-stop: unsupported local classifier model: ${modelKey}`);
|
|
}
|
|
const builtPrompt = prompt.render(unexpectedStopClassifierPrompt, { message: text });
|
|
const output = await tinyModelClient.complete(modelKey, builtPrompt, {
|
|
maxTokens: ANSWER_MAX_TOKENS,
|
|
signal: deps.signal,
|
|
});
|
|
if (!output) {
|
|
return undefined;
|
|
}
|
|
return parseUnexpectedStopClassification(output);
|
|
}
|
|
|
|
export function parseUnexpectedStopClassification(text: string): boolean | undefined {
|
|
const trimmed = text.trim().toLowerCase();
|
|
if (trimmed.startsWith("yes")) return true;
|
|
if (trimmed.startsWith("no")) return false;
|
|
return undefined;
|
|
}
|