5822b64d7c
接入 Godot 原生桥、受控执行、Runner 回执与编辑器操作指南 保留主分支 Cocos 和 Unity 跨工程能力及外置提示词结构 解决插件生命周期、工具目录、前端启动和文档合并冲突
2666 lines
98 KiB
Rust
2666 lines
98 KiB
Rust
use super::agent::{
|
||
read_agent_runtime_json_sidecar_with_max_bytes, sanitize_prompt_context,
|
||
write_agent_runtime_json_sidecar_with_max_bytes, AGENT_RUNTIME_TASK_MAX_CHARS,
|
||
};
|
||
use super::project::{
|
||
normalize_relative_path, resolve_local_project_path, unix_timestamp, validate_project_root,
|
||
};
|
||
use platform_agent::game_creation::{
|
||
derive_game_creation_isolated_agent_group_at_depth,
|
||
derive_game_creation_isolated_agent_identity, join_game_creation_isolated_agent_results,
|
||
validate_game_creation_isolated_agent_child_result,
|
||
validate_game_creation_isolated_agent_spawn_request_at_depth,
|
||
GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult,
|
||
GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence,
|
||
GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus,
|
||
GameCreationIsolatedAgentSpawnRequest,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::fs::{self, File};
|
||
use std::io::Read;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
pub(crate) const ISOLATED_AGENT_INSTANCE_SCHEMA_VERSION: &str =
|
||
"game-creator-isolated-agent-instance.v1";
|
||
pub(crate) const ISOLATED_AGENT_GROUP_SCHEMA_VERSION: &str = "game-creator-isolated-agent-group.v1";
|
||
pub(crate) const ISOLATED_AGENT_RESULT_SCHEMA_VERSION: &str =
|
||
"game-creator-isolated-agent-result.v1";
|
||
pub(crate) const ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION: &str =
|
||
"game-creator-isolated-agent-join-delivery.v1";
|
||
pub(crate) const ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION: &str =
|
||
"game-creator-isolated-agent-join-claim.v1";
|
||
pub(crate) const ISOLATED_AGENT_JOIN_PROMPT_SCHEMA_VERSION: &str =
|
||
"game-creator-isolated-agent-join-prompt.v1";
|
||
pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str =
|
||
"game-creator-isolated-agent-private-memory.v1";
|
||
|
||
pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[
|
||
"project.verify",
|
||
"project.bootstrap",
|
||
"project.git_commit",
|
||
"command.exec",
|
||
"command.start",
|
||
"command.stdin",
|
||
"cocos.editor.execute",
|
||
"unity.editor.execute",
|
||
"godot.editor.execute",
|
||
"preview.start",
|
||
"agent.delegate",
|
||
"agent.spawn_isolated",
|
||
"agent.goal_contract",
|
||
"agent.acceptance_update",
|
||
"project.restore",
|
||
"agent.schedule_ready",
|
||
"canvas.asset_generate",
|
||
"canvas.asset_import",
|
||
"task.create",
|
||
"task.update",
|
||
];
|
||
|
||
pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[
|
||
"project.verify",
|
||
"project.bootstrap",
|
||
"project.git_commit",
|
||
"command.exec",
|
||
"command.start",
|
||
"command.stdin",
|
||
"cocos.editor.execute",
|
||
"unity.editor.execute",
|
||
"godot.editor.execute",
|
||
"preview.start",
|
||
"agent.delegate",
|
||
"agent.spawn_isolated",
|
||
"agent.goal_contract",
|
||
"agent.acceptance_update",
|
||
"project.restore",
|
||
"agent.schedule_ready",
|
||
"canvas.asset_generate",
|
||
"canvas.asset_import",
|
||
"task.create",
|
||
"task.update",
|
||
"blackboard.write",
|
||
];
|
||
|
||
const ISOLATED_AGENT_INSTANCE_DIR: &str = ".agent/runtime/isolated-agents/instances";
|
||
const ISOLATED_AGENT_GROUP_DIR: &str = ".agent/runtime/isolated-agents/groups";
|
||
const ISOLATED_AGENT_RESULT_DIR: &str = ".agent/runtime/isolated-agents/results";
|
||
const ISOLATED_AGENT_JOIN_DELIVERY_DIR: &str = ".agent/runtime/isolated-agents/join-deliveries";
|
||
const ISOLATED_AGENT_JOIN_CLAIM_DIR: &str = ".agent/runtime/isolated-agents/join-claims";
|
||
const ISOLATED_AGENT_PRIVATE_MEMORY_DIR: &str = ".agent/runtime/isolated-agents/memory";
|
||
const ISOLATED_AGENT_RECORD_MAX_BYTES: usize = 512 * 1024;
|
||
const ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES: usize = 64 * 1024;
|
||
const ISOLATED_AGENT_MAX_SCANNED_ARTIFACT_ENTRIES: usize = 10_000;
|
||
const ISOLATED_AGENT_MAX_RESULT_ARTIFACTS: usize = 32;
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct IsolatedAgentInstanceRecord {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) parent_agent_id: String,
|
||
pub(crate) parent_session_id: String,
|
||
pub(crate) parent_run_id: String,
|
||
pub(crate) parent_action_id: String,
|
||
pub(crate) delegation_group_id: String,
|
||
pub(crate) delegation_id: String,
|
||
pub(crate) child_index: usize,
|
||
pub(crate) instance_id: String,
|
||
pub(crate) template_agent_id: String,
|
||
pub(crate) session_id: String,
|
||
pub(crate) run_id: String,
|
||
pub(crate) depth: u8,
|
||
pub(crate) task: String,
|
||
pub(crate) acceptance_criteria: Vec<String>,
|
||
pub(crate) expected_artifacts: Vec<String>,
|
||
pub(crate) write_scopes: Vec<String>,
|
||
pub(crate) created_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct IsolatedAgentGroupRecord {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) parent_agent_id: String,
|
||
pub(crate) parent_session_id: String,
|
||
pub(crate) parent_run_id: String,
|
||
pub(crate) parent_action_id: String,
|
||
pub(crate) delegation_group_id: String,
|
||
pub(crate) join_run_id: String,
|
||
pub(crate) depth: u8,
|
||
pub(crate) join_mode: GameCreationIsolatedAgentJoinMode,
|
||
pub(crate) request: GameCreationIsolatedAgentSpawnRequest,
|
||
pub(crate) instance_ids: Vec<String>,
|
||
pub(crate) created_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||
pub(crate) struct IsolatedAgentGroupSummary {
|
||
pub(crate) group_count: usize,
|
||
pub(crate) child_count: usize,
|
||
pub(crate) max_child_count: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct IsolatedAgentResultRecord {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) delegation_group_id: String,
|
||
pub(crate) child_index: usize,
|
||
pub(crate) result: GameCreationIsolatedAgentChildResult,
|
||
pub(crate) recorded_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub(crate) enum IsolatedAgentJoinDeliveryStatus {
|
||
Dispatched,
|
||
ClaimedByParent,
|
||
Suppressed,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub(crate) enum IsolatedAgentJoinDeliveryTarget {
|
||
#[default]
|
||
Continuation,
|
||
ParentWake,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct IsolatedAgentJoinDeliveryRecord {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) parent_agent_id: String,
|
||
pub(crate) parent_run_id: String,
|
||
pub(crate) delegation_group_id: String,
|
||
pub(crate) join_run_id: String,
|
||
pub(crate) status: IsolatedAgentJoinDeliveryStatus,
|
||
#[serde(default)]
|
||
pub(crate) delivery_target: IsolatedAgentJoinDeliveryTarget,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub(crate) queued_run_id: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub(crate) claimed_by_action_id: Option<String>,
|
||
pub(crate) updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub(crate) enum IsolatedAgentJoinClaimStatus {
|
||
Prepared,
|
||
Committed,
|
||
Observed,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct IsolatedAgentTerminalTask {
|
||
pub(crate) agent_id: String,
|
||
pub(crate) session_id: String,
|
||
pub(crate) run_id: String,
|
||
pub(crate) delegation_id: String,
|
||
pub(crate) status: String,
|
||
pub(crate) phase: String,
|
||
pub(crate) terminal_detail: Option<String>,
|
||
pub(crate) error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct IsolatedAgentVerificationGateSnapshot {
|
||
pub(crate) agent_id: String,
|
||
pub(crate) run_id: String,
|
||
pub(crate) requires_verification: bool,
|
||
pub(crate) mutation_revision: Option<u64>,
|
||
pub(crate) verified_revision: Option<u64>,
|
||
pub(crate) last_verification_tool: Option<String>,
|
||
pub(crate) last_verification_status: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct JoinDispatch {
|
||
pub(crate) parent_agent_id: String,
|
||
pub(crate) parent_session_id: String,
|
||
pub(crate) parent_run_id: String,
|
||
pub(crate) parent_action_id: String,
|
||
pub(crate) delegation_group_id: String,
|
||
pub(crate) join_run_id: String,
|
||
pub(crate) source: String,
|
||
pub(crate) prompt: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct IsolatedAgentJoinClaimRecord {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) parent_agent_id: String,
|
||
pub(crate) parent_run_id: String,
|
||
pub(crate) action_id: String,
|
||
pub(crate) status: IsolatedAgentJoinClaimStatus,
|
||
pub(crate) joins: Vec<JoinDispatch>,
|
||
pub(crate) updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct IsolatedAgentBuildResult {
|
||
pub(crate) result: GameCreationIsolatedAgentChildResult,
|
||
pub(crate) join_dispatch: Option<JoinDispatch>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct IsolatedAgentCancelTarget {
|
||
pub(crate) delegation_group_id: String,
|
||
pub(crate) delegation_id: String,
|
||
pub(crate) instance_id: String,
|
||
pub(crate) session_id: String,
|
||
pub(crate) run_id: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
struct IsolatedAgentPrivateMemoryRecord {
|
||
schema_version: String,
|
||
instance_id: String,
|
||
content: String,
|
||
updated_at: u64,
|
||
}
|
||
|
||
pub(crate) fn create_or_read_isolated_group_at(
|
||
root: &Path,
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
parent_session_id: &str,
|
||
parent_action_id: &str,
|
||
request: &GameCreationIsolatedAgentSpawnRequest,
|
||
) -> Result<IsolatedAgentGroupRecord, String> {
|
||
validate_project_root(root)?;
|
||
validate_safe_id(parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(parent_session_id, "parentSessionId", 160)?;
|
||
validate_safe_id(parent_run_id, "parentRunId", 160)?;
|
||
validate_safe_id(parent_action_id, "parentActionId", 256)?;
|
||
|
||
let parent_depth = if parent_agent_id.starts_with("child-") {
|
||
let parent = resolve_isolated_agent_instance_at(root, parent_agent_id)?;
|
||
if parent.session_id != parent_session_id || parent.run_id != parent_run_id {
|
||
return Err("动态隔离父 Agent 的 session/run 身份不一致".to_string());
|
||
}
|
||
parent.depth
|
||
} else {
|
||
0
|
||
};
|
||
let request = sanitize_spawn_request(root, request)?;
|
||
validate_game_creation_isolated_agent_spawn_request_at_depth(&request, parent_depth)
|
||
.map_err(|error| error.to_string())?;
|
||
let duplicate = list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_GROUP_DIR,
|
||
"动态隔离 Agent group",
|
||
|record| validate_isolated_group_record(root, record),
|
||
)?
|
||
.into_iter()
|
||
.find(|group| {
|
||
group.parent_agent_id == parent_agent_id
|
||
&& group.parent_session_id == parent_session_id
|
||
&& group.parent_run_id == parent_run_id
|
||
&& group.parent_action_id != parent_action_id
|
||
&& group.depth == parent_depth + 1
|
||
&& group.request == request
|
||
});
|
||
if let Some(duplicate) = duplicate {
|
||
return Err(format!(
|
||
"相同动态隔离 Agent 请求已由 action {} 创建,不得在同一父 run 重复 spawn",
|
||
duplicate.parent_action_id
|
||
));
|
||
}
|
||
let derived = derive_game_creation_isolated_agent_group_at_depth(
|
||
parent_action_id,
|
||
&request,
|
||
parent_depth,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let group_path = isolated_group_relative_path(&derived.delegation_group_id);
|
||
|
||
let existing =
|
||
read_json_record::<IsolatedAgentGroupRecord>(root, &group_path, "动态隔离 Agent group")?;
|
||
let created_at = existing
|
||
.as_ref()
|
||
.map(|record| record.created_at)
|
||
.unwrap_or_else(unix_timestamp);
|
||
let group = IsolatedAgentGroupRecord {
|
||
schema_version: ISOLATED_AGENT_GROUP_SCHEMA_VERSION.to_string(),
|
||
parent_agent_id: parent_agent_id.to_string(),
|
||
parent_session_id: parent_session_id.to_string(),
|
||
parent_run_id: parent_run_id.to_string(),
|
||
parent_action_id: parent_action_id.to_string(),
|
||
delegation_group_id: derived.delegation_group_id.clone(),
|
||
join_run_id: derived.join_run_id.clone(),
|
||
depth: derived.depth,
|
||
join_mode: derived.join_mode,
|
||
request: request.clone(),
|
||
instance_ids: derived
|
||
.children
|
||
.iter()
|
||
.map(|child| child.instance_id.clone())
|
||
.collect(),
|
||
created_at,
|
||
};
|
||
validate_isolated_group_record(root, &group)?;
|
||
if existing.as_ref().is_some_and(|record| record != &group) {
|
||
return Err(format!(
|
||
"parentActionId 已绑定不同的动态隔离 group:{parent_action_id}"
|
||
));
|
||
}
|
||
|
||
for (child, spec) in derived.children.iter().zip(request.children.iter()) {
|
||
let relative_path = isolated_instance_relative_path(&child.instance_id);
|
||
let existing = read_json_record::<IsolatedAgentInstanceRecord>(
|
||
root,
|
||
&relative_path,
|
||
"动态隔离 Agent instance",
|
||
)?;
|
||
let instance = IsolatedAgentInstanceRecord {
|
||
schema_version: ISOLATED_AGENT_INSTANCE_SCHEMA_VERSION.to_string(),
|
||
parent_agent_id: parent_agent_id.to_string(),
|
||
parent_session_id: parent_session_id.to_string(),
|
||
parent_run_id: parent_run_id.to_string(),
|
||
parent_action_id: parent_action_id.to_string(),
|
||
delegation_group_id: child.delegation_group_id.clone(),
|
||
delegation_id: child.delegation_id.clone(),
|
||
child_index: child.child_index,
|
||
instance_id: child.instance_id.clone(),
|
||
template_agent_id: child.template_agent_id.clone(),
|
||
session_id: isolated_child_session_id(&child.instance_id),
|
||
run_id: isolated_child_run_id(&child.delegation_id),
|
||
depth: derived.depth,
|
||
task: spec.task.clone(),
|
||
acceptance_criteria: spec.acceptance_criteria.clone(),
|
||
expected_artifacts: spec.expected_artifacts.clone(),
|
||
write_scopes: spec.write_scopes.clone(),
|
||
created_at: existing
|
||
.as_ref()
|
||
.map(|record| record.created_at)
|
||
.unwrap_or(created_at),
|
||
};
|
||
validate_isolated_instance_record(root, &instance)?;
|
||
match existing {
|
||
Some(record) if record != instance => {
|
||
return Err(format!(
|
||
"动态隔离 Agent instance 身份冲突:{}",
|
||
child.instance_id
|
||
));
|
||
}
|
||
Some(_) => {}
|
||
None => write_json_record(root, &relative_path, "动态隔离 Agent instance", &instance)?,
|
||
}
|
||
}
|
||
if existing.is_none() {
|
||
write_json_record(root, &group_path, "动态隔离 Agent group", &group)?;
|
||
}
|
||
Ok(group)
|
||
}
|
||
|
||
pub(crate) fn isolated_agent_group_summary_at(
|
||
root: &Path,
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
) -> Result<IsolatedAgentGroupSummary, String> {
|
||
validate_safe_id(parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(parent_run_id, "parentRunId", 160)?;
|
||
let mut summary = IsolatedAgentGroupSummary::default();
|
||
for group in list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_GROUP_DIR,
|
||
"动态隔离 Agent group",
|
||
|record| validate_isolated_group_record(root, record),
|
||
)?
|
||
.into_iter()
|
||
.filter(|group| {
|
||
group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id
|
||
}) {
|
||
summary.group_count = summary.group_count.saturating_add(1);
|
||
summary.child_count = summary
|
||
.child_count
|
||
.saturating_add(group.request.children.len());
|
||
summary.max_child_count = summary.max_child_count.max(group.request.children.len());
|
||
}
|
||
Ok(summary)
|
||
}
|
||
|
||
pub(crate) fn isolated_agent_spawn_has_durable_side_effect_at(
|
||
root: &Path,
|
||
parent_agent_id: &str,
|
||
parent_session_id: &str,
|
||
parent_run_id: &str,
|
||
parent_action_id: &str,
|
||
) -> Result<bool, String> {
|
||
validate_project_root(root)?;
|
||
validate_safe_id(parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(parent_session_id, "parentSessionId", 160)?;
|
||
validate_safe_id(parent_run_id, "parentRunId", 160)?;
|
||
validate_safe_id(parent_action_id, "parentActionId", 256)?;
|
||
|
||
let mut found = false;
|
||
for group in list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_GROUP_DIR,
|
||
"动态隔离 Agent group",
|
||
|record| validate_isolated_group_record(root, record),
|
||
)?
|
||
.into_iter()
|
||
.filter(|group| group.parent_action_id == parent_action_id)
|
||
{
|
||
if group.parent_agent_id != parent_agent_id
|
||
|| group.parent_session_id != parent_session_id
|
||
|| group.parent_run_id != parent_run_id
|
||
{
|
||
return Err("恢复 isolated spawn 时 durable group 父身份冲突".to_string());
|
||
}
|
||
found = true;
|
||
}
|
||
for instance in list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_INSTANCE_DIR,
|
||
"动态隔离 Agent instance",
|
||
|record| validate_isolated_instance_record(root, record),
|
||
)?
|
||
.into_iter()
|
||
.filter(|instance| instance.parent_action_id == parent_action_id)
|
||
{
|
||
if instance.parent_agent_id != parent_agent_id
|
||
|| instance.parent_session_id != parent_session_id
|
||
|| instance.parent_run_id != parent_run_id
|
||
{
|
||
return Err("恢复 isolated spawn 时 durable instance 父身份冲突".to_string());
|
||
}
|
||
found = true;
|
||
}
|
||
Ok(found)
|
||
}
|
||
|
||
pub(crate) fn list_isolated_agent_instances_at(
|
||
root: &Path,
|
||
) -> Result<Vec<IsolatedAgentInstanceRecord>, String> {
|
||
list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_INSTANCE_DIR,
|
||
"动态隔离 Agent instance",
|
||
|record| validate_isolated_instance_record(root, record),
|
||
)
|
||
}
|
||
|
||
pub(crate) fn resolve_isolated_agent_instance_at(
|
||
root: &Path,
|
||
instance_id: &str,
|
||
) -> Result<IsolatedAgentInstanceRecord, String> {
|
||
validate_safe_id(instance_id, "instanceId", 96)?;
|
||
let record = read_json_record::<IsolatedAgentInstanceRecord>(
|
||
root,
|
||
&isolated_instance_relative_path(instance_id),
|
||
"动态隔离 Agent instance",
|
||
)?
|
||
.ok_or_else(|| format!("未找到动态隔离 Agent instance:{instance_id}"))?;
|
||
validate_isolated_instance_record(root, &record)?;
|
||
if record.instance_id != instance_id {
|
||
return Err("动态隔离 Agent instance 文件名与记录身份不一致".to_string());
|
||
}
|
||
Ok(record)
|
||
}
|
||
|
||
pub(crate) fn render_isolated_agent_task_contract(
|
||
instance: &IsolatedAgentInstanceRecord,
|
||
) -> Result<String, String> {
|
||
let contract = serde_json::to_string_pretty(&serde_json::json!({
|
||
"task": instance.task,
|
||
"acceptanceCriteria": instance.acceptance_criteria,
|
||
"expectedArtifacts": instance.expected_artifacts,
|
||
"writeScopes": instance.write_scopes,
|
||
}))
|
||
.map_err(|error| format!("序列化动态隔离子任务合同失败:{error}"))?;
|
||
let rendered = format!(
|
||
prompt_text!("goalContext.isolated_task_contract"),
|
||
contract = contract,
|
||
);
|
||
if rendered.chars().count() > AGENT_RUNTIME_TASK_MAX_CHARS {
|
||
return Err(format!(
|
||
"动态隔离子任务与持久化合同合计超过 {} 字符",
|
||
AGENT_RUNTIME_TASK_MAX_CHARS
|
||
));
|
||
}
|
||
Ok(rendered)
|
||
}
|
||
|
||
pub(crate) fn validate_isolated_agent_tool_scope_at(
|
||
root: &Path,
|
||
instance_id: &str,
|
||
tool: &str,
|
||
input: &Value,
|
||
) -> Result<(), String> {
|
||
let instance = resolve_isolated_agent_instance_at(root, instance_id)?;
|
||
let tool = tool.trim();
|
||
if tool == "project.git_commit" {
|
||
return Err(
|
||
"动态隔离子 Agent 作用域拒绝 project.git_commit:最终提交由父 Agent 统一负责"
|
||
.to_string(),
|
||
);
|
||
}
|
||
if tool == "memory.write" {
|
||
let scope = input
|
||
.get("scope")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("agent")
|
||
.trim();
|
||
if scope != "agent" {
|
||
return Err(format!(
|
||
"动态隔离子 Agent 只能写入自己的 instance 私有记忆,拒绝 scope={scope}"
|
||
));
|
||
}
|
||
let target_agent_id = ["agentId", "agent_id", "targetAgentId", "target_agent_id"]
|
||
.into_iter()
|
||
.filter_map(|key| input.get(key).and_then(Value::as_str))
|
||
.map(str::trim)
|
||
.find(|value| !value.is_empty());
|
||
if target_agent_id.is_some_and(|target| target != instance.instance_id) {
|
||
return Err("动态隔离子 Agent 只能写入自己的 instance 私有记忆".to_string());
|
||
}
|
||
}
|
||
if ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
|
||
return Err(format!(
|
||
"动态隔离子 Agent 默认拒绝无 writeScope 落点的工具:{tool}"
|
||
));
|
||
}
|
||
if tool == "project.patchset" {
|
||
let changes = input
|
||
.get("changes")
|
||
.and_then(Value::as_array)
|
||
.filter(|changes| !changes.is_empty())
|
||
.ok_or_else(|| "project.patchset 缺少非空 changes".to_string())?;
|
||
for change in changes {
|
||
let path = change
|
||
.get("path")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| "project.patchset change 缺少字符串 path".to_string())?;
|
||
let path = normalize_relative_path(path)?;
|
||
resolve_local_project_path(root, &path)?;
|
||
if !instance
|
||
.write_scopes
|
||
.iter()
|
||
.any(|scope| path_matches_write_scope(&path, scope))
|
||
{
|
||
return Err(format!(
|
||
"动态隔离子 Agent patchset 路径超出 writeScopes:{path}"
|
||
));
|
||
}
|
||
}
|
||
return Ok(());
|
||
}
|
||
if !matches!(tool, "file.write" | "file.patch" | "file.delete") {
|
||
return Ok(());
|
||
}
|
||
let path = input
|
||
.get("path")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| format!("{tool} 缺少字符串 path"))?;
|
||
let path = normalize_relative_path(path)?;
|
||
resolve_local_project_path(root, &path)?;
|
||
if instance
|
||
.write_scopes
|
||
.iter()
|
||
.any(|scope| path_matches_write_scope(&path, scope))
|
||
{
|
||
Ok(())
|
||
} else {
|
||
Err(format!("动态隔离子 Agent 写入路径超出 writeScopes:{path}"))
|
||
}
|
||
}
|
||
|
||
pub(crate) fn read_isolated_agent_private_memory_at(
|
||
root: &Path,
|
||
instance_id: &str,
|
||
) -> Result<String, String> {
|
||
let instance = resolve_isolated_agent_instance_at(root, instance_id)?;
|
||
let record = read_json_record::<IsolatedAgentPrivateMemoryRecord>(
|
||
root,
|
||
&isolated_private_memory_relative_path(&instance.instance_id),
|
||
"动态隔离 Agent 私有临时记忆",
|
||
)?;
|
||
let Some(record) = record else {
|
||
return Ok(String::new());
|
||
};
|
||
validate_isolated_private_memory_record(&record, &instance)?;
|
||
Ok(record.content)
|
||
}
|
||
|
||
pub(crate) fn write_isolated_agent_private_memory_at(
|
||
root: &Path,
|
||
instance_id: &str,
|
||
content: &str,
|
||
) -> Result<String, String> {
|
||
let instance = resolve_isolated_agent_instance_at(root, instance_id)?;
|
||
if content.as_bytes().len() > ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES {
|
||
return Err(format!(
|
||
"动态隔离 Agent 私有临时记忆超过 {} 字节上限",
|
||
ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES
|
||
));
|
||
}
|
||
let relative_path = isolated_private_memory_relative_path(&instance.instance_id);
|
||
let record = IsolatedAgentPrivateMemoryRecord {
|
||
schema_version: ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION.to_string(),
|
||
instance_id: instance.instance_id.clone(),
|
||
content: content.to_string(),
|
||
updated_at: unix_timestamp(),
|
||
};
|
||
validate_isolated_private_memory_record(&record, &instance)?;
|
||
write_json_record(root, &relative_path, "动态隔离 Agent 私有临时记忆", &record)?;
|
||
Ok(relative_path)
|
||
}
|
||
|
||
pub(crate) fn build_isolated_child_result_at(
|
||
root: &Path,
|
||
instance_id: &str,
|
||
task: &IsolatedAgentTerminalTask,
|
||
expected_artifacts: &[String],
|
||
verification_gate: &IsolatedAgentVerificationGateSnapshot,
|
||
evidence: &[GameCreationIsolatedAgentEvidence],
|
||
) -> Result<IsolatedAgentBuildResult, String> {
|
||
let instance = resolve_isolated_agent_instance_at(root, instance_id)?;
|
||
validate_terminal_task_identity(&instance, task)?;
|
||
if expected_artifacts != instance.expected_artifacts.as_slice() {
|
||
return Err("动态隔离子 Agent expectedArtifacts 与实例契约不一致".to_string());
|
||
}
|
||
validate_verification_gate(&instance, verification_gate)?;
|
||
let status = terminal_result_status(task)?;
|
||
let completed = status == GameCreationIsolatedAgentResultStatus::Completed;
|
||
let artifacts = collect_expected_artifacts(root, expected_artifacts, completed)?;
|
||
let evidence = sanitize_evidence(root, evidence)?;
|
||
if completed && verification_gate.requires_verification {
|
||
let expected_kind = verification_gate
|
||
.last_verification_tool
|
||
.as_deref()
|
||
.unwrap_or_default();
|
||
if !evidence.iter().any(|item| item.kind == expected_kind) {
|
||
return Err(format!(
|
||
"动态隔离子 Agent 缺少通过验证的安全 evidence:{expected_kind}"
|
||
));
|
||
}
|
||
}
|
||
let summary_source = task
|
||
.terminal_detail
|
||
.as_deref()
|
||
.or(task.error.as_deref())
|
||
.unwrap_or(match status {
|
||
GameCreationIsolatedAgentResultStatus::Completed => "动态隔离子任务已完成",
|
||
GameCreationIsolatedAgentResultStatus::Failed => "动态隔离子任务失败",
|
||
GameCreationIsolatedAgentResultStatus::Cancelled => "动态隔离子任务已取消",
|
||
GameCreationIsolatedAgentResultStatus::BudgetExhausted => "动态隔离子任务预算已耗尽",
|
||
});
|
||
let summary = sanitize_persisted_text(root, summary_source, 2_000);
|
||
let error = matches!(
|
||
status,
|
||
GameCreationIsolatedAgentResultStatus::Failed
|
||
| GameCreationIsolatedAgentResultStatus::BudgetExhausted
|
||
)
|
||
.then(|| {
|
||
sanitize_persisted_text(
|
||
root,
|
||
task.error
|
||
.as_deref()
|
||
.or(task.terminal_detail.as_deref())
|
||
.unwrap_or("动态隔离子任务未提供错误详情"),
|
||
2_000,
|
||
)
|
||
});
|
||
let verified_revision = (verification_gate.last_verification_status.as_deref()
|
||
== Some("passed"))
|
||
.then_some(verification_gate.verified_revision)
|
||
.flatten();
|
||
let result = GameCreationIsolatedAgentChildResult {
|
||
delegation_id: instance.delegation_id,
|
||
instance_id: instance.instance_id,
|
||
template_agent_id: instance.template_agent_id,
|
||
run_id: instance.run_id,
|
||
status,
|
||
summary,
|
||
artifacts,
|
||
evidence,
|
||
verified_revision,
|
||
error,
|
||
};
|
||
validate_game_creation_isolated_agent_child_result(&result)
|
||
.map_err(|error| error.to_string())?;
|
||
let join_dispatch = record_isolated_child_result_at(root, &result)?;
|
||
Ok(IsolatedAgentBuildResult {
|
||
result,
|
||
join_dispatch,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn build_isolated_child_result_with_failure_fallback_at(
|
||
root: &Path,
|
||
instance_id: &str,
|
||
task: &IsolatedAgentTerminalTask,
|
||
expected_artifacts: &[String],
|
||
verification_gate: &IsolatedAgentVerificationGateSnapshot,
|
||
evidence: &[GameCreationIsolatedAgentEvidence],
|
||
) -> Result<IsolatedAgentBuildResult, String> {
|
||
match build_isolated_child_result_at(
|
||
root,
|
||
instance_id,
|
||
task,
|
||
expected_artifacts,
|
||
verification_gate,
|
||
evidence,
|
||
) {
|
||
Ok(result) => Ok(result),
|
||
Err(error)
|
||
if terminal_result_status(task)?
|
||
== GameCreationIsolatedAgentResultStatus::Completed =>
|
||
{
|
||
let failure = format!("动态隔离子 Agent 结果发布失败:{error}");
|
||
let failed_task = IsolatedAgentTerminalTask {
|
||
agent_id: task.agent_id.clone(),
|
||
session_id: task.session_id.clone(),
|
||
run_id: task.run_id.clone(),
|
||
delegation_id: task.delegation_id.clone(),
|
||
status: "failed".to_string(),
|
||
phase: "failed".to_string(),
|
||
terminal_detail: Some(failure.clone()),
|
||
error: Some(failure),
|
||
};
|
||
build_isolated_child_result_at(
|
||
root,
|
||
instance_id,
|
||
&failed_task,
|
||
expected_artifacts,
|
||
verification_gate,
|
||
evidence,
|
||
)
|
||
}
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn record_isolated_child_result_at(
|
||
root: &Path,
|
||
result: &GameCreationIsolatedAgentChildResult,
|
||
) -> Result<Option<JoinDispatch>, String> {
|
||
validate_game_creation_isolated_agent_child_result(result)
|
||
.map_err(|error| error.to_string())?;
|
||
let instance = resolve_isolated_agent_instance_at(root, &result.instance_id)?;
|
||
if result.delegation_id != instance.delegation_id
|
||
|| result.template_agent_id != instance.template_agent_id
|
||
|| result.run_id != instance.run_id
|
||
{
|
||
return Err("动态隔离子 Agent result 身份与实例不一致".to_string());
|
||
}
|
||
let path = isolated_result_relative_path(&instance.instance_id);
|
||
let existing =
|
||
read_json_record::<IsolatedAgentResultRecord>(root, &path, "动态隔离 Agent result")?;
|
||
let record = IsolatedAgentResultRecord {
|
||
schema_version: ISOLATED_AGENT_RESULT_SCHEMA_VERSION.to_string(),
|
||
delegation_group_id: instance.delegation_group_id.clone(),
|
||
child_index: instance.child_index,
|
||
result: result.clone(),
|
||
recorded_at: existing
|
||
.as_ref()
|
||
.map(|record| record.recorded_at)
|
||
.unwrap_or_else(unix_timestamp),
|
||
};
|
||
validate_isolated_result_record(root, &record)?;
|
||
match existing {
|
||
Some(existing) if existing != record => {
|
||
return Err(format!(
|
||
"动态隔离 Agent terminal result 已存在且内容冲突:{}",
|
||
result.instance_id
|
||
));
|
||
}
|
||
Some(_) => {}
|
||
None => write_json_record(root, &path, "动态隔离 Agent result", &record)?,
|
||
}
|
||
build_join_dispatch_if_ready_at(root, &instance.delegation_group_id)
|
||
}
|
||
|
||
pub(crate) fn reconcile_all_isolated_groups_at(root: &Path) -> Result<Vec<JoinDispatch>, String> {
|
||
let groups = list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_GROUP_DIR,
|
||
"动态隔离 Agent group",
|
||
|record| validate_isolated_group_record(root, record),
|
||
)?;
|
||
let mut dispatches = Vec::new();
|
||
for group in groups {
|
||
if let Some(dispatch) = build_join_dispatch_if_ready_at(root, &group.delegation_group_id)? {
|
||
dispatches.push(dispatch);
|
||
}
|
||
}
|
||
Ok(dispatches)
|
||
}
|
||
|
||
pub(crate) fn isolated_join_completion_barrier_at(
|
||
root: &Path,
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
) -> Result<Option<String>, String> {
|
||
validate_safe_id(parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(parent_run_id, "parentRunId", 160)?;
|
||
let groups = list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_GROUP_DIR,
|
||
"动态隔离 Agent group",
|
||
|record| validate_isolated_group_record(root, record),
|
||
)?;
|
||
let claims = list_isolated_join_claims_at(root)?;
|
||
let journaled_claimed_groups = claims
|
||
.iter()
|
||
.filter(|claim| {
|
||
claim.parent_agent_id == parent_agent_id && claim.parent_run_id == parent_run_id
|
||
})
|
||
.flat_map(|claim| {
|
||
claim
|
||
.joins
|
||
.iter()
|
||
.map(|join| (claim.action_id.clone(), join.delegation_group_id.clone()))
|
||
})
|
||
.collect::<BTreeSet<_>>();
|
||
let mut waiting_groups = 0usize;
|
||
let mut ready_unclaimed_groups = 0usize;
|
||
let mut unjournaled_claimed_groups = 0usize;
|
||
for group in groups.into_iter().filter(|group| {
|
||
group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id
|
||
}) {
|
||
let Some(join) = build_join_dispatch_if_ready_at(root, &group.delegation_group_id)? else {
|
||
waiting_groups = waiting_groups.saturating_add(1);
|
||
continue;
|
||
};
|
||
match read_isolated_join_delivery_at(root, &join)? {
|
||
Some(delivery)
|
||
if delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent =>
|
||
{
|
||
let claimed_by_action_id = delivery
|
||
.claimed_by_action_id
|
||
.as_deref()
|
||
.ok_or_else(|| "动态隔离 Agent 已认领 delivery 缺少 actionId".to_string())?;
|
||
if !journaled_claimed_groups.contains(&(
|
||
claimed_by_action_id.to_string(),
|
||
join.delegation_group_id.clone(),
|
||
)) {
|
||
unjournaled_claimed_groups = unjournaled_claimed_groups.saturating_add(1);
|
||
}
|
||
}
|
||
_ => {
|
||
ready_unclaimed_groups = ready_unclaimed_groups.saturating_add(1);
|
||
}
|
||
}
|
||
}
|
||
let unobserved_claims = claims
|
||
.iter()
|
||
.filter(|claim| {
|
||
claim.parent_agent_id == parent_agent_id
|
||
&& claim.parent_run_id == parent_run_id
|
||
&& claim.status != IsolatedAgentJoinClaimStatus::Observed
|
||
})
|
||
.count();
|
||
if waiting_groups == 0
|
||
&& ready_unclaimed_groups == 0
|
||
&& unjournaled_claimed_groups == 0
|
||
&& unobserved_claims == 0
|
||
{
|
||
return Ok(None);
|
||
}
|
||
Ok(Some(format!(
|
||
"waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · unjournaledClaimedGroups={unjournaled_claimed_groups} · unobservedJoinClaims={unobserved_claims} · 必须调用 agent.run_status 取得并持久观察 all-join 后再继续"
|
||
)))
|
||
}
|
||
|
||
pub(crate) fn read_isolated_join_claim_at(
|
||
root: &Path,
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
action_id: &str,
|
||
) -> Result<Option<IsolatedAgentJoinClaimRecord>, String> {
|
||
let relative_path =
|
||
isolated_join_claim_relative_path(parent_agent_id, parent_run_id, action_id);
|
||
let claim = read_agent_runtime_json_sidecar_with_max_bytes::<IsolatedAgentJoinClaimRecord>(
|
||
root,
|
||
&relative_path,
|
||
"动态隔离 Agent join claim",
|
||
ISOLATED_AGENT_RECORD_MAX_BYTES,
|
||
)?;
|
||
if let Some(claim) = &claim {
|
||
validate_isolated_join_claim_record(root, claim)?;
|
||
if claim.parent_agent_id != parent_agent_id
|
||
|| claim.parent_run_id != parent_run_id
|
||
|| claim.action_id != action_id
|
||
{
|
||
return Err("动态隔离 Agent join claim 文件与请求身份不一致".to_string());
|
||
}
|
||
}
|
||
Ok(claim)
|
||
}
|
||
|
||
pub(crate) fn write_isolated_join_claim_at(
|
||
root: &Path,
|
||
claim: &IsolatedAgentJoinClaimRecord,
|
||
) -> Result<(), String> {
|
||
validate_isolated_join_claim_record(root, claim)?;
|
||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||
root,
|
||
&isolated_join_claim_relative_path(
|
||
&claim.parent_agent_id,
|
||
&claim.parent_run_id,
|
||
&claim.action_id,
|
||
),
|
||
"动态隔离 Agent join claim",
|
||
claim,
|
||
ISOLATED_AGENT_RECORD_MAX_BYTES,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn list_isolated_join_claims_at(
|
||
root: &Path,
|
||
) -> Result<Vec<IsolatedAgentJoinClaimRecord>, String> {
|
||
let dir = resolve_local_project_path(root, ISOLATED_AGENT_JOIN_CLAIM_DIR)?;
|
||
let entries = match fs::read_dir(&dir) {
|
||
Ok(entries) => entries,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取动态隔离 Agent join claim 目录失败:{}: {error}",
|
||
dir.display()
|
||
))
|
||
}
|
||
};
|
||
let mut stems = BTreeSet::new();
|
||
for entry in entries {
|
||
let entry =
|
||
entry.map_err(|error| format!("读取动态隔离 Agent join claim 条目失败:{error}"))?;
|
||
let file_name = entry
|
||
.file_name()
|
||
.into_string()
|
||
.map_err(|_| "动态隔离 Agent join claim 文件名不是 UTF-8".to_string())?;
|
||
let stem = if let Some(stem) = file_name.strip_suffix(".json") {
|
||
Some(stem)
|
||
} else {
|
||
file_name
|
||
.strip_prefix('.')
|
||
.and_then(|value| value.strip_suffix(".json.previous"))
|
||
};
|
||
let Some(stem) = stem.filter(|value| value.starts_with("claim-")) else {
|
||
continue;
|
||
};
|
||
if stem.len() != "claim-".len() + 64
|
||
|| !stem["claim-".len()..]
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_hexdigit())
|
||
{
|
||
return Err("动态隔离 Agent join claim 文件名无效".to_string());
|
||
}
|
||
stems.insert(stem.to_string());
|
||
}
|
||
let mut claims = Vec::with_capacity(stems.len());
|
||
for stem in stems {
|
||
let relative_path = format!("{ISOLATED_AGENT_JOIN_CLAIM_DIR}/{stem}.json");
|
||
let claim = read_agent_runtime_json_sidecar_with_max_bytes::<IsolatedAgentJoinClaimRecord>(
|
||
root,
|
||
&relative_path,
|
||
"动态隔离 Agent join claim",
|
||
ISOLATED_AGENT_RECORD_MAX_BYTES,
|
||
)?
|
||
.ok_or_else(|| format!("动态隔离 Agent join claim 在枚举后消失:{stem}"))?;
|
||
validate_isolated_join_claim_record(root, &claim)?;
|
||
if isolated_join_claim_relative_path(
|
||
&claim.parent_agent_id,
|
||
&claim.parent_run_id,
|
||
&claim.action_id,
|
||
) != relative_path
|
||
{
|
||
return Err("动态隔离 Agent join claim 文件名与记录身份不一致".to_string());
|
||
}
|
||
claims.push(claim);
|
||
}
|
||
let mut owner_by_group = BTreeMap::<String, (String, String, String)>::new();
|
||
for claim in &claims {
|
||
for join in &claim.joins {
|
||
let owner = (
|
||
claim.parent_agent_id.clone(),
|
||
claim.parent_run_id.clone(),
|
||
claim.action_id.clone(),
|
||
);
|
||
if let Some(existing) = owner_by_group.insert(join.delegation_group_id.clone(), owner) {
|
||
return Err(format!(
|
||
"动态隔离 Agent join group 同时归属多个 claim journal:{} / {}:{}:{} / {}:{}:{}",
|
||
join.delegation_group_id,
|
||
existing.0,
|
||
existing.1,
|
||
existing.2,
|
||
claim.parent_agent_id,
|
||
claim.parent_run_id,
|
||
claim.action_id
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Ok(claims)
|
||
}
|
||
|
||
pub(crate) fn isolated_join_claim_lock_id(
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
action_id: &str,
|
||
) -> String {
|
||
isolated_join_claim_stem(parent_agent_id, parent_run_id, action_id)
|
||
}
|
||
|
||
pub(crate) fn read_isolated_join_delivery_at(
|
||
root: &Path,
|
||
join: &JoinDispatch,
|
||
) -> Result<Option<IsolatedAgentJoinDeliveryRecord>, String> {
|
||
let record = read_json_record::<IsolatedAgentJoinDeliveryRecord>(
|
||
root,
|
||
&isolated_join_delivery_relative_path(&join.delegation_group_id),
|
||
"动态隔离 Agent join delivery",
|
||
)?;
|
||
if let Some(record) = &record {
|
||
validate_isolated_join_delivery_record(root, record, join)?;
|
||
}
|
||
Ok(record)
|
||
}
|
||
|
||
pub(crate) fn write_isolated_join_delivery_at(
|
||
root: &Path,
|
||
join: &JoinDispatch,
|
||
status: IsolatedAgentJoinDeliveryStatus,
|
||
queued_run_id: Option<&str>,
|
||
claimed_by_action_id: Option<&str>,
|
||
) -> Result<IsolatedAgentJoinDeliveryRecord, String> {
|
||
write_isolated_join_delivery_with_target_at(
|
||
root,
|
||
join,
|
||
status,
|
||
None,
|
||
queued_run_id,
|
||
claimed_by_action_id,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn write_isolated_parent_wake_join_delivery_at(
|
||
root: &Path,
|
||
join: &JoinDispatch,
|
||
) -> Result<IsolatedAgentJoinDeliveryRecord, String> {
|
||
write_isolated_join_delivery_with_target_at(
|
||
root,
|
||
join,
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched,
|
||
Some(IsolatedAgentJoinDeliveryTarget::ParentWake),
|
||
None,
|
||
None,
|
||
)
|
||
}
|
||
|
||
fn write_isolated_join_delivery_with_target_at(
|
||
root: &Path,
|
||
join: &JoinDispatch,
|
||
status: IsolatedAgentJoinDeliveryStatus,
|
||
requested_target: Option<IsolatedAgentJoinDeliveryTarget>,
|
||
queued_run_id: Option<&str>,
|
||
claimed_by_action_id: Option<&str>,
|
||
) -> Result<IsolatedAgentJoinDeliveryRecord, String> {
|
||
let existing = read_isolated_join_delivery_at(root, join)?;
|
||
if let Some(existing) = &existing {
|
||
let transition_allowed = match (existing.status, status) {
|
||
(current, next) if current == next => true,
|
||
(
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched,
|
||
IsolatedAgentJoinDeliveryStatus::ClaimedByParent
|
||
| IsolatedAgentJoinDeliveryStatus::Suppressed,
|
||
) => true,
|
||
_ => false,
|
||
};
|
||
if !transition_allowed {
|
||
return Err(format!(
|
||
"动态隔离 Agent join delivery 状态不可逆:{:?} -> {:?}",
|
||
existing.status, status
|
||
));
|
||
}
|
||
if requested_target.is_some_and(|target| target != existing.delivery_target) {
|
||
return Err("动态隔离 Agent join delivery 不能更改投递目标".to_string());
|
||
}
|
||
}
|
||
let delivery_target = existing
|
||
.as_ref()
|
||
.map(|record| record.delivery_target)
|
||
.or(requested_target)
|
||
.unwrap_or_default();
|
||
let queued_run_id = queued_run_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string)
|
||
.or_else(|| {
|
||
existing
|
||
.as_ref()
|
||
.and_then(|record| record.queued_run_id.clone())
|
||
});
|
||
if let Some(run_id) = &queued_run_id {
|
||
validate_safe_id(run_id, "queuedRunId", 160)?;
|
||
if run_id != &join.join_run_id {
|
||
return Err(format!(
|
||
"动态隔离 Agent join 必须使用稳定 runId:expected={}, actual={run_id}",
|
||
join.join_run_id
|
||
));
|
||
}
|
||
}
|
||
let claimed_by_action_id = claimed_by_action_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string)
|
||
.or_else(|| {
|
||
existing
|
||
.as_ref()
|
||
.and_then(|record| record.claimed_by_action_id.clone())
|
||
});
|
||
if let (Some(existing_action_id), Some(next_action_id)) = (
|
||
existing
|
||
.as_ref()
|
||
.and_then(|record| record.claimed_by_action_id.as_deref()),
|
||
claimed_by_action_id.as_deref(),
|
||
) {
|
||
if existing_action_id != next_action_id {
|
||
return Err("动态隔离 Agent join 已被其他 actionId 认领".to_string());
|
||
}
|
||
}
|
||
if status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent {
|
||
let action_id = claimed_by_action_id
|
||
.as_deref()
|
||
.ok_or_else(|| "动态隔离 Agent join 认领缺少 actionId".to_string())?;
|
||
validate_safe_id(action_id, "claimedByActionId", 256)?;
|
||
} else if claimed_by_action_id.is_some() {
|
||
return Err("未认领的动态隔离 Agent join 不能保存 claimedByActionId".to_string());
|
||
}
|
||
if let Some(existing) = &existing {
|
||
if existing.status == status
|
||
&& existing.delivery_target == delivery_target
|
||
&& existing.queued_run_id == queued_run_id
|
||
&& existing.claimed_by_action_id == claimed_by_action_id
|
||
{
|
||
return Ok(existing.clone());
|
||
}
|
||
}
|
||
let record = IsolatedAgentJoinDeliveryRecord {
|
||
schema_version: ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION.to_string(),
|
||
parent_agent_id: join.parent_agent_id.clone(),
|
||
parent_run_id: join.parent_run_id.clone(),
|
||
delegation_group_id: join.delegation_group_id.clone(),
|
||
join_run_id: join.join_run_id.clone(),
|
||
status,
|
||
delivery_target,
|
||
queued_run_id,
|
||
claimed_by_action_id,
|
||
updated_at: unix_timestamp(),
|
||
};
|
||
validate_isolated_join_delivery_record(root, &record, join)?;
|
||
write_json_record(
|
||
root,
|
||
&isolated_join_delivery_relative_path(&join.delegation_group_id),
|
||
"动态隔离 Agent join delivery",
|
||
&record,
|
||
)?;
|
||
Ok(record)
|
||
}
|
||
|
||
pub(crate) fn list_non_terminal_isolated_children_for_parent_cancel_at(
|
||
root: &Path,
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
) -> Result<Vec<IsolatedAgentCancelTarget>, String> {
|
||
validate_safe_id(parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(parent_run_id, "parentRunId", 160)?;
|
||
let groups = list_json_records(
|
||
root,
|
||
ISOLATED_AGENT_GROUP_DIR,
|
||
"动态隔离 Agent group",
|
||
|record| validate_isolated_group_record(root, record),
|
||
)?;
|
||
let mut targets = Vec::new();
|
||
for group in groups.into_iter().filter(|group| {
|
||
group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id
|
||
}) {
|
||
for instance_id in group.instance_ids {
|
||
let instance = resolve_isolated_agent_instance_at(root, &instance_id)?;
|
||
if read_isolated_result_at(root, &instance)?.is_none() {
|
||
targets.push(IsolatedAgentCancelTarget {
|
||
delegation_group_id: instance.delegation_group_id,
|
||
delegation_id: instance.delegation_id,
|
||
instance_id: instance.instance_id,
|
||
session_id: instance.session_id,
|
||
run_id: instance.run_id,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
targets.sort_by(|left, right| left.instance_id.cmp(&right.instance_id));
|
||
Ok(targets)
|
||
}
|
||
|
||
fn build_join_dispatch_if_ready_at(
|
||
root: &Path,
|
||
delegation_group_id: &str,
|
||
) -> Result<Option<JoinDispatch>, String> {
|
||
let group = read_json_record::<IsolatedAgentGroupRecord>(
|
||
root,
|
||
&isolated_group_relative_path(delegation_group_id),
|
||
"动态隔离 Agent group",
|
||
)?
|
||
.ok_or_else(|| format!("未找到动态隔离 Agent group:{delegation_group_id}"))?;
|
||
validate_isolated_group_record(root, &group)?;
|
||
let mut results = Vec::with_capacity(group.instance_ids.len());
|
||
for instance_id in &group.instance_ids {
|
||
let instance = resolve_isolated_agent_instance_at(root, instance_id)?;
|
||
let Some(record) = read_isolated_result_at(root, &instance)? else {
|
||
return Ok(None);
|
||
};
|
||
results.push(record.result);
|
||
}
|
||
let derived = derive_game_creation_isolated_agent_group_at_depth(
|
||
&group.parent_action_id,
|
||
&group.request,
|
||
group.depth.saturating_sub(1),
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let joined = join_game_creation_isolated_agent_results(&derived, results)
|
||
.map_err(|error| error.to_string())?;
|
||
let mut prompt = serde_json::json!({
|
||
"schemaVersion": ISOLATED_AGENT_JOIN_PROMPT_SCHEMA_VERSION,
|
||
"kind": "agent-isolated-join",
|
||
"delegationGroupId": joined.delegation_group_id,
|
||
"joinMode": joined.join_mode,
|
||
"results": joined.results,
|
||
});
|
||
sanitize_json_value(root, &mut prompt);
|
||
let prompt = serde_json::to_string(&prompt)
|
||
.map_err(|error| format!("序列化动态隔离 Agent join prompt 失败:{error}"))?;
|
||
serde_json::from_str::<Value>(&prompt)
|
||
.map_err(|error| format!("动态隔离 Agent join prompt 清洗后不是 JSON:{error}"))?;
|
||
Ok(Some(JoinDispatch {
|
||
parent_agent_id: group.parent_agent_id,
|
||
parent_session_id: group.parent_session_id,
|
||
parent_run_id: group.parent_run_id,
|
||
parent_action_id: group.parent_action_id,
|
||
delegation_group_id: group.delegation_group_id,
|
||
join_run_id: group.join_run_id,
|
||
source: "agent-isolated-join".to_string(),
|
||
prompt,
|
||
}))
|
||
}
|
||
|
||
fn validate_isolated_group_record(
|
||
root: &Path,
|
||
record: &IsolatedAgentGroupRecord,
|
||
) -> Result<(), String> {
|
||
if record.schema_version != ISOLATED_AGENT_GROUP_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的动态隔离 Agent group schema:{}",
|
||
record.schema_version
|
||
));
|
||
}
|
||
validate_safe_id(&record.parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(&record.parent_session_id, "parentSessionId", 160)?;
|
||
validate_safe_id(&record.parent_run_id, "parentRunId", 160)?;
|
||
validate_safe_id(&record.parent_action_id, "parentActionId", 256)?;
|
||
let parent_depth = record
|
||
.depth
|
||
.checked_sub(1)
|
||
.ok_or_else(|| "动态隔离 Agent group.depth 不能为 0".to_string())?;
|
||
let sanitized = sanitize_spawn_request(root, &record.request)?;
|
||
if sanitized != record.request {
|
||
return Err("动态隔离 Agent group 含未清洗文本".to_string());
|
||
}
|
||
let derived = derive_game_creation_isolated_agent_group_at_depth(
|
||
&record.parent_action_id,
|
||
&record.request,
|
||
parent_depth,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let instance_ids = derived
|
||
.children
|
||
.iter()
|
||
.map(|child| child.instance_id.clone())
|
||
.collect::<Vec<_>>();
|
||
if record.delegation_group_id != derived.delegation_group_id
|
||
|| record.join_run_id != derived.join_run_id
|
||
|| record.depth != derived.depth
|
||
|| record.join_mode != derived.join_mode
|
||
|| record.instance_ids != instance_ids
|
||
{
|
||
return Err("动态隔离 Agent group 派生身份不一致".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_isolated_join_delivery_record(
|
||
root: &Path,
|
||
record: &IsolatedAgentJoinDeliveryRecord,
|
||
join: &JoinDispatch,
|
||
) -> Result<(), String> {
|
||
if record.schema_version != ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的动态隔离 Agent join delivery schema:{}",
|
||
record.schema_version
|
||
));
|
||
}
|
||
validate_safe_id(&record.parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(&record.parent_run_id, "parentRunId", 160)?;
|
||
validate_safe_id(&record.delegation_group_id, "delegationGroupId", 160)?;
|
||
validate_safe_id(&record.join_run_id, "joinRunId", 160)?;
|
||
let group = read_json_record::<IsolatedAgentGroupRecord>(
|
||
root,
|
||
&isolated_group_relative_path(&record.delegation_group_id),
|
||
"动态隔离 Agent group",
|
||
)?
|
||
.ok_or_else(|| "动态隔离 Agent join delivery 找不到 group".to_string())?;
|
||
validate_isolated_group_record(root, &group)?;
|
||
if record.parent_agent_id != group.parent_agent_id
|
||
|| record.parent_run_id != group.parent_run_id
|
||
|| record.delegation_group_id != group.delegation_group_id
|
||
|| record.join_run_id != group.join_run_id
|
||
|| record.parent_agent_id != join.parent_agent_id
|
||
|| record.parent_run_id != join.parent_run_id
|
||
|| record.delegation_group_id != join.delegation_group_id
|
||
|| record.join_run_id != join.join_run_id
|
||
{
|
||
return Err("动态隔离 Agent join delivery 身份不一致".to_string());
|
||
}
|
||
match (record.delivery_target, record.queued_run_id.as_deref()) {
|
||
(IsolatedAgentJoinDeliveryTarget::ParentWake, Some(_)) => {
|
||
return Err("动态隔离 Agent parent-wake delivery 不能含 queuedRunId".to_string());
|
||
}
|
||
(IsolatedAgentJoinDeliveryTarget::Continuation, None)
|
||
if record.status == IsolatedAgentJoinDeliveryStatus::Dispatched =>
|
||
{
|
||
return Err("动态隔离 Agent continuation delivery 缺少 queuedRunId".to_string());
|
||
}
|
||
(_, Some(queued_run_id)) => {
|
||
validate_safe_id(queued_run_id, "queuedRunId", 160)?;
|
||
if queued_run_id != record.join_run_id {
|
||
return Err("动态隔离 Agent join delivery 使用了非稳定 queuedRunId".to_string());
|
||
}
|
||
}
|
||
(_, None) => {}
|
||
}
|
||
match (record.status, record.claimed_by_action_id.as_deref()) {
|
||
(IsolatedAgentJoinDeliveryStatus::ClaimedByParent, Some(action_id)) => {
|
||
validate_safe_id(action_id, "claimedByActionId", 256)?;
|
||
}
|
||
(IsolatedAgentJoinDeliveryStatus::ClaimedByParent, None) => {
|
||
return Err("动态隔离 Agent join delivery 缺少 claimedByActionId".to_string());
|
||
}
|
||
(_, Some(_)) => {
|
||
return Err("未认领的动态隔离 Agent join delivery 含 claimedByActionId".to_string());
|
||
}
|
||
(_, None) => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_isolated_instance_record(
|
||
root: &Path,
|
||
record: &IsolatedAgentInstanceRecord,
|
||
) -> Result<(), String> {
|
||
if record.schema_version != ISOLATED_AGENT_INSTANCE_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的动态隔离 Agent instance schema:{}",
|
||
record.schema_version
|
||
));
|
||
}
|
||
validate_safe_id(&record.parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(&record.parent_session_id, "parentSessionId", 160)?;
|
||
validate_safe_id(&record.parent_run_id, "parentRunId", 160)?;
|
||
validate_safe_id(&record.parent_action_id, "parentActionId", 256)?;
|
||
validate_safe_id(&record.instance_id, "instanceId", 96)?;
|
||
validate_safe_id(&record.session_id, "sessionId", 160)?;
|
||
validate_safe_id(&record.run_id, "runId", 160)?;
|
||
let identity = derive_game_creation_isolated_agent_identity(
|
||
&record.parent_action_id,
|
||
record.child_index,
|
||
&record.template_agent_id,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
if record.delegation_group_id != identity.delegation_group_id
|
||
|| record.delegation_id != identity.delegation_id
|
||
|| record.instance_id != identity.instance_id
|
||
|| record.session_id != isolated_child_session_id(&identity.instance_id)
|
||
|| record.run_id != isolated_child_run_id(&identity.delegation_id)
|
||
|| record.depth == 0
|
||
{
|
||
return Err("动态隔离 Agent instance 派生身份不一致".to_string());
|
||
}
|
||
let child = GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: record.template_agent_id.clone(),
|
||
task: record.task.clone(),
|
||
acceptance_criteria: record.acceptance_criteria.clone(),
|
||
expected_artifacts: record.expected_artifacts.clone(),
|
||
write_scopes: record.write_scopes.clone(),
|
||
};
|
||
let request = GameCreationIsolatedAgentSpawnRequest {
|
||
children: vec![child],
|
||
join_mode: GameCreationIsolatedAgentJoinMode::All,
|
||
};
|
||
validate_game_creation_isolated_agent_spawn_request_at_depth(
|
||
&request,
|
||
record.depth.saturating_sub(1),
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
if sanitize_spawn_request(root, &request)? != request {
|
||
return Err("动态隔离 Agent instance 含未清洗文本".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_isolated_result_record(
|
||
root: &Path,
|
||
record: &IsolatedAgentResultRecord,
|
||
) -> Result<(), String> {
|
||
if record.schema_version != ISOLATED_AGENT_RESULT_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的动态隔离 Agent result schema:{}",
|
||
record.schema_version
|
||
));
|
||
}
|
||
validate_game_creation_isolated_agent_child_result(&record.result)
|
||
.map_err(|error| error.to_string())?;
|
||
let instance = resolve_isolated_agent_instance_at(root, &record.result.instance_id)?;
|
||
if record.delegation_group_id != instance.delegation_group_id
|
||
|| record.child_index != instance.child_index
|
||
|| record.result.delegation_id != instance.delegation_id
|
||
|| record.result.template_agent_id != instance.template_agent_id
|
||
|| record.result.run_id != instance.run_id
|
||
{
|
||
return Err("动态隔离 Agent result 记录身份不一致".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_isolated_private_memory_record(
|
||
record: &IsolatedAgentPrivateMemoryRecord,
|
||
instance: &IsolatedAgentInstanceRecord,
|
||
) -> Result<(), String> {
|
||
if record.schema_version != ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的动态隔离 Agent 私有临时记忆 schema:{}",
|
||
record.schema_version
|
||
));
|
||
}
|
||
if record.instance_id != instance.instance_id {
|
||
return Err("动态隔离 Agent 私有临时记忆身份与 instance 不一致".to_string());
|
||
}
|
||
if record.content.as_bytes().len() > ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES {
|
||
return Err(format!(
|
||
"动态隔离 Agent 私有临时记忆超过 {} 字节上限",
|
||
ISOLATED_AGENT_PRIVATE_MEMORY_MAX_BYTES
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_terminal_task_identity(
|
||
instance: &IsolatedAgentInstanceRecord,
|
||
task: &IsolatedAgentTerminalTask,
|
||
) -> Result<(), String> {
|
||
if task.agent_id != instance.instance_id
|
||
|| task.session_id != instance.session_id
|
||
|| task.run_id != instance.run_id
|
||
|| task.delegation_id != instance.delegation_id
|
||
{
|
||
return Err("动态隔离子 Agent 终态 task 身份不一致".to_string());
|
||
}
|
||
terminal_result_status(task).map(|_| ())
|
||
}
|
||
|
||
fn terminal_result_status(
|
||
task: &IsolatedAgentTerminalTask,
|
||
) -> Result<GameCreationIsolatedAgentResultStatus, String> {
|
||
if task.phase == "budget-exhausted" {
|
||
return Ok(GameCreationIsolatedAgentResultStatus::BudgetExhausted);
|
||
}
|
||
match task.status.as_str() {
|
||
"completed" => Ok(GameCreationIsolatedAgentResultStatus::Completed),
|
||
"failed" => Ok(GameCreationIsolatedAgentResultStatus::Failed),
|
||
"cancelled" => Ok(GameCreationIsolatedAgentResultStatus::Cancelled),
|
||
_ => Err(format!(
|
||
"动态隔离子 Agent task 尚未终态:{} / {}",
|
||
task.status, task.phase
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn validate_verification_gate(
|
||
instance: &IsolatedAgentInstanceRecord,
|
||
gate: &IsolatedAgentVerificationGateSnapshot,
|
||
) -> Result<(), String> {
|
||
if gate.agent_id != instance.instance_id || gate.run_id != instance.run_id {
|
||
return Err("动态隔离子 Agent verification gate 身份不一致".to_string());
|
||
}
|
||
if gate.requires_verification {
|
||
let mutation = gate
|
||
.mutation_revision
|
||
.filter(|revision| *revision > 0)
|
||
.ok_or_else(|| {
|
||
"动态隔离子 Agent verification gate 缺少 mutationRevision".to_string()
|
||
})?;
|
||
let verified = gate
|
||
.verified_revision
|
||
.filter(|revision| *revision >= mutation)
|
||
.ok_or_else(|| "动态隔离子 Agent 尚未通过当前 revision 验证".to_string())?;
|
||
if verified == 0 || gate.last_verification_status.as_deref() != Some("passed") {
|
||
return Err("动态隔离子 Agent verification gate 未通过".to_string());
|
||
}
|
||
if !matches!(
|
||
gate.last_verification_tool.as_deref(),
|
||
Some("project.verify" | "game.static_smoke")
|
||
) {
|
||
return Err("动态隔离子 Agent verification tool 无效".to_string());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn sanitize_spawn_request(
|
||
root: &Path,
|
||
request: &GameCreationIsolatedAgentSpawnRequest,
|
||
) -> Result<GameCreationIsolatedAgentSpawnRequest, String> {
|
||
let mut request = request.clone();
|
||
for child in &mut request.children {
|
||
child.task = sanitize_persisted_text(root, &child.task, 4_000);
|
||
for criterion in &mut child.acceptance_criteria {
|
||
*criterion = sanitize_persisted_text(root, criterion, 500);
|
||
}
|
||
for path in child
|
||
.expected_artifacts
|
||
.iter()
|
||
.chain(child.write_scopes.iter())
|
||
{
|
||
if is_private_or_sensitive_path(path.trim_end_matches("/**")) {
|
||
return Err(format!("动态隔离 Agent 不允许私有或敏感路径:{path}"));
|
||
}
|
||
}
|
||
}
|
||
Ok(request)
|
||
}
|
||
|
||
fn sanitize_evidence(
|
||
root: &Path,
|
||
evidence: &[GameCreationIsolatedAgentEvidence],
|
||
) -> Result<Vec<GameCreationIsolatedAgentEvidence>, String> {
|
||
let mut sanitized = Vec::with_capacity(evidence.len());
|
||
for item in evidence {
|
||
let mut item = item.clone();
|
||
item.summary = sanitize_persisted_text(root, &item.summary, 1_000);
|
||
if let Some(path) = &item.path {
|
||
item.path = Some(normalize_relative_path(path)?);
|
||
}
|
||
sanitized.push(item);
|
||
}
|
||
Ok(sanitized)
|
||
}
|
||
|
||
fn collect_expected_artifacts(
|
||
root: &Path,
|
||
patterns: &[String],
|
||
require_each: bool,
|
||
) -> Result<Vec<GameCreationIsolatedAgentArtifact>, String> {
|
||
let mut artifacts = BTreeMap::<String, String>::new();
|
||
for pattern in patterns {
|
||
let matches = collect_artifact_matches(root, pattern)?;
|
||
if require_each && matches.is_empty() {
|
||
return Err(format!(
|
||
"动态隔离子 Agent 缺少 expected artifact:{pattern}"
|
||
));
|
||
}
|
||
for path in matches {
|
||
let absolute = resolve_local_project_path(root, &path)?;
|
||
artifacts.insert(path, sha256_file(&absolute)?);
|
||
if artifacts.len() > ISOLATED_AGENT_MAX_RESULT_ARTIFACTS {
|
||
return Err(format!(
|
||
"动态隔离子 Agent artifacts 超过 {ISOLATED_AGENT_MAX_RESULT_ARTIFACTS} 项"
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Ok(artifacts
|
||
.into_iter()
|
||
.map(|(path, sha256)| GameCreationIsolatedAgentArtifact { path, sha256 })
|
||
.collect())
|
||
}
|
||
|
||
fn collect_artifact_matches(root: &Path, pattern: &str) -> Result<Vec<String>, String> {
|
||
let prefix = pattern
|
||
.split('/')
|
||
.take_while(|part| !part.contains('*'))
|
||
.collect::<Vec<_>>()
|
||
.join("/");
|
||
let start = if prefix.is_empty() {
|
||
root.to_path_buf()
|
||
} else {
|
||
resolve_local_project_path(root, &prefix)?
|
||
};
|
||
if !start.exists() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let mut candidates = Vec::new();
|
||
let mut stack = vec![start];
|
||
let mut scanned = 0usize;
|
||
while let Some(path) = stack.pop() {
|
||
let metadata = fs::symlink_metadata(&path)
|
||
.map_err(|error| format!("读取 expected artifact 失败:{}: {error}", path.display()))?;
|
||
if metadata.file_type().is_symlink() {
|
||
continue;
|
||
}
|
||
if metadata.is_file() {
|
||
let relative = path
|
||
.strip_prefix(root)
|
||
.map_err(|_| "expected artifact 不在项目目录内".to_string())?
|
||
.components()
|
||
.map(|part| part.as_os_str().to_string_lossy().into_owned())
|
||
.collect::<Vec<_>>()
|
||
.join("/");
|
||
if !is_private_or_sensitive_path(&relative) && glob_matches_path(pattern, &relative) {
|
||
candidates.push(relative);
|
||
}
|
||
continue;
|
||
}
|
||
if !metadata.is_dir() {
|
||
continue;
|
||
}
|
||
for entry in fs::read_dir(&path).map_err(|error| {
|
||
format!(
|
||
"读取 expected artifact 目录失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})? {
|
||
let entry = entry.map_err(|error| {
|
||
format!(
|
||
"读取 expected artifact 条目失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
scanned += 1;
|
||
if scanned > ISOLATED_AGENT_MAX_SCANNED_ARTIFACT_ENTRIES {
|
||
return Err("动态隔离子 Agent expected artifact 扫描超过安全上限".to_string());
|
||
}
|
||
let candidate = entry.path();
|
||
let relative = candidate
|
||
.strip_prefix(root)
|
||
.unwrap_or(&candidate)
|
||
.to_string_lossy()
|
||
.replace('\\', "/");
|
||
if !is_private_or_sensitive_path(&relative) {
|
||
stack.push(candidate);
|
||
}
|
||
}
|
||
}
|
||
candidates.sort();
|
||
candidates.dedup();
|
||
Ok(candidates)
|
||
}
|
||
|
||
fn glob_matches_path(pattern: &str, path: &str) -> bool {
|
||
fn segment_matches(pattern: &str, value: &str) -> bool {
|
||
let pattern = pattern.as_bytes();
|
||
let value = value.as_bytes();
|
||
let (mut pi, mut vi, mut star, mut matched) = (0, 0, None, 0);
|
||
while vi < value.len() {
|
||
if pi < pattern.len() && pattern[pi] == value[vi] {
|
||
pi += 1;
|
||
vi += 1;
|
||
} else if pi < pattern.len() && pattern[pi] == b'*' {
|
||
star = Some(pi);
|
||
matched = vi;
|
||
pi += 1;
|
||
} else if let Some(star_index) = star {
|
||
matched += 1;
|
||
vi = matched;
|
||
pi = star_index + 1;
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
while pi < pattern.len() && pattern[pi] == b'*' {
|
||
pi += 1;
|
||
}
|
||
pi == pattern.len()
|
||
}
|
||
fn recurse(pattern: &[&str], path: &[&str]) -> bool {
|
||
match pattern.split_first() {
|
||
None => path.is_empty(),
|
||
Some((head, rest)) if *head == "**" => {
|
||
recurse(rest, path) || (!path.is_empty() && recurse(pattern, &path[1..]))
|
||
}
|
||
Some((head, rest)) => {
|
||
!path.is_empty() && segment_matches(head, path[0]) && recurse(rest, &path[1..])
|
||
}
|
||
}
|
||
}
|
||
recurse(
|
||
&pattern.split('/').collect::<Vec<_>>(),
|
||
&path.split('/').collect::<Vec<_>>(),
|
||
)
|
||
}
|
||
|
||
fn read_isolated_result_at(
|
||
root: &Path,
|
||
instance: &IsolatedAgentInstanceRecord,
|
||
) -> Result<Option<IsolatedAgentResultRecord>, String> {
|
||
let record = read_json_record::<IsolatedAgentResultRecord>(
|
||
root,
|
||
&isolated_result_relative_path(&instance.instance_id),
|
||
"动态隔离 Agent result",
|
||
)?;
|
||
if let Some(record) = &record {
|
||
validate_isolated_result_record(root, record)?;
|
||
}
|
||
Ok(record)
|
||
}
|
||
|
||
fn path_matches_write_scope(path: &str, scope: &str) -> bool {
|
||
let prefix = scope.strip_suffix("/**").unwrap_or(scope);
|
||
path == prefix || path.starts_with(&format!("{prefix}/"))
|
||
}
|
||
|
||
fn isolated_child_session_id(instance_id: &str) -> String {
|
||
format!("isolated-session-{instance_id}")
|
||
}
|
||
|
||
fn isolated_child_run_id(delegation_id: &str) -> String {
|
||
format!(
|
||
"agent-isolated-{}",
|
||
delegation_id.chars().take(24).collect::<String>()
|
||
)
|
||
}
|
||
|
||
fn isolated_instance_relative_path(instance_id: &str) -> String {
|
||
format!("{ISOLATED_AGENT_INSTANCE_DIR}/{instance_id}.json")
|
||
}
|
||
|
||
fn isolated_group_relative_path(group_id: &str) -> String {
|
||
format!("{ISOLATED_AGENT_GROUP_DIR}/{group_id}.json")
|
||
}
|
||
|
||
fn isolated_result_relative_path(instance_id: &str) -> String {
|
||
format!("{ISOLATED_AGENT_RESULT_DIR}/{instance_id}.json")
|
||
}
|
||
|
||
fn isolated_join_delivery_relative_path(group_id: &str) -> String {
|
||
format!("{ISOLATED_AGENT_JOIN_DELIVERY_DIR}/{group_id}.json")
|
||
}
|
||
|
||
fn isolated_join_claim_relative_path(
|
||
parent_agent_id: &str,
|
||
parent_run_id: &str,
|
||
action_id: &str,
|
||
) -> String {
|
||
format!(
|
||
"{ISOLATED_AGENT_JOIN_CLAIM_DIR}/{}.json",
|
||
isolated_join_claim_stem(parent_agent_id, parent_run_id, action_id)
|
||
)
|
||
}
|
||
|
||
fn isolated_join_claim_stem(parent_agent_id: &str, parent_run_id: &str, action_id: &str) -> String {
|
||
let identity = format!("{parent_agent_id}\n{parent_run_id}\n{action_id}");
|
||
let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes()));
|
||
format!("claim-{fingerprint}")
|
||
}
|
||
|
||
fn isolated_private_memory_relative_path(instance_id: &str) -> String {
|
||
format!("{ISOLATED_AGENT_PRIVATE_MEMORY_DIR}/{instance_id}.json")
|
||
}
|
||
|
||
fn validate_safe_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
|
||
if value.is_empty() || value.trim() != value || value.chars().count() > max_chars {
|
||
return Err(format!("动态隔离 Agent {label} 非法"));
|
||
}
|
||
if value.chars().any(|character| {
|
||
!(character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||
}) {
|
||
return Err(format!(
|
||
"动态隔离 Agent {label} 只能包含 ASCII 字母、数字、点、短横线和下划线"
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_isolated_join_claim_record(
|
||
root: &Path,
|
||
claim: &IsolatedAgentJoinClaimRecord,
|
||
) -> Result<(), String> {
|
||
if claim.schema_version != ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION {
|
||
return Err("动态隔离 Agent join claim schemaVersion 不受支持".to_string());
|
||
}
|
||
validate_safe_id(&claim.parent_agent_id, "parentAgentId", 96)?;
|
||
validate_safe_id(&claim.parent_run_id, "parentRunId", 160)?;
|
||
validate_safe_id(&claim.action_id, "actionId", 256)?;
|
||
if claim.joins.is_empty() || claim.joins.len() > 16 {
|
||
return Err("动态隔离 Agent join claim 数量无效".to_string());
|
||
}
|
||
let mut previous_group_id: Option<&str> = None;
|
||
for join in &claim.joins {
|
||
if join.parent_agent_id != claim.parent_agent_id
|
||
|| join.parent_run_id != claim.parent_run_id
|
||
|| join.source != "agent-isolated-join"
|
||
{
|
||
return Err("动态隔离 Agent join claim 与父 run 身份不一致".to_string());
|
||
}
|
||
validate_safe_id(&join.parent_agent_id, "join.parentAgentId", 96)?;
|
||
validate_safe_id(&join.parent_session_id, "join.parentSessionId", 160)?;
|
||
validate_safe_id(&join.parent_run_id, "join.parentRunId", 160)?;
|
||
validate_safe_id(&join.parent_action_id, "join.parentActionId", 256)?;
|
||
validate_safe_id(&join.delegation_group_id, "join.delegationGroupId", 160)?;
|
||
validate_safe_id(&join.join_run_id, "join.joinRunId", 160)?;
|
||
if previous_group_id.is_some_and(|previous| previous >= join.delegation_group_id.as_str()) {
|
||
return Err(
|
||
"动态隔离 Agent join claim 必须按 delegationGroupId 严格排序且不能重复".to_string(),
|
||
);
|
||
}
|
||
let current = build_join_dispatch_if_ready_at(root, &join.delegation_group_id)?
|
||
.ok_or_else(|| "动态隔离 Agent join claim 对应 group 尚未 ready".to_string())?;
|
||
if current != *join {
|
||
return Err("动态隔离 Agent join claim 与当前 durable join 结果冲突".to_string());
|
||
}
|
||
previous_group_id = Some(&join.delegation_group_id);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn is_private_or_sensitive_path(path: &str) -> bool {
|
||
let path = path.trim_start_matches("./").to_ascii_lowercase();
|
||
path == ".agent"
|
||
|| path.starts_with(".agent/")
|
||
|| path == ".git"
|
||
|| path.starts_with(".git/")
|
||
|| path == "node_modules"
|
||
|| path.starts_with("node_modules/")
|
||
|| path == ".env"
|
||
|| path.starts_with(".env.")
|
||
|| path.ends_with(".pem")
|
||
|| path.ends_with(".key")
|
||
|| path.contains("credentials")
|
||
|| path.contains("game-creator.config")
|
||
}
|
||
|
||
fn sanitize_persisted_text(root: &Path, value: &str, max_chars: usize) -> String {
|
||
let mut value = sanitize_prompt_context(value);
|
||
let root_text = root.to_string_lossy();
|
||
if !root_text.is_empty() {
|
||
value = value.replace(root_text.as_ref(), "$PROJECT_ROOT");
|
||
}
|
||
if let Ok(canonical) = root.canonicalize() {
|
||
let canonical = canonical.to_string_lossy();
|
||
if !canonical.is_empty() {
|
||
value = value.replace(canonical.as_ref(), "$PROJECT_ROOT");
|
||
}
|
||
}
|
||
for token in value
|
||
.split_whitespace()
|
||
.map(|token| {
|
||
token.trim_matches(|character: char| {
|
||
matches!(
|
||
character,
|
||
'"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
|
||
)
|
||
})
|
||
})
|
||
.filter(|token| {
|
||
Path::new(token).is_absolute()
|
||
|| (token.len() > 2
|
||
&& token.as_bytes()[1] == b':'
|
||
&& token.as_bytes()[0].is_ascii_alphabetic())
|
||
})
|
||
.map(str::to_string)
|
||
.collect::<BTreeSet<_>>()
|
||
{
|
||
value = value.replace(&token, "[redacted-absolute-path]");
|
||
}
|
||
let trimmed = value.trim();
|
||
let mut output = trimmed.chars().take(max_chars).collect::<String>();
|
||
if output.is_empty() {
|
||
output = "[redacted sensitive context]".to_string();
|
||
}
|
||
output
|
||
}
|
||
|
||
fn sanitize_json_value(root: &Path, value: &mut Value) {
|
||
match value {
|
||
Value::String(text) => *text = sanitize_persisted_text(root, text, 4_000),
|
||
Value::Array(items) => {
|
||
for item in items {
|
||
sanitize_json_value(root, item);
|
||
}
|
||
}
|
||
Value::Object(map) => {
|
||
for (key, value) in map.iter_mut() {
|
||
let key = key.to_ascii_lowercase();
|
||
if key.contains("token")
|
||
|| key.contains("secret")
|
||
|| key.contains("password")
|
||
|| key.contains("cookie")
|
||
|| key.contains("authorization")
|
||
|| key.contains("apikey")
|
||
|| key.contains("api_key")
|
||
{
|
||
*value = Value::String("[redacted sensitive context]".to_string());
|
||
} else {
|
||
sanitize_json_value(root, value);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn sha256_file(path: &Path) -> Result<String, String> {
|
||
let mut file = File::open(path)
|
||
.map_err(|error| format!("读取 artifact 失败:{}: {error}", path.display()))?;
|
||
let mut digest = Sha256::new();
|
||
let mut buffer = [0u8; 64 * 1024];
|
||
loop {
|
||
let count = file
|
||
.read(&mut buffer)
|
||
.map_err(|error| format!("读取 artifact 失败:{}: {error}", path.display()))?;
|
||
if count == 0 {
|
||
break;
|
||
}
|
||
digest.update(&buffer[..count]);
|
||
}
|
||
Ok(format!("{:x}", digest.finalize()))
|
||
}
|
||
|
||
fn write_json_record<T: Serialize>(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
label: &str,
|
||
value: &T,
|
||
) -> Result<(), String> {
|
||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||
root,
|
||
relative_path,
|
||
label,
|
||
value,
|
||
ISOLATED_AGENT_RECORD_MAX_BYTES,
|
||
)
|
||
}
|
||
|
||
fn read_json_record<T: serde::de::DeserializeOwned>(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
label: &str,
|
||
) -> Result<Option<T>, String> {
|
||
let path = resolve_local_project_path(root, relative_path)?;
|
||
let metadata = match fs::symlink_metadata(&path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 {label} 元数据失败:{}: {error}",
|
||
path.display()
|
||
))
|
||
}
|
||
};
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err(format!("{label} 必须是普通文件"));
|
||
}
|
||
if metadata.len() > ISOLATED_AGENT_RECORD_MAX_BYTES as u64 {
|
||
return Err(format!("{label} 超过安全大小上限"));
|
||
}
|
||
let mut file = File::open(&path)
|
||
.map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?;
|
||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||
file.by_ref()
|
||
.take((ISOLATED_AGENT_RECORD_MAX_BYTES + 1) as u64)
|
||
.read_to_end(&mut bytes)
|
||
.map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?;
|
||
if bytes.len() > ISOLATED_AGENT_RECORD_MAX_BYTES {
|
||
return Err(format!("{label} 超过安全大小上限"));
|
||
}
|
||
serde_json::from_slice(&bytes)
|
||
.map(Some)
|
||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", path.display()))
|
||
}
|
||
|
||
fn list_json_records<T, F>(
|
||
root: &Path,
|
||
relative_dir: &str,
|
||
label: &str,
|
||
validate: F,
|
||
) -> Result<Vec<T>, String>
|
||
where
|
||
T: serde::de::DeserializeOwned,
|
||
F: Fn(&T) -> Result<(), String>,
|
||
{
|
||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||
let entries = match fs::read_dir(&dir) {
|
||
Ok(entries) => entries,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||
Err(error) => return Err(format!("读取 {label} 目录失败:{}: {error}", dir.display())),
|
||
};
|
||
let mut paths = Vec::<PathBuf>::new();
|
||
for entry in entries {
|
||
let entry = entry.map_err(|error| format!("读取 {label} 条目失败:{error}"))?;
|
||
let path = entry.path();
|
||
if path.extension().and_then(|value| value.to_str()) == Some("json") {
|
||
paths.push(path);
|
||
}
|
||
}
|
||
paths.sort();
|
||
let mut records = Vec::with_capacity(paths.len());
|
||
for path in paths {
|
||
let file_name = path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.ok_or_else(|| format!("{label} 文件名不是 UTF-8"))?;
|
||
let relative_path = format!("{relative_dir}/{file_name}");
|
||
let record = read_json_record::<T>(root, &relative_path, label)?
|
||
.ok_or_else(|| format!("{label} 在枚举后消失:{relative_path}"))?;
|
||
validate(&record)?;
|
||
records.push(record);
|
||
}
|
||
Ok(records)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::io::Write;
|
||
use tempfile::tempdir;
|
||
|
||
fn request(children: Vec<(&str, &str)>) -> GameCreationIsolatedAgentSpawnRequest {
|
||
GameCreationIsolatedAgentSpawnRequest {
|
||
children: children
|
||
.into_iter()
|
||
.map(|(template, scope)| GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: template.to_string(),
|
||
task: format!("完成 {scope} 子任务"),
|
||
acceptance_criteria: vec!["产物可验证".to_string()],
|
||
expected_artifacts: vec![scope.to_string()],
|
||
write_scopes: vec![scope.to_string()],
|
||
})
|
||
.collect(),
|
||
join_mode: GameCreationIsolatedAgentJoinMode::All,
|
||
}
|
||
}
|
||
|
||
fn create_group(
|
||
root: &Path,
|
||
action: &str,
|
||
request: &GameCreationIsolatedAgentSpawnRequest,
|
||
) -> IsolatedAgentGroupRecord {
|
||
create_or_read_isolated_group_at(
|
||
root,
|
||
"code-prototype",
|
||
"parent-run",
|
||
"parent-session",
|
||
action,
|
||
request,
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
fn completed_result(
|
||
instance: &IsolatedAgentInstanceRecord,
|
||
) -> GameCreationIsolatedAgentChildResult {
|
||
GameCreationIsolatedAgentChildResult {
|
||
delegation_id: instance.delegation_id.clone(),
|
||
instance_id: instance.instance_id.clone(),
|
||
template_agent_id: instance.template_agent_id.clone(),
|
||
run_id: instance.run_id.clone(),
|
||
status: GameCreationIsolatedAgentResultStatus::Completed,
|
||
summary: "已完成".to_string(),
|
||
artifacts: vec![GameCreationIsolatedAgentArtifact {
|
||
path: format!("game/{}/main.js", instance.child_index),
|
||
sha256: "a".repeat(64),
|
||
}],
|
||
evidence: vec![GameCreationIsolatedAgentEvidence {
|
||
kind: "project.verify".to_string(),
|
||
summary: "验证通过".to_string(),
|
||
path: None,
|
||
sha256: None,
|
||
}],
|
||
verified_revision: Some(1),
|
||
error: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn create_group_is_idempotent() {
|
||
let temp = tempdir().unwrap();
|
||
let request = request(vec![("code-prototype", "game/a/**")]);
|
||
let first = create_group(temp.path(), "action-idempotent", &request);
|
||
let second = create_group(temp.path(), "action-idempotent", &request);
|
||
assert_eq!(first, second);
|
||
assert_eq!(
|
||
list_isolated_agent_instances_at(temp.path()).unwrap().len(),
|
||
1
|
||
);
|
||
let duplicate = create_or_read_isolated_group_at(
|
||
temp.path(),
|
||
"code-prototype",
|
||
"parent-run",
|
||
"parent-session",
|
||
"action-duplicate-request",
|
||
&request,
|
||
)
|
||
.unwrap_err();
|
||
assert!(duplicate.contains("不得在同一父 run 重复 spawn"));
|
||
assert_eq!(
|
||
list_isolated_agent_instances_at(temp.path()).unwrap().len(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn isolated_agent_group_summary_counts_idempotent_group_once() {
|
||
let temp = tempdir().unwrap();
|
||
let request = request(vec![("code-a", "game/a/**"), ("code-b", "game/b/**")]);
|
||
let first = create_group(temp.path(), "action-summary", &request);
|
||
let second = create_group(temp.path(), "action-summary", &request);
|
||
assert_eq!(first, second);
|
||
assert_eq!(
|
||
isolated_agent_group_summary_at(temp.path(), "code-prototype", "parent-run")
|
||
.expect("summarize groups"),
|
||
IsolatedAgentGroupSummary {
|
||
group_count: 1,
|
||
child_count: 2,
|
||
max_child_count: 2,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn completed_child_with_missing_expected_artifact_dispatches_failed_join_result() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-missing-artifact",
|
||
&request(vec![("code-prototype", "game/missing/**")]),
|
||
);
|
||
let instance =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
let task = IsolatedAgentTerminalTask {
|
||
agent_id: instance.instance_id.clone(),
|
||
session_id: instance.session_id.clone(),
|
||
run_id: instance.run_id.clone(),
|
||
delegation_id: instance.delegation_id.clone(),
|
||
status: "completed".to_string(),
|
||
phase: "completed".to_string(),
|
||
terminal_detail: Some("只读检查完成".to_string()),
|
||
error: None,
|
||
};
|
||
let gate = IsolatedAgentVerificationGateSnapshot {
|
||
agent_id: instance.instance_id.clone(),
|
||
run_id: instance.run_id.clone(),
|
||
requires_verification: false,
|
||
mutation_revision: None,
|
||
verified_revision: None,
|
||
last_verification_tool: None,
|
||
last_verification_status: None,
|
||
};
|
||
|
||
let built = build_isolated_child_result_with_failure_fallback_at(
|
||
temp.path(),
|
||
&instance.instance_id,
|
||
&task,
|
||
&instance.expected_artifacts,
|
||
&gate,
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
built.result.status,
|
||
GameCreationIsolatedAgentResultStatus::Failed
|
||
);
|
||
assert!(built.result.artifacts.is_empty());
|
||
assert!(built
|
||
.result
|
||
.error
|
||
.as_deref()
|
||
.is_some_and(|error| error.contains("缺少 expected artifact:game/missing/**")));
|
||
assert!(built.join_dispatch.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn write_tools_stay_inside_instance_scopes_and_dangerous_tools_are_denied() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-scope",
|
||
&request(vec![("code-prototype", "game/a/**")]),
|
||
);
|
||
let instance_id = &group.instance_ids[0];
|
||
assert!(validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
"file.write",
|
||
&serde_json::json!({"path": "game/a/main.js"}),
|
||
)
|
||
.is_ok());
|
||
assert!(validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
"file.delete",
|
||
&serde_json::json!({"path": "game/b/main.js"}),
|
||
)
|
||
.is_err());
|
||
assert!(validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
"project.patchset",
|
||
&serde_json::json!({
|
||
"changes": [
|
||
{"operation": "create", "path": "game/a/main.js", "content": "ok"},
|
||
{"operation": "delete", "path": "game/a/old.js", "expectedSha256": "a".repeat(64)}
|
||
]
|
||
}),
|
||
)
|
||
.is_ok());
|
||
assert!(validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
"project.patchset",
|
||
&serde_json::json!({
|
||
"changes": [
|
||
{"operation": "create", "path": "game/a/main.js", "content": "ok"},
|
||
{"operation": "create", "path": "game/b/main.js", "content": "blocked"}
|
||
]
|
||
}),
|
||
)
|
||
.is_err());
|
||
assert!(validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
"agent.spawn_isolated",
|
||
&serde_json::json!({}),
|
||
)
|
||
.is_err());
|
||
for tool in ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS {
|
||
let error = validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
tool,
|
||
&serde_json::json!({}),
|
||
)
|
||
.expect_err("unscoped isolated child tool must be denied");
|
||
assert!(
|
||
error.contains("动态隔离子 Agent"),
|
||
"unexpected {tool} error: {error}"
|
||
);
|
||
}
|
||
for tool in [
|
||
"command.output_read",
|
||
"command.poll",
|
||
"command.terminate",
|
||
"command.run_limited",
|
||
"preview.validate",
|
||
"project.checkpoint",
|
||
] {
|
||
assert!(
|
||
validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
instance_id,
|
||
tool,
|
||
&serde_json::json!({}),
|
||
)
|
||
.is_ok(),
|
||
"{tool} should remain available to isolated children"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rendered_child_task_contains_persisted_contract_without_expanding_write_scope() {
|
||
let temp = tempdir().unwrap();
|
||
let request = GameCreationIsolatedAgentSpawnRequest {
|
||
children: vec![GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: "code-prototype".to_string(),
|
||
task: "实现严格局部子任务".to_string(),
|
||
acceptance_criteria: vec!["验收标记 acceptance-contract-unique".to_string()],
|
||
expected_artifacts: vec!["game/contract/output.unique".to_string()],
|
||
write_scopes: vec!["game/contract/**".to_string()],
|
||
}],
|
||
join_mode: GameCreationIsolatedAgentJoinMode::All,
|
||
};
|
||
let group = create_group(temp.path(), "action-render-contract", &request);
|
||
let instance =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
|
||
let rendered = render_isolated_agent_task_contract(&instance).unwrap();
|
||
|
||
for expected in [
|
||
"实现严格局部子任务",
|
||
"acceptanceCriteria",
|
||
"验收标记 acceptance-contract-unique",
|
||
"expectedArtifacts",
|
||
"game/contract/output.unique",
|
||
"writeScopes",
|
||
"game/contract/**",
|
||
"继承上下文只用于理解这项局部任务",
|
||
"写入范围限于 writeScopes",
|
||
"仅向父 Agent 报告这项局部任务的实际结果与证据",
|
||
] {
|
||
assert!(
|
||
rendered.contains(expected),
|
||
"missing {expected}: {rendered}"
|
||
);
|
||
}
|
||
assert!(validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
&instance.instance_id,
|
||
"file.write",
|
||
&serde_json::json!({"path": "game/contract/output.unique"}),
|
||
)
|
||
.is_ok());
|
||
let error = validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
&instance.instance_id,
|
||
"file.write",
|
||
&serde_json::json!({"path": "game/outside/output.unique"}),
|
||
)
|
||
.unwrap_err();
|
||
assert!(
|
||
error.contains("超出 writeScopes"),
|
||
"unexpected error: {error}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn child_agent_cannot_git_commit_even_when_paths_are_inside_write_scopes() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-git-commit-scope",
|
||
&request(vec![("code-prototype", "game/a/**")]),
|
||
);
|
||
|
||
let error = validate_isolated_agent_tool_scope_at(
|
||
temp.path(),
|
||
&group.instance_ids[0],
|
||
"project.git_commit",
|
||
&serde_json::json!({
|
||
"message": "提交子任务产物",
|
||
"paths": ["game/a/main.js", "game/a/assets/player.png"]
|
||
}),
|
||
)
|
||
.unwrap_err();
|
||
|
||
assert_eq!(
|
||
error,
|
||
"动态隔离子 Agent 作用域拒绝 project.git_commit:最终提交由父 Agent 统一负责"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn same_template_produces_distinct_instances_sessions_and_runs() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-same-template",
|
||
&request(vec![
|
||
("code-prototype", "game/a/**"),
|
||
("code-prototype", "game/b/**"),
|
||
]),
|
||
);
|
||
let first =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
let second =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[1]).unwrap();
|
||
assert_ne!(first.instance_id, second.instance_id);
|
||
assert_ne!(first.session_id, second.session_id);
|
||
assert_ne!(first.run_id, second.run_id);
|
||
assert_eq!(first.template_agent_id, second.template_agent_id);
|
||
}
|
||
|
||
#[test]
|
||
fn all_join_waits_for_every_terminal_result_and_reconciles_stably() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-all-join",
|
||
&request(vec![("code-a", "game/a/**"), ("code-b", "game/b/**")]),
|
||
);
|
||
let first =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
let second =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[1]).unwrap();
|
||
assert!(
|
||
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
|
||
.unwrap()
|
||
.is_some_and(|detail| detail.contains("waitingGroups=1"))
|
||
);
|
||
assert!(
|
||
record_isolated_child_result_at(temp.path(), &completed_result(&first))
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&second))
|
||
.unwrap()
|
||
.unwrap();
|
||
assert_eq!(dispatch.join_run_id, group.join_run_id);
|
||
assert_eq!(dispatch.parent_session_id, "parent-session");
|
||
assert!(serde_json::from_str::<Value>(&dispatch.prompt).is_ok());
|
||
assert!(
|
||
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
|
||
.unwrap()
|
||
.is_some_and(|detail| detail.contains("readyUnclaimedGroups=1"))
|
||
);
|
||
let reconciled = reconcile_all_isolated_groups_at(temp.path()).unwrap();
|
||
assert_eq!(reconciled, vec![dispatch]);
|
||
}
|
||
|
||
#[test]
|
||
fn join_delivery_is_persistent_and_cannot_reopen_after_parent_claims_it() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-join-delivery",
|
||
&request(vec![("code-prototype", "game/a/**")]),
|
||
);
|
||
let instance =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance))
|
||
.unwrap()
|
||
.unwrap();
|
||
|
||
let dispatched = write_isolated_join_delivery_at(
|
||
temp.path(),
|
||
&dispatch,
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched,
|
||
Some(&dispatch.join_run_id),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
dispatched.status,
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched
|
||
);
|
||
assert_eq!(
|
||
dispatched.delivery_target,
|
||
IsolatedAgentJoinDeliveryTarget::Continuation
|
||
);
|
||
let claimed = write_isolated_join_delivery_at(
|
||
temp.path(),
|
||
&dispatch,
|
||
IsolatedAgentJoinDeliveryStatus::ClaimedByParent,
|
||
None,
|
||
Some("run-status-action-1"),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
claimed.status,
|
||
IsolatedAgentJoinDeliveryStatus::ClaimedByParent
|
||
);
|
||
assert_eq!(
|
||
claimed.claimed_by_action_id.as_deref(),
|
||
Some("run-status-action-1")
|
||
);
|
||
assert_eq!(
|
||
claimed.queued_run_id.as_deref(),
|
||
Some(&*dispatch.join_run_id)
|
||
);
|
||
let unjournaled =
|
||
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
|
||
.unwrap()
|
||
.expect("claimed delivery without journal must block completion");
|
||
assert!(
|
||
unjournaled.contains("unjournaledClaimedGroups=1"),
|
||
"{unjournaled}"
|
||
);
|
||
write_isolated_join_claim_at(
|
||
temp.path(),
|
||
&IsolatedAgentJoinClaimRecord {
|
||
schema_version: ISOLATED_AGENT_JOIN_CLAIM_SCHEMA_VERSION.to_string(),
|
||
parent_agent_id: dispatch.parent_agent_id.clone(),
|
||
parent_run_id: dispatch.parent_run_id.clone(),
|
||
action_id: "run-status-action-1".to_string(),
|
||
status: IsolatedAgentJoinClaimStatus::Observed,
|
||
joins: vec![dispatch.clone()],
|
||
updated_at: unix_timestamp(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run")
|
||
.unwrap(),
|
||
None
|
||
);
|
||
assert!(write_isolated_join_delivery_at(
|
||
temp.path(),
|
||
&dispatch,
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched,
|
||
Some(&dispatch.join_run_id),
|
||
None,
|
||
)
|
||
.is_err());
|
||
assert_eq!(
|
||
read_isolated_join_delivery_at(temp.path(), &dispatch)
|
||
.unwrap()
|
||
.unwrap(),
|
||
claimed
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parent_wake_delivery_is_persistent_idempotent_and_claim_inherits_target() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-parent-wake",
|
||
&request(vec![("code-prototype", "game/a/**")]),
|
||
);
|
||
let instance =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance))
|
||
.unwrap()
|
||
.unwrap();
|
||
|
||
let dispatched =
|
||
write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).unwrap();
|
||
assert_eq!(
|
||
dispatched.delivery_target,
|
||
IsolatedAgentJoinDeliveryTarget::ParentWake
|
||
);
|
||
assert_eq!(
|
||
dispatched.status,
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched
|
||
);
|
||
assert!(dispatched.queued_run_id.is_none());
|
||
assert_eq!(
|
||
write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).unwrap(),
|
||
dispatched
|
||
);
|
||
assert_eq!(
|
||
read_isolated_join_delivery_at(temp.path(), &dispatch)
|
||
.unwrap()
|
||
.unwrap(),
|
||
dispatched
|
||
);
|
||
|
||
let claimed = write_isolated_join_delivery_at(
|
||
temp.path(),
|
||
&dispatch,
|
||
IsolatedAgentJoinDeliveryStatus::ClaimedByParent,
|
||
None,
|
||
Some("parent-wake-claim"),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
claimed.delivery_target,
|
||
IsolatedAgentJoinDeliveryTarget::ParentWake
|
||
);
|
||
assert!(claimed.queued_run_id.is_none());
|
||
assert_eq!(
|
||
claimed.claimed_by_action_id.as_deref(),
|
||
Some("parent-wake-claim")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn join_delivery_infers_legacy_continuation_and_rejects_invalid_target_combinations() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-delivery-target-validation",
|
||
&request(vec![("code-prototype", "game/a/**")]),
|
||
);
|
||
let instance =
|
||
resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap();
|
||
let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance))
|
||
.unwrap()
|
||
.unwrap();
|
||
let path = isolated_join_delivery_relative_path(&dispatch.delegation_group_id);
|
||
let mut legacy = serde_json::json!({
|
||
"schemaVersion": ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION,
|
||
"parentAgentId": dispatch.parent_agent_id,
|
||
"parentRunId": dispatch.parent_run_id,
|
||
"delegationGroupId": dispatch.delegation_group_id,
|
||
"joinRunId": dispatch.join_run_id,
|
||
"status": "dispatched",
|
||
"queuedRunId": dispatch.join_run_id,
|
||
"updatedAt": unix_timestamp(),
|
||
});
|
||
write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap();
|
||
let inferred = read_isolated_join_delivery_at(temp.path(), &dispatch)
|
||
.unwrap()
|
||
.unwrap();
|
||
assert_eq!(
|
||
inferred.delivery_target,
|
||
IsolatedAgentJoinDeliveryTarget::Continuation
|
||
);
|
||
assert!(write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).is_err());
|
||
|
||
legacy["deliveryTarget"] = Value::String("parent-wake".to_string());
|
||
write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap();
|
||
assert!(read_isolated_join_delivery_at(temp.path(), &dispatch).is_err());
|
||
|
||
legacy["deliveryTarget"] = Value::String("continuation".to_string());
|
||
legacy.as_object_mut().unwrap().remove("queuedRunId");
|
||
write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap();
|
||
assert!(read_isolated_join_delivery_at(temp.path(), &dispatch).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn corrupt_record_fails_closed() {
|
||
let temp = tempdir().unwrap();
|
||
let group = create_group(
|
||
temp.path(),
|
||
"action-corrupt",
|
||
&request(vec![("code-prototype", "game/a/**")]),
|
||
);
|
||
let path = resolve_local_project_path(
|
||
temp.path(),
|
||
&isolated_instance_relative_path(&group.instance_ids[0]),
|
||
)
|
||
.unwrap();
|
||
let mut file = File::create(path).unwrap();
|
||
file.write_all(b"{broken-json").unwrap();
|
||
assert!(resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).is_err());
|
||
assert!(list_isolated_agent_instances_at(temp.path()).is_err());
|
||
}
|
||
}
|