增加 Provider 交接失败本地诊断
成功 handoff 失败时保存应用私有 Provider 响应诊断 在项目状态和 Agent DB 中记录本地诊断相对引用 补充 Runtime 技术方案与排障记录 增加私有诊断落盘和引用校验测试
This commit is contained in:
@@ -460,6 +460,148 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT: &str =
|
||||
"diagnostics/provider-reconciliation";
|
||||
const PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Persist the raw successful Provider response only in the application
|
||||
/// private data directory. Project state keeps the safe summary below; this
|
||||
/// sidecar is diagnostic-only and is never consulted by recovery/retry logic.
|
||||
pub(in crate::agent) fn write_provider_reconciliation_diagnostic_at(
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
request_id: &str,
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
error: &str,
|
||||
) -> Result<String, String> {
|
||||
let config_dir = game_creator_runtime_config_dir()
|
||||
.ok_or_else(|| "Runtime config dir 未初始化,无法写入本地 Provider 诊断".to_string())?;
|
||||
write_provider_reconciliation_diagnostic_in_dir(
|
||||
&config_dir,
|
||||
snapshot,
|
||||
request_id,
|
||||
response,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_provider_reconciliation_diagnostic_in_dir(
|
||||
config_dir: &Path,
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
request_id: &str,
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
error: &str,
|
||||
) -> Result<String, String> {
|
||||
let project_key = format!("{:x}", Sha256::digest(snapshot.project_id.as_bytes()));
|
||||
let request_key = format!("{:x}", Sha256::digest(request_id.as_bytes()));
|
||||
let directory = config_dir
|
||||
.join(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT)
|
||||
.join(&project_key);
|
||||
fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("创建本地 Provider 诊断目录失败:{error}"))?;
|
||||
let relative_path = format!(
|
||||
"{PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT}/{project_key}/{request_key}.json"
|
||||
);
|
||||
let path = directory.join(format!("{request_key}.json"));
|
||||
if let Ok(metadata) = fs::symlink_metadata(&path) {
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("本地 Provider 诊断目标必须是普通文件".to_string());
|
||||
}
|
||||
return Ok(relative_path);
|
||||
}
|
||||
let diagnostic = serde_json::json!({
|
||||
"schemaVersion": "provider-reconciliation-diagnostic.v1",
|
||||
"identity": {
|
||||
"projectId": snapshot.project_id.clone(),
|
||||
"agentId": snapshot.agent_id.clone(),
|
||||
"taskId": snapshot.task_id.clone(),
|
||||
"sessionId": snapshot.session_id.clone(),
|
||||
"runId": snapshot.run_id.clone(),
|
||||
"source": snapshot.source.clone(),
|
||||
"requestKind": snapshot.request_kind.clone(),
|
||||
"requestSlot": snapshot.request_slot.clone(),
|
||||
"requestId": request_id,
|
||||
"appliedSteerCursor": snapshot.applied_steer_cursor,
|
||||
},
|
||||
"provider": {
|
||||
"provider": format!("{:?}", response.provider),
|
||||
"model": response.model.clone(),
|
||||
"responseId": response.response_id.clone(),
|
||||
"finishReason": response.finish_reason.clone(),
|
||||
"usage": response.usage.clone(),
|
||||
},
|
||||
"failure": {
|
||||
"error": error,
|
||||
},
|
||||
"response": {
|
||||
"text": response.text.clone(),
|
||||
"toolCalls": response.tool_calls.iter().map(|call| serde_json::json!({
|
||||
"id": call.id.clone(),
|
||||
"name": call.name.clone(),
|
||||
"arguments": call.arguments.clone(),
|
||||
})).collect::<Vec<_>>(),
|
||||
},
|
||||
});
|
||||
let mut content = serde_json::to_string_pretty(&diagnostic)
|
||||
.map_err(|error| format!("序列化本地 Provider 诊断失败:{error}"))?;
|
||||
content.push('\n');
|
||||
if content.len() > PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"本地 Provider 诊断超过 {PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES} 字节"
|
||||
));
|
||||
}
|
||||
let temporary = path.with_file_name(format!(".{request_key}.tmp.{}", unix_timestamp_nanos()));
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("创建本地 Provider 诊断临时文件失败:{error}"))?;
|
||||
if let Err(error) = file
|
||||
.write_all(content.as_bytes())
|
||||
.and_then(|_| file.sync_data())
|
||||
{
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("写入本地 Provider 诊断失败:{error}"));
|
||||
}
|
||||
drop(file);
|
||||
if let Err(error) = fs::rename(&temporary, &path) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("安装本地 Provider 诊断失败:{error}"));
|
||||
}
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn private_diagnostic_reference(error: &str) -> Option<&str> {
|
||||
let reference = error.split_once(";localDiagnostic=")?.1.trim();
|
||||
let reference = reference.split(';').next()?.trim();
|
||||
if reference.starts_with(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT)
|
||||
&& reference
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "/.-_".contains(character))
|
||||
{
|
||||
Some(reference)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_private_diagnostic_reference(
|
||||
mut audit: serde_json::Value,
|
||||
reference: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
if let (Some(reference), Some(audit)) = (reference, audit.as_object_mut()) {
|
||||
audit.insert(
|
||||
"localDiagnostic".to_string(),
|
||||
serde_json::Value::String(reference),
|
||||
);
|
||||
}
|
||||
audit
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test(
|
||||
root: &Path,
|
||||
@@ -505,6 +647,8 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
|
||||
{
|
||||
return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string());
|
||||
}
|
||||
let private_reference =
|
||||
diagnostic.and_then(|(_, error)| private_diagnostic_reference(error).map(str::to_string));
|
||||
let diagnostic = diagnostic.map(|(failure_kind, error)| {
|
||||
(
|
||||
failure_kind,
|
||||
@@ -544,6 +688,11 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
|
||||
detail
|
||||
})
|
||||
.unwrap_or_else(|| format!("requestId={request_id}"));
|
||||
let public_detail = if let Some(reference) = private_reference.as_deref() {
|
||||
format!("{public_detail} · localDiagnostic={reference}")
|
||||
} else {
|
||||
public_detail
|
||||
};
|
||||
let event_detail = diagnostic
|
||||
.is_some()
|
||||
.then_some(public_detail.as_str())
|
||||
@@ -625,6 +774,7 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
|
||||
"requestSlot": snapshot.request_slot,
|
||||
})
|
||||
};
|
||||
let audit = attach_private_diagnostic_reference(audit, private_reference);
|
||||
let _ = append_agent_db_record(root, audit);
|
||||
emit_game_creator_agent_runtime_update(root, &snapshot.agent_id);
|
||||
Ok(())
|
||||
@@ -676,3 +826,76 @@ where
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_reconciliation_diagnostic_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn private_diagnostic_keeps_raw_response_outside_project_state() {
|
||||
let directory = tempfile::tempdir().expect("diagnostic directory");
|
||||
let snapshot = AgentRuntimeProviderRequestSnapshot {
|
||||
project_id: "project-1".to_string(),
|
||||
agent_id: "project-planning".to_string(),
|
||||
task_id: "task-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
run_id: "run-1".to_string(),
|
||||
source: "agent-delegate".to_string(),
|
||||
goal_id: None,
|
||||
goal_revision: 0,
|
||||
goal_snapshot_fingerprint: String::new(),
|
||||
applied_steer_cursor: 0,
|
||||
request_kind: "tool-plan".to_string(),
|
||||
request_slot: "loop-1-repair-0".to_string(),
|
||||
web_search_enabled: false,
|
||||
allow_idle_context_compaction: false,
|
||||
planning_session_binding: None,
|
||||
};
|
||||
let response = platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "test-model".to_string(),
|
||||
text: "C:\\private\\response".to_string(),
|
||||
finish_reason: Some("completed".to_string()),
|
||||
response_id: Some("response-1".to_string()),
|
||||
usage: None,
|
||||
tool_calls: vec![platform_llm::LlmToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "runtime_tool_plan_submit_gdd".to_string(),
|
||||
arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(),
|
||||
}],
|
||||
};
|
||||
let relative = write_provider_reconciliation_diagnostic_in_dir(
|
||||
directory.path(),
|
||||
&snapshot,
|
||||
"provider-request-1",
|
||||
&response,
|
||||
"绝对路径 C:\\private\\error",
|
||||
)
|
||||
.expect("write diagnostic");
|
||||
assert!(relative.starts_with("diagnostics/provider-reconciliation/"));
|
||||
let persisted =
|
||||
fs::read_to_string(directory.path().join(&relative)).expect("read diagnostic");
|
||||
let persisted: serde_json::Value =
|
||||
serde_json::from_str(&persisted).expect("parse diagnostic");
|
||||
assert_eq!(persisted["response"]["text"], "C:\\private\\response");
|
||||
assert_eq!(
|
||||
persisted["response"]["toolCalls"][0]["arguments"],
|
||||
"{\"path\":\"C:\\\\private\\\\argument\"}"
|
||||
);
|
||||
assert_eq!(persisted["failure"]["error"], "绝对路径 C:\\private\\error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_diagnostic_reference_accepts_only_relative_reference() {
|
||||
assert_eq!(
|
||||
private_diagnostic_reference(
|
||||
"失败;localDiagnostic=diagnostics/provider-reconciliation/p/r.json"
|
||||
),
|
||||
Some("diagnostics/provider-reconciliation/p/r.json")
|
||||
);
|
||||
assert_eq!(
|
||||
private_diagnostic_reference("失败;localDiagnostic=C:\\secret.json"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1409,7 +1409,7 @@ where
|
||||
attempt_snapshot.clone(),
|
||||
provider_request,
|
||||
|provider_request_id, response| {
|
||||
if persist_handoff {
|
||||
let handoff_result = if persist_handoff {
|
||||
let response = canonicalize_handoff_response(response);
|
||||
provider_handoff::write_at(
|
||||
root,
|
||||
@@ -1418,7 +1418,8 @@ where
|
||||
attempt,
|
||||
provider_request_id,
|
||||
&response,
|
||||
)?;
|
||||
)
|
||||
.map(|_| ())
|
||||
} else if persist_tool_plan_handoff {
|
||||
tool_plan_handoff::write_at(
|
||||
root,
|
||||
@@ -1427,7 +1428,24 @@ where
|
||||
attempt,
|
||||
provider_request_id,
|
||||
response,
|
||||
)?;
|
||||
)
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = handoff_result {
|
||||
let error = match write_provider_reconciliation_diagnostic_at(
|
||||
&attempt_snapshot,
|
||||
provider_request_id,
|
||||
response,
|
||||
&error,
|
||||
) {
|
||||
Ok(path) => format!("{error};localDiagnostic={path}"),
|
||||
Err(diagnostic_error) => {
|
||||
format!("{error};localDiagnosticWriteFailed={diagnostic_error}")
|
||||
}
|
||||
};
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 2026-08-27 Provider 成功 handoff 失败时需要保留本地私有原始响应
|
||||
|
||||
- **现象**:Provider 已返回响应,但 tool-plan handoff 因绝对路径或其它内容安全校验失败,Runtime 只留下 `failureKind`、哈希和被压平的 JSON pointer;排障时无法确认实际工具名和完整 arguments。
|
||||
- **处理**:项目 `.agent`、Agent DB 和公共 event 继续只写安全摘要;额外在应用私有配置目录的 `diagnostics/provider-reconciliation/<projectHash>/<requestHash>.json` 保存本次响应、tool calls 和校验错误,供本机人工排障。该文件不参与恢复/重试、不复制到项目、不进入 Git,单文件限制 1 MiB,写入失败不改变 reconciliation 语义。
|
||||
- **排查顺序**:先读 Runtime 状态里的 `localDiagnostic` 相对引用,再在应用私有目录读取诊断,核对 requestId、requestSlot、tool name 和失败 pointer;不要为了取得原文而放宽 handoff 的安全门。
|
||||
|
||||
## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁
|
||||
|
||||
- **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`。
|
||||
|
||||
@@ -1480,6 +1480,8 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transie
|
||||
|
||||
V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是为 `requestKind=tool-plan` 增加独立私有账本 `.agent/runtime/tool-plan-handoffs/<agentKey>/<runKey>.json`,schema 固定为 `game-creator-tool-plan-handoff.v1`。同一 Agent/run 账本按 `(loopIteration, repairAttempt)` 单调保存已成功的 `repair-0..N` Provider 响应,每条绑定完整 retry identity、实际物理 `providerRequestId`、真实 request slot/attempt、Provider/model、去除 thinking 后的响应、thinking 归一化哈希/计数、完整 function call envelope、usage、响应指纹和创建时间。账本使用既有 `0600`、原子替换、父目录同步、`.previous` 恢复和写后完整回读;未知字段、乱序/缺口、重复 slot 冲突、超限、危险可执行路径、密钥或配置痕迹一律失败关闭。
|
||||
|
||||
当成功响应因 handoff 校验失败而进入 `needs-reconciliation` 时,Runtime 额外在应用私有数据目录的 `diagnostics/provider-reconciliation/<projectHash>/<requestHash>.json` 写入一次本地诊断。该诊断只服务人工排障,不参与恢复、重试或业务状态判断,可保留本次 Provider 响应、tool call arguments 和原始校验错误;项目 `.agent`、Agent DB、公共 event、CLI 与报告只保留安全摘要及该私有诊断的相对引用。诊断文件限制为 1 MiB,使用原子写入;应用配置目录不可用或诊断写入失败时,不改变既有 fail-closed reconciliation 语义。
|
||||
|
||||
### 提交、重放与所有权
|
||||
|
||||
- 每个 tool-plan 物理请求的顺序固定为:Provider 成功 -> tool-plan handoff 追加并回读 -> 同一实际 requestId lifecycle `completed` -> 解析/格式修复或动作预检。function arguments 只存在于私有 handoff 与后续 pending/action batch。protocol/repair 公共审计共同保存 `agentId/taskId/sessionId/runId/source/loopIteration/repairAttempt/requestSlot/responseFingerprint/providerRequestIdSha256/protocol`;protocol 只额外保存 `functionCallCount/callIdSha256s/functionNames/responseIdSha256/responseIdChars` 和既有 normalization 字段,其中 function names 必须由 catalog 绑定;repair 只额外保存 attempt/maxAttempts、协议错误/preview 哈希与字符数及 `callIdSha256/functionNameSha256`。公共 task、event、Agent DB、CLI 和报告不得保存原始 callId/callIds/responseId/providerRequestId。两类审计都在 Agent DB append 锁内按完整 Agent/task/Session/run/source/slot 身份做全历史 compare-and-append,不能以受限尾部读取替代幂等。
|
||||
|
||||
Reference in New Issue
Block a user