From 6de368218ad0dcd2d5101566ec55a20429a1c6b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 16:06:09 +0000 Subject: [PATCH 1/2] fix: report the real cause when curation output fails to parse The curator node collapsed every failure into Zod's generic "expected object, received null" by silently discarding the SyntaxError from a failed JSON.parse. Worse, the real root cause never even reached that point: WorkCoordinator.curateRepos was already swallowing curateTrending's exceptions into an empty string, so the actual provider/LLM error was logged locally and thrown away before the graph ever saw it. Now the curate node distinguishes curate() throwing, invalid JSON (with a bounded excerpt + length + the real parse error), and schema mismatches, and curateRepos lets exceptions propagate so the graph can catch and report them instead of masking them as a bad parse. --- src/agent/curator-graph.test.ts | 43 +++++++++++++++++++++++++++++++++ src/agent/curator.graph.ts | 28 +++++++++++++++++---- src/agent/index.ts | 8 +----- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts index 6e511ad..d8f0eb7 100644 --- a/src/agent/curator-graph.test.ts +++ b/src/agent/curator-graph.test.ts @@ -59,3 +59,46 @@ test('gives up after exhausting retries — curated is null, not an empty array, assert.equal(result.curated, null) assert.ok(result.error, 'expected a validation error to be surfaced, not swallowed') }) + +test('non-JSON output names the parse failure and includes the offending text, not a generic Zod null error', async () => { + const fakeCurate = async () => 'this is not json at all' + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /not valid JSON/) + assert.match(result.error ?? '', /this is not json at all/) +}) + +test('a long non-JSON response is truncated in the recorded error, with the original length reported', async () => { + const longRaw = 'x'.repeat(500) + const fakeCurate = async () => longRaw + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /500 chars/) + assert.ok((result.error ?? '').length < longRaw.length, 'expected the excerpt to be bounded, not a full raw dump') +}) + +test('well-formed JSON that fails the schema produces a distinguishably different error from a parse failure', async () => { + const fakeCurate = async () => JSON.stringify({ repos: [{ nope: true }] }) + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /did not match the expected schema/) + assert.doesNotMatch(result.error ?? '', /not valid JSON/) +}) + +test('an exception thrown by curate() is caught and recorded instead of crashing the graph', async () => { + const fakeCurate = async () => { + throw new Error('rate limited') + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /curate\(\) threw/) + assert.match(result.error ?? '', /rate limited/) +}) diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts index c83d772..5c387cd 100644 --- a/src/agent/curator.graph.ts +++ b/src/agent/curator.graph.ts @@ -2,7 +2,7 @@ import { StateGraph, StateSchema, START, END } from '@langchain/langgraph' import { z } from 'zod' import { TrendingRepoOutputSchema, type CuratedRepo } from '../schemas/index.js' -import { parseJson } from './utils.ts' +import { truncate } from './utils.ts' import { TrendingRepo } from '../schemas/index.ts' const CuratedRepoOutputSchema = z.object({ repos: z.array(TrendingRepoOutputSchema) }) @@ -11,6 +11,7 @@ export type CurateFn = (repos: TrendingRepo[], feedback?: string) => Promise(), @@ -22,12 +23,29 @@ const CuratorState = new StateSchema({ export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { const graph = new StateGraph(CuratorState) .addNode('curate', async (state) => { - const raw = await curate(state.repos, state.error || undefined) - const parsed = CuratedRepoOutputSchema.safeParse(parseJson(raw, null)) - + let raw: string + try { + raw = await curate(state.repos, state.error || undefined) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { error: `curate() threw: ${message}`, attempts: state.attempts + 1 } + } + + let json: unknown + try { + json = JSON.parse(raw) + } catch (err) { + const cause = err instanceof Error ? err.message : String(err) + return { + error: `curator output was not valid JSON (${cause}) — ${raw.length} chars: ${truncate(raw, EXCERPT_MAX)}`, + attempts: state.attempts + 1, + } + } + + const parsed = CuratedRepoOutputSchema.safeParse(json) if (parsed.success) return { curated: parsed.data.repos, error: null } - return { error: parsed.error.message, attempts: state.attempts + 1 } + return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } }) .addEdge(START, 'curate') .addConditionalEdges('curate', (state) => (state.curated || state.attempts >= MAX_ATTEMPTS ? END : 'curate')) diff --git a/src/agent/index.ts b/src/agent/index.ts index 1d13607..53d8a4d 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -175,13 +175,7 @@ export class WorkCoordinator { private static async curateRepos(newRepos: TrendingRepo[], feedback?: string): Promise { logger.info('⚡️ Writing digest summaries...') - try { - return await curateTrending(newRepos, feedback) - } catch (err) { - logger.error({ err }, '❌ Trending curation attempt failed') - - return '' - } + return curateTrending(newRepos, feedback) } // Step 4: deliver the digest via Telegram. Returns whether the send succeeded. From d1d41bb5bccb58ad1e7368a8d69fa279e5e76149 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 16:09:51 +0000 Subject: [PATCH 2/2] fix: reduce curate-node complexity to satisfy the Fallow CRAP gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallow flagged the curate node's CRAP score (56, threshold 30) — the try/catch branches added real cyclomatic complexity, and the `err instanceof Error ? err.message : String(err)` ternary was duplicated with its non-Error branch untested. Extracted an errorMessage() helper and the node body into curateNode(), and added a test that throws a non-Error value so both branches are covered. --- src/agent/curator-graph.test.ts | 12 ++++++++ src/agent/curator.graph.ts | 54 ++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts index d8f0eb7..2925862 100644 --- a/src/agent/curator-graph.test.ts +++ b/src/agent/curator-graph.test.ts @@ -102,3 +102,15 @@ test('an exception thrown by curate() is caught and recorded instead of crashing assert.match(result.error ?? '', /curate\(\) threw/) assert.match(result.error ?? '', /rate limited/) }) + +test('a non-Error value thrown by curate() is still stringified into the recorded error', async () => { + const fakeCurate = async () => { + throw 'connection reset' + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null) + assert.match(result.error ?? '', /curate\(\) threw/) + assert.match(result.error ?? '', /connection reset/) +}) diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts index 5c387cd..eb164f1 100644 --- a/src/agent/curator.graph.ts +++ b/src/agent/curator.graph.ts @@ -20,33 +20,37 @@ const CuratorState = new StateSchema({ attempts: z.custom(), }) +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +async function curateNode(state: { repos: TrendingRepo[]; error: string | null; attempts: number }, curate: CurateFn) { + let raw: string + try { + raw = await curate(state.repos, state.error || undefined) + } catch (err) { + return { error: `curate() threw: ${errorMessage(err)}`, attempts: state.attempts + 1 } + } + + let json: unknown + try { + json = JSON.parse(raw) + } catch (err) { + return { + error: `curator output was not valid JSON (${errorMessage(err)}) — ${raw.length} chars: ${truncate(raw, EXCERPT_MAX)}`, + attempts: state.attempts + 1, + } + } + + const parsed = CuratedRepoOutputSchema.safeParse(json) + if (parsed.success) return { curated: parsed.data.repos, error: null } + + return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } +} + export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { const graph = new StateGraph(CuratorState) - .addNode('curate', async (state) => { - let raw: string - try { - raw = await curate(state.repos, state.error || undefined) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { error: `curate() threw: ${message}`, attempts: state.attempts + 1 } - } - - let json: unknown - try { - json = JSON.parse(raw) - } catch (err) { - const cause = err instanceof Error ? err.message : String(err) - return { - error: `curator output was not valid JSON (${cause}) — ${raw.length} chars: ${truncate(raw, EXCERPT_MAX)}`, - attempts: state.attempts + 1, - } - } - - const parsed = CuratedRepoOutputSchema.safeParse(json) - if (parsed.success) return { curated: parsed.data.repos, error: null } - - return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } - }) + .addNode('curate', (state) => curateNode(state, curate)) .addEdge(START, 'curate') .addConditionalEdges('curate', (state) => (state.curated || state.attempts >= MAX_ATTEMPTS ? END : 'curate')) .compile()