Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/judges/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ interface ModelConfig {
displayName: string
supportsTemperature: boolean
defaultTemperature: number
maxTokensParam: "maxTokens" | "max_completion_tokens"
defaultMaxTokens: number
}
```

`defaultMaxTokens` is passed to the AI SDK as `maxOutputTokens`, which the SDK maps to each
provider's own parameter — there is no per-provider parameter name to configure. Give
reasoning/thinking models a roomy value: their reasoning tokens are billed against the same
ceiling, so a tight cap can be spent before any visible answer is produced.

## Provider-Specific Prompts

Providers can override judge prompts. See [providers/README.md](../providers/README.md#custom-prompts).
Expand Down
15 changes: 6 additions & 9 deletions src/judges/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,14 @@ export class AnthropicJudge implements Judge {

const prompt = buildJudgePrompt(input)

const params: Record<string, unknown> = {
const { text } = await generateText({
model: this.client(this.modelConfig.id),
prompt,
maxTokens: this.modelConfig.defaultMaxTokens,
}

if (this.modelConfig.supportsTemperature) {
params.temperature = this.modelConfig.defaultTemperature
}

const { text } = await generateText(params as Parameters<typeof generateText>[0])
maxOutputTokens: this.modelConfig.defaultMaxTokens,
...(this.modelConfig.supportsTemperature
? { temperature: this.modelConfig.defaultTemperature }
: {}),
})

return parseJudgeResponse(text)
}
Expand Down
10 changes: 10 additions & 0 deletions src/judges/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ System's Hypothesis: ${input.hypothesis}`
}

export function parseJudgeResponse(response: string): JudgeResult {
// An empty completion is not a verdict. Now that maxOutputTokens is actually enforced,
// a reasoning model can spend its whole ceiling on reasoning and return nothing; scoring
// that as "incorrect" would silently mark questions wrong and skew the run's accuracy.
// Throwing lets the evaluate phase record a real failure that a resume can retry.
if (!response.trim()) {
throw new Error(
"Judge returned an empty response (likely truncated before producing a verdict — check the model's maxOutputTokens)"
)
}

try {
const jsonMatch = response.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
Expand Down
15 changes: 6 additions & 9 deletions src/judges/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,14 @@ export class GoogleJudge implements Judge {

const prompt = buildJudgePrompt(input)

const params: Record<string, unknown> = {
const { text } = await generateText({
model: this.client(this.modelConfig.id),
prompt,
maxTokens: this.modelConfig.defaultMaxTokens,
}

if (this.modelConfig.supportsTemperature) {
params.temperature = this.modelConfig.defaultTemperature
}

const { text } = await generateText(params as Parameters<typeof generateText>[0])
maxOutputTokens: this.modelConfig.defaultMaxTokens,
...(this.modelConfig.supportsTemperature
? { temperature: this.modelConfig.defaultTemperature }
: {}),
})

return parseJudgeResponse(text)
}
Expand Down
18 changes: 8 additions & 10 deletions src/judges/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,16 @@ export class OpenAIJudge implements Judge {

const prompt = buildJudgePrompt(input)

const params: Record<string, unknown> = {
// No `as Parameters<typeof generateText>[0]` cast here: it suppressed excess-property
// checking, which is why the v4 `maxTokens` name survived the AI SDK v5 upgrade unnoticed.
const { text } = await generateText({
model: this.client(this.modelConfig.id),
prompt,
}

if (this.modelConfig.supportsTemperature) {
params.temperature = this.modelConfig.defaultTemperature
}

params.maxTokens = this.modelConfig.defaultMaxTokens

const { text } = await generateText(params as Parameters<typeof generateText>[0])
maxOutputTokens: this.modelConfig.defaultMaxTokens,
...(this.modelConfig.supportsTemperature
? { temperature: this.modelConfig.defaultTemperature }
: {}),
})

return parseJudgeResponse(text)
}
Expand Down
15 changes: 6 additions & 9 deletions src/orchestrator/phases/answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,14 @@ export async function runAnswerPhase(
// custom prompt functions that transform context (e.g. Zep's XML-like tags).
const contextTokens = Math.max(0, promptTokens - basePromptTokens)

const params: Record<string, unknown> = {
const { text } = await generateText({
model: client(modelConfig.id),
prompt,
maxTokens: modelConfig.defaultMaxTokens,
}

if (modelConfig.supportsTemperature) {
params.temperature = modelConfig.defaultTemperature
}

const { text } = await generateText(params as Parameters<typeof generateText>[0])
maxOutputTokens: modelConfig.defaultMaxTokens,
...(modelConfig.supportsTemperature
? { temperature: modelConfig.defaultTemperature }
: {}),
})

const durationMs = Date.now() - startTime
checkpointManager.updatePhase(checkpoint, question.questionId, "answer", {
Expand Down
22 changes: 18 additions & 4 deletions src/prompts/extraction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { createOpenAI } from "@ai-sdk/openai"
import { generateText } from "ai"
import type { UnifiedSession } from "../types/unified"
import { logger } from "../utils/logger"

/** Model used for memory extraction (fast, cheap, sufficient for extraction) */
const EXTRACTION_MODEL = "gpt-4o-mini"
/**
* A long session can yield dozens of bullets, and this ceiling is now actually enforced
* (it previously used the AI SDK v4 `maxTokens` name and was silently dropped), so it needs
* real headroom: a truncated extraction quietly drops memories from the corpus the
* filesystem/rag providers are scored on.
*/
const EXTRACTION_MAX_TOKENS = 8000

/**
* Build an extraction prompt that instructs the LLM to extract structured
Expand Down Expand Up @@ -74,14 +82,20 @@ export async function extractMemories(
): Promise<string> {
const prompt = buildExtractionPrompt(session)

const params: Record<string, unknown> = {
const { text, finishReason } = await generateText({
model: openai(EXTRACTION_MODEL),
prompt,
maxTokens: 2000,
maxOutputTokens: EXTRACTION_MAX_TOKENS,
temperature: 0,
})

// Truncation here silently shrinks the memory corpus, which reads as a provider
// quality problem rather than a harness limit. Say so instead.
if (finishReason === "length") {
logger.warn(
`Memory extraction for session ${session.sessionId} hit the ${EXTRACTION_MAX_TOKENS}-token ceiling and was truncated; some memories are missing.`
)
}

const { text } = await generateText(params as Parameters<typeof generateText>[0])

return text.trim()
}
35 changes: 35 additions & 0 deletions src/utils/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect } from "bun:test"
import { MODEL_CONFIGS, getModelConfig } from "./models"

// Reasoning/thinking models are billed for reasoning tokens against the same ceiling as
// visible output, so a tight maxOutputTokens can be consumed before any answer is emitted.
// An empty judge completion would mark questions incorrect, so these need real headroom.
const NEEDS_HEADROOM = /^(gpt-5|o1|o3|o4|gemini-2\.5|gemini-3)/
const HEADROOM_FLOOR = 25000

test("every model config declares a positive output ceiling", () => {
for (const [alias, config] of Object.entries(MODEL_CONFIGS)) {
expect(config.defaultMaxTokens, alias).toBeGreaterThan(0)
}
})

test("reasoning and thinking models get enough headroom to emit a verdict", () => {
for (const [alias, config] of Object.entries(MODEL_CONFIGS)) {
if (NEEDS_HEADROOM.test(alias) || !config.supportsTemperature) {
expect(config.defaultMaxTokens, alias).toBeGreaterThanOrEqual(HEADROOM_FLOOR)
}
}
})

test("unknown models fall back to a ceiling that cannot truncate a verdict", () => {
// We cannot tell whether an unrecognised model reasons, so the fallback must be roomy.
for (const alias of ["gpt-5.5", "o5-mini", "gpt-4.7", "claude-opus-5", "gemini-4-pro"]) {
expect(getModelConfig(alias).defaultMaxTokens, alias).toBeGreaterThanOrEqual(HEADROOM_FLOOR)
}
})

test("non-reasoning models stay capped tightly enough to be worth capping", () => {
for (const alias of ["gpt-4o", "gpt-4.1-mini", "sonnet-4", "opus-4.5"]) {
expect(getModelConfig(alias).defaultMaxTokens, alias).toBeLessThanOrEqual(4000)
}
})
Loading