完善自主 Swarm 无干预可玩交付

固定首批程序实现与质量只读职责,并在计划执行前阻断质量 Agent 项目写入
要求非只读专业 Agent 完成本人修改和对应 revision 验证后再交付
补齐 Provider 瞬态重试、最终回复和 Swarm CLI 终态收敛
升级 Provider action batch v3 并兼容 v2/v1 持久恢复
扩展确定性与真实外部 Provider 塔防验收、前端状态和项目文档
This commit is contained in:
AIGameCreator App
2026-07-23 01:06:54 +08:00
parent 7b145b3067
commit 167b11d52e
31 changed files with 3236 additions and 272 deletions
@@ -116,10 +116,57 @@ function parseChildReport(result) {
}
}
function responsibilityContractEvidence(stats) {
const qualityPlanningCount =
stats?.byAgent?.['quality-review']?.planning ?? null;
const evidence = {
delegationContractCount: stats?.delegationContractCount ?? null,
delegationContractViolationCount:
stats?.delegationContractViolationCount ?? null,
codePrototypeDelegationCount: stats?.codePrototypeDelegationCount ?? null,
codePrototypeExpectedArtifactCount:
stats?.codePrototypeExpectedArtifactCount ?? null,
codePrototypeGameIndexArtifactDelegationCount:
stats?.codePrototypeGameIndexArtifactDelegationCount ?? null,
codePrototypeProjectMutationCount:
stats?.byAgent?.['code-prototype']?.projectMutation ?? null,
qualityReviewDelegationCount: stats?.qualityReviewDelegationCount ?? null,
qualityReviewReadOnlyDelegationCount:
stats?.qualityReviewReadOnlyDelegationCount ?? null,
qualityReviewExpectedArtifactCount:
stats?.qualityReviewExpectedArtifactCount ?? null,
qualityReviewProjectMutationCount:
stats?.byAgent?.['quality-review']?.projectMutation ?? null,
qualityPlanningCount,
qualityRevisionReplanCount: stats?.qualityRevisionReplanCount ?? null,
};
return {
...evidence,
codeOwnsGameIndex:
evidence.codePrototypeDelegationCount === 2 &&
evidence.codePrototypeExpectedArtifactCount === 2 &&
evidence.codePrototypeGameIndexArtifactDelegationCount === 2,
qualityIsReadOnly:
evidence.qualityReviewDelegationCount === 1 &&
evidence.qualityReviewReadOnlyDelegationCount === 1 &&
evidence.qualityReviewExpectedArtifactCount === 0 &&
evidence.qualityReviewProjectMutationCount === 0,
qualityIndependentOfCodeTiming:
(qualityPlanningCount === 1 || qualityPlanningCount === 2) &&
evidence.qualityRevisionReplanCount === qualityPlanningCount - 1,
contractViolationFree:
evidence.delegationContractCount === 3 &&
evidence.delegationContractViolationCount === 0,
};
}
function expectedProviderStats(stats) {
const responsibility = responsibilityContractEvidence(stats);
const qualityPlanningCount = responsibility.qualityPlanningCount;
return (
stats.requestCount === 17 &&
stats.planningRequestCount === 17 &&
(qualityPlanningCount === 1 || qualityPlanningCount === 2) &&
stats.requestCount === 15 + qualityPlanningCount &&
stats.planningRequestCount === 15 + qualityPlanningCount &&
stats.finalReplyRequestCount === 0 &&
stats.initialDelegationCount === 2 &&
stats.followupDelegationCount === 1 &&
@@ -133,10 +180,73 @@ function expectedProviderStats(stats) {
Object.keys(stats.rejectionCodes ?? {}).length === 0 &&
stats.byAgent?.['project-supervisor']?.planning === 9 &&
stats.byAgent?.['project-supervisor']?.finalReply === 0 &&
stats.byAgent?.['project-supervisor']?.projectMutation === 1 &&
stats.byAgent?.['code-prototype']?.planning === 6 &&
stats.byAgent?.['code-prototype']?.finalReply === 0 &&
stats.byAgent?.['quality-review']?.planning === 2 &&
stats.byAgent?.['quality-review']?.finalReply === 0
stats.byAgent?.['code-prototype']?.projectMutation === 2 &&
stats.byAgent?.['quality-review']?.finalReply === 0 &&
responsibility.codeOwnsGameIndex &&
responsibility.qualityIsReadOnly &&
responsibility.qualityIndependentOfCodeTiming &&
responsibility.contractViolationFree
);
}
const requiredZeroChildEvidenceFields = Object.freeze([
'activeRunnerKillCount',
'approveInputCount',
'answerInputCount',
'steerInputCount',
'turnReportWaitingForConfirmationCount',
'turnReportWaitingForUserInputCount',
'turnReportReconciliationAgentCount',
'providerLifecycleFailedCount',
'openProviderLifecycleCount',
'pendingActionCount',
'confirmationSidecarCount',
'userInputSidecarCount',
'providerActionBatchSidecarCount',
'providerRetrySidecarCount',
'providerHandoffSidecarCount',
'toolPlanHandoffSidecarCount',
'finalizationJournalCount',
'reconciliationResidueCount',
]);
function expectedChildReport(report, options, providerStats) {
const evidence = report?.evidence;
const providerRequestCount = providerStats?.requestCount;
return (
report?.status === 'PASS' &&
report?.suite === suite &&
report?.errorCount === 0 &&
Array.isArray(report?.blocked) &&
report.blocked.length === 0 &&
report?.cleanup?.performed === !options.keepProject &&
report?.cleanup?.kept === options.keepProject &&
evidence?.evidenceCompleteness === 'complete' &&
evidence?.dedicatedZeroInterventionPath === true &&
evidence?.stdinTaskCount === 1 &&
evidence?.stdinEndedAfterTask === true &&
evidence?.turnReportOutcome === 'settled' &&
evidence?.parentTaskStatus === 'completed' &&
evidence?.parentRuntimeStatus === 'idle' &&
evidence?.parentRuntimePhase === 'completed' &&
evidence?.laneDefensePlaytestPassed === true &&
evidence?.laneDefenseAssertionCount === 37 &&
evidence?.laneDefensePassedAssertionCount === 37 &&
evidence?.browserValidationPassed === true &&
evidence?.staticSmokePassed === true &&
evidence?.gameIndexChanged === true &&
Number.isInteger(evidence?.projectRevisionDelta) &&
evidence.projectRevisionDelta > 0 &&
evidence?.finalSupervisorAssistantCount === 1 &&
evidence?.professionalAssistantCount === 3 &&
evidence?.providerRequestIdentityCount === providerRequestCount &&
evidence?.providerLifecycleStartedCount === providerRequestCount &&
evidence?.providerLifecycleTerminalCount === providerRequestCount &&
evidence?.providerLifecycleCompletedCount === providerRequestCount &&
requiredZeroChildEvidenceFields.every((field) => evidence?.[field] === 0)
);
}
@@ -160,6 +270,13 @@ function responseFunctionNames(response) {
);
}
function responseFunctionCalls(response) {
return response.choices[0].message.tool_calls.map((call) => ({
name: call.function.name,
arguments: JSON.parse(call.function.arguments),
}));
}
async function runSelfTest() {
const html = deterministicLaneDefenseInitialHtml();
assert([...html].length <= 8_000, 'self-test-html-source-budget-invalid');
@@ -189,17 +306,41 @@ async function runSelfTest() {
authorization: `Bearer ${apiKey}`,
payload: syntheticPayload(agentId, runId, tools, extraContext),
});
const initialDelegationResponse = route('project-supervisor', 'parent-run');
const initialDelegationCalls = responseFunctionCalls(
initialDelegationResponse,
);
assert(
responseFunctionNames(route('project-supervisor', 'parent-run')).join(
',',
) === 'runtime_tool_agent_delegate,runtime_tool_agent_delegate',
initialDelegationCalls.map((call) => call.name).join(',') ===
'runtime_tool_agent_delegate,runtime_tool_agent_delegate',
'self-test-initial-delegation-invalid',
);
const initialCodeDelegation = initialDelegationCalls[0]?.arguments?.input;
const initialQualityDelegation = initialDelegationCalls[1]?.arguments?.input;
assert(
initialCodeDelegation?.agentId === 'code-prototype' &&
JSON.stringify(initialCodeDelegation.expectedArtifacts) ===
JSON.stringify(['game/index.html']) &&
initialQualityDelegation?.agentId === 'quality-review' &&
initialQualityDelegation.task.includes('只读验收') &&
initialQualityDelegation.task.includes('不要修改任何文件') &&
initialQualityDelegation.task.includes(
'不要读取或依赖并行 code-prototype',
) &&
Array.isArray(initialQualityDelegation.expectedArtifacts) &&
initialQualityDelegation.expectedArtifacts.length === 0,
'self-test-initial-delegation-contract-invalid',
);
const qualityPlan =
'计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无';
const builderPlan =
'计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无';
route('quality-review', 'quality-run', allTools, qualityPlan);
assert(
responseFunctionNames(
route('quality-review', 'quality-run', allTools, qualityPlan),
).join(',') === 'update_agent_plan,respond_to_user',
'self-test-quality-read-only-invalid',
);
route('code-prototype', 'initial-code-run');
route('code-prototype', 'initial-code-run');
assert(
@@ -222,15 +363,21 @@ async function runSelfTest() {
'runtime_tool_file_patch',
'self-test-forbidden-parent-mutation-invalid',
);
const followupDelegationResponse = route(
'project-supervisor',
'parent-run',
['runtime_tool_agent_delegate'],
'当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate',
);
const followupDelegationCall = responseFunctionCalls(
followupDelegationResponse,
)[0];
assert(
responseFunctionNames(
route(
'project-supervisor',
'parent-run',
['runtime_tool_agent_delegate'],
'当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate',
),
)[0] === 'runtime_tool_agent_delegate',
followupDelegationCall?.name === 'runtime_tool_agent_delegate' &&
followupDelegationCall.arguments?.input?.agentId === 'code-prototype' &&
JSON.stringify(
followupDelegationCall.arguments.input.expectedArtifacts,
) === JSON.stringify(['game/index.html']),
'self-test-followup-delegation-invalid',
);
route('code-prototype', 'repair-code-run');
@@ -250,6 +397,78 @@ async function runSelfTest() {
route('project-supervisor', 'parent-run');
const stats = router.getStats();
assert(expectedProviderStats(stats), 'self-test-provider-stats-invalid');
const responsibilityContract = responsibilityContractEvidence(stats);
assert(
responsibilityContract.codeOwnsGameIndex &&
responsibilityContract.qualityIsReadOnly &&
responsibilityContract.qualityIndependentOfCodeTiming &&
responsibilityContract.contractViolationFree,
'self-test-responsibility-contract-invalid',
);
const singlePassQualityStats = structuredClone(stats);
singlePassQualityStats.requestCount -= 1;
singlePassQualityStats.planningRequestCount -= 1;
singlePassQualityStats.qualityRevisionReplanCount = 0;
singlePassQualityStats.byAgent['quality-review'].planning = 1;
assert(
expectedProviderStats(singlePassQualityStats),
'self-test-quality-single-pass-timing-invalid',
);
const syntheticChildReport = {
status: 'PASS',
suite,
errorCount: 0,
blocked: [],
cleanup: { performed: true, kept: false },
evidence: {
...Object.fromEntries(
requiredZeroChildEvidenceFields.map((field) => [field, 0]),
),
evidenceCompleteness: 'complete',
dedicatedZeroInterventionPath: true,
stdinTaskCount: 1,
stdinEndedAfterTask: true,
turnReportOutcome: 'settled',
parentTaskStatus: 'completed',
parentRuntimeStatus: 'idle',
parentRuntimePhase: 'completed',
laneDefensePlaytestPassed: true,
laneDefenseAssertionCount: 37,
laneDefensePassedAssertionCount: 37,
browserValidationPassed: true,
staticSmokePassed: true,
gameIndexChanged: true,
projectRevisionDelta: 2,
finalSupervisorAssistantCount: 1,
professionalAssistantCount: 3,
providerRequestIdentityCount: stats.requestCount,
providerLifecycleStartedCount: stats.requestCount,
providerLifecycleTerminalCount: stats.requestCount,
providerLifecycleCompletedCount: stats.requestCount,
},
};
const incompletePlaytestReport = structuredClone(syntheticChildReport);
incompletePlaytestReport.evidence.laneDefensePassedAssertionCount = 36;
const manualInputReport = structuredClone(syntheticChildReport);
manualInputReport.evidence.approveInputCount = 1;
const residualSidecarReport = structuredClone(syntheticChildReport);
residualSidecarReport.evidence.providerHandoffSidecarCount = 1;
assert(
expectedChildReport(syntheticChildReport, { keepProject: false }, stats) &&
!expectedChildReport(
incompletePlaytestReport,
{ keepProject: false },
stats,
) &&
!expectedChildReport(manualInputReport, { keepProject: false }, stats) &&
!expectedChildReport(
residualSidecarReport,
{ keepProject: false },
stats,
),
'self-test-child-hard-gates-invalid',
);
const rootPackage = JSON.parse(
await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8'),
@@ -273,6 +492,9 @@ async function runSelfTest() {
providerUsed: false,
htmlChars: [...html].length,
providerStats: stats,
responsibilityContract,
qualityTimingOrdersValidated: ['before-code', 'after-code'],
childHardGatesValidated: true,
packageCommandsRegistered: true,
};
}
@@ -359,12 +581,10 @@ async function runE2e(options) {
childResult?.code === 0 &&
childResult?.signal === null &&
!childResult?.error &&
childReport?.status === 'PASS' &&
childReport?.suite === suite &&
childReport?.cleanup?.performed === !options.keepProject &&
childReport?.cleanup?.kept === options.keepProject;
expectedChildReport(childReport, options, providerStats);
const providerPassed =
providerStats?.stopped === true && expectedProviderStats(providerStats);
const responsibilityContract = responsibilityContractEvidence(providerStats);
const status =
!failureCode && childPassed && providerPassed && configRemoved
? 'PASS'
@@ -383,6 +603,7 @@ async function runE2e(options) {
delegatedSuite: suite,
child: childReport,
provider: providerStats,
responsibilityContract,
cleanup: {
providerStopped: providerStats?.stopped === true,
configRemoved,
@@ -187,17 +187,51 @@ function chatTextResponse(sequence, model, content) {
};
}
function delegateCall(agentId, task, acceptanceCriteria) {
function delegateCall(agentId, task, acceptanceCriteria, expectedArtifacts) {
return nativeAction('agent.delegate', `把独立工作交给 ${agentId}`, {
agentId,
task,
acceptanceCriteria,
expectedArtifacts: ['game/index.html'],
expectedArtifacts,
repairOfDelegationId: null,
runId: null,
});
}
const projectMutationFunctionNames = new Set(
[
'blackboard.write',
'canvas.asset_generate',
'command.exec',
'command.start',
'file.delete',
'file.patch',
'file.write',
'memory.write',
'project.git_commit',
'project.patchset',
'project.restore',
'task.create',
'task.update',
].map(runtimeFunction),
);
function isGameIndexArtifactContract(expectedArtifacts) {
return (
Array.isArray(expectedArtifacts) &&
expectedArtifacts.length === 1 &&
expectedArtifacts[0] === 'game/index.html'
);
}
function isExplicitReadOnlyQualityTask(task) {
return (
typeof task === 'string' &&
task.includes('只读验收') &&
task.includes('不要修改任何文件')
);
}
function staticSmokeCall(reason) {
return nativeAction('command.run_limited', reason, {
commandId: 'game.static_smoke',
@@ -262,6 +296,15 @@ export function createDeterministicLaneDefenseRouter({
staticSmokeCount: 0,
previewValidationCount: 0,
supervisorDirectMutationAttemptCount: 0,
delegationContractCount: 0,
delegationContractViolationCount: 0,
codePrototypeDelegationCount: 0,
codePrototypeExpectedArtifactCount: 0,
codePrototypeGameIndexArtifactDelegationCount: 0,
qualityReviewDelegationCount: 0,
qualityReviewReadOnlyDelegationCount: 0,
qualityReviewExpectedArtifactCount: 0,
qualityRevisionReplanCount: 0,
unexpectedRequestCount: 0,
rejectionCodes: {},
rejections: [],
@@ -272,10 +315,7 @@ export function createDeterministicLaneDefenseRouter({
let initialBuilderRunId = null;
let repairBuilderRunId = null;
function recordAgent(agentId, kind) {
const current = agentCounts.get(agentId) ?? { planning: 0, finalReply: 0 };
current[kind] += 1;
agentCounts.set(agentId, current);
function publishAgentCounts() {
stats.byAgent = Object.fromEntries(
[...agentCounts.entries()].sort(([left], [right]) =>
left.localeCompare(right),
@@ -283,8 +323,70 @@ export function createDeterministicLaneDefenseRouter({
);
}
function callsResponse(tools, calls) {
function getAgentCounts(agentId) {
return (
agentCounts.get(agentId) ?? {
planning: 0,
finalReply: 0,
projectMutation: 0,
}
);
}
function recordAgent(agentId, kind) {
const current = getAgentCounts(agentId);
current[kind] += 1;
agentCounts.set(agentId, current);
publishAgentCounts();
}
function recordEmittedCalls(agentId, calls) {
const current = getAgentCounts(agentId);
current.projectMutation += calls.filter((call) =>
projectMutationFunctionNames.has(call.name),
).length;
agentCounts.set(agentId, current);
publishAgentCounts();
for (const call of calls) {
if (call.name !== runtimeFunction('agent.delegate')) continue;
stats.delegationContractCount += 1;
const input = call.arguments?.input;
const hasExplicitExpectedArtifacts = Array.isArray(
input?.expectedArtifacts,
);
const expectedArtifacts = hasExplicitExpectedArtifacts
? input.expectedArtifacts
: [];
if (input?.agentId === 'code-prototype') {
stats.codePrototypeDelegationCount += 1;
stats.codePrototypeExpectedArtifactCount += expectedArtifacts.length;
if (isGameIndexArtifactContract(expectedArtifacts)) {
stats.codePrototypeGameIndexArtifactDelegationCount += 1;
} else {
stats.delegationContractViolationCount += 1;
}
} else if (input?.agentId === 'quality-review') {
stats.qualityReviewDelegationCount += 1;
stats.qualityReviewExpectedArtifactCount += expectedArtifacts.length;
if (
hasExplicitExpectedArtifacts &&
expectedArtifacts.length === 0 &&
isExplicitReadOnlyQualityTask(input.task)
) {
stats.qualityReviewReadOnlyDelegationCount += 1;
} else {
stats.delegationContractViolationCount += 1;
}
} else {
stats.delegationContractViolationCount += 1;
}
}
}
function callsResponse(agentId, tools, calls) {
requireAdvertised(tools, calls);
recordEmittedCalls(agentId, calls);
responseSequence += 1;
return chatToolResponse(responseSequence, model, calls);
}
@@ -302,7 +404,7 @@ export function createDeterministicLaneDefenseRouter({
}
parentStage = 'verify-repair';
stats.followupDelegationCount += 1;
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
delegateCall(
'code-prototype',
'修复最近一次真实浏览器验证证明的 canvas 不可见问题;保持现有 lane-defense-v1 交互、状态面、关卡推进和重开行为不变,并在修改后通过静态自检。',
@@ -311,6 +413,7 @@ export function createDeterministicLaneDefenseRouter({
'lane-defense-v1 全部固定试玩断言继续通过',
'修改后的当前 revision 通过 game.static_smoke',
],
['game/index.html'],
),
]);
}
@@ -319,7 +422,7 @@ export function createDeterministicLaneDefenseRouter({
case 'initial-delegation': {
parentStage = 'claim-initial';
stats.initialDelegationCount += 2;
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
delegateCall(
'code-prototype',
'生成一个紧凑但完整的植物塔防网页游戏:支持开始、选择植物、放置防御单位、敌人移动和受伤、胜利、下一关与重开;写入 game/index.html,并在修改后通过静态自检。',
@@ -328,51 +431,60 @@ export function createDeterministicLaneDefenseRouter({
'敌人会移动并受伤,关卡可胜利、进入下一关并重开',
'修改后的当前 revision 通过 game.static_smoke',
],
['game/index.html'],
),
delegateCall(
'quality-review',
'独立核对塔防交付合同,确认必须覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回验收重点,不修改项目。',
['验收结论覆盖完整可玩循环和双视口', '不修改项目文件'],
'执行只读验收:独立核对塔防交付合同是否覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回合同验收重点,不修改任何文件,也不要读取或依赖并行 code-prototype 尚未完成的项目产物。',
[
'验收结论覆盖完整可玩循环和双视口',
'只读返回合同重点且项目 mutation 为零',
],
[],
),
]);
}
case 'claim-initial':
parentStage = 'static-initial';
stats.runStatusCount += 1;
return callsResponse(tools, [runStatusCall('认领两份首轮专业回执')]);
return callsResponse('project-supervisor', tools, [
runStatusCall('认领两份首轮专业回执'),
]);
case 'static-initial':
parentStage = 'preview-initial';
stats.staticSmokeCount += 1;
return callsResponse(tools, [staticSmokeCall('验证初版当前 revision')]);
return callsResponse('project-supervisor', tools, [
staticSmokeCall('验证初版当前 revision'),
]);
case 'preview-initial':
parentStage = 'attempt-forbidden-repair';
stats.previewValidationCount += 1;
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
previewCall('真实试玩并检查桌面与移动视口'),
]);
case 'attempt-forbidden-repair':
parentStage = 'await-delegation-repair';
stats.supervisorDirectMutationAttemptCount += 1;
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
repairPatchCall('根据浏览器失败诊断直接修复不可见 canvas'),
]);
case 'verify-repair':
parentStage = 'verify-repair-after-claim';
stats.runStatusCount += 1;
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
runStatusCall('先认领返工后的专业交付,再验证最新 revision'),
]);
case 'verify-repair-after-claim':
parentStage = 'respond';
stats.staticSmokeCount += 1;
stats.previewValidationCount += 1;
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
staticSmokeCall('验证返工后的当前 revision'),
previewCall('重跑完整真实试玩和双视口检查'),
]);
case 'respond':
parentStage = 'done';
return callsResponse(tools, [
return callsResponse('project-supervisor', tools, [
...completedPlanCallsForContext(
context,
'可试玩项目和全部验证已经完成',
@@ -405,7 +517,7 @@ export function createDeterministicLaneDefenseRouter({
if (phase === 0) {
runPhases.set(runId, 1);
stats.sourceWriteCount += 1;
return callsResponse(tools, [
return callsResponse('code-prototype', tools, [
nativeAction('file.write', '写入完整可玩的塔防初版', {
path: 'game/index.html',
content: deterministicLaneDefenseInitialHtml(),
@@ -415,11 +527,13 @@ export function createDeterministicLaneDefenseRouter({
if (phase === 1) {
runPhases.set(runId, 2);
stats.staticSmokeCount += 1;
return callsResponse(tools, [staticSmokeCall('验证初版源码与状态面')]);
return callsResponse('code-prototype', tools, [
staticSmokeCall('验证初版源码与状态面'),
]);
}
if (phase === 2) {
runPhases.set(runId, 3);
return callsResponse(tools, [
return callsResponse('code-prototype', tools, [
...completedPlanCallsForContext(
context,
'初版实现和静态验证已经完成',
@@ -432,18 +546,20 @@ export function createDeterministicLaneDefenseRouter({
if (phase === 0) {
runPhases.set(runId, 1);
stats.sourcePatchCount += 1;
return callsResponse(tools, [
return callsResponse('code-prototype', tools, [
repairPatchCall('修复真实浏览器发现的 canvas 可见性'),
]);
}
if (phase === 1) {
runPhases.set(runId, 2);
stats.staticSmokeCount += 1;
return callsResponse(tools, [staticSmokeCall('验证返工后的源码')]);
return callsResponse('code-prototype', tools, [
staticSmokeCall('验证返工后的源码'),
]);
}
if (phase === 2) {
runPhases.set(runId, 3);
return callsResponse(tools, [
return callsResponse('code-prototype', tools, [
...completedPlanCallsForContext(
context,
'画布可见性返工和静态验证已经完成',
@@ -462,7 +578,7 @@ export function createDeterministicLaneDefenseRouter({
if (phase === 0) {
runKinds.set(runId, 'quality-review');
runPhases.set(runId, 1);
return callsResponse(tools, [
return callsResponse('quality-review', tools, [
...completedPlanCallsForContext(context, '独立验收重点已经核对'),
nativeResponse(
'验收必须以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。',
@@ -471,7 +587,8 @@ export function createDeterministicLaneDefenseRouter({
}
if (phase === 1) {
runPhases.set(runId, 2);
return callsResponse(tools, [
stats.qualityRevisionReplanCount += 1;
return callsResponse('quality-review', tools, [
...completedPlanCallsForContext(
context,
'项目 revision 更新后已重新核对独立验收重点',
File diff suppressed because it is too large Load Diff
@@ -73,7 +73,7 @@ pub(in crate::agent) struct AgentRuntimeParallelReadBatch {
pub(in crate::agent) updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeProviderActionBatch {
pub(crate) schema_version: String,
@@ -102,6 +102,79 @@ pub(crate) struct AgentRuntimeProviderActionBatch {
pub(crate) updated_at: u64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeProviderActionBatchWire {
schema_version: String,
batch_id: String,
project_id: String,
agent_id: String,
task_id: String,
session_id: String,
run_id: String,
source: String,
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
loop_iteration: u32,
planned_steer_cursor: u64,
status: String,
next_action_index: u32,
plan: AgentRuntimeToolPlan,
actions: Vec<AgentRuntimePendingToolAction>,
#[serde(default)]
collaboration_contract: Option<SupervisorCollaborationContract>,
project_revision_before: AgentRuntimeProjectRevision,
planned_repository_context_fingerprint: String,
created_at: u64,
updated_at: u64,
}
impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch {
fn deserialize<Deserializer>(deserializer: Deserializer) -> Result<Self, Deserializer::Error>
where
Deserializer: serde::Deserializer<'de>,
{
let wire = AgentRuntimeProviderActionBatchWire::deserialize(deserializer)?;
let batch = Self {
schema_version: wire.schema_version,
batch_id: wire.batch_id,
project_id: wire.project_id,
agent_id: wire.agent_id,
task_id: wire.task_id,
session_id: wire.session_id,
run_id: wire.run_id,
source: wire.source,
run_profile: wire.run_profile,
run_profile_binding_fingerprint: wire.run_profile_binding_fingerprint,
loop_iteration: wire.loop_iteration,
planned_steer_cursor: wire.planned_steer_cursor,
status: wire.status,
next_action_index: wire.next_action_index,
plan: wire.plan,
actions: wire.actions,
collaboration_contract: wire.collaboration_contract,
project_revision_before: wire.project_revision_before,
planned_repository_context_fingerprint: wire.planned_repository_context_fingerprint,
created_at: wire.created_at,
updated_at: wire.updated_at,
};
if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
&& batch.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& batch
.collaboration_contract
.as_ref()
.is_some_and(|contract| contract.initial_wave)
{
validate_agent_runtime_autonomous_initial_collaboration_contract(&batch.plan)
.map_err(serde::de::Error::custom)?;
}
Ok(batch)
}
}
impl AgentRuntimePendingToolAction {
pub(crate) fn summary(&self) -> AgentRuntimePendingToolActionSummary {
AgentRuntimePendingToolActionSummary {
@@ -290,6 +363,26 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
} else {
(None, None, SupervisorCollaborationPreflight::default())
};
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& collaboration_preflight
.contract
.as_ref()
.is_some_and(|contract| contract.initial_wave)
{
if let Err(error) =
validate_agent_runtime_autonomous_initial_collaboration_contract(&batch_plan)
{
return Ok(AgentRuntimeProviderActionBatchPreparation::Blocked(
AgentRuntimeToolObservation {
tool: "runtime.collaboration_policy".to_string(),
status: "blocked".to_string(),
summary: "Project Supervisor 首批协作不满足 autonomous-game-build 合同"
.to_string(),
detail: Some(error),
},
));
}
}
if let Some(violation) = collaboration_preflight.violation {
return Ok(AgentRuntimeProviderActionBatchPreparation::Blocked(
AgentRuntimeToolObservation {
@@ -39,7 +39,7 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id_v1(
}
#[allow(clippy::too_many_arguments)]
pub(in crate::agent) fn agent_runtime_provider_action_batch_id(
pub(in crate::agent) fn agent_runtime_provider_action_batch_id_v2(
project_id: &str,
agent_id: &str,
task_id: &str,
@@ -79,6 +79,37 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id(
))
}
#[allow(clippy::too_many_arguments)]
pub(in crate::agent) fn agent_runtime_provider_action_batch_id(
project_id: &str,
agent_id: &str,
task_id: &str,
session_id: &str,
run_id: &str,
loop_iteration: u32,
planned_steer_cursor: u64,
plan: &AgentRuntimeToolPlan,
project_revision_before: &AgentRuntimeProjectRevision,
planned_repository_context_fingerprint: &str,
actions: &[AgentRuntimePendingToolAction],
collaboration_contract: Option<&SupervisorCollaborationContract>,
) -> Result<String, String> {
agent_runtime_provider_action_batch_id_v2(
project_id,
agent_id,
task_id,
session_id,
run_id,
loop_iteration,
planned_steer_cursor,
plan,
project_revision_before,
planned_repository_context_fingerprint,
actions,
collaboration_contract,
)
}
pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batch(
root: &Path,
batch: &AgentRuntimeProviderActionBatch,
@@ -97,6 +128,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
batch.schema_version.as_str(),
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
| AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION
| AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION
) {
return Err(format!(
"不支持的 Agent Runtime Provider action 批次版本:{}",
@@ -132,7 +164,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
));
}
let minimum_action_count = if batch.schema_version
== AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
!= AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION
&& batch.collaboration_contract.is_some()
{
1
@@ -298,8 +330,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
};
let state = read_supervisor_collaboration_state_at(root, &batch.agent_id, &batch.run_id)?;
if let Some(contract) = batch.collaboration_contract.as_ref() {
if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION {
return Err("旧版 Provider action 批次不能携带协作合同".to_string());
if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION {
return Err("v1 Provider action 批次不能携带协作合同".to_string());
}
let pristine = next_action_index == 0
&& batch.actions.iter().all(|pending| {
@@ -315,6 +347,12 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
contract,
pristine.then_some(!state.has_collaboration()),
)?;
if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
&& batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& contract.initial_wave
{
validate_agent_runtime_autonomous_initial_collaboration_contract(&batch.plan)?;
}
} else {
let preflight = preflight_supervisor_collaboration_plan(
&batch.agent_id,
@@ -335,8 +373,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
} else if batch.collaboration_contract.is_some() {
return Err("非 Project Supervisor Provider 批次不能携带协作合同".to_string());
}
let expected_batch_id =
if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION {
let expected_batch_id = match batch.schema_version.as_str() {
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION => {
agent_runtime_provider_action_batch_id_v1(
&batch.project_id,
&batch.agent_id,
@@ -350,7 +388,24 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
&batch.planned_repository_context_fingerprint,
&batch.actions,
)?
} else {
}
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION => {
agent_runtime_provider_action_batch_id_v2(
&batch.project_id,
&batch.agent_id,
&batch.task_id,
&batch.session_id,
&batch.run_id,
batch.loop_iteration,
batch.planned_steer_cursor,
&batch.plan,
&batch.project_revision_before,
&batch.planned_repository_context_fingerprint,
&batch.actions,
batch.collaboration_contract.as_ref(),
)?
}
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => {
agent_runtime_provider_action_batch_id(
&batch.project_id,
&batch.agent_id,
@@ -365,7 +420,9 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
&batch.actions,
batch.collaboration_contract.as_ref(),
)?
};
}
_ => unreachable!("Provider action batch schema was validated above"),
};
if batch.batch_id != expected_batch_id {
return Err("Agent Runtime Provider action 批次身份指纹已变化".to_string());
}
@@ -1,5 +1,19 @@
use super::*;
fn normalize_game_creator_agent_background_final_reply_response(
mut response: platform_llm::LlmRunResponse,
observations: &[AgentRuntimeToolObservation],
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
response.text = redact_agent_runtime_private_process_output_from_response(
&strip_llm_thinking_blocks(&response.text),
observations,
);
if response.text.trim().is_empty() {
return Err(platform_llm::LlmError::EmptyResponse);
}
Ok(response)
}
pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_at(
root: &Path,
agent_id: &str,
@@ -120,6 +134,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
let fallback_response = fallback_response.filter(|response| !response.trim().is_empty());
let stream_response = llm.stream;
let request_stream_snapshot = stream_snapshot.clone();
let response_observations = observations;
let response_result =
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
root,
@@ -146,8 +161,19 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
.await
{
Ok(response) => {
publisher.handoff();
Ok(response)
match normalize_game_creator_agent_background_final_reply_response(
response,
response_observations,
) {
Ok(response) => {
publisher.handoff();
Ok(response)
}
Err(error) => {
publisher.failed();
Err(error)
}
}
}
Err(error) => {
publisher.failed();
@@ -155,18 +181,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
}
}
} else {
client.run(attempt_request).await
normalize_game_creator_agent_background_final_reply_response(
client.run(attempt_request).await?,
response_observations,
)
}
}
},
|response| {
let mut response = response.clone();
response.text = redact_agent_runtime_private_process_output_from_response(
&strip_llm_thinking_blocks(&response.text),
observations,
);
response
},
|response| response.clone(),
)
.await;
if response_result.is_err() {
@@ -160,8 +160,13 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
} else {
AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS
};
let read_only_delivery = run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
let task_text_requires_read_only_delivery = run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_runtime_task_requires_read_only_delivery(agent_id, task);
let read_only_delivery = run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_runtime_task_requires_read_only_delivery_at(
root, agent_id, session_id, run_id, task,
)?;
let verified_delivery = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
let verification_gate =
read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?;
@@ -273,6 +278,28 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
)
.and_then(|parsed| {
let source_payload = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
let verification_gate = read_game_creator_agent_runtime_verification_gate(
root, agent_id, run_id,
)
.map_err(|error| {
AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
error,
)
})?;
validate_agent_runtime_autonomous_specialist_response_delivery(
agent_id,
run_id,
read_only_delivery,
&verification_gate,
&parsed.plan,
)
.map_err(|error| {
AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
error,
)
})?;
let source_payload = validate_agent_runtime_autonomous_source_payload(&parsed.plan)
.map_err(|error| {
AgentRuntimeToolPlanProtocolError::new(
@@ -280,6 +307,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
error,
)
})?;
validate_agent_runtime_autonomous_read_only_delivery_plan(
read_only_delivery,
&parsed.plan,
)
.map_err(|error| {
AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
error,
)
})?;
if autonomous_scaffold_repair_active
&& source_payload.max_field_chars
> AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS
@@ -368,7 +405,23 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
&collaboration_policy,
&collaboration_state,
)?;
if let Some(violation) = preflight.violation {
let autonomous_initial_collaboration = if run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& preflight
.contract
.as_ref()
.is_some_and(|contract| contract.initial_wave)
{
validate_agent_runtime_autonomous_initial_collaboration_contract(&parsed.plan)
} else {
Ok(())
};
if let Err(error) = autonomous_initial_collaboration {
Err(AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
error,
))
} else if let Some(violation) = preflight.violation {
Err(AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
format!("{}{}", violation.summary, violation.detail),
@@ -415,6 +468,20 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
mut normalized_text_chars,
mut normalized_text_sha256,
} = parsed;
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& read_only_delivery
&& !task_text_requires_read_only_delivery
&& !plan.response.trim().is_empty()
{
let runtime = read_game_creator_agent_runtime_for_session_at(
root,
agent_id,
Some(session_id),
)?
.state;
plan.plan_update =
agent_runtime_read_only_delivery_completion_plan_update(&runtime);
}
if let Some((count, source_chars, source_sha256)) =
response_handoff.thinking_normalization_metadata()
{
@@ -548,8 +615,25 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& protocol_error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX)
&& !request.function_tools.is_empty();
let force_autonomous_read_only_delivery =
force_autonomous_pre_mutation && read_only_delivery;
let force_autonomous_read_only_delivery = read_only_delivery
&& (force_autonomous_pre_mutation
|| protocol_error
.starts_with(AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX));
let force_autonomous_specialist_mutation_only = run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& !read_only_delivery
&& agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& (force_autonomous_pre_mutation
|| protocol_error.starts_with(
AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX,
))
&& !request.function_tools.is_empty();
let force_autonomous_specialist_verification_only = run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& protocol_error.starts_with(
AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX,
)
&& !request.function_tools.is_empty();
let force_autonomous_pending_verification = run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& protocol_error.starts_with(
@@ -607,6 +691,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
&protocol_error,
) && !request.function_tools.is_empty();
if force_supervisor_initial_collaboration
|| force_autonomous_specialist_mutation_only
|| force_autonomous_specialist_verification_only
|| force_autonomous_response_plan_completion
|| force_autonomous_delegated_playtest_repair
|| force_autonomous_failed_playtest
@@ -616,6 +702,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|| force_autonomous_preview_after_static
|| force_autonomous_verified_delivery
|| force_autonomous_truncated_scaffold
|| force_autonomous_read_only_delivery
|| force_autonomous_pre_mutation
{
request.function_tools =
@@ -623,8 +710,30 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
}
if force_supervisor_initial_collaboration {
restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?;
let instruction = if run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.htmlquality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 []。两者都使用 agent.delegaterepairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
)
} else {
format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须根据当前 Project Supervisor 协作策略,在同一响应中一次性调用完整的 agent.delegate / agent.spawn_isolated 批次,使首批协作合同全部成立。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
)
};
request.messages.push(LlmMessage::user(instruction));
} else if force_autonomous_specialist_mutation_only {
autonomous_scaffold_repair_active = true;
restrict_agent_runtime_autonomous_specialist_mutation_repair_tools(
&mut request,
)?;
request.messages.push(LlmMessage::user(format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须根据当前 Project Supervisor 协作策略,在同一响应中一次性调用完整的 agent.delegate / agent.spawn_isolated 批次,使首批协作合同全部成立。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
"上一条输出不符合工具计划协议:{protocol_error}\n当前是 autonomous-game-build 的非只读专业任务,本次修复的原生工具目录只保留项目 mutation 工具。必须立即完成本人 run 的实际项目修改;不得 respond_to_user、验证、更新计划、读取、搜索、查询状态或委派。首次源码字段不得超过 {AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS} 字符,完整写入 game/index.html 时必须保持 HTML 与 script 闭合。不要解释,不要 markdown,不要代码围栏。"
)));
} else if force_autonomous_specialist_verification_only {
restrict_agent_runtime_autonomous_reverification_repair_tools(&mut request)?;
request.messages.push(LlmMessage::user(format!(
"上一条输出不符合工具计划协议:{protocol_error}\n当前是 autonomous-game-build 的非只读专业任务,且本人 run 已有 mutation。本次修复的原生工具目录只保留 project.verify 与 command.run_limited;必须立即验证本人 run 的最新 mutation revision,通过后才能 respond_to_user。不得继续修改项目、更新计划、读取、搜索、查询状态、委派或解释。不要 markdown,不要代码围栏。"
)));
} else if force_autonomous_response_plan_completion {
restrict_agent_runtime_autonomous_response_plan_repair_tools(&mut request)?;
@@ -636,7 +745,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
&mut request,
)?;
request.messages.push(LlmMessage::user(format!(
"上一条输出不符合工具计划协议:{protocol_error}\n当前父 run 已有 ready 未认领回执或 3 个 active delivery本次修复的原生工具目录只保留 agent.run_status。必须立即以 agentId=null、scope=all、delegationId=null 查询状态并原子认领 readyDelegateReceipts;不得创建第四次 agent.delegate、更新计划、读取、搜索、修改项目、重复验证或 respond_to_user。认领完成后再依据最新 project revision 重新规划验证。不要解释,不要 markdown,不要代码围栏。"
"上一条输出不符合工具计划协议:{protocol_error}\n当前父 run 已有 ready 未认领回执、尚未 observed 的持久 claim或 3 个 active delivery本次修复的原生工具目录只保留 agent.run_status。必须立即以 agentId=null、scope=all、delegationId=null 查询状态并原子认领、观察 readyDelegateReceipts;不得创建第四次 agent.delegate、更新计划、读取、搜索、修改项目、重复验证或 respond_to_user。收敛完成后再依据最新 project revision 重新规划验证或 repair。不要解释,不要 markdown,不要代码围栏。"
)));
} else if force_autonomous_preview_after_static {
restrict_agent_runtime_autonomous_preview_after_static_repair_tools(
@@ -50,8 +50,10 @@ pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_EXECUTING: &str = "exe
pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED: &str = "observed";
pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str =
"game-creator-provider-action-batch.v2";
"game-creator-provider-action-batch.v3";
pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION: &str =
"game-creator-provider-action-batch.v2";
pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION: &str =
"game-creator-provider-action-batch.v1";
pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION: &str =
"waiting-confirmation";
@@ -10,13 +10,18 @@ pub(super) fn game_creator_agent_background_final_reply_fallback(
let response = strip_llm_thinking_blocks(plan_response);
return (!response.trim().is_empty()).then_some(response);
}
(run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.then(|| {
if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
return None;
}
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
Some(
format!(
"项目已完成生成,并通过当前 revision {response_revision} 的静态检查和桌面、移动端交互试玩验证。"
)
})
),
)
} else {
Some("当前专业任务已完成,执行结果与验证证据已记录。".to_string())
}
}
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_TOOL_PLAN: &str = "tool-plan-failed";
@@ -180,14 +180,17 @@ fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
}
#[test]
fn ordinary_agent_empty_plan_has_no_deterministic_final_reply_fallback() {
assert!(game_creator_agent_background_final_reply_fallback(
"",
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
"code-prototype",
7,
)
.is_none());
fn autonomous_specialist_empty_plan_uses_internal_completion_fallback() {
assert_eq!(
game_creator_agent_background_final_reply_fallback(
"",
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
"code-prototype",
7,
)
.as_deref(),
Some("当前专业任务已完成,执行结果与验证证据已记录。")
);
}
#[test]
@@ -201,6 +204,17 @@ fn standard_supervisor_empty_plan_has_no_deterministic_final_reply_fallback() {
.is_none());
}
#[test]
fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() {
assert!(game_creator_agent_background_final_reply_fallback(
"",
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
"code-prototype",
7,
)
.is_none());
}
#[test]
fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() {
assert_eq!(
@@ -314,13 +328,26 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
.collect::<Vec<_>>(),
vec![fallback.as_str()]
);
let stream = read_game_creator_agent_runtime_response_stream_at(
let mut stream = read_game_creator_agent_runtime_response_stream_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUN_ID,
)
.expect("read autonomous fallback response stream")
.expect("autonomous fallback response stream exists");
for _ in 0..250 {
if stream.status == "committed" {
break;
}
std::thread::sleep(Duration::from_millis(20));
stream = read_game_creator_agent_runtime_response_stream_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUN_ID,
)
.expect("poll autonomous fallback response stream")
.expect("autonomous fallback response stream remains present");
}
assert_eq!(stream.status, "committed");
assert_eq!(stream.accumulated_text, fallback);
assert!(read_game_creator_agent_runtime_finalization_journal(
@@ -377,5 +404,11 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
})
.filter_map(|record| record["status"].as_str().map(str::to_string))
.collect::<Vec<_>>();
assert_eq!(final_reply_lifecycle_statuses, vec!["started", "failed"]);
assert!(
final_reply_lifecycle_statuses.len() >= 2 && final_reply_lifecycle_statuses.len() % 2 == 0,
"final reply retry chain must contain complete started/failed pairs"
);
assert!(final_reply_lifecycle_statuses
.chunks_exact(2)
.all(|pair| pair == ["started", "failed"]));
}
@@ -80,6 +80,7 @@ pub(crate) use steering::{
consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_steer_ledger_path,
interrupt_game_creator_agent_runtime_provider_request_at,
render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at,
steer_game_creator_agent_runtime_task_for_profile_at,
validate_game_creator_agent_runtime_steer_notification_at,
};
pub(crate) use verification::{
@@ -34,6 +34,20 @@ pub(in crate::agent) fn game_creator_agent_runtime_transient_provider_error_kind
platform_llm::LlmError::Upstream {
status_code: 400, ..
} if retry_autonomous_upstream_400 => Some("upstream-400"),
platform_llm::LlmError::Upstream {
status_code: 408, ..
} => Some("upstream-408"),
platform_llm::LlmError::Upstream {
status_code: 429, ..
} => Some("upstream-429"),
platform_llm::LlmError::Upstream { status_code, .. }
if (500..=599).contains(status_code) =>
{
Some("upstream-5xx")
}
platform_llm::LlmError::EmptyResponse => Some("empty-response"),
platform_llm::LlmError::Deserialize(_) => Some("deserialize"),
platform_llm::LlmError::StreamUnavailable => Some("stream-unavailable"),
_ => None,
}
}
@@ -1317,3 +1331,105 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test(
)?;
Ok(request_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_retry_error_classification_accepts_durable_failures_and_rejects_client_faults() {
let retryable = vec![
(platform_llm::LlmError::Timeout { attempts: 1 }, "timeout"),
(
platform_llm::LlmError::Connectivity {
attempts: 1,
message: "temporary connection failure".to_string(),
},
"connectivity",
),
(
platform_llm::LlmError::Transport("temporary transport failure".to_string()),
"transport",
),
(
platform_llm::LlmError::Upstream {
status_code: 408,
message: "request timeout".to_string(),
},
"upstream-408",
),
(
platform_llm::LlmError::Upstream {
status_code: 429,
message: "rate limited".to_string(),
},
"upstream-429",
),
(
platform_llm::LlmError::Upstream {
status_code: 500,
message: "server failure".to_string(),
},
"upstream-5xx",
),
(
platform_llm::LlmError::Upstream {
status_code: 599,
message: "server failure".to_string(),
},
"upstream-5xx",
),
(platform_llm::LlmError::EmptyResponse, "empty-response"),
(
platform_llm::LlmError::Deserialize("temporary invalid response".to_string()),
"deserialize",
),
(
platform_llm::LlmError::StreamUnavailable,
"stream-unavailable",
),
];
for (error, expected_kind) in retryable {
assert_eq!(
game_creator_agent_runtime_transient_provider_error_kind(&error, false),
Some(expected_kind),
"unexpected retry classification for {error:?}"
);
}
let upstream_400 = platform_llm::LlmError::Upstream {
status_code: 400,
message: "autonomous gateway failure".to_string(),
};
assert_eq!(
game_creator_agent_runtime_transient_provider_error_kind(&upstream_400, true),
Some("upstream-400")
);
assert_eq!(
game_creator_agent_runtime_transient_provider_error_kind(&upstream_400, false),
None
);
for status_code in [400, 401, 403, 404, 422, 499, 600] {
let error = platform_llm::LlmError::Upstream {
status_code,
message: "non-retryable upstream response".to_string(),
};
assert_eq!(
game_creator_agent_runtime_transient_provider_error_kind(&error, false),
None,
"upstream {status_code} must not enter durable retry"
);
}
for error in [
platform_llm::LlmError::InvalidConfig("invalid config".to_string()),
platform_llm::LlmError::InvalidRequest("invalid request".to_string()),
] {
assert_eq!(
game_creator_agent_runtime_transient_provider_error_kind(&error, false),
None,
"client fault must not enter durable retry: {error:?}"
);
}
}
}
@@ -473,6 +473,28 @@ pub(crate) fn steer_game_creator_agent_runtime_task_at(
steer_id: &str,
instruction: &str,
accepted_via: &str,
) -> Result<AgentRuntimeSteerResult, String> {
steer_game_creator_agent_runtime_task_for_profile_at(
root,
agent_id,
session_id,
run_id,
steer_id,
instruction,
None,
accepted_via,
)
}
pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
steer_id: &str,
instruction: &str,
expected_run_profile: Option<&str>,
accepted_via: &str,
) -> Result<AgentRuntimeSteerResult, String> {
validate_project_root(root)?;
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
@@ -495,6 +517,25 @@ pub(crate) fn steer_game_creator_agent_runtime_task_at(
if state.run_id != run_id || state.session_id != session_id {
return Err("追加指令与当前 Agent 的 session/run 身份不匹配".to_string());
}
if let Some(expected_run_profile) = expected_run_profile {
let expected_run_profile = normalize_agent_runtime_run_profile(Some(expected_run_profile))?;
let (persisted_run_profile, persisted_binding_fingerprint) =
agent_runtime_run_profile_identity_at(
root,
&agent_id,
run_id,
Some(&state.run_profile),
Some(&state.run_profile_binding_fingerprint),
)?;
if persisted_run_profile != expected_run_profile
|| state.run_profile != persisted_run_profile
|| state.run_profile_binding_fingerprint != persisted_binding_fingerprint
{
return Err(format!(
"追加指令请求的 Run Profile 与目标 run 不匹配:expected={expected_run_profile} actual={persisted_run_profile}"
));
}
}
validate_agent_runtime_steer_target_state(&state)?;
if game_creator_agent_runtime_finalization_path(root, &agent_id, run_id).exists() {
return Err("当前 Agent run 已进入最终持久化,不再接受追加指令".to_string());
@@ -637,18 +637,20 @@ pub(crate) fn steer_game_creator_agent_runtime_task(
run_id: String,
steer_id: String,
instruction: String,
run_profile: Option<String>,
) -> Result<AgentRuntimeSteerResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
let mut result = steer_game_creator_agent_runtime_task_at(
let mut result = steer_game_creator_agent_runtime_task_for_profile_at(
root,
agent_id.trim(),
session_id.trim(),
run_id.trim(),
steer_id.trim(),
instruction.trim(),
run_profile.as_deref(),
"tauri",
)?;
if !result.provider_interrupted
@@ -98,17 +98,31 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
.map_err(|error| format!("写入终端失败:{error}"))?;
}
if runtimes_are_busy(&existing_runtimes) {
let active_conversation =
read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
let active_session_id = active_conversation
.session_id
.as_deref()
.ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?;
let matching_parent_is_busy = swarm_parent_runtime(
parent_agent_id,
active_session_id,
run_profile,
&existing_runtimes,
)
.is_some_and(runtime_is_busy);
if matching_parent_is_busy {
writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。")
.map_err(|error| format!("写入终端失败:{error}"))?;
let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
let before = active_conversation;
let session_id = before
.session_id
.as_deref()
.ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?;
let parent_run_id = swarm_parent_runtime(parent_agent_id, session_id, &existing_runtimes)
.map(|runtime| runtime.state.run_id.as_str())
.unwrap_or_default();
let parent_run_id =
swarm_parent_runtime(parent_agent_id, session_id, run_profile, &existing_runtimes)
.map(|runtime| runtime.state.run_id.as_str())
.unwrap_or_default();
let mut conversation_baseline =
new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id);
capture_recovered_swarm_assistant_at(
@@ -122,6 +136,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
root,
parent_agent_id,
session_id,
run_profile,
conversation_baseline,
input,
output,
@@ -172,6 +187,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
root,
parent_agent_id,
&observation.session_id,
run_profile,
conversation_baseline,
input,
output,
@@ -214,6 +230,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
goal.run_id.clone(),
steer_id.clone(),
message,
Some(run_profile.to_string()),
)?;
writeln!(
output,
@@ -229,6 +246,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
root,
parent_agent_id,
&session_id,
run_profile,
conversation_baseline,
input,
output,
@@ -307,6 +325,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
root,
parent_agent_id,
&session_id,
run_profile,
conversation_baseline,
input,
output,
@@ -24,10 +24,13 @@ pub(super) struct SwarmTerminalFailureScan {
pub(super) fn swarm_parent_runtime<'a>(
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
runtimes: &'a [AgentRuntimeResult],
) -> Option<&'a AgentRuntimeResult> {
runtimes.iter().find(|runtime| {
runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id
runtime.state.agent_id == parent_agent_id
&& runtime.state.session_id == session_id
&& runtime.state.run_profile == run_profile
})
}
@@ -131,10 +134,12 @@ pub(super) fn scan_swarm_terminal_failures_at(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
runtimes: &[AgentRuntimeResult],
) -> SwarmTerminalFailureScan {
let mut scan = SwarmTerminalFailureScan::default();
let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, runtimes) else {
let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes)
else {
return scan;
};
let claimed_deliveries = match claimed_static_delegate_deliveries_at(
@@ -146,6 +146,107 @@ fn same_run_steer_preserves_bound_autonomous_profile() {
fs::remove_dir_all(root).ok();
}
#[test]
fn cross_profile_steer_is_rejected_before_persistent_side_effects() {
let root = std::env::temp_dir().join(format!(
"swarm-cli-profile-mismatch-{}-{}",
std::process::id(),
unix_millis()
));
init_local_game_project_at(&root, "project-swarm-mismatch", "Swarm Profile Mismatch")
.expect("initialize profile mismatch project");
let run_id = "swarm-profile-standard-run";
let state = start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"等待开发者确认的标准任务",
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
"准备标准模式运行",
vec!["等待确认".to_string()],
)
.expect("start standard supervisor runtime");
assert_eq!(
state.run_profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
"fixture must stay standard"
);
let conversation_before = read_local_conversation_for_session_at(
&root,
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
Some(&state.session_id),
)
.expect("read conversation before rejected steer");
let error = steer_game_creator_agent_runtime_task_for_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.session_id,
run_id,
"swarm-profile-mismatch-steer",
"切换为自主构建并继续",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
"swarm-cli",
)
.expect_err("cross-profile steer must fail closed");
assert!(error.contains("Run Profile"), "unexpected error: {error}");
assert!(!game_creator_agent_runtime_steer_ledger_path(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.exists());
let conversation_after = read_local_conversation_for_session_at(
&root,
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
Some(&state.session_id),
)
.expect("read conversation after rejected steer");
assert_eq!(conversation_after.messages, conversation_before.messages);
let persisted =
read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.expect("read runtime after rejected steer");
assert_eq!(persisted.state.run_id, run_id);
assert_eq!(
persisted.state.run_profile,
AGENT_RUNTIME_RUN_PROFILE_STANDARD
);
fs::remove_dir_all(root).ok();
}
#[test]
fn parent_runtime_matching_is_scoped_to_requested_profile() {
let mut standard = runtime("running", "planning", 0);
standard.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string();
standard.state.session_id = "session-profile-match".to_string();
standard.state.run_id = "run-standard".to_string();
standard.state.run_profile = AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string();
let mut autonomous = standard.clone();
autonomous.state.run_id = "run-autonomous".to_string();
autonomous.state.run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string();
let runtimes = vec![standard, autonomous];
assert_eq!(
swarm_parent_runtime(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"session-profile-match",
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
&runtimes,
)
.map(|runtime| runtime.state.run_id.as_str()),
Some("run-standard")
);
assert_eq!(
swarm_parent_runtime(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"session-profile-match",
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
&runtimes,
)
.map(|runtime| runtime.state.run_id.as_str()),
Some("run-autonomous")
);
}
#[test]
fn parses_chat_commands_without_stealing_normal_messages() {
assert_eq!(parse_swarm_chat_input(" "), None);
@@ -427,6 +528,7 @@ fn active_turn_eof_keeps_observing_until_parent_completes() {
&root,
parent_agent_id,
&session_id,
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
conversation_baseline,
&rx,
&mut output,
@@ -801,6 +903,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() {
&root,
&parent.state.agent_id,
&parent.state.session_id,
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
&[parent.clone(), child.clone()],
);
assert!(original_scan.failed_agents.is_empty());
@@ -827,6 +930,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() {
&root,
&parent.state.agent_id,
&parent.state.session_id,
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
&[parent.clone(), child],
);
assert_eq!(repair_scan.failed_agents, vec!["code-prototype:failed"]);
@@ -13,6 +13,7 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
conversation_baseline: SwarmTurnConversationBaseline,
input: &Receiver<SwarmInputEvent>,
output: &mut W,
@@ -91,8 +92,13 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
pending_interactions,
);
}
let failure_scan =
scan_swarm_terminal_failures_at(root, parent_agent_id, session_id, &runtimes);
let failure_scan = scan_swarm_terminal_failures_at(
root,
parent_agent_id,
session_id,
run_profile,
&runtimes,
);
if !failure_scan.reconciliation_agents.is_empty() {
observer.close_response_line(output)?;
return build_reconciliation_turn_outcome(
@@ -169,7 +175,8 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
session_id,
&conversation_baseline,
)?;
let parent_runtime = swarm_parent_runtime(parent_agent_id, session_id, &runtimes);
let parent_runtime =
swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes);
let completion_blockers = parent_runtime
.map(|parent| swarm_parent_completion_contract_blockers_at(root, parent))
.unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]);
@@ -275,6 +282,8 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
SwarmChatInput::Message(message) => {
if let Some(parent) = runtimes.iter().find(|runtime| {
runtime.state.agent_id == parent_agent_id
&& runtime.state.session_id == session_id
&& runtime.state.run_profile == run_profile
&& matches!(runtime.state.status.as_str(), "pending" | "running")
}) {
let steer_id = format!("swarm-steer-{}", unix_millis());
@@ -285,6 +294,7 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
parent.state.run_id.clone(),
steer_id.clone(),
message,
Some(run_profile.to_string()),
)?;
writeln!(
output,
File diff suppressed because it is too large Load Diff
@@ -1495,6 +1495,60 @@ fn spawn_mock_llm_upstream_400_then_raw_response(
base_url
}
fn spawn_mock_llm_http_failure_then_response(
failed_status_line: &'static str,
failed_body: String,
recovered_content: String,
request_notice_sender: Option<mpsc::Sender<()>>,
) -> String {
let listener = bind_test_tcp_listener("mock durable Provider classification bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut failed_stream, _) = listener
.accept()
.expect("mock durable Provider failure accept");
drop(read_mock_http_request(&mut failed_stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let failed_response = format!(
"HTTP/1.1 {failed_status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
failed_body.len(),
failed_body
);
failed_stream
.write_all(failed_response.as_bytes())
.expect("mock durable Provider failure response");
let (mut recovered_stream, _) = listener
.accept()
.expect("mock durable Provider recovery accept");
drop(read_mock_http_request(&mut recovered_stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let recovered_body = serde_json::json!({
"id": "chatcmpl_durable_provider_recovered",
"model": "mock-game-model",
"choices": [{
"message": { "content": recovered_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
.to_string();
let recovered_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
recovered_body.len(),
recovered_body
);
recovered_stream
.write_all(recovered_response.as_bytes())
.expect("mock durable Provider recovery response");
});
base_url
}
fn spawn_mock_llm_tool_plan_then_transient_final_reply(
planning_response: String,
final_response: String,
@@ -3425,14 +3425,14 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_
}
#[tokio::test]
async fn background_agent_runtime_does_not_replay_empty_provider_response() {
async fn provider_retry_empty_response_closes_then_stable_retry_succeeds() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let (sender, receiver) = mpsc::channel();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![
String::new(),
final_tool_plan_response("不应发起第二次请求"),
final_tool_plan_response("空响应持久重试后已恢复"),
],
Some(sender),
);
@@ -3443,57 +3443,217 @@ async fn background_agent_runtime_does_not_replay_empty_provider_response() {
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_chat"
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 1,
"retryBackoffMs": 30000
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
let run_id = "design-empty-response-retry-run";
let runtime = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证后台 Agent 空响应不会自动重放",
"design-empty-response-no-replay-run",
"验证后台 Agent 空响应进入持久重试",
run_id,
"agent-background-task",
"等待 Provider 规划",
vec!["请求并验证工具计划".to_string()],
)
.expect("start background task");
.expect("start empty-response retry runtime");
let request = receiver
let waiting = request_game_creator_agent_background_tool_plan_waiting_retry_for_test(
&root,
"design-director",
&runtime.session_id,
run_id,
&runtime.current_task,
&[],
1,
0,
)
.await
.expect("empty response enters durable retry wait");
let first_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("capture only empty-response Provider request");
assert!(request.contains("POST /chat/completions HTTP/1.1"));
assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err());
let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read empty-response runtime")
.state;
for _ in 0..250 {
if runtime.phase == "failed" {
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("poll empty-response runtime")
.state;
}
assert_eq!(runtime.phase, "failed");
assert!(runtime
.error
.as_deref()
.is_some_and(|error| error.contains("kind=empty-response")));
.expect("capture empty-response Provider request");
assert!(first_request.contains("POST /chat/completions HTTP/1.1"));
assert_eq!(waiting.error_kind, "empty-response");
assert_eq!(waiting.next_attempt, 1);
assert_eq!(waiting.max_retries, 1);
assert!(provider_retry::remaining_ms(&waiting) > 0);
assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity)
.expect("force empty-response retry due");
let plan = request_game_creator_agent_background_tool_plan_for_test(
&root,
"design-director",
&runtime.session_id,
run_id,
&runtime.current_task,
&[],
1,
0,
)
.await
.expect("empty-response retry succeeds")
.expect("recovered tool plan");
receiver
.recv_timeout(Duration::from_secs(2))
.expect("capture recovered Provider request");
assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
assert_eq!(plan.response, "空响应持久重试后已恢复");
assert!(
provider_retry::read_for_run_at(&root, "design-director", run_id)
.expect("read cleared empty-response retry")
.is_none()
);
let lifecycle = read_agent_db_records_for_test(&root)
.into_iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.lifecycle"
&& record["runId"] == "design-empty-response-no-replay-run"
&& record["runId"] == run_id
})
.collect::<Vec<_>>();
assert_eq!(lifecycle.len(), 2);
assert_eq!(lifecycle.len(), 4);
assert_eq!(lifecycle[0]["status"], "started");
assert_eq!(lifecycle[1]["status"], "failed");
assert_eq!(lifecycle[0]["requestId"], lifecycle[1]["requestId"]);
assert_eq!(lifecycle[2]["status"], "started");
assert_eq!(lifecycle[3]["status"], "completed");
assert_eq!(lifecycle[2]["requestId"], lifecycle[3]["requestId"]);
assert_ne!(lifecycle[0]["requestId"], lifecycle[2]["requestId"]);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn provider_retry_http_and_deserialize_failures_recover_through_durable_sidecar() {
let cases = vec![
(
"rate-limit",
"429 Too Many Requests",
serde_json::json!({"error": {"message": "rate limited"}}).to_string(),
"upstream-429",
),
(
"server-error",
"503 Service Unavailable",
serde_json::json!({"error": {"message": "temporarily unavailable"}}).to_string(),
"upstream-5xx",
),
("deserialize", "200 OK", "{".to_string(), "deserialize"),
];
for (case_id, status_line, failed_body, expected_kind) in cases {
let root = unique_project_path();
init_local_game_project_at(
&root,
"project-provider-classification",
"Provider 持久分类恢复测试",
)
.expect("provider classification project init");
let recovered_text = format!("{case_id} 持久重试后已恢复");
let (sender, receiver) = mpsc::channel();
let base_url = spawn_mock_llm_http_failure_then_response(
status_line,
failed_body,
final_tool_plan_response(&recovered_text),
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "provider-classification-key",
"baseUrl": {base_url:?},
"model": "provider-classification-model",
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 1,
"retryBackoffMs": 30000
}}
}}
}}"#
));
let run_id = format!("provider-classification-{case_id}-run");
let runtime = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证 Provider 错误进入同一 durable retry",
&run_id,
"agent-background-task",
"等待 Provider 规划",
vec!["请求并验证工具计划".to_string()],
)
.expect("start provider classification runtime");
let waiting = request_game_creator_agent_background_tool_plan_waiting_retry_for_test(
&root,
"design-director",
&runtime.session_id,
&run_id,
&runtime.current_task,
&[],
1,
0,
)
.await
.expect("Provider failure enters durable retry wait");
receiver
.recv_timeout(Duration::from_secs(2))
.expect("capture failed Provider request");
assert_eq!(waiting.error_kind, expected_kind, "case={case_id}");
assert_eq!(waiting.next_attempt, 1, "case={case_id}");
assert_eq!(waiting.max_retries, 1, "case={case_id}");
assert!(provider_retry::remaining_ms(&waiting) > 0, "case={case_id}");
assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity)
.expect("force classified Provider retry due");
let plan = request_game_creator_agent_background_tool_plan_for_test(
&root,
"design-director",
&runtime.session_id,
&run_id,
&runtime.current_task,
&[],
1,
0,
)
.await
.expect("classified Provider retry succeeds")
.expect("classified Provider retry returns plan");
receiver
.recv_timeout(Duration::from_secs(2))
.expect("capture recovered Provider request");
assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
assert_eq!(plan.response, recovered_text, "case={case_id}");
assert!(
provider_retry::read_for_run_at(&root, "design-director", &run_id)
.expect("read cleared classified Provider retry")
.is_none()
);
let lifecycle = read_agent_db_records_for_test(&root)
.into_iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.lifecycle"
&& record["runId"] == run_id
})
.collect::<Vec<_>>();
assert_eq!(lifecycle.len(), 4, "case={case_id}");
assert_eq!(lifecycle[0]["status"], "started", "case={case_id}");
assert_eq!(lifecycle[1]["status"], "failed", "case={case_id}");
assert_eq!(lifecycle[2]["status"], "started", "case={case_id}");
assert_eq!(lifecycle[3]["status"], "completed", "case={case_id}");
fs::remove_dir_all(root).ok();
}
}
#[tokio::test]
async fn provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds() {
let root = unique_project_path();
@@ -4564,8 +4724,8 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
"reason": "委派独立质量评审",
"input": {
"agentId": "quality-review",
"task": "评审可玩性与闯关闭环",
"acceptanceCriteria": ["给出阻塞试玩的问题和验收结论"],
"task": "只读评审可玩性与闯关闭环,不要修改任何项目文件",
"acceptanceCriteria": ["只读给出阻塞试玩的问题和验收结论"],
"expectedArtifacts": [],
"repairOfDelegationId": null,
"runId": null
@@ -608,7 +608,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_
}
#[tokio::test]
async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fallback() {
async fn response_stream_final_failure_with_retry_disabled_commits_planning_fallback() {
let root = unique_project_path();
init_local_game_project_at(
&root,
@@ -632,7 +632,7 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa
"model": "response-stream-retry-model",
"apiKind": "openai_responses",
"stream": true,
"maxRetries": 7,
"maxRetries": 0,
"retryBackoffMs": 1
}}
}}
@@ -642,7 +642,7 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa
let started = start_game_creator_agent_background_task_at(
&root,
"design-director",
"验证 final reply 失败不在 lifecycle 内重试",
"验证关闭 retry 时 final reply 失败只发起一次请求",
run_id,
)
.expect("start final stream single-attempt task");
@@ -704,7 +704,11 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa
);
let requests = mock.stop_and_collect();
assert_eq!(requests.len(), 2, "final stream must not retry physically");
assert_eq!(
requests.len(),
2,
"disabled retry must not issue a second final stream"
);
assert_eq!(
mock_http_request_json(&requests[0])["stream"],
Value::Bool(false)
@@ -757,6 +761,187 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn provider_retry_final_reply_thinking_only_response_retries_before_handoff() {
const PRIVATE_THINKING: &str = "FINAL_REPLY_PRIVATE_THINKING_MUST_NOT_PERSIST";
const FINAL_RESPONSE: &str = "去除 thinking 后为空的最终回复已通过持久重试恢复。";
let root = unique_project_path();
init_local_game_project_at(
&root,
"project-final-reply-empty-normalization",
"最终回复规范化为空持久重试项目",
)
.expect("thinking-only final reply project init");
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![
final_tool_plan_response(""),
format!("<think>{PRIVATE_THINKING}</think>"),
FINAL_RESPONSE.to_string(),
],
Some(request_sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "final-reply-empty-normalization-key",
"baseUrl": {base_url:?},
"model": "final-reply-empty-normalization-model",
"apiKind": "openai_responses",
"stream": false,
"maxRetries": 1,
"retryBackoffMs": 30000
}}
}}
}}"#
));
let run_id = "provider-retry-final-reply-empty-normalization-run";
let started = start_game_creator_agent_background_task_at(
&root,
"design-director",
"验证 final reply 在 durable handoff 前完成去 thinking 和非空校验",
run_id,
)
.expect("start thinking-only final reply task");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("tool-plan Provider request");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("thinking-only final-reply Provider request");
let mut retry = None;
for _ in 0..250 {
retry = crate::provider_retry::read_for_run_at(&root, "design-director", run_id)
.expect("read thinking-only final reply retry");
if retry.is_some()
&& game_creator_agent_runtime_task_lock_is_available(&root, "design-director")
.expect("probe thinking-only final reply lane")
{
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let retry = retry.expect("thinking-only final reply must persist retry sidecar");
assert_eq!(retry.identity.request_kind, "final-reply");
assert_eq!(retry.error_kind, "empty-response");
assert_eq!(retry.next_attempt, 1);
assert!(retry.next_request_slot.ends_with("-transient-1"));
assert!(crate::provider_retry::remaining_ms(&retry) > 0);
assert!(
provider_handoff::read_for_run_at(&root, "design-director", run_id)
.expect("read absent thinking-only final reply handoff")
.is_none(),
"normalized empty final reply must fail before durable handoff"
);
let before_retry_records = read_agent_db_records_for_test(&root);
let before_retry_lifecycle = before_retry_records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.lifecycle"
&& record["runId"] == run_id
&& record["requestKind"] == "final-reply"
})
.collect::<Vec<_>>();
assert_eq!(before_retry_lifecycle.len(), 2);
assert_eq!(before_retry_lifecycle[0]["status"], "started");
assert_eq!(before_retry_lifecycle[1]["status"], "failed");
assert!(before_retry_lifecycle
.iter()
.all(|record| record["status"] != "completed"));
let before_retry_conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&started.state.session_id),
)
.expect("read conversation before final reply retry");
assert!(before_retry_conversation
.messages
.iter()
.all(|message| message.role != "assistant"));
crate::provider_retry::force_provider_retry_due_for_test_at(&root, &retry.identity)
.expect("force thinking-only final reply retry due");
resume_game_creator_agent_background_tasks_at(&root)
.expect("resume thinking-only final reply retry");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("recovered final-reply Provider request");
assert!(request_receiver
.recv_timeout(Duration::from_millis(100))
.is_err());
let completed = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(completed.phase, "completed");
assert_eq!(completed.last_response.as_deref(), Some(FINAL_RESPONSE));
assert!(
crate::provider_retry::read_for_run_at(&root, "design-director", run_id)
.expect("read cleared thinking-only final reply retry")
.is_none()
);
assert!(
provider_handoff::read_for_run_at(&root, "design-director", run_id)
.expect("read cleared thinking-only final reply handoff")
.is_none()
);
let committed =
wait_for_response_stream_status(&root, "design-director", run_id, "committed", 1);
assert_eq!(committed.accumulated_text, FINAL_RESPONSE);
let conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&started.state.session_id),
)
.expect("read recovered final reply conversation");
assert_eq!(
conversation
.messages
.iter()
.filter(|message| message.role == "assistant")
.map(|message| message.content.as_str())
.collect::<Vec<_>>(),
vec![FINAL_RESPONSE]
);
let records = read_agent_db_records_for_test(&root);
let final_reply_lifecycle = records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.lifecycle"
&& record["runId"] == run_id
&& record["requestKind"] == "final-reply"
})
.collect::<Vec<_>>();
assert_eq!(final_reply_lifecycle.len(), 4);
assert_eq!(final_reply_lifecycle[0]["status"], "started");
assert_eq!(final_reply_lifecycle[1]["status"], "failed");
assert_eq!(final_reply_lifecycle[2]["status"], "started");
assert_eq!(final_reply_lifecycle[3]["status"], "completed");
let retry_audits = records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.retry"
&& record["runId"] == run_id
&& record["requestKind"] == "final-reply"
})
.collect::<Vec<_>>();
assert_eq!(retry_audits.len(), 1);
assert_eq!(retry_audits[0]["errorKind"], "empty-response");
let persisted = format!(
"{}\n{}\n{}",
serde_json::to_string(&completed).expect("serialize completed Runtime"),
fs::read_to_string(root.join(".agent/runtime/events/design-director.jsonl"))
.expect("read final reply events"),
fs::read_to_string(root.join(".agent/agent.db")).expect("read final reply Agent DB"),
);
assert!(!persisted.contains(PRIVATE_THINKING));
fs::remove_dir_all(root).ok();
}
#[test]
fn visual_specialist_finalization_requires_existing_registered_canvas_image() {
for (agent_id, local_path, kind) in [
@@ -14,13 +14,12 @@ pub(super) use super::super::{
append_auto_tool_action_audit_pair_for_test, assert_auto_tool_action_audit_pair,
assert_pending_runtime_decision_revalidates_after_lock, assert_task_status,
fake_llm_game_draft, final_tool_plan_response, mock_http_request_json,
native_agent_tool_plan_chat_response, native_agent_tool_plan_chat_response_with_calls,
pending_tool_action_for_test, persist_needs_reconciliation_runtime_for_test,
persist_project_verification_for_test, read_agent_db_records_for_test,
register_canvas_visual_asset_fixture, spawn_barrier_mock_llm_server,
spawn_interruptible_mock_llm_server_with_capture, spawn_mock_llm_raw_responses_with_capture,
spawn_mock_llm_server, spawn_mock_llm_server_responses,
spawn_mock_llm_server_responses_with_capture,
native_agent_tool_plan_chat_response, pending_tool_action_for_test,
persist_needs_reconciliation_runtime_for_test, persist_project_verification_for_test,
read_agent_db_records_for_test, register_canvas_visual_asset_fixture,
spawn_barrier_mock_llm_server, spawn_interruptible_mock_llm_server_with_capture,
spawn_mock_llm_raw_responses_with_capture, spawn_mock_llm_server,
spawn_mock_llm_server_responses, spawn_mock_llm_server_responses_with_capture,
spawn_releasable_mock_llm_server_responses_with_capture,
spawn_releasable_mock_llm_server_responses_with_capture_at, start_agent_runtime_steer_fixture,
ui_prototype_assessment_fixture, unique_project_path, use_test_runtime_config_dir,
@@ -667,6 +667,9 @@ export function matchingAgentRuntimeForSteer(
runtimes: Array<AgentRuntimeState | null | undefined>,
agentId: string,
sessionId: string | null,
requestedRunProfile: NonNullable<
AgentRuntimeState['runProfile']
> = 'standard',
) {
if (!sessionId) {
return null;
@@ -676,6 +679,7 @@ export function matchingAgentRuntimeForSteer(
(runtime) =>
runtime?.agentId === agentId &&
runtime.sessionId === sessionId &&
(runtime.runProfile ?? 'standard') === requestedRunProfile &&
isAgentRuntimeSteerableState(runtime),
) ?? null
);
@@ -772,6 +776,7 @@ export async function submitProjectSupervisorRuntimeTask({
[runtime],
PROJECT_SUPERVISOR_AGENT_ID,
sessionId,
runProfile,
);
if (steerRuntime) {
const steer = await invoke<AgentRuntimeSteerResult>(
@@ -783,6 +788,7 @@ export async function submitProjectSupervisorRuntimeTask({
runId: steerRuntime.runId,
steerId: createAgentChatRunId('project-supervisor-steer'),
instruction: prompt,
runProfile,
},
);
return { mode: 'steer' as const, runtimeResult: steer.runtime };

Some files were not shown because too many files have changed in this diff Show More