mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
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:
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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in new issue
Block a user