Harden local LLM direct reply output

This commit is contained in:
Meta Vens 2026-05-02 19:41:16 +01:00
commit 80b5ec337c
2 changed files with 145 additions and 1 deletions

View file

@ -304,6 +304,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise<void>
details: {
command: options.command,
summary: response.summary,
outputPreview: truncate(response.outputText ?? response.summary, 500),
},
});

View file

@ -33,6 +33,44 @@ export type {
LocalLlmToolbox,
} from "./local-llm-shared";
const FINAL_OUTPUT_REWRITE_LIMIT = 1;
const METADATA_ONLY_KEYS = new Set([
"timestamp",
"editedAt",
"authorId",
"authorName",
"channelId",
"channelName",
"threadId",
"jumpUrl",
"sourceId",
"repoName",
"path",
"language",
"title",
"score",
"text",
"chunkIndex",
"lineStart",
"lineEnd",
"query",
"found",
"count",
"resultCount",
"messages",
"results",
"repos",
"repoCount",
"chunks",
]);
const STRUCTURED_OUTPUT_REQUEST_PATTERN =
/\b(json|yaml|yml|xml|csv|tsv|object|array|schema|key-value|key value|machine-readable|machine readable)\b/i;
const RAW_TOOL_OUTPUT_REWRITE_PROMPT = [
"You just returned raw JSON or a tool payload fragment instead of answering the user.",
"Do not paste raw tool output.",
"Answer the user's question directly in concise natural-language Discord prose using the context you already gathered.",
].join(" ");
export class LocalLlmProvider implements ProviderAdapter {
public constructor(private readonly options: LocalLlmProviderOptions) {}
@ -97,8 +135,9 @@ export class LocalLlmProvider implements ProviderAdapter {
try {
let finalPayload: OllamaChatResponse | undefined;
let finalOutputText: string | undefined;
let rewriteAttempts = 0;
for (let turn = 0; turn <= MAX_TOOL_TURNS; turn += 1) {
for (let turn = 0; turn <= MAX_TOOL_TURNS; ) {
const requestBody = {
model: this.options.ollamaModel,
messages,
@ -142,6 +181,23 @@ export class LocalLlmProvider implements ProviderAdapter {
if (toolCalls.length === 0) {
const candidateOutput = normalizeModelText(assistantMessage.content);
if (
candidateOutput &&
rewriteAttempts < FINAL_OUTPUT_REWRITE_LIMIT &&
shouldRewriteStructuredOutput(
candidateOutput,
request.contextBundle.prompt,
toolTrace.length,
)
) {
rewriteAttempts += 1;
messages.push({
role: "user",
content: RAW_TOOL_OUTPUT_REWRITE_PROMPT,
});
continue;
}
finalOutputText = candidateOutput;
break;
}
@ -171,6 +227,8 @@ export class LocalLlmProvider implements ProviderAdapter {
content: JSON.stringify(execution.payload, null, 2),
});
}
turn += 1;
}
if (!finalOutputText) {
@ -232,3 +290,88 @@ export class LocalLlmProvider implements ProviderAdapter {
return [];
}
}
function shouldRewriteStructuredOutput(
candidateOutput: string,
userPrompt: string,
toolTraceCount: number,
): boolean {
if (STRUCTURED_OUTPUT_REQUEST_PATTERN.test(userPrompt)) {
return false;
}
const parsed = parseStructuredOutput(candidateOutput);
if (parsed === undefined) {
return false;
}
if (toolTraceCount > 0) {
return true;
}
return isMetadataOnlyValue(parsed);
}
function parseStructuredOutput(input: string): unknown {
const normalized = unwrapStructuredFence(input);
if (!normalized.startsWith("{") && !normalized.startsWith("[")) {
return undefined;
}
try {
return JSON.parse(normalized);
} catch {
return undefined;
}
}
function unwrapStructuredFence(input: string): string {
const trimmed = input.trim();
const match = /^```(?:json|yaml|yml)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
return (match?.[1] ?? trimmed).trim();
}
function isMetadataOnlyValue(value: unknown): boolean {
if (Array.isArray(value)) {
return value.length > 0 && value.every((entry) => isMetadataOnlyValue(entry));
}
if (!value || typeof value !== "object") {
return false;
}
const entries = Object.entries(value);
if (entries.length === 0) {
return false;
}
return entries.every(([key, child]) => {
if (!METADATA_ONLY_KEYS.has(key)) {
return false;
}
if (Array.isArray(child)) {
return child.every((entry) =>
entry && typeof entry === "object" ? isMetadataOnlyValue(entry) : isScalarMetadataValue(entry),
);
}
if (child && typeof child === "object") {
return isMetadataOnlyValue(child);
}
return isScalarMetadataValue(child);
});
}
function isScalarMetadataValue(value: unknown): boolean {
return (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
value === null
);
}