Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/agent/curator-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,58 @@ 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/)
})

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/)
})
40 changes: 31 additions & 9 deletions src/agent/curator.graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand All @@ -11,6 +11,7 @@ export type CurateFn = (repos: TrendingRepo[], feedback?: string) => Promise<str
export type CuratorResult = { curated: CuratedRepo[] | null; error: string | null }

const MAX_ATTEMPTS = 2
const EXCERPT_MAX = 300

const CuratorState = new StateSchema({
repos: z.custom<TrendingRepo[]>(),
Expand All @@ -19,16 +20,37 @@ const CuratorState = new StateSchema({
attempts: z.custom<number>(),
})

export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise<CuratorResult> {
const graph = new StateGraph(CuratorState)
.addNode('curate', async (state) => {
const raw = await curate(state.repos, state.error || undefined)
const parsed = CuratedRepoOutputSchema.safeParse(parseJson<unknown>(raw, null))
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

if (parsed.success) return { curated: parsed.data.repos, error: null }
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 }
}

return { error: parsed.error.message, attempts: state.attempts + 1 }
})
export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise<CuratorResult> {
const graph = new StateGraph(CuratorState)
.addNode('curate', (state) => curateNode(state, curate))
.addEdge(START, 'curate')
.addConditionalEdges('curate', (state) => (state.curated || state.attempts >= MAX_ATTEMPTS ? END : 'curate'))
.compile()
Expand Down
8 changes: 1 addition & 7 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,7 @@ export class WorkCoordinator {
private static async curateRepos(newRepos: TrendingRepo[], feedback?: string): Promise<string> {
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.
Expand Down
Loading