接入 Codex 节点 Agent 并完善总控交互
新增 Codex app-server 与 CLI 节点执行模式并保留 Provider 回退 加固节点凭据隔离、终态未知回收、进程生命周期与持久恢复边界 修复资源画布等价刷新闪烁 为 Supervisor steer 增加 LLM 回复与条件中断 补齐配置界面、测试和技术文档
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
|
||||
@@ -651,10 +651,12 @@ async function runConfigWizardRegressionChecks() {
|
||||
apiKind: 'openai_responses',
|
||||
},
|
||||
);
|
||||
assert.equal(updatedPrimary.agentMode, 'provider');
|
||||
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
|
||||
const reloadedState =
|
||||
await readGameCreatorWizardConfigState(overlayConfigDir);
|
||||
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
|
||||
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
|
||||
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
|
||||
assert.equal(
|
||||
reloadedState.effectiveConfig.llm.baseUrl,
|
||||
@@ -1160,6 +1162,12 @@ if (defaultAppConfig.llm?.apiKey !== '') {
|
||||
throw new Error('AI game creator shell default llm.apiKey must stay empty');
|
||||
}
|
||||
|
||||
if (defaultAppConfig.agentMode !== 'codex_app_server') {
|
||||
throw new Error(
|
||||
'AI game creator shell default agentMode must be codex_app_server',
|
||||
);
|
||||
}
|
||||
|
||||
const allowedLlmReasoningEfforts = new Set([
|
||||
'default',
|
||||
'low',
|
||||
@@ -1530,7 +1538,6 @@ for (const snippet of [
|
||||
'$verified = $targetItem.GetAccessControl()',
|
||||
'$rules.Count -ne 1',
|
||||
'[System.Security.AccessControl.FileSystemRights]::FullControl',
|
||||
"runChildCapture('powershell.exe'",
|
||||
"'-NoProfile'",
|
||||
"'-Command'",
|
||||
'windowsPrivateAclScript',
|
||||
@@ -1543,6 +1550,11 @@ for (const snippet of [
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
|
||||
throw new Error(
|
||||
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
|
||||
);
|
||||
}
|
||||
if (/\bGet-Acl\b/u.test(configWizardSource)) {
|
||||
throw new Error(
|
||||
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
|
||||
|
||||
@@ -211,6 +211,7 @@ export function buildGameCreatorWizardConfig(existingConfig, llmInput) {
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
agentMode: 'provider',
|
||||
llm: {
|
||||
...previousLlm,
|
||||
apiKey,
|
||||
@@ -290,21 +291,25 @@ export async function secureWindowsGameCreatorPathForCurrentUser(
|
||||
targetPath,
|
||||
{ isDirectory },
|
||||
) {
|
||||
const result = await runChildCapture('powershell.exe', [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
windowsPrivateAclScript,
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
GENARRATIVE_AGC_PRIVATE_PATH: targetPath,
|
||||
GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory),
|
||||
const result = await runChildCapture(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
windowsPrivateAclScript,
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
GENARRATIVE_AGC_PRIVATE_PATH: targetPath,
|
||||
GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
if (result.code !== 0 || result.signal) {
|
||||
const detail = result.stderr.trim() || result.stdout.trim();
|
||||
throw new Error(
|
||||
|
||||
@@ -9,6 +9,8 @@ use std::collections::BTreeSet;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod generation;
|
||||
mod interaction;
|
||||
mod prompt;
|
||||
@@ -18,6 +20,8 @@ mod runtime_driver;
|
||||
mod runtime_protocol;
|
||||
mod runtime_state;
|
||||
mod runtime_tools;
|
||||
use codex_app_server::*;
|
||||
use codex_cli::*;
|
||||
pub(crate) use generation::*;
|
||||
pub(crate) use interaction::*;
|
||||
pub(crate) use prompt::*;
|
||||
@@ -27,3 +31,7 @@ pub(crate) use runtime_driver::*;
|
||||
pub(crate) use runtime_protocol::*;
|
||||
pub(crate) use runtime_state::*;
|
||||
pub(crate) use runtime_tools::*;
|
||||
|
||||
pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> {
|
||||
shutdown_game_creator_codex_app_servers_impl()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume";
|
||||
const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location";
|
||||
const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200;
|
||||
const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction";
|
||||
pub(crate) const AGENT_RUNTIME_STEER_DECISION_TOOL: &str = "runtime_steer_decision";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum AgentInteractionToolKind {
|
||||
@@ -80,6 +81,171 @@ enum AgentInteractionTextEnvelope {
|
||||
ProjectLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct AgentRuntimeSteerDecisionArguments {
|
||||
reply: String,
|
||||
interrupt_current_provider: bool,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
fn agent_runtime_steer_decision_function_tool() -> platform_llm::LlmFunctionTool {
|
||||
platform_llm::LlmFunctionTool::new(
|
||||
AGENT_RUNTIME_STEER_DECISION_TOOL,
|
||||
"回复用户,并判断是否必须中断当前正在进行的 LLM Provider 请求。",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["reply", "interruptCurrentProvider", "reason"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reply": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000
|
||||
},
|
||||
"interruptCurrentProvider": { "type": "boolean" },
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.with_strict(true)
|
||||
}
|
||||
|
||||
fn build_agent_runtime_steer_decision_request(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
instruction: &str,
|
||||
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
|
||||
let (llm, config_path, context) = build_game_creator_role_agent_context_for_session(
|
||||
root,
|
||||
&state.agent_id,
|
||||
Some(&state.session_id),
|
||||
)?;
|
||||
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
||||
let runtime_summary = serde_json::json!({
|
||||
"status": state.status,
|
||||
"phase": state.phase,
|
||||
"currentAction": state.current_action,
|
||||
"waitingOn": state.waiting_on,
|
||||
"nextStep": state.next_step,
|
||||
"plan": state.plan,
|
||||
"appliedSteerCursor": state.applied_steer_cursor,
|
||||
});
|
||||
let system = format!(
|
||||
"{}\n\n你现在是项目总控的非终态对话判定层。一个制作 Run 正在执行,你必须先自然回答用户,再判断是否需要中断当前正在进行的 LLM Provider 请求。不要因为收到新消息就默认中断:状态询问、解释、鼓励、确认以及不冲突的补充应保持 interruptCurrentProvider=false;只有用户明确停止/改向,或新要求会让当前 LLM 继续生成明显过期方案时才设为 true。宿主工具和已经开始的外部动作不会被此判定强杀,它们会在安全边界完成。你的回复不能宣称整个 Run 已完成,也不能把这次回复当作终态。必须调用且只调用 runtime_steer_decision。",
|
||||
game_creator_project_supervisor_chat_system_prompt()
|
||||
);
|
||||
let user = format!(
|
||||
"项目上下文如下,只作为背景,不要逐字复述:\n{context}\n\n当前 Runtime 摘要:\n{}\n\n用户在制作过程中发来的消息:\n{}",
|
||||
serde_json::to_string_pretty(&runtime_summary)
|
||||
.map_err(|error| format!("序列化 steer Runtime 摘要失败:{error}"))?,
|
||||
instruction.trim(),
|
||||
);
|
||||
let request = LlmRunRequest::single_turn(system, user)
|
||||
.with_api_kind(api_kind)
|
||||
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
|
||||
.with_function_tools(vec![agent_runtime_steer_decision_function_tool()])
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||
let request = apply_game_creator_llm_reasoning_effort(request, &llm)?;
|
||||
Ok((llm, config_path, request))
|
||||
}
|
||||
|
||||
fn parse_agent_runtime_steer_decision_response(
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
) -> Result<AgentRuntimeSteerDecision, String> {
|
||||
if response.tool_calls.len() != 1 {
|
||||
return Err("Supervisor steer decision 必须返回一个 runtime_steer_decision".to_string());
|
||||
}
|
||||
let call = &response.tool_calls[0];
|
||||
if call.name != AGENT_RUNTIME_STEER_DECISION_TOOL {
|
||||
return Err(format!(
|
||||
"Supervisor steer decision 返回未知工具:{}",
|
||||
call.name
|
||||
));
|
||||
}
|
||||
let arguments = serde_json::from_str::<AgentRuntimeSteerDecisionArguments>(&call.arguments)
|
||||
.map_err(|error| format!("runtime_steer_decision 参数无效:{error}"))?;
|
||||
let reply = arguments.reply.trim();
|
||||
let reason = arguments.reason.trim();
|
||||
if reply.is_empty() || reason.is_empty() {
|
||||
return Err("runtime_steer_decision reply/reason 不能为空".to_string());
|
||||
}
|
||||
Ok(AgentRuntimeSteerDecision {
|
||||
reply: reply.to_string(),
|
||||
interrupt_current_provider: arguments.interrupt_current_provider,
|
||||
reason: reason.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn decide_game_creator_agent_runtime_steer_at(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
steer_id: &str,
|
||||
sequence: u64,
|
||||
instruction: &str,
|
||||
) -> Result<AgentRuntimeSteerDecision, String> {
|
||||
if let Some(decision) = read_game_creator_agent_runtime_steer_decision_at(
|
||||
root,
|
||||
&state.agent_id,
|
||||
&state.run_id,
|
||||
steer_id,
|
||||
)? {
|
||||
return Ok(decision);
|
||||
}
|
||||
if state.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| state.parent_agent_id.is_some()
|
||||
|| state.parent_run_id.is_some()
|
||||
{
|
||||
return Err("只有根 Project Supervisor 支持运行中非终态回复".to_string());
|
||||
}
|
||||
let (llm, config_path, request) =
|
||||
build_agent_runtime_steer_decision_request(root, state, instruction)?;
|
||||
let decision_snapshot = {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.steer_decision.snapshot",
|
||||
)?;
|
||||
let mut snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
|
||||
root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
&state.run_id,
|
||||
"steer-decision",
|
||||
&format!("steer-{sequence}"),
|
||||
state.applied_steer_cursor,
|
||||
)?;
|
||||
// The main Runtime Provider request must keep running while this
|
||||
// independent LLM turn decides whether it is stale. A distinct node
|
||||
// identity gives Codex app-server a separate process/thread gate.
|
||||
snapshot.agent_id = format!("{}-steer-decision", state.agent_id);
|
||||
snapshot.task_id = snapshot.agent_id.clone();
|
||||
snapshot
|
||||
};
|
||||
let agent_mode =
|
||||
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
|
||||
let response = match agent_mode.as_str() {
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
||||
request_game_creator_agent_codex_app_server(&decision_snapshot, &llm, request).await
|
||||
}
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_CLI => request_game_creator_agent_codex_cli(request).await,
|
||||
GAME_CREATOR_AGENT_MODE_PROVIDER => {
|
||||
build_game_creator_agent_runtime_llm_client(&llm, &config_path)?
|
||||
.run(request)
|
||||
.await
|
||||
}
|
||||
_ => unreachable!("agent mode is normalized"),
|
||||
}
|
||||
.map_err(|error| format!("Supervisor steer decision 调用 LLM 失败:{error}"))?;
|
||||
let decision = parse_agent_runtime_steer_decision_response(&response)?;
|
||||
persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||||
root, state, steer_id, sequence, decision,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool {
|
||||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
return true;
|
||||
@@ -407,6 +573,40 @@ mod tests {
|
||||
assert!(error.contains("最多只能选择一个"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steer_decision_replies_without_interrupting_status_questions() {
|
||||
let decision = parse_agent_runtime_steer_decision_response(&response(
|
||||
"",
|
||||
vec![tool_call(
|
||||
AGENT_RUNTIME_STEER_DECISION_TOOL,
|
||||
r#"{"reply":"我正在完成玩法实现,当前任务会继续。","interruptCurrentProvider":false,"reason":"这是状态询问,不会使当前方案过期。"}"#,
|
||||
)],
|
||||
))
|
||||
.expect("parse non-interrupting steer decision");
|
||||
assert_eq!(
|
||||
decision,
|
||||
AgentRuntimeSteerDecision {
|
||||
reply: "我正在完成玩法实现,当前任务会继续。".to_string(),
|
||||
interrupt_current_provider: false,
|
||||
reason: "这是状态询问,不会使当前方案过期。".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steer_decision_can_request_safe_interrupt_for_conflicting_change() {
|
||||
let decision = parse_agent_runtime_steer_decision_response(&response(
|
||||
"",
|
||||
vec![tool_call(
|
||||
AGENT_RUNTIME_STEER_DECISION_TOOL,
|
||||
r#"{"reply":"明白,我会停止旧方向并改成回合制。","interruptCurrentProvider":true,"reason":"用户明确改向,旧 Provider 仍在生成过期方案。"}"#,
|
||||
)],
|
||||
))
|
||||
.expect("parse interrupting steer decision");
|
||||
assert!(decision.interrupt_current_provider);
|
||||
assert!(decision.reply.contains("改成回合制"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_registry_derives_unique_strict_function_tools() {
|
||||
let registry = agent_interaction_tool_registry().expect("interaction registry");
|
||||
|
||||
+68
-1
@@ -135,6 +135,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
|
||||
let stream_response = llm.stream;
|
||||
let request_stream_snapshot = stream_snapshot.clone();
|
||||
let response_observations = observations;
|
||||
let agent_mode =
|
||||
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
|
||||
let llm_for_request = llm.clone();
|
||||
let snapshot_for_request = provider_snapshot.clone();
|
||||
let config_path_for_request = config_path.clone();
|
||||
let response_result =
|
||||
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
|
||||
root,
|
||||
@@ -143,9 +148,71 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
|
||||
&config_path,
|
||||
"后台 Agent 最终回复",
|
||||
&request,
|
||||
move |client, attempt_request| {
|
||||
agent_mode.clone(),
|
||||
move |attempt_request| {
|
||||
let stream_snapshot = request_stream_snapshot.clone();
|
||||
async move {
|
||||
if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||||
if stream_response {
|
||||
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
|
||||
root,
|
||||
&stream_snapshot,
|
||||
response_revision,
|
||||
);
|
||||
return match stream_game_creator_agent_codex_app_server(
|
||||
&snapshot_for_request,
|
||||
&llm_for_request,
|
||||
attempt_request,
|
||||
|delta| {
|
||||
if !suppress_private_process_output {
|
||||
publisher.push(delta);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
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();
|
||||
Err(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
return normalize_game_creator_agent_background_final_reply_response(
|
||||
request_game_creator_agent_codex_app_server(
|
||||
&snapshot_for_request,
|
||||
&llm_for_request,
|
||||
attempt_request,
|
||||
)
|
||||
.await?,
|
||||
response_observations,
|
||||
);
|
||||
}
|
||||
if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI {
|
||||
return normalize_game_creator_agent_background_final_reply_response(
|
||||
request_game_creator_agent_codex_cli(attempt_request).await?,
|
||||
response_observations,
|
||||
);
|
||||
}
|
||||
let client = build_game_creator_agent_runtime_llm_client(
|
||||
&llm_for_request,
|
||||
&config_path_for_request,
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
if stream_response {
|
||||
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
|
||||
root,
|
||||
|
||||
+24
-6
@@ -1008,7 +1008,10 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
"agentLlm.design-director",
|
||||
"测试 Provider handoff 身份漂移",
|
||||
&request,
|
||||
|_client, _request| async {
|
||||
load_game_creator_app_config()
|
||||
.expect("load test agent mode")
|
||||
.agent_mode,
|
||||
|_request| async {
|
||||
Err(platform_llm::LlmError::Transport(
|
||||
"identity drift must not call Provider".to_string(),
|
||||
))
|
||||
@@ -1135,7 +1138,10 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
"agentLlm.project-supervisor",
|
||||
"测试 tool-plan handoff 身份漂移",
|
||||
&base_request,
|
||||
|_client, _request| async {
|
||||
load_game_creator_app_config()
|
||||
.expect("load test agent mode")
|
||||
.agent_mode,
|
||||
|_request| async {
|
||||
Err(platform_llm::LlmError::Transport(
|
||||
"identity drift must not call Provider".to_string(),
|
||||
))
|
||||
@@ -1214,7 +1220,10 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
||||
"agentLlm.project-supervisor",
|
||||
"测试通用 retry 身份漂移",
|
||||
&request,
|
||||
|_client, _request| async {
|
||||
load_game_creator_app_config()
|
||||
.expect("load test agent mode")
|
||||
.agent_mode,
|
||||
|_request| async {
|
||||
Err(platform_llm::LlmError::Transport(
|
||||
"generic retry drift must not call Provider".to_string(),
|
||||
))
|
||||
@@ -1326,7 +1335,10 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
"agentLlm.project-supervisor",
|
||||
"测试 tool-plan 请求前容量门禁",
|
||||
&request,
|
||||
move |_client, _request| async move {
|
||||
load_game_creator_app_config()
|
||||
.expect("load test agent mode")
|
||||
.agent_mode,
|
||||
move |_request| async move {
|
||||
provider_called_for_request.store(true, Ordering::Release);
|
||||
Err(platform_llm::LlmError::Transport(
|
||||
"capacity gate must run before Provider".to_string(),
|
||||
@@ -1458,7 +1470,10 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
"agentLlm.project-supervisor",
|
||||
"测试 durable control 清理 tool-plan handoff",
|
||||
&base_request,
|
||||
|_client, _request| async {
|
||||
load_game_creator_app_config()
|
||||
.expect("load test agent mode")
|
||||
.agent_mode,
|
||||
|_request| async {
|
||||
Err(platform_llm::LlmError::Transport(
|
||||
"durable control must not call Provider".to_string(),
|
||||
))
|
||||
@@ -1723,7 +1738,10 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
"agentLlm.design-director",
|
||||
"测试 Provider recovery 冲突",
|
||||
&request,
|
||||
|_client, _request| async {
|
||||
load_game_creator_app_config()
|
||||
.expect("load test agent mode")
|
||||
.agent_mode,
|
||||
|_request| async {
|
||||
Err(platform_llm::LlmError::Transport(
|
||||
"reconciliation must not call Provider".to_string(),
|
||||
))
|
||||
|
||||
@@ -83,14 +83,20 @@ pub(crate) use run_configuration::{
|
||||
};
|
||||
pub(crate) use steering::{
|
||||
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
|
||||
append_game_creator_agent_runtime_steer_decision_failure_reply_at,
|
||||
consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer,
|
||||
game_creator_agent_runtime_provider_request_count_for_roots,
|
||||
game_creator_agent_runtime_steer_ledger_path,
|
||||
interrupt_game_creator_agent_runtime_provider_for_decided_steer_at,
|
||||
interrupt_game_creator_agent_runtime_provider_request_at,
|
||||
interrupt_game_creator_agent_runtime_provider_requests_for_roots,
|
||||
persist_game_creator_agent_runtime_steer_decision_and_reply_at,
|
||||
read_game_creator_agent_runtime_steer_decision_at,
|
||||
register_game_creator_agent_runtime_provider_request,
|
||||
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,
|
||||
unregister_game_creator_agent_runtime_provider_request,
|
||||
validate_game_creator_agent_runtime_steer_notification_at, AgentRuntimeSteerDecision,
|
||||
};
|
||||
pub(crate) use verification::{
|
||||
game_creator_agent_runtime_verification_gate_path,
|
||||
|
||||
@@ -480,9 +480,10 @@ pub(in crate::agent) struct AgentRuntimeSteerLedgerSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::agent) struct AgentRuntimeProviderInterrupt {
|
||||
pub(in crate::agent) interrupted: AtomicBool,
|
||||
pub(in crate::agent) notify: tokio::sync::Notify,
|
||||
pub(crate) struct AgentRuntimeProviderInterrupt {
|
||||
pub(crate) applied_steer_cursor: u64,
|
||||
pub(crate) interrupted: AtomicBool,
|
||||
pub(crate) notify: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
pub(in crate::agent) static GAME_CREATOR_AGENT_PROVIDER_INTERRUPTS: OnceLock<
|
||||
|
||||
+233
-19
@@ -71,6 +71,15 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_error_with_transient
|
||||
operation: &str,
|
||||
retry_autonomous_upstream_400: bool,
|
||||
) -> String {
|
||||
if matches!(
|
||||
error,
|
||||
platform_llm::LlmError::Transport(message)
|
||||
if message.starts_with(GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX)
|
||||
) {
|
||||
return format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: Codex app-server turn 终态未知"
|
||||
);
|
||||
}
|
||||
let public_error = format!(
|
||||
"{config_path} {operation}调用 LLM 失败:{}",
|
||||
game_creator_agent_llm_error_public_summary(error)
|
||||
@@ -284,7 +293,76 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint(
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_provider_config_fingerprint(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
) -> Result<String, String> {
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
let agent_mode = normalize_game_creator_agent_mode(&app_config.agent_mode)?;
|
||||
let codex_cli_version = if matches!(
|
||||
agent_mode.as_str(),
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
|
||||
) {
|
||||
Some(game_creator_codex_cli_version_identity()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
&agent_mode,
|
||||
codex_cli_version.as_deref(),
|
||||
llm,
|
||||
)
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
agent_mode: &str,
|
||||
codex_cli_version: Option<&str>,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
) -> Result<String, String> {
|
||||
let agent_mode = normalize_game_creator_agent_mode(agent_mode)?;
|
||||
let codex_cli = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI {
|
||||
Some(serde_json::json!({
|
||||
"executable": "codex",
|
||||
"version": codex_cli_version.ok_or_else(|| {
|
||||
"Codex CLI 模式缺少 CLI 版本身份".to_string()
|
||||
})?,
|
||||
"protocol": "genarrative-codex-cli-agent.v1",
|
||||
"sandbox": "read-only",
|
||||
"shellTool": false,
|
||||
"ephemeral": true,
|
||||
"ignoreUserConfig": true,
|
||||
"ignoreRules": true,
|
||||
"approvalPolicy": "never",
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let codex_app_server = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||||
Some(serde_json::json!({
|
||||
"executable": "codex",
|
||||
"version": codex_cli_version.ok_or_else(|| {
|
||||
"Codex app-server 模式缺少 CLI 版本身份".to_string()
|
||||
})?,
|
||||
"protocol": game_creator_codex_app_server_protocol_identity(),
|
||||
"providerRoute": if llm.api_key.trim().is_empty() {
|
||||
"codex-login"
|
||||
} else {
|
||||
"agc-openai-responses"
|
||||
},
|
||||
"sandbox": "read-only",
|
||||
"networkAccess": false,
|
||||
"shellTool": false,
|
||||
"webSearch": false,
|
||||
"multiAgent": false,
|
||||
"isolatedOsHome": true,
|
||||
"mcpServers": false,
|
||||
"ephemeralThread": true,
|
||||
"approvalPolicy": "never",
|
||||
"outputSchema": "per-turn",
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let stable = serde_json::json!({
|
||||
"agentMode": agent_mode,
|
||||
"codexAppServer": codex_app_server,
|
||||
"codexCli": codex_cli,
|
||||
"apiKeySha256": format!("{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
||||
"baseUrl": llm.base_url,
|
||||
"model": llm.model,
|
||||
@@ -306,6 +384,30 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
request: &LlmRunRequest,
|
||||
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
game_creator_agent_runtime_provider_retry_identity_for_mode(
|
||||
snapshot,
|
||||
llm,
|
||||
request,
|
||||
&app_config.agent_mode,
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_mode(
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
request: &LlmRunRequest,
|
||||
agent_mode: &str,
|
||||
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
|
||||
let agent_mode = normalize_game_creator_agent_mode(agent_mode)?;
|
||||
let codex_cli_version = if matches!(
|
||||
agent_mode.as_str(),
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
|
||||
) {
|
||||
Some(game_creator_codex_cli_version_identity()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AgentRuntimeProviderRetryIdentity {
|
||||
project_id: snapshot.project_id.clone(),
|
||||
agent_id: snapshot.agent_id.clone(),
|
||||
@@ -320,7 +422,12 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity(
|
||||
request_kind: snapshot.request_kind.clone(),
|
||||
base_request_slot: snapshot.request_slot.clone(),
|
||||
request_fingerprint: game_creator_agent_runtime_llm_request_fingerprint(request)?,
|
||||
provider_config_fingerprint: game_creator_agent_runtime_provider_config_fingerprint(llm)?,
|
||||
provider_config_fingerprint:
|
||||
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
&agent_mode,
|
||||
codex_cli_version.as_deref(),
|
||||
llm,
|
||||
)?,
|
||||
web_search_enabled: snapshot.web_search_enabled,
|
||||
allow_idle_context_compaction: snapshot.allow_idle_context_compaction,
|
||||
})
|
||||
@@ -566,11 +673,12 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
|
||||
config_path: &str,
|
||||
operation: &str,
|
||||
request: &LlmRunRequest,
|
||||
agent_mode: String,
|
||||
execute: F,
|
||||
canonicalize_handoff_response: H,
|
||||
) -> Result<AgentRuntimePersistedProviderRequestOutcome, String>
|
||||
where
|
||||
F: FnOnce(LlmClient, LlmRunRequest) -> Fut,
|
||||
F: FnOnce(LlmRunRequest) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
|
||||
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
|
||||
{
|
||||
@@ -582,8 +690,12 @@ where
|
||||
)?;
|
||||
let retry_autonomous_upstream_400 =
|
||||
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
|
||||
let identity =
|
||||
game_creator_agent_runtime_provider_retry_identity(provider_snapshot, llm, request)?;
|
||||
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
|
||||
provider_snapshot,
|
||||
llm,
|
||||
request,
|
||||
&agent_mode,
|
||||
)?;
|
||||
let tool_plan_handoff_missing = if identity.request_kind == "tool-plan" {
|
||||
match tool_plan_handoff::lookup_at(
|
||||
root,
|
||||
@@ -1008,19 +1120,8 @@ where
|
||||
.map_err(|error| redact_agent_runtime_error(root, &error, 500))?;
|
||||
}
|
||||
let attempt_snapshot = provider_snapshot.with_request_slot(request_slot);
|
||||
let client = match build_game_creator_agent_runtime_llm_client(llm, config_path) {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
crate::provider_retry::remove_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
&provider_snapshot.run_id,
|
||||
)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let provider_request = async {
|
||||
execute(client, request.clone()).await.map_err(|error| {
|
||||
execute(request.clone()).await.map_err(|error| {
|
||||
game_creator_agent_runtime_provider_error_with_transient_kind(
|
||||
&error,
|
||||
config_path,
|
||||
@@ -1293,6 +1394,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
|
||||
operation: &str,
|
||||
request: &LlmRunRequest,
|
||||
) -> Result<AgentRuntimePersistedProviderRequestOutcome, String> {
|
||||
let agent_mode =
|
||||
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
|
||||
let llm_for_request = llm.clone();
|
||||
let snapshot_for_request = provider_snapshot.clone();
|
||||
let config_path_for_request = config_path.to_string();
|
||||
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
|
||||
root,
|
||||
provider_snapshot,
|
||||
@@ -1300,7 +1406,31 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
|
||||
config_path,
|
||||
operation,
|
||||
request,
|
||||
|client, request| async move { client.run(request).await },
|
||||
agent_mode.clone(),
|
||||
move |request| async move {
|
||||
match agent_mode.as_str() {
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
||||
request_game_creator_agent_codex_app_server(
|
||||
&snapshot_for_request,
|
||||
&llm_for_request,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
|
||||
request_game_creator_agent_codex_cli(request).await
|
||||
}
|
||||
GAME_CREATOR_AGENT_MODE_PROVIDER => {
|
||||
let client = build_game_creator_agent_runtime_llm_client(
|
||||
&llm_for_request,
|
||||
&config_path_for_request,
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
client.run(request).await
|
||||
}
|
||||
_ => unreachable!("agent mode is normalized"),
|
||||
}
|
||||
},
|
||||
|response| response.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -1329,9 +1459,33 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
||||
format!("{}-transient-{attempt}", provider_snapshot.request_slot)
|
||||
};
|
||||
let attempt_snapshot = provider_snapshot.with_request_slot(request_slot.clone());
|
||||
let client = build_game_creator_agent_runtime_llm_client(llm, config_path)?;
|
||||
let agent_mode =
|
||||
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
|
||||
let client = (agent_mode == GAME_CREATOR_AGENT_MODE_PROVIDER)
|
||||
.then(|| build_game_creator_agent_runtime_llm_client(llm, config_path))
|
||||
.transpose()?;
|
||||
let provider_request = async {
|
||||
client.run(request.clone()).await.map_err(|error| {
|
||||
let response = match agent_mode.as_str() {
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
||||
request_game_creator_agent_codex_app_server(
|
||||
&attempt_snapshot,
|
||||
llm,
|
||||
request.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
|
||||
request_game_creator_agent_codex_cli(request.clone()).await
|
||||
}
|
||||
GAME_CREATOR_AGENT_MODE_PROVIDER => {
|
||||
client
|
||||
.expect("provider mode constructs an HTTP client")
|
||||
.run(request.clone())
|
||||
.await
|
||||
}
|
||||
_ => unreachable!("agent mode is normalized"),
|
||||
};
|
||||
response.map_err(|error| {
|
||||
game_creator_agent_runtime_provider_error_with_transient_kind(
|
||||
&error,
|
||||
config_path,
|
||||
@@ -1471,6 +1625,66 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_config_fingerprint_separates_all_agent_modes() {
|
||||
let llm = GameCreatorLlmConfig::default();
|
||||
let app_server = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
|
||||
Some("codex-cli test-version"),
|
||||
&llm,
|
||||
)
|
||||
.expect("Codex app-server config fingerprint");
|
||||
let codex = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
|
||||
Some("codex-cli test-version"),
|
||||
&llm,
|
||||
)
|
||||
.expect("Codex CLI config fingerprint");
|
||||
let provider = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
GAME_CREATOR_AGENT_MODE_PROVIDER,
|
||||
None,
|
||||
&llm,
|
||||
)
|
||||
.expect("Provider config fingerprint");
|
||||
assert_ne!(app_server, codex);
|
||||
assert_ne!(app_server, provider);
|
||||
assert_ne!(codex, provider);
|
||||
assert_ne!(
|
||||
app_server,
|
||||
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
|
||||
Some("codex-cli changed-version"),
|
||||
&llm,
|
||||
)
|
||||
.expect("changed Codex app-server config fingerprint")
|
||||
);
|
||||
assert_ne!(
|
||||
codex,
|
||||
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
|
||||
Some("codex-cli changed-version"),
|
||||
&llm,
|
||||
)
|
||||
.expect("changed Codex CLI config fingerprint")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() {
|
||||
let error = platform_llm::LlmError::Transport(format!(
|
||||
"{GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX} eof"
|
||||
));
|
||||
let encoded = game_creator_agent_runtime_provider_error_with_transient_kind(
|
||||
&error,
|
||||
"agentLlm.project-supervisor",
|
||||
"规划",
|
||||
false,
|
||||
);
|
||||
assert!(encoded.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX));
|
||||
assert!(!encoded.starts_with(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX));
|
||||
assert!(!encoded.contains("eof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_retry_error_classification_accepts_durable_failures_and_rejects_client_faults() {
|
||||
let retryable = vec![
|
||||
|
||||
+20
@@ -1075,6 +1075,7 @@ where
|
||||
root,
|
||||
&snapshot.agent_id,
|
||||
&snapshot.run_id,
|
||||
snapshot.applied_steer_cursor,
|
||||
)
|
||||
.map_err(|error| redact_agent_runtime_error(root, &error, 500))?;
|
||||
before_control_recheck();
|
||||
@@ -1246,6 +1247,25 @@ where
|
||||
}
|
||||
}
|
||||
unregister_game_creator_agent_runtime_provider_request(&key, &active);
|
||||
if result
|
||||
.as_ref()
|
||||
.is_err_and(|error| error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX))
|
||||
{
|
||||
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.provider_request.unknown_terminal_reconciliation",
|
||||
);
|
||||
if control_lock.is_ok() {
|
||||
let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
|
||||
root,
|
||||
&snapshot,
|
||||
&request_id,
|
||||
);
|
||||
}
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · Codex app-server turn 终态未知"
|
||||
));
|
||||
}
|
||||
let status = match &result {
|
||||
Ok(Some(_)) => "completed",
|
||||
Ok(None) => "interrupted",
|
||||
|
||||
@@ -100,6 +100,8 @@ pub(in crate::agent) fn normalize_agent_runtime_steer_id(steer_id: &str) -> Resu
|
||||
Ok(steer_id.to_string())
|
||||
}
|
||||
|
||||
pub(in crate::agent) const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX: &str = "agent-steer-";
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_steer_message_id(
|
||||
project_id: &str,
|
||||
agent_id: &str,
|
||||
@@ -112,7 +114,7 @@ pub(in crate::agent) fn agent_runtime_steer_message_id(
|
||||
format!("{project_id}\n{agent_id}\n{task_id}\n{session_id}\n{run_id}\n{steer_id}");
|
||||
let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes()));
|
||||
format!(
|
||||
"agent-steer-{}",
|
||||
"{AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX}{}",
|
||||
fingerprint.chars().take(32).collect::<String>()
|
||||
)
|
||||
}
|
||||
@@ -429,6 +431,202 @@ pub(in crate::agent) fn ensure_game_creator_agent_runtime_steer_audit(
|
||||
)
|
||||
}
|
||||
|
||||
const AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE: &str = "agent.runtime.steer_decision";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct AgentRuntimeSteerDecision {
|
||||
pub(crate) reply: String,
|
||||
pub(crate) interrupt_current_provider: bool,
|
||||
pub(crate) reason: String,
|
||||
}
|
||||
|
||||
fn parse_game_creator_agent_runtime_steer_decision_record(
|
||||
record: &serde_json::Value,
|
||||
) -> Result<AgentRuntimeSteerDecision, String> {
|
||||
let reply = record
|
||||
.get("reply")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "Supervisor steer decision 缺少 reply".to_string())?;
|
||||
let reply_sha256 = format!("{:x}", Sha256::digest(reply.as_bytes()));
|
||||
if record
|
||||
.get("replySha256")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
!= Some(reply_sha256.as_str())
|
||||
{
|
||||
return Err("Supervisor steer decision reply 指纹冲突".to_string());
|
||||
}
|
||||
let interrupt_current_provider = record
|
||||
.get("interruptCurrentProvider")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.ok_or_else(|| "Supervisor steer decision 缺少 interruptCurrentProvider".to_string())?;
|
||||
let reason = record
|
||||
.get("reason")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
Ok(AgentRuntimeSteerDecision {
|
||||
reply: reply.to_string(),
|
||||
interrupt_current_provider,
|
||||
reason: reason.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_game_creator_agent_runtime_steer_decision_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
steer_id: &str,
|
||||
) -> Result<Option<AgentRuntimeSteerDecision>, String> {
|
||||
let (records, _) = read_agent_db_records_bounded(root, 16 * 1024 * 1024)?;
|
||||
let mut decision = None;
|
||||
for record in records.iter().filter(|record| {
|
||||
record.get("recordType").and_then(serde_json::Value::as_str)
|
||||
== Some(AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE)
|
||||
&& record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id)
|
||||
&& record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id)
|
||||
&& record.get("steerId").and_then(serde_json::Value::as_str) == Some(steer_id)
|
||||
}) {
|
||||
let parsed = parse_game_creator_agent_runtime_steer_decision_record(record)?;
|
||||
if decision
|
||||
.as_ref()
|
||||
.is_some_and(|existing| existing != &parsed)
|
||||
{
|
||||
return Err("Supervisor steer decision 出现冲突终态".to_string());
|
||||
}
|
||||
decision = Some(parsed);
|
||||
}
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
pub(crate) fn persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
steer_id: &str,
|
||||
sequence: u64,
|
||||
decision: AgentRuntimeSteerDecision,
|
||||
) -> Result<AgentRuntimeSteerDecision, String> {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.steer_decision.persist",
|
||||
)?;
|
||||
let snapshot =
|
||||
read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?;
|
||||
let entry = snapshot
|
||||
.entries
|
||||
.get(steer_id)
|
||||
.ok_or_else(|| "Supervisor steer decision 未命中持久 steer".to_string())?;
|
||||
if entry.identity.sequence != sequence
|
||||
|| entry.identity.session_id != state.session_id
|
||||
|| entry.identity.source != state.source
|
||||
{
|
||||
return Err("Supervisor steer decision 与持久 steer 身份冲突".to_string());
|
||||
}
|
||||
let reply = sanitize_agent_runtime_text(decision.reply.trim(), 2_000);
|
||||
if reply.is_empty() {
|
||||
return Err("Supervisor steer decision reply 不能为空".to_string());
|
||||
}
|
||||
let reason = redact_agent_runtime_project_paths(
|
||||
root,
|
||||
&sanitize_agent_runtime_text(decision.reason.trim(), 500),
|
||||
500,
|
||||
);
|
||||
let decision = AgentRuntimeSteerDecision {
|
||||
reply,
|
||||
interrupt_current_provider: decision.interrupt_current_provider,
|
||||
reason,
|
||||
};
|
||||
let persisted = if let Some(existing) = read_game_creator_agent_runtime_steer_decision_at(
|
||||
root,
|
||||
&state.agent_id,
|
||||
&state.run_id,
|
||||
steer_id,
|
||||
)? {
|
||||
existing
|
||||
} else {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE,
|
||||
"agentId": state.agent_id,
|
||||
"taskId": state.task_id,
|
||||
"sessionId": state.session_id,
|
||||
"runId": state.run_id,
|
||||
"source": state.source,
|
||||
"steerId": steer_id,
|
||||
"sequence": sequence,
|
||||
"messageId": entry.identity.message_id,
|
||||
"instructionSha256": entry.identity.instruction_sha256,
|
||||
"reply": decision.reply,
|
||||
"replySha256": format!("{:x}", Sha256::digest(decision.reply.as_bytes())),
|
||||
"interruptCurrentProvider": decision.interrupt_current_provider,
|
||||
"reason": decision.reason,
|
||||
"decidedAt": unix_timestamp(),
|
||||
}),
|
||||
)?;
|
||||
decision
|
||||
};
|
||||
let message_id = entry.identity.message_id.as_deref().unwrap_or_default();
|
||||
let correlation_id = message_id
|
||||
.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)
|
||||
.ok_or_else(|| "Supervisor steer decision 缺少消息关联身份".to_string())?;
|
||||
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
|
||||
root,
|
||||
correlation_id,
|
||||
"steer-reply",
|
||||
&persisted.reply,
|
||||
)?;
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
pub(crate) fn append_game_creator_agent_runtime_steer_decision_failure_reply_at(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
steer_id: &str,
|
||||
error: &str,
|
||||
) -> Result<String, String> {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.steer_decision.failure",
|
||||
)?;
|
||||
let snapshot =
|
||||
read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?;
|
||||
let entry = snapshot
|
||||
.entries
|
||||
.get(steer_id)
|
||||
.ok_or_else(|| "Supervisor steer decision 失败记录未命中持久 steer".to_string())?;
|
||||
let correlation_id = entry
|
||||
.identity
|
||||
.message_id
|
||||
.as_deref()
|
||||
.and_then(|message_id| message_id.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX))
|
||||
.ok_or_else(|| "Supervisor steer decision 失败记录缺少消息关联身份".to_string())?;
|
||||
let reply = "我会继续当前任务。这次没有完成中断判断,你的消息仍会在下一个安全边界进入规划。";
|
||||
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
|
||||
root,
|
||||
correlation_id,
|
||||
"steer-decision-failed",
|
||||
reply,
|
||||
)?;
|
||||
let safe_error = redact_agent_runtime_error(root, error, 500);
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.steer_decision.failed",
|
||||
"agentId": state.agent_id,
|
||||
"taskId": state.task_id,
|
||||
"sessionId": state.session_id,
|
||||
"runId": state.run_id,
|
||||
"source": state.source,
|
||||
"steerId": steer_id,
|
||||
"sequence": entry.identity.sequence,
|
||||
"errorSha256": format!("{:x}", Sha256::digest(safe_error.as_bytes())),
|
||||
"errorChars": safe_error.chars().count(),
|
||||
"failedAt": unix_timestamp(),
|
||||
}),
|
||||
)?;
|
||||
Ok(reply.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_accepts_steer(state: &AgentRuntimeState) -> bool {
|
||||
!state.agent_id.starts_with("child-")
|
||||
&& state.source != AGENT_RUNTIME_ISOLATED_CHILD_SOURCE
|
||||
@@ -658,6 +856,20 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
|
||||
latest_status = status.to_string();
|
||||
}
|
||||
}
|
||||
if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
&& state.parent_agent_id.is_none()
|
||||
&& state.parent_run_id.is_none()
|
||||
{
|
||||
let correlation_id = message_id
|
||||
.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)
|
||||
.ok_or_else(|| "运行中追加指令消息缺少公开状态关联身份".to_string())?;
|
||||
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
|
||||
root,
|
||||
correlation_id,
|
||||
"steer-queued",
|
||||
"收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。",
|
||||
)?;
|
||||
}
|
||||
let was_waiting_for_provider_retry = state.phase == "waiting-for-provider-retry";
|
||||
if was_waiting_for_provider_retry {
|
||||
remove_game_creator_agent_runtime_provider_recovery_at(root, &agent_id, run_id)?;
|
||||
@@ -682,8 +894,7 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
|
||||
Some(&format!("steerId={steer_id}")),
|
||||
)?;
|
||||
}
|
||||
let provider_interrupted =
|
||||
interrupt_game_creator_agent_runtime_provider_request_at(root, &agent_id, run_id)?;
|
||||
let provider_interrupted = false;
|
||||
ensure_game_creator_agent_runtime_steer_audit(
|
||||
root,
|
||||
&state,
|
||||
@@ -722,6 +933,9 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
|
||||
sequence: entry.identity.sequence,
|
||||
status: latest_status,
|
||||
provider_interrupted,
|
||||
assistant_reply: None,
|
||||
interrupt_decision: None,
|
||||
decision_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -772,6 +986,44 @@ pub(crate) fn interrupt_game_creator_agent_runtime_provider_request_at(
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
steer_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
let decision =
|
||||
read_game_creator_agent_runtime_steer_decision_at(root, agent_id, run_id, steer_id)?
|
||||
.ok_or_else(|| "Supervisor steer 尚无 LLM 中断判定".to_string())?;
|
||||
if !decision.interrupt_current_provider {
|
||||
return Ok(false);
|
||||
}
|
||||
let snapshot = read_game_creator_agent_runtime_steer_ledger(root, agent_id, run_id)?;
|
||||
let entry = snapshot
|
||||
.entries
|
||||
.get(steer_id)
|
||||
.ok_or_else(|| "LLM 中断判定未命中持久 steer".to_string())?;
|
||||
let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?;
|
||||
let active = {
|
||||
let registry = game_creator_agent_provider_interrupts()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
registry.get(&key).cloned()
|
||||
};
|
||||
let Some(active) = active else {
|
||||
return Ok(false);
|
||||
};
|
||||
if active.applied_steer_cursor >= entry.identity.sequence {
|
||||
// The provider request was created after this steer was already
|
||||
// applied. Interrupting it would cancel the new plan, not the stale
|
||||
// request the LLM evaluated.
|
||||
return Ok(false);
|
||||
}
|
||||
let first = !active.interrupted.swap(true, Ordering::AcqRel);
|
||||
active.notify.notify_one();
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt_game_creator_agent_runtime_provider_requests_for_roots(
|
||||
roots: &[PathBuf],
|
||||
) -> usize {
|
||||
@@ -833,13 +1085,17 @@ pub(crate) fn validate_game_creator_agent_runtime_steer_notification_at(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request(
|
||||
pub(crate) fn register_game_creator_agent_runtime_provider_request(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
applied_steer_cursor: u64,
|
||||
) -> Result<(String, Arc<AgentRuntimeProviderInterrupt>), String> {
|
||||
let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?;
|
||||
let active = Arc::new(AgentRuntimeProviderInterrupt::default());
|
||||
let active = Arc::new(AgentRuntimeProviderInterrupt {
|
||||
applied_steer_cursor,
|
||||
..AgentRuntimeProviderInterrupt::default()
|
||||
});
|
||||
let mut registry = game_creator_agent_provider_interrupts()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
@@ -849,7 +1105,7 @@ pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request(
|
||||
Ok((key, active))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn unregister_game_creator_agent_runtime_provider_request(
|
||||
pub(crate) fn unregister_game_creator_agent_runtime_provider_request(
|
||||
key: &str,
|
||||
active: &Arc<AgentRuntimeProviderInterrupt>,
|
||||
) {
|
||||
@@ -1216,12 +1472,14 @@ mod shutdown_tests {
|
||||
&known_root,
|
||||
"code-prototype",
|
||||
"known-run",
|
||||
0,
|
||||
)
|
||||
.expect("register known Provider request");
|
||||
let (other_key, other_request) = register_game_creator_agent_runtime_provider_request(
|
||||
&other_root,
|
||||
"code-prototype",
|
||||
"other-run",
|
||||
0,
|
||||
)
|
||||
.expect("register other Provider request");
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ fn game_creator_agent_runtime_public_status_message_id(
|
||||
) -> String {
|
||||
let correlation_id =
|
||||
game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id);
|
||||
game_creator_agent_runtime_public_status_message_id_for_correlation(&correlation_id, status)
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_public_status_message_id_for_correlation(
|
||||
correlation_id: &str,
|
||||
status: &str,
|
||||
) -> String {
|
||||
let status_fingerprint = format!("{:x}", Sha256::digest(status.as_bytes()));
|
||||
format!(
|
||||
"{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-{}",
|
||||
@@ -62,6 +69,28 @@ pub(crate) fn append_game_creator_agent_runtime_public_status_message_at(
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn append_game_creator_agent_runtime_public_status_message_for_correlation_at(
|
||||
root: &Path,
|
||||
correlation_id: &str,
|
||||
status: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let message_id =
|
||||
game_creator_agent_runtime_public_status_message_id_for_correlation(correlation_id, status);
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: content.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&message_id,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
|
||||
@@ -289,6 +289,7 @@ pub(crate) fn take_cli_runtime_config_dir(
|
||||
|
||||
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
|
||||
let mut lines = vec![
|
||||
format!("agent.mode={}", status.agent_mode),
|
||||
format!("llm.configured={}", status.configured),
|
||||
format!("llm.apiKeyPresent={}", status.api_key_present),
|
||||
format!(
|
||||
|
||||
@@ -675,7 +675,7 @@ pub(crate) fn clear_game_creator_agent_goal(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn steer_game_creator_agent_runtime_task(
|
||||
pub(crate) async fn steer_game_creator_agent_runtime_task(
|
||||
project_path: String,
|
||||
agent_id: String,
|
||||
session_id: String,
|
||||
@@ -717,13 +717,90 @@ pub(crate) fn steer_game_creator_agent_runtime_task(
|
||||
run_profile.as_deref(),
|
||||
"tauri",
|
||||
)?;
|
||||
if !result.provider_interrupted
|
||||
&& external_agent_runner_enabled()
|
||||
&& !external_agent_runner_is_server_process()
|
||||
{
|
||||
result.provider_interrupted =
|
||||
let external_runner =
|
||||
external_agent_runner_enabled() && !external_agent_runner_is_server_process();
|
||||
let wake_external_runner_without_interrupt = || -> Result<(), String> {
|
||||
let provider_interrupted =
|
||||
steer_external_agent_runner(root, agent_id.trim(), run_id.trim(), steer_id.trim())?;
|
||||
if provider_interrupted {
|
||||
return Err("Agent Runner 的 runtime.steer 非法中断了 Provider".to_string());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let state = result.runtime.state.clone();
|
||||
let requires_supervisor_decision = state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
&& state.parent_agent_id.is_none()
|
||||
&& state.parent_run_id.is_none();
|
||||
if requires_supervisor_decision {
|
||||
match decide_game_creator_agent_runtime_steer_at(
|
||||
root,
|
||||
&state,
|
||||
steer_id.trim(),
|
||||
result.sequence,
|
||||
instruction.trim(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(decision) => {
|
||||
result.assistant_reply = Some(decision.reply.clone());
|
||||
result.interrupt_decision = Some(decision.interrupt_current_provider);
|
||||
result.decision_reason = Some(decision.reason.clone());
|
||||
if decision.interrupt_current_provider {
|
||||
result.provider_interrupted = if external_runner {
|
||||
interrupt_external_agent_runner_provider_for_steer_decision(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
run_id.trim(),
|
||||
steer_id.trim(),
|
||||
)?
|
||||
} else {
|
||||
interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
run_id.trim(),
|
||||
steer_id.trim(),
|
||||
)?
|
||||
};
|
||||
if !external_runner {
|
||||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
} else if external_runner {
|
||||
wake_external_runner_without_interrupt()?;
|
||||
} else {
|
||||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
result.assistant_reply = Some(
|
||||
append_game_creator_agent_runtime_steer_decision_failure_reply_at(
|
||||
root,
|
||||
&state,
|
||||
steer_id.trim(),
|
||||
&error,
|
||||
)?,
|
||||
);
|
||||
result.decision_reason = Some("decision-failed".to_string());
|
||||
if external_runner {
|
||||
wake_external_runner_without_interrupt()?;
|
||||
} else {
|
||||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if external_runner {
|
||||
wake_external_runner_without_interrupt()?;
|
||||
} else {
|
||||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
result.runtime = read_game_creator_agent_runtime_for_session_at(
|
||||
root,
|
||||
agent_id.trim(),
|
||||
Some(session_id.trim()),
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
return GameCreatorLlmConfigStatus {
|
||||
agent_mode: default_game_creator_agent_mode(),
|
||||
configured: false,
|
||||
api_key_present: false,
|
||||
base_url: None,
|
||||
@@ -224,6 +225,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
|
||||
};
|
||||
}
|
||||
};
|
||||
if app_config.agent_mode != GAME_CREATOR_AGENT_MODE_PROVIDER {
|
||||
return check_game_creator_codex_config(&app_config);
|
||||
}
|
||||
let global_route_shape_error =
|
||||
validate_game_creator_llm_web_search_config(&app_config.llm, "llm").err();
|
||||
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
|
||||
@@ -245,6 +249,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
|
||||
.map(|definition| {
|
||||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id);
|
||||
check_game_creator_agent_llm_config_values(
|
||||
&app_config.agent_mode,
|
||||
&definition.agent_id,
|
||||
&definition.label,
|
||||
&llm,
|
||||
@@ -279,6 +284,120 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
|
||||
status
|
||||
}
|
||||
|
||||
fn check_game_creator_codex_config(
|
||||
app_config: &GameCreatorAppConfig,
|
||||
) -> GameCreatorLlmConfigStatus {
|
||||
let cli_error = check_game_creator_codex_cli_available()
|
||||
.and_then(|()| {
|
||||
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||||
check_game_creator_codex_app_server_available()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.err();
|
||||
let global_route_error = game_creator_codex_app_server_llm_route_error(
|
||||
&app_config.agent_mode,
|
||||
&app_config.llm,
|
||||
"llm",
|
||||
);
|
||||
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
|
||||
status.agent_mode = app_config.agent_mode.clone();
|
||||
status.configured = cli_error.is_none() && global_route_error.is_none();
|
||||
status.error = cli_error.clone().or(global_route_error);
|
||||
status.agents = game_creator_llm_agent_status_definitions()
|
||||
.iter()
|
||||
.map(|definition| {
|
||||
let llm = resolve_game_creator_llm_config_for_agent(app_config, &definition.agent_id);
|
||||
let mut agent = check_game_creator_agent_llm_config_values(
|
||||
&app_config.agent_mode,
|
||||
&definition.agent_id,
|
||||
&definition.label,
|
||||
&llm,
|
||||
);
|
||||
let route_error = game_creator_codex_app_server_llm_route_error(
|
||||
&app_config.agent_mode,
|
||||
&llm,
|
||||
&format!("agentLlm.{}", definition.agent_id),
|
||||
);
|
||||
agent.configured = cli_error.is_none() && route_error.is_none();
|
||||
agent.error = cli_error.clone().or(route_error);
|
||||
agent
|
||||
})
|
||||
.collect();
|
||||
let required_errors = status
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
|
||||
.filter_map(|agent| {
|
||||
agent
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|error| format!("{}:{error}", agent.label))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !required_errors.is_empty() {
|
||||
status.configured = false;
|
||||
let mut errors = status.error.take().into_iter().collect::<Vec<_>>();
|
||||
errors.extend(required_errors);
|
||||
errors.dedup();
|
||||
status.error = Some(errors.join(";"));
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_codex_app_server_llm_route_error(
|
||||
agent_mode: &str,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
config_path: &str,
|
||||
) -> Option<String> {
|
||||
if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||||
return None;
|
||||
}
|
||||
if llm.api_kind != "openai_responses" {
|
||||
return Some(format!(
|
||||
"配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式",
|
||||
llm.api_kind
|
||||
));
|
||||
}
|
||||
llm.web_search_enabled.then(|| {
|
||||
format!(
|
||||
"配置项 {config_path}.webSearchEnabled 在 codex_app_server 模式下必须为 false;该模式由 AGC Runtime 独占工具执行,不能启用 Codex 原生联网工具"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
|
||||
let output = std::process::Command::new("codex")
|
||||
.arg("--version")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Codex CLI 未安装或不在当前客户端 PATH 中".to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err("Codex CLI 版本检查失败".to_string());
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
if !version.trim().starts_with("codex-cli ") {
|
||||
return Err("Codex CLI 返回了无法识别的版本信息".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
|
||||
let output = std::process::Command::new("codex")
|
||||
.args(["app-server", "--help"])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
|
||||
if !output.status.success()
|
||||
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
|
||||
{
|
||||
return Err("当前 Codex CLI 不支持 app-server 子命令,请升级 Codex CLI".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn check_game_creator_llm_config_values(
|
||||
config: &GameCreatorLlmConfig,
|
||||
config_path: &str,
|
||||
@@ -317,6 +436,7 @@ pub(crate) fn check_game_creator_llm_config_values(
|
||||
});
|
||||
|
||||
GameCreatorLlmConfigStatus {
|
||||
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
||||
configured: error.is_none(),
|
||||
api_key_present,
|
||||
base_url,
|
||||
@@ -339,6 +459,7 @@ pub(crate) fn check_game_creator_llm_config_values(
|
||||
}
|
||||
|
||||
pub(crate) fn check_game_creator_agent_llm_config_values(
|
||||
agent_mode: &str,
|
||||
agent_id: &str,
|
||||
label: &str,
|
||||
config: &GameCreatorLlmConfig,
|
||||
@@ -359,6 +480,7 @@ pub(crate) fn check_game_creator_agent_llm_config_values(
|
||||
}
|
||||
}
|
||||
GameCreatorAgentLlmConfigStatus {
|
||||
agent_mode: agent_mode.to_string(),
|
||||
agent_id: agent_id.to_string(),
|
||||
label: label.to_string(),
|
||||
configured: status.configured,
|
||||
@@ -1135,11 +1257,53 @@ pub(crate) fn configure_game_creator_runtime_config_dir(
|
||||
if !config_path.exists() {
|
||||
write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
|
||||
.map_err(std::io::Error::other)?;
|
||||
} else {
|
||||
migrate_legacy_game_creator_agent_mode(&config_path).map_err(std::io::Error::other)?;
|
||||
}
|
||||
set_game_creator_runtime_config_dir(config_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_game_creator_agent_mode(
|
||||
config: &GameCreatorAppConfigFile,
|
||||
) -> Option<&'static str> {
|
||||
if config.agent_mode.is_some() {
|
||||
return None;
|
||||
}
|
||||
let responses_only = config
|
||||
.llm
|
||||
.as_ref()
|
||||
.and_then(|llm| llm.api_kind.as_deref())
|
||||
.map(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses")
|
||||
.unwrap_or(true)
|
||||
&& config
|
||||
.agent_llm
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|agents| agents.values())
|
||||
.filter_map(|llm| llm.api_kind.as_deref())
|
||||
.all(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses");
|
||||
Some(if responses_only {
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
||||
} else {
|
||||
GAME_CREATOR_AGENT_MODE_PROVIDER
|
||||
})
|
||||
}
|
||||
|
||||
fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?;
|
||||
let mut config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
|
||||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
|
||||
let Some(agent_mode) = legacy_game_creator_agent_mode(&config) else {
|
||||
return Ok(());
|
||||
};
|
||||
config.agent_mode = Some(agent_mode.to_string());
|
||||
let content = serde_json::to_string_pretty(&config)
|
||||
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||||
write_game_creator_config_atomically(path, &format!("{content}\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_runtime_config_dir_lock() -> &'static Mutex<Option<PathBuf>> {
|
||||
GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
@@ -1256,6 +1420,9 @@ pub(crate) fn merge_game_creator_config_file(
|
||||
.map_err(|error| format!("读取客户端配置失败:{}: {error}", read_path.display()))?;
|
||||
let file_config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
|
||||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?;
|
||||
if let Some(agent_mode) = file_config.agent_mode {
|
||||
config.agent_mode = agent_mode;
|
||||
}
|
||||
if let Some(llm) = file_config.llm {
|
||||
merge_game_creator_llm_config(&mut config.llm, llm);
|
||||
}
|
||||
@@ -1508,6 +1675,7 @@ pub(crate) fn trim_config_string(value: &str) -> Option<String> {
|
||||
pub(crate) fn normalize_game_creator_app_config(
|
||||
mut config: GameCreatorAppConfig,
|
||||
) -> Result<GameCreatorAppConfig, String> {
|
||||
config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?;
|
||||
config.llm.api_key = config.llm.api_key.trim().to_string();
|
||||
config.llm.base_url =
|
||||
trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?;
|
||||
@@ -1545,6 +1713,19 @@ pub(crate) fn normalize_game_creator_app_config(
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result<String, String> {
|
||||
match value.trim() {
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
||||
Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string())
|
||||
}
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()),
|
||||
GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()),
|
||||
value => Err(format!(
|
||||
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_game_creator_llm_patch_config(
|
||||
agent_id: &str,
|
||||
mut patch: GameCreatorLlmConfigFile,
|
||||
|
||||
@@ -621,6 +621,12 @@ struct AgentRuntimeSteerResult {
|
||||
sequence: u64,
|
||||
status: String,
|
||||
provider_interrupted: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
assistant_reply: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
interrupt_decision: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
decision_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -666,6 +672,7 @@ struct GameCreatorAgentProgressEvent {
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfigStatus {
|
||||
agent_mode: String,
|
||||
configured: bool,
|
||||
api_key_present: bool,
|
||||
base_url: Option<String>,
|
||||
@@ -687,6 +694,7 @@ struct GameCreatorLlmConfigStatus {
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorAgentLlmConfigStatus {
|
||||
agent_mode: String,
|
||||
agent_id: String,
|
||||
label: String,
|
||||
configured: bool,
|
||||
@@ -709,6 +717,7 @@ struct GameCreatorAgentLlmConfigStatus {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorAppConfigFile {
|
||||
agent_mode: Option<String>,
|
||||
llm: Option<GameCreatorLlmConfigFile>,
|
||||
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
|
||||
editor_api: Option<GameCreatorEditorApiConfigFile>,
|
||||
@@ -756,6 +765,8 @@ struct GameCreatorEditorApiConfigFile {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorAppConfig {
|
||||
#[serde(default = "default_game_creator_agent_mode")]
|
||||
agent_mode: String,
|
||||
llm: GameCreatorLlmConfig,
|
||||
#[serde(default)]
|
||||
agent_llm: BTreeMap<String, GameCreatorLlmConfigFile>,
|
||||
@@ -1196,6 +1207,9 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
|
||||
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
|
||||
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
|
||||
const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json";
|
||||
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
|
||||
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
|
||||
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
|
||||
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";
|
||||
@@ -1205,6 +1219,10 @@ const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
|
||||
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
|
||||
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2;
|
||||
|
||||
fn default_game_creator_agent_mode() -> String {
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
|
||||
}
|
||||
|
||||
fn default_game_creator_llm_context_window_tokens() -> u64 {
|
||||
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
|
||||
}
|
||||
@@ -1277,6 +1295,7 @@ static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceL
|
||||
impl Default for GameCreatorAppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent_mode: default_game_creator_agent_mode(),
|
||||
llm: GameCreatorLlmConfig::default(),
|
||||
agent_llm: BTreeMap::new(),
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
|
||||
@@ -12,8 +12,9 @@ pub(crate) use client::{
|
||||
compact_external_agent_runner_context, configure_external_agent_runner,
|
||||
configure_external_agent_runner_read_only, continue_external_agent_runner_action,
|
||||
ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui,
|
||||
notify_external_agent_runner, pause_external_agent_runner,
|
||||
read_external_agent_runner_mcp_catalog, read_external_agent_runner_status,
|
||||
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
||||
pause_external_agent_runner, read_external_agent_runner_mcp_catalog,
|
||||
read_external_agent_runner_status,
|
||||
require_external_agent_runner_configured_for_cli_runtime_write,
|
||||
require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner,
|
||||
shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit,
|
||||
|
||||
@@ -1438,6 +1438,29 @@ pub(crate) fn steer_external_agent_runner(
|
||||
parse_external_agent_runner_steer_result(&result)
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt_external_agent_runner_provider_for_steer_decision(
|
||||
root: &Path,
|
||||
agent: &str,
|
||||
run_id: &str,
|
||||
steer_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
if [agent, run_id, steer_id]
|
||||
.into_iter()
|
||||
.any(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err("LLM steer 中断判定必须同时提供 agent/runId/steerId".to_string());
|
||||
}
|
||||
let result = send_external_agent_runner_runtime_request(
|
||||
root,
|
||||
"runtime.interrupt_for_steer_decision",
|
||||
Some(agent.trim()),
|
||||
Some(run_id.trim()),
|
||||
None,
|
||||
Some(steer_id.trim()),
|
||||
)?;
|
||||
parse_external_agent_runner_steer_result(&result)
|
||||
}
|
||||
|
||||
pub(crate) fn pause_external_agent_runner(
|
||||
root: &Path,
|
||||
agent: &str,
|
||||
|
||||
@@ -412,6 +412,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
||||
| "runtime.resume"
|
||||
| "runtime.continue_action"
|
||||
| "runtime.steer"
|
||||
| "runtime.interrupt_for_steer_decision"
|
||||
| "runtime.pause"
|
||||
| "runtime.cancel"
|
||||
| "runtime.compact"
|
||||
@@ -430,6 +431,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
||||
| "runtime.resume"
|
||||
| "runtime.continue_action"
|
||||
| "runtime.steer"
|
||||
| "runtime.interrupt_for_steer_decision"
|
||||
| "runtime.pause"
|
||||
| "runtime.cancel"
|
||||
| "runtime.compact" => {
|
||||
@@ -490,10 +492,23 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
||||
crate::validate_game_creator_agent_runtime_steer_notification_at(
|
||||
&root, &agent, &run_id, &steer_id,
|
||||
)?;
|
||||
let provider_interrupted =
|
||||
crate::interrupt_game_creator_agent_runtime_provider_request_at(
|
||||
&root, &agent, &run_id,
|
||||
)?;
|
||||
crate::wake_pending_game_creator_agent_background_tasks_at(&root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(json!({
|
||||
"accepted": true,
|
||||
"providerInterrupted": false,
|
||||
}))
|
||||
})(),
|
||||
"runtime.interrupt_for_steer_decision" => (|| {
|
||||
let agent = external_agent_runner_request_agent(request)?;
|
||||
let run_id = external_agent_runner_request_run_id(request)?;
|
||||
let steer_id = external_agent_runner_request_steer_id(request)?;
|
||||
let provider_interrupted = crate::interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
|
||||
&root,
|
||||
&agent,
|
||||
&run_id,
|
||||
&steer_id,
|
||||
)?;
|
||||
crate::wake_pending_game_creator_agent_background_tasks_at(&root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(json!({
|
||||
@@ -845,6 +860,7 @@ pub(super) fn handle_external_agent_runner_request(
|
||||
| "runtime.resume"
|
||||
| "runtime.continue_action"
|
||||
| "runtime.steer"
|
||||
| "runtime.interrupt_for_steer_decision"
|
||||
| "runtime.pause"
|
||||
| "runtime.cancel"
|
||||
| "runtime.compact"
|
||||
|
||||
@@ -194,6 +194,7 @@ pub(super) fn spawn_external_agent_runner_gui_owner_watchdog(
|
||||
.store(true, Ordering::Release);
|
||||
state.shutdown_requested.store(true, Ordering::Release);
|
||||
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT);
|
||||
let _ = crate::agent::shutdown_game_creator_codex_app_servers();
|
||||
remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id);
|
||||
std::process::exit(1);
|
||||
})
|
||||
@@ -356,6 +357,12 @@ pub(crate) fn run_external_agent_runner_server(
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() {
|
||||
process_shutdown = Err(match process_shutdown {
|
||||
Ok(()) => app_server_error,
|
||||
Err(process_error) => format!("{process_error};{app_server_error}"),
|
||||
});
|
||||
}
|
||||
if let Some(error) = server_error {
|
||||
Err(match process_shutdown {
|
||||
Ok(()) => error,
|
||||
|
||||
@@ -1231,7 +1231,7 @@ fn typed_steer_result_requires_provider_interrupted_boolean() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() {
|
||||
fn runtime_steer_queues_without_interrupt_and_deduplicates_request_id() {
|
||||
let directory = unique_test_directory();
|
||||
let root = directory.0.join("project");
|
||||
crate::init_local_game_project_at(&root, "project-steer-rpc", "Runner steer 测试")
|
||||
@@ -1265,6 +1265,14 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() {
|
||||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||
test_endpoint(token, "steer-rpc-boot", 30303),
|
||||
);
|
||||
let (provider_key, active_provider) =
|
||||
crate::register_game_creator_agent_runtime_provider_request(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"run-steer-rpc",
|
||||
0,
|
||||
)
|
||||
.expect("register active Provider before runtime.steer");
|
||||
let request = ExternalAgentRunnerRequest {
|
||||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
request_id: "steer-rpc-request-1".to_string(),
|
||||
@@ -1288,9 +1296,14 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() {
|
||||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
assert!(
|
||||
!active_provider.interrupted.load(Ordering::Acquire),
|
||||
"runtime.steer must not interrupt an active Provider before the LLM decision"
|
||||
);
|
||||
|
||||
let replay = dispatch_external_agent_runner_runtime_request(&request, &state);
|
||||
assert_eq!(replay, first);
|
||||
assert!(!active_provider.interrupted.load(Ordering::Acquire));
|
||||
|
||||
let mut conflict = request;
|
||||
conflict.params.steer_id = Some("steer-rpc-2".to_string());
|
||||
@@ -1300,6 +1313,294 @@ fn runtime_steer_reports_provider_interrupt_and_deduplicates_request_id() {
|
||||
conflict.error.as_ref().map(|error| error.code.as_str()),
|
||||
Some("request-id-conflict")
|
||||
);
|
||||
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_interrupt_for_steer_decision_rejects_missing_durable_decision() {
|
||||
let directory = unique_test_directory();
|
||||
let root = directory.0.join("project");
|
||||
crate::init_local_game_project_at(
|
||||
&root,
|
||||
"project-steer-missing-decision-rpc",
|
||||
"Runner steer 缺少判定测试",
|
||||
)
|
||||
.expect("initialize missing-decision project");
|
||||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
None,
|
||||
"保持当前 Provider 请求运行",
|
||||
"run-steer-missing-decision-rpc",
|
||||
"agent-background-task",
|
||||
"等待 Supervisor 判定",
|
||||
vec!["完成当前任务".to_string()],
|
||||
)
|
||||
.expect("start missing-decision runtime");
|
||||
let steer = crate::steer_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.session_id,
|
||||
&runtime.run_id,
|
||||
"steer-missing-decision-rpc-1",
|
||||
"先告诉我进度。",
|
||||
"runner-test",
|
||||
)
|
||||
.expect("persist steer without decision");
|
||||
assert_eq!(steer.sequence, 1);
|
||||
let (provider_key, active_provider) =
|
||||
crate::register_game_creator_agent_runtime_provider_request(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
0,
|
||||
)
|
||||
.expect("register active Provider before missing decision request");
|
||||
let token = "steer-missing-decision-token-steer-missing-decision-token";
|
||||
let state = ExternalAgentRunnerServerState::new(
|
||||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||
test_endpoint(token, "steer-missing-decision-boot", 30304),
|
||||
);
|
||||
let response = dispatch_external_agent_runner_runtime_request(
|
||||
&ExternalAgentRunnerRequest {
|
||||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
request_id: "steer-missing-decision-request-1".to_string(),
|
||||
token: token.to_string(),
|
||||
method: "runtime.interrupt_for_steer_decision".to_string(),
|
||||
params: ExternalAgentRunnerRequestParams {
|
||||
root: Some(root.to_string_lossy().into_owned()),
|
||||
agent: Some(runtime.agent_id.clone()),
|
||||
run_id: Some(runtime.run_id.clone()),
|
||||
steer_id: Some(steer.steer_id),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
},
|
||||
&state,
|
||||
);
|
||||
|
||||
assert!(!response.ok);
|
||||
assert_eq!(
|
||||
response.error.as_ref().map(|error| error.code.as_str()),
|
||||
Some("runtime-error")
|
||||
);
|
||||
assert!(response
|
||||
.error
|
||||
.as_ref()
|
||||
.is_some_and(|error| error.message.contains("尚无 LLM 中断判定")));
|
||||
assert!(
|
||||
!active_provider.interrupted.load(Ordering::Acquire),
|
||||
"a missing durable LLM decision must leave the active Provider running"
|
||||
);
|
||||
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_interrupt_for_false_steer_decision_keeps_provider_running() {
|
||||
let directory = unique_test_directory();
|
||||
let root = directory.0.join("project");
|
||||
crate::init_local_game_project_at(
|
||||
&root,
|
||||
"project-steer-false-decision-rpc",
|
||||
"Runner steer 不中断判定测试",
|
||||
)
|
||||
.expect("initialize false-decision project");
|
||||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
None,
|
||||
"保持当前 Provider 请求运行",
|
||||
"run-steer-false-decision-rpc",
|
||||
"agent-background-task",
|
||||
"处理状态询问",
|
||||
vec!["完成当前任务".to_string()],
|
||||
)
|
||||
.expect("start false-decision runtime");
|
||||
let steer = crate::steer_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.session_id,
|
||||
&runtime.run_id,
|
||||
"steer-false-decision-rpc-1",
|
||||
"现在做到哪一步了?",
|
||||
"runner-test",
|
||||
)
|
||||
.expect("persist status-question steer");
|
||||
crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||||
&root,
|
||||
&runtime,
|
||||
&steer.steer_id,
|
||||
steer.sequence,
|
||||
crate::AgentRuntimeSteerDecision {
|
||||
reply: "当前任务仍在继续,我会按现有方向完成。".to_string(),
|
||||
interrupt_current_provider: false,
|
||||
reason: "状态询问不会让当前方案过期。".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("persist non-interrupting LLM decision");
|
||||
let (provider_key, active_provider) =
|
||||
crate::register_game_creator_agent_runtime_provider_request(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
0,
|
||||
)
|
||||
.expect("register active Provider for false decision");
|
||||
let token = "steer-false-decision-token-steer-false-decision-token";
|
||||
let state = ExternalAgentRunnerServerState::new(
|
||||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||
test_endpoint(token, "steer-false-decision-boot", 30305),
|
||||
);
|
||||
let response = dispatch_external_agent_runner_runtime_request(
|
||||
&ExternalAgentRunnerRequest {
|
||||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
request_id: "steer-false-decision-request-1".to_string(),
|
||||
token: token.to_string(),
|
||||
method: "runtime.interrupt_for_steer_decision".to_string(),
|
||||
params: ExternalAgentRunnerRequestParams {
|
||||
root: Some(root.to_string_lossy().into_owned()),
|
||||
agent: Some(runtime.agent_id.clone()),
|
||||
run_id: Some(runtime.run_id.clone()),
|
||||
steer_id: Some(steer.steer_id),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
},
|
||||
&state,
|
||||
);
|
||||
|
||||
assert!(
|
||||
response.ok,
|
||||
"false decision RPC failed: {:?}",
|
||||
response.error
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
assert!(!active_provider.interrupted.load(Ordering::Acquire));
|
||||
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor() {
|
||||
let directory = unique_test_directory();
|
||||
let root = directory.0.join("project");
|
||||
crate::init_local_game_project_at(
|
||||
&root,
|
||||
"project-steer-true-decision-rpc",
|
||||
"Runner steer 条件中断测试",
|
||||
)
|
||||
.expect("initialize true-decision project");
|
||||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
None,
|
||||
"按当前方向实现玩法",
|
||||
"run-steer-true-decision-rpc",
|
||||
"agent-background-task",
|
||||
"生成当前方案",
|
||||
vec!["完成当前任务".to_string()],
|
||||
)
|
||||
.expect("start true-decision runtime");
|
||||
let steer = crate::steer_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.session_id,
|
||||
&runtime.run_id,
|
||||
"steer-true-decision-rpc-1",
|
||||
"停止旧方向,改成全新的玩法。",
|
||||
"runner-test",
|
||||
)
|
||||
.expect("persist conflicting steer");
|
||||
crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||||
&root,
|
||||
&runtime,
|
||||
&steer.steer_id,
|
||||
steer.sequence,
|
||||
crate::AgentRuntimeSteerDecision {
|
||||
reply: "这个改动会让旧方案过期,我会安全切换方向。".to_string(),
|
||||
interrupt_current_provider: true,
|
||||
reason: "当前 Provider 正在生成已经冲突的旧方案。".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("persist interrupting LLM decision");
|
||||
let token = "steer-true-decision-token-steer-true-decision-token";
|
||||
let state = ExternalAgentRunnerServerState::new(
|
||||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||
test_endpoint(token, "steer-true-decision-boot", 30306),
|
||||
);
|
||||
let request = |request_id: &str| ExternalAgentRunnerRequest {
|
||||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
request_id: request_id.to_string(),
|
||||
token: token.to_string(),
|
||||
method: "runtime.interrupt_for_steer_decision".to_string(),
|
||||
params: ExternalAgentRunnerRequestParams {
|
||||
root: Some(root.to_string_lossy().into_owned()),
|
||||
agent: Some(runtime.agent_id.clone()),
|
||||
run_id: Some(runtime.run_id.clone()),
|
||||
steer_id: Some(steer.steer_id.clone()),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
};
|
||||
|
||||
let (old_provider_key, old_provider) =
|
||||
crate::register_game_creator_agent_runtime_provider_request(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
steer.sequence.saturating_sub(1),
|
||||
)
|
||||
.expect("register Provider created before steer");
|
||||
let old_response = dispatch_external_agent_runner_runtime_request(
|
||||
&request("steer-true-decision-old-provider-request"),
|
||||
&state,
|
||||
);
|
||||
assert!(
|
||||
old_response.ok,
|
||||
"old Provider interrupt RPC failed: {:?}",
|
||||
old_response.error
|
||||
);
|
||||
assert_eq!(
|
||||
old_response
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert!(old_provider.interrupted.load(Ordering::Acquire));
|
||||
crate::unregister_game_creator_agent_runtime_provider_request(&old_provider_key, &old_provider);
|
||||
|
||||
let (new_provider_key, new_provider) =
|
||||
crate::register_game_creator_agent_runtime_provider_request(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
steer.sequence,
|
||||
)
|
||||
.expect("register Provider created after steer was applied");
|
||||
let new_response = dispatch_external_agent_runner_runtime_request(
|
||||
&request("steer-true-decision-new-provider-request"),
|
||||
&state,
|
||||
);
|
||||
assert!(
|
||||
new_response.ok,
|
||||
"new Provider keep-running RPC failed: {:?}",
|
||||
new_response.error
|
||||
);
|
||||
assert_eq!(
|
||||
new_response
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
assert!(
|
||||
!new_provider.interrupted.load(Ordering::Acquire),
|
||||
"a Provider created at the decided steer cursor must keep running"
|
||||
);
|
||||
crate::unregister_game_creator_agent_runtime_provider_request(&new_provider_key, &new_provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -348,7 +348,7 @@ fn steer_and_wait_for_swarm_turn<W: Write>(
|
||||
) -> Result<SwarmChatFlow, String> {
|
||||
require_external_agent_runner_for_cli_runtime_write(root)?;
|
||||
let steer_id = format!("swarm-steer-{}", unix_millis());
|
||||
let result = steer_game_creator_agent_runtime_task(
|
||||
let result = tauri::async_runtime::block_on(steer_game_creator_agent_runtime_task(
|
||||
root.display().to_string(),
|
||||
parent_agent_id.to_string(),
|
||||
session_id.to_string(),
|
||||
@@ -357,7 +357,7 @@ fn steer_and_wait_for_swarm_turn<W: Write>(
|
||||
message.to_string(),
|
||||
Some(run_profile.to_string()),
|
||||
None,
|
||||
)?;
|
||||
))?;
|
||||
writeln!(
|
||||
output,
|
||||
"[{label}] run={} steer={} providerInterrupted={}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user