完善Agent受控命令与推理配置

新增受控 command.exec 固定程序、参数策略、隔离环境和有界审计
接入项目 revision、验证资格、确认动作复核与失败核对门禁
支持全局及每 Agent 独立推理档位并提供发布默认配置
补齐前端配置、共享契约、Runtime 回归和真实 Provider 验收
同步开发流程、技术方案与项目共享决策记录
This commit is contained in:
AIGameCreator App
2026-07-12 23:22:11 +08:00
parent 5e627a4677
commit 49ea9eec67
18 changed files with 4068 additions and 111 deletions
@@ -4,6 +4,7 @@
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKind": "openai_responses",
"reasoningEffort": "high",
"stream": false,
"requestTimeoutMs": 180000,
"maxRetries": 0,
@@ -18,6 +18,10 @@ const visibleText = 'GENARRATIVE_REAL_E2E_VISIBLE';
const patchedText = 'REAL_E2E_PATCHED';
const editorAssetPrompt = 'real e2e amber arcade token, transparent background';
const verificationCommand = 'node verify-e2e.mjs';
const commandFailureMarker = 'real-e2e-command=failed';
const commandPassedMarker = 'real-e2e-command=passed';
const failedCommandArgs = ['test'];
const successfulCommandArgs = ['run', 'check:e2e'];
const pollIntervalMs = 750;
const runTimeoutMs = 30 * 60 * 1000;
const commandOutputLimit = 4 * 1024 * 1024;
@@ -401,7 +405,10 @@ async function seedDisposableProject() {
{
name: 'genarrative-agent-runtime-real-e2e-project',
private: true,
scripts: { 'check:e2e': verificationCommand },
scripts: {
test: verificationCommand,
'check:e2e': verificationCommand,
},
},
null,
2,
@@ -409,7 +416,7 @@ async function seedDisposableProject() {
),
fs.writeFile(
path.join(state.projectRoot, 'verify-e2e.mjs'),
`import fs from 'node:fs';\nconst html = fs.readFileSync('game/index.html', 'utf8');\nconst agents = fs.readFileSync('AGENTS.md', 'utf8');\nif (!html.includes('${patchedText}') || !html.includes('<canvas') || !html.includes('requestAnimationFrame') || !agents.includes('REPOSITORY_CONTEXT_MARKER')) process.exit(1);\nconsole.log('real-e2e-project.verify=passed');\n`,
`import fs from 'node:fs';\nconst html = fs.readFileSync('game/index.html', 'utf8');\nconst agents = fs.readFileSync('AGENTS.md', 'utf8');\nif (!html.includes('${patchedText}') || !html.includes('<canvas') || !html.includes('requestAnimationFrame') || !agents.includes('REPOSITORY_CONTEXT_MARKER')) { console.error('${commandFailureMarker}'); process.exit(1); }\nconsole.log('${commandPassedMarker}');\n`,
),
fs.writeFile(
path.join(state.projectRoot, 'game/index.html'),
@@ -482,12 +489,12 @@ function buildTaskPrompt(suite) {
: '本套件禁止调用 canvas.asset_generate。';
return `这是 Agent Runtime 真实 E2E,必须完整执行,不能跳过或口头声称完成。
1. 使用 repository context:先 project.index,并用 file.read 读取 AGENTS.md、package.json、game/index.html;不得读取任何敏感诱饵文件。
2. 修改前调用 project.checkpoint。随后优先用 file.patch,把 game/index.html 中唯一的 REAL_E2E_TARGET:before 精确替换为 ${patchedText};若精确 patch 不可用才允许 file.write。保留可见文本 ${visibleText} 和非空 canvas 动画。
2. 修改前先调用 command.execinput 必须是 {"program":"npm","args":["test"],"cwd":".","timeoutSeconds":120};它必须真实失败并返回 ${commandFailureMarker},不得把失败当成完成。随后调用 project.checkpoint,再优先用 file.patch,把 game/index.html 中唯一的 REAL_E2E_TARGET:before 精确替换为 ${patchedText};若精确 patch 不可用才允许 file.write。保留可见文本 ${visibleText} 和非空 canvas 动画。
3. ${canvasStep}
4. 必须且只能调用一次 agent.spawn_isolatedjoinMode=allchildren 恰好三个:前两个 templateAgentId 都是 code-prototype,第三个是 quality-review。三个子任务只读检查 AGENTS.md 与各自已存在的 evidence.txt,不修改项目;expectedArtifacts 分别为 e2e/isolated-a/evidence.txt、e2e/isolated-b/evidence.txt、e2e/isolated-c/evidence.txtwriteScopes 分别为 e2e/isolated-a/**、e2e/isolated-b/**、e2e/isolated-c/**;每项 acceptanceCriteria 写“已读取 repository context 并给出独立结论”。必须等待三个子结果形成唯一一次 all join,不得重复 spawn。
5. 最后一次项目修改后读取 package.json 的原始脚本并调用 project.verifyinput 必须是 {"script":"check:e2e","expectedCommand":"${verificationCommand}","timeoutSeconds":120}。
5. 最后一次项目修改后先再次调用 command.execinput 必须是 {"program":"npm","args":["run","check:e2e"],"cwd":".","timeoutSeconds":120},并取得 ${commandPassedMarker}。随后读取 package.json 的原始脚本并调用 project.verifyinput 必须是 {"script":"check:e2e","expectedCommand":"${verificationCommand}","timeoutSeconds":120}。
6. 验证通过后调用 preview.validateinput 必须包含 {"viewports":["desktop","mobile"],"expectedText":["${visibleText}","${patchedText}"],"settleMs":1000,"failOnConsoleError":true},必须真实生成 desktop/mobile PNG 且通过。
7. 只有 repository context、checkpoint、read、patch/write、project.verify、preview.validate、三个隔离实例和单一 join 全部形成落盘证据后才可最终回复。不要输出或转述任何 API Key。`;
7. 只有 repository context、失败命令反馈、checkpoint、read、patch/write、成功命令复验、project.verify、preview.validate、三个隔离实例和单一 join 全部形成落盘证据后才可最终回复。不要输出或转述任何 API Key。`;
}
async function prepareCliBinary() {
@@ -715,6 +722,7 @@ async function confirmPendingActions() {
'project.checkpoint',
'file.patch',
'file.write',
'command.exec',
'project.verify',
'preview.validate',
'agent.spawn_isolated',
@@ -853,6 +861,90 @@ async function validateLandedEvidence() {
(execution) => auditPathEquals(execution.inputSummary, 'game/index.html'),
);
assert(Boolean(mutationExecution), 'file-mutation-evidence-missing');
const failedCommandArgsSha256 = createHash('sha256')
.update(JSON.stringify(failedCommandArgs))
.digest('hex');
const successfulCommandArgsSha256 = createHash('sha256')
.update(JSON.stringify(successfulCommandArgs))
.digest('hex');
const commandRecords = agentDb
.map((record, index) => ({ record, index }))
.filter(
({ record }) =>
record.recordType === 'agent.runtime.command.exec' &&
record.agentId === mainAgentId &&
record.runId === state.initialRunId,
);
assert(commandRecords.length === 2, 'command-exec-record-count-invalid');
const failedCommandRecord = commandRecords.find(
({ record }) => record.status === 'failed',
);
const successfulCommandRecord = commandRecords.find(
({ record }) => record.status === 'completed',
);
assert(
failedCommandRecord?.record.program === 'npm' &&
failedCommandRecord.record.argsCount === failedCommandArgs.length &&
failedCommandRecord.record.argsSha256 === failedCommandArgsSha256 &&
failedCommandRecord.record.cwd === '.' &&
Number.isInteger(failedCommandRecord.record.exitCode) &&
failedCommandRecord.record.exitCode !== 0 &&
failedCommandRecord.record.timedOut === false &&
failedCommandRecord.record.sourceChanged === false &&
failedCommandRecord.record.output?.includes(commandFailureMarker),
'command-exec-failure-record-invalid',
);
assert(
successfulCommandRecord?.record.program === 'npm' &&
successfulCommandRecord.record.argsCount ===
successfulCommandArgs.length &&
successfulCommandRecord.record.argsSha256 ===
successfulCommandArgsSha256 &&
successfulCommandRecord.record.cwd === '.' &&
successfulCommandRecord.record.exitCode === 0 &&
successfulCommandRecord.record.timedOut === false &&
successfulCommandRecord.record.sourceChanged === false &&
successfulCommandRecord.record.output?.includes(commandPassedMarker),
'command-exec-success-record-invalid',
);
assert(
commandRecords.every(
({ record }) =>
!Object.hasOwn(record, 'args') &&
!Object.hasOwn(record, 'arguments') &&
/^[0-9a-f]{64}$/u.test(record.argsSha256) &&
isNonEmptyString(record.actionId),
),
'command-exec-raw-argv-audit-leak',
);
const failedCommandObservationIndex = agentDb.findIndex(
(record) =>
record.recordType === 'agent.runtime.tool_observation' &&
record.agentId === mainAgentId &&
record.runId === state.initialRunId &&
record.actionId === failedCommandRecord.record.actionId &&
record.tool === 'command.exec' &&
record.status === 'command-failed' &&
record.decision === 'approved',
);
assert(
failedCommandObservationIndex > failedCommandRecord.index,
'command-exec-failure-observation-missing',
);
const successfulCommandExecution = requireSuccessfulToolExecution(
agentDb,
'command.exec',
state.initialRunId,
(execution) =>
auditInputValue(execution.inputSummary, 'program') === 'npm' &&
auditInputValue(execution.inputSummary, 'argsCount') ===
String(successfulCommandArgs.length) &&
auditInputValue(execution.inputSummary, 'argsSha256') ===
successfulCommandArgsSha256 &&
auditInputValue(execution.inputSummary, 'cwd') === '.' &&
auditInputValue(execution.inputSummary, 'timeoutSeconds') === '120',
'command-exec-success-action-invalid',
);
const verificationExecution = requireSuccessfulToolExecution(
agentDb,
'project.verify',
@@ -912,10 +1004,23 @@ async function validateLandedEvidence() {
),
'project-index-not-before-repository-reads',
);
assert(
failedCommandObservationIndex < checkpointExecution.startIndex,
'checkpoint-not-after-failed-command-feedback',
);
assert(
checkpointExecution.completionIndex < mutationExecution.startIndex,
'checkpoint-not-before-file-mutation',
);
assert(
mutationExecution.completionIndex < successfulCommandExecution.startIndex,
'successful-command-not-after-file-mutation',
);
assert(
successfulCommandExecution.completionIndex <
verificationExecution.startIndex,
'project-verification-not-after-successful-command',
);
assert(
mutationExecution.completionIndex < verificationExecution.startIndex,
'verification-not-after-file-mutation',
@@ -1471,6 +1576,7 @@ async function validateLandedEvidence() {
...repositoryReadExecutions,
checkpointExecution,
mutationExecution,
successfulCommandExecution,
verificationExecution,
previewExecution,
spawnExecution,
@@ -1493,6 +1599,7 @@ async function validateLandedEvidence() {
repositoryContextSourceCount:
contextBundle.repositoryContextSourcePaths.length,
checkpointFileCount: checkpointRecord.fileCount,
commandExecRunCount: commandRecords.length,
editorApiAssetCount: editorAssetRecord ? 1 : 0,
verificationPassed: true,
browserValidationCount: browserReports.length,
@@ -1639,6 +1746,7 @@ function emptyEvidence() {
projectIndexExecutionCount: 0,
repositoryContextSourceCount: 0,
checkpointFileCount: 0,
commandExecRunCount: 0,
editorApiAssetCount: 0,
verificationPassed: false,
browserValidationCount: 0,
@@ -1828,7 +1936,8 @@ function validateConfirmedActionLifecycles(records) {
const observed = lifecycle.filter(
({ record }) =>
record.recordType === 'agent.runtime.tool_observation' &&
record.status === 'ok',
record.decision === 'approved' &&
record.status !== 'waiting-for-confirmation',
);
const required = lifecycle.filter(
({ record }) =>
@@ -1859,7 +1968,6 @@ function validateConfirmedActionLifecycles(records) {
) &&
waiting[0].record.tool === tool &&
observed[0].record.tool === tool &&
observed[0].record.decision === 'approved' &&
approved[0].record.tool === tool &&
waiting[0].record.actionFingerprint === actionFingerprint &&
approved[0].record.actionFingerprint === actionFingerprint &&
@@ -449,6 +449,32 @@ if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
const allowedLlmReasoningEfforts = new Set([
'default',
'low',
'medium',
'high',
]);
if (defaultAppConfig.llm?.reasoningEffort !== 'high') {
throw new Error(
'AI game creator shell default llm.reasoningEffort must stay high',
);
}
for (const [agentId, agentConfig] of Object.entries(
defaultAppConfig.agentLlm ?? {},
)) {
if (
agentConfig?.reasoningEffort !== undefined &&
!allowedLlmReasoningEfforts.has(agentConfig.reasoningEffort)
) {
throw new Error(
`AI game creator shell agentLlm.${agentId}.reasoningEffort is invalid`,
);
}
}
if (defaultAppConfig.editorApi?.apiKey !== '') {
throw new Error(
'AI game creator shell default editorApi.apiKey must stay empty',
File diff suppressed because it is too large Load Diff
@@ -172,6 +172,7 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
),
format!("llm.model={}", status.model.as_deref().unwrap_or_default()),
format!("llm.apiKind={}", status.api_kind),
format!("llm.reasoningEffort={}", status.reasoning_effort),
format!("llm.stream={}", status.stream),
];
for agent in &status.agents {
@@ -197,6 +198,10 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
"llm.agent.{}.apiKind={}",
agent.agent_id, agent.api_kind
));
lines.push(format!(
"llm.agent.{}.reasoningEffort={}",
agent.agent_id, agent.reasoning_effort
));
lines.push(format!(
"llm.agent.{}.stream={}",
agent.agent_id, agent.stream
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,32 @@ pub(crate) fn parse_game_creator_llm_api_kind(value: &str) -> Result<LlmApiKind,
}
}
pub(crate) fn parse_game_creator_llm_reasoning_effort(
value: &str,
) -> Result<Option<platform_llm::LlmResponseReasoningEffort>, String> {
match value.trim().to_ascii_lowercase().as_str() {
"default" => Ok(None),
"low" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)),
"medium" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)),
"high" => Ok(Some(platform_llm::LlmResponseReasoningEffort::High)),
value => Err(format!(
"LLM reasoning_effort 无效:{value},请使用 default、low、medium 或 high"
)),
}
}
pub(crate) fn apply_game_creator_llm_reasoning_effort(
request: LlmRunRequest,
llm: &GameCreatorLlmConfig,
) -> Result<LlmRunRequest, String> {
Ok(
match parse_game_creator_llm_reasoning_effort(&llm.reasoning_effort)? {
Some(effort) => request.with_response_reasoning_effort(effort),
None => request,
},
)
}
pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus {
let app_config = match load_game_creator_app_config() {
Ok(config) => config,
@@ -57,6 +83,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
base_url: None,
model: None,
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: false,
error: Some(error),
agents: Vec::new(),
@@ -150,6 +177,7 @@ pub(crate) fn check_game_creator_llm_config_values(
base_url,
model,
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
reasoning_effort: config.reasoning_effort.clone(),
stream: config.stream,
error,
agents: Vec::new(),
@@ -184,6 +212,7 @@ pub(crate) fn check_game_creator_agent_llm_config_values(
base_url: status.base_url,
model: status.model,
api_kind: status.api_kind,
reasoning_effort: config.reasoning_effort.clone(),
stream: config.stream,
error: status.error,
}
@@ -201,6 +230,8 @@ pub(crate) fn validate_game_creator_llm_timing_config(
if config.retry_backoff_ms == 0 {
return Err(format!("配置项 {config_path}.retryBackoffMs 必须大于 0"));
}
parse_game_creator_llm_reasoning_effort(&config.reasoning_effort)
.map_err(|error| format!("配置项 {config_path}.reasoningEffort 无效:{error}"))?;
Ok(())
}
@@ -213,6 +244,16 @@ pub(crate) fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String {
.to_string()
}
pub(crate) fn game_creator_llm_reasoning_effort_name(
value: &str,
config_path: &str,
) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
parse_game_creator_llm_reasoning_effort(&normalized)
.map_err(|error| format!("配置项 {config_path} 无效:{error}"))?;
Ok(normalized)
}
fn validate_game_creator_runtime_config_dir_metadata(
path: &Path,
tighten: bool,
@@ -926,6 +967,9 @@ pub(crate) fn merge_game_creator_llm_config(
if let Some(value) = patch.api_kind {
config.api_kind = value;
}
if let Some(value) = patch.reasoning_effort {
config.reasoning_effort = value;
}
if let Some(value) = patch.stream {
config.stream = value;
}
@@ -956,6 +1000,9 @@ pub(crate) fn merge_game_creator_llm_patch(
if let Some(value) = patch.api_kind {
config.api_kind = Some(value);
}
if let Some(value) = patch.reasoning_effort {
config.reasoning_effort = Some(value);
}
if let Some(value) = patch.stream {
config.stream = Some(value);
}
@@ -1012,6 +1059,10 @@ pub(crate) fn normalize_game_creator_app_config(
trim_config_string(&config.llm.model).ok_or_else(|| llm_model_config_error("llm"))?;
config.llm.api_kind =
game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind(&config.llm.api_kind)?);
config.llm.reasoning_effort = game_creator_llm_reasoning_effort_name(
&config.llm.reasoning_effort,
"llm.reasoningEffort",
)?;
validate_game_creator_llm_timing_config(&config.llm, "llm")?;
let mut agent_llm = BTreeMap::new();
for (agent_id, patch) in config.agent_llm {
@@ -1045,6 +1096,13 @@ pub(crate) fn normalize_game_creator_llm_patch_config(
)),
None => None,
};
patch.reasoning_effort = match patch.reasoning_effort {
Some(value) => Some(game_creator_llm_reasoning_effort_name(
&value,
&format!("agentLlm.{agent_id}.reasoningEffort"),
)?),
None => None,
};
if patch
.request_timeout_ms
.is_some_and(|value| value < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS)
@@ -1066,6 +1124,7 @@ pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile)
&& patch.base_url.is_none()
&& patch.model.is_none()
&& patch.api_kind.is_none()
&& patch.reasoning_effort.is_none()
&& patch.stream.is_none()
&& patch.request_timeout_ms.is_none()
&& patch.max_retries.is_none()
@@ -44,6 +44,7 @@ mod agent;
mod assets;
mod browser;
mod cli;
mod command_exec;
mod commands;
mod config;
#[cfg(all(debug_assertions, not(test)))]
@@ -59,6 +60,7 @@ use agent::*;
use assets::*;
use browser::*;
use cli::*;
use command_exec::*;
use commands::*;
use config::*;
use isolated_agent::*;
@@ -431,6 +433,7 @@ struct GameCreatorLlmConfigStatus {
base_url: Option<String>,
model: Option<String>,
api_kind: String,
reasoning_effort: String,
stream: bool,
error: Option<String>,
agents: Vec<GameCreatorAgentLlmConfigStatus>,
@@ -446,6 +449,7 @@ struct GameCreatorAgentLlmConfigStatus {
base_url: Option<String>,
model: Option<String>,
api_kind: String,
reasoning_effort: String,
stream: bool,
error: Option<String>,
}
@@ -470,6 +474,8 @@ struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
api_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
request_timeout_ms: Option<u64>,
@@ -502,6 +508,7 @@ struct GameCreatorLlmConfig {
base_url: String,
model: String,
api_kind: String,
reasoning_effort: String,
stream: bool,
request_timeout_ms: u64,
max_retries: u32,
@@ -834,6 +841,7 @@ const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.jso
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1";
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
@@ -897,6 +905,7 @@ impl Default for GameCreatorLlmConfig {
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: false,
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
max_retries: 0,
File diff suppressed because it is too large Load Diff
+100 -12
View File
@@ -384,12 +384,22 @@ interface GameCreatorAgentRuntimeUpdateEvent {
runtime: AgentRuntimeResult;
}
const gameCreatorLlmReasoningEfforts = [
'default',
'low',
'medium',
'high',
] as const;
type GameCreatorLlmReasoningEffort =
(typeof gameCreatorLlmReasoningEfforts)[number];
interface GameCreatorLlmConfigStatus {
configured: boolean;
apiKeyPresent: boolean;
baseUrl: string | null;
model: string | null;
apiKind: string;
reasoningEffort: GameCreatorLlmReasoningEffort;
stream: boolean;
error: string | null;
agents?: GameCreatorAgentLlmConfigStatus[];
@@ -403,6 +413,7 @@ interface GameCreatorAgentLlmConfigStatus {
baseUrl: string | null;
model: string | null;
apiKind: string;
reasoningEffort: GameCreatorLlmReasoningEffort;
stream: boolean;
error: string | null;
}
@@ -421,6 +432,7 @@ interface GameCreatorLlmConfig {
baseUrl: string;
model: string;
apiKind: GameCreatorLlmApiKind;
reasoningEffort: GameCreatorLlmReasoningEffort;
stream: boolean;
requestTimeoutMs: number;
maxRetries: number;
@@ -1611,6 +1623,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
requestTimeoutMs: 180000,
maxRetries: 0,
@@ -1714,6 +1727,12 @@ function clampRuntimeConfigNumber(value: number, minimum: number) {
: minimum;
}
function isGameCreatorLlmReasoningEffort(
value: unknown,
): value is GameCreatorLlmReasoningEffort {
return gameCreatorLlmReasoningEfforts.some((effort) => effort === value);
}
function normalizeRuntimeAgentLlmConfig(
config: GameCreatorAgentLlmConfig | undefined,
): GameCreatorAgentLlmConfig {
@@ -1736,6 +1755,9 @@ function normalizeRuntimeAgentLlmConfig(
) {
normalized.apiKind = config.apiKind;
}
if (isGameCreatorLlmReasoningEffort(config.reasoningEffort)) {
normalized.reasoningEffort = config.reasoningEffort;
}
if (typeof config.stream === 'boolean') {
normalized.stream = config.stream;
}
@@ -1767,6 +1789,11 @@ function normalizeRuntimeConfigDraft(
].includes(config.llm.apiKind)
? config.llm.apiKind
: defaultRuntimeConfigDraft.llm.apiKind;
const reasoningEffort = isGameCreatorLlmReasoningEffort(
config.llm.reasoningEffort,
)
? config.llm.reasoningEffort
: defaultRuntimeConfigDraft.llm.reasoningEffort;
const agentLlm: Record<string, GameCreatorAgentLlmConfig> = {};
for (const [agentId, agentConfig] of Object.entries(config.agentLlm ?? {})) {
const normalized = normalizeRuntimeAgentLlmConfig(agentConfig);
@@ -1779,6 +1806,7 @@ function normalizeRuntimeConfigDraft(
llm: {
...config.llm,
apiKind,
reasoningEffort,
requestTimeoutMs: clampRuntimeConfigNumber(
config.llm.requestTimeoutMs,
1000,
@@ -2751,6 +2779,25 @@ function RuntimeConfigDialog({
<option value="anthropic">anthropic</option>
</select>
</label>
<label>
LLM
<select
aria-label="LLM 推理档"
value={runtimeConfigDraft.llm.reasoningEffort}
onChange={(event) =>
updateRuntimeLlmConfig(
'reasoningEffort',
event.currentTarget.value as GameCreatorLlmReasoningEffort,
)
}
>
{gameCreatorLlmReasoningEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort}
</option>
))}
</select>
</label>
<label className="settings-checkbox">
<input
aria-label="LLM 流式请求"
@@ -2898,6 +2945,30 @@ function RuntimeConfigDialog({
<option value="anthropic">anthropic</option>
</select>
</label>
<label>
{agent.label} LLM
<select
aria-label={`${agent.label} LLM 推理档`}
value={agentLlm.reasoningEffort ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'reasoningEffort',
event.currentTarget.value
? (event.currentTarget
.value as GameCreatorLlmReasoningEffort)
: undefined,
)
}
>
<option value=""></option>
{gameCreatorLlmReasoningEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort}
</option>
))}
</select>
</label>
<label>
{agent.label} LLM
<select
@@ -4051,11 +4122,17 @@ export function WorkspaceLauncher({
}
setAgentChatLlmStatus(
agentStatus.configured
? `当前 Agent LLM 已配置:${
agentStatus.model ?? '未命名模型'
? `当前 Agent LLM 已配置:${agentStatus.model ?? '未命名模型'}${
agentStatus.reasoningEffort
? `,推理 ${agentStatus.reasoningEffort}`
: ''
}API Key ${agentStatus.apiKeyPresent ? '已读取' : '未读取'}`
: `当前 Agent LLM 未就绪:${
agentStatus.error ?? '缺少 API Key 或模型配置'
}${
agentStatus.reasoningEffort
? `(推理 ${agentStatus.reasoningEffort}`
: ''
}`,
);
} catch (error) {
@@ -12918,6 +12995,7 @@ function formatLlmAgentStatusLine(agent: GameCreatorAgentLlmConfigStatus) {
`${agent.label}${agent.configured ? '已配置' : '未就绪'}`,
`${agent.model ?? '未命名模型'} @ ${agent.baseUrl ?? '未设置 base_url'}`,
agent.apiKind,
...(agent.reasoningEffort ? [`推理 ${agent.reasoningEffort}`] : []),
`流式 ${agent.stream ? '开启' : '关闭'}`,
`API Key ${agent.apiKeyPresent ? '已读取' : '未读取'}`,
];
@@ -12930,12 +13008,19 @@ function formatLlmAgentStatusLine(agent: GameCreatorAgentLlmConfigStatus) {
function formatLlmRouteEndpoint(
status: Pick<
GameCreatorLlmConfigStatus,
'baseUrl' | 'model' | 'apiKind' | 'stream' | 'apiKeyPresent'
| 'baseUrl'
| 'model'
| 'apiKind'
| 'reasoningEffort'
| 'stream'
| 'apiKeyPresent'
>,
) {
return `${status.model ?? '未命名模型'} @ ${
status.baseUrl ?? '未设置 base_url'
}${status.apiKind} ${
}${status.apiKind}${
status.reasoningEffort ? `,推理 ${status.reasoningEffort}` : ''
} ${
status.stream ? '开启' : '关闭'
}API Key ${status.apiKeyPresent ? '已读取' : '未读取'}`;
}
@@ -12948,6 +13033,7 @@ function isSameResolvedLlmRouteAsGlobal(
agentStatus.baseUrl === globalStatus.baseUrl &&
agentStatus.model === globalStatus.model &&
agentStatus.apiKind === globalStatus.apiKind &&
agentStatus.reasoningEffort === globalStatus.reasoningEffort &&
agentStatus.stream === globalStatus.stream
);
}
@@ -13029,6 +13115,9 @@ function formatAgentCardLlmStatus(
`LLM${agentStatus.configured ? '已配置' : '未就绪'}`,
agentStatus.model ?? '未命名模型',
agentStatus.apiKind,
...(agentStatus.reasoningEffort
? [`推理 ${agentStatus.reasoningEffort}`]
: []),
`流式${agentStatus.stream ? '开' : '关'}`,
`Key${agentStatus.apiKeyPresent ? '已读' : '未读'}`,
].join(' · ');
@@ -13093,6 +13182,9 @@ function formatAgentDialogLlmStatus(
agentStatus.baseUrl ?? '未设置 base_url'
}`,
agentStatus.apiKind,
...(agentStatus.reasoningEffort
? [`推理 ${agentStatus.reasoningEffort}`]
: []),
`流式 ${agentStatus.stream ? '开启' : '关闭'}`,
`API Key ${agentStatus.apiKeyPresent ? '已读取' : '未读取'}`,
];
@@ -17954,14 +18046,10 @@ export function App() {
setCommandLog((current) => [...current, 'llm.config_check']);
const agentLines = (status.agents ?? []).map(formatLlmAgentStatusLine);
const summary = status.configured
? `LLM 已配置:${status.model ?? '未命名模型'} @ ${
status.baseUrl ?? '未设置 base_url'
}${status.apiKind} ${
status.stream ? '开启' : '关闭'
}API Key ${status.apiKeyPresent ? '已读取' : '未读取'}`
: `LLM 未就绪:${status.error ?? '配置不完整'}。API Key${
status.apiKeyPresent ? '已读取' : '未读取'
}`;
? `LLM 已配置:${formatLlmRouteEndpoint(status)}`
: `LLM 未就绪:${status.error ?? '配置不完整'}${
status.reasoningEffort ? `推理 ${status.reasoningEffort}` : ''
}API Key${status.apiKeyPresent ? '已读取' : '未读取'}`;
setMessages((current) => [
...current,
{
@@ -5319,6 +5319,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-main',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
error: null,
agents: [
@@ -5330,6 +5331,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://planner.example.test/v1',
model: 'planner-model',
apiKind: 'anthropic',
reasoningEffort: 'medium',
stream: true,
error: null,
},
@@ -5341,6 +5343,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://art.example.test/v1',
model: 'art-model',
apiKind: 'openai_chat',
reasoningEffort: 'high',
stream: true,
error: null,
},
@@ -5352,6 +5355,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://audio.example.test/v1',
model: 'audio-model',
apiKind: 'openai_chat',
reasoningEffort: 'default',
stream: false,
error:
'LLM 未配置:请在 agentLlm.audio-asset-plan.apiKey 中设置 API Key',
@@ -5536,12 +5540,12 @@ describe('AI 游戏创作 App 界面边界', () => {
const agentStatusPane = screen.getByLabelText('Agent 状态');
expect(
within(agentStatusPane).getByText(
'LLM:已配置 · art-model · openai_chat · 流式开 · Key已读',
'LLM:已配置 · art-model · openai_chat · 推理 high · 流式开 · Key已读',
),
).not.toBeNull();
expect(
within(agentStatusPane).getByText(
'LLM:未就绪 · audio-model · openai_chat · 流式关 · Key未读',
'LLM:未就绪 · audio-model · openai_chat · 推理 default · 流式关 · Key未读',
),
).not.toBeNull();
expect(screen.queryByText(/planner-secret/)).toBeNull();
@@ -5602,7 +5606,7 @@ describe('AI 游戏创作 App 界面边界', () => {
});
expect(
within(agentDialog).getByText(
'LLM:已配置,art-model @ https://art.example.test/v1openai_chat,流式 开启,API Key 已读取',
'LLM:已配置,art-model @ https://art.example.test/v1openai_chat推理 high流式 开启,API Key 已读取',
),
).not.toBeNull();
fireEvent.click(within(agentDialog).getByRole('button', { name: '关闭' }));
@@ -16497,6 +16501,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
reasoningEffort: 'medium',
stream: false,
requestTimeoutMs: 180000,
maxRetries: 0,
@@ -16508,6 +16513,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://api.anthropic.com',
model: 'claude-3-5-sonnet-latest',
apiKind: 'anthropic',
reasoningEffort: 'default',
stream: false,
},
'art-asset-plan': {
@@ -16583,6 +16589,21 @@ describe('AI 游戏创作 App 界面边界', () => {
'value',
'claude-3-5-sonnet-latest',
);
expect(screen.getByLabelText('LLM 推理档')).toHaveProperty(
'value',
'medium',
);
expect(screen.getByLabelText('Planner LLM 推理档')).toHaveProperty(
'value',
'default',
);
expect(screen.getByLabelText('Generator LLM 推理档')).toHaveProperty(
'value',
'',
);
expect(
screen.getByLabelText('规划美术资产 (art/Asset) LLM 推理档'),
).toHaveProperty('value', '');
expect(screen.getByLabelText('Planner LLM 流式请求')).toHaveProperty(
'value',
'false',
@@ -16622,6 +16643,9 @@ describe('AI 游戏创作 App 界面边界', () => {
fireEvent.change(screen.getByLabelText('Generator LLM Provider'), {
target: { value: 'deepseek' },
});
fireEvent.change(screen.getByLabelText('Generator LLM 推理档'), {
target: { value: 'high' },
});
fireEvent.change(screen.getByLabelText('Generator LLM 流式请求'), {
target: { value: 'true' },
});
@@ -16648,6 +16672,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://new-llm.example.test/v1',
model: 'gpt-next',
apiKind: 'openai_chat',
reasoningEffort: 'medium',
stream: true,
requestTimeoutMs: 90000,
maxRetries: 3,
@@ -16659,6 +16684,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://api.anthropic.com',
model: 'claude-3-5-sonnet-latest',
apiKind: 'anthropic',
reasoningEffort: 'default',
stream: false,
},
generator: {
@@ -16666,6 +16692,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiKind: 'openai_chat',
reasoningEffort: 'high',
stream: true,
},
'art-asset-plan': {
@@ -16724,6 +16751,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'value',
'gpt-4.1',
);
expect(screen.getByLabelText('LLM 推理档')).toHaveProperty('value', 'high');
expect(screen.getByLabelText('画板 API Base URL')).toHaveProperty(
'value',
'http://127.0.0.1:8082',
@@ -16738,6 +16766,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
requestTimeoutMs: 180000,
maxRetries: 0,
@@ -22682,6 +22711,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
error: null,
agents: [
@@ -22693,6 +22723,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://planner.example.test/v1',
model: 'planner-model',
apiKind: 'anthropic',
reasoningEffort: 'medium',
stream: true,
error: null,
},
@@ -22704,6 +22735,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://generator.example.test/v1',
model: 'generator-model',
apiKind: 'openai_chat',
reasoningEffort: 'default',
stream: false,
error:
'LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key',
@@ -22720,13 +22752,13 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(await screen.findByText(/LLM 已配置:gpt-test/)).not.toBeNull();
expect(screen.getByLabelText('聊天').textContent).toContain(
'LLM 已配置:gpt-test @ https://llm.example.test/v1openai_responses,流式 关闭,API Key 未读取。',
'LLM 已配置:gpt-test @ https://llm.example.test/v1openai_responses推理 high流式 关闭,API Key 未读取。',
);
expect(screen.getByLabelText('聊天').textContent).toContain(
'Planner:已配置,planner-model @ https://planner.example.test/v1anthropic,流式 开启,API Key 已读取',
'Planner:已配置,planner-model @ https://planner.example.test/v1anthropic推理 medium流式 开启,API Key 已读取',
);
expect(screen.getByLabelText('聊天').textContent).toContain(
'Generator:未就绪,generator-model @ https://generator.example.test/v1openai_chat,流式 关闭,API Key 未读取,错误:LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key',
'Generator:未就绪,generator-model @ https://generator.example.test/v1openai_chat推理 default流式 关闭,API Key 未读取,错误:LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key',
);
expect(screen.queryByText(/sk-test-secret/)).toBeNull();
expect(screen.queryByText(/planner-secret/)).toBeNull();
@@ -22743,6 +22775,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-main',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
error: null,
agents: [
@@ -22754,6 +22787,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-main',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
error: null,
},
@@ -22765,6 +22799,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://generator.example.test/v1',
model: 'generator-model',
apiKind: 'openai_chat',
reasoningEffort: 'low',
stream: true,
error: null,
},
@@ -22776,6 +22811,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-main',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
error:
'LLM 未配置:请在 agentLlm.audio-sfx.apiKey 中设置 API Key',
@@ -22793,17 +22829,17 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(await screen.findByText(/Agent LLM 路由:/)).not.toBeNull();
const chatText = screen.getByLabelText('聊天').textContent ?? '';
expect(chatText).toContain(
'默认路由:gpt-main @ https://llm.example.test/v1openai_responses,流式 关闭,API Key 已读取',
'默认路由:gpt-main @ https://llm.example.test/v1openai_responses推理 high流式 关闭,API Key 已读取',
);
expect(chatText).toContain('Agent2/3 就绪 · 1 个单独路由 · 1 个缺口');
expect(chatText).toContain(
'Planner:已配置 · 解析后与全局一致 · gpt-main @ https://llm.example.test/v1openai_responses,流式 关闭,API Key 已读取',
'Planner:已配置 · 解析后与全局一致 · gpt-main @ https://llm.example.test/v1openai_responses推理 high流式 关闭,API Key 已读取',
);
expect(chatText).toContain(
'Generator:已配置 · 单独路由 · generator-model @ https://generator.example.test/v1openai_chat,流式 开启,API Key 已读取',
'Generator:已配置 · 单独路由 · generator-model @ https://generator.example.test/v1openai_chat推理 low流式 开启,API Key 已读取',
);
expect(chatText).toContain(
'音效规划:未就绪 · 解析后与全局一致 · gpt-main @ https://llm.example.test/v1openai_responses,流式 关闭,API Key 未读取 · 错误:LLM 未配置:请在 agentLlm.audio-sfx.apiKey 中设置 API Key',
'音效规划:未就绪 · 解析后与全局一致 · gpt-main @ https://llm.example.test/v1openai_responses推理 high流式 关闭,API Key 未读取 · 错误:LLM 未配置:请在 agentLlm.audio-sfx.apiKey 中设置 API Key',
);
expect(chatText).toContain(
'边界:只读取运行时配置解析结果;不请求上游;不显示 API Key;不写项目',
@@ -22827,6 +22863,7 @@ describe('AI 游戏创作 App 界面边界', () => {
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
reasoningEffort: 'default',
stream: false,
error: null,
agents: [],
@@ -22841,7 +22878,7 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(await screen.findByText(/LLM 已配置:gpt-test/)).not.toBeNull();
expect(screen.getByLabelText('聊天').textContent).toContain(
'LLM 已配置:gpt-test @ https://llm.example.test/v1openai_responses,流式 关闭,API Key 已读取。',
'LLM 已配置:gpt-test @ https://llm.example.test/v1openai_responses推理 default流式 关闭,API Key 已读取。',
);
expect(screen.queryByText(/sk-test-secret/)).toBeNull();
expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config');
@@ -4188,3 +4188,12 @@
- 2026-07-12 修正:`preview.validate` 必须固定同时生成 desktop / mobile 证据,每个视口都要有可见且至少两种 RGBA 状态的 canvas;单视口、无 canvas、透明或均匀纯色画布不能通过。
- 验证:真实 `gpt-5.5``llm-runtime` 套件已通过 Runner 强杀恢复、11 条合法工具协议、4 套完整确认生命周期、5 个零重放副作用动作、9 次结构化成功工具执行、checkpoint/修改、项目验证、Chrome 桌面与移动取证、3 个隔离实例和唯一 join;终态投影与 assistant audit 唯一,action/message/receipt 重复为 0,密钥与诱饵泄露为 0。未配置 External Editor API 时 `full` 套件按契约返回 `BLOCKED(editorApi)`
- 详细契约与验收矩阵见 `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## 2026-07-12 AI 游戏创作 Agent Runtime V1.2 受控命令与推理档位
- 决策:新增 `command.exec` 补齐“复现 -> 读取真实输出 -> 修改 -> 再验证”闭环。输入固定为 `program / args / cwd / timeoutSeconds``program` 只能来自 Runtime 内置白名单,`args` 必须是逐项 argv,禁止 shell 字符串、管道、重定向、命令替换、环境变量注入、PTY、后台服务和用户指定可执行路径。
- 决策:`command.exec` 权限默认为 `confirm`,精确确认继续绑定 actionId、动作指纹、repository fingerprint、project revision 和 execution owner,并在取得项目写锁后重读策略及复核 pending action 身份;策略改 deny、actionId / 指纹或 revision 漂移都必须在启动前失败关闭。命令请求进入 durable action;每次真正启动前保守推进一次 revision,清洗后的 stdout / stderr、退出码、超时与源码指纹进入 observation。只有 `cargo check/test/clippy/fmt/build`、npm 测试或规范命名的验证脚本、精确 `node --test` 具备验证资格;Git、rg、cargo metadata 和普通 npm run 只作为诊断。验证型命令还必须退出码为 0、未超时、无源码漂移且命令日志、manifest、Agent DB 审计全部成功才能绑定当前 revision;Agent DB 审计失败先保持 failed gate 再进入 `needs-reconciliation``executing` 阶段中断不自动重放。
- 决策:`command.exec` 可执行文件必须解析为项目外绝对路径,子进程只使用安全绝对 PATH;npm 转发参数拒绝 shell 元字符,Node、Git、rg 分别拒绝可加载外部文件、pager / pathspec / object-path、follow / hidden / preprocessor / 类型覆盖等间接读取或执行能力,敏感搜索排除必须在用户选项之后注入。首版超时处理只请求终止受控进程组并检查调用结果,安全等级仍与 `project.verify` 相同,即“固定程序 + 参数级策略 + 用户确认”;当前不宣称具备 Codex CLI 级 OS sandbox 或完整 detached-process 隔离,在平台级沙箱落地前不得把默认权限改为 `auto`
- 决策:AppData LLM 配置使用全局 `llm.reasoningEffort` 和可选 `agentLlm.<agentId>.reasoningEffort`;per-Agent 配置有值时覆盖全局、缺省时继承全局。值只允许 `default / low / medium / high``default` 不向 Provider 发送推理档位;发布默认固定为 `high`planning、普通单 Agent 聊天和最终回复共用同一解析结果,不再硬编码 `low`
- 验证:真实 `gpt-5.5` 的最终安全收紧版 `llm-runtime` 套件已完成失败 `command.exec` -> 精确修复 -> 不同 argv 复验通过,并覆盖 Runner 强杀恢复且 run/session 身份稳定、95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、6 套确认生命周期、3 个隔离实例和唯一 join;两次命令只审计 args 数量与 SHA-256,副作用重放、重复 action / message / receipt 和密钥 / 诱饵泄露均为 0,临时项目按 sentinel 自动清理。
- 详细白名单、参数拒绝规则与验收口径见 `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` 的“V1.2 对标 Codex CLI 增量”。
@@ -65,6 +65,33 @@ npm run ai-game-creator-shell:agent-task -- --config-dir /absolute/app-data --in
省略 `--init` 时项目必须已经由客户端初始化。所有 Runtime 写命令都必须显式传入项目外 `--config-dir` 并投递给独立 Runner`--runner-status` 和 Agent 状态查询只读取已有配置与 endpoint,不得创建 AppData、修改权限或为了查询启动 Runner。遇到权限确认会返回非零并保留待确认动作,继续操作应回到开发窗口,不能用 CLI 静默绕过。
### AI 游戏创作 Runtime V1.2 定向复验
`command.exec` 动作必须保留固定程序和逐项 argv,不得把参数拼成 shell 字符串。例如,定向执行当前仓库的受控命令测试时,action 形状为:
```json
{
"program": "cargo",
"args": ["test", "project_command_"],
"cwd": "apps/ai-game-creator-shell/src-tauri",
"timeoutSeconds": 120
}
```
该 action 仍需开发窗口精确确认;不能用 CLI 或项目策略把 `command.exec` 默认改为 `auto`。实现或调整 Runtime V1.2 后,从仓库根目录优先运行以下定向命令:
只有 `cargo check/test/clippy/fmt/build``npm test`、规范命名的 npm 验证脚本和精确 `node --test` 可以形成验证凭证;`git``rg``cargo metadata` 与普通 `npm run` 即使成功也只是诊断结果,最后仍需执行验证型命令。
```bash
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_command_
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml config_file_overrides_defaults_without_env
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml llm_reasoning_effort_supports_provider_default_and_explicit_levels
npm run test -- packages/shared/src/contracts/gameCreationApp.test.ts
npm run ai-game-creator-shell:typecheck
```
第一条覆盖固定程序 / argv 拒绝规则、输出清洗、超时和源码改写检测;第二条覆盖全局 / per-Agent 配置继承,第三条覆盖 `default / low / medium / high` 到 Provider 请求的映射;后两条覆盖共享 `confirm` 契约、配置结构和发布默认 `high`。模块级定向验证通过后,再按改动范围运行 `npm run ai-game-creator-shell:check``npm run check:encoding``git diff --check`
`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start``start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
@@ -237,6 +237,31 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
- 浏览器纯逻辑测试覆盖单视口、重复视口、无 canvas、透明 canvas 和均匀纯色 canvas 失败;显式真实 Chrome 测试同时证明桌面/移动截图有效,跨 origin HTTP redirect 与 WebSocket 目标在发送前零连接。
- Runner 测试覆盖只读状态无副作用、锁链接 / 硬链接拒绝、跨 AppData 唯一 owner,以及缺失、截断或损坏 owner 诊断在 OS 锁后原子恢复。
## V1.2 对标 Codex CLI 增量
### 受控命令反馈循环
单 Agent 新增 `command.exec`,用于补齐“复现问题 -> 读取真实 stdout / stderr -> 修改 -> 再验证”的开发闭环。该工具不是任意 shell,也不提供 PTY、后台服务或通用进程管理:
- 输入固定为 `program / args / cwd / timeoutSeconds``program` 只接受 Runtime 内置白名单,`args` 是逐项 argv,不接受 shell 字符串、重定向、管道、命令替换、环境变量或用户指定 executable 路径。
- 首批只允许 `cargo``check / test / clippy / fmt / build / metadata``npm``test / run`、精确 `node --test <项目内普通测试文件...>`、受限只读 Git 子命令和受限 `rg`。npm 转发 argv 拒绝 shell 元字符和空白;Node 拒绝额外选项、glob、符号链接和 reparse pointGit 拒绝 pager、外部 diff/textconv、`grep -O`、pathspec 文件和 `.git / .env / key / config` 等敏感对象路径;`rg` 拒绝 follow、hidden/no-ignore、zip、preprocessor、类型覆盖和用户 glob,并在用户选项之后注入不可覆盖的敏感路径排除。每种程序继续执行参数级拒绝规则,禁止安装、发布、联网、修改 Git、切换工作区、读取项目外路径或覆盖执行环境。
- `cwd` 必须是项目内规范相对目录,拒绝符号链接、绝对路径、`..`、Windows 盘符 / UNC / ADS 和整个 `.agent` 控制面;超时固定在 1-300 秒,stdin 关闭,stdout / stderr 采用有界头尾保留并先做凭据清洗。
- 默认权限为 `confirm`。确认摘要包含程序、argv 摘要、cwd 和超时;精确动作继续绑定 actionId、repository fingerprint、project revision 和 execution owner。Runner 在 `executing` 阶段退出时保持 `needs-reconciliation`,不得自动重放命令。
- 子进程继承环境清空;可执行文件必须从项目外安全绝对目录解析为绝对路径,子进程 PATH 只保留这些已规范化目录,并注入隔离 HOME / TMP / cache、离线包管理配置和不可达代理。超时或读流失败时 Runtime 请求终止受控进程组并检查终止调用结果,但这不等同于完整 detached-process / 容器隔离。首版安全等级与现有 `project.verify` 相同:固定程序和参数策略加用户确认,不宣称已经具备 Codex CLI 的完整 OS sandbox;在完成平台沙箱前不得把 `command.exec` 默认改为 `auto`
- Cargo / npm 缓存固定写入项目私有 `.agent/runtime/command-env/cache`,不复用或改写用户宿主缓存,也不允许联网补依赖。依赖未进入项目 vendor、现有 `node_modules` 或隔离缓存时,命令应以真实失败输出回到 Agent;首版不为“跑通命令”复制宿主的 Cargo registry、凭据或用户级配置。
- `command.exec` 的执行前后源码指纹各自最多遍历 20,000 个目录项、10,000 个受保护文件和 512 MiB 正文;执行前超预算直接拒绝启动,执行后无法完成指纹则进入 `needs-reconciliation`,不得把截断扫描当成完整验证凭证。
- 命令结束后重建安全项目文件指纹。若命令改写了受保护项目文件,则保持 verification gate 未通过并要求 Agent 重新检查;每次真正启动命令前已经保守推进一次 revision。可签发验证凭证的命令仅限 `cargo check/test/clippy/fmt/build``npm test`、命名为 `check/typecheck/test/lint/build/verify/validate` 的 npm 验证脚本及精确 `node --test``git``rg``cargo metadata` 和普通 `npm run` 即使退出码为 0 也只作为诊断结果。只有验证型命令退出码为 0、未超时、未改写受保护文件,且命令日志、manifest 投影和 Agent DB 审计全部成功后,才允许绑定当前 revision 的 passed gate;任一审计失败必须先保持 failed gate,再进入 `needs-reconciliation`
`command.exec` 的最小真实验收必须给 Provider 一个未注明文件路径和脚本名的失败测试项目,证明 Agent 能自行定位、运行定向命令、根据失败输出修改、经历精确确认和 Runner 恢复,再形成唯一 completed / assistant 终态。现有按固定配方执行的 Runtime E2E 继续保留,但不能替代该诊断型验收。
2026-07-12 V1.2 真实验收:发布 AppData 中配置的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件。Disposable 项目先由 `command.exec` 真实取得失败输出,精确修复后以不同 argv 再次执行通过;Runner 强杀后恢复同一 run / session 且身份稳定。最终形成 95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、6 套完整确认生命周期、10 次结构化成功工具执行和 7 个副作用 action;两次 `command.exec` 审计只保留参数数量与 SHA-256。项目 revision 为 3,3 个隔离实例来自 2 个模板且只形成 1 个 joincompleted / assistant audit 各 1 条,副作用重放、重复 action / message / receipt、已加载密钥泄露和项目诱饵泄露均为 0;桌面 / 移动浏览器证据有效,临时项目按 sentinel 自动清理。
### 推理档位
- AppData 配置新增全局 `llm.reasoningEffort` 和可继承的 `agentLlm.<agentId>.reasoningEffort`,值只允许 `default / low / medium / high`
- `default` 表示不向 Provider 发送推理档位;其余值映射到统一 LLM 请求。工具规划、普通单 Agent 聊天和最终回复必须使用同一解析后的 Agent 配置,Runtime 不再硬编码 `low`
- 发布默认值使用 `high`,旧配置缺少字段时由默认配置补齐;非 OpenAI Responses / Chat Provider 可以选择 `default`,避免发送不支持的参数。
## 验收命令
- `npm run ai-game-creator-shell:typecheck`
@@ -18,7 +18,9 @@
2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](./【技术方案】AI游戏创作Agent%20Runtime%20V1.1-2026-07-12.md) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。
2026-07-12 真实验收:发布 AppData 中的真实 Provider 已通过强化后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复、仓库上下文、checkpoint/精确修改/四段确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件因当前 AppData 未配置 External Editor API 正确返回 `BLOCKED(editorApi)`,不得记为通过
同一文档的“V1.2 对标 Codex CLI 增量”继续作为受控命令与推理档位的事实源。`command.exec` 只接受 Runtime 白名单内的固定 `program` 和逐项 `args` argv,默认 `confirm`,可执行文件解析为项目外绝对路径且子进程只使用安全 PATH;不解析 shell 字符串,不提供管道、重定向、PTY 或后台进程。它的 action、stdout / stderr、退出码、超时与源码指纹结果统一进入现有 `action / observation`、project revision、verification gate 和 `needs-reconciliation` 链路;只有明确验证型命令且退出码、源码指纹、命令日志、manifest 与 Agent DB 审计全通过才签发 passed gateGit / rg / cargo metadata / 普通 npm run 只作诊断。首版只请求终止受控进程组,安全等级与 `project.verify` 相同,不宣称已具备完整 OS sandbox 或 detached-process 隔离
2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。
以下能力清单保留 Runtime V1 的演进记录;其中“App 进程内 tokio task”“跨进程同项目写入不作为支持目标”和“恢复到当前 App 进程”的旧描述均已由 V1.1 替代。当前边界是 App / CLI 只落账并唤醒同一发布二进制的独立 Runnerappend-only JSONL 使用进程内锁加 OS 文件锁,恢复继续由 Runner 接管同一 run / session。
@@ -73,7 +75,7 @@ Agent Runtime 负责:
- 2026-07-11 调整,2026-07-12 更新:后台单 Agent planning loop 每 6 轮形成一个上下文压缩窗口,每轮最多 3 个工具动作;6 轮是窗口大小,不是单个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口的结束轮次;待确认或重启恢复后按 context bundle 的 `nextLoopIndex` 在同一 run 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续下一窗口,最近 6 轮没有独立进展或相邻窗口指纹重复时才写入 `failed / budget-exhausted``loop-budget-exhausted`,不生成总结伪装完成。这只调整后台单 Agent Runtime;游戏草案 Generator/Evaluator 仍保持独立的 3 轮修复预算。旧摘要中“后台最多 3 轮”或“整个 run 最多 6 轮”的描述不再有效。
- 2026-07-12 补充并冻结:后台 Agent 每个 run 的可恢复 planning 上下文通过临时文件替换原子写入 `.agent/runtime/context-bundles/<agentId>/<runId>.json`,绑定 Agent、Task、Session、Run、任务正文和 revision / verification gate 关联,schema 固定升级为 `game-creator-runtime-context-bundle.v2`;保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation、上一窗口指纹和 `contextStalled`。stale continuation 必须清空旧 actions 与 fallback response,保留 blocker、loop 位置和窗口进度;`contextStalled` 一旦在窗口边界成立,同 run 重规划和进程重启都不得清除。`runtime.verification` 的上下文指纹忽略动态 revision 数值前缀,仅保留稳定处置指引;成功 `project.verify / game.static_smoke` 的动态命令输出不进入窗口指纹。revision 数字或时间戳持续变化本身不算独立进展,重复 stale 最迟在相邻窗口指纹重复时以 `loop-budget-exhausted` 终止。单文件最多 64 KiB、最多 12 条 observation;写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘。读取时要求普通文件并校验 schema、Agent、Session、Run、任务正文、observation 数量和 revision / gate 关联,身份不一致时拒绝续跑。v1 context bundle 恢复必须失败关闭,不自动迁移,也不能把缺失 gate 当成 `requiresVerification=false`;revision 与验证资格仍以锁内重读的独立持久化文件为准,bundle 只保存恢复上下文。该文件属于 Runtime 私有控制面,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。
- 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。
- 2026-07-11 调整:后台 planning 不再复用普通聊天的 1,800 输出 token 上限,而是使用 4,000;最终回复使用 2,400。两类请求均设置 low reasoning effort / low text verbosityOpenAI Responses 序列化为 `reasoning.effort=low`OpenAI Chat Completions 序列化为可选 `reasoning_effort=low`。该设置用于避免推理模型把全部 completion 预算消耗在不可见 reasoning 后留下空 content,并继续叠加最多 3 次 EmptyResponse 重试
- 2026-07-11 调整2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort``agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high``default` 表示不向 Provider 发送推理档位
- 2026-07-11 补充:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求,每次只把限长且经过统一敏感信息过滤的上一次输出作为修复上下文,并把修复尝试写入 `.agent/agent.db``agent.runtime.tool_plan.repair` 审计。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。
- 2026-07-12 补充:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schemaRuntime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 保留文本 JSON 回退,planning 强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 会在本地拒绝无 function tools 的 tool choice 和 Anthropic function tools,并把协议类型写入 `agent.runtime.tool_plan.protocol` 审计。
- 2026-07-11 调整:工具计划四个顶层字段均为必填并拒绝未知顶层字段;thinkingSummary 与 action.tool 必须非空。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。
@@ -17,6 +17,13 @@ import {
describe('AI 游戏创作 App 共享契约', () => {
it('keeps command permissions explicit', () => {
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(52);
expect(commandIds).toContain('command.exec');
expect(commandIds.indexOf('command.exec')).toBe(
commandIds.indexOf('command.run_limited') + 1,
);
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'help.show')
?.permission,
@@ -26,6 +33,11 @@ describe('AI 游戏创作 App 共享契约', () => {
(command) => command.id === 'command.run_limited',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.exec',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.verify',
@@ -162,6 +174,7 @@ describe('AI 游戏创作 App 共享契约', () => {
(capability) => capability.id,
);
expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(32);
expect(capabilityIds).toEqual(
expect.arrayContaining([
'chat',
@@ -186,8 +199,19 @@ describe('AI 游戏创作 App 共享契约', () => {
'canvas-project-sync',
'local-preview',
'developer-window',
'command-exec',
]),
);
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'command-exec',
),
).toEqual({
id: 'command-exec',
area: 'dev-runtime',
title:
'受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'conversation-history',
@@ -54,6 +54,7 @@ export const GAME_CREATION_APP_COMMANDS = [
{ id: 'preview.stop', permission: 'auto' },
{ id: 'preview.status', permission: 'auto' },
{ id: 'command.run_limited', permission: 'confirm' },
{ id: 'command.exec', permission: 'confirm' },
{ id: 'canvas.project_open', permission: 'confirm' },
{ id: 'canvas.project_sync', permission: 'confirm' },
{ id: 'canvas.asset_import', permission: 'confirm' },
@@ -149,6 +150,12 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [
},
{ id: 'developer-window', area: 'dev-runtime', title: '开发窗口' },
{ id: 'persistent-runner', area: 'dev-runtime', title: '独立持久 Runner' },
{
id: 'command-exec',
area: 'dev-runtime',
title:
'受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)',
},
{ id: 'guardrails', area: 'dev-runtime', title: '权限 Gate' },
{ id: 'project-policy', area: 'dev-runtime', title: '项目级权限策略' },
{ id: 'trace-log', area: 'dev-runtime', title: '执行日志' },
@@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 51] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 52] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
@@ -63,6 +63,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 51] = [
command("preview.stop", GameCreationAppPermission::Auto),
command("preview.status", GameCreationAppPermission::Auto),
command("command.run_limited", GameCreationAppPermission::Confirm),
command("command.exec", GameCreationAppPermission::Confirm),
command("canvas.project_open", GameCreationAppPermission::Confirm),
command("canvas.project_sync", GameCreationAppPermission::Confirm),
command("canvas.asset_import", GameCreationAppPermission::Confirm),
@@ -90,7 +91,7 @@ pub struct GameCreationAgentCapabilityDescriptor {
pub title: &'static str,
}
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 31] = [
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 32] = [
capability("chat", "user", "聊天入口"),
capability("file-upload", "user", "上传文件"),
capability("built-in-commands", "agent-runtime", "内置命令调用"),
@@ -135,6 +136,11 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript
capability("canvas-project-sync", "local-runtime", "画板项目资源同步"),
capability("developer-window", "dev-runtime", "开发窗口"),
capability("persistent-runner", "dev-runtime", "独立持久 Runner"),
capability(
"command-exec",
"dev-runtime",
"受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)",
),
capability("guardrails", "dev-runtime", "权限 Gate"),
capability("project-policy", "dev-runtime", "项目级权限策略"),
capability("trace-log", "dev-runtime", "执行日志"),
@@ -631,6 +637,22 @@ mod tests {
#[test]
fn command_contract_keeps_expected_permissions() {
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 52);
let command_ids = GAME_CREATION_APP_COMMANDS
.iter()
.map(|command| command.id)
.collect::<Vec<_>>();
let limited_index = command_ids
.iter()
.position(|command_id| *command_id == "command.run_limited")
.expect("command.run_limited should exist");
let exec_index = command_ids
.iter()
.position(|command_id| *command_id == "command.exec")
.expect("command.exec should exist");
assert_eq!(exec_index, limited_index + 1);
let help = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "help.show")
@@ -643,6 +665,12 @@ mod tests {
.expect("command should exist");
assert_eq!(command.permission, GameCreationAppPermission::Confirm);
let command_exec = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.exec")
.expect("command.exec should exist");
assert_eq!(command_exec.permission, GameCreationAppPermission::Confirm);
let project_verify = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.verify")
@@ -849,6 +877,8 @@ mod tests {
#[test]
fn capabilities_cover_standard_agent_runtime_needs() {
assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 32);
let ids = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.map(|capability| capability.id)
@@ -873,9 +903,19 @@ mod tests {
"canvas-project-sync",
"local-preview",
"developer-window",
"command-exec",
] {
assert!(ids.contains(&expected), "missing {expected}");
}
let command_exec = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "command-exec")
.expect("command-exec capability should exist");
assert_eq!(command_exec.area, "dev-runtime");
assert_eq!(
command_exec.title,
"受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)"
);
assert_eq!(
GAME_CREATION_AGENT_CAPABILITIES
.iter()