fix: route group discussion through explicit agent requests

Use persisted taskDecision.nextKinds instead of prose mentions so negated and historical references cannot dispatch peers. Normalize identifier case generically, validate active recipients, and retain owner judgment and legacy ledger compatibility.

Validation: desktop suite 1542/1542 with native Claude integration; final protocol and discussion tests 37/37 after aligning the recipient limit to 32. Real Electron Codex/OpenClaw write, readback, completion and history reload passed. Frontend builds and release packaging remain pending.
This commit is contained in:
Ryder Sun committed 2026-09-09 20:31:30 +08:00
1 parent 17c38f6ede
commit ee0bd66708
7 files changed
+129 -122

No files matched your search

+9 -1
View File
@@ -3,7 +3,7 @@ const { redactSecrets } = require('../security/secret-redaction.cjs')
function parseTaskDecision(input) {
const invalid = () => { throw new Error('LOCAL_RUN_TASK_DECISION_INVALID') }
if (!input || typeof input !== 'object' || Array.isArray(input)
|| Object.keys(input).some(key => !['status', 'reason', 'deliverables', 'handoffTo'].includes(key))
|| Object.keys(input).some(key => !['status', 'reason', 'deliverables', 'handoffTo', 'nextKinds'].includes(key))
|| !['completed', 'continue', 'blocked', 'needs-human'].includes(input.status)) invalid()
const text = (value, limit) => {
if (typeof value !== 'string' || !value.trim() || value.length > limit
@@ -17,9 +17,17 @@ function parseTaskDecision(input) {
if (input.handoffTo != null && (input.status !== 'continue'
|| typeof input.handoffTo !== 'string'
|| !/^[a-z0-9][a-z0-9-]{0,79}$/.test(input.handoffTo))) invalid()
if (input.nextKinds != null && (!Array.isArray(input.nextKinds)
|| input.nextKinds.length > 32
|| input.nextKinds.some(kind => typeof kind !== 'string'
|| !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(kind))
|| (input.nextKinds.length > 0 && input.status !== 'continue'))) invalid()
const nextKinds = input.nextKinds?.map(kind => kind.toLowerCase())
if (nextKinds && new Set(nextKinds).size !== nextKinds.length) invalid()
return {
status: input.status, reason, deliverables,
...(input.handoffTo != null ? { handoffTo: input.handoffTo } : {}),
...(nextKinds ? { nextKinds } : {}),
}
}
@@ -2452,49 +2452,18 @@ class LocalWorkspaceAutoRunner {
].filter(Boolean).join('\n\n')
}
v4NaturalRouteDecision(text, activeKinds) {
v4NaturalRouteDecision(decision, activeKinds) {
const participants = Array.isArray(activeKinds) ? activeKinds : []
// Quoted history and code examples are evidence, not new requests to peers.
const prose = String(text || '')
.replace(/^\s*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\s*\1\s*$/gmu, '')
.replace(/^\s*>.*$/gmu, '')
.replace(/(`+)[\s\S]*?\1/gu, '')
.replace(/!?\[[^\]\n]*\]\([^\n)]*\)/gu, '')
if (!prose || !participants.length) return { status: 'none', kinds: [] }
const aliases = new Map()
for (const kind of participants) {
const label = String(this.agentLabel?.(kind) || kind).trim()
for (const alias of new Set([kind, label, label.replace(/\s+(?:code|cli|agent)$/iu, '')])) {
const key = alias.toLowerCase()
if (!key) continue
aliases.set(key, aliases.has(key) && aliases.get(key) !== kind ? null : kind)
}
if (decision == null) return { status: 'none', kinds: [] }
let parsed
try { parsed = parseTaskDecision(decision) }
catch { return { status: 'invalid', kinds: [] } }
const requested = parsed.nextKinds || []
if (requested.some(kind => !participants.includes(kind))) {
return { status: 'invalid', kinds: [] }
}
const alternatives = [...aliases.keys()].sort((a, b) => b.length - a.length)
.map(alias => alias.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
const mentionPattern = new RegExp(
`(?<![A-Za-z0-9_/@\\\\])@(${alternatives.join('|')})(?![A-Za-z0-9_/-]|\\.[A-Za-z0-9])`, 'giu',
)
const selected = new Set()
let ambiguous = false
const remaining = prose.replace(mentionPattern, (_match, alias) => {
const kind = aliases.get(alias.toLowerCase())
if (kind) selected.add(kind)
else ambiguous = true
return ''
})
if (ambiguous || /(?<![A-Za-z0-9_/@\\])@[\p{L}\p{N}][\p{L}\p{N}_-]*(?![A-Za-z0-9_/-]|\.[A-Za-z0-9])/u.test(remaining)) {
return {
status: 'invalid',
kinds: participants.filter(kind => selected.has(kind)),
}
}
if (!selected.size) return { status: 'none', kinds: [] }
return { status: 'valid', kinds: participants.filter(kind => selected.has(kind)) }
}
v4MentionedNextKinds(text, activeKinds) {
return this.v4NaturalRouteDecision(text, activeKinds).kinds
if (!requested.length) return { status: 'none', kinds: [] }
return { status: 'valid', kinds: participants.filter(kind => requested.includes(kind)) }
}
v4NaturalMessageMatchesBinding(message, controller) {
@@ -2716,7 +2685,7 @@ class LocalWorkspaceAutoRunner {
kind === options.ownerKind
? [
'You are responsible for judging progress against the original user task and the actual available deliverables. Decide whether to continue work, report a blocker, request a human decision, or declare completion. Silence from peers is not evidence of completion. You may continue working yourself; another reviewer is optional.',
'After your natural answer append exactly one receipt: [[MELDWORK_COLLABORATION:{"summary":"concise result","taskDecision":{"status":"completed|continue|blocked|needs-human","reason":"basis for the decision, remaining work or blocker","deliverables":["actual delivered result and its supporting observation"]}}]]. Use an empty deliverables array when no deliverable exists. A completed judgment requires actual deliverables. This is your task judgment, not human adoption or independent system verification.',
'In your final collaboration receipt use taskDecision.status completed, continue, blocked or needs-human to judge the whole task. A completed judgment requires actual deliverables. This is your task judgment, not human adoption or independent system verification.',
'To hand off responsibility, use status continue and add handoffTo with one exact selected Agent kind inside taskDecision. Do not hand off to an absent participant. Append nothing after the receipt.',
].join('\n')
: 'Contribute freely and request peers when useful. Only the current delivery owner can decide task completion or hand off that responsibility.',
@@ -2725,13 +2694,16 @@ class LocalWorkspaceAutoRunner {
phase === 'discussion' && options.allowRouting === true
? [
'Read the completed peer turns across recent rounds. Decide who should contribute next from their earlier evidence, unresolved questions, and work still needed; do not automatically reply only to whoever last mentioned you.',
`Address peers naturally inside your explanation using exact mentions: ${agentList}. Put each mention beside its concrete question or requested contribution; no separate final-line mention list is needed.`,
`Address peers naturally inside your explanation: ${agentList}. Put each request beside its concrete question or requested contribution. Prose mentions are discussion only; they do not dispatch peers.`,
'Selected peers are external Meldwork participants; their @handles are not addresses for your native agent messaging or wait tools. Meldwork dispatches requested peers only after this invocation completes. Put the request in your final response and finish this invocation so they can run; do not wait inside this invocation for their reply.',
'Use one mention for a single next Agent or multiple mentions for a concurrent batch. Do not mention yourself. Quote historical mentions in blockquotes or code so they are not interpreted as new requests.',
`In taskDecision.nextKinds list the selected Agent identifiers whose contribution you actually request next, chosen from ${JSON.stringify(activeKinds)}, or [] when none is needed. Use status continue for a nonempty list. Multiple kinds request a concurrent batch. Do not request yourself or absent participants. Historical, negated and explanatory mentions do not belong in nextKinds.`,
'Avoid repeating the same handoff or conclusions. Invite a peer who has not recently contributed when their earlier proposal can resolve the issue, and state what new evidence or decision you need.',
'Mention a peer only when you need their contribution. You may finish your own contribution without mentioning another Agent; the delivery owner will judge overall task completion.',
'Request a peer only when you need their contribution. You may finish your own contribution with nextKinds: []; the delivery owner will judge overall task completion.',
].join('\n')
: '',
phase === 'discussion'
? 'After your natural answer append exactly one receipt: [[MELDWORK_COLLABORATION:{"summary":"concise result","taskDecision":{"status":"continue","reason":"what you accomplished and what remains","deliverables":[],"nextKinds":[]}}]]. List actual deliverables when available. Only the current delivery owner judges the whole task; other participants report their contribution and may request peers with nextKinds. Merge any owner judgment or handoffTo into this same receipt. Append nothing after it.'
: '',
].filter(Boolean).join('\n\n')
}
@@ -2774,8 +2746,13 @@ class LocalWorkspaceAutoRunner {
const messages = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const owner = this.v4NaturalOwner(group, controller, threadRootId)
const routes = messages.map(message => this.v4NaturalRouteDecision(
this.v4NaturalMessageContent(message).text, activeKinds,
message.trace?.context?.taskDecision, activeKinds,
))
if (routes.some(route => route.status === 'invalid')) {
this.addMessage(group.id, 'system', 'Discussion stopped without a valid task completion decision.',
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
return { status: 'partial' }
}
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
if (nextKinds.length) return { nextKinds, ownerReview: false }
if (!activeKinds.includes(owner)) return { status: 'partial' }
@@ -2788,7 +2765,7 @@ class LocalWorkspaceAutoRunner {
const sawAllContributions = messages.length === 1
|| (controller.discussionStyle === 'sequential' && messages.at(-1)?.agentKind === owner)
if (!ownerMessage || !sawAllContributions) return { nextKinds: [owner], ownerReview: true }
if (routes.some(route => route.status === 'invalid') || !decision) {
if (!decision) {
this.addMessage(group.id, 'system', 'Discussion stopped without a valid task completion decision.',
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
return { status: 'partial' }
@@ -2806,7 +2783,7 @@ class LocalWorkspaceAutoRunner {
v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds) {
const current = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const selected = new Set(current.flatMap(message => (
this.v4NaturalRouteDecision(this.v4NaturalMessageContent(message).text, activeKinds).kinds
this.v4NaturalRouteDecision(message.trace?.context?.taskDecision, activeKinds).kinds
.filter(kind => kind !== message.agentKind)
)))
if (!selected.size) return []
@@ -2,6 +2,25 @@ const test = require('node:test')
const assert = require('node:assert/strict')
const { parseTaskDecision } = require('../../src/collaboration/task-decision.cjs')
test('explicit next participants survive normalization without changing legacy decisions', () => {
const legacy = { status: 'continue', reason: 'More evidence is needed.', deliverables: [] }
assert.deepEqual(parseTaskDecision(legacy), legacy)
for (const nextKinds of [[], ['hermes'], ['hermes', 'codex']]) {
const decision = { ...legacy, nextKinds }
assert.deepEqual(parseTaskDecision(decision), decision)
assert.notStrictEqual(parseTaskDecision(decision).nextKinds, nextKinds)
}
assert.deepEqual(parseTaskDecision({ ...legacy, nextKinds: ['OpenClaw'] }).nextKinds, ['openclaw'])
const fullGroup = Array.from({ length: 32 }, (_, i) => `agent-${i}`)
assert.deepEqual(parseTaskDecision({ ...legacy, nextKinds: fullGroup }).nextKinds, fullGroup)
for (const nextKinds of ['hermes', ['@hermes'], ['hermes', 'HERMES'], [null], [''], ['two words'],
[...fullGroup, 'agent-32']]) {
assert.throws(() => parseTaskDecision({ ...legacy, nextKinds }), /LOCAL_RUN_TASK_DECISION_INVALID/)
}
assert.throws(() => parseTaskDecision({ ...legacy, status: 'completed',
deliverables: ['Result'], nextKinds: ['hermes'] }), /LOCAL_RUN_TASK_DECISION_INVALID/)
})
test('completion requires an explicit reason and deliverable evidence', () => {
const decision = { status: 'completed', reason: 'Checked the requested output.', deliverables: ['result.txt'] }
assert.deepEqual(parseTaskDecision(decision), decision)
@@ -29,6 +29,31 @@ function naturalPhase(prompt) {
return ''
}
for (const nextKinds of [undefined, []]) {
test(`Natural discussion does not dispatch prose mentions with ${nextKinds ? 'empty' : 'legacy missing'} recipients`, async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const ledger = new RunLedger({ storagePath: path.join(directory, 'run-ledger.json') })
const turns = []
const workspace = new LocalWorkspace({
...options, runLedger: ledger, naturalAgentResponses: true,
runAgent: async (agent, prompt) => {
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial evidence.' }
turns.push(agent.kind)
return { outcome: 'completed', text: decisionReply('No need to ask @hermes. @ghost was only a historical example. 无需再请 @hermes。',
'completed', nextKinds ? { nextKinds } : {}) }
},
})
await workspace.refreshAgents()
const group = workspace.createGroup({ name: 'Prose is not a dispatch', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false })
const controller = await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 4 })
assert.deepEqual(turns, ['codex'])
assert.equal(ledger.get(controller.runId).status, 'completed')
const reopened = new RunLedger({ storagePath: ledger.storagePath })
assert.deepEqual(reopened.get(controller.runId).agentRuns.at(-1).context.taskDecision.nextKinds, nextKinds)
})
}
async function runDiscussion(workspace, group, input) {
await workspace.sendMessage({
groupId: group.id,
@@ -101,7 +126,8 @@ test('Natural routing retains a request beyond the stored message excerpt', asyn
if (naturalPhase(prompt) === 'proposal') return { outcome: 'completed', text: 'Initial contribution.' }
turns.push(agent.kind)
return { outcome: 'completed', text: turns.length === 1
? `${'x'.repeat(21000)}\n@hermes Please verify the missing evidence.`
? decisionReply(`${'x'.repeat(21000)}\n@hermes Please verify the missing evidence.`, 'continue',
{ reason: 'Request evidence verification.', nextKinds: ['hermes'] })
: turns.length === 2 ? 'The evidence is unavailable.'
: decisionReply('Required evidence is unavailable.', 'blocked') }
},
@@ -184,9 +210,9 @@ test('Natural sequential V4 runs every Agent once per round in configured CLI or
await delay(5)
running -= 1
return {
text: agent.kind === 'codex'
text: decisionReply(agent.kind === 'codex'
? 'Codex discusses @hermes in the body.\n\n@所有人'
: `${agent.kind} continues without a routing instruction.`,
: `${agent.kind} continues without a routing instruction.`, 'continue', { nextKinds: [] }),
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
}
}
@@ -299,7 +325,7 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
routedRunning -= 1
if (agent.kind === 'codex' && calls.filter(call => call.kind === 'codex' && call.phase === 'discussion').length === 1) {
return {
text: '@hermes please validate the proposal; @workbuddy check the implementation risks.\n\nBoth findings will inform our decision.',
text: decisionReply('@hermes please validate the proposal; @workbuddy check the implementation risks.\n\nBoth findings will inform our decision.', 'continue', { nextKinds: ['hermes', 'workbuddy'] }),
sessionRef: runOptions.sessionRef,
}
}
@@ -353,66 +379,22 @@ test('Natural Agent-led V4 uses the full first-round transcript and inline menti
assert.equal(ledger.get(controller.runId).status, 'completed')
})
test('Natural Agent-led routing reads inline canonical mentions but excludes quoted examples', () => {
test('Natural routing uses explicit canonical recipients independently of display labels', () => {
const { directory, options } = fixture()
const workspace = new LocalWorkspace(options)
try {
const activeKinds = ['codex', 'hermes', 'workbuddy']
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Body mentions @hermes and quotes `@workbuddy`.\n\n@codex',
activeKinds,
), ['codex', 'hermes'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Body mention only: @hermes\n\nDiscussion complete.',
activeKinds,
), ['hermes'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Discussion complete.\n\n@所有人',
activeKinds,
), [])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Continue.\n\n@hermes @workbuddy @hermes',
activeKinds,
), ['hermes', 'workbuddy'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Continue.\n\nPlease ask @hermes next.',
activeKinds,
), ['hermes'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Continue.\n\n@HERMES',
activeKinds,
), ['hermes'])
assert.equal(workspace.autoRunner.v4NaturalRouteDecision(
'Continue.\n\n@hermes @ghost',
activeKinds,
).status, 'invalid')
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Continue.\n\n@hermes check the result; @ghost is unavailable.',
activeKinds,
), ['hermes'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Continue.\n\n@hermes,@workbuddy;@hermes',
activeKinds,
), ['hermes', 'workbuddy'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'> Historical request: @codex\n\n```text\n@workbuddy\n```\n\nPlease review this, @hermes.',
activeKinds,
), ['hermes'])
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'Contact [email protected]; install @codex/package; escape \\@workbuddy; quote `@codex`; [@codex](https://example.test).',
activeKinds,
), [])
assert.equal(workspace.autoRunner.v4NaturalRouteDecision(
'Please review @所有人', activeKinds,
).status, 'invalid')
workspace.autoRunner.agentLabel = kind => ({
codex: 'Codex CLI', hermes: 'Custom Reviewer', workbuddy: 'Pi Agent',
})[kind]
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds(
'@Custom Reviewer check this; @Pi Agent reproduce the problem; @Codex finalize it.',
activeKinds,
), activeKinds)
assert.deepEqual(workspace.autoRunner.v4MentionedNextKinds('@Pi check this.', activeKinds), ['workbuddy'])
const decision = { status: 'continue', reason: 'Request a contribution.', deliverables: [] }
const route = nextKinds => workspace.autoRunner.v4NaturalRouteDecision({ ...decision, nextKinds }, activeKinds)
assert.deepEqual(route(['workbuddy', 'hermes']), { status: 'valid', kinds: ['hermes', 'workbuddy'] })
assert.deepEqual(route([]), { status: 'none', kinds: [] })
assert.deepEqual(route(['hermes', 'ghost']), { status: 'invalid', kinds: [] })
assert.deepEqual(route(['HERMES']), { status: 'valid', kinds: ['hermes'] })
assert.deepEqual(route(['hermes', 'hermes']), { status: 'invalid', kinds: [] })
assert.deepEqual(workspace.autoRunner.v4NaturalRouteDecision(undefined, activeKinds), { status: 'none', kinds: [] })
assert.deepEqual(workspace.autoRunner.v4NaturalRouteDecision(decision, activeKinds), { status: 'none', kinds: [] })
workspace.autoRunner.agentLabel = () => 'Identical display name'
assert.deepEqual(route(['hermes']), { status: 'valid', kinds: ['hermes'] })
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
@@ -428,8 +410,8 @@ test('Natural Agent-led V4 preserves chosen peers without forced participation a
calls.push({ kind: agent.kind, phase, prompt })
return {
text: phase === 'proposal' ? `${agent.kind} proposes an independent approach.`
: agent.kind === 'codex' ? '@hermes please validate the remaining issue.\nCodex evidence.'
: agent.kind === 'hermes' ? '@codex please check this finding.\nHermes evidence.'
: agent.kind === 'codex' ? decisionReply('@hermes please validate the remaining issue.\nCodex evidence.', 'continue', { nextKinds: ['hermes'] })
: agent.kind === 'hermes' ? decisionReply('@codex please check this finding.\nHermes evidence.', 'continue', { nextKinds: ['codex'] })
: 'WorkBuddy finds an additional implementation constraint.',
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
}
@@ -469,8 +451,8 @@ test('Natural Agent-led V4 keeps a peer request when another concurrent peer acc
if (phase === 'discussion' && agent.kind === 'codex') codexTurns += 1
return {
text: phase === 'proposal' ? `${agent.kind} proposal.`
: agent.kind === 'codex' && codexTurns === 1 ? '@hermes assess feasibility; @workbuddy validate risks.'
: agent.kind === 'workbuddy' ? '@codex address this remaining risk before proceeding.'
: agent.kind === 'codex' && codexTurns === 1 ? decisionReply('@hermes assess feasibility; @workbuddy validate risks.', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
: agent.kind === 'workbuddy' ? decisionReply('@codex address this remaining risk before proceeding.', 'continue', { nextKinds: ['codex'] })
: decisionReply('I accept the current result without changes.'),
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
}
@@ -503,7 +485,7 @@ test('Natural Agent-led V4 terminates repeated handoffs even with unlimited roun
assert.ok(calls < 20, 'An unchanged reciprocal handoff must stop')
return {
text: naturalPhase(prompt) === 'proposal' ? `${agent.kind} initial proposal.`
: `Please review the same unresolved question, @${agent.kind === 'codex' ? 'hermes' : 'codex'}.`,
: decisionReply(`Please review the same unresolved question, @${agent.kind === 'codex' ? 'hermes' : 'codex'}.`, 'continue', { nextKinds: [agent.kind === 'codex' ? 'hermes' : 'codex'] }),
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
}
}
@@ -573,7 +555,7 @@ test('Natural Agent-led V4 reuses partially committed routed results after resta
return {
text: phase === 'discussion' && agent.kind === 'codex'
? initialCalls.filter(call => call.kind === 'codex' && call.phase === 'discussion').length === 1
? 'Hermes and WorkBuddy should validate this together.\n\n@hermes @workbuddy'
? decisionReply('Hermes and WorkBuddy should validate this together.\n\n@hermes @workbuddy', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
: decisionReply('The requested validation is complete.')
: `${agent.kind} completes ${phase}.`,
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
@@ -1134,7 +1116,7 @@ test('Natural Agent-led V4 routes a single mention serially and completes after
return {
text: phase === 'discussion' && agent.kind === 'codex'
? calls.filter(call => call === 'discussion:codex').length === 1
? 'Hermes should review the current result.\n\n@hermes'
? decisionReply('Hermes should review the current result.\n\n@hermes', 'continue', { nextKinds: ['hermes'] })
: decisionReply('The deliverable incorporates the completed peer review.')
: `${agent.kind} accepts the current result without changes.`,
sessionRef: runOptions.sessionRef || `${agent.kind}-task-session`,
@@ -1226,7 +1208,7 @@ test('Natural Agent-led V4 does not treat concurrent self-routes as peer confirm
discussionTurns.set(agent.kind, turn)
let text = `${agent.kind} accepts the current result without changes.`
if (phase === 'discussion' && agent.kind === 'codex' && turn === 1) {
text = 'Hermes and WorkBuddy should review together.\n\n@hermes @workbuddy'
text = decisionReply('Hermes and WorkBuddy should review together.\n\n@hermes @workbuddy', 'continue', { nextKinds: ['hermes', 'workbuddy'] })
} else if (phase === 'discussion' && ['hermes', 'workbuddy'].includes(agent.kind)
&& turn === 1) {
text = `${agent.kind} will inspect its own notes once more.\n\n@${agent.kind}`
@@ -1278,9 +1260,9 @@ test('Natural Agent-led V4 does not treat an invalid route as acceptance', async
if (phase === 'discussion' && agent.kind === 'hermes') hermesTurns += 1
let text = `${agent.kind} accepts the current result without changes.`
if (phase === 'discussion' && agent.kind === 'codex' && codexTurns === 1) {
text = 'Hermes should review next.\n\n@hermes'
text = decisionReply('Hermes should review next.\n\n@hermes', 'continue', { nextKinds: ['hermes'] })
} else if (phase === 'discussion' && agent.kind === 'hermes' && hermesTurns === 1) {
text = 'This route is malformed.\n\n@codex @ghost'
text = decisionReply('This route is malformed.\n\n@codex @ghost', 'continue', { nextKinds: ['codex', 'ghost'] })
}
return { text, sessionRef: runOptions.sessionRef || `${agent.kind}-task-session` }
}
@@ -1301,8 +1283,10 @@ test('Natural Agent-led V4 does not treat an invalid route as acceptance', async
assert.deepEqual(calls, [
'proposal:codex', 'proposal:hermes',
'discussion:codex', 'discussion:hermes',
'discussion:codex',
])
assert.equal(workspace.snapshot().messages.some(message => (
message.system?.key === 'system.autoTaskDecisionMissing'
)), true)
})
test('Natural Agent-led V4 recovers a completed proposal harness run before message commit', async (t) => {
+1 -1
View File
@@ -45,7 +45,7 @@ A 组:5 位需复核 AI 产出的技术负责人或研发服务者。B 组:5
| --- | --- | --- | --- |
| P0 | 原生可用 CLI 被正确识别,失败明确 | 安装、协议、认证、当前运行分别留证;普通正文不污染认证状态 | 已/未登录、同名程序、网络受限、Keychain 失败、部分 Agent 失败;真实 Electron |
| P0 | 任务不被外层擅自替换 | 不靠品牌、Skill 名或媒体词决定路由 | 换契约兼容 Agent 仍成立;普通任务无意外外部副作用 |
| P0 | 群聊结束状态准确 | 空 @ 队列触发 AI 收尾判断;完成依据与人类采用分离 | 沉默无产出、只读阻塞、真实成功、继续 @、预算停止分别显示 |
| P0 | 群聊结束状态准确 | 明确协作请求队列为空时触发 AI 收尾判断;正文提及不直接派发;完成依据与人类采用分离 | 沉默无产出、否定/历史提及、明确继续请求、只读阻塞、真实成功、预算停止分别验证 |
| P1 | 中断或重启后可继续 | 任务与原生 session 区分;恢复可解释;避免重复回放全部历史 | 会话连续、引用有效、取消后不重复执行、版本限制可见 |
| P1 | 单 Agent 与按需协作均顺畅 | 同一套任务和结果,不强制角色轮次 | 单 Agent/手动双 Agent/Meldwork 路径记录人工与质量 |
+1 -1
View File
@@ -105,7 +105,7 @@ Conductor/Superset 本地免费层意味着,终端聚合收费需要额外价
Meldwork 建设任务身份、可靠状态、能力协商、恢复、权限、交付引用与采用记录。Agent 继续负责推理、任务分解、工具使用和执行。
没有未处理 @ 可以触发收尾判断;指定交付负责人可继续 @、报告阻塞或提交完成依据。系统不因沉默声称交付成功,也不要求每项任务机械走完固定角色。人类采用决定与 AI 完成判断分别记录。
没有未处理的明确协作请求可以触发收尾判断;指定交付负责人可继续请求成员、报告阻塞或提交完成依据。正文 @ 用于表达讨论,Agent 通过回执中的 nextKinds 明确派发意图,避免把否定或历史提及当作新任务。系统不因沉默声称交付成功,也不要求每项任务机械走完固定角色。人类采用决定与 AI 完成判断分别记录。
商业能力包括访谈、任务评价、标准报价、成本记录和交付范围管理。它们比扩展目录更直接决定近期业务是否成立。
+19
View File
@@ -193,3 +193,22 @@
- 修改前两次真实版本/能力扫描为 2343ms 与 1935ms,均返回 12 个条目;这是基线,不是提速证据。本次不改变探测调度或能力缓存策略。
本检查点未重跑完整桌面或前端套件、构建与打包,之前 1536/1536 桌面全量结果对应本次缓存改动之前。群聊语义路由、可恢复 needs-human 与最终版本验收仍未完成,版本号尚未更新。
## 自然讨论的明确协作请求
2026-09-09:移除 Agent 回复正文中的 @ 正则派发。自然讨论使用已有 taskDecision 回执中的可选 nextKinds 表达下一批请求成员;正文可以自由讨论、引用或否定提及,不触发新调用。请求由 Agent 决定,运行层仅校验成员标识、活动参与者和状态契约。没有请求时仍由交付负责人判断继续、完成或阻塞,不将空列表解释为任务成功;用户输入的 @ 选择不受此改动影响。
nextKinds 使用最多 32 个唯一标识,与现有 V4 成员上限一致;大小写统一为小写,不接受空标识、命令文本或不存在的参与者。非空列表要求 continue;未知参与者混入时整批不派发,也不当作接受结果。字段沿用现有调用上下文、回执和账本持久化,恢复时读取绑定的请求,不重新解析正文。旧判断不包含 nextKinds 时仍可读,不改写既有理由、交付物或历史规范化结果。
验证与实际失败:
- 新增中英文否定提及、历史未知成员、空/缺省请求、成员大小写、重复及越界请求测试。原有顺序、并发、预算、会话连续性与恢复测试的模拟 Agent 改为显式请求回执;保留原调用顺序和账本断言。最终针对性协议/否定提及/路由恢复文件筛选 6/6。
- 第一轮完整桌面 1542 项,1541 通过、1 项可选 Claude 集成跳过、失败 0,日志 `/tmp/meldwork-105-routing-desktop.log`。运行期间补充了成员标识大小写规范化,因此不能以这轮结果证明最后代码的全部覆盖。
- 首次真实 Electron 样本 `/tmp/meldwork-105-auto-writer-124GBI` 已写出正确文件,但 Codex 请求标识为 OpenClaw,原严格小写解析触发 LOCAL_RUN_TASK_DECISION_INVALID,运行 `ea88ef57-c9cc-48e7-8113-9baa93df8454` 为 partial,脚本退出 1。该失败未计为通过;依据原始最终回复增加通用大小写规范化,并在提示中列出准确标识,没有按品牌分支。
- 修正后 `/tmp/meldwork-105-auto-writer-HE04nu` 的运行 `bfeec25e-2202-4180-9d38-9afcd3188181` 完成两位成员提案、Codex 写入与 readback、OpenClaw 独立回读、Codex 最终判断。5 次调用均 completed,文件精确为 `MELDWORK_AUTO_105_OK\n` 共 21 字节,账本任务 completed。两次 nextKinds 分别为 openclaw、codex,最终为空。负责人最终正文包含描述性的 @openclaw,没有再次派发。
- `/tmp/meldwork-105-auto-writer.cjs` 退出 0,Electron 正常关闭;已查看 `/tmp/meldwork-105-auto-writer.png`,正文不泄露回执、整项完成卡片可见。单次实际任务不证明所有 Agent 组合稳定。
- `/tmp/meldwork-105-outcome-ui.cjs /tmp/meldwork-105-auto-writer-HE04nu` 重开已完成任务,完成卡片仍正确显示,运行标识与调用数量不变,无新模型调用;脚本退出 0,已查看 `/tmp/meldwork-105-outcome-bfeec25e-2202-4180-9d38-9afcd3188181.png`。
- 第二轮 `MELDWORK_TEST_CLAUDE_EXECUTABLE=... npm --prefix desktop test`:1542/1542,跳过 0,退出 0,约 319 秒;日志 `/tmp/meldwork-105-routing-final-desktop.log`。该轮启动后仅将 nextKinds 上限从 16 对齐现有 32 成员上限并补充边界断言,最终协议和完整自然讨论文件再次 37/37,退出 0,日志 `/tmp/meldwork-105-routing-final-focused.log`。不将不同批次计数相加。
- `git diff --check` 通过。本轮未改前端源码,未重跑前端测试、构建或打包;桌面实际运行与历史重开验证如上。
本检查点未改变人类采用的含义,needs-human 仍待接入可恢复人类决定流程。尚未更新版本号、构建、打包或替换日常应用。