fix: include full v0.93.0 release changes

This commit is contained in:
coso
2026-03-22 00:50:45 +08:00
parent 7765a3b029
commit b48d5b874f
19 changed files with 1507 additions and 235 deletions
@@ -250,6 +250,10 @@ pub struct AgentRuntimeSpawnSubagentRequest {
pub reasoning_effort: Option<String>,
#[serde(default, alias = "forkContext")]
pub fork_context: bool,
#[serde(default, alias = "blueprintRoleId")]
pub blueprint_role_id: Option<String>,
#[serde(default, alias = "blueprintRoleLabel")]
pub blueprint_role_label: Option<String>,
#[serde(default, alias = "profileId")]
pub profile_id: Option<String>,
#[serde(default, alias = "profileName")]
@@ -333,6 +333,12 @@ use run_metadata::{
resolve_social_run_artifact_descriptor,
};
pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor};
#[cfg(test)]
pub(crate) use runtime_turn::{
build_runtime_prepared_team_spawn_message, parse_runtime_prepared_team_roles,
plan_runtime_prepared_team_actions, RuntimePreparedTeamAction, RuntimePreparedTeamRole,
RuntimePreparedTeamSessionCandidate,
};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
resolve_session_provider_selector,
@@ -173,6 +173,76 @@ pub(crate) fn merge_system_prompt_with_elicitation_context(
}
}
fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
role_items
.iter()
.filter_map(|value| {
let object = value.as_object()?;
let label = object
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let summary = object
.get("summary")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("负责当前分工。");
let role_id_suffix = object
.get("id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / id: {value}"))
.unwrap_or_default();
let profile_suffix = object
.get("profile_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / profile: {value}"))
.unwrap_or_default();
let role_key_suffix = object
.get("role_key")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / roleKey: {value}"))
.unwrap_or_default();
let skill_suffix = object
.get("skill_ids")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty())
.map(|items| format!(" / skills: {}", items.join(", ")))
.unwrap_or_default();
Some(format!(
" - {label}:{summary}{role_id_suffix}{profile_suffix}{role_key_suffix}{skill_suffix}"
))
})
.collect()
}
fn describe_turn_team_reason(reason: &str) -> &'static str {
match reason {
"runtime_team_prepared" => "GUI 已完成本轮 Team 预编队",
"runtime_team_generation_failed" => "GUI 尝试预编队失败,当前回合降级为单 Agent",
"subagent_disabled" => "当前回合未开启 Team 模式",
"turn_purpose_override" => "当前回合属于特定目的任务,不走 Team 预编队",
"single_agent_direct" => "当前回合判断为直接单 Agent 执行更合适",
_ => "GUI 已记录本轮 Team 判定",
}
}
pub(crate) fn build_team_preference_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
@@ -201,6 +271,16 @@ pub(crate) fn build_team_preference_system_prompt(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
);
let turn_team_decision = extract_harness_string(
request_metadata,
&["turn_team_decision", "turnTeamDecision"],
);
let turn_team_reason =
extract_harness_string(request_metadata, &["turn_team_reason", "turnTeamReason"]);
let turn_team_blueprint = extract_harness_nested_object(
request_metadata,
&["turn_team_blueprint", "turnTeamBlueprint"],
);
if !subagent_mode_enabled {
return None;
@@ -241,68 +321,84 @@ pub(crate) fn build_team_preference_system_prompt(
}
if let Some(role_items) = selected_team_roles {
let rendered_roles = role_items
.iter()
.filter_map(|value| {
let object = value.as_object()?;
let label = object
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let summary = object
.get("summary")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("负责当前分工。");
let profile_suffix = object
.get("profile_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / profile: {value}"))
.unwrap_or_default();
let role_key_suffix = object
.get("role_key")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / roleKey: {value}"))
.unwrap_or_default();
let skill_suffix = object
.get("skill_ids")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty())
.map(|items| format!(" / skills: {}", items.join(", ")))
.unwrap_or_default();
Some(format!(
" - {label}:{summary}{profile_suffix}{role_key_suffix}{skill_suffix}"
))
})
.collect::<Vec<_>>();
let rendered_roles = render_team_roles(role_items);
if !rendered_roles.is_empty() {
lines.push("- 当前 Team 角色参考:".to_string());
lines.extend(rendered_roles);
lines.push(
"- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段,保持 GUI Team 画布与实际分工一致。"
"- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段;若该角色带有 id,优先同步写入 blueprintRoleId / blueprintRoleLabel,保持 GUI Team 画布与实际分工一致。"
.to_string(),
);
}
}
match turn_team_decision.as_deref() {
Some("team_prepared") => {
lines.push(
"- 当前回合已经在 GUI 发送前完成 Team 预编队;这是一份本轮执行契约,不是事后建议。"
.to_string(),
);
if let Some(reason) = turn_team_reason.as_deref() {
lines.push(format!(
"- GUI 判定:{}。",
describe_turn_team_reason(reason)
));
}
if let Some(blueprint) = turn_team_blueprint {
let blueprint_label = blueprint
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let blueprint_description = blueprint
.get("description")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let rendered_roles = blueprint
.get("roles")
.and_then(serde_json::Value::as_array)
.map(|items| render_team_roles(items))
.unwrap_or_default();
if let Some(label) = blueprint_label {
lines.push(format!("- 本轮 Team 蓝图:{label}。"));
}
if let Some(description) = blueprint_description {
lines.push(format!("- 本轮蓝图说明:{description}"));
}
if !rendered_roles.is_empty() {
lines.push("- 本轮 Team 角色分工:".to_string());
lines.extend(rendered_roles);
}
}
lines.push(
"- 如果你决定调用 spawn_agent / send_input,应在本轮开局阶段优先按上述蓝图启动角色,并把蓝图里的 id / label 映射到 blueprintRoleId / blueprintRoleLabel,让各角色承担自己的输出,不要等主 agent 整轮完成后再补做 team。"
.to_string(),
);
}
Some("single_agent") => {
lines.push(
"- 当前回合未在 GUI 中预编队 Team,本轮默认按单 Agent 直接推进。".to_string(),
);
if let Some(reason) = turn_team_reason.as_deref() {
lines.push(format!(
"- GUI 判定:{}。",
describe_turn_team_reason(reason)
));
}
lines.push(
"- 除非执行中出现明确的拆分必要性,否则不要为了形式化 team 而推迟主任务。"
.to_string(),
);
}
_ => {}
}
lines.push(
"- spawn_agent 支持这些结构化字段:teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。"
"- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。"
.to_string(),
);
lines.push(
@@ -93,6 +93,12 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("selectedTeamSummary", "selected_team_summary"),
("selected_team_roles", "selected_team_roles"),
("selectedTeamRoles", "selected_team_roles"),
("turn_team_decision", "turn_team_decision"),
("turnTeamDecision", "turn_team_decision"),
("turn_team_reason", "turn_team_reason"),
("turnTeamReason", "turn_team_reason"),
("turn_team_blueprint", "turn_team_blueprint"),
("turnTeamBlueprint", "turn_team_blueprint"),
("browser_requirement", "browser_requirement"),
("browserRequirement", "browser_requirement"),
("browser_requirement_reason", "browser_requirement_reason"),
@@ -1,5 +1,352 @@
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuntimePreparedTeamRole {
pub(crate) id: String,
pub(crate) label: String,
pub(crate) summary: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) role_key: Option<String>,
pub(crate) skill_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuntimePreparedTeamSessionCandidate {
pub(crate) blueprint_role_id: String,
pub(crate) session_id: String,
pub(crate) status_kind: SubagentRuntimeStatusKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RuntimePreparedTeamAction {
Spawn(RuntimePreparedTeamRole),
Resume {
role: RuntimePreparedTeamRole,
session_id: String,
},
}
fn normalize_runtime_team_role_string(value: Option<&serde_json::Value>) -> Option<String> {
normalize_optional_text(
value
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
)
}
fn normalize_runtime_team_role_id_fragment(value: &str) -> Option<String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join("-");
let normalized = normalized.trim_matches('-').to_string();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn normalize_runtime_team_skill_ids(value: Option<&serde_json::Value>) -> Vec<String> {
let Some(items) = value.and_then(serde_json::Value::as_array) else {
return Vec::new();
};
let mut seen = HashSet::new();
let mut skill_ids = Vec::new();
for item in items {
let Some(skill_id) = normalize_runtime_team_role_string(Some(item)) else {
continue;
};
if seen.insert(skill_id.clone()) {
skill_ids.push(skill_id);
}
}
skill_ids
}
fn parse_runtime_prepared_team_role(
role_value: &serde_json::Value,
index: usize,
) -> Option<RuntimePreparedTeamRole> {
let role = role_value.as_object()?;
let profile_id = normalize_runtime_team_role_string(role.get("profile_id"))
.or_else(|| normalize_runtime_team_role_string(role.get("profileId")));
let role_key = normalize_runtime_team_role_string(role.get("role_key"))
.or_else(|| normalize_runtime_team_role_string(role.get("roleKey")));
let label = normalize_runtime_team_role_string(role.get("label"))
.or_else(|| profile_id.clone())
.or_else(|| role_key.clone())
.unwrap_or_else(|| format!("角色 {}", index + 1));
let id = normalize_runtime_team_role_string(role.get("id"))
.or_else(|| {
profile_id
.as_deref()
.and_then(normalize_runtime_team_role_id_fragment)
.map(|fragment| format!("profile-{fragment}"))
})
.or_else(|| {
role_key
.as_deref()
.and_then(normalize_runtime_team_role_id_fragment)
.map(|fragment| format!("role-{fragment}"))
})
.or_else(|| {
normalize_runtime_team_role_id_fragment(&label)
.map(|fragment| format!("lane-{fragment}"))
})
.unwrap_or_else(|| format!("runtime-team-role-{}", index + 1));
Some(RuntimePreparedTeamRole {
id,
label,
summary: normalize_runtime_team_role_string(role.get("summary")),
profile_id,
role_key,
skill_ids: normalize_runtime_team_skill_ids(
role.get("skill_ids").or_else(|| role.get("skillIds")),
),
})
}
pub(crate) fn parse_runtime_prepared_team_roles(
request_metadata: Option<&serde_json::Value>,
) -> Vec<RuntimePreparedTeamRole> {
if extract_harness_string(
request_metadata,
&["turn_team_decision", "turnTeamDecision"],
)
.as_deref()
!= Some("team_prepared")
{
return Vec::new();
}
let Some(blueprint) = extract_harness_nested_object(
request_metadata,
&["turn_team_blueprint", "turnTeamBlueprint"],
) else {
return Vec::new();
};
let Some(role_values) = blueprint.get("roles").and_then(serde_json::Value::as_array) else {
return Vec::new();
};
let mut seen = HashSet::new();
let mut roles = Vec::new();
for (index, role_value) in role_values.iter().enumerate() {
let Some(role) = parse_runtime_prepared_team_role(role_value, index) else {
continue;
};
if seen.insert(role.id.clone()) {
roles.push(role);
}
}
roles
}
fn runtime_prepared_team_session_rank(status_kind: SubagentRuntimeStatusKind) -> u8 {
match status_kind {
SubagentRuntimeStatusKind::Running => 7,
SubagentRuntimeStatusKind::Queued => 6,
SubagentRuntimeStatusKind::Idle => 5,
SubagentRuntimeStatusKind::Completed => 4,
SubagentRuntimeStatusKind::Failed => 3,
SubagentRuntimeStatusKind::Aborted => 2,
SubagentRuntimeStatusKind::Closed => 1,
SubagentRuntimeStatusKind::NotFound => 0,
}
}
pub(crate) fn plan_runtime_prepared_team_actions(
roles: &[RuntimePreparedTeamRole],
existing_candidates: &[RuntimePreparedTeamSessionCandidate],
) -> Vec<RuntimePreparedTeamAction> {
let mut existing_by_role_id: HashMap<&str, &RuntimePreparedTeamSessionCandidate> =
HashMap::new();
for candidate in existing_candidates {
let replace = existing_by_role_id
.get(candidate.blueprint_role_id.as_str())
.map(|current| {
runtime_prepared_team_session_rank(candidate.status_kind)
> runtime_prepared_team_session_rank(current.status_kind)
})
.unwrap_or(true);
if replace {
existing_by_role_id.insert(candidate.blueprint_role_id.as_str(), candidate);
}
}
roles
.iter()
.filter_map(|role| match existing_by_role_id.get(role.id.as_str()) {
Some(candidate) if candidate.status_kind == SubagentRuntimeStatusKind::Closed => {
Some(RuntimePreparedTeamAction::Resume {
role: role.clone(),
session_id: candidate.session_id.clone(),
})
}
Some(candidate) if candidate.status_kind != SubagentRuntimeStatusKind::NotFound => None,
_ => Some(RuntimePreparedTeamAction::Spawn(role.clone())),
})
.collect()
}
pub(crate) fn build_runtime_prepared_team_spawn_message(
role: &RuntimePreparedTeamRole,
user_message: &str,
) -> String {
let mut sections = vec![format!("你是本轮 Team 中的「{}」角色。", role.label)];
if let Some(summary) = role.summary.as_deref() {
sections.push(format!("你的职责:{summary}"));
}
sections.push(
"请直接在当前子会话输出你的执行过程与结果,不要把具体产出留给父会话代写。".to_string(),
);
sections.push(format!("当前用户任务:\n{}", user_message.trim()));
sections.push("只处理当前角色范围内的工作;如果依赖其他角色,请明确指出协作边界。".to_string());
sections.join("\n\n")
}
async fn collect_runtime_prepared_team_candidates(
parent_session_id: &str,
) -> Result<Vec<RuntimePreparedTeamSessionCandidate>, String> {
let child_sessions = list_subagent_child_sessions(parent_session_id)
.await
.map_err(|error| format!("读取 runtime team child sessions 失败: {error}"))?;
let mut candidates = Vec::new();
for child_session in child_sessions {
let Some(customization) = SubagentCustomizationState::from_session(&child_session) else {
continue;
};
let Some(blueprint_role_id) = normalize_optional_text(customization.blueprint_role_id)
else {
continue;
};
let status_kind = load_subagent_runtime_status(&child_session.id)
.await
.map(|status| status.kind)
.unwrap_or(SubagentRuntimeStatusKind::NotFound);
candidates.push(RuntimePreparedTeamSessionCandidate {
blueprint_role_id,
session_id: child_session.id,
status_kind,
});
}
Ok(candidates)
}
async fn maybe_prepare_runtime_team_sessions(
app: &AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
request: &AsterChatRequest,
) -> Result<(), String> {
let roles = parse_runtime_prepared_team_roles(request.metadata.as_ref());
if roles.is_empty() {
return Ok(());
}
let existing_candidates = collect_runtime_prepared_team_candidates(&request.session_id).await?;
let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates);
if actions.is_empty() {
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 所有 blueprint 角色已就绪,无需新增预拉起",
request.session_id
);
return Ok(());
}
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 开始按 blueprint 预拉起角色: total_roles={}, pending_actions={}",
request.session_id,
roles.len(),
actions.len()
);
let runtime = SubagentControlRuntime::new(
app.clone(),
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
);
for action in actions {
match action {
RuntimePreparedTeamAction::Spawn(role) => {
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 自动预拉起角色: role_id={}, label={}",
request.session_id,
role.id,
role.label
);
agent_runtime_spawn_subagent_internal(
&runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id: request.session_id.clone(),
message: build_runtime_prepared_team_spawn_message(&role, &request.message),
agent_type: Some(role.label.clone()),
model: None,
reasoning_effort: None,
fork_context: false,
blueprint_role_id: Some(role.id.clone()),
blueprint_role_label: Some(role.label.clone()),
profile_id: role.profile_id.clone(),
profile_name: None,
role_key: role.role_key.clone(),
skill_ids: role.skill_ids.clone(),
skill_directories: Vec::new(),
team_preset_id: None,
theme: None,
system_overlay: None,
output_contract: None,
},
)
.await
.map_err(|error| {
format!(
"自动预拉起 team 角色失败: role_id={}, label={}, error={error}",
role.id, role.label
)
})?;
}
RuntimePreparedTeamAction::Resume { role, session_id } => {
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 恢复已关闭角色 lane: role_id={}, label={}, child_session={}",
request.session_id,
role.id,
role.label,
session_id
);
agent_runtime_resume_subagent_internal(
&runtime,
AgentRuntimeResumeSubagentRequest { id: session_id.clone() },
)
.await
.map_err(|error| {
format!(
"恢复已关闭 team 角色失败: role_id={}, label={}, child_session={}, error={error}",
role.id, role.label, session_id
)
})?;
}
}
}
Ok(())
}
async fn execute_aster_chat_request(
app: &AppHandle,
state: &AsterAgentState,
@@ -604,21 +951,50 @@ async fn execute_aster_chat_request(
session_config_builder.build()
};
let runtime_turn_initialized = {
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
match agent
.ensure_runtime_turn_initialized(
&runtime_status_session_config,
Some(request.message.clone()),
)
.await
{
Ok(_) => true,
Err(error) => {
tracing::warn!(
"[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}",
error
);
false
}
}
};
if runtime_turn_initialized {
maybe_prepare_runtime_team_sessions(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
&request,
)
.await?;
} else {
tracing::warn!(
"[AsterAgent][RuntimeTeam] 跳过本轮 team 预拉起,因为父会话 runtime turn 尚未就绪: session={}",
session_id
);
}
// 获取 Agent Arc 并保持 guard 在整个流处理期间存活
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
if let Err(error) = agent
.ensure_runtime_turn_initialized(
&runtime_status_session_config,
Some(request.message.clone()),
)
.await
{
tracing::warn!(
"[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}",
error
);
}
let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses(
&request,
@@ -153,9 +153,11 @@ fn normalize_optional_vec(values: &[String]) -> Vec<String> {
fn build_subagent_session_name(
message: &str,
agent_type: Option<&str>,
blueprint_role_label: Option<&str>,
profile_name: Option<&str>,
) -> String {
normalize_optional_text(agent_type.map(ToString::to_string))
.or_else(|| normalize_optional_text(blueprint_role_label.map(ToString::to_string)))
.or_else(|| normalize_optional_text(profile_name.map(ToString::to_string)))
.or_else(|| build_subagent_task_summary(message))
.unwrap_or_else(|| "子代理".to_string())
@@ -166,6 +168,7 @@ fn resolve_subagent_role_hint(
customization: Option<&SubagentCustomizationState>,
) -> Option<String> {
normalize_optional_text(request.agent_type.clone())
.or_else(|| customization.and_then(|state| state.blueprint_role_label.clone()))
.or_else(|| customization.and_then(|state| state.profile_name.clone()))
.or_else(|| customization.and_then(|state| state.role_key.clone()))
}
@@ -210,6 +213,8 @@ fn build_local_subagent_skill_payload(
pub(crate) fn build_subagent_customization_state(
request: &AgentRuntimeSpawnSubagentRequest,
) -> Result<Option<SubagentCustomizationState>, String> {
let blueprint_role_id = normalize_optional_text(request.blueprint_role_id.clone());
let blueprint_role_label = normalize_optional_text(request.blueprint_role_label.clone());
let profile_id = normalize_optional_text(request.profile_id.clone());
let profile = profile_id
.as_deref()
@@ -250,6 +255,8 @@ pub(crate) fn build_subagent_customization_state(
}
let state = SubagentCustomizationState {
blueprint_role_id,
blueprint_role_label,
profile_id,
profile_name: normalize_optional_text(request.profile_name.clone())
.or_else(|| profile.map(|descriptor| descriptor.name.to_string())),
@@ -602,7 +609,14 @@ async fn create_runtime_subagent_session(
let session = SessionManager::create_session(
parent_session.working_dir.clone(),
build_subagent_session_name(&message, request.agent_type.as_deref(), profile_name),
build_subagent_session_name(
&message,
request.agent_type.as_deref(),
customization
.as_ref()
.and_then(|state| state.blueprint_role_label.as_deref()),
profile_name,
),
SessionType::SubAgent,
)
.await
@@ -720,6 +734,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal(
"reasoning_effort": request.reasoning_effort,
"fork_context": request.fork_context,
"origin_tool": "spawn_agent",
"blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()),
"blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()),
"profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()),
"profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()),
"role_key": customization.as_ref().and_then(|state| state.role_key.clone()),
@@ -788,6 +804,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
"subagent": {
"origin_tool": "send_input",
"interrupt": request.interrupt,
"blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()),
"blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()),
"profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()),
"profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()),
"role_key": customization.as_ref().and_then(|state| state.role_key.clone()),
+189 -3
View File
@@ -1249,6 +1249,61 @@ mod tests {
assert!(prompt.contains("映射到对应结构化字段"));
}
#[test]
fn test_build_team_preference_system_prompt_renders_turn_team_contract() {
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
"harness": {
"subagent_mode_enabled": true,
"turn_team_decision": "team_prepared",
"turn_team_reason": "runtime_team_prepared",
"turn_team_blueprint": {
"label": "本轮调试 Team",
"description": "先分析,再实现,最后验证。",
"roles": [
{
"id": "runtime-explorer",
"label": "分析",
"summary": "负责定位问题。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration"]
},
{
"label": "执行",
"summary": "负责提交修复。"
}
]
}
}
})))
.expect("team prompt should exist");
assert!(prompt.contains("发送前完成 Team 预编队"));
assert!(prompt.contains("本轮调试 Team"));
assert!(prompt.contains("先分析,再实现,最后验证。"));
assert!(prompt.contains("分析:负责定位问题。"));
assert!(prompt.contains("id: runtime-explorer"));
assert!(prompt.contains("blueprintRoleId"));
assert!(prompt.contains("让各角色承担自己的输出"));
assert!(prompt.contains("不要等主 agent 整轮完成后再补做 team"));
}
#[test]
fn test_build_team_preference_system_prompt_renders_single_agent_turn_decision() {
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
"harness": {
"subagent_mode_enabled": true,
"turn_team_decision": "single_agent",
"turn_team_reason": "single_agent_direct",
}
})))
.expect("team prompt should exist");
assert!(prompt.contains("当前回合未在 GUI 中预编队 Team"));
assert!(prompt.contains("直接单 Agent 执行更合适"));
assert!(prompt.contains("不要为了形式化 team 而推迟主任务"));
}
#[test]
fn test_build_subagent_customization_state_applies_profile_defaults() {
let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest {
@@ -1258,6 +1313,8 @@ mod tests {
model: None,
reasoning_effort: None,
fork_context: false,
blueprint_role_id: Some("runtime-explorer".to_string()),
blueprint_role_label: Some("分析".to_string()),
profile_id: Some("code-explorer".to_string()),
profile_name: None,
role_key: None,
@@ -1271,6 +1328,11 @@ mod tests {
.expect("build customization state")
.expect("customization should exist");
assert_eq!(
customization.blueprint_role_id.as_deref(),
Some("runtime-explorer")
);
assert_eq!(customization.blueprint_role_label.as_deref(), Some("分析"));
assert_eq!(customization.profile_name.as_deref(), Some("代码分析员"));
assert_eq!(customization.role_key.as_deref(), Some("explorer"));
assert_eq!(
@@ -1293,6 +1355,8 @@ mod tests {
fn test_build_subagent_customization_system_prompt_renders_builtin_configuration() {
let prompt =
build_subagent_customization_system_prompt(Some(&SubagentCustomizationState {
blueprint_role_id: Some("runtime-explorer".to_string()),
blueprint_role_label: Some("分析".to_string()),
profile_id: Some("code-explorer".to_string()),
profile_name: Some("代码分析员".to_string()),
role_key: Some("explorer".to_string()),
@@ -1313,12 +1377,133 @@ mod tests {
.expect("prompt should exist");
assert!(prompt.contains("【Subagent 定制配置】"));
assert!(prompt.contains("蓝图角色:分析 (runtime-explorer)"));
assert!(prompt.contains("代码分析员"));
assert!(prompt.contains("代码排障团队"));
assert!(prompt.contains("仓库探索"));
assert!(prompt.contains("输出问题定位、证据与影响面。"));
}
#[test]
fn test_parse_runtime_prepared_team_roles_from_metadata() {
let roles = parse_runtime_prepared_team_roles(Some(&serde_json::json!({
"harness": {
"turn_team_decision": "team_prepared",
"turn_team_blueprint": {
"roles": [
{
"id": "runtime-explorer",
"label": "分析",
"summary": "负责定位问题。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration", "repo-exploration", " "]
},
{
"label": "执行 角色",
"summary": "负责提交修复。"
},
{
"id": "runtime-explorer",
"label": "重复分析"
}
]
}
}
})));
assert_eq!(roles.len(), 2);
assert_eq!(roles[0].id, "runtime-explorer");
assert_eq!(roles[0].label, "分析");
assert_eq!(roles[0].profile_id.as_deref(), Some("code-explorer"));
assert_eq!(roles[0].role_key.as_deref(), Some("explorer"));
assert_eq!(roles[0].skill_ids, vec!["repo-exploration".to_string()]);
assert_eq!(roles[1].id, "lane-执行-角色");
assert_eq!(roles[1].label, "执行 角色");
assert_eq!(roles[1].summary.as_deref(), Some("负责提交修复。"));
}
#[test]
fn test_plan_runtime_prepared_team_actions_skips_existing_and_resumes_closed_lane() {
let roles = vec![
RuntimePreparedTeamRole {
id: "runtime-explorer".to_string(),
label: "分析".to_string(),
summary: Some("负责定位问题。".to_string()),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
RuntimePreparedTeamRole {
id: "runtime-executor".to_string(),
label: "执行".to_string(),
summary: Some("负责提交修复。".to_string()),
profile_id: Some("code-executor".to_string()),
role_key: Some("executor".to_string()),
skill_ids: vec![],
},
RuntimePreparedTeamRole {
id: "runtime-verifier".to_string(),
label: "验证".to_string(),
summary: Some("负责回归验证。".to_string()),
profile_id: Some("code-verifier".to_string()),
role_key: Some("verifier".to_string()),
skill_ids: vec![],
},
];
let existing_candidates = vec![
RuntimePreparedTeamSessionCandidate {
blueprint_role_id: "runtime-explorer".to_string(),
session_id: "child-explorer".to_string(),
status_kind: SubagentRuntimeStatusKind::Running,
},
RuntimePreparedTeamSessionCandidate {
blueprint_role_id: "runtime-executor".to_string(),
session_id: "child-executor-old".to_string(),
status_kind: SubagentRuntimeStatusKind::Closed,
},
RuntimePreparedTeamSessionCandidate {
blueprint_role_id: "runtime-executor".to_string(),
session_id: "child-executor-stale".to_string(),
status_kind: SubagentRuntimeStatusKind::NotFound,
},
];
let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates);
assert_eq!(actions.len(), 2);
assert!(matches!(
&actions[0],
RuntimePreparedTeamAction::Resume { role, session_id }
if role.id == "runtime-executor" && session_id == "child-executor-old"
));
assert!(matches!(
&actions[1],
RuntimePreparedTeamAction::Spawn(role) if role.id == "runtime-verifier"
));
}
#[test]
fn test_build_runtime_prepared_team_spawn_message_emphasizes_lane_output() {
let message = build_runtime_prepared_team_spawn_message(
&RuntimePreparedTeamRole {
id: "runtime-explorer".to_string(),
label: "分析".to_string(),
summary: Some("负责定位问题。".to_string()),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
"修正 team runtime 的执行顺序",
);
assert!(message.contains("当前子会话输出"));
assert!(message.contains("修正 team runtime 的执行顺序"));
assert!(message.contains("负责定位问题。"));
assert!(message.contains("不要把具体产出留给父会话代写"));
}
#[test]
fn test_normalize_shell_command_params_accepts_cmd_alias() {
let input = serde_json::json!({
@@ -1360,8 +1545,9 @@ mod tests {
let input = serde_json::json!({
"path": "/memories/preferences.md"
});
let normalized: serde_json::Value = normalize_params_for_durable_memory_support("read", &input)
.expect("normalize read params");
let normalized: serde_json::Value =
normalize_params_for_durable_memory_support("read", &input)
.expect("normalize read params");
let expected = tmp
.path()
.join("preferences.md")
@@ -1385,7 +1571,7 @@ mod tests {
});
let normalized: serde_json::Value =
normalize_params_for_durable_memory_support("glob", &input)
.expect("normalize glob params");
.expect("normalize glob params");
let expected_root = tmp.path().to_string_lossy().to_string();
assert_eq!(
@@ -412,6 +412,8 @@ impl Tool for SubAgentTaskTool {
model: input.model.clone(),
reasoning_effort: None,
fork_context: false,
blueprint_role_id: None,
blueprint_role_label: None,
profile_id: None,
profile_name: None,
role_key: None,
@@ -511,6 +513,8 @@ struct SpawnAgentToolInput {
model: Option<String>,
reasoning_effort: Option<String>,
fork_context: Option<bool>,
blueprint_role_id: Option<String>,
blueprint_role_label: Option<String>,
profile_id: Option<String>,
profile_name: Option<String>,
role_key: Option<String>,
@@ -569,6 +573,14 @@ impl Tool for SpawnAgentTool {
"type": "boolean",
"description": "保留字段,当前仅记录到 metadata"
},
"blueprintRoleId": {
"type": "string",
"description": "可选本轮 Team 蓝图角色 id;当 GUI 已预编队 Team 时,优先传入对应角色 id,便于真实成员接管画布泳道"
},
"blueprintRoleLabel": {
"type": "string",
"description": "可选本轮 Team 蓝图角色标签,例如 分析 / 执行 / 验证"
},
"profileId": {
"type": "string",
"description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier"
@@ -629,6 +641,8 @@ impl Tool for SpawnAgentTool {
model: input.model,
reasoning_effort: input.reasoning_effort,
fork_context: input.fork_context.unwrap_or(false),
blueprint_role_id: input.blueprint_role_id,
blueprint_role_label: input.blueprint_role_label,
profile_id: input.profile_id,
profile_name: input.profile_name,
role_key: input.role_key,