fix: revert streaming, add dedup and format enforcement

This commit is contained in:
2026-05-31 18:34:58 -05:00
parent e79affa257
commit e8b2c9952b
3 changed files with 84 additions and 44 deletions
+31 -33
View File
@@ -152,49 +152,47 @@ async function runOllamaLoop(ctx: AgentRunContext): Promise<AgentResult> {
iterations++; iterations++;
log.info(`» Ollama turn ${iterations}/${MAX_ITERATIONS}...`); log.info(`» Ollama turn ${iterations}/${MAX_ITERATIONS}...`);
// Stream the response so the activity monitor sees tokens arriving during // Non-streaming with a heartbeat timer so the activity monitor stays alive
// long prefill. Without streaming, a 50k+ token context can take 200-300s // during long prefill. Streaming was tried but Ollama only emits one tool
// of silent prefill before the first token, tripping the activity timeout. // call per chunk — batched tool calls collapse to one-per-turn, turning a
let assistantMessage: Message; // 7-turn run into 26 turns. The heartbeat fires every 60s to prevent the
let promptTokens: number | undefined; // 300s activity timeout from triggering during large-context prefill.
let evalTokens: number | undefined; let response: Awaited<ReturnType<typeof ollama.chat>>;
const turnStart = Date.now();
const heartbeat = setInterval(() => {
log.info(`» still waiting for model... (${Math.round((Date.now() - turnStart) / 1000)}s)`);
}, 60_000);
try { try {
const stream = await ollama.chat({ response = await retry(
() => ollama.chat({
model, model,
messages, messages,
tools, tools,
keep_alive: -1, keep_alive: -1,
think: false, think: false,
options: { num_ctx: 262144, temperature: 0.1 }, options: { num_ctx: 262144, temperature: 0.1 },
stream: true, }),
}); {
delaysMs: [3_000, 8_000],
let content = ""; shouldRetry: (err) => {
let streamToolCalls: ToolCall[] | undefined;
let chunkCount = 0;
for await (const chunk of stream) {
content += chunk.message.content ?? "";
if (chunk.message.tool_calls?.length) streamToolCalls = chunk.message.tool_calls;
if (chunk.prompt_eval_count !== undefined) promptTokens = chunk.prompt_eval_count;
if (chunk.eval_count !== undefined) evalTokens = chunk.eval_count;
chunkCount++;
// Log a heartbeat every 50 chunks so the activity monitor stays alive
if (chunkCount % 50 === 0) log.debug(` streaming… (${chunkCount} chunks)`);
}
assistantMessage = { role: "assistant", content, tool_calls: streamToolCalls };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
const isRetryable = /unexpected EOF|XML syntax error|ECONNRESET|ETIMEDOUT|fetch failed/i.test(msg); return /unexpected EOF|XML syntax error|ECONNRESET|ETIMEDOUT|fetch failed/i.test(msg);
if (!isRetryable) { },
label: `Ollama turn ${iterations}`,
},
);
} catch (err) {
clearInterval(heartbeat);
await unloadModel(ollama, model); await unloadModel(ollama, model);
log.error(`Ollama error: ${msg}`); const lastError = err instanceof Error ? err.message : String(err);
return { success: false, error: `Ollama request failed: ${msg}` }; log.error(`Ollama error: ${lastError}`);
} return { success: false, error: `Ollama request failed: ${lastError}` };
// Retryable: wait and loop — the outer while will retry on the next iteration
log.warning(`» stream error (retryable): ${msg} — retrying in 5s`);
await new Promise((r) => setTimeout(r, 5_000));
continue;
} }
clearInterval(heartbeat);
const promptTokens = response.prompt_eval_count;
const evalTokens = response.eval_count;
const assistantMessage = response.message;
if (promptTokens !== undefined) { if (promptTokens !== undefined) {
const total = promptTokens + (evalTokens ?? 0); const total = promptTokens + (evalTokens ?? 0);
+43 -3
View File
@@ -213,6 +213,46 @@ export const CreatePullRequestReview = type({
.optional(), .optional(),
}); });
/**
* Remove duplicate inline comments on the same file. Uses Jaccard similarity
* on content words — if two comments on the same file share ≥40% of their
* significant words they're almost certainly the same finding repeated.
*/
function deduplicateComments(comments: ReviewCommentInput[]): ReviewCommentInput[] {
const STOPWORDS = new Set(["the", "this", "that", "with", "from", "have", "will", "when", "also", "both", "into", "than", "then", "they", "some", "more", "and", "but", "for", "are", "not", "use"]);
const keywords = (text: string): Set<string> =>
new Set((text ?? "").toLowerCase().split(/\W+/).filter((w) => w.length > 3 && !STOPWORDS.has(w)));
const jaccard = (a: Set<string>, b: Set<string>): number => {
const inter = [...a].filter((w) => b.has(w)).length;
const union = new Set([...a, ...b]).size;
return union === 0 ? 0 : inter / union;
};
const byPath = new Map<string, ReviewCommentInput[]>();
for (const c of comments) {
const group = byPath.get(c.path) ?? [];
group.push(c);
byPath.set(c.path, group);
}
const result: ReviewCommentInput[] = [];
for (const [, group] of byPath) {
const kept: ReviewCommentInput[] = [];
for (const candidate of group) {
const ckw = keywords(candidate.body ?? "");
const isDup = kept.some((k) => jaccard(ckw, keywords(k.body ?? "")) >= 0.4);
if (isDup) {
log.info(`deduped inline comment at ${candidate.path}:${candidate.line} — similar to existing comment on same file`);
} else {
kept.push(candidate);
}
}
result.push(...kept);
}
return result;
}
/** Assemble the **Reviewed changes** preamble block from structured params. */ /** Assemble the **Reviewed changes** preamble block from structured params. */
function assemblePreamble(preamble: string, changes: string[]): string { function assemblePreamble(preamble: string, changes: string[]): string {
const lines = [`**Reviewed changes** — ${preamble}`]; const lines = [`**Reviewed changes** — ${preamble}`];
@@ -350,15 +390,15 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
runDiffCoveragePreflight({ ctx }); runDiffCoveragePreflight({ ctx });
// Build review comments // Build review comments (deduplicate same-file same-topic comments first)
const reviewComments: ReviewCommentInput[] = comments.map((c) => { const reviewComments: ReviewCommentInput[] = deduplicateComments(comments.map((c) => {
let commentBody = fixDoubleEscapedString(c.body || ""); let commentBody = fixDoubleEscapedString(c.body || "");
if (c.suggestion !== undefined) { if (c.suggestion !== undefined) {
const block = "```suggestion\n" + c.suggestion + "\n```"; const block = "```suggestion\n" + c.suggestion + "\n```";
commentBody = commentBody ? `${commentBody}\n\n${block}` : block; commentBody = commentBody ? `${commentBody}\n\n${block}` : block;
} }
return { path: c.path, line: c.line, side: c.side || "RIGHT", body: commentBody, start_line: c.start_line }; return { path: c.path, line: c.line, side: c.side || "RIGHT", body: commentBody, start_line: c.start_line };
}); }));
let droppedComments: DroppedComment[] = []; let droppedComments: DroppedComment[] = [];
let validComments: ReviewCommentInput[] = []; let validComments: ReviewCommentInput[] = [];
+3 -1
View File
@@ -390,7 +390,7 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line. for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line.
7. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\`the review is the final record and the progress comment will be cleaned up automatically. 7. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. **Do NOT call \`report_progress\`**it creates a second visible comment and must not be used in Review mode. The review IS the final record; the progress comment is cleaned up automatically.
**MANDATORY pre-submission self-check**: before calling \`${t("create_pull_request_review")}\`, do both of these: **MANDATORY pre-submission self-check**: before calling \`${t("create_pull_request_review")}\`, do both of these:
1. For each finding already in your \`comments\` array: verify it does NOT also appear as a \`### \` section in the body. A finding goes in ONE place only — inline comment OR body section, never both. If it has a line anchor it goes inline; remove the duplicate body section. 1. For each finding already in your \`comments\` array: verify it does NOT also appear as a \`### \` section in the body. A finding goes in ONE place only — inline comment OR body section, never both. If it has a line anchor it goes inline; remove the duplicate body section.
@@ -411,6 +411,8 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
\`\`\` \`\`\`
Inline comments are passed via the \`comments\` parameter, not in the body. Inline comments are passed via the \`comments\` parameter, not in the body.
**Body format** — use ONLY the structure from the default format below. Forbidden patterns: \`## \` headings, numbered bold items like \`**1. title**\`, \`## Issues to address\`, \`## Positive notes\`, \`## Minor suggestions\`, or any praise/summary section. Use \`### {emoji} {title}\` for non-anchored issue sections ONLY. No praise sections.
The opening callout is what the author sees first — pick the one that matches what you want them to do. Five tiers, from loudest to friendliest: The opening callout is what the author sees first — pick the one that matches what you want them to do. Five tiers, from loudest to friendliest:
- \`[!CAUTION]\` — large red banner. Reads as "this will break something." - \`[!CAUTION]\` — large red banner. Reads as "this will break something."