fix(release-bot): request DeepSeek JSON output mode for every role (#601)

Two roles failed in 2026-09 with STRUCTURED_OUTPUT_VALIDATION_FAILED on
bracket mismatches deep inside long nested output, and the in-run
correction failed the same way. #599 moved the nested object to the end of
each schema so the model has no boundary to hold, which works around the
symptom without changing what the provider guarantees.

Set `response_format: { type: 'json_object' }` on the shared role config
through `extraBody`. DeepSeek then guarantees that the answer parses as
JSON, so the syntax failure class goes away at the source. A direct API
probe with the pre-#599 planner schema, thinking at max effort, and a tool
turn confirmed that DeepSeek accepts the mode in that combination and
returned strict JSON on every run. OMA still validates the answer against
the role's schema, so the in-run correction keeps handling schema
mismatches.

The scripted adapter test asserts that every role carries the mode, and
the README documents it next to the shared output ceiling.
This commit is contained in:
Jack Chen authored and GitHub committed 2026-09-12 02:14:44 +08:00
1 parent f9d3ca4606
commit d52380a0a7
3 files changed
+25 -2

No files matched your search

+5 -1
View File
@@ -27,7 +27,11 @@ as the truncation it is. The evidence roles have five turns and the planner and
reviewer three. Every DAG task has `maxRetries: 0`, so a failed role is not
silently rerun as a whole new analysis (OMA's one in-run structured-output
correction still applies). The complete planning DAG has a thirty-minute
wall-clock deadline.
wall-clock deadline. Every request also sets DeepSeek's JSON output mode
(`response_format: json_object`), so the provider guarantees that the answer
parses as JSON. OMA still validates the answer against the role's schema, so
the in-run correction only has to handle a schema mismatch, not a bracket
error in several thousand characters of nested output.
Repository diffs are untrusted evidence. The analyst and compatibility auditor
receive only three custom read-only tools: immutable release evidence, a
+10 -1
View File
@@ -74,7 +74,7 @@ export async function generateReleaseDecision(
const model = options.model ?? DEFAULT_MODEL
const shared: Pick<AgentConfig,
'model' | 'provider' | 'adapter' | 'apiKey' | 'temperature' | 'thinking' | 'maxTokens' |
'parallelToolCalls' | 'maxToolOutputChars' | 'compressToolResults'> = {
'parallelToolCalls' | 'extraBody' | 'maxToolOutputChars' | 'compressToolResults'> = {
model,
provider: options.adapter ? undefined : 'deepseek',
adapter: options.adapter,
@@ -90,6 +90,15 @@ export async function generateReleaseDecision(
// replace it because it is only checked after a call returns.
maxTokens: 64_000,
parallelToolCalls: false,
// DeepSeek's JSON output mode makes the provider guarantee that the
// answer parses. Two roles failed in 2026-09 on bracket mismatches deep
// inside long nested output, and the in-run correction failed the same
// way; #599 moved the nested object last as a workaround. The mode is
// accepted together with thinking and tool calls, and the structured
// output instruction already carries the word "json" that DeepSeek
// requires in the prompt. Schema conformance is still validated by OMA;
// this only removes the syntax failure class.
extraBody: { response_format: { type: 'json_object' } },
maxToolOutputChars: 75_000,
compressToolResults: { minChars: 2_000 },
}
@@ -93,6 +93,14 @@ describe('OMA release orchestration', () => {
['release-planner', { enabled: true, effort: 'max' }],
['release-reviewer', { enabled: true, effort: 'max' }],
]))
// Every role asks DeepSeek for JSON output mode so the provider, not the
// in-run correction, guarantees the answer parses.
expect(adapter.extraBodyByRole).toEqual(new Map([
['change-analyst', { response_format: { type: 'json_object' } }],
['compatibility-auditor', { response_format: { type: 'json_object' } }],
['release-planner', { response_format: { type: 'json_object' } }],
['release-reviewer', { response_format: { type: 'json_object' } }],
]))
expect(run.tokenUsage).toEqual({ input_tokens: 40, output_tokens: 20 })
})
@@ -193,6 +201,7 @@ class ReleaseScriptAdapter implements LLMAdapter {
readonly toolSets: string[][] = []
readonly maxTokensByRole = new Map<string, number | undefined>()
readonly thinkingByRole = new Map<string, ThinkingConfig | undefined>()
readonly extraBodyByRole = new Map<string, Record<string, unknown> | undefined>()
plannerMessages = ''
reviewerMessages = ''
private sequence = 0
@@ -203,6 +212,7 @@ class ReleaseScriptAdapter implements LLMAdapter {
this.toolSets.push((options.tools ?? []).map(tool => tool.name))
this.maxTokensByRole.set(role, options.maxTokens)
this.thinkingByRole.set(role, options.thinking)
this.extraBodyByRole.set(role, options.extraBody)
const messageText = JSON.stringify(messages)
if (role === 'release-planner') this.plannerMessages = messageText
if (role === 'release-reviewer') this.reviewerMessages = messageText