refactor: make group discussions natural and session-continuous

Run sequential mode strictly in configured CLI order, let auto-beta route later turns from canonical mentions, and persist one native Agent session per task across rounds and restarts. Remove rigid review framing from natural prompts and add recovery, routing, and continuity coverage.
This commit is contained in:
Ryder Sun committed 2026-08-31 18:05:14 +08:00
1 parent 5fef68fc4b
commit 61a71f3439
28 files changed
+2194 -203

No files matched your search

@@ -210,6 +210,15 @@ function normalizeExternalRunRef(value) {
return EXTERNAL_RUN_REF.test(ref) ? ref : ''
}
function terminalAuthenticationDiagnostic(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text || text.length > 2000) return ''
return /^HTTP\s+(?:401|403)\s*:\s*(?:(?:unauthorized|forbidden)\b|(?:invalid|expired|missing)\b[\s\S]*(?:token|credential|api[ _-]?key|auth))/i
.test(text)
? text
: ''
}
function failureCategory(code) {
const value = String(code || '')
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
@@ -299,6 +308,10 @@ function requireTerminalAgentResult(result) {
failure,
)
}
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
if (authenticationDiagnostic) {
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
}
return normalized
}
@@ -332,4 +345,5 @@ module.exports = {
normalizeOutcome,
outcomeForAcpStopReason,
requireTerminalAgentResult,
terminalAuthenticationDiagnostic,
}
+3 -9
View File
@@ -745,7 +745,7 @@ function publicSessionRef(value, childEnv) {
return safe.includes('[redacted]') ? '' : safe
}
async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
async function runAcpTurn(runtime, prompt, options, spec, profile) {
if (runtime.closed || runtime.failureError) {
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
}
@@ -812,9 +812,6 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
try {
if (!protocolSessionRef) {
if (persistent && options.sessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
protocolSessionRef = String(options.sessionRef)
await operation(runtime.connection.resumeSession({
@@ -955,9 +952,6 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
runtime = null
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime && options.sessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime) {
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
@@ -969,7 +963,7 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
}
runtime.active = true
try {
return await runAcpTurn(runtime, prompt, options, spec, profile, true)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} catch (error) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
@@ -1003,7 +997,7 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
acpDisposableRuntimes.add(runtime)
return await runAcpTurn(runtime, prompt, options, spec, profile, false)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} finally {
if (runtime) {
acpDisposableRuntimes.delete(runtime)
@@ -9,7 +9,7 @@ const V4_TEMPLATES = new Set(['concurrent-batch', 'discussion'])
const V4_WORKFLOWS = new Set(['manual', 'auto', 'concurrent-batch', 'discussion'])
const V4_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile',
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'commit', 'committed', 'completed', 'failed', 'stopped', 'human-gate',
])
const V4_SLOT_STATUSES = new Set([
@@ -60,7 +60,7 @@ const MESSAGE_ID = /^message-[a-f0-9]{64}$/u
const EVIDENCE_ID = /^evidence-[a-f0-9]{64}$/u
const ORCHESTRATION_FIELDS = new Set([
'version', 'workflow', 'template', 'phase', 'batchId', 'round',
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'batchId', 'round',
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
@@ -1784,6 +1784,14 @@ function parseOrchestrationV4(input, options = {}) {
}
const { workflow, template } = normalizeWorkflow(input.workflow, input.template)
const phase = phaseForTemplate(template, String(input.phase || ''))
const discussionStyle = hasOwn(input, 'discussionStyle')
? String(input.discussionStyle || '')
: ''
if (discussionStyle && (
workflow !== 'auto'
|| template !== 'discussion'
|| !['sequential', 'agent-led'].includes(discussionStyle)
)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
const batchId = cleanId(input.batchId)
const snapshotHash = String(input.snapshotHash || '')
if (!batchId || !SHA256.test(snapshotHash)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
@@ -2017,6 +2025,7 @@ function parseOrchestrationV4(input, options = {}) {
workflow,
template,
phase,
...(discussionStyle ? { discussionStyle } : {}),
batchId,
currentKinds,
activeKinds,
@@ -252,6 +252,10 @@ function normalizeContextStats(input) {
const value = String(input[field] || '')
if (SHA256.test(value)) context[field] = value
}
const operationId = cleanId(input.operationId)
if (operationId) context.operationId = operationId
const snapshotHash = String(input.snapshotHash || '')
if (SHA256.test(snapshotHash)) context.snapshotHash = snapshotHash
if (input.sessionRotated === true) context.sessionRotated = true
const contextPackId = normalizeContextPackId(input.contextPackId)
if (contextPackId) context.contextPackId = contextPackId
+1 -1
View File
@@ -28,7 +28,7 @@ const CONTEXT_FIELDS = new Set([
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
'connector', 'connectorEventState',
])
+18 -6
View File
@@ -42,7 +42,7 @@ const CONTEXT_FIELDS = new Set([
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
'connector', 'connectorEventState',
])
const CONTEXT_PACK_STATES = new Set(['captured', 'legacy-unavailable'])
@@ -62,7 +62,7 @@ const CONTINUATION_FIELDS = new Set([
])
const CONTINUATION_INVOCATION_FIELDS = ['phase', 'slotId', 'operationId', 'snapshotHash']
const CONTINUATION_V4_PHASES = new Set([
'proposal', 'challenge', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
])
const CONTINUATION_GATE_TYPES = new Set(['permission', 'budget', 'decision', 'retry', 'input'])
const CONTINUATION_RESUME_KINDS = new Set([
@@ -75,8 +75,9 @@ const ACTIVE_CONTINUATION_STATES = new Set(['pending', 'ready', 'resuming'])
const ORCHESTRATION_FIELDS = new Set([
'version', 'workflow', 'currentKind', 'pendingKinds', 'activeKinds',
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'template', 'taskGraph',
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'totalSuccesses', 'terminalFailureOccurred', 'executionSequence',
'collaboration', 'template', 'taskGraph',
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings', 'synthesisBinding',
'synthesisRecovery', 'convergence', 'coordinationPlan', 'workReceipts', 'candidateCommit',
])
@@ -239,7 +240,7 @@ function normalizeAgentRun(input, parent, fallbackTimestamp) {
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'deliveryRecordIds', 'sessionProvenance', 'outcomeRefs',
'sourceCount', 'sourceHash', 'promptBytes', 'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'connector', 'connectorEventState',
'operationId', 'snapshotHash', 'connector', 'connectorEventState',
].some(key => hasOwn(input.context, key))
const rawEvents = Array.isArray(input.events) ? input.events : []
const rawSourceIds = Array.isArray(input.sourceMessageIds) ? input.sourceMessageIds : []
@@ -489,8 +490,9 @@ function normalizeOrchestration(input) {
const agreementKinds = normalizeKinds(input.agreementKinds)
const attachmentRecipients = normalizeKinds(input.attachmentRecipients)
const totalSuccesses = boundedNumber(input.totalSuccesses, 0, 1000000)
const executionSequence = boundedNumber(input.executionSequence, 0, 1000000)
const v4OnlyFields = [
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings',
'synthesisBinding', 'synthesisRecovery', 'convergence', 'candidateCommit',
]
@@ -498,6 +500,10 @@ function normalizeOrchestration(input) {
|| (version === 1 && hasOwn(input, 'collaboration'))
|| (version === 2 && workflow !== 'auto')
|| (version < 3 && (hasOwn(input, 'template') || hasOwn(input, 'taskGraph')))
|| (hasOwn(input, 'executionSequence')
&& (!Number.isInteger(input.executionSequence)
|| input.executionSequence < 0
|| input.executionSequence > 1000000))
|| (version < 4 && v4OnlyFields.some(field => hasOwn(input, field)))
|| (version === 3 && (workflow !== 'auto' || input.template !== 'task-graph'))) {
return undefined
@@ -529,6 +535,7 @@ function normalizeOrchestration(input) {
attachmentRecipients,
totalSuccesses,
terminalFailureOccurred: input.terminalFailureOccurred === true,
...(hasOwn(input, 'executionSequence') ? { executionSequence } : {}),
...(version >= 2 ? { collaboration } : {}),
...(version === 3 ? { template: 'task-graph', taskGraph } : {}),
}
@@ -694,6 +701,11 @@ function hasValidStoredRecordShape(input) {
&& (typeof agentRun.context[field] !== 'string'
|| !SHA256.test(agentRun.context[field]))) return false
}
if (hasOwn(agentRun.context, 'operationId')
&& cleanId(agentRun.context.operationId) !== agentRun.context.operationId) return false
if (hasOwn(agentRun.context, 'snapshotHash')
&& (typeof agentRun.context.snapshotHash !== 'string'
|| !SHA256.test(agentRun.context.snapshotHash))) return false
if (hasOwn(agentRun.context, 'contextPackId')
&& normalizeContextPackId(agentRun.context.contextPackId)
!== agentRun.context.contextPackId) return false
@@ -83,7 +83,9 @@ function agentReturnedMediaProviderFailure(text) {
}
const OPERATION_ID = /^[A-Za-z0-9._:-]{1,120}$/
const SHA256 = /^[a-f0-9]{64}$/
const V4_PHASES = new Set(['proposal', 'challenge', 'work', 'synthesis', 'verification'])
const V4_PHASES = new Set([
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
])
const V4_RECEIPT_MARKER_PREFIX = '[[MELDWORK_COLLABORATION'
const V4_RECEIPT_MARKER = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?:([\s\S]*?)\]\]/i
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]/i
@@ -326,7 +328,8 @@ function attachInvocationFailure(error, input) {
}
function isolatedInvocationContext(context) {
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)) return null
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)
&& !(context?.v4 === true && typeof context.promptOverride === 'string')) return null
const promptOverride = typeof context.promptOverride === 'string'
? context.promptOverride
: ''
@@ -339,7 +342,7 @@ function isolatedInvocationContext(context) {
return {
promptOverride,
contextPackId,
policy: context.sessionPolicy,
policy: ['isolated', 'frozen'].includes(context?.sessionPolicy) ? context.sessionPolicy : '',
}
}
@@ -712,7 +715,12 @@ class LocalWorkspaceAgentInvocation {
&& !resumedConnectorGate
? context.resumedGate
: null
const sessionNeedsRotation = sessionRef && shouldRotateSession(sessionMeta)
const preserveV4TaskSession = context.v4 === true
&& group.conversationType !== 'direct'
&& Boolean(taskId)
const sessionNeedsRotation = sessionRef
&& shouldRotateSession(sessionMeta)
&& !preserveV4TaskSession
if (resumedPermission) {
const requestHash = sha256(canonicalJson(resumedPermission.request))
const persistedBinding = {
@@ -1172,6 +1180,12 @@ class LocalWorkspaceAgentInvocation {
...(Number.isInteger(context.executionSequence)
? { executionSequence: context.executionSequence }
: {}),
...(v4AgentSlotBinding
? {
operationId: v4AgentSlotBinding.operationId,
snapshotHash: v4AgentSlotBinding.snapshotHash,
}
: {}),
...runtimeContext,
...deliveryContext,
...connectorContext,
@@ -1261,12 +1275,14 @@ class LocalWorkspaceAgentInvocation {
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
? v4Prompt
: [
this.promptFor(
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
threadRootId, context.skillHints || [],
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
activeRun?.unlimitedRounds === true,
),
context.v4 === true && v4Prompt
? v4Prompt
: this.promptFor(
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
threadRootId, context.skillHints || [],
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
activeRun?.unlimitedRounds === true,
),
stagedAgentInputPrompt(stagedInputs),
generatedMedia
? `Meldwork generated and will attach ${generatedMedia.filename}. Confirm the delivered media briefly; do not claim a different file was created.`
@@ -70,6 +70,7 @@ const {
terminalRunStatusForReason,
unauthorizedFailure,
} = require('./local-workspace-auto-runner-support.cjs')
const { traceCapsuleFromAgentRun } = require('../runs/run-harness-normalization.cjs')
const { v4OutcomeMethods } = require('./local-workspace-auto-runner-v4-outcomes.cjs')
class LocalWorkspaceAutoRunner {
@@ -1473,14 +1474,16 @@ class LocalWorkspaceAutoRunner {
)
}
v4OperationId(controller, kind, phase, slotId = '') {
v4OperationId(
controller, kind, phase, slotId = '', round = controller.currentRound || 0,
) {
const digest = createHash('sha256').update(JSON.stringify([
controller.runId,
controller.taskId,
kind,
slotId,
phase,
controller.currentRound || 0,
round,
])).digest('hex')
return `operation-${digest}`
}
@@ -2037,7 +2040,7 @@ class LocalWorkspaceAutoRunner {
}
v4CheckpointRole(phase, slot, coordinationPlan, workAssignment = null) {
if (['proposal', 'challenge'].includes(phase)) return 'participant'
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
const assignment = phase === 'work'
? (workAssignment || this.v4CoordinationAssignment(coordinationPlan, slot.agentKind))
: (phase === 'synthesis'
@@ -2052,7 +2055,7 @@ class LocalWorkspaceAutoRunner {
}
v4PromptRole(phase, kind, coordinationPlan, workAssignment = null) {
if (['proposal', 'challenge'].includes(phase)) return 'participant'
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
if (phase === 'verification') {
if (!coordinationPlan?.verifierKinds?.includes(kind)) {
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
@@ -2425,6 +2428,443 @@ class LocalWorkspaceAutoRunner {
].filter(Boolean).join('\n\n')
}
v4NaturalRouteDecision(text, activeKinds) {
const participants = Array.isArray(activeKinds) ? activeKinds : []
const finalLine = String(text || '').split(/\r?\n/u)
.map(line => line.trim())
.filter(Boolean)
.at(-1) || ''
if (!finalLine || !participants.length) return { status: 'none', kinds: [] }
const selected = new Set()
const mentionPattern = /@([A-Za-z0-9][A-Za-z0-9_-]{0,39})/gu
let cursor = 0
for (const match of finalLine.matchAll(mentionPattern)) {
const separator = finalLine.slice(cursor, match.index)
if (separator.includes('@') || !/^[\s\p{P}]*$/u.test(separator)) {
return { status: 'invalid', kinds: [] }
}
const kind = match[1]
if (!participants.includes(kind)) return { status: 'invalid', kinds: [] }
selected.add(kind)
cursor = match.index + match[0].length
}
const tail = finalLine.slice(cursor)
if (!selected.size) {
return finalLine.includes('@')
? { status: 'invalid', kinds: [] }
: { status: 'none', kinds: [] }
}
if (tail.includes('@') || !/^[\s\p{P}]*$/u.test(tail)) {
return { status: 'invalid', kinds: [] }
}
return { status: 'valid', kinds: participants.filter(kind => selected.has(kind)) }
}
v4MentionedNextKinds(text, activeKinds) {
return this.v4NaturalRouteDecision(text, activeKinds).kinds
}
v4NaturalMessageMatchesBinding(message, controller) {
const phase = String(message?.trace?.phase || '')
const kind = String(message?.agentKind || '')
const round = Number(message?.trace?.round) || 0
if (!['proposal', 'discussion'].includes(phase) || !kind || round < 1) return false
if (message.trace?.runId !== controller.runId) return false
const slot = controller.orchestration?.slots?.find(candidate => candidate.agentKind === kind)
const snapshotHash = String(controller.orchestration?.snapshotHash || '')
if (!slot?.slotId || !snapshotHash) return false
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
const expectedOperationId = this.v4OperationId(
controller, kind, operationPhase, slot.slotId, round,
)
return message.trace?.context?.operationId === expectedOperationId
&& message.trace?.context?.snapshotHash === snapshotHash
}
v4NaturalThreadTranscript(group, threadRootId, options = {}) {
const rounds = Array.isArray(options.rounds) ? new Set(options.rounds) : null
const blocks = this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind
&& (!rounds || rounds.has(Number(message.trace?.round) || 0))
&& (!options.controller || this.v4NaturalMessageMatchesBinding(
message, options.controller,
))
)).map(message => [
`Round ${Number(message.trace?.round) || 0} - @${message.agentKind}`,
String(message.content || '').trim(),
].filter(Boolean).join('\n'))
return this.v4SanitizeDeliveryText(blocks.join('\n\n'), Number.MAX_SAFE_INTEGER)
}
v4NaturalRecoveredPhaseResult(group, controller, threadRootId, phase, kind, slot) {
if (!this.naturalAgentResponses || !slot || controller.currentRound < 1) return null
if (!['proposal', 'discussion'].includes(phase)) return null
const operationId = String(slot.operationId || '')
const snapshotHash = String(slot.snapshotHash || '')
if (!operationId || !snapshotHash) return null
const matchesBinding = context => (
context?.operationId === operationId && context?.snapshotHash === snapshotHash
)
const scopedMessages = [...this.state().messages].reverse().filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind === kind
&& message.trace?.runId === controller.runId
&& message.trace?.phase === phase
&& Number(message.trace?.round) === controller.currentRound
))
if (scopedMessages.some(message => !matchesBinding(message.trace?.context))) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const existingMessage = scopedMessages.find(message => matchesBinding(message.trace?.context))
let content = String(existingMessage?.content || '')
let trace = existingMessage?.trace || null
let metadata = existingMessage ? {
...(existingMessage.elapsedMs != null ? { elapsedMs: existingMessage.elapsedMs } : {}),
...(Array.isArray(existingMessage.toolCalls) ? { toolCalls: existingMessage.toolCalls } : {}),
...(Array.isArray(existingMessage.attachments)
? { attachments: existingMessage.attachments } : {}),
...(existingMessage.responseVersionRootId
? { responseVersionRootId: existingMessage.responseVersionRootId } : {}),
trace,
} : null
if (!content || !trace?.agentRunId) {
const scopedRuns = [...(controller.harness?.snapshot?.() || [])].reverse().filter(run => (
run.kind === kind
&& run.round === controller.currentRound
&& run.status === 'completed'
&& String(run.output || '')
))
if (scopedRuns.some(run => !matchesBinding(run.context))) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const recoveredRun = scopedRuns.find(run => matchesBinding(run.context))
if (!recoveredRun) return null
content = String(recoveredRun.output)
trace = traceCapsuleFromAgentRun(recoveredRun, {
runId: controller.runId,
status: recoveredRun.status,
phase,
})
if (!trace?.agentRunId) return null
metadata = { trace }
}
return {
text: content,
pendingMessage: {
groupId: group.id,
role: 'agent',
content,
agentKind: kind,
threadRootId,
system: null,
metadata,
},
collaboration: null,
outcomeRefs: trace.context?.outcomeRefs || {},
operationId: slot.operationId,
consensus: false,
}
}
v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, kind, slot) {
return this.v4NaturalRecoveredPhaseResult(
group, controller, threadRootId, 'discussion', kind, slot,
)
}
v4NaturalPhasePrompt(group, kind, phase, snapshot, receiptRecords, activeKinds, options = {}) {
const transcript = String(options.transcript || '')
const agentList = activeKinds.map(agentKind => `@${agentKind}`).join(', ')
return [
v4Prompt({
group, kind, phase, snapshot, role: 'participant',
skillHints: options.skillHints, naturalResponse: true,
}),
transcript ? `Completed peer turns:\n${transcript}` : '',
phase === 'discussion' && options.allowRouting === true
? [
'Decide which Agent or Agents should contribute next based on the discussion and the work still needed.',
`The final non-empty line may contain only the exact Agent mentions that should respond next: ${agentList}.`,
'Use one mention for a single next Agent or multiple mentions for a concurrent batch.',
'End without any Agent mention only when you accept the current result and made no substantive change. If you made a substantive change, mention at least one different Agent for review.',
].join('\n')
: '',
].filter(Boolean).join('\n\n')
}
v4NaturalDiscussionOperationIds(controller, slots, kinds, round) {
return new Map(kinds.map((kind) => {
const slot = slots.find(candidate => candidate.agentKind === kind)
return [kind, this.v4OperationId(
controller, kind, `discussion:${round}`, slot?.slotId || '', round,
)]
}))
}
v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round) {
return this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.trace?.runId === controller.runId
&& message.trace?.phase === 'discussion'
&& Number(message.trace?.round) === round
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
}
v4NaturalRoundHasPeerConfirmation(
group, controller, threadRootId, round, activeKinds,
) {
if (round <= 2) return false
const currentMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round,
)
if (!currentMessages.length) return false
const currentDecisions = currentMessages.map(message => (
this.v4NaturalRouteDecision(message.content, activeKinds)
))
if (currentDecisions.some(decision => decision.status !== 'none')) return false
const previousMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round - 1,
)
if (!previousMessages.length) return false
const previousDecisions = previousMessages.map(message => ({
agentKind: message.agentKind,
decision: this.v4NaturalRouteDecision(message.content, activeKinds),
}))
const peerSelected = currentMessages.some(message => previousDecisions.some(previous => (
previous.agentKind !== message.agentKind
&& previous.decision.status === 'valid'
&& previous.decision.kinds.includes(message.agentKind)
)))
if (peerSelected) return true
return previousDecisions.every(previous => previous.decision.status === 'none')
&& currentMessages.some(message => (
previousMessages.every(previous => previous.agentKind !== message.agentKind)
))
}
v4NaturalConfirmationKind(activeKinds, currentKinds) {
const current = new Set(currentKinds)
return activeKinds.find(kind => !current.has(kind)) || ''
}
async runV4SequentialDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
checkpointPhase, removePhaseFailures,
} = input
let activeKinds = [...initialActiveKinds]
let slots = initialSlots
let round = Math.max(1, Number(controller.currentRound) || 1)
let pendingKinds = controller.orchestration?.discussionStyle === 'sequential'
&& controller.orchestration?.phase === 'discussion'
&& controller.orchestration?.round === round
? (controller.orchestration.pendingKinds || []).filter(kind => activeKinds.includes(kind))
: [...activeKinds]
if (!pendingKinds.length && controller.orchestration?.phase === 'discussion') {
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) return 'completed'
round += 1
pendingKinds = [...activeKinds]
}
while (!controller.signal.aborted) {
controller.currentRound = round
while (pendingKinds.length && !controller.signal.aborted) {
const kind = pendingKinds[0]
const remainingKinds = pendingKinds.slice(1)
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: [kind],
targetKinds,
participantKinds: activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, [kind], round,
),
pendingKinds,
remainingKinds,
sessionPolicy: null,
disableDeliveryPrompt: true,
promptBuilder: ({ kind: promptKind }) => this.v4NaturalPhasePrompt(
group, promptKind, 'discussion', snapshot, receiptRecords, activeKinds, {
transcript: this.v4NaturalThreadTranscript(
group, threadRootId, { controller },
),
skillHints: context.rootSkillsByKind.get(promptKind) || [],
},
),
})
slots = dispatch.slots || slots
if (!dispatch.ok) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(agentKind => !failedKinds.includes(agentKind))
if (!activeKinds.length) {
return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
pendingKinds = remainingKinds.filter(agentKind => activeKinds.includes(agentKind))
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) return 'completed'
round += 1
controller.currentRound = round
pendingKinds = [...activeKinds]
}
return terminalRunStatusForReason(controller.stopReason)
}
async runV4AgentLedDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
latestFor, runPhase, checkpointPhase, removePhaseFailures, addRoundLimitNotice,
} = input
let activeKinds = [...initialActiveKinds]
let slots = initialSlots
let round = Math.max(1, Number(controller.currentRound) || 1)
const proposalCompleteBeforeRun = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalCompleteBeforeRun) {
controller.currentRound = 1
const proposal = await runPhase('proposal', activeKinds)
slots = proposal.slots || slots
if (!proposal.ok) {
const failedKinds = removePhaseFailures(proposal)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
checkpointPhase('proposal', [], slots, null)
if (!activeKinds.length) return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
const proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalComplete) {
return activeKinds.some(kind => Boolean(latestFor('proposal', kind))) ? 'partial' : 'failed'
}
if (!controller.unlimitedRounds && (controller.maxRounds || 6) <= 1) return 'completed'
let nextKinds = []
if (controller.orchestration?.phase === 'discussion') {
round = Math.max(2, Number(controller.orchestration.round) || 2)
nextKinds = (controller.orchestration.pendingKinds || [])
.filter(kind => activeKinds.includes(kind))
if (!nextKinds.length) {
const roundMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round,
)
nextKinds = [...new Set(roundMessages.flatMap((message) => {
const decision = this.v4NaturalRouteDecision(message.content, activeKinds)
return decision.status === 'valid' ? decision.kinds : []
}))]
if (!nextKinds.length) {
if (this.v4NaturalRoundHasPeerConfirmation(
group, controller, threadRootId, round, activeKinds,
)) return 'completed'
const confirmationKind = this.v4NaturalConfirmationKind(
activeKinds, roundMessages.map(message => message.agentKind),
)
if (!confirmationKind) return roundMessages.length ? 'partial' : 'failed'
nextKinds = [confirmationKind]
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
}
} else {
round = 2
nextKinds = activeKinds.slice(0, 1)
}
while (!controller.signal.aborted) {
controller.currentRound = round
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: nextKinds,
targetKinds,
participantKinds: activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, nextKinds, round,
),
pendingKinds: nextKinds,
remainingKinds: [],
sessionPolicy: null,
disableDeliveryPrompt: true,
promptBuilder: ({ kind }) => this.v4NaturalPhasePrompt(
group, kind, 'discussion', snapshot, receiptRecords, activeKinds, {
transcript: this.v4NaturalThreadTranscript(group, threadRootId, { controller }),
skillHints: context.rootSkillsByKind.get(kind) || [],
allowRouting: true,
},
),
})
slots = dispatch.slots || slots
if (!dispatch.ok) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
if (!activeKinds.length) return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
}
const roundMessages = this.v4NaturalDiscussionRoundMessages(
group, controller, threadRootId, round,
)
nextKinds = [...new Set(roundMessages.flatMap((message) => {
const decision = this.v4NaturalRouteDecision(message.content, activeKinds)
return decision.status === 'valid' ? decision.kinds : []
}))].filter(kind => activeKinds.includes(kind))
if (!nextKinds.length) {
if (this.v4NaturalRoundHasPeerConfirmation(
group, controller, threadRootId, round, activeKinds,
)) return 'completed'
const confirmationKind = this.v4NaturalConfirmationKind(
activeKinds, roundMessages.map(message => message.agentKind),
)
if (!confirmationKind) return roundMessages.length ? 'partial' : 'failed'
nextKinds = [confirmationKind]
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
controller.currentRound = round
}
return terminalRunStatusForReason(controller.stopReason)
}
async runV4NaturalDiscussion(group, controller, threadRootId, context, input) {
const style = controller.orchestration?.discussionStyle
|| controller.discussionStyle
|| 'sequential'
return style === 'agent-led'
? this.runV4AgentLedDiscussion(group, controller, threadRootId, context, input)
: this.runV4SequentialDiscussion(group, controller, threadRootId, context, input)
}
recordV4ReviewerFindings({ controller, phase, kind, result, verdict, summary,
reviewedArtifactId = '' }) {
if (!this.outcomeStore || !['challenge', 'verification'].includes(phase)
@@ -2793,6 +3233,9 @@ class LocalWorkspaceAutoRunner {
workflow: 'auto',
template: 'discussion',
phase,
discussionStyle: controller.discussionStyle
|| controller.orchestration?.discussionStyle
|| 'sequential',
batchId,
round: Math.max(0, Number(controller.currentRound) || 0),
currentKind: '',
@@ -2964,22 +3407,27 @@ class LocalWorkspaceAutoRunner {
const settled = await Promise.all(dispatches.map(async (dispatch) => {
const { kind, slot, context } = dispatch
try {
const invocation = await this.invokeWithUnauthorizedRecovery({
group,
kind,
controller,
activeKinds: participantKinds,
threadRootId,
context: {
...context,
onLeaseAcquired: (leaseState) => {
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
slot.agentRunId = leaseState.agentRunId
slot.status = 'running'
checkpoint()
},
},
})
const recoveredResult = this.v4NaturalRecoveredPhaseResult(
group, controller, threadRootId, 'proposal', kind, slot,
)
const invocation = recoveredResult
? { result: recoveredResult }
: await this.invokeWithUnauthorizedRecovery({
group,
kind,
controller,
activeKinds: participantKinds,
threadRootId,
context: {
...context,
onLeaseAcquired: (leaseState) => {
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
slot.agentRunId = leaseState.agentRunId
slot.status = 'running'
checkpoint()
},
},
})
if (controller.signal.aborted) {
slot.status = 'stopped'
slot.commitStatus = 'partial'
@@ -3280,7 +3728,9 @@ class LocalWorkspaceAutoRunner {
const synthesisAttempt = phase === 'synthesis'
? this.v4ActiveSynthesisAttempt(synthesisRecovery)
: null
const operationIds = synthesisAttempt
const operationIds = phaseInput.operationIds instanceof Map
? phaseInput.operationIds
: synthesisAttempt
? new Map([[synthesisAttempt.writerKind, synthesisAttempt.operationId]])
: (phase === 'work'
? new Map(activeKinds.map((kind) => {
@@ -3321,6 +3771,9 @@ class LocalWorkspaceAutoRunner {
slot.status = phase === 'synthesis' ? 'queued' : 'running'
}
}
const cursorPendingKinds = Array.isArray(phaseInput.pendingKinds)
? phaseInput.pendingKinds
: activeKinds
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
@@ -3330,7 +3783,7 @@ class LocalWorkspaceAutoRunner {
slots: phaseSlots,
writerKind,
currentKinds: activeKinds,
pendingKinds: activeKinds,
pendingKinds: cursorPendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
@@ -3351,6 +3804,7 @@ class LocalWorkspaceAutoRunner {
skillHints: context.rootSkillsByKind.get(kind) || [],
},
)
const sessionPolicy = this.naturalAgentResponses ? null : 'frozen'
return {
kind,
slot,
@@ -3360,7 +3814,7 @@ class LocalWorkspaceAutoRunner {
batchId,
slotId: slot.slotId,
snapshotHash,
sessionPolicy: 'frozen',
...(sessionPolicy ? { sessionPolicy } : {}),
promptOverride: prompt,
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
@@ -3439,27 +3893,39 @@ class LocalWorkspaceAutoRunner {
phase, kind, coordinationPlan, workAssignmentsByKind.get(kind) || null,
)
const extraContext = extraContextFor(kind)
const prompt = this.v4PhasePrompt(
group,
kind,
phase,
snapshot,
deliveryReceiptRecords,
role,
{
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
packageText: '',
extraContext,
snapshotHash,
skillHints: context.rootSkillsByKind.get(kind) || [],
},
)
const promptOptions = {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
packageText: '',
extraContext,
snapshotHash,
skillHints: context.rootSkillsByKind.get(kind) || [],
}
const prompt = typeof phaseInput.promptBuilder === 'function'
? phaseInput.promptBuilder({ kind, slot, role, options: promptOptions })
: this.v4PhasePrompt(
group,
kind,
phase,
snapshot,
deliveryReceiptRecords,
role,
promptOptions,
)
const sessionPolicy = phaseInput.sessionPolicy === undefined
? 'frozen'
: phaseInput.sessionPolicy
let preparedDelivery = null
let item
const resumedSlotGate = resumedGateFor(slot)
const recoveredResult = phase === 'discussion'
? this.v4NaturalRecoveredDiscussionResult(
group, controller, threadRootId, kind, slot,
)
: null
if (recoveredResult) return { kind, invocation: { result: recoveredResult } }
try {
const invocation = await this.invokeWithUnauthorizedRecovery({
group,
@@ -3473,7 +3939,7 @@ class LocalWorkspaceAutoRunner {
batchId,
slotId: slot.slotId,
snapshotHash,
sessionPolicy: 'frozen',
...(sessionPolicy ? { sessionPolicy } : {}),
promptOverride: prompt,
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
@@ -3521,7 +3987,7 @@ class LocalWorkspaceAutoRunner {
slots: phaseSlots,
writerKind,
currentKinds: activeKinds,
pendingKinds: activeKinds,
pendingKinds: cursorPendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
@@ -3531,26 +3997,30 @@ class LocalWorkspaceAutoRunner {
workAssignments,
})
},
v4PromptBuilder: (sessionBinding) => {
const built = this.v4DeliveryPrompt(group, controller, {
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
role, targetKinds: participantKinds, slot,
options: {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
extraContext,
...(['synthesis', 'verification'].includes(phase)
? { artifactContext: { coordinationPlan } }
: {}),
},
sessionBinding,
skillHints: context.rootSkillsByKind.get(kind) || [],
})
preparedDelivery = built.delivery
return built
},
...(phaseInput.disableDeliveryPrompt === true
? {}
: {
v4PromptBuilder: (sessionBinding) => {
const built = this.v4DeliveryPrompt(group, controller, {
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
role, targetKinds: participantKinds, slot,
options: {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
extraContext,
...(['synthesis', 'verification'].includes(phase)
? { artifactContext: { coordinationPlan } }
: {}),
},
sessionBinding,
skillHints: context.rootSkillsByKind.get(kind) || [],
})
preparedDelivery = built.delivery
return built
},
}),
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
},
@@ -3692,7 +4162,7 @@ class LocalWorkspaceAutoRunner {
synthesisPendingMessage = result.pendingMessage || result.message || null
} else if (result.pendingMessage?.content) {
phasePendingMessages.push({ kind: item.kind, pendingMessage: result.pendingMessage })
if (phase === 'challenge') {
if (['discussion', 'challenge'].includes(phase)) {
this.commitV4PhaseMessage({
group, controller, threadRootId, phase, kind: item.kind,
pendingMessage: result.pendingMessage,
@@ -3777,6 +4247,9 @@ class LocalWorkspaceAutoRunner {
&& !this.completeV4ResumedAgentSlotContinuation(controller, resumedGate)) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
const remainingKinds = Array.isArray(phaseInput.remainingKinds)
? phaseInput.remainingKinds
: []
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
@@ -3786,7 +4259,7 @@ class LocalWorkspaceAutoRunner {
slots: phaseSlots,
writerKind,
currentKinds: [],
pendingKinds: [],
pendingKinds: remainingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
@@ -4053,6 +4526,9 @@ class LocalWorkspaceAutoRunner {
? controller.orchestration
: null
const targetKinds = [...snapshotTargetKinds]
controller.discussionStyle = existing?.discussionStyle
|| controller.discussionStyle
|| 'sequential'
let activeKinds = Array.isArray(existing?.activeKinds) && existing.activeKinds.length
? existing.activeKinds.filter(kind => targetKinds.includes(kind))
: [...targetKinds]
@@ -4609,6 +5085,24 @@ class LocalWorkspaceAutoRunner {
writerKind, verificationKinds, acceptance, participantKinds: activeKinds,
})
}
if (this.naturalAgentResponses && !coordinationPlan && !synthesisBinding && !synthesisRecovery) {
return this.runV4NaturalDiscussion(group, controller, threadRootId, context, {
targetKinds,
activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
latestFor,
runPhase,
checkpointPhase,
removePhaseFailures,
addRoundLimitNotice,
})
}
while (!controller.signal.aborted) {
controller.currentRound = round
if (!proposalComplete) {
@@ -5289,6 +5783,7 @@ class LocalWorkspaceAutoRunner {
group.id, 'auto', targetKinds, threadRootId, reservation, maxRounds, unlimitedRounds,
)
controller.v4 = v4
controller.discussionStyle = reservation?.discussionStyle || 'sequential'
const promise = (async () => {
let runStatus = 'failed'
try {
@@ -230,8 +230,9 @@ function v4Prompt({
const selectedAgentCount = Array.isArray(snapshot?.targetKinds) ? snapshot.targetKinds.length : 0
const requiredVerifierCount = Math.max(0, selectedAgentCount - 1)
const phaseInstruction = naturalResponse
? {
? {
proposal: 'Work independently on the user task and offer your own analysis, recommendations, and next steps.',
discussion: 'Continue from the available peer responses. Add useful evidence, challenge weak assumptions, resolve disagreements where possible, and advance the shared result.',
challenge: 'Review the available peer context as a subject-matter peer. Explain what you agree with, what is missing, and the clearest correction or direction.',
work: 'Complete the responsibility assigned to you. Report useful work, evidence, blockers, or handoffs in your own words.',
synthesis: 'Combine the available work into the clearest useful answer for the user. Resolve contradictions where possible and state remaining uncertainty plainly.',
@@ -270,12 +271,12 @@ function v4Prompt({
if (naturalResponse) {
return [
'You are participating in a local multi-agent discussion.',
`Your role this turn is ${role}.`,
`The current collaboration phase is ${phase}.`,
`User task:\n${cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT) || '(none)'}`,
skillHintsPrompt(frozenSkillHints),
history !== '(none)' ? `Relevant prior context:\n${history}` : '',
phaseInstruction,
'Engage with the other Agents as peers: discuss differences, challenge weak assumptions, build agreement where justified, and collaborate on the user\'s requested outcome.',
'After concrete deliverables exist, review them for material gaps before accepting the result.',
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
].filter(Boolean).join('\n\n')
@@ -45,6 +45,8 @@ const {
validateV4SnapshotBody,
} = require('./local-workspace-context.cjs')
const DISCUSSION_STYLES = new Set(['sequential', 'agent-led'])
function hardBudgetFailure(error) {
return error?.code === 'LOCAL_BUDGET_EXHAUSTED'
|| error?.message === 'LOCAL_BUDGET_EXHAUSTED'
@@ -337,15 +339,28 @@ class LocalWorkspaceMessageSubmission {
if (regenerateMessageId && (mode !== 'manual' || targetKinds.length !== 1)) {
throw new Error('LOCAL_MESSAGE_REGENERATION_INVALID')
}
const v4 = (mode === 'manual' || mode === 'auto')
&& group.conversationType !== 'direct'
&& targetKinds.length > 1
&& !regenerateMessageId
&& input.protocol !== 'legacy'
&& this.v4Requested(input)
const requestedDiscussionStyle = input.discussionStyle == null
? ''
: cleanInline(input.discussionStyle, 40)
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
}
if (requestedDiscussionStyle && (mode !== 'auto' || !v4)) {
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
}
return {
mode,
targetKinds,
v4: (mode === 'manual' || mode === 'auto')
&& group.conversationType !== 'direct'
&& targetKinds.length > 1
&& !regenerateMessageId
&& input.protocol !== 'legacy'
&& this.v4Requested(input),
v4,
discussionStyle: mode === 'auto' && v4
? (requestedDiscussionStyle || 'sequential')
: '',
unlimitedRounds,
maxRounds,
requestedThreadRootId,
@@ -1428,6 +1443,7 @@ class LocalWorkspaceMessageSubmission {
regenerateMessageId,
routingDecision,
taskGraph,
discussionStyle,
} = this.validateInput(group, input)
const regeneration = this.resolveRegeneration(group, regenerateMessageId, targetKinds)
const reservation = this.reserveRun(
@@ -1439,6 +1455,7 @@ class LocalWorkspaceMessageSubmission {
unlimitedRounds,
)
reservation.v4 = v4
reservation.discussionStyle = discussionStyle
try {
if (regeneration) reservation.responseVersionRootId = regeneration.responseVersionRootId
this.configureRunBudget(reservation, input.budget || {})
@@ -1797,6 +1814,18 @@ class LocalWorkspaceMessageSubmission {
group.id, 'auto', targetKinds, threadRootId, maxRounds, unlimitedRounds,
)
reservation.v4 = input.protocol !== 'legacy' && this.v4Requested(input)
const requestedDiscussionStyle = input.discussionStyle == null
? ''
: cleanInline(input.discussionStyle, 40)
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
this.releasePreparation(group.id, reservation)
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
}
if (requestedDiscussionStyle && !reservation.v4) {
this.releasePreparation(group.id, reservation)
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
}
reservation.discussionStyle = requestedDiscussionStyle || 'sequential'
try {
this.configureRunBudget(reservation, input.budget || {})
const contextPack = this.createContextPack({
@@ -19,7 +19,7 @@ const FAILED_AGENT_STATUSES = new Set(['failed', 'stopped', 'timeout'])
const TERMINAL_PERSISTENCE_STATES = new Set(['pending', 'retrying', 'failed'])
const V4_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'challenge',
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'discussion', 'challenge',
'coordination', 'work', 'synthesis', 'verification', 'commit', 'committed', 'completed',
'human-gate', 'stopped', 'failed',
])
+29 -6
View File
@@ -66,7 +66,29 @@ const TERMINAL_RUN_STATUSES = new Set([
'completed', 'partial', 'failed', 'stopped', 'timeout', 'round-limit', 'interrupted',
'budget-exhausted', 'circuit-breaker',
])
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'challenge', 'work', 'verification']
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'discussion', 'challenge', 'work', 'verification']
function naturalV4MessageBindingMatches(message, record, phase) {
if (!record.orchestration?.discussionStyle || !['proposal', 'discussion'].includes(phase)) {
return true
}
const round = Number(message?.trace?.round) || 0
const slot = record.orchestration.slots?.find(candidate => (
candidate.agentKind === message.agentKind
))
if (round < 1 || !slot?.slotId) return false
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
const operationId = `operation-${createHash('sha256').update(JSON.stringify([
record.runId,
record.taskId,
message.agentKind,
slot.slotId,
operationPhase,
round,
])).digest('hex')}`
return message.trace?.context?.operationId === operationId
&& message.trace?.context?.snapshotHash === record.orchestration.snapshotHash
}
function isCommittedV4PhaseMessage(message, record) {
const agentRunId = String(message?.trace?.agentRunId || '')
@@ -74,15 +96,16 @@ function isCommittedV4PhaseMessage(message, record) {
|| message?.trace?.runId !== record.runId || !message.agentKind || !agentRunId) {
return false
}
return V4_VISIBLE_MESSAGE_PHASES.some(phase => (
message.id === `message-${hashValue({
return V4_VISIBLE_MESSAGE_PHASES.some((phase) => {
if (message.id !== `message-${hashValue({
agentKind: message.agentKind,
agentRunId,
phase,
runId: record.runId,
taskId: record.taskId,
})}`
))
})}`) return false
return naturalV4MessageBindingMatches(message, record, phase)
})
}
class LocalWorkspace extends EventEmitter {
@@ -476,7 +499,7 @@ class LocalWorkspace extends EventEmitter {
const commitState = orchestration?.commitState
const candidateCommit = orchestration?.candidateCommit
const resumablePhase = [
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
].includes(orchestration?.phase)
const resumableCommit = ['commit', 'committed', 'completed'].includes(orchestration?.phase)
&& ['committing', 'committed'].includes(commitState?.status)
@@ -10,6 +10,7 @@ const {
normalizeExternalRunRef,
outcomeForAcpStopReason,
requireTerminalAgentResult,
terminalAuthenticationDiagnostic,
} = require('../../src/agents/agent-runtime-contract.cjs')
const { ALLOWED_KINDS } = require('../../src/agents/cli/cli-discovery.cjs')
@@ -98,3 +99,21 @@ test('failed and cancelled outcomes do not become successful results', () => {
&& error.failure.category === 'cancellation',
)
})
test('short terminal HTTP credential diagnostics cannot become successful results', () => {
const diagnostic = 'HTTP 401: Invalid token (request id: request-1)'
assert.equal(terminalAuthenticationDiagnostic(diagnostic), diagnostic)
assert.equal(
terminalAuthenticationDiagnostic('HTTP 403: Forbidden'),
'HTTP 403: Forbidden',
)
assert.throws(
() => requireTerminalAgentResult({ outcome: 'completed', text: diagnostic }),
error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
&& error.failure.category === 'authentication'
&& error.diagnostic === diagnostic,
)
assert.equal(terminalAuthenticationDiagnostic(
'When an API returns HTTP 401: Invalid token, refresh the configured credential.',
), '')
})
@@ -51,9 +51,15 @@ input.on('line', (line) => {
} else if (message.method === 'session/prompt') {
promptCount += 1
if (resumed) {
const prompt = message.params?.prompt?.[0]?.text || ''
update(sessionId, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'HTTP 401: Invalid token' },
content: {
type: 'text',
text: prompt === 'auth prompt'
? 'HTTP 401: Invalid token (request id: request-1)'
: 'resumed turn',
},
})
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
return
@@ -120,12 +126,22 @@ input.on('line', (line) => {
assert.equal(typeof adapters.shutdownAcpSessionRuntime, 'function')
await adapters.shutdownAcpSessionRuntime()
await within(readWhenReady(stoppedFile))
await assert.rejects(() => adapters.runAgent(agent, 'stale prompt', directory, {
const resumed = await adapters.runAgent(agent, 'resumed prompt', directory, {
acpPersistenceKey: 'workspace:hermes:direct',
sessionRef: first.sessionRef,
sessionTransport: 'acp',
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
}), { message: 'LOCAL_AGENT_SESSION_INVALID' })
})
assert.equal(resumed.text, 'resumed turn')
assert.equal(resumed.sessionRef, first.sessionRef)
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '2')
await assert.rejects(() => adapters.runAgent(agent, 'auth prompt', directory, {
acpPersistenceKey: 'workspace:hermes:direct',
sessionRef: first.sessionRef,
sessionTransport: 'acp',
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
}), error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
&& error.failure?.category === 'authentication')
})
test('ACP closes a start-only tool with the partial terminal outcome', async (t) => {
@@ -1422,8 +1422,11 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
workspace.save()
const rotatedA = await deliver(workspace, runA, receiptsFor(runA, 3), 'wave-3-rotated')
assert.ok(rotatedA.result)
assert.equal(calls.find(call => call.tag === 'A' && call.attempt === 'wave-3-rotated').sessionRef, '')
assert.equal(workspace.state.sessions[sessionKeyA], 'A-codex-native-2')
assert.equal(
calls.find(call => call.tag === 'A' && call.attempt === 'wave-3-rotated').sessionRef,
'A-codex-native-1',
)
assert.equal(workspace.state.sessions[sessionKeyA], 'A-codex-native-1')
const durableAAtRestart = ledger.get(runA.controller.runId)
const durableBAtRestart = ledger.get(runB.controller.runId)
@@ -1471,7 +1474,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
watermark: entry.watermark,
})), [{ sourceAgentKind: 'kimi', watermark: 4 }])
const postRestartCall = calls.find(call => call.attempt === 'post-restart-delta')
assert.equal(postRestartCall.sessionRef, 'A-codex-native-2')
assert.equal(postRestartCall.sessionRef, 'A-codex-native-1')
assert.match(postRestartCall.prompt, /A WAVE_4_kimi_LATEST/)
assert.doesNotMatch(postRestartCall.prompt, /A WAVE_3_/)
assert.deepEqual(
@@ -1487,7 +1490,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
.map(entry => ({ watermark: entry.watermark, status: entry.status })),
[{ watermark: 4, status: 'acknowledged' }],
)
assert.equal(restarted.state.sessions[sessionKeyA], 'A-codex-native-2')
assert.equal(restarted.state.sessions[sessionKeyA], 'A-codex-native-1')
restarted.activeRuns.delete(recoveredRunA.group.id)
})
@@ -391,6 +391,7 @@ async function verifyAutomaticGateRecovery(t, gateIndex) {
attachmentRecipients: [],
totalSuccesses: 0,
terminalFailureOccurred: false,
executionSequence: gateIndex + 1,
})
assert.equal(collaboration.version, 1)
assert.deepEqual(
@@ -3,7 +3,10 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { runAgent } = require('../../src/agents/cli/cli-adapters.cjs')
const {
runAgent,
shutdownAcpSessionRuntime,
} = require('../../src/agents/cli/cli-adapters.cjs')
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
const { createLegacyOutboundPayload } = require('../../src/collaboration/outbound-payload.cjs')
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
@@ -460,7 +463,10 @@ test('LocalWorkspace persists and resumes ACP permission approval and rejection
test('ACP permission Gate survives shutdown and resumes the persisted Session after approval', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
t.after(async () => {
await shutdownAcpSessionRuntime()
fs.rmSync(directory, { recursive: true, force: true })
})
const ledgerPath = path.join(directory, 'run-ledger.json')
const cli = acpPermissionExecutable(directory)
const ledger = new RunLedger({ storagePath: ledgerPath })
@@ -70,6 +70,72 @@ test('group OpenCode conversations reuse one persistent ACP runtime key across r
assert.equal(opencodeCalls[1].runOptions.acpPersistenceKey, opencodeCalls[0].runOptions.acpPersistenceKey)
})
test('Natural V4 keeps Hermes, OpenCode, and Claude Code on one Session per group task',
async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.naturalAgentResponses = true
options.detectAgents = async () => [
{
kind: 'hermes', name: 'Hermes CLI', executable: '/tmp/hermes', version: '2',
acpAvailable: true,
},
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
{ kind: 'claude', name: 'Claude Code', executable: '/tmp/claude', version: '1' },
]
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
const sessionRef = runOptions.sessionRef || `${agent.kind}-native-session`
if (['hermes', 'opencode'].includes(agent.kind)) {
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
}
return { text: `${agent.kind} round response`, sessionRef, outcome: 'completed' }
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Native Session continuity',
agentKinds: ['hermes', 'opencode', 'claude'],
workdir: directory,
})
await workspace.sendMessage({
groupId: group.id,
text: 'Continue one shared task for three rounds.',
mode: 'auto',
discussionStyle: 'sequential',
targetKinds: group.agentKinds,
maxRounds: 3,
protocol: 'v4',
})
const controller = workspace.activeRuns.get(group.id)
assert.ok(controller)
await controller.promise
for (const kind of group.agentKinds) {
const agentCalls = calls.filter(call => call.agent.kind === kind)
assert.deepEqual(agentCalls.map(call => call.runOptions.sessionRef), [
'', `${kind}-native-session`, `${kind}-native-session`,
])
assert.equal(typeof agentCalls[0].runOptions.acpPersistenceKey, 'string')
assert.equal(agentCalls[0].runOptions.acpPersistenceKey.length > 0, true)
assert.equal(agentCalls.every(call => (
call.runOptions.acpPersistenceKey === agentCalls[0].runOptions.acpPersistenceKey
)), true)
}
assert.deepEqual(
calls.filter(call => call.agent.kind === 'hermes')
.map(call => call.runOptions.sessionTransport),
['', 'acp', 'acp'],
)
assert.deepEqual(
calls.filter(call => call.agent.kind === 'opencode')
.map(call => call.runOptions.sessionTransport),
['', 'acp', 'acp'],
)
})
test('Workspace stream bridge redacts split credentials and paths while preserving tools', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -376,55 +376,27 @@ async function createManualPermissionRestart(t, restartOptions = {}) {
}
}
test('Manual V4 native ACP permission restart requires whole-slot recovery and a fresh Gate', async (t) => {
test('Manual V4 native ACP permission restart resumes the persisted Session without a fresh Gate',
async (t) => {
const recovery = await createManualPermissionRestart(t)
const {
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, waiting, writerSlot,
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
} = recovery
const recoveryGate = await pendingGate(restarted, gate => (
gate.gateId !== originalGate.gateId && gate.type === 'retry'
))
const recoveryRequest = restarted.humanGateStore.request(recoveryGate.gateId)
const recovered = restartedLedger.get(originalGate.runId)
const recoveredWriter = recovered.orchestration.slots.find(slot => slot.agentKind === 'hermes')
const terminal = await terminalRun(restartedLedger, originalGate.runId)
const recoveredWriter = terminal.orchestration.slots.find(slot => slot.agentKind === 'hermes')
const hermesCalls = calls.filter(call => call.kind === 'hermes')
assert.equal(calls.length, beforeApprovalCalls + 1)
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
assert.equal(recovered.status, 'waiting')
assert.equal(recoveredWriter.status, 'running')
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(recoveredWriter.status, 'completed')
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
assert.equal(recoveredWriter.commitStatus, 'pending')
assert.deepEqual(recoveredWriter.resultRefs.workflowOutcomeRefs || [], [])
assert.deepEqual(recoveryRequest, {
phase: 'manual-writer-recovery',
batchId: waiting.orchestration.batchId,
slotId: writerSlot.slotId,
operationId: writerSlot.operationId,
attempt: writerSlot.attempt,
outcomeCertainty: 'unknown_outcome',
sideEffectsPossible: true,
})
for (const kind of ['codex', 'workbuddy']) {
assert.equal(calls.filter(call => call.kind === kind).length, 1)
}
restarted.decideHumanGate(recoveryGate.gateId, {
status: 'approved', optionId: 'retry-once', actorId: 'local-user',
})
const freshPermissionGate = await pendingGate(restarted, gate => (
gate.type === 'permission' && ![originalGate.gateId, recoveryGate.gateId].includes(gate.gateId)
))
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
assert.equal(calls.at(-1).sessionRef, '')
assert.notEqual(freshPermissionGate.gateId, originalGate.gateId)
restarted.decideHumanGate(freshPermissionGate.gateId, {
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
})
const terminal = await terminalRun(restartedLedger, originalGate.runId)
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
assert.equal(hermesCalls.length, 2)
assert.deepEqual(hermesCalls.map(call => call.operationId), [
writerSlot.operationId, writerSlot.operationId,
])
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
for (const kind of ['codex', 'workbuddy']) {
assert.equal(calls.filter(call => call.kind === kind).length, 1)
}
@@ -494,13 +466,28 @@ test('Manual V4 native permission rejects an already accepted bound operation be
assert.equal(calls.length, beforeApprovalCalls)
})
test('Manual V4 native permission restart recovers a missing or naturally rotated Session', async (t) => {
test('Manual V4 native permission restart recovers a missing Session and preserves a V4 Session',
async (t) => {
for (const sessionState of ['missing', 'rotated']) {
await t.test(sessionState, async (subtest) => {
const recovery = await createManualPermissionRestart(subtest, { sessionState })
const {
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
} = recovery
if (sessionState === 'rotated') {
const terminal = await terminalRun(restartedLedger, originalGate.runId)
const recoveredWriter = terminal.orchestration.slots.find(slot => (
slot.agentKind === writerSlot.agentKind
))
assert.equal(calls.length, beforeApprovalCalls + 1)
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(recoveredWriter.status, 'completed')
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
return
}
const recoveryGate = await pendingGate(restarted, gate => (
gate.type === 'retry' && gate.gateId !== originalGate.gateId
))
@@ -526,7 +513,7 @@ test('Manual V4 native permission restart recovers a missing or naturally rotate
}
})
test('Auto V4 read-only native ACP permission restart completes through a fresh Gate', async (t) => {
test('Auto V4 read-only native ACP permission restart resumes the persisted Session', async (t) => {
const { directory, options } = fixture()
const cli = hermesAcpExecutable(directory)
const ledger = new RunLedger({
@@ -551,7 +538,7 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
})
const send = workspace.sendMessage({
groupId: group.id,
text: 'Resume one native read-only proposal through a fresh permission Gate.',
text: 'Resume one native read-only proposal through its persisted Session.',
mode: 'auto',
maxRounds: 3,
targetKinds: ['codex', 'hermes', 'workbuddy'],
@@ -591,23 +578,19 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
})
const freshGate = await pendingGate(restarted, gate => (
gate.type === 'permission' && gate.gateId !== originalGate.gateId
))
const terminal = await terminalRun(restartedLedger, originalGate.runId)
const resumedCalls = calls.filter(call => (
call.kind === 'hermes' && call.phase === 'proposal'
))
assert.equal(calls.length, beforeApprovalCalls + 2)
assert.equal(calls[beforeApprovalCalls].kind, 'hermes')
assert.equal(calls[beforeApprovalCalls].phase, 'proposal')
assert.equal(calls[beforeApprovalCalls].sessionRef, 'hermes-v4-native-session')
assert.deepEqual(resumedCalls.map(call => call.operationId), [
gatedSlot.operationId, gatedSlot.operationId, gatedSlot.operationId,
gatedSlot.operationId, gatedSlot.operationId,
])
assert.equal(resumedCalls.at(-1).sessionRef, '')
restarted.decideHumanGate(freshGate.gateId, {
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
})
const terminal = await terminalRun(restartedLedger, originalGate.runId)
assert.equal(resumedCalls.at(-1).sessionRef, 'hermes-v4-native-session')
assert.equal(terminal.status, 'completed', terminal.reason)
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
for (const kind of ['codex', 'workbuddy']) {
assert.equal(calls.filter(call => call.kind === kind && call.phase === 'proposal').length, 1)
}
File diff suppressed because it is too large. Load diff
@@ -346,7 +346,10 @@ test('Natural V4 prompts carry task context without exposing the receipt templat
assert.match(prompt, /Compare the available skills/)
assert.match(prompt, /Answer the user naturally in Markdown/)
assert.doesNotMatch(prompt, /Receipt JSON shape|MELDWORK_COLLABORATION|MELDWORK_V4_FROZEN_SNAPSHOT_V1/)
assert.doesNotMatch(
prompt,
/current collaboration phase|your role|Receipt JSON shape|MELDWORK_COLLABORATION|MELDWORK_V4_FROZEN_SNAPSHOT_V1/i,
)
})
test('Natural V4 challenge responses receive a deterministic internal responsibility graph', () => {
@@ -196,7 +196,7 @@ describe('Meldwork workbench', () => {
wrapper.unmount()
})
it('shows the Auto discussion Beta mode with an explanation placeholder and a coming-soon guard', async () => {
it('sends the Auto discussion Beta mode as an Agent-led V4 discussion', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-auto-beta',
@@ -228,8 +228,18 @@ describe('Meldwork workbench', () => {
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('coming soon')
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
groupId: 'group-auto-beta',
text: 'Plan a launch',
targetKinds: ['codex', 'hermes'],
skillHints: [],
knowledgeBaseHints: [],
attachments: [],
mode: 'auto',
discussionStyle: 'agent-led',
protocol: 'v4',
maxRounds: 6,
})
wrapper.unmount()
})
+1 -1
View File
@@ -1670,7 +1670,7 @@ describe('Meldwork workbench', () => {
await range.setValue(6)
await wrapper.get('.round-unlimited-button').trigger('click')
await flushPromises()
expect(wrapper.get('.confirmation-modal-body').text()).toContain('until consensus or manual stop')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('until collaboration ends or you stop it')
expect(wrapper.find('.round-range-input').exists()).toBe(true)
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
await flushPromises()
@@ -15,7 +15,7 @@ afterEach(() => {
document.body.innerHTML = ''
})
describe('Unlimited-round review mode', () => {
describe('Unlimited-round discussion mode', () => {
it('keeps the normal composer surface while retaining the infinity cue', () => {
const styles = readStylesSource()
@@ -64,7 +64,7 @@ describe('Unlimited-round review mode', () => {
wrapper.unmount()
})
it('explains strict peer review in the confirmation and composer', async () => {
it('explains natural collaboration in the confirmation and composer', async () => {
const { wrapper, state, emitWorkspaceChanged } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-1',
@@ -84,8 +84,9 @@ describe('Unlimited-round review mode', () => {
await wrapper.get('.round-unlimited-button').trigger('click')
await flushPromises()
expect(wrapper.get('.confirmation-modal-body').text()).toContain('strict peer review')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('independently verify claims')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('discuss, divide work, collaborate')
expect(wrapper.get('.confirmation-modal-body').text()).toContain('once concrete results exist')
expect(wrapper.get('.confirmation-modal-body').text()).not.toContain('strict peer review')
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
await flushPromises()
@@ -95,9 +96,9 @@ describe('Unlimited-round review mode', () => {
expect(wrapper.get('.round-settings-trigger').classes()).toContain('unlimited')
expect(wrapper.get('.round-unlimited-symbol').text()).toBe('∞')
expect(wrapper.get('.round-settings-trigger').text()).toContain('No round limit')
expect(wrapper.get('.round-unlimited-active').text()).toContain('report material defects')
expect(wrapper.get('.round-unlimited-active').text()).toContain('review material gaps')
expect(wrapper.get('.composer-box textarea').attributes('placeholder'))
.toBe('Message Review with strict peer review enabled')
.toBe('Message Review')
state.runningGroupIds = ['group-1']
state.runs = [{
@@ -62,20 +62,17 @@ export function useConversationExecution({
const text = draft.value.trim()
const attachments = composerAttachments.value.map(safeAttachmentPayload)
const mode = composerMode.value
const backendMode = mode === 'auto-beta' ? 'auto' : mode
if (!text && !attachments.length) {
notify(t('composer.messageRequired'))
return
}
if (mode === 'auto-beta') {
notify(`${t('composer.autoBetaLockTitle')} — ${t('composer.autoBetaLockBody')}`)
return
}
const targets = [...composerTargetKinds.value]
if (!targets.length) {
notify(t('composer.selectTarget'))
return
}
if (mode === 'auto' && targets.length < 2) {
if (backendMode === 'auto' && targets.length < 2) {
notify(t('error.autoAgentCount'))
return
}
@@ -91,7 +88,7 @@ export function useConversationExecution({
const previousComposerContext = captureComposerContext()
const previousAttachments = composerAttachments.value.map(attachment => ({ ...attachment }))
const v4GroupRun = group.conversationType !== 'direct'
&& (mode === 'manual' || mode === 'auto')
&& (backendMode === 'manual' || backendMode === 'auto')
&& (targets.length > 1 || automaticTeamFormation.value)
clearComposerContext()
composerAttachments.value = []
@@ -107,10 +104,11 @@ export function useConversationExecution({
skillHints,
knowledgeBaseHints,
attachments,
mode,
mode: backendMode,
...(mode === 'auto-beta' ? { discussionStyle: 'agent-led' } : {}),
...(v4GroupRun ? { protocol: 'v4' } : {}),
maxRounds: maxRounds.value,
...(mode === 'auto' && unlimitedRounds.value ? { unlimitedRounds: true } : {}),
...(backendMode === 'auto' && unlimitedRounds.value ? { unlimitedRounds: true } : {}),
})
snapshot.value = normalizeSnapshot(await workspace.value.get())
} catch (error) {
+1 -1
View File
@@ -40,7 +40,7 @@ const EVENT_STATUSES = new Set([
])
const ORCHESTRATION_PHASES = new Set([
'prepare', 'dispatch', 'running', 'reconcile',
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
'commit', 'committed', 'failed', 'stopped', 'human-gate',
])
const TRACE_PHASES = new Set([
+4 -5
View File
@@ -375,6 +375,7 @@ export default {
'trace.eventConnectorLimited': 'Connector provided only the final answer; structured tool activity was unavailable.',
'trace.eventOther': 'Event',
'run.phase.proposal': 'Parallel proposals',
'run.phase.discussion': 'Discussion',
'run.phase.prepare': 'Preparing orchestration',
'run.phase.dispatch': 'Dispatching Agents',
'run.phase.running': 'Running',
@@ -402,7 +403,7 @@ export default {
'run.phaseSlots': '{completed}/{total} Agents',
'run.phaseFeedback': 'Current phase',
'composer.placeholder': 'Message {name}',
'composer.unlimitedPlaceholder': 'Message {name} with strict peer review enabled',
'composer.unlimitedPlaceholder': 'Message {name}',
'composer.targets': 'Replying Agents',
'composer.send': 'Send',
'composer.stop': 'Stop',
@@ -415,8 +416,6 @@ export default {
'composer.autoBeta': 'Auto discussion',
'composer.autoBetaHint': 'Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.',
'composer.autoBetaBadge': 'Beta',
'composer.autoBetaLockTitle': 'Auto discussion is a Beta feature',
'composer.autoBetaLockBody': 'True auto discussion — Agents autonomously @ each other to collaborate — is coming soon.',
'composer.smartTeam': 'Smart team',
'composer.smartTeamOn': 'On',
'composer.smartTeamOff': 'Off',
@@ -429,9 +428,9 @@ export default {
'composer.autoRounds': '{count} rounds',
'composer.maxRounds': 'Maximum rounds',
'composer.unlimitedRounds': 'No round limit',
'composer.unlimitedHint': 'Runs until consensus or manual stop. Agents must independently verify claims, challenge unsupported suggestions, and report material defects during acceptance. Agent timeouts and a mandatory circuit breaker still apply.',
'composer.unlimitedHint': 'The discussion continues until collaboration ends or you stop it. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply.',
'composer.unlimitedConfirmTitle': 'Continue without a round limit?',
'composer.unlimitedConfirm': 'The discussion can run until consensus or manual stop and consume more Provider quota. Unlimited mode also applies strict peer review: Agents must independently verify claims, challenge unsupported suggestions, and report material defects during acceptance. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may consume more Provider quota. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
'composer.useBoundedRounds': 'Use bounded rounds',
'composer.addTarget': 'Include {agent}',
'composer.removeTarget': 'Exclude {agent}',
+4 -5
View File
@@ -375,6 +375,7 @@ export default {
'trace.eventConnectorLimited': '连接器只提供最终答案,未暴露结构化工具过程。',
'trace.eventOther': '事件',
'run.phase.proposal': '并行提案',
'run.phase.discussion': '讨论',
'run.phase.prepare': '准备编排',
'run.phase.dispatch': '分发 Agent',
'run.phase.running': '运行中',
@@ -402,7 +403,7 @@ export default {
'run.phaseSlots': '{completed}/{total} 个 Agent',
'run.phaseFeedback': '当前阶段',
'composer.placeholder': '发消息给 {name}',
'composer.unlimitedPlaceholder': '发消息给 {name}(已启用严格互审)',
'composer.unlimitedPlaceholder': '发消息给 {name}',
'composer.targets': '回复 Agent',
'composer.send': '发送',
'composer.stop': '停止',
@@ -415,8 +416,6 @@ export default {
'composer.autoBeta': '自动讨论',
'composer.autoBetaHint': 'Beta:Agent 可相互 @ 拉入讨论,由模型自主编排各轮协作。',
'composer.autoBetaBadge': 'Beta',
'composer.autoBetaLockTitle': '自动讨论为 Beta 功能',
'composer.autoBetaLockBody': 'Agent 自主 @ 协作的真正自动讨论即将上线,敬请期待。',
'composer.smartTeam': '智能组队',
'composer.smartTeamOn': '开启',
'composer.smartTeamOff': '关闭',
@@ -429,9 +428,9 @@ export default {
'composer.autoRounds': '{count} 轮',
'composer.maxRounds': '最大轮数',
'composer.unlimitedRounds': '不限轮数',
'composer.unlimitedHint': '群聊会持续到达成共识或手动停止。各 Agent 必须独立核验信息、质疑缺乏依据的建议,并在验收阶段当场指出重大问题;Agent 超时与不可关闭的熔断器仍然生效。',
'composer.unlimitedHint': '讨论会持续到协作自然结束或你手动停止。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。',
'composer.unlimitedConfirmTitle': '继续使用不限制轮数吗?',
'composer.unlimitedConfirm': '讨论会持续到达成共识或手动停止,并可能消耗更多 Provider 额度。不限轮数还会启用严格互审:各 Agent 必须独立核验信息、质疑缺乏依据的建议,并在验收阶段当场指出重大问题;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多 Provider 额度。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
'composer.useBoundedRounds': '改回固定轮数',
'composer.addTarget': '加入 {agent}',
'composer.removeTarget': '移除 {agent}',