zimejin commited on
Commit
f49206c
·
1 Parent(s): a19123a

fix: enhance auditor's JSON parsing and error handling

Browse files

Added robust error handling for auditor responses, including a retry mechanism for JSON parsing and a fallback to extract verdicts from partial outputs. Improved the extractJSON utility to handle cases where JSON objects may be emitted without braces.

lib/agent/nodes/auditor.ts CHANGED
@@ -6,6 +6,19 @@ import { chatComplete } from "../llm";
6
  import { extractJSON, loadPolicy } from "../utils";
7
  import { ResearchState, AuditResult, appendReasoning } from "../state";
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  function normalizeStringArray(value: unknown): string[] {
10
  if (!Array.isArray(value)) return [];
11
  return value.map((item) => {
@@ -20,6 +33,34 @@ function normalizeStringArray(value: unknown): string[] {
20
  });
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  /**
24
  * Compares the current plan against POLICY.md.
25
  * Sets `auditResult` with verdict + structured feedback.
@@ -61,7 +102,7 @@ Global rules:
61
  - Do not include markdown fences or any prose outside the JSON object.
62
  `.trim();
63
 
64
- const userMessage = `
65
  POLICY:
66
  ${policy}
67
 
@@ -69,23 +110,52 @@ PLAN TO AUDIT:
69
  ${JSON.stringify(state.plan, null, 2)}
70
  `.trim();
71
 
72
- const rawThought = await chatComplete(system, userMessage);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- let parsed: unknown;
75
- try {
76
- parsed = extractJSON(rawThought);
77
- } catch {
78
- throw new Error(`Auditor produced non-JSON output: ${rawThought.slice(0, 300)}`);
79
  }
80
 
81
- const base = parsed as Record<string, unknown>;
82
- const auditResult = AuditResult.parse({
83
- ...base,
84
- policyViolations: normalizeStringArray(base.policyViolations),
85
- suggestions: normalizeStringArray(base.suggestions),
86
- auditedAt: new Date().toISOString(),
87
- });
 
 
 
88
 
 
 
 
 
 
89
  const isRejected = auditResult.verdict !== "approved";
90
 
91
  const rejectionFeedback: string | null = isRejected
@@ -121,4 +191,3 @@ ${JSON.stringify(state.plan, null, 2)}
121
  updatedAt: new Date().toISOString(),
122
  };
123
  }
124
-
 
6
  import { extractJSON, loadPolicy } from "../utils";
7
  import { ResearchState, AuditResult, appendReasoning } from "../state";
8
 
9
+ const MAX_AUDITOR_ATTEMPTS = 3;
10
+
11
+ const VERDICT_REGEX = /"verdict"\s*:\s*"(approved|rejected|needs_revision)"/;
12
+
13
+ /**
14
+ * Format Zod errors for inclusion in the next prompt so the LLM can self-correct.
15
+ */
16
+ function formatZodErrors(issues: { path: unknown[]; message: string }[]): string {
17
+ return issues
18
+ .map((i) => ` - ${i.path.map((p) => String(p)).join(".")}: ${i.message}`)
19
+ .join("\n");
20
+ }
21
+
22
  function normalizeStringArray(value: unknown): string[] {
23
  if (!Array.isArray(value)) return [];
24
  return value.map((item) => {
 
33
  });
34
  }
35
 
36
+ function parseAuditResult(parsed: unknown) {
37
+ const base = parsed as Record<string, unknown>;
38
+ return AuditResult.safeParse({
39
+ ...base,
40
+ policyViolations: normalizeStringArray(base.policyViolations),
41
+ suggestions: normalizeStringArray(base.suggestions),
42
+ auditedAt: new Date().toISOString(),
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Last resort when JSON and retries fail: recover verdict from a substring fragment.
48
+ */
49
+ function auditFromVerdictRegex(rawThought: string): AuditResult | null {
50
+ const m = rawThought.match(VERDICT_REGEX);
51
+ if (!m) return null;
52
+ const verdict = m[1];
53
+ const result = AuditResult.safeParse({
54
+ verdict,
55
+ policyViolations: [
56
+ "Auditor output could not be parsed as full JSON; verdict was recovered from partial output.",
57
+ ],
58
+ suggestions: [],
59
+ auditedAt: new Date().toISOString(),
60
+ });
61
+ return result.success ? result.data : null;
62
+ }
63
+
64
  /**
65
  * Compares the current plan against POLICY.md.
66
  * Sets `auditResult` with verdict + structured feedback.
 
102
  - Do not include markdown fences or any prose outside the JSON object.
103
  `.trim();
104
 
105
+ const userMessageBase = `
106
  POLICY:
107
  ${policy}
108
 
 
110
  ${JSON.stringify(state.plan, null, 2)}
111
  `.trim();
112
 
113
+ let lastRawThought = "";
114
+ let lastParseError: string | null = null;
115
+
116
+ for (let attempt = 1; attempt <= MAX_AUDITOR_ATTEMPTS; attempt++) {
117
+ const parseFeedback =
118
+ lastParseError &&
119
+ `\n\nYour previous response had errors. Fix them and return ONLY valid JSON:\n${lastParseError}`;
120
+
121
+ const rawThought = await chatComplete(
122
+ system,
123
+ userMessageBase + (parseFeedback ?? "")
124
+ );
125
+ lastRawThought = rawThought;
126
+
127
+ let parsed: unknown;
128
+ try {
129
+ parsed = extractJSON(rawThought);
130
+ } catch {
131
+ lastParseError = `Could not parse as JSON. Output started with: ${rawThought.slice(0, 200)}`;
132
+ continue;
133
+ }
134
+
135
+ const zodResult = parseAuditResult(parsed);
136
+ if (zodResult.success) {
137
+ return buildAuditorState(state, zodResult.data, rawThought);
138
+ }
139
 
140
+ lastParseError = formatZodErrors(zodResult.error.issues);
 
 
 
 
141
  }
142
 
143
+ const regexAudit = auditFromVerdictRegex(lastRawThought);
144
+ if (regexAudit) {
145
+ return buildAuditorState(state, regexAudit, lastRawThought);
146
+ }
147
+
148
+ throw new Error(
149
+ `Auditor failed after ${MAX_AUDITOR_ATTEMPTS} attempts. The model did not return valid JSON matching the audit schema. ` +
150
+ `Try a larger model or lower sampling temperature. Last output (excerpt): ${lastRawThought.slice(0, 400)}`
151
+ );
152
+ }
153
 
154
+ function buildAuditorState(
155
+ state: ResearchState,
156
+ auditResult: AuditResult,
157
+ rawThought: string
158
+ ): Partial<ResearchState> {
159
  const isRejected = auditResult.verdict !== "approved";
160
 
161
  const rejectionFeedback: string | null = isRejected
 
191
  updatedAt: new Date().toISOString(),
192
  };
193
  }
 
lib/agent/utils/extract-json.ts CHANGED
@@ -2,6 +2,28 @@
2
  * JSON extraction helper — handles models that add extra text
3
  */
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  /**
6
  * Extracts JSON from model output that may contain extra text,
7
  * markdown fences, or other non-JSON content.
@@ -37,6 +59,12 @@ export function extractJSON(text: string): unknown {
37
  }
38
  }
39
 
 
 
 
 
 
 
 
40
  throw new Error(`Could not extract valid JSON from: ${text.slice(0, 200)}...`);
41
  }
42
-
 
2
  * JSON extraction helper — handles models that add extra text
3
  */
4
 
5
+ /**
6
+ * Small models often emit JSON object *contents* without `{` / `}`.
7
+ * Strip a trailing comma before wrapping so `{"a":1,}` is not produced.
8
+ */
9
+ function tryParseBraceWrappedObjectBody(text: string): unknown | null {
10
+ const trimmed = text.trim();
11
+ let candidate = trimmed;
12
+ if (!candidate.startsWith('"')) {
13
+ const q = candidate.indexOf('"');
14
+ if (q === -1) return null;
15
+ candidate = candidate.slice(q);
16
+ }
17
+ if (!candidate.includes(":")) return null;
18
+ const inner = candidate.replace(/,\s*$/, "").trim();
19
+ if (!inner.startsWith('"')) return null;
20
+ try {
21
+ return JSON.parse(`{${inner}}`);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
  /**
28
  * Extracts JSON from model output that may contain extra text,
29
  * markdown fences, or other non-JSON content.
 
59
  }
60
  }
61
 
62
+ // Braceless object body, e.g. `"verdict": "needs_revision"`
63
+ const fromCleaned = tryParseBraceWrappedObjectBody(cleaned);
64
+ if (fromCleaned !== null) return fromCleaned;
65
+
66
+ const fromOriginal = tryParseBraceWrappedObjectBody(text);
67
+ if (fromOriginal !== null) return fromOriginal;
68
+
69
  throw new Error(`Could not extract valid JSON from: ${text.slice(0, 200)}...`);
70
  }