Skip to content

Commit caf1d2c

Browse files
fix(server): robust agent retry and lenient JSON parsing for LLM output (#1004)
1 parent 2d7c7a6 commit caf1d2c

3 files changed

Lines changed: 135 additions & 32 deletions

File tree

server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/handler/PracticeDetectionResultParser.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package de.tum.in.www1.hephaestus.agent.handler;
22

3+
import com.fasterxml.jackson.core.JsonParser;
34
import com.fasterxml.jackson.core.JsonProcessingException;
45
import com.fasterxml.jackson.databind.JsonNode;
56
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -63,10 +64,14 @@ public class PracticeDetectionResultParser {
6364
static final int MAX_DIFF_NOTES = 30;
6465

6566
private final ObjectMapper objectMapper;
67+
private final ObjectMapper lenientMapper;
6668
private final int maxFindingsPerJob;
6769

6870
public PracticeDetectionResultParser(ObjectMapper objectMapper, int maxFindingsPerJob) {
6971
this.objectMapper = objectMapper;
72+
// Lenient mapper for agent output: LLMs produce JSON with literal newlines,
73+
// tabs, and other control chars inside string values that strict JSON rejects.
74+
this.lenientMapper = objectMapper.copy().configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true);
7075
this.maxFindingsPerJob = maxFindingsPerJob;
7176
}
7277

@@ -97,7 +102,8 @@ public ParseResult parse(JsonNode jobOutput) {
97102
String sanitizedText = sanitizeJsonEscapes(rawOutputText);
98103
JsonNode root;
99104
try {
100-
root = objectMapper.readTree(sanitizedText);
105+
// Use lenient mapper: LLMs produce JSON with literal newlines/tabs in strings
106+
root = lenientMapper.readTree(sanitizedText);
101107
} catch (JsonProcessingException e) {
102108
// Fallback: try to extract JSON from mixed text (e.g., "[PHASE0]...\n{...}")
103109
root = extractJsonFromText(sanitizedText);
@@ -566,7 +572,7 @@ private JsonNode extractJsonFromText(String text) {
566572
int braceIdx = text.indexOf('{', startIdx);
567573
if (braceIdx < 0) break;
568574
try {
569-
JsonNode node = objectMapper.readTree(text.substring(braceIdx));
575+
JsonNode node = lenientMapper.readTree(text.substring(braceIdx));
570576
if (node != null && node.isObject() && node.has("findings")) {
571577
return node;
572578
}

server/application-server/src/main/resources/agent/PI-AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The diff and practice criteria are in workspace files. Read them before analyzin
2828
This is an authorized code review. The diff may contain API keys, tokens, or secrets — analyzing and flagging these is part of this review. Never refuse because the diff contains security-sensitive patterns — flag them as findings instead.
2929

3030
## Output
31-
Write a JSON object to `.output/result.json` using the write tool:
31+
Your final action MUST be a write tool call to `.output/result.json`. Do NOT output the JSON as text — you MUST use the write tool. The review fails if this file is not written.
3232
```json
3333
{
3434
"findings": [{

server/application-server/src/main/resources/agent/pi-runner.mjs

Lines changed: 126 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,66 @@ function accumulateUsage(prev, curr) {
123123
usageTotals.totalCalls += Math.max(0, curr.totalCalls - (prev?.totalCalls || 0));
124124
}
125125

126+
// ── Text rescue: extract findings from agent text responses ──────
127+
128+
function extractLastAssistantText(sessionState) {
129+
const messages = sessionState.messages || [];
130+
for (let i = messages.length - 1; i >= 0; i--) {
131+
const msg = messages[i];
132+
if (msg.role !== "assistant") continue;
133+
const textBlocks = (msg.content || []).filter(c => c.type === "text");
134+
const text = textBlocks.map(c => c.text).join("").trim();
135+
if (!text || text.length < 50) continue;
136+
// Only return text that looks like it might contain JSON (has braces)
137+
if (text.includes("{") && text.includes("}")) return text;
138+
}
139+
return null;
140+
}
141+
142+
function tryParseJsonFromText(text) {
143+
if (!text) return null;
144+
try {
145+
const parsed = JSON.parse(text);
146+
if (isValidFindingsPayload(parsed)) return parsed;
147+
} catch {}
148+
const jsonBlockPattern = /```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g;
149+
let match;
150+
while ((match = jsonBlockPattern.exec(text)) !== null) {
151+
try {
152+
const parsed = JSON.parse(match[1].trim());
153+
if (isValidFindingsPayload(parsed)) return parsed;
154+
} catch {}
155+
}
156+
// Try to find a JSON object with "findings" — use JSON.parse error position
157+
// to progressively find valid JSON instead of naive brace matching
158+
// (which breaks on braces inside string values like code snippets)
159+
const braceStart = text.indexOf('{"findings"');
160+
if (braceStart < 0) return null;
161+
// Try progressively longer substrings from braceStart
162+
for (let end = text.indexOf("}", braceStart); end >= 0; end = text.indexOf("}", end + 1)) {
163+
try {
164+
const candidate = text.slice(braceStart, end + 1);
165+
const parsed = JSON.parse(candidate);
166+
if (isValidFindingsPayload(parsed)) return parsed;
167+
} catch {}
168+
}
169+
return null;
170+
}
171+
172+
function tryRescueFromTextResponse(sessionState) {
173+
const text = extractLastAssistantText(sessionState);
174+
if (!text) return false;
175+
try { writeFileSync(`${OUTPUT}/last-assistant-text.txt`, text); } catch {}
176+
const payload = tryParseJsonFromText(text);
177+
if (!payload) {
178+
console.error(`[pi-runner] Text rescue: found text (${text.length} chars) but no valid JSON. First 200: ${text.slice(0, 200)}`);
179+
return false;
180+
}
181+
console.error(`[pi-runner] Text rescue: extracted ${payload.findings.length} findings`);
182+
writeFileSync(`${OUTPUT}/result.json`, JSON.stringify(payload, null, 2));
183+
return checkResultFile();
184+
}
185+
126186
// ── Main ─────────────────────────────────────────────────────────
127187

128188
const prompt = readFileSync("/workspace/.prompt", "utf-8").trim();
@@ -233,62 +293,99 @@ async function main() {
233293
process.exit(0);
234294
}
235295

236-
// ── Attempt 2: Continuation (same session, directive prompt) ──
296+
// ── Validate & retry: if result.json is missing, re-prompt the agent ──
297+
298+
// Extract what the agent actually said
299+
const agentText = extractLastAssistantText(session.state);
300+
if (agentText) {
301+
console.error(`[pi-runner] Agent produced text (${agentText.length} chars) but no result.json`);
302+
// Try to rescue valid JSON from the text
303+
if (tryRescueFromTextResponse(session.state)) {
304+
console.error(`[pi-runner] SUCCESS: rescued valid JSON from agent text`);
305+
unsubscribe();
306+
process.exit(0);
307+
}
308+
}
237309

238-
console.error(`[pi-runner] Continuation: directing agent to write findings NOW`);
310+
// Re-prompt: tell the agent exactly what went wrong
311+
console.error(`[pi-runner] Re-prompting agent to write result.json`);
239312

240-
let contAborted = false;
241-
const contTimer = setTimeout(() => {
242-
contAborted = true;
243-
console.error(`[pi-runner] Continuation hard timeout — aborting`);
313+
let retryAborted = false;
314+
const retryTimer = setTimeout(() => {
315+
retryAborted = true;
316+
console.error(`[pi-runner] Retry hard timeout — aborting`);
244317
session.agent.abort();
245318
}, CONTINUATION_TIMEOUT_MS);
246319

247-
const contStartMs = Date.now();
320+
const retryStartMs = Date.now();
321+
322+
// Build retry prompt based on what actually happened.
323+
// Check timeout first — it's the most operationally relevant signal.
324+
let retryPrompt;
325+
if (softTimeoutFired || hardAborted) {
326+
retryPrompt =
327+
`You ran out of time before writing result.json. ` +
328+
`Based on what you already analyzed, write the result.json file NOW using the write tool. ` +
329+
`Include one finding per practice. For any you did not analyze, use POSITIVE with confidence 0.70. ` +
330+
`The JSON needs a "findings" array and a "delivery.mrNote" string. ` +
331+
`Write to /workspace/.output/result.json immediately. Do not explain — just call the write tool.`;
332+
} else if (agentText) {
333+
retryPrompt =
334+
`You completed your analysis but output the result as text instead of writing it to a file. ` +
335+
`You MUST use the write tool to save the JSON to /workspace/.output/result.json. ` +
336+
`The JSON needs a "findings" array and a "delivery.mrNote" string. ` +
337+
`Call the write tool NOW. Do not explain — just write the file.`;
338+
} else {
339+
retryPrompt =
340+
`Your previous response did not write the required output file. ` +
341+
`You MUST call the write tool to save a JSON object to /workspace/.output/result.json. ` +
342+
`The JSON needs a "findings" array and a "delivery.mrNote" string. ` +
343+
`Do not explain — just call the write tool with the JSON.`;
344+
}
248345

249346
try {
250-
await session.prompt(
251-
`You ran out of time. You have already read the diff and practice criteria in this session. ` +
252-
`Do NOT read any more files. Do NOT grep anything. Do NOT explore the repo. ` +
253-
`Based on what you have already analyzed, IMMEDIATELY write the result.json file using the write tool. ` +
254-
`Include findings for ALL practices in the index. For practices you analyzed in detail, use your analysis. ` +
255-
`For practices you did not fully analyze, emit POSITIVE with confidence 0.70 and a brief positive note. ` +
256-
`Write the COMPLETE JSON to /workspace/.output/result.json NOW. This is your ONLY task.`
257-
);
347+
await session.prompt(retryPrompt);
258348
} catch (err) {
259-
console.error(`[pi-runner] Continuation error: ${err.message}`);
349+
console.error(`[pi-runner] Retry error: ${err.message}`);
260350
}
261351

262-
clearTimeout(contTimer);
352+
clearTimeout(retryTimer);
263353

264-
const contDurationMs = Date.now() - contStartMs;
265-
const contUsage = extractUsageFromSession(session.state);
266-
accumulateUsage(prevUsage, contUsage);
354+
const retryDurationMs = Date.now() - retryStartMs;
355+
const retryUsage = extractUsageFromSession(session.state);
356+
accumulateUsage(prevUsage, retryUsage);
357+
prevUsage = retryUsage;
267358

268359
runnerDebug.attempts.push({
269-
label: "continuation",
270-
durationMs: contDurationMs,
271-
hardAborted: contAborted,
272-
assistantMessages: contUsage.assistantMessages,
273-
stopReasons: contUsage.stopReasons,
274-
usage: contUsage,
360+
label: "retry",
361+
durationMs: retryDurationMs,
362+
hardAborted: retryAborted,
363+
assistantMessages: retryUsage.assistantMessages,
364+
stopReasons: retryUsage.stopReasons,
365+
usage: retryUsage,
275366
resultFilePresent: existsSync(`${OUTPUT}/result.json`),
276367
});
277368
persistRunnerDebug();
278369
persistUsage();
279370

280-
console.error(`[pi-runner] Continuation: ${(contDurationMs / 1000).toFixed(1)}s, resultFile=${existsSync(`${OUTPUT}/result.json`)}`);
371+
console.error(`[pi-runner] Retry: ${(retryDurationMs / 1000).toFixed(1)}s, resultFile=${existsSync(`${OUTPUT}/result.json`)}`);
281372

282373
unsubscribe();
283374

284375
if (checkResultFile()) {
285-
console.error(`[pi-runner] SUCCESS: result.json valid after continuation`);
376+
console.error(`[pi-runner] SUCCESS: result.json valid after retry`);
377+
process.exit(0);
378+
}
379+
380+
// Last attempt: try to rescue from text
381+
if (tryRescueFromTextResponse(session.state)) {
382+
console.error(`[pi-runner] SUCCESS: rescued valid JSON from retry text`);
286383
process.exit(0);
287384
}
288385

289386
// ── Failed ───────────────────────────────────────────────────
290387

291-
console.error(`[pi-runner] FAILED: no valid result.json after initial + continuation`);
388+
console.error(`[pi-runner] FAILED: no valid result.json after initial + retry`);
292389
process.exit(1);
293390
}
294391

0 commit comments

Comments
 (0)