并行拆分客户端运行时与项目摘要模块

将 runtime_tools 拆为十五个职责模块并保留 Agent 可见性

将 Runner 拆为协议端点分发客户端与所有权模块

将进程会话拆为模型持久化生命周期 IO 恢复与测试模块

将项目摘要拆为十四个无环模块并保留一百一十二个导出

记录并行拆分边界与稳定树验收规则
This commit is contained in:
AIGameCreator App
2026-07-22 14:45:36 +08:00
parent 2ec05ba85d
commit 0aaa191f8d
50 changed files with 26226 additions and 25657 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
use super::*;
pub(crate) fn observe_agent_runtime_action_history(
root: &Path,
agent_id: &str,
current_run_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
match read_agent_runtime_action_history(root, agent_id, current_run_id, input) {
Ok((detail, item_count, truncated, output_truncated)) => AgentRuntimeToolObservation {
tool: "agent.action_history".to_string(),
status: "ok".to_string(),
summary: format!(
"已读取当前 Agent 的 {} 条终态动作{}",
item_count,
if truncated || output_truncated {
",结果已显式截断"
} else {
""
}
),
detail: Some(detail),
},
Err(error) => AgentRuntimeToolObservation {
tool: "agent.action_history".to_string(),
status: "failed".to_string(),
summary: "读取当前 Agent 的动作历史失败".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
},
}
}
pub(in crate::agent) fn read_agent_runtime_action_history(
root: &Path,
agent_id: &str,
current_run_id: &str,
input: &serde_json::Value,
) -> Result<(String, usize, bool, bool), String> {
let query = serde_json::from_value::<AgentRuntimeActionHistoryInput>(input.clone())
.map_err(|error| format!("agent.action_history 输入无效:{error}"))?;
let requested_run_id = query
.run_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(current_run_id)
.to_string();
let run_id = agent_runtime_action_receipt_identity_text(root, &requested_run_id, 160, "runId")
.map_err(|_| "agent.action_history 的 runId 无效".to_string())?;
let action_id = query
.action_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
if action_id
.as_deref()
.is_some_and(|value| !is_valid_agent_runtime_action_id(value))
{
return Err("agent.action_history 的 actionId 无效".to_string());
}
let tool = query
.tool
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
if tool.as_deref().is_some_and(|value| {
!agent_runtime_executable_tools()
.into_iter()
.any(|candidate| candidate == value)
}) {
return Err("agent.action_history 的 tool 不在 Runtime 白名单中".to_string());
}
let status = query
.status
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
if status
.as_deref()
.is_some_and(|value| value.chars().count() > 40 || value.chars().any(char::is_control))
{
return Err("agent.action_history 的 status 无效".to_string());
}
let limit = query
.limit
.unwrap_or(AGENT_RUNTIME_ACTION_HISTORY_DEFAULT_LIMIT);
if limit == 0 || limit > AGENT_RUNTIME_ACTION_HISTORY_MAX_LIMIT {
return Err(format!(
"agent.action_history 的 limit 必须在 1-{} 之间",
AGENT_RUNTIME_ACTION_HISTORY_MAX_LIMIT
));
}
let (records, scan_truncated) =
read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?;
let task_identity = read_all_game_creator_agent_runtime_tasks(
&game_creator_agent_runtime_task_path(root, agent_id),
)?
.into_iter()
.rev()
.find(|record| record.run_id == run_id);
let mut metadata =
std::collections::BTreeMap::<String, AgentRuntimeActionHistoryMetadata>::new();
for (sequence, record) in records.iter().enumerate() {
if agent_db_record_text(record, "agentId") != Some(agent_id)
|| agent_db_record_text(record, "runId") != Some(run_id.as_str())
{
continue;
}
if agent_db_record_text(record, "recordType")
== Some(AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE)
{
continue;
}
let Some(record_action_id) = agent_db_record_text(record, "actionId")
.filter(|value| is_valid_agent_runtime_action_id(value))
else {
continue;
};
let entry = metadata.entry(record_action_id.to_string()).or_default();
entry.task_id = agent_db_record_text(record, "taskId")
.and_then(|value| {
agent_runtime_action_receipt_identity_text(root, value, 96, "taskId").ok()
})
.or_else(|| entry.task_id.clone());
entry.session_id = agent_db_record_text(record, "sessionId")
.and_then(|value| {
agent_runtime_action_receipt_identity_text(root, value, 160, "sessionId").ok()
})
.or_else(|| entry.session_id.clone());
entry.action_fingerprint = agent_db_record_text(record, "actionFingerprint")
.filter(|value| is_valid_agent_runtime_action_fingerprint(value))
.map(ToString::to_string)
.or_else(|| entry.action_fingerprint.clone());
entry.tool = agent_db_record_text(record, "tool")
.and_then(|value| {
agent_runtime_action_receipt_identity_text(root, value, 80, "tool").ok()
})
.or_else(|| entry.tool.clone());
entry.execution_mode = agent_db_record_text(record, "executionMode")
.filter(|value| is_valid_agent_runtime_action_execution_mode(value))
.map(ToString::to_string)
.or_else(|| entry.execution_mode.clone());
entry.input_summary = agent_db_record_text(record, "inputSummary")
.and_then(|value| agent_runtime_action_receipt_safe_text(root, value, 160, None))
.or_else(|| entry.input_summary.clone());
entry.updated_at = record
.get("updatedAt")
.and_then(serde_json::Value::as_u64)
.unwrap_or(entry.updated_at);
entry.sequence = sequence;
}
let mut receipts = std::collections::BTreeMap::<String, AgentRuntimeActionHistoryItem>::new();
let mut receipt_actions = std::collections::BTreeSet::<String>::new();
for (sequence, record) in records.iter().enumerate() {
if agent_db_record_text(record, "agentId") != Some(agent_id)
|| agent_db_record_text(record, "runId") != Some(run_id.as_str())
{
continue;
}
let record_type = agent_db_record_text(record, "recordType").unwrap_or_default();
if record_type != AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
&& record_type != "agent.runtime.tool_observation"
&& record_type != "agent.runtime.tool_action.observed"
{
continue;
}
let Some(record_action_id) = agent_db_record_text(record, "actionId")
.filter(|value| is_valid_agent_runtime_action_id(value))
else {
if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE {
return Err("Agent 持久动作回执包含无效 actionId".to_string());
}
continue;
};
if receipt_actions.contains(record_action_id)
&& record_type != AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
{
continue;
}
let fallback = metadata.get(record_action_id).cloned().unwrap_or_default();
let record_tool = agent_db_record_text(record, "tool")
.map(ToString::to_string)
.or_else(|| fallback.tool.clone())
.unwrap_or_else(|| "unknown".to_string());
let record_tool =
agent_runtime_action_receipt_identity_text(root, &record_tool, 80, "tool")
.unwrap_or_else(|_| "unknown".to_string());
let record_status = if record_type == "agent.runtime.tool_action.observed" {
agent_db_record_text(record, "observationStatus")
.or_else(|| agent_db_record_text(record, "status"))
} else {
agent_db_record_text(record, "status")
.or_else(|| agent_db_record_text(record, "observationStatus"))
}
.unwrap_or("unknown");
if !is_terminal_agent_runtime_action_status(record_status) {
if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE {
return Err(format!(
"Agent 持久动作回执不是终态:actionId={record_action_id}"
));
}
continue;
}
let record_task_id = agent_db_record_text(record, "taskId").and_then(|value| {
agent_runtime_action_receipt_identity_text(root, value, 96, "taskId").ok()
});
let record_session_id = agent_db_record_text(record, "sessionId").and_then(|value| {
agent_runtime_action_receipt_identity_text(root, value, 160, "sessionId").ok()
});
let record_action_fingerprint = agent_db_record_text(record, "actionFingerprint")
.filter(|value| is_valid_agent_runtime_action_fingerprint(value))
.map(ToString::to_string);
let record_execution_mode = agent_db_record_text(record, "executionMode")
.filter(|value| is_valid_agent_runtime_action_execution_mode(value))
.map(ToString::to_string);
if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
&& (record_tool == "unknown"
|| record_task_id.is_none()
|| record_session_id.is_none()
|| record_action_fingerprint.is_none()
|| record_execution_mode.is_none())
{
return Err(format!(
"Agent 持久动作回执身份字段无效:actionId={record_action_id}"
));
}
if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
&& task_identity.as_ref().is_some_and(|task| {
record_task_id.as_deref() != Some(task.task_id.as_str())
|| record_session_id.as_deref() != Some(task.session_id.as_str())
})
{
return Err(format!(
"Agent 持久动作回执与任务账本身份冲突:actionId={record_action_id}"
));
}
if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
&& (fallback
.task_id
.as_deref()
.is_some_and(|value| record_task_id.as_deref() != Some(value))
|| fallback
.session_id
.as_deref()
.is_some_and(|value| record_session_id.as_deref() != Some(value))
|| fallback
.action_fingerprint
.as_deref()
.is_some_and(|value| record_action_fingerprint.as_deref() != Some(value))
|| fallback
.tool
.as_deref()
.is_some_and(|value| record_tool != value)
|| fallback
.execution_mode
.as_deref()
.is_some_and(|value| record_execution_mode.as_deref() != Some(value)))
{
return Err(format!(
"Agent 持久动作回执与动作账本身份冲突:actionId={record_action_id}"
));
}
let record_summary = agent_db_record_text(record, "summary").unwrap_or("工具动作已结束");
let summary = agent_runtime_action_receipt_safe_text(
root,
record_summary,
200,
Some("工具动作已结束,敏感摘要已省略"),
)
.unwrap_or_else(|| "工具动作已结束,敏感摘要已省略".to_string());
let persisted_safe_detail = agent_db_record_text(record, "safeDetail");
let safe_detail = if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE {
persisted_safe_detail.and_then(|value| {
agent_runtime_action_receipt_safe_detail(
root,
&AgentRuntimeToolObservation {
tool: record_tool.clone(),
status: record_status.to_string(),
summary: summary.clone(),
detail: Some(value.to_string()),
},
)
})
} else {
None
};
let detail_unavailable = if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE {
record
.get("detailUnavailable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
|| persisted_safe_detail.is_some() && safe_detail.is_none()
} else {
true
};
let item = AgentRuntimeActionHistoryItem {
agent_id: agent_id.to_string(),
task_id: record_task_id
.or_else(|| fallback.task_id.clone())
.or_else(|| task_identity.as_ref().map(|record| record.task_id.clone()))
.unwrap_or_else(|| agent_id.to_string()),
session_id: record_session_id
.or_else(|| fallback.session_id.clone())
.or_else(|| {
task_identity
.as_ref()
.map(|record| record.session_id.clone())
})
.unwrap_or_default(),
action_id: record_action_id.to_string(),
action_fingerprint: record_action_fingerprint.or(fallback.action_fingerprint),
run_id: run_id.clone(),
tool: record_tool,
execution_mode: record_execution_mode.or(fallback.execution_mode),
status: sanitize_agent_runtime_text(record_status, 40),
input_summary: agent_db_record_text(record, "inputSummary")
.and_then(|value| agent_runtime_action_receipt_safe_text(root, value, 160, None))
.or(fallback.input_summary),
summary,
safe_detail,
detail_unavailable,
updated_at: record
.get("updatedAt")
.and_then(serde_json::Value::as_u64)
.unwrap_or(fallback.updated_at),
sequence: sequence.max(fallback.sequence),
};
if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE {
receipt_actions.insert(record_action_id.to_string());
}
receipts.insert(record_action_id.to_string(), item);
}
let include_history_tool = tool.as_deref() == Some("agent.action_history");
let mut items = receipts
.into_values()
.filter(|item| {
action_id
.as_deref()
.is_none_or(|value| item.action_id == value)
})
.filter(|item| tool.as_deref().is_none_or(|value| item.tool == value))
.filter(|item| status.as_deref().is_none_or(|value| item.status == value))
.filter(|item| include_history_tool || item.tool != "agent.action_history")
.collect::<Vec<_>>();
items.sort_by(|left, right| {
(left.updated_at, left.sequence, left.action_id.as_str()).cmp(&(
right.updated_at,
right.sequence,
right.action_id.as_str(),
))
});
let mut truncated = scan_truncated || items.len() > limit;
if items.len() > limit {
items = items.split_off(items.len() - limit);
}
let mut output_truncated = false;
let initial = serialize_agent_runtime_action_history_detail(
&run_id,
&items,
truncated,
output_truncated,
)?;
if initial.chars().count() <= AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS {
return Ok((initial, items.len(), truncated, output_truncated));
}
output_truncated = true;
if output_truncated {
for item in items.iter_mut() {
item.safe_detail = None;
item.detail_unavailable = true;
item.summary = sanitize_agent_runtime_text(&item.summary, 100);
item.input_summary = None;
}
}
loop {
let detail = serialize_agent_runtime_action_history_detail(
&run_id,
&items,
truncated,
output_truncated,
)?;
if detail.chars().count() <= AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS {
return Ok((detail, items.len(), truncated, output_truncated));
}
if items.len() <= 1 {
return Err("单条 Agent 动作历史超过结构化输出上限".to_string());
}
items.remove(0);
truncated = true;
}
}
pub(in crate::agent) fn serialize_agent_runtime_action_history_detail(
run_id: &str,
items: &[AgentRuntimeActionHistoryItem],
truncated: bool,
output_truncated: bool,
) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"runId": run_id,
"count": items.len(),
"truncated": truncated,
"outputTruncated": output_truncated,
"actions": items,
}))
.map_err(|error| format!("序列化 Agent 动作历史失败:{error}"))
}
pub(in crate::agent) fn agent_db_record_text<'a>(
record: &'a serde_json::Value,
field: &str,
) -> Option<&'a str> {
record.get(field).and_then(serde_json::Value::as_str)
}
pub(crate) fn is_valid_agent_runtime_action_id(value: &str) -> bool {
value.strip_prefix("action-").is_some_and(|suffix| {
suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
})
}
pub(in crate::agent) fn is_valid_agent_runtime_action_fingerprint(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
pub(in crate::agent) fn is_valid_agent_runtime_action_execution_mode(value: &str) -> bool {
matches!(
value,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO | AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION
)
}
pub(in crate::agent) fn is_terminal_agent_runtime_action_status(value: &str) -> bool {
matches!(
value,
"ok" | "failed"
| "command-failed"
| "verification-failed"
| "blocked"
| "rejected"
| "cancelled"
| "budget-exhausted"
| AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
use super::*;
pub(in crate::agent) fn observe_agent_runtime_memory(
root: &Path,
agent_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let scope = input
.get("scope")
.and_then(|value| value.as_str())
.unwrap_or("blackboard")
.trim();
let result = match scope {
"session" => read_optional_text(&root.join("memory/session.md")),
"project" => read_optional_text(&root.join("memory/project.md")),
"blackboard" => read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH)),
"agent" if agent_id.starts_with("child-") => {
read_isolated_agent_private_memory_at(root, agent_id)
}
"agent" => read_local_agent_memory_at(root, agent_id).map(|result| result.content),
_ => Err(format!("不支持的记忆 scope{scope}")),
};
observation_from_text_result_preserving_tail("memory.read", result, "已读取记忆")
}
pub(in crate::agent) fn observe_agent_runtime_memory_write(
root: &Path,
agent_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let scope = input
.get("scope")
.and_then(|value| value.as_str())
.unwrap_or("agent")
.trim();
let isolated_child = agent_id.starts_with("child-");
if isolated_child && scope != "agent" {
return AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "blocked".to_string(),
summary: format!(
"动态隔离子 Agent 只能写入自己的 instance 私有记忆,拒绝 scope={scope}"
),
detail: None,
};
}
let content = agent_runtime_tool_input_text(input, &["content", "summary", "message"]);
if content.trim().is_empty() {
return AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "failed".to_string(),
summary: "缺少 content".to_string(),
detail: None,
};
}
let title = agent_runtime_tool_input_text(input, &["title", "topic"]);
let entry = agent_runtime_memory_write_entry(agent_id, &title, &content);
let overwrite = agent_runtime_tool_input_text(input, &["mode", "writeMode"])
.eq_ignore_ascii_case("overwrite");
let target_agent_id = if scope == "agent" {
let target_agent_id = agent_runtime_tool_input_text(
input,
&["agentId", "agent_id", "targetAgentId", "target_agent_id"],
);
let target_agent_id = if target_agent_id.trim().is_empty() {
agent_id.to_string()
} else {
match normalize_game_creator_runtime_agent_id(target_agent_id.as_str()) {
Ok(target_agent_id) => target_agent_id,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
};
}
}
};
if target_agent_id != agent_id {
return AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "blocked".to_string(),
summary: format!(
"Agent 私有记忆只能由本人写入:{agent_id} 不能写入 {target_agent_id}"
),
detail: Some(
"跨 Agent 共享稳定结论请使用 blackboard.write;给单个 Agent 留上下文请使用 agent.message。"
.to_string(),
),
};
}
Some(target_agent_id)
} else {
None
};
let _lock = match acquire_project_write_lock(root, "memory.write") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
};
}
};
if let Err(error) = advance_agent_runtime_project_revision_locked(root) {
return agent_runtime_revision_advance_failure_observation(root, "memory.write", &error);
}
let result = if scope == "agent" {
let target_agent_id = target_agent_id.unwrap_or_else(|| agent_id.to_string());
if isolated_child {
read_isolated_agent_private_memory_at(root, &target_agent_id)
.and_then(|existing| {
let next_content =
agent_runtime_next_memory_content(&existing, &entry, overwrite);
write_isolated_agent_private_memory_at(root, &target_agent_id, &next_content)
})
.and_then(|path| {
let relative_path = normalize_relative_path(&path)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.memory.write",
"agentId": agent_id,
"targetAgentId": target_agent_id,
"scope": "agent",
"path": relative_path,
"mode": if overwrite { "overwrite" } else { "append" },
"memoryLane": "isolated-instance-private",
}),
)
.map(|()| format!("已写入动态隔离 Agent 私有记忆 {target_agent_id}"))
})
} else {
read_local_agent_memory_at(root, &target_agent_id)
.and_then(|existing| {
let next_content =
agent_runtime_next_memory_content(&existing.content, &entry, overwrite);
write_local_agent_memory_at(root, &target_agent_id, &next_content)
})
.and_then(|memory| {
let relative_path = relative_project_path(root, Path::new(&memory.path))?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.memory.write",
"agentId": agent_id,
"targetAgentId": target_agent_id,
"scope": "agent",
"path": relative_path,
"mode": if overwrite { "overwrite" } else { "append" },
}),
)
.map(|()| format!("已写入 Agent 记忆 {}", memory.task_id))
})
}
} else {
let game_scope = match scope {
"session" | "short" => "short",
"project" | "long" => "long",
"blackboard" => "blackboard",
_ => {
return AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "failed".to_string(),
summary: format!("不支持的记忆 scope{scope}"),
detail: None,
};
}
};
read_local_game_memory_at(root, game_scope)
.and_then(|existing| {
let next_content =
agent_runtime_next_memory_content(&existing.content, &entry, overwrite);
write_local_game_memory_at(root, game_scope, &next_content)
})
.and_then(|memory| {
let relative_path = relative_project_path(root, Path::new(&memory.path))?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.memory.write",
"agentId": agent_id,
"scope": memory.scope,
"path": relative_path,
"mode": if overwrite { "overwrite" } else { "append" },
}),
)
.map(|()| format!("已写入 {} 记忆", memory.scope))
})
};
match result {
Ok(summary) => AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "ok".to_string(),
summary,
detail: Some(entry),
},
Err(error) => AgentRuntimeToolObservation {
tool: "memory.write".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
},
}
}
pub(in crate::agent) fn resolve_game_creator_agent_runtime_session_id_for_run_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<String, String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
let run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id);
if let Some(task) =
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)?
{
return resolve_agent_conversation_session_id_at(
root,
&agent_id,
Some(&task.session_id),
false,
);
}
let runtime = read_game_creator_agent_runtime_at(root, &agent_id)?;
if runtime.state.run_id == run_id && !runtime.state.session_id.trim().is_empty() {
return resolve_agent_conversation_session_id_at(
root,
&agent_id,
Some(&runtime.state.session_id),
false,
);
}
resolve_agent_conversation_session_id_at(root, &agent_id, None, false)
}
pub(in crate::agent) fn observe_agent_runtime_conversation(
root: &Path,
agent_id: &str,
run_id: &str,
) -> AgentRuntimeToolObservation {
let result = resolve_game_creator_agent_runtime_session_id_for_run_at(root, agent_id, run_id)
.and_then(|session_id| {
render_local_conversation_prompt_context_for_session(
root,
Some(agent_id),
Some(&session_id),
)
});
observation_from_text_result_preserving_tail(
"conversation.read",
result,
"已读取本 Agent 最近对话",
)
}
pub(in crate::agent) fn observe_agent_runtime_assets(root: &Path) -> AgentRuntimeToolObservation {
observation_from_text_result(
"asset.list",
render_local_asset_prompt_context(root),
"已读取项目资产清单",
)
}
pub(in crate::agent) fn observe_agent_runtime_project_index(
root: &Path,
) -> AgentRuntimeToolObservation {
let result = build_repository_startup_context_at(root)
.map(|context| render_repository_startup_context_for_prompt(&context));
observation_from_text_result("project.index", result, "已刷新仓库启动上下文")
}
pub(in crate::agent) fn observe_agent_runtime_project_search(
root: &Path,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let query = agent_runtime_tool_input_text(input, &["query", "text", "needle"]);
if query.trim().is_empty() {
return AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "failed".to_string(),
summary: "缺少 query".to_string(),
detail: None,
};
}
if query.contains('\n') || query.contains('\r') || query.chars().count() > 256 {
return AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "failed".to_string(),
summary: "query 必须是 1-256 字符的单行字面文本".to_string(),
detail: None,
};
}
let scope = agent_runtime_tool_input_text(input, &["path", "scope"]);
let max_results = agent_runtime_tool_input_usize(input, &["maxResults", "max_results"])
.unwrap_or(AGENT_RUNTIME_PROJECT_SEARCH_DEFAULT_RESULTS)
.clamp(1, AGENT_RUNTIME_PROJECT_SEARCH_MAX_RESULTS);
let case_sensitive = input
.get("caseSensitive")
.or_else(|| input.get("case_sensitive"))
.and_then(|value| value.as_bool())
.unwrap_or(false);
match search_agent_runtime_project(root, &scope, &query, max_results, case_sensitive) {
Ok((matches, scanned_files, truncated)) => {
let match_count = matches.len();
let mut lines = vec![format!("scannedFiles: {scanned_files}")];
lines.extend(matches);
if match_count == 0 {
lines.push("未找到匹配文本".to_string());
} else if truncated {
lines.push(format!("结果已限制为前 {max_results}"));
}
AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: format!(
"已搜索项目:{match_count} 个匹配(扫描 {scanned_files} 个文本文件)"
),
detail: Some(truncate_agent_runtime_text(
sanitize_prompt_context(&lines.join("\n")).as_str(),
AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS,
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
pub(in crate::agent) fn search_agent_runtime_project(
root: &Path,
scope: &str,
query: &str,
max_results: usize,
case_sensitive: bool,
) -> Result<(Vec<String>, usize, bool), String> {
validate_project_root(root)?;
let start = if scope.trim().is_empty() || scope.trim() == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, scope.trim())?
};
if !start.exists() {
return Err(format!("搜索范围不存在:{}", scope.trim()));
}
let normalized_query = (!case_sensitive).then(|| query.to_lowercase());
let mut pending = vec![start];
let mut matches = Vec::new();
let mut scanned_files = 0usize;
let mut visited_entries = 0usize;
let mut truncated = false;
while let Some(path) = pending.pop() {
if visited_entries >= AGENT_RUNTIME_PROJECT_SEARCH_MAX_ENTRIES
|| scanned_files >= AGENT_RUNTIME_PROJECT_SEARCH_MAX_FILES
{
truncated = true;
break;
}
visited_entries += 1;
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取搜索路径失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
let mut children = fs::read_dir(&path)
.map_err(|error| format!("读取搜索目录失败:{}: {error}", path.display()))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect::<Vec<_>>();
children.sort_by(|left, right| right.cmp(left));
for child in children {
let Ok(relative_path) = agent_runtime_relative_project_path(root, &child) else {
continue;
};
if agent_runtime_project_search_ignored_path(&relative_path) {
continue;
}
pending.push(child);
}
continue;
}
if !metadata.is_file() || metadata.len() > AGENT_RUNTIME_PROJECT_SEARCH_MAX_FILE_BYTES {
continue;
}
let relative_path = agent_runtime_relative_project_path(root, &path)?;
if agent_runtime_project_search_ignored_path(&relative_path) {
continue;
}
let Ok(file) = read_local_project_file_at(root, &relative_path) else {
continue;
};
scanned_files += 1;
for (line_index, line) in file.content.lines().enumerate() {
let is_match = if case_sensitive {
line.contains(query)
} else {
line.to_lowercase()
.contains(normalized_query.as_deref().unwrap_or_default())
};
if !is_match {
continue;
}
matches.push(format!(
"{}:{}: {}",
relative_path,
line_index + 1,
sanitize_agent_runtime_text(line.trim(), 320)
));
if matches.len() >= max_results {
truncated = true;
return Ok((matches, scanned_files, truncated));
}
}
}
Ok((matches, scanned_files, truncated))
}
pub(in crate::agent) fn agent_runtime_relative_project_path(
root: &Path,
path: &Path,
) -> Result<String, String> {
let relative = path
.strip_prefix(root)
.map_err(|_| "搜索路径不在项目目录内".to_string())?;
let normalized = relative
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
normalize_relative_path(&normalized)
}
pub(in crate::agent) fn agent_runtime_project_search_ignored_path(relative_path: &str) -> bool {
relative_path.split('/').any(|part| {
let lower = part.to_ascii_lowercase();
matches!(
lower.as_str(),
".agent" | ".git" | "node_modules" | "dist" | "build" | "target" | ".next" | "coverage"
) || lower == ".env"
|| lower.starts_with(".env.")
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,494 @@
use super::*;
pub(in crate::agent) fn observe_agent_runtime_file(
root: &Path,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let path = agent_runtime_tool_input_text(input, &["path"]);
if path.is_empty() {
return AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "failed".to_string(),
summary: "缺少 path".to_string(),
detail: None,
};
}
let start_line = agent_runtime_tool_input_usize(input, &["startLine", "start_line"])
.unwrap_or(1)
.max(1);
let max_lines = agent_runtime_tool_input_usize(input, &["maxLines", "max_lines"])
.unwrap_or(AGENT_RUNTIME_FILE_READ_DEFAULT_LINES)
.clamp(1, AGENT_RUNTIME_FILE_READ_MAX_LINES);
match read_local_project_file_at(root, &path) {
Ok(result) => {
let content_sha256 = format!("{:x}", Sha256::digest(result.content.as_bytes()));
let lines = result.content.lines().collect::<Vec<_>>();
let total_lines = lines.len();
if total_lines == 0 {
return AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "ok".to_string(),
summary: format!("已读取 {}(空文件)", result.path),
detail: Some(format!(
"{} · sha256={} · lines 0 of 0",
result.path, content_sha256
)),
};
}
if start_line > total_lines.max(1) {
return AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "failed".to_string(),
summary: format!("startLine {start_line} 超出文件范围(共 {total_lines} 行)"),
detail: None,
};
}
let selected = lines
.iter()
.skip(start_line.saturating_sub(1))
.take(max_lines)
.enumerate()
.map(|(index, line)| {
format!(
"{} | {}",
start_line + index,
sanitize_agent_runtime_text(line, 1_000)
)
})
.collect::<Vec<_>>();
let end_line = if selected.is_empty() {
0
} else {
start_line + selected.len() - 1
};
let has_more = end_line < total_lines;
let mut detail = vec![format!(
"{} · sha256={} · lines {}-{} of {}",
result.path, content_sha256, start_line, end_line, total_lines
)];
detail.extend(selected);
if has_more {
detail.push(format!(
"... 还有 {} 行,可从 startLine={} 继续读取",
total_lines - end_line,
end_line + 1
));
}
AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "ok".to_string(),
summary: format!(
"已读取 {} 第 {}-{} 行(共 {} 行)",
result.path, start_line, end_line, total_lines
),
detail: Some(truncate_agent_runtime_text(
sanitize_prompt_context(&detail.join("\n")).as_str(),
AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS,
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
pub(in crate::agent) fn observe_agent_runtime_file_write(
root: &Path,
agent_id: &str,
run_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let path = input
.get("path")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim();
if path.is_empty() {
return AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: "缺少 path".to_string(),
detail: None,
};
}
let Some(content) = input.get("content").and_then(|value| value.as_str()) else {
return AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: "缺少 content".to_string(),
detail: None,
};
};
if content.trim().is_empty() {
return AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: "缺少非空 content".to_string(),
detail: None,
};
}
let content_chars = content.chars().count();
if content_chars > AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS {
return AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: format!(
"content 不能超过 {} 字符",
AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS
),
detail: None,
};
}
let path = match normalize_relative_path(path)
.and_then(|path| reject_agent_runtime_private_control_path(&path).map(|()| path))
{
Ok(path) => path,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let _lock =
match acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "file.write") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if let Err(error) =
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.write")
{
return agent_runtime_mutation_gate_failure_observation(root, "file.write", &error);
}
let observation_content = truncate_agent_runtime_text(
sanitize_prompt_context(content).as_str(),
AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS,
);
let result = write_local_project_file_at(root, &path, content).and_then(|written| {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.file.write",
"agentId": agent_id,
"path": written.path,
}),
)
.map(|()| written)
});
match result {
Ok(written) => AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "ok".to_string(),
summary: format!("已写入 {}", written.path),
detail: Some(observation_content),
},
Err(error) => AgentRuntimeToolObservation {
tool: "file.write".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
pub(in crate::agent) fn observe_agent_runtime_file_delete(
root: &Path,
agent_id: &str,
run_id: &str,
pending_action: Option<&AgentRuntimePendingToolAction>,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let path = agent_runtime_tool_input_text(input, &["path"]);
if path.is_empty() {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "failed".to_string(),
summary: "缺少 path".to_string(),
detail: None,
};
}
let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"file.delete",
) {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if let Some(blocked) = game_creator_agent_runtime_tool_policy_block_after_lock(
root,
agent_id,
"file.delete",
pending_action,
) {
return agent_runtime_tool_policy_block_observation("file.delete", blocked);
}
if let Some(pending_action) = pending_action {
if pending_action.agent_id != agent_id || pending_action.run_id != run_id {
return agent_runtime_mutation_gate_failure_observation(
root,
"file.delete",
"Agent Runtime file.delete 的 pending action 身份不匹配",
);
}
if let Err(error) =
validate_agent_runtime_pending_verification_gate_before(root, pending_action)
{
return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error);
}
}
if let Err(error) =
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete")
{
return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error);
}
let deleted = match delete_local_project_file_at(root, &path) {
Ok(deleted) => deleted,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let audit_result = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.file.delete",
"agentId": agent_id,
"path": deleted.path,
"deleted": deleted.deleted,
}),
);
if let Err(error) = audit_result {
let audit_error = redact_agent_runtime_project_paths(root, &error, 240);
if deleted.deleted {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: format!(
"文件已删除但 Agent DB 审计失败,需要人工核对:{}",
deleted.path
),
detail: Some(format!(
"sideEffectApplied=true; deleted=true; auditError={audit_error}"
)),
};
}
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "failed".to_string(),
summary: audit_error,
detail: None,
};
}
AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "ok".to_string(),
summary: if deleted.deleted {
format!("已删除 {}", deleted.path)
} else {
format!("目标文件已不存在:{}", deleted.path)
},
detail: Some(format!("deleted={}", deleted.deleted)),
}
}
pub(in crate::agent) fn observe_agent_runtime_file_patch(
root: &Path,
agent_id: &str,
run_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let path = agent_runtime_tool_input_text(input, &["path"]);
if path.is_empty() {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: "缺少 path".to_string(),
detail: None,
};
}
let old_text = input
.get("oldText")
.or_else(|| input.get("old_text"))
.and_then(|value| value.as_str());
let Some(old_text) = old_text.filter(|value| !value.is_empty()) else {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: "缺少非空 oldText".to_string(),
detail: None,
};
};
let Some(new_text) = input
.get("newText")
.or_else(|| input.get("new_text"))
.and_then(|value| value.as_str())
else {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: "缺少 newText".to_string(),
detail: None,
};
};
if old_text.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES
|| new_text.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES
{
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: format!(
"oldText/newText 单段不能超过 {} bytes",
AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES
),
detail: None,
};
}
let expected_replacements =
agent_runtime_tool_input_usize(input, &["expectedReplacements", "expected_replacements"])
.unwrap_or(1);
if expected_replacements == 0 || expected_replacements > 100 {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: "expectedReplacements 必须在 1-100 之间".to_string(),
detail: None,
};
}
let path = match normalize_relative_path(&path)
.and_then(|path| reject_agent_runtime_private_control_path(&path).map(|()| path))
{
Ok(path) => path,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let _lock =
match acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "file.patch") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if let Err(error) =
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.patch")
{
return agent_runtime_mutation_gate_failure_observation(root, "file.patch", &error);
}
let current = match read_local_project_file_at(root, &path) {
Ok(current) => current,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if current.content.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: format!(
"目标文件超过局部修改上限:{} bytes",
AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES
),
detail: None,
};
}
let actual_replacements = current.content.match_indices(old_text).count();
if actual_replacements != expected_replacements {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: format!(
"oldText 匹配数不符:期望 {expected_replacements},实际 {actual_replacements};文件未修改"
),
detail: None,
};
}
let next_content = current
.content
.replacen(old_text, new_text, expected_replacements);
if next_content.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES {
return AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: format!(
"修改后文件超过局部修改上限:{} bytes",
AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES
),
detail: None,
};
}
let before_bytes = current.content.len();
let after_bytes = next_content.len();
let result = write_local_project_file_at(root, &path, &next_content).and_then(|written| {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.file.patch",
"agentId": agent_id,
"path": written.path,
"replacementCount": expected_replacements,
"beforeBytes": before_bytes,
"afterBytes": after_bytes,
}),
)
.map(|()| written)
});
match result {
Ok(written) => AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "ok".to_string(),
summary: format!(
"已局部修改 {}{} 处替换)",
written.path, expected_replacements
),
detail: Some(format!(
"replacements={expected_replacements} · bytes={before_bytes}->{after_bytes}"
)),
},
Err(error) => AgentRuntimeToolObservation {
tool: "file.patch".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
@@ -0,0 +1,150 @@
use super::*;
pub(in crate::agent) fn agent_runtime_tool_input_text(
input: &serde_json::Value,
keys: &[&str],
) -> String {
for key in keys {
if let Some(value) = input.get(*key).and_then(|value| value.as_str()) {
return value.trim().to_string();
}
}
String::new()
}
pub(in crate::agent) fn agent_runtime_tool_input_usize(
input: &serde_json::Value,
keys: &[&str],
) -> Option<usize> {
for key in keys {
let Some(value) = input.get(*key) else {
continue;
};
if let Some(number) = value.as_u64() {
return usize::try_from(number).ok();
}
if let Some(text) = value.as_str() {
if let Ok(number) = text.trim().parse::<usize>() {
return Some(number);
}
}
}
None
}
pub(in crate::agent) fn agent_runtime_tool_input_string_list(
input: &serde_json::Value,
keys: &[&str],
) -> Vec<String> {
for key in keys {
let Some(value) = input.get(*key) else {
continue;
};
if let Some(items) = value.as_array() {
return items
.iter()
.filter_map(|item| item.as_str())
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect();
}
if let Some(item) = value.as_str() {
return item
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect();
}
}
Vec::new()
}
pub(in crate::agent) fn agent_runtime_memory_write_entry(
agent_id: &str,
title: &str,
content: &str,
) -> String {
let title = if title.trim().is_empty() {
"运行结论".to_string()
} else {
sanitize_agent_runtime_text(title, 80)
};
let content = truncate_agent_runtime_text(
sanitize_prompt_context(content).as_str(),
AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS,
);
format!("## Agent {agent_id} - {title}\n\n{content}\n")
}
pub(in crate::agent) fn agent_runtime_next_memory_content(
existing: &str,
entry: &str,
overwrite: bool,
) -> String {
if overwrite || existing.trim().is_empty() {
return format!("{}\n", entry.trim());
}
format!("{}\n\n{}\n", existing.trim_end(), entry.trim())
}
pub(in crate::agent) fn observation_from_text_result(
tool: &str,
result: Result<String, String>,
success_summary: &str,
) -> AgentRuntimeToolObservation {
observation_from_text_result_with_truncation(tool, result, success_summary, false)
}
pub(in crate::agent) fn observation_from_text_result_preserving_tail(
tool: &str,
result: Result<String, String>,
success_summary: &str,
) -> AgentRuntimeToolObservation {
observation_from_text_result_with_truncation(tool, result, success_summary, true)
}
pub(in crate::agent) fn observation_from_text_result_with_truncation(
tool: &str,
result: Result<String, String>,
success_summary: &str,
preserve_tail: bool,
) -> AgentRuntimeToolObservation {
match result {
Ok(content) => {
let sanitized = sanitize_prompt_context(&content);
let detail = if preserve_tail {
truncate_agent_runtime_text_preserving_tail(
sanitized.as_str(),
AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS,
)
} else {
truncate_agent_runtime_text(
sanitized.as_str(),
AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS,
)
};
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "ok".to_string(),
summary: if detail.trim().is_empty() {
format!("{success_summary},内容为空")
} else {
success_summary.to_string()
},
detail: if detail.trim().is_empty() {
None
} else {
Some(detail)
},
}
}
Err(error) => AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
},
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
use super::*;
pub(in crate::agent) fn refresh_game_creator_agent_runtime_tool_policy(
root: &Path,
state: &mut AgentRuntimeState,
) -> Result<(), String> {
state.tool_policy = agent_runtime_tool_policy_snapshot_for_run_at(
root,
&state.agent_id,
&state.run_id,
Some(&state.run_profile),
Some(&state.run_profile_binding_fingerprint),
)?;
state.run_profile = state.tool_policy.run_profile.clone();
state.run_profile_binding_fingerprint =
state.tool_policy.run_profile_binding_fingerprint.clone();
Ok(())
}
pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run(
root: &Path,
agent_id: &str,
run_id: &str,
stored_profile: Option<&str>,
stored_binding_fingerprint: Option<&str>,
command_id: &str,
) -> Option<AgentRuntimeToolPolicyBlock> {
let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id);
let (run_profile, _) = match agent_runtime_run_profile_identity_at(
root,
agent_id,
run_id,
stored_profile,
stored_binding_fingerprint,
) {
Ok(identity) => identity,
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
};
match blocked {
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_))
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS.contains(&command_id) =>
{
None
}
blocked => blocked,
}
}
pub(in crate::agent) fn agent_runtime_effective_tool_policy_at(
root: &Path,
agent_id: &str,
) -> Result<ProjectAgentPermissionPolicy, String> {
let view = match read_project_permission_policy_at(root) {
Ok(view) => view,
Err(error) => return Err(error),
};
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
let isolated = agent_id.starts_with("child-");
let policy_agent_id = game_creator_runtime_template_agent_id_at(root, &agent_id)?;
let mut denied_commands = view.policy.denied_commands.clone();
let mut confirm_commands = view.policy.confirm_commands.clone();
if let Some(agent_policy) = view.policy.agent_policies.get(&policy_agent_id) {
for command_id in &agent_policy.denied_commands {
if !denied_commands.iter().any(|command| command == command_id) {
denied_commands.push(command_id.clone());
}
}
for command_id in &agent_policy.confirm_commands {
if !confirm_commands.iter().any(|command| command == command_id) {
confirm_commands.push(command_id.clone());
}
}
}
if isolated {
for command_id in ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS {
if !denied_commands
.iter()
.any(|command| command.as_str() == *command_id)
{
denied_commands.push((*command_id).to_string());
}
}
}
confirm_commands.retain(|command| !denied_commands.contains(command));
Ok(ProjectAgentPermissionPolicy {
denied_commands,
confirm_commands,
})
}
pub(in crate::agent) fn game_creator_agent_runtime_tool_policy_rule(
root: &Path,
agent_id: &str,
command_id: &str,
) -> Option<AgentRuntimeToolPolicyBlock> {
let view = match read_project_permission_policy_at(root) {
Ok(view) => view,
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
};
let agent_id = match normalize_game_creator_runtime_agent_id(agent_id) {
Ok(agent_id) => agent_id,
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
};
if agent_id.starts_with("child-")
&& ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS.contains(&command_id)
{
return Some(AgentRuntimeToolPolicyBlock::Denied(format!(
"动态隔离子 Agent 默认拒绝无 writeScope 落点的命令:{command_id}"
)));
}
let policy_agent_id = match game_creator_runtime_template_agent_id_at(root, &agent_id) {
Ok(policy_agent_id) => policy_agent_id,
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
};
if view
.policy
.denied_commands
.iter()
.any(|command| command == command_id)
{
return Some(AgentRuntimeToolPolicyBlock::Denied(format!(
"项目权限策略拒绝执行:{command_id}"
)));
}
if view
.policy
.agent_policies
.get(&policy_agent_id)
.map(|policy| {
policy
.denied_commands
.iter()
.any(|command| command == command_id)
})
.unwrap_or(false)
{
return Some(AgentRuntimeToolPolicyBlock::Denied(format!(
"Agent 权限策略拒绝执行:{policy_agent_id} / {command_id}"
)));
}
if view
.policy
.confirm_commands
.iter()
.any(|command| command == command_id)
{
return Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(format!(
"项目权限策略要求用户确认:{command_id}"
)));
}
if view
.policy
.agent_policies
.get(&policy_agent_id)
.map(|policy| {
policy
.confirm_commands
.iter()
.any(|command| command == command_id)
})
.unwrap_or(false)
{
return Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(format!(
"Agent 权限策略要求用户确认:{policy_agent_id} / {command_id}"
)));
}
None
}
pub(in crate::agent) fn game_creator_agent_runtime_tool_policy_block(
root: &Path,
agent_id: &str,
run_id: &str,
command_id: &str,
action_fingerprint: &str,
) -> Option<AgentRuntimeToolPolicyBlock> {
let blocked = game_creator_agent_runtime_tool_policy_rule_for_run(
root, agent_id, run_id, None, None, command_id,
)?;
if !matches!(
&blocked,
AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)
) {
return Some(blocked);
}
let agent_id = match normalize_game_creator_runtime_agent_id(agent_id) {
Ok(agent_id) => agent_id,
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
};
match consume_game_creator_agent_runtime_tool_confirmation(
root,
&agent_id,
run_id,
command_id,
action_fingerprint,
) {
Ok(true) => None,
Ok(false) => Some(blocked),
Err(error) => Some(AgentRuntimeToolPolicyBlock::Denied(error)),
}
}
pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock(
root: &Path,
agent_id: &str,
command_id: &str,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> Option<AgentRuntimeToolPolicyBlock> {
let blocked = match pending_action {
Some(pending) => game_creator_agent_runtime_tool_policy_rule_for_run(
root,
agent_id,
&pending.run_id,
Some(&pending.run_profile),
Some(&pending.run_profile_binding_fingerprint),
command_id,
),
None => game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id),
}?;
let confirmation_approved = pending_action
.map(|pending| !pending.is_auto() && pending.approved())
.unwrap_or(false);
if matches!(
&blocked,
AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)
) && confirmation_approved
{
None
} else {
Some(blocked)
}
}
pub(in crate::agent) fn validate_agent_runtime_pending_action_after_lock(
root: &Path,
agent_id: &str,
run_id: &str,
tool: &str,
action_id: Option<&str>,
action_fingerprint: &str,
pending_action: &AgentRuntimePendingToolAction,
) -> Result<(), String> {
validate_agent_runtime_pending_tool_action_record(root, pending_action)?;
if pending_action.agent_id != agent_id || pending_action.run_id != run_id {
return Err("Agent Runtime pending action 身份不匹配".to_string());
}
validate_agent_runtime_pending_current_goal_snapshot(root, pending_action)?;
if !pending_action.approved() {
return Err("Agent Runtime pending action 尚未获准执行".to_string());
}
if pending_action.action.tool != tool
|| pending_action.action_fingerprint != action_fingerprint
|| action_id != Some(pending_action.action_id.as_str())
{
return Err(format!("Agent Runtime {tool} 的 actionId 或动作指纹已变化"));
}
Ok(())
}
@@ -0,0 +1,401 @@
use super::*;
pub(in crate::agent) fn observe_agent_runtime_preview_start(
root: &Path,
agent_id: &str,
) -> AgentRuntimeToolObservation {
let registry = game_creator_preview_registry();
let result = start_local_game_preview_at(root, &registry).and_then(|preview| {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.preview.start",
"agentId": agent_id,
"status": "running",
"url": preview.url,
"port": preview.port,
}),
)
.map(|()| preview)
});
match result {
Ok(preview) => AgentRuntimeToolObservation {
tool: "preview.start".to_string(),
status: "ok".to_string(),
summary: format!("preview.start 已启动:{}", preview.url),
detail: Some(format!("url={}, port={}", preview.url, preview.port)),
},
Err(error) => AgentRuntimeToolObservation {
tool: "preview.start".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(in crate::agent) struct AgentRuntimePreviewValidationInput {
#[serde(default = "default_agent_runtime_preview_validation_viewports")]
pub(in crate::agent) viewports: Vec<BrowserValidationViewport>,
#[serde(default)]
pub(in crate::agent) expected_text: Vec<String>,
#[serde(default = "default_agent_runtime_preview_validation_settle_ms")]
pub(in crate::agent) settle_ms: u64,
#[serde(default = "default_agent_runtime_preview_validation_fail_on_console_error")]
pub(in crate::agent) fail_on_console_error: bool,
#[serde(default)]
pub(in crate::agent) playtest_scenario: Option<BrowserPlaytestScenario>,
}
pub(in crate::agent) fn default_agent_runtime_preview_validation_viewports(
) -> Vec<BrowserValidationViewport> {
vec![
BrowserValidationViewport::Desktop,
BrowserValidationViewport::Mobile,
]
}
pub(in crate::agent) fn default_agent_runtime_preview_validation_settle_ms() -> u64 {
800
}
pub(in crate::agent) fn default_agent_runtime_preview_validation_fail_on_console_error() -> bool {
true
}
pub(in crate::agent) fn browser_validation_relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
pub(in crate::agent) async fn observe_agent_runtime_preview_validate(
root: &Path,
agent_id: &str,
run_id: &str,
action_id: Option<&str>,
action_fingerprint: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let input = match serde_json::from_value::<AgentRuntimePreviewValidationInput>(input.clone()) {
Ok(input) => input,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(
&format!("preview.validate 输入无效:{error}"),
240,
),
detail: None,
};
}
};
let runtime = match read_game_creator_agent_runtime_at(root, agent_id) {
Ok(runtime) if runtime.state.run_id == run_id => runtime.state,
Ok(_) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "preview.validate 与当前 Runtime run 身份不匹配".to_string(),
detail: None,
};
}
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let completion_contract = match autonomous_completion_contract_for_state_at(root, &runtime) {
Ok(contract) => contract,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
};
if let (Some(contract), Some(requested)) = (
completion_contract.as_ref(),
input.playtest_scenario.as_ref(),
) {
if requested != &contract.playtest_scenario {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "preview.validate 试玩场景与自主构建完成合同不匹配".to_string(),
detail: None,
};
}
}
let playtest_scenario = completion_contract
.as_ref()
.map(|contract| contract.playtest_scenario.clone())
.or(input.playtest_scenario);
if completion_contract.is_some() {
if let Err(error) = remove_autonomous_playtest_receipt(root, agent_id, run_id) {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "旧自主试玩回执无法失效,未执行新的浏览器试玩".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
}
let revision_before = match read_game_creator_agent_runtime_project_revision(root) {
Ok(revision) => revision,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let registry = game_creator_preview_registry();
let existing_status = registry.status();
let existing_url = if ensure_preview_belongs_to_project(&existing_status, root).is_ok() {
existing_status.url.clone()
} else {
None
};
let reused_existing_preview = existing_url.is_some();
let (url, temporary_stop) = match existing_url {
Some(url) => (url, None),
None => match start_local_game_preview_for_project(root) {
Ok((preview, stop)) => (preview.url, Some(stop)),
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
},
};
let evidence_relative_root = format!(
".agent/runtime/browser-validations/{}/{}/{}",
agent_runtime_confirmation_path_component(agent_id, "agent"),
agent_runtime_confirmation_path_component(run_id, "run"),
revision_before.revision,
);
let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) {
Ok(path) => path,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: url.clone(),
viewports: input.viewports,
expected_text: input.expected_text,
settle_ms: input.settle_ms,
fail_on_console_error: input.fail_on_console_error,
playtest_scenario,
evidence_root,
})
.await;
let temporary_preview_identity_valid = temporary_stop
.map(|stop| stop.send(()).is_ok())
.unwrap_or(true);
let result = match validation {
Ok(result) => result,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if !temporary_preview_identity_valid {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器验证期间临时预览服务已退出,证据身份无法确认".to_string(),
detail: None,
};
}
let revision_after = match read_game_creator_agent_runtime_project_revision(root) {
Ok(revision) => revision,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if revision_after.revision != revision_before.revision {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器验证期间项目 revision 已变化,证据已失效".to_string(),
detail: Some(format!(
"revisionBefore={}, revisionAfter={}",
revision_before.revision, revision_after.revision
)),
};
}
if reused_existing_preview {
let current_status = registry.status();
if ensure_preview_belongs_to_project(&current_status, root).is_err()
|| current_status.url.as_deref() != Some(url.as_str())
{
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器验证期间当前项目预览身份已变化,证据已失效".to_string(),
detail: None,
};
}
}
if completion_contract.is_some() && !result.passed {
if let Err(error) = invalidate_agent_runtime_project_verification_after_preview_failure_at(
root,
agent_id,
run_id,
revision_after.revision,
) {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器验证未通过,且当前验证凭证无法安全失效".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
}
let autonomous_receipt = if let Some(contract) = completion_contract.as_ref() {
if !result.passed {
None
} else {
let Some(action_id) = action_id else {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "自主浏览器试玩缺少持久 action 身份".to_string(),
detail: None,
};
};
let receipt = match write_autonomous_playtest_receipt_at(
root,
contract,
action_id,
action_fingerprint,
revision_after.revision,
&result,
) {
Ok(receipt) => Some(receipt),
Err(error) => {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器试玩已返回,但自主试玩回执无法形成".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
};
if let Err(error) = clear_agent_runtime_failed_playtest_at(
root,
agent_id,
run_id,
revision_after.revision,
) {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
receipt
}
} else {
None
};
let report_path = browser_validation_relative_path(root, &result.evidence.report_path);
let screenshots = result
.viewport_results
.iter()
.map(|viewport| browser_validation_relative_path(root, &viewport.screenshot_path))
.collect::<Vec<_>>();
let detail_value = serde_json::json!({
"passed": result.passed,
"revision": revision_after.revision,
"reportPath": report_path,
"screenshots": screenshots,
"diagnostics": result.diagnostics,
"playtest": result.playtest,
"autonomousReceiptFingerprint": autonomous_receipt
.as_ref()
.map(|receipt| receipt.receipt_fingerprint.clone()),
"viewports": result.viewport_results.iter().map(|viewport| serde_json::json!({
"viewport": viewport.viewport,
"passed": viewport.passed,
"consoleErrors": viewport.console_errors.len(),
"consoleWarnings": viewport.console_warnings.len(),
"exceptions": viewport.exceptions.len(),
"failedRequests": viewport.failed_requests.iter().filter(|request| request.fatal).count(),
"canvases": viewport.canvases.len(),
})).collect::<Vec<_>>(),
});
if let Err(error) = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.preview.validation",
"agentId": agent_id,
"runId": run_id,
"revision": revision_after.revision,
"passed": result.passed,
"reportPath": detail_value["reportPath"],
"screenshots": detail_value["screenshots"],
"diagnostics": detail_value["diagnostics"],
"playtestPassed": result.playtest.as_ref().map(|playtest| playtest.passed),
"playtestScenario": result.playtest.as_ref().map(|playtest| playtest.scenario.clone()),
"autonomousReceiptFingerprint": detail_value["autonomousReceiptFingerprint"],
}),
) {
return AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
let detail = serde_json::to_string(&detail_value)
.ok()
.map(|value| redact_agent_runtime_project_paths(root, &value, 3_600));
AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: if result.passed { "ok" } else { "failed" }.to_string(),
summary: if result.passed {
"浏览器验证已通过,已生成桌面与移动证据".to_string()
} else {
"浏览器验证未通过,请根据诊断修复后重试".to_string()
},
detail,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
use super::*;
pub(in crate::agent) fn observe_agent_runtime_task_list(
root: &Path,
) -> AgentRuntimeToolObservation {
let result = read_manifest_for_project(root).map(|manifest| {
let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks);
let ready_text = if ready_task_ids.is_empty() {
"(none)".to_string()
} else {
ready_task_ids.join(", ")
};
let mut lines = vec![format!("readyTaskIds: {ready_text}")];
lines.extend(manifest.tasks.iter().map(|task| {
let dependencies = if task.dependencies.is_empty() {
"-".to_string()
} else {
task.dependencies.join(", ")
};
let artifacts = if task.artifacts.is_empty() {
"-".to_string()
} else {
task.artifacts.join(", ")
};
format!(
"- {} [{}] {}/{} · {} · deps: {} · artifacts: {}",
task.id,
agent_runtime_task_status_label(&task.status),
agent_runtime_task_group_label(&task.group),
task.role,
task.title,
dependencies,
artifacts
)
}));
lines.join("\n")
});
observation_from_text_result("task.list", result, "已读取 manifest 任务图")
}
pub(in crate::agent) fn observe_agent_runtime_task_create(
root: &Path,
agent_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let task_id = agent_runtime_tool_input_text(input, &["taskId", "task_id", "id"]);
let title = agent_runtime_tool_input_text(input, &["title", "name"]);
if title.trim().is_empty() {
return AgentRuntimeToolObservation {
tool: "task.create".to_string(),
status: "failed".to_string(),
summary: "缺少 title".to_string(),
detail: None,
};
}
let group_input = agent_runtime_tool_input_text(input, &["group", "area"]);
let group = if group_input.trim().is_empty() {
game_creator_agent_role_definition(agent_id)
.map(|(group, _role)| group.id)
.and_then(agent_runtime_task_group_from_label)
.unwrap_or(GameCreationAppAgentGroup::Design)
} else {
match agent_runtime_task_group_from_label(&group_input) {
Some(group) => group,
None => {
return AgentRuntimeToolObservation {
tool: "task.create".to_string(),
status: "failed".to_string(),
summary: format!("不支持的任务分组:{group_input}"),
detail: None,
};
}
}
};
let role = {
let role_input = agent_runtime_tool_input_text(input, &["role", "ownerRole"]);
if role_input.trim().is_empty() {
game_creator_agent_role_definition(agent_id)
.map(|(_group, role)| role.role.to_string())
.unwrap_or_else(|| "Agent".to_string())
} else {
role_input
}
};
let status_input = agent_runtime_tool_input_text(input, &["status", "state"]);
let status = if status_input.trim().is_empty() {
GameCreationAppTaskStatus::Pending
} else {
match parse_agent_runtime_task_status(status_input.as_str()) {
Ok(status) => status,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "task.create".to_string(),
status: "failed".to_string(),
summary: error,
detail: None,
};
}
}
};
let dependencies =
agent_runtime_tool_input_string_list(input, &["dependencies", "deps", "dependsOn"]);
let artifacts = agent_runtime_tool_input_string_list(input, &["artifacts", "outputs"]);
let acceptance_criteria = agent_runtime_tool_input_string_list(
input,
&["acceptanceCriteria", "acceptance", "criteria"],
);
let _lock = match acquire_project_write_lock(root, "task.create") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "task.create".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let status_label = agent_runtime_task_status_label(&status);
let result = create_manifest_task_at(
root,
task_id.as_str(),
title.as_str(),
group,
role.as_str(),
status,
dependencies,
artifacts,
acceptance_criteria,
)
.and_then(|task| {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.task.create",
"agentId": agent_id,
"taskId": task.id.clone(),
"status": status_label,
"title": task.title.clone(),
"group": agent_runtime_task_group_label(&task.group),
"role": task.role.clone(),
"dependencies": task.dependencies.clone(),
}),
)
.map(|()| task)
});
match result {
Ok(task) => AgentRuntimeToolObservation {
tool: "task.create".to_string(),
status: "ok".to_string(),
summary: format!("已创建任务 {}{}", task.id, task.title),
detail: Some(format!(
"taskId={}, group={}, role={}, status={}, deps={}",
task.id,
agent_runtime_task_group_label(&task.group),
task.role,
status_label,
if task.dependencies.is_empty() {
"-".to_string()
} else {
task.dependencies.join(", ")
}
)),
},
Err(error) => AgentRuntimeToolObservation {
tool: "task.create".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
pub(in crate::agent) fn observe_agent_runtime_task_update(
root: &Path,
agent_id: &str,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let task_id = agent_runtime_tool_input_text(input, &["taskId", "task_id", "id"]);
if task_id.trim().is_empty() {
return AgentRuntimeToolObservation {
tool: "task.update".to_string(),
status: "failed".to_string(),
summary: "缺少 taskId".to_string(),
detail: None,
};
}
let status_input = agent_runtime_tool_input_text(input, &["status", "state"]);
let status = match parse_agent_runtime_task_status(status_input.as_str()) {
Ok(status) => status,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "task.update".to_string(),
status: "failed".to_string(),
summary: error,
detail: None,
};
}
};
let status_label = agent_runtime_task_status_label(&status);
let _lock = match acquire_project_write_lock(root, "task.update") {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "task.update".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
if status == GameCreationAppTaskStatus::Completed {
if let Some(blocker) =
visual_asset_completion_blocker_at_locked(root, task_id.as_str(), None)
{
return AgentRuntimeToolObservation {
tool: "task.update".to_string(),
status: "failed".to_string(),
summary: blocker.summary,
detail: blocker.detail,
};
}
}
let result = update_manifest_task_status_at(root, task_id.as_str(), status).and_then(|task| {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.task.update",
"agentId": agent_id,
"taskId": task.id.clone(),
"status": status_label,
"title": task.title.clone(),
"group": agent_runtime_task_group_label(&task.group),
"role": task.role.clone(),
}),
)
.map(|()| task)
});
match result {
Ok(task) => AgentRuntimeToolObservation {
tool: "task.update".to_string(),
status: "ok".to_string(),
summary: format!("任务 {} 已更新为 {}", task.id, status_label),
detail: Some(format!(
"taskId={}, title={}, group={}, role={}, status={}",
task.id,
task.title,
agent_runtime_task_group_label(&task.group),
task.role,
status_label
)),
},
Err(error) => AgentRuntimeToolObservation {
tool: "task.update".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
},
}
}
pub(in crate::agent) fn parse_agent_runtime_task_status(
value: &str,
) -> Result<GameCreationAppTaskStatus, String> {
match value.trim() {
"pending" => Ok(GameCreationAppTaskStatus::Pending),
"running" => Ok(GameCreationAppTaskStatus::Running),
"waiting-for-confirmation" | "waiting_for_confirmation" => {
Ok(GameCreationAppTaskStatus::WaitingForConfirmation)
}
"completed" => Ok(GameCreationAppTaskStatus::Completed),
"failed" => Ok(GameCreationAppTaskStatus::Failed),
"" => Err("缺少 status".to_string()),
status => Err(format!("不支持的任务状态:{status}")),
}
}
pub(in crate::agent) fn agent_runtime_task_status_label(
status: &GameCreationAppTaskStatus,
) -> &'static str {
match status {
GameCreationAppTaskStatus::Pending => "pending",
GameCreationAppTaskStatus::Running => "running",
GameCreationAppTaskStatus::WaitingForConfirmation => "waiting-for-confirmation",
GameCreationAppTaskStatus::Completed => "completed",
GameCreationAppTaskStatus::Failed => "failed",
}
}
pub(in crate::agent) fn agent_runtime_task_group_label(
group: &GameCreationAppAgentGroup,
) -> &'static str {
match group {
GameCreationAppAgentGroup::Design => "design",
GameCreationAppAgentGroup::Art => "art",
GameCreationAppAgentGroup::Code => "code",
GameCreationAppAgentGroup::Balance => "balance",
GameCreationAppAgentGroup::Audio => "audio",
GameCreationAppAgentGroup::Publishing => "publishing",
}
}
pub(in crate::agent) fn agent_runtime_task_group_from_label(
value: &str,
) -> Option<GameCreationAppAgentGroup> {
match value.trim().to_ascii_lowercase().as_str() {
"design" => Some(GameCreationAppAgentGroup::Design),
"art" => Some(GameCreationAppAgentGroup::Art),
"code" => Some(GameCreationAppAgentGroup::Code),
"balance" => Some(GameCreationAppAgentGroup::Balance),
"audio" => Some(GameCreationAppAgentGroup::Audio),
"publishing" | "publish" => Some(GameCreationAppAgentGroup::Publishing),
_ => None,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,367 @@
use super::*;
pub(super) fn live_process_session(
process_id: &str,
) -> Result<Option<Arc<LiveProcessSession>>, String> {
validate_process_id(process_id)?;
Ok(process_session_registry()
.lock()
.map_err(|_| "process session registry 锁已损坏".to_string())?
.sessions
.get(process_id)
.cloned())
}
pub(super) fn poll_result_from_output(
process_id: &str,
output: &str,
state: &ProcessOutputState,
sandbox_backend: &str,
sandbox_mode: &str,
network_access: &str,
sandbox_profile_version: &str,
sandbox_establishment: &str,
target_exec: &str,
launch_failure_kind: Option<&str>,
cursor: Option<&str>,
max_chars: usize,
) -> Result<ProcessSessionPollResult, String> {
let offset = parse_process_session_cursor(process_id, cursor, output)?;
let end = output[offset..]
.char_indices()
.nth(max_chars)
.map(|(index, _)| offset + index)
.unwrap_or(output.len());
let next_cursor = process_session_cursor(process_id, end);
Ok(ProcessSessionPollResult {
process_id: process_id.to_string(),
status: state.status.clone(),
output: output[offset..end].to_string(),
cursor: process_session_cursor(process_id, offset),
next_cursor,
has_more: end < output.len(),
stdin_open: state.stdin_open,
exit_code: state.exit_code,
signal: state.signal.clone(),
output_bytes: output.len(),
output_sha256: format!("{:x}", Sha256::digest(output.as_bytes())),
source_changed: state.source_changed,
needs_reconciliation: state.needs_reconciliation,
sandbox_backend: sandbox_backend.to_string(),
sandbox_mode: sandbox_mode.to_string(),
network_access: network_access.to_string(),
sandbox_profile_version: sandbox_profile_version.to_string(),
sandbox_establishment: sandbox_establishment.to_string(),
target_exec: target_exec.to_string(),
launch_failure_kind: launch_failure_kind.map(str::to_string),
})
}
pub(crate) fn poll_process_session_at(
root: &Path,
identity: &ProcessSessionIdentity,
process_id: &str,
cursor: Option<&str>,
max_chars: Option<usize>,
wait_ms: Option<u64>,
) -> Result<ProcessSessionPollResult, String> {
validate_process_session_identity(identity)?;
let max_chars = max_chars
.unwrap_or(PROCESS_SESSION_DEFAULT_POLL_CHARS)
.min(PROCESS_SESSION_MAX_POLL_CHARS);
let wait_ms = wait_ms.unwrap_or(0).min(PROCESS_SESSION_MAX_POLL_WAIT_MS);
if let Some(live) = live_process_session(process_id)? {
if live.root != root || live.identity != *identity {
return Err("process session 不属于当前 Agent run".to_string());
}
let mut output = live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?;
let initial_offset = parse_process_session_cursor(process_id, cursor, &output.text)?;
if wait_ms > 0 && initial_offset == output.text.len() && output.status == "running" {
let waited = live
.output_changed
.wait_timeout(output, Duration::from_millis(wait_ms))
.map_err(|_| "process session output 锁已损坏".to_string())?;
output = waited.0;
}
return poll_result_from_output(
process_id,
&output.text,
&output,
&live.sandbox_backend,
&live.sandbox_mode,
&live.network_access,
&live.sandbox_profile_version,
&live.sandbox_establishment,
&live.target_exec,
output.launch_failure_kind.as_deref(),
cursor,
max_chars,
);
}
let mut record = read_process_session_record(root, process_id)?
.ok_or_else(|| "process session 不存在".to_string())?;
validate_process_session_access(&record, identity)?;
if matches!(
record.status.as_str(),
"prepared" | "launching" | "running" | "terminating"
) && record.owner_boot_id != process_session_boot_id()
{
reconcile_stale_active_process_session(&mut record);
write_process_session_record(root, &record)?;
}
let transcript = if let Some(output_ref) = record.output_ref.as_deref() {
read_agent_runtime_json_sidecar_with_max_bytes::<ProcessSessionTranscript>(
root,
output_ref,
"Agent Runtime process transcript",
PROCESS_SESSION_TRANSCRIPT_MAX_BYTES,
)?
} else {
None
};
if let Some(transcript) = &transcript {
if let Err(error) = validate_process_session_transcript(transcript, &record) {
record.status = "needs-reconciliation".to_string();
record.stdin_open = false;
record.needs_reconciliation = true;
record.terminal_at = Some(unix_timestamp());
record.updated_at = unix_timestamp();
let _ = write_process_session_record(root, &record);
return Err(error);
}
}
let output = transcript
.as_ref()
.map(|value| value.output.as_str())
.unwrap_or_default();
let state = ProcessOutputState {
text: output.to_string(),
status: record.status,
exit_code: record.exit_code,
signal: record.signal,
stdin_open: record.stdin_open,
reader_finished: true,
output_limit_exceeded: false,
source_fingerprint_after: record.source_fingerprint_after,
source_changed: record.source_changed,
needs_reconciliation: record.needs_reconciliation,
launch_failure_kind: record.launch_failure_kind.clone(),
};
poll_result_from_output(
process_id,
output,
&state,
&record.sandbox_backend,
&record.sandbox_mode,
&record.network_access,
&record.sandbox_profile_version,
&record.sandbox_establishment,
&record.target_exec,
record.launch_failure_kind.as_deref(),
cursor,
max_chars,
)
}
pub(crate) fn write_process_session_stdin_at(
root: &Path,
identity: &ProcessSessionIdentity,
process_id: &str,
data: &str,
append_newline: bool,
eof: bool,
) -> Result<ProcessSessionStdinResult, String> {
write_process_session_stdin_at_with_after_write(
root,
identity,
process_id,
data,
append_newline,
eof,
|_| {},
)
}
pub(super) fn write_process_session_stdin_at_with_after_write<F>(
root: &Path,
identity: &ProcessSessionIdentity,
process_id: &str,
data: &str,
append_newline: bool,
eof: bool,
after_write: F,
) -> Result<ProcessSessionStdinResult, String>
where
F: FnOnce(&LiveProcessSession),
{
validate_process_session_identity(identity)?;
let live = live_process_session(process_id)?
.ok_or_else(|| "process session 不在当前 Runner 中运行".to_string())?;
if live.root != root || live.identity != *identity {
return Err("process session 不属于当前 Agent run".to_string());
}
let mut bytes = data.as_bytes().to_vec();
if append_newline {
bytes.push(b'\n');
}
if bytes.len() > PROCESS_SESSION_MAX_STDIN_BYTES {
return Err(format!(
"command.stdin 单次最多写入 {PROCESS_SESSION_MAX_STDIN_BYTES} 字节"
));
}
if bytes.iter().any(|byte| *byte == 0) {
return Err("command.stdin 不接受 NUL 或二进制正文".to_string());
}
let content_sha256 = format!("{:x}", Sha256::digest(&bytes));
let mut writer = live
.writer
.lock()
.map_err(|_| "process session stdin 锁已损坏".to_string())?;
if live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?
.status
!= "running"
{
return Err("process session 已进入终态".to_string());
}
if !bytes.is_empty() {
let stream = writer
.as_mut()
.ok_or_else(|| "process session stdin 已关闭".to_string())?;
stream
.write_all(&bytes)
.and_then(|()| stream.flush())
.map_err(|error| format!("写入 process session stdin 失败:{error}"))?;
}
if eof {
writer.take();
}
drop(writer);
after_write(&live);
let mut output = live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?;
if eof || output.status != "running" {
output.stdin_open = false;
}
let record = process_session_record_from_live(&live, &output);
if let Err(error) = write_process_session_record(root, &record) {
output.status = "needs-reconciliation".to_string();
output.needs_reconciliation = true;
output.stdin_open = false;
let reconciliation = process_session_record_from_live(&live, &output);
let _ = write_process_session_record(root, &reconciliation);
let _ = live.control.send(ProcessControl::Terminate);
return Err(format!(
"command.stdin 已写入但状态无法落盘,需要人工核对:{error}"
));
}
Ok(ProcessSessionStdinResult {
process_id: process_id.to_string(),
bytes_written: bytes.len(),
content_sha256,
stdin_open: output.stdin_open,
eof,
sandbox_backend: live.sandbox_backend.clone(),
sandbox_mode: live.sandbox_mode.clone(),
network_access: live.network_access.clone(),
sandbox_profile_version: live.sandbox_profile_version.clone(),
})
}
pub(crate) fn terminate_process_session_at(
root: &Path,
identity: &ProcessSessionIdentity,
process_id: &str,
cursor: Option<&str>,
) -> Result<ProcessSessionPollResult, String> {
validate_process_session_identity(identity)?;
if let Some(live) = live_process_session(process_id)? {
if live.root != root || live.identity != *identity {
return Err("process session 不属于当前 Agent run".to_string());
}
let running = live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?
.status
== "running";
if running {
live.control
.send(ProcessControl::Terminate)
.map_err(|_| "process session 监督线程已结束".to_string())?;
let mut output = live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?;
let deadline = std::time::Instant::now()
+ Duration::from_millis(PROCESS_SESSION_TERMINATE_GRACE_MS + 1_500);
while output.status == "running" && std::time::Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let waited = live
.output_changed
.wait_timeout(output, remaining.min(Duration::from_millis(100)))
.map_err(|_| "process session output 锁已损坏".to_string())?;
output = waited.0;
}
}
let mut result =
poll_process_session_at(root, identity, process_id, cursor, Some(1), Some(0))?;
let cursor_offset = result
.cursor
.rsplit_once(':')
.and_then(|(_, offset)| offset.parse::<usize>().ok())
.ok_or_else(|| "command.terminate 返回了无效 cursor".to_string())?;
result.output.clear();
result.next_cursor = result.cursor.clone();
result.has_more = cursor_offset < result.output_bytes;
return Ok(result);
}
let mut result = poll_process_session_at(root, identity, process_id, cursor, Some(1), Some(0))?;
let cursor_offset = result
.cursor
.rsplit_once(':')
.and_then(|(_, offset)| offset.parse::<usize>().ok())
.ok_or_else(|| "command.terminate 返回了无效 cursor".to_string())?;
result.output.clear();
result.next_cursor = result.cursor.clone();
result.has_more = cursor_offset < result.output_bytes;
Ok(result)
}
pub(crate) fn mark_process_session_start_audit_failure_at(
root: &Path,
process_id: &str,
error: &str,
) -> Result<(), String> {
if let Some(live) = live_process_session(process_id)? {
if live.root != root {
return Err("process session 不属于当前项目".to_string());
}
mark_process_session_reconciliation(
&live,
&format!("command.start audit persistence failed: {error}"),
);
if let Ok(mut output) = live.output.lock() {
output.launch_failure_kind = Some("start-audit-failed".to_string());
}
let _ = live.control.send(ProcessControl::Terminate);
}
let mut record = read_process_session_record(root, process_id)?
.ok_or_else(|| "command.start audit 失败后 process record 缺失".to_string())?;
record.status = "needs-reconciliation".to_string();
record.stdin_open = false;
record.needs_reconciliation = true;
record.launch_failure_kind = Some("start-audit-failed".to_string());
record.signal = Some(redact_agent_runtime_project_paths(root, error, 240));
record.terminal_at = Some(unix_timestamp());
record.updated_at = unix_timestamp();
write_process_session_record(root, &record)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
use super::*;
pub(super) const PROCESS_SESSION_SCHEMA_VERSION: &str = "3";
pub(super) const PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION: &str = "2";
pub(super) const PROCESS_SESSION_CURSOR_VERSION: &str = "v1";
pub(super) const PROCESS_SESSION_MAX_PER_PROJECT: usize = 4;
pub(super) const PROCESS_SESSION_MAX_PER_AGENT: usize = 2;
pub(super) const PROCESS_SESSION_MAX_OUTPUT_BYTES: usize = 256 * 1024;
pub(super) const PROCESS_SESSION_MAX_PENDING_LINE_BYTES: usize = 16 * 1024;
pub(super) const PROCESS_SESSION_MAX_STDIN_BYTES: usize = 8 * 1024;
pub(super) const PROCESS_SESSION_DEFAULT_POLL_CHARS: usize = 8_000;
pub(super) const PROCESS_SESSION_MAX_POLL_CHARS: usize = 16_000;
pub(super) const PROCESS_SESSION_MAX_POLL_WAIT_MS: u64 = 30_000;
pub(super) const PROCESS_SESSION_RECORD_MAX_BYTES: usize = 32 * 1024;
pub(super) const PROCESS_SESSION_TRANSCRIPT_MAX_BYTES: usize = 320 * 1024;
pub(super) const PROCESS_SESSION_TERMINATE_GRACE_MS: u64 = 800;
#[cfg(target_os = "linux")]
pub(super) const PROCESS_SESSION_OWNER_PID_ENV: &str = "GENARRATIVE_PROCESS_SESSION_OWNER_PID";
#[cfg(target_os = "linux")]
pub(super) const PROCESS_SESSION_CHILD_MODE: &str = "--process-session-child";
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProcessSessionIdentity {
pub(crate) project_id: String,
pub(crate) agent_id: String,
pub(crate) task_id: String,
pub(crate) conversation_session_id: String,
pub(crate) run_id: String,
pub(crate) start_action_id: String,
pub(crate) start_action_fingerprint: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ProcessSessionRecord {
pub(crate) schema_version: String,
pub(crate) project_id: String,
pub(crate) agent_id: String,
pub(crate) task_id: String,
pub(crate) conversation_session_id: String,
pub(crate) run_id: String,
pub(crate) start_action_id: String,
pub(crate) start_action_fingerprint: String,
pub(crate) process_id: String,
pub(crate) owner_boot_id: String,
pub(crate) command_id: String,
pub(crate) program: String,
pub(crate) cwd: String,
#[serde(default)]
pub(crate) sandbox_backend: String,
#[serde(default)]
pub(crate) sandbox_mode: String,
#[serde(default)]
pub(crate) network_access: String,
#[serde(default)]
pub(crate) sandbox_profile_version: String,
#[serde(default)]
pub(crate) sandbox_establishment: String,
#[serde(default)]
pub(crate) target_exec: String,
#[serde(default)]
pub(crate) launch_failure_kind: Option<String>,
#[serde(default)]
pub(crate) sandbox_ready_at: Option<u64>,
#[serde(default)]
pub(crate) exec_established_at: Option<u64>,
pub(crate) status: String,
pub(crate) exit_code: Option<i32>,
pub(crate) signal: Option<String>,
pub(crate) stdin_open: bool,
pub(crate) output_bytes: usize,
pub(crate) output_sha256: String,
pub(crate) output_ref: Option<String>,
pub(crate) source_fingerprint_before: String,
pub(crate) source_fingerprint_after: Option<String>,
pub(crate) source_changed: Option<bool>,
pub(crate) needs_reconciliation: bool,
pub(crate) started_at: u64,
pub(crate) terminal_at: Option<u64>,
pub(crate) updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(super) struct ProcessSessionTranscript {
pub(super) schema_version: String,
pub(super) project_id: String,
pub(super) agent_id: String,
pub(super) task_id: String,
pub(super) conversation_session_id: String,
pub(super) run_id: String,
pub(super) start_action_id: String,
pub(super) start_action_fingerprint: String,
pub(super) process_id: String,
pub(super) output: String,
pub(super) output_sha256: String,
pub(super) output_bytes: usize,
pub(super) updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProcessSessionPollResult {
pub(crate) process_id: String,
pub(crate) status: String,
pub(crate) output: String,
pub(crate) cursor: String,
pub(crate) next_cursor: String,
pub(crate) has_more: bool,
pub(crate) stdin_open: bool,
pub(crate) exit_code: Option<i32>,
pub(crate) signal: Option<String>,
pub(crate) output_bytes: usize,
pub(crate) output_sha256: String,
pub(crate) source_changed: Option<bool>,
pub(crate) needs_reconciliation: bool,
pub(crate) sandbox_backend: String,
pub(crate) sandbox_mode: String,
pub(crate) network_access: String,
pub(crate) sandbox_profile_version: String,
pub(crate) sandbox_establishment: String,
pub(crate) target_exec: String,
pub(crate) launch_failure_kind: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProcessSessionStdinResult {
pub(crate) process_id: String,
pub(crate) bytes_written: usize,
pub(crate) content_sha256: String,
pub(crate) stdin_open: bool,
pub(crate) eof: bool,
pub(crate) sandbox_backend: String,
pub(crate) sandbox_mode: String,
pub(crate) network_access: String,
pub(crate) sandbox_profile_version: String,
}
#[derive(Debug)]
pub(super) struct ProcessOutputState {
pub(super) text: String,
pub(super) status: String,
pub(super) exit_code: Option<i32>,
pub(super) signal: Option<String>,
pub(super) stdin_open: bool,
pub(super) reader_finished: bool,
pub(super) output_limit_exceeded: bool,
pub(super) source_fingerprint_after: Option<String>,
pub(super) source_changed: Option<bool>,
pub(super) needs_reconciliation: bool,
pub(super) launch_failure_kind: Option<String>,
}
impl ProcessOutputState {
pub(super) fn running() -> Self {
Self {
text: String::new(),
status: "running".to_string(),
exit_code: None,
signal: None,
stdin_open: true,
reader_finished: false,
output_limit_exceeded: false,
source_fingerprint_after: None,
source_changed: None,
needs_reconciliation: false,
launch_failure_kind: None,
}
}
}
#[derive(Debug)]
pub(super) enum ProcessControl {
Terminate,
OutputLimit,
Shutdown,
}
pub(super) struct LiveProcessSession {
pub(super) root: PathBuf,
pub(super) identity: ProcessSessionIdentity,
pub(super) process_id: String,
pub(super) command_id: String,
pub(super) program: String,
pub(super) cwd: String,
pub(super) sandbox_backend: String,
pub(super) sandbox_mode: String,
pub(super) network_access: String,
pub(super) sandbox_profile_version: String,
pub(super) sandbox_establishment: String,
pub(super) target_exec: String,
pub(super) sandbox_ready_at: Option<u64>,
pub(super) exec_established_at: Option<u64>,
pub(super) source_fingerprint_before: String,
pub(super) started_at: u64,
pub(super) output: Mutex<ProcessOutputState>,
pub(super) output_changed: Condvar,
pub(super) writer: Mutex<Option<Box<dyn std::io::Write + Send>>>,
pub(super) master: Mutex<Option<Box<dyn MasterPty + Send>>>,
#[cfg(windows)]
pub(super) job: Mutex<Option<WindowsProcessJob>>,
pub(super) control: std::sync::mpsc::Sender<ProcessControl>,
}
#[cfg(windows)]
pub(super) struct WindowsProcessJob(windows_sys::Win32::Foundation::HANDLE);
#[cfg(windows)]
unsafe impl Send for WindowsProcessJob {}
#[cfg(windows)]
unsafe impl Sync for WindowsProcessJob {}
#[cfg(windows)]
impl WindowsProcessJob {
pub(super) fn assign(child: &dyn Child) -> Result<Self, String> {
use std::mem::size_of;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
let process = child
.as_raw_handle()
.ok_or_else(|| "command.start Windows child 缺少 process handle".to_string())?
as windows_sys::Win32::Foundation::HANDLE;
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
return Err(format!(
"创建 command.start Windows Job Object 失败:{}",
std::io::Error::last_os_error()
));
}
let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&information as *const _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
let assigned = configured != 0 && unsafe { AssignProcessToJobObject(handle, process) } != 0;
if !assigned {
let error = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(format!(
"配置 command.start Windows Job Object 失败:{error}"
));
}
Ok(Self(handle))
}
pub(super) fn terminate(&self) -> Result<(), String> {
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
if unsafe { TerminateJobObject(self.0, 1) } == 0 {
return Err(format!(
"终止 command.start Windows Job Object 失败:{}",
std::io::Error::last_os_error()
));
}
Ok(())
}
}
#[cfg(windows)]
impl Drop for WindowsProcessJob {
fn drop(&mut self) {
unsafe {
windows_sys::Win32::Foundation::CloseHandle(self.0);
}
}
}
impl std::fmt::Debug for LiveProcessSession {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LiveProcessSession")
.field("process_id", &self.process_id)
.field("agent_id", &self.identity.agent_id)
.field("run_id", &self.identity.run_id)
.finish_non_exhaustive()
}
}
#[derive(Default)]
pub(super) struct ProcessSessionRegistry {
pub(super) sessions: HashMap<String, Arc<LiveProcessSession>>,
}
#[cfg(target_os = "linux")]
#[derive(Clone, Debug)]
pub(super) struct PendingProcessLaunch {
pub(super) root: PathBuf,
pub(super) agent_id: String,
pub(super) process_group_leader: Option<i32>,
pub(super) shutdown_requested: bool,
}
#[cfg(target_os = "linux")]
#[derive(Default)]
pub(super) struct PendingProcessLaunchRegistry {
pub(super) launches: HashMap<String, PendingProcessLaunch>,
}
#[cfg(target_os = "linux")]
pub(super) struct PendingProcessLaunchGuard {
process_id: String,
}
#[cfg(target_os = "linux")]
impl Drop for PendingProcessLaunchGuard {
fn drop(&mut self) {
if let Ok(mut registry) = pending_process_launch_registry().lock() {
registry.launches.remove(&self.process_id);
}
}
}
static PROCESS_SESSION_REGISTRY: OnceLock<Mutex<ProcessSessionRegistry>> = OnceLock::new();
static PROCESS_SESSION_BOOT_ID: OnceLock<String> = OnceLock::new();
#[cfg(target_os = "linux")]
static PENDING_PROCESS_LAUNCH_REGISTRY: OnceLock<Mutex<PendingProcessLaunchRegistry>> =
OnceLock::new();
pub(super) fn process_session_registry() -> &'static Mutex<ProcessSessionRegistry> {
PROCESS_SESSION_REGISTRY.get_or_init(|| Mutex::new(ProcessSessionRegistry::default()))
}
#[cfg(target_os = "linux")]
pub(super) fn pending_process_launch_registry() -> &'static Mutex<PendingProcessLaunchRegistry> {
PENDING_PROCESS_LAUNCH_REGISTRY
.get_or_init(|| Mutex::new(PendingProcessLaunchRegistry::default()))
}
#[cfg(target_os = "linux")]
pub(super) fn reserve_pending_process_launch(
root: &Path,
agent_id: &str,
process_id: &str,
) -> Result<PendingProcessLaunchGuard, String> {
let mut registry = pending_process_launch_registry()
.lock()
.map_err(|_| "pending process launch registry 锁已损坏".to_string())?;
if registry.launches.contains_key(process_id) {
return Err("command.start pending launch 身份冲突".to_string());
}
registry.launches.insert(
process_id.to_string(),
PendingProcessLaunch {
root: root.to_path_buf(),
agent_id: agent_id.to_string(),
process_group_leader: None,
shutdown_requested: false,
},
);
Ok(PendingProcessLaunchGuard {
process_id: process_id.to_string(),
})
}
#[cfg(target_os = "linux")]
pub(super) fn activate_pending_process_launch(
process_id: &str,
process_group_leader: i32,
) -> Result<(), String> {
if process_group_leader <= 1 {
return Err("command.start wrapper 进程组身份无效".to_string());
}
let mut registry = pending_process_launch_registry()
.lock()
.map_err(|_| "pending process launch registry 锁已损坏".to_string())?;
let launch = registry
.launches
.get_mut(process_id)
.ok_or_else(|| "command.start pending launch reservation 缺失".to_string())?;
launch.process_group_leader = Some(process_group_leader);
if launch.shutdown_requested {
unsafe {
libc::kill(-process_group_leader, libc::SIGKILL);
}
return Err("Runner shutdown 已取消 pending process launch".to_string());
}
Ok(())
}
pub(crate) fn process_session_boot_id() -> &'static str {
PROCESS_SESSION_BOOT_ID
.get_or_init(|| {
let mut digest = Sha256::new();
digest.update(std::process::id().to_le_bytes());
digest.update(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
digest.update(unix_timestamp().to_le_bytes());
let value = format!("{:x}", digest.finalize());
format!("boot-{}", &value[..32])
})
.as_str()
}
pub(crate) fn initialize_process_session_boot_id(boot_id: &str) -> Result<(), String> {
let boot_id = boot_id.trim();
if boot_id.is_empty()
|| boot_id.chars().count() > 160
|| boot_id.chars().any(|character| character.is_control())
{
return Err("Agent Runner bootId 无效,无法初始化 process session owner".to_string());
}
match PROCESS_SESSION_BOOT_ID.set(boot_id.to_string()) {
Ok(()) => Ok(()),
Err(_) if PROCESS_SESSION_BOOT_ID.get().map(String::as_str) == Some(boot_id) => Ok(()),
Err(_) => Err("process session owner bootId 已被其他 Runner 初始化".to_string()),
}
}
#[cfg(target_os = "linux")]
pub(crate) fn is_process_session_child_mode(args: &[String]) -> bool {
args.first().map(String::as_str) == Some(PROCESS_SESSION_CHILD_MODE)
}
#[cfg(target_os = "linux")]
pub(crate) fn run_process_session_child(args: &[String]) -> Result<i32, String> {
if args != [PROCESS_SESSION_CHILD_MODE] {
return Err("process session child 参数无效".to_string());
}
let expected_parent = std::env::var(PROCESS_SESSION_OWNER_PID_ENV)
.map_err(|_| "process session child 缺少 owner pid".to_string())?
.parse::<libc::pid_t>()
.map_err(|_| "process session child owner pid 无效".to_string())?;
if expected_parent <= 1 {
return Err("process session child owner pid 无效".to_string());
}
if unsafe { libc::getppid() } != expected_parent {
return Err("process session owner 在 child containment 生效前已退出".to_string());
}
unsafe {
libc::signal(libc::SIGHUP, libc::SIG_IGN);
}
std::env::remove_var(PROCESS_SESSION_OWNER_PID_ENV);
run_process_session_bridge_child(expected_parent)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,221 @@
use super::*;
pub(crate) fn active_process_session_records_at(
root: &Path,
agent_id: Option<&str>,
run_id: Option<&str>,
) -> Result<Vec<ProcessSessionRecord>, String> {
let live_sessions = process_session_registry()
.lock()
.map_err(|_| "process session registry 锁已损坏".to_string())?
.sessions
.values()
.filter(|live| live.root == root)
.cloned()
.collect::<Vec<_>>();
let mut records = Vec::with_capacity(live_sessions.len());
for live in live_sessions {
if agent_id.is_some_and(|value| value != live.identity.agent_id.as_str())
|| run_id.is_some_and(|value| value != live.identity.run_id.as_str())
{
continue;
}
let output = live
.output
.lock()
.map_err(|_| "process session output 锁已损坏".to_string())?;
records.push(process_session_record_from_live(&live, &output));
}
let directory = root.join(".agent/runtime/process-sessions");
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
records.sort_by(|left, right| left.started_at.cmp(&right.started_at));
return Ok(records);
}
Err(error) => return Err(format!("读取 process session 目录失败:{error}")),
};
for entry in entries {
let entry = entry.map_err(|error| format!("读取 process session 目录项失败:{error}"))?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(process_id) = name
.strip_suffix(".json")
.filter(|value| !value.ends_with(".output"))
else {
continue;
};
if validate_process_id(process_id).is_err() {
continue;
}
if records.iter().any(|record| record.process_id == process_id) {
continue;
}
let Some(mut record) = read_process_session_record(root, process_id)? else {
continue;
};
if matches!(
record.status.as_str(),
"prepared" | "launching" | "running" | "terminating"
) && record.owner_boot_id != process_session_boot_id()
{
reconcile_stale_active_process_session(&mut record);
write_process_session_record(root, &record)?;
}
if (record.needs_reconciliation
|| matches!(
record.status.as_str(),
"prepared" | "launching" | "running" | "terminating" | "needs-reconciliation"
))
&& agent_id.is_none_or(|value| value == record.agent_id)
&& run_id.is_none_or(|value| value == record.run_id)
{
records.push(record);
}
}
records.sort_by(|left, right| left.started_at.cmp(&right.started_at));
Ok(records)
}
pub(crate) fn has_active_process_sessions_at(root: &Path) -> Result<bool, String> {
if !active_process_session_records_at(root, None, None)?.is_empty() {
return Ok(true);
}
#[cfg(target_os = "linux")]
{
return Ok(pending_process_launch_registry()
.lock()
.map_err(|_| "pending process launch registry 锁已损坏".to_string())?
.launches
.values()
.any(|launch| launch.root == root));
}
#[cfg(not(target_os = "linux"))]
Ok(false)
}
pub(crate) fn terminate_process_sessions_for_run_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<(), String> {
let records = active_process_session_records_at(root, Some(agent_id), Some(run_id))?;
for record in &records {
if record.status == "needs-reconciliation" || record.needs_reconciliation {
return Err(format!(
"进程会话 {} 需要人工核对,不能把 run 标记为已取消",
record.process_id
));
}
let identity = ProcessSessionIdentity {
project_id: record.project_id.clone(),
agent_id: record.agent_id.clone(),
task_id: record.task_id.clone(),
conversation_session_id: record.conversation_session_id.clone(),
run_id: record.run_id.clone(),
start_action_id: record.start_action_id.clone(),
start_action_fingerprint: record.start_action_fingerprint.clone(),
};
let terminal = terminate_process_session_at(root, &identity, &record.process_id, None)?;
if terminal.status == "running" || terminal.needs_reconciliation {
return Err(format!(
"进程会话 {} 尚未形成可信终态,不能把 run 标记为已取消",
record.process_id
));
}
}
if active_process_session_records_at(root, Some(agent_id), Some(run_id))?.is_empty() {
Ok(())
} else {
Err("仍有未收束的 process session,不能把 run 标记为已取消".to_string())
}
}
pub(crate) fn shutdown_all_process_sessions() {
#[cfg(target_os = "linux")]
{
let pending = pending_process_launch_registry()
.lock()
.ok()
.map(|mut registry| {
registry
.launches
.values_mut()
.filter_map(|launch| {
launch.shutdown_requested = true;
launch.process_group_leader
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
for process_group_leader in pending {
unsafe {
libc::kill(-process_group_leader, libc::SIGKILL);
}
}
}
let sessions = process_session_registry()
.lock()
.ok()
.map(|registry| registry.sessions.values().cloned().collect::<Vec<_>>())
.unwrap_or_default();
for live in sessions {
let _ = live.control.send(ProcessControl::Shutdown);
}
}
pub(crate) fn shutdown_all_process_sessions_and_wait(timeout: Duration) -> Result<(), String> {
shutdown_all_process_sessions();
let deadline = std::time::Instant::now() + timeout;
loop {
let sessions = process_session_registry()
.lock()
.map_err(|_| "process session registry 锁已损坏".to_string())?
.sessions
.values()
.cloned()
.collect::<Vec<_>>();
let running = sessions.iter().filter(|live| {
live.output
.lock()
.map(|output| {
matches!(
output.status.as_str(),
"prepared" | "launching" | "running" | "terminating"
)
})
.unwrap_or(true)
});
if running.count() == 0 {
#[cfg(target_os = "linux")]
let pending_empty = pending_process_launch_registry()
.lock()
.map_err(|_| "pending process launch registry 锁已损坏".to_string())?
.launches
.is_empty();
#[cfg(not(target_os = "linux"))]
let pending_empty = true;
if pending_empty {
return Ok(());
}
}
if std::time::Instant::now() >= deadline {
return Err("Runner 退出前未能回收全部 process session".to_string());
}
thread::sleep(Duration::from_millis(25));
}
}
#[cfg(test)]
pub(crate) fn clear_process_session_registry_for_tests() {
shutdown_all_process_sessions();
if let Ok(mut registry) = process_session_registry().lock() {
registry.sessions.clear();
}
#[cfg(target_os = "linux")]
if let Ok(mut registry) = pending_process_launch_registry().lock() {
registry.launches.clear();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More