From 399f906f8cc494e09ce18587d674f8a3cfbc1f52 Mon Sep 17 00:00:00 2001 From: Jack Chen Date: Thu, 20 Aug 2026 11:26:18 +0800 Subject: [PATCH] fix(orchestrator): keep parsed consensus output out of the public result (#537) Follow-up to #536. `runConsensusCore()` tracks the parsed value so an accepted verify revision can replace `output` and `structured` together, but the same loop backs the standalone `runConsensus()`, where several proposer answers may be joined into one string and no parsed value corresponds to the answer. That path was returning a `structured` field the public `ConsensusResult` never declared. Drop it at the public boundary, and cover both halves of the verify contract: an accepted revision replaces the pair, a rejected one preserves it. Credit green3sf in CONTRIBUTORS.md for #536. --- CONTRIBUTORS.md | 1 + .../core/src/orchestrator/orchestrator.ts | 6 +- packages/core/tests/consensus.test.ts | 76 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bb491af..1c5bbc1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -25,6 +25,7 @@ Opening your first PR gets you added here. See the [contribution guide](.github/ - [@tlysanhuo](https://github.com/tlysanhuo) (trace span parent linkage) - [@LambIessz](https://github.com/LambIessz) (orchestrator cost budget, MessageBus persistence in checkpoints, retryable route fallback) - [@Bobuyoucrypto](https://github.com/Bobuyoucrypto) (Windows bash timeout process-tree kill) +- [@green3sf](https://github.com/green3sf) (structured output refresh after an accepted verify revision) ## Provider integrations diff --git a/packages/core/src/orchestrator/orchestrator.ts b/packages/core/src/orchestrator/orchestrator.ts index 90810dc..6ccab1e 100644 --- a/packages/core/src/orchestrator/orchestrator.ts +++ b/packages/core/src/orchestrator/orchestrator.ts @@ -2691,7 +2691,11 @@ export class OpenMultiAgent { }) } - const result = await runConsensusCore({ + // `structured` belongs to the per-task verify hook, where one task result + // carries one parsed value. Here several proposer answers may be joined into + // a single string, so no parsed value corresponds to it; drop it rather than + // leak an undeclared field onto the public ConsensusResult. + const { structured: _structured, ...result } = await runConsensusCore({ team, prompt, initialAnswer: candidates.join('\n\n---\n\n'), diff --git a/packages/core/tests/consensus.test.ts b/packages/core/tests/consensus.test.ts index 55b173d..9deddb7 100644 --- a/packages/core/tests/consensus.test.ts +++ b/packages/core/tests/consensus.test.ts @@ -253,6 +253,7 @@ describe('runConsensus onDissent', () => { expect(res.verdict).toBe('rejected') }) + it('keep stops but keeps the answer (accepted despite dissent)', async () => { const { res, proposerCalls } = await run('keep') expect(proposerCalls).toBe(1) @@ -260,6 +261,33 @@ describe('runConsensus onDissent', () => { expect(res.verdict).toBe('accepted') expect(res.dissent.length).toBeGreaterThan(0) }) + + it('does not surface the revision proposer\'s parsed output on the public result', async () => { + // A schema-bearing proposer parses its revision internally, but runConsensus + // may join several proposer answers into one string, so no single parsed value + // corresponds to `answer`. The public ConsensusResult must not carry one. + const proposer: AgentConfig = { + ...agent('proposer', captureAdapter((p) => + p.includes('previous answer') ? '{"decision":"quarantine"}' : '{"decision":"accept"}', + ).adapter), + outputSchema: z.object({ decision: z.enum(['accept', 'quarantine']) }), + } + const judge = captureAdapter((p) => (p.includes('quarantine') ? ACCEPT : DISSENT)) + const orch = new OpenMultiAgent() + const team = orch.createTeam('t', { name: 't', agents: [] }) + + const res = await orch.runConsensus(team, 'classify the interval', { + proposer, + judges: [agent('judge', judge.adapter)], + quorum: 1, + maxRounds: 2, + onDissent: 'revise', + }) + + expect(res.verdict).toBe('accepted') + expect(res.answer).toBe('{"decision":"quarantine"}') + expect(Object.keys(res)).not.toContain('structured') + }) }) // --------------------------------------------------------------------------- @@ -536,6 +564,54 @@ describe('per-task verify hook', () => { expect(consumer.prompts[0]).not.toContain('{"decision":"accept"}') }) + it('keeps the original structured output when a revision is rejected', async () => { + // Mirror of the accepted-revision case: the worker still produces a revision, + // but no judge ever accepts it, so both halves of the original pair must survive. + const worker = captureAdapter((p) => + p.includes('previous answer') + ? '{"decision":"quarantine"}' + : '{"decision":"accept"}', + ) + const judge = captureAdapter(DISSENT) // never accepts -> verdict stays rejected + const consumer = captureAdapter('done') + const orch = new OpenMultiAgent() + const team = orch.createTeam('t', { + name: 't', + agents: [{ + ...agent('worker', worker.adapter), + outputSchema: z.object({ decision: z.enum(['accept', 'quarantine']) }), + }, agent('consumer', consumer.adapter)], + }) + + const run = await orch.runTasks(team, [ + { + title: 'verified', + description: 'classify the interval', + assignee: 'worker', + verify: { + judges: [agent('judge', judge.adapter)], + quorum: 1, + maxRounds: 2, + onDissent: 'revise', + }, + }, + { + title: 'consume-verdict', + description: 'use the verified structured decision', + assignee: 'consumer', + dependsOn: ['verified'], + dependencyPayload: 'structured', + }, + ]) + const taskId = run.tasks![0]!.id + + // The rejected revision must not leak into either representation. + expect(run.taskResults?.get(taskId)?.output).toBe('{"decision":"accept"}') + expect(run.taskResults?.get(taskId)?.structured).toEqual({ decision: 'accept' }) + expect(consumer.prompts[0]).toContain('{"decision":"accept"}') + expect(consumer.prompts[0]).not.toContain('{"decision":"quarantine"}') + }) + it('feeds the prior answer into the revision prompt', async () => { const worker = captureAdapter((p) => (p.includes('previous answer') ? 'revised' : 'original')) const judge = captureAdapter(DISSENT)