修复 AGC 统一错误诊断与素材验收 (#376)
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m49s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 5m9s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m20s
Project CI / AI game creator shell Rust crates (push) Successful in 2m27s
Project CI / Native shell tests (push) Failing after 2m36s
Project CI / AI game creator shell web tests (push) Failing after 47s
Project CI / Frontend tests (push) Successful in 4m23s
Project CI / Repository checks (push) Failing after 3m14s
Project CI / Backend tests (push) Successful in 7m17s

## 变更内容

- 新增统一 Agent Runtime 错误事件和项目内诊断 sidecar,统一 DirectProject、Agent Runtime、app-server 与 AGC 工具桥的失败记录。
- 失败回执写回 DirectProject 对话历史,下一轮可读取上一轮失败证据;前端支持读取脱敏诊断详情。
- 区分 app-server 终态、空闲/硬超时、工具参数错误和试玩次数越界。
- 素材完成门扫描实际 npm 源码模块,并识别 manifest 中合法的自定义登记素材路径。
- 更新 AGC 技术规范、项目决策记录、里程碑和实施计划。

## 验证

- cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests
- 统一错误测试 6/6 通过
- DirectProject 失败诊断测试 4/4 通过
- npm 场景模块素材引用回归测试通过
- npm run --prefix apps/ai-game-creator-shell typecheck
- npm run check:encoding
- npm run check:doc-index
- git diff --check、Rust 格式检查

真实 Provider / 安装包 smoke 尚未在本 PR 中运行。

---------

Co-authored-by: Git Hooks Test <git-hooks-test@example.invalid>
Reviewed-on: #376
This commit was merged in pull request #376.
This commit is contained in:
2026-09-15 20:21:53 +08:00
parent dd24690b02
commit 361daf374d
14 changed files with 561 additions and 36 deletions
@@ -28,6 +28,7 @@ mod prompt;
mod runtime_actions;
mod runtime_adapter;
mod runtime_driver;
mod runtime_error;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
@@ -56,6 +57,7 @@ pub(crate) use prompt::*;
pub(crate) use runtime_actions::*;
pub(crate) use runtime_adapter::*;
pub(crate) use runtime_driver::*;
pub(crate) use runtime_error::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
pub(crate) use runtime_tools::*;
@@ -271,6 +271,51 @@ fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmErro
))
}
fn game_creator_codex_app_server_error_kind_with_machine_detail(
kind: &str,
error: &serde_json::Value,
) -> platform_llm::LlmError {
let mut fields = Vec::new();
if let Some(object) = error.as_object() {
if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) {
if !code.is_empty()
&& code.len() <= 80
&& code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
{
fields.push(format!("code={code}"));
}
}
let keys = object
.keys()
.filter(|key| {
matches!(
key.as_str(),
"httpConnectionFailed"
| "responseStreamConnectionFailed"
| "responseStreamDisconnected"
| "responseTooManyFailedAttempts"
| "activeTurnNotSteerable"
| "codexErrorInfo"
)
})
.cloned()
.collect::<Vec<_>>();
if !keys.is_empty() {
fields.push(format!("fields={}", keys.join(",")));
}
}
let suffix = if fields.is_empty() {
String::new()
} else {
format!(" detail={}", fields.join(" "))
};
platform_llm::LlmError::InvalidRequest(format!(
"{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}{suffix}"
))
}
fn game_creator_codex_app_server_error_http_status(
info: &serde_json::Value,
field: &str,
@@ -425,7 +470,7 @@ fn game_creator_codex_app_server_failed_turn_error(
return game_creator_codex_app_server_error_kind("unauthorized");
}
let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else {
return game_creator_codex_app_server_error_kind("other");
return game_creator_codex_app_server_error_kind_with_machine_detail("other", error);
};
if let Some(kind) = info.as_str() {
return match kind {
@@ -449,8 +494,8 @@ fn game_creator_codex_app_server_failed_turn_error(
game_creator_codex_app_server_error_kind("thread-rollback-failed")
}
"sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"),
"other" => game_creator_codex_app_server_error_kind("other"),
_ => game_creator_codex_app_server_error_kind("other"),
"other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
_ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
};
}
for field in [
@@ -466,7 +511,7 @@ fn game_creator_codex_app_server_failed_turn_error(
if info.get("activeTurnNotSteerable").is_some() {
return game_creator_codex_app_server_error_kind("active-turn-not-steerable");
}
game_creator_codex_app_server_error_kind("other")
game_creator_codex_app_server_error_kind_with_machine_detail("other", error)
}
async fn isolate_game_creator_codex_app_server_terminal_unknown(
@@ -1905,7 +1905,11 @@ fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
|| normalized.contains("insufficient-mud-points")
}
fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String {
fn record_direct_codex_turn_failure(
root: &Path,
failure: DirectCodexTurnFailure,
client_turn_id: Option<&str>,
) -> String {
let summary = direct_codex_failure_public_summary(&failure.error)
.map(str::to_string)
.unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320));
@@ -1942,18 +1946,53 @@ fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure
} else {
"未能保存项目诊断"
};
format!(
"direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}",
let error_code = classify_direct_codex_error(&failure.error);
let unified_detail_ref = persist_agent_runtime_error(
root,
client_turn_id,
"direct-codex",
failure.stage.id(),
error_code,
retryable,
&summary,
recovery_hint,
&failure.error,
None,
serde_json::json!({
"legacyDiagnosticWritten": diagnostic_written,
}),
)
.ok()
.map(|event| event.detail_ref);
format!(
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}",
failure.stage.id(),
error_code,
retryable,
diagnostic["summary"]
.as_str()
.unwrap_or("未提供可安全展示的详细原因"),
recovery_hint,
diagnostics_suffix,
unified_detail_ref
.map(|path| format!(";详情:{path}"))
.unwrap_or_default(),
)
}
fn persist_direct_codex_failure_context(
root: &Path,
client_turn_id: &str,
error: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"assistant",
error,
Some(&format!("direct-codex:{client_turn_id}:failure")),
)?;
append_direct_project_history_item_at(root, &item)
}
fn direct_taonier_art_generation_runtime_context(
root: &Path,
output_path: &str,
@@ -2269,9 +2308,19 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
}
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
let sources = direct_codex_game_outputs(root)
let mut source_paths = direct_codex_game_outputs(root)
.into_iter()
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
.map(|(relative_path, _, _)| relative_path)
.collect::<Vec<_>>();
// npm/Phaser projects put the actual scene and loader code below `game/src`.
// Keep the canonical output list for manifest projection, but scan the
// complete bounded source list for the asset reference contract.
source_paths.extend(direct_npm_source_paths(root));
source_paths.sort();
source_paths.dedup();
let sources = source_paths
.into_iter()
.filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok())
.collect::<Vec<_>>();
let mut available_paths = Vec::new();
if direct_taonier_art_base_is_valid(root) {
@@ -2284,6 +2333,28 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH.to_string());
}
available_paths.extend(direct_registered_taonier_slice_paths(root));
// A project may have a valid, client-registered art-spritesheet at a
// project-specific path (for example a generated building sheet). The
// fixed canonical package paths above are compatibility candidates only;
// the manifest is the authority for additional runtime image identities.
if let Ok(manifest) = read_manifest_for_project(root) {
available_paths.extend(
manifest
.assets
.into_iter()
.filter(|asset| {
matches!(
asset.kind.as_str(),
"art-spritesheet" | "art-spritesheet-slice" | "game-background"
) && asset.media_type == "image/png"
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& asset.local_path.starts_with("assets/")
})
.map(|asset| asset.local_path),
);
}
available_paths.sort();
available_paths.dedup();
available_paths
.into_iter()
.filter(|path| sources.iter().any(|source| source.contains(path.as_str())))
@@ -4087,7 +4158,17 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
{
Ok(reply) => Ok(reply),
Err(failure) => {
let error = record_direct_codex_turn_failure(root, failure);
let error = record_direct_codex_turn_failure(
root,
failure,
turn_emitter.map(|emitter| emitter.turn_id()),
);
if let Some(emitter) = turn_emitter {
// Persist the safe terminal projection so the next DirectProject
// turn can answer a diagnostic question from evidence instead of
// guessing or starting another playtest.
let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error);
}
if let Some(emitter) = turn_emitter {
emitter.emit("failed", Some("none"), None);
}
@@ -6620,26 +6701,27 @@ mod tests {
#[test]
fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret",
),
None,
);
assert!(error
.starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary="));
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
assert!(error.contains("<redacted-url>"), "{error}");
assert!(error.contains("<absolute-path>"), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
assert!(!error.contains("?token=secret"), "{error}");
assert!(!error.contains("provider.example"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
@@ -6658,25 +6740,25 @@ mod tests {
#[test]
fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let history_path = root
.path()
.join(".agent/conversations/project.jsonl")
.display()
.to_string();
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::CodeGeneration,
format!("DirectProject 历史记录类型无效:{history_path}"),
),
None,
);
assert!(
error.starts_with(
"direct-codex-failure:v1 stage=code-generation retryable=false summary="
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
),
"{error}"
);
@@ -6686,9 +6768,9 @@ mod tests {
),
"{error}"
);
assert!(error.ends_with("已保存脱敏项目诊断"), "{error}");
assert!(error.contains("已保存脱敏项目诊断"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
@@ -6702,19 +6784,20 @@ mod tests {
#[test]
fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复",
),
None,
);
assert!(
error.contains("stage=art-preparation retryable=false"),
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
"{error}"
);
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
@@ -6722,15 +6805,16 @@ mod tests {
#[test]
fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据",
),
None,
);
assert!(
@@ -7830,6 +7914,40 @@ mod tests {
.any(|warning| warning.contains("不得猜测切片")));
}
#[test]
fn direct_completion_scans_npm_scene_modules_for_registered_asset_references() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-src-runtime", "源码模块素材引用")
.expect("init project");
register_direct_taonier_art_package_fixture(&root);
register_direct_taonier_art_slice_entries_fixture(&root);
std::fs::write(
root.join("game/package.json"),
"{\"scripts\":{\"build\":\"vite build\"}}",
)
.expect("package");
std::fs::write(root.join("game/index.html"), "<!doctype html>").expect("index");
std::fs::write(root.join("game/style.css"), "body {}").expect("style");
std::fs::write(root.join("game/game.js"), "import './src/scene.js';").expect("entry");
std::fs::create_dir_all(root.join("game/src")).expect("src dir");
std::fs::write(
root.join("game/src/scene.js"),
"const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';",
)
.expect("scene");
std::fs::write(
root.join("assets/art-spritesheet-slices/player.png"),
tiny_opaque_png(),
)
.expect("slice");
assert_eq!(
direct_game_sources_referenced_taonier_assets(&root),
vec!["assets/art-spritesheet-slices/player.png".to_string()]
);
}
#[test]
fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() {
let root = tempfile::tempdir().expect("temp dir");
@@ -1078,7 +1078,9 @@ fn bridge_attempt(arguments: &Value) -> Result<usize, String> {
.and_then(Value::as_u64)
.ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?;
if !(1..=3).contains(&attempt) {
return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string());
return Err(format!(
"playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果"
));
}
Ok(attempt as usize)
}
@@ -2608,6 +2610,30 @@ async fn handle_direct_tool_bridge(
}
_ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true),
};
if result.get("isError").and_then(Value::as_bool) == Some(true) {
let message = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or("客户端工具执行失败");
let code = if message.contains("playtest-attempt-limit-exceeded") {
"playtest-attempt-limit-exceeded"
} else {
"tool-error"
};
let _ = persist_agent_runtime_error(
&state.root,
None,
"agc-tools",
"tool-execution",
code,
true,
message,
"查看项目错误诊断后处理",
message,
None,
serde_json::json!({"tool": request.tool}),
);
}
Json(result)
}
@@ -898,7 +898,9 @@ fn tool_attempt(arguments: &Value) -> Result<usize, String> {
.and_then(Value::as_u64)
.ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?;
if !(1..=3).contains(&attempt) {
return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string());
return Err(format!(
"playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果"
));
}
Ok(attempt as usize)
}
@@ -0,0 +1,167 @@
//! Shared, project-bound error events for Agent Runtime and DirectProject.
//!
//! Every caller supplies a safe public summary and a private detail. This
//! module is the only persistence boundary for the latter: it redacts project
//! paths and credentials before writing a bounded diagnostic sidecar.
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub(crate) struct AgentRuntimeErrorEvent {
pub schema_version: &'static str,
pub event_id: String,
pub client_turn_id: Option<String>,
pub source: String,
pub stage: String,
pub code: String,
pub retryable: bool,
pub occurred_at_unix_nanos: String,
pub elapsed_ms: Option<u64>,
pub public_text: String,
pub recovery_hint: String,
pub detail_ref: String,
pub persistence_failed: bool,
pub metadata: Value,
}
pub(crate) fn persist_agent_runtime_error(
root: &Path,
client_turn_id: Option<&str>,
source: &str,
stage: &str,
code: &str,
retryable: bool,
public_text: &str,
recovery_hint: &str,
detail: &str,
elapsed_ms: Option<u64>,
metadata: Value,
) -> Result<AgentRuntimeErrorEvent, String> {
let occurred_at_unix_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("读取错误事件时间失败:{error}"))?
.as_nanos();
let sequence = ERROR_EVENT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let event_id = format!("error-{occurred_at_unix_nanos}-{sequence}");
let detail_ref = format!(".agent/runtime/errors/{event_id}.json");
let safe_detail =
redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS);
let diagnostic = serde_json::json!({
"schemaVersion": AGENT_RUNTIME_ERROR_SCHEMA_VERSION,
"eventId": event_id,
"clientTurnId": client_turn_id,
"source": source,
"stage": stage,
"code": code,
"retryable": retryable,
"occurredAtUnixNanos": occurred_at_unix_nanos.to_string(),
"elapsedMs": elapsed_ms,
"publicText": public_text,
"recoveryHint": recovery_hint,
"detail": safe_detail,
"metadata": metadata,
});
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&detail_ref,
"统一 Agent Runtime 错误诊断",
&diagnostic,
16 * 1024,
)?;
Ok(AgentRuntimeErrorEvent {
schema_version: AGENT_RUNTIME_ERROR_SCHEMA_VERSION,
event_id,
client_turn_id: client_turn_id.map(str::to_string),
source: source.to_string(),
stage: stage.to_string(),
code: code.to_string(),
retryable,
occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(),
elapsed_ms,
public_text: public_text.to_string(),
recovery_hint: recovery_hint.to_string(),
detail_ref,
persistence_failed: false,
metadata,
})
}
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
"turn-idle-timeout"
} else if normalized.contains("达到 directproject 硬上限") {
"turn-hard-timeout"
} else if normalized.contains("transport closed") || normalized.contains("连接已关闭") {
"transport-closed"
} else if normalized.contains("playtest-attempt-limit-exceeded") {
"playtest-attempt-limit-exceeded"
} else if (normalized.contains("tool") || normalized.contains("工具"))
&& normalized.contains("参数")
{
"tool-invalid-arguments"
} else if normalized.contains("codex app-server-error:other") {
"app-server-other"
} else {
"runtime-failure"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_event_is_bounded_and_redacts_private_detail() {
let parent = tempfile::tempdir().expect("temp root");
let root = parent.path().join("project");
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
.expect("init project");
let event = persist_agent_runtime_error(
&root,
Some("turn-123"),
"direct-codex",
"code-generation",
"turn-idle-timeout",
true,
"本轮没有收到完成事件",
"查看诊断后重试",
"C:\\Users\\private\\project https://provider.example/a?token=secret",
Some(1200),
serde_json::json!({"lastEvent":"item/started"}),
)
.expect("persist event");
assert_eq!(event.code, "turn-idle-timeout");
let path = root.join(&event.detail_ref);
let text = std::fs::read_to_string(path).expect("diagnostic");
assert!(text.contains("<absolute-path>"));
assert!(text.contains("<redacted-url>"));
assert!(!text.contains("token=secret"));
}
#[test]
fn timeout_and_tool_errors_have_distinct_codes() {
assert_eq!(
classify_direct_codex_error("等待 turn/completed 超时"),
"turn-idle-timeout"
);
assert_eq!(
classify_direct_codex_error("达到 DirectProject 硬上限"),
"turn-hard-timeout"
);
assert_eq!(
classify_direct_codex_error("工具参数 attempt 必须是 1 到 3 的整数"),
"tool-invalid-arguments"
);
}
}
@@ -101,6 +101,26 @@ pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at(
error: &str,
) -> Result<(), String> {
let content = game_creator_agent_runtime_failure_conversation_message(&state.agent_id, error);
// Keep the existing conversation projection, but also persist one common
// bounded diagnostic event for every Agent Runtime terminal failure. This
// makes non-DirectProject failures observable through the same detail API.
let _ = persist_agent_runtime_error(
root,
Some(&state.run_id),
"agent-runtime",
&state.phase,
"agent-runtime-terminal",
false,
&content,
"查看项目错误诊断后处理",
error,
None,
serde_json::json!({
"agentId": state.agent_id,
"sessionId": state.session_id,
"runId": state.run_id,
}),
);
let status = if state.phase == "budget-exhausted" {
"budget-exhausted"
} else if state.phase == "needs-reconciliation" {
@@ -5244,6 +5244,38 @@ pub(crate) async fn read_direct_project_conversation(
.map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_agent_runtime_error_detail(
project_path: String,
detail_ref: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let relative = detail_ref.trim();
let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else {
return Err("错误诊断引用不在项目错误目录内".to_string());
};
if file_name.is_empty()
|| file_name.contains(['/', '\\'])
|| file_name.contains("..")
|| !file_name.ends_with(".json")
{
return Err("错误诊断引用格式无效".to_string());
}
let path = root.join(relative);
prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?;
let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?;
if bytes.len() > 16 * 1024 {
return Err("错误诊断超过读取上限".to_string());
}
let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?;
Ok(redact_agent_runtime_error(root, &text, 16 * 1024))
})
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn append_local_conversation_message(
project_path: String,
@@ -2768,6 +2768,7 @@ fn main() {
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_agent_runtime_error_detail,
append_local_conversation_message,
append_direct_project_conversation_message,
build_local_project_index,
+18 -1
View File
@@ -6519,8 +6519,25 @@ export function App({
void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID);
const message =
error instanceof Error ? error.message : String(error);
let persistedDetail = '';
const detailRef = message.match(
/详情:(\.agent\/runtime\/errors\/[^\s;]+)/,
)?.[1];
if (detailRef && directInvoke) {
try {
persistedDetail = await directInvoke<string>(
'read_agent_runtime_error_detail',
{
projectPath: directProjectPath,
detailRef,
},
);
} catch {
persistedDetail = '';
}
}
const visibleMessage = projectRuntimeVisibleError(
message,
persistedDetail ? `${message}\n\n${persistedDetail}` : message,
'陶泥儿智能创作',
true,
);