diff --git a/packages/release-bot/README.md b/packages/release-bot/README.md index 52159ed..e9a4c52 100644 --- a/packages/release-bot/README.md +++ b/packages/release-bot/README.md @@ -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 diff --git a/packages/release-bot/src/orchestrator.ts b/packages/release-bot/src/orchestrator.ts index 08f0c9d..dff5785 100644 --- a/packages/release-bot/src/orchestrator.ts +++ b/packages/release-bot/src/orchestrator.ts @@ -74,7 +74,7 @@ export async function generateReleaseDecision( const model = options.model ?? DEFAULT_MODEL const shared: Pick = { + '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 }, } diff --git a/packages/release-bot/tests/orchestrator.test.ts b/packages/release-bot/tests/orchestrator.test.ts index deaf04a..993c114 100644 --- a/packages/release-bot/tests/orchestrator.test.ts +++ b/packages/release-bot/tests/orchestrator.test.ts @@ -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() readonly thinkingByRole = new Map() + readonly extraBodyByRole = new Map | 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