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.
This commit is contained in:
Jack Chen authored and GitHub committed 2026-08-20 11:26:18 +08:00
1 parent 4d9a5cf3de
commit 399f906f8c
3 files changed
+82 -1

No files matched your search

+1
View File
@@ -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
@@ -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'),
+76
View File
@@ -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)