完善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');