继续拆分客户端工作区与 Agent 运行时模块
拆出项目工作区视图、命令策略、摘要命令与开发面板模块。 拆出 Runtime action、driver 与 protocol 的职责模块并保留稳定 facade。 保留测试模块、兼容重导出与原有可见性边界。 补充结构拆分决策、踩坑记录与实施计划验收结果。
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at(
|
||||
root: &Path,
|
||||
runtime: &mut AgentRuntimeState,
|
||||
pending: &mut AgentRuntimePendingToolAction,
|
||||
) -> Result<(), String> {
|
||||
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Err("自主构建 Run 禁止进入 waiting-for-user-input".to_string());
|
||||
}
|
||||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||||
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT.to_string();
|
||||
pending.observation = None;
|
||||
pending.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_pending_tool_action(root, pending)?;
|
||||
let request = match prepare_game_creator_agent_user_input_request_at(root, pending)? {
|
||||
AgentRuntimeUserInputRecovery::Waiting(request) => request,
|
||||
AgentRuntimeUserInputRecovery::Answered { .. } => {
|
||||
return Err("新建用户输入等待时 sidecar 已进入 answered,需由恢复路径继续".to_string());
|
||||
}
|
||||
AgentRuntimeUserInputRecovery::Cancelled => {
|
||||
return Err("新建用户输入等待时 sidecar 已取消".to_string());
|
||||
}
|
||||
};
|
||||
let waiting_observation = AgentRuntimeToolObservation {
|
||||
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
|
||||
status: "waiting-for-user-input".to_string(),
|
||||
summary: format!(
|
||||
"Agent 正在等待用户回答 {} 个澄清问题",
|
||||
request.questions.len()
|
||||
),
|
||||
detail: None,
|
||||
};
|
||||
append_agent_runtime_tool_call_record(
|
||||
root,
|
||||
runtime,
|
||||
&pending.task,
|
||||
&pending.action,
|
||||
&waiting_observation,
|
||||
Some(&pending.action_id),
|
||||
);
|
||||
runtime.pending_tool_action = Some(pending.summary());
|
||||
runtime.status = "waiting-for-user-input".to_string();
|
||||
runtime.phase = "waiting-for-user-input".to_string();
|
||||
runtime.current_action = "等待用户补充关键信息".to_string();
|
||||
runtime.waiting_on = "用户回答 Agent 的结构化澄清问题".to_string();
|
||||
runtime.next_step = "提交全部回答后在同一 run 继续当前计划".to_string();
|
||||
runtime.error = None;
|
||||
runtime.updated_at = unix_timestamp();
|
||||
append_game_creator_agent_runtime_task_projection_once(root, runtime, &pending.action_id)?;
|
||||
refresh_game_creator_agent_runtime_task_queue(root, runtime)?;
|
||||
write_game_creator_agent_runtime_state(root, runtime)?;
|
||||
append_game_creator_agent_runtime_action_event(
|
||||
root,
|
||||
runtime,
|
||||
"user_input.required",
|
||||
"waiting-for-user-input",
|
||||
"waiting-for-user-input",
|
||||
"Agent 已暂停当前 run,等待用户补充关键信息。",
|
||||
pending.input_summary.as_deref(),
|
||||
&pending.action_id,
|
||||
)?;
|
||||
if let Err(error) = append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.user_input.required",
|
||||
"agentId": pending.agent_id,
|
||||
"taskId": pending.task_id,
|
||||
"sessionId": pending.session_id,
|
||||
"runId": pending.run_id,
|
||||
"actionId": pending.action_id,
|
||||
"actionFingerprint": pending.action_fingerprint,
|
||||
"requestId": request.request_id,
|
||||
"questionCount": request.questions.len(),
|
||||
"inputSummary": pending.input_summary,
|
||||
}),
|
||||
) {
|
||||
let _ = append_game_creator_agent_runtime_event(
|
||||
root,
|
||||
runtime,
|
||||
"user_input.audit_failed",
|
||||
"waiting-for-user-input",
|
||||
"waiting-for-user-input",
|
||||
"用户输入请求已安全暂停,但公共审计记录写入失败。",
|
||||
Some(&sanitize_agent_runtime_text(&error, 240)),
|
||||
);
|
||||
}
|
||||
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn mark_game_creator_agent_runtime_auto_action_executing_if_current(
|
||||
root: &Path,
|
||||
pending: &mut AgentRuntimePendingToolAction,
|
||||
) -> Result<bool, String> {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.tool_action.executing",
|
||||
)?;
|
||||
if game_creator_agent_runtime_cancel_requested_for(root, &pending.agent_id, &pending.run_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
if game_creator_agent_runtime_has_queued_steer_after_cursor(
|
||||
root,
|
||||
&pending.agent_id,
|
||||
&pending.run_id,
|
||||
pending.planned_steer_cursor,
|
||||
)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if validate_agent_runtime_pending_current_goal_snapshot(root, pending).is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string();
|
||||
pending.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_pending_tool_action(root, pending)?;
|
||||
update_game_creator_agent_runtime_provider_batch_member(root, pending)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn append_game_creator_agent_runtime_auto_tool_action_executing_record(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
) -> Result<(), String> {
|
||||
let public_input_summary = agent_runtime_public_action_input_summary(
|
||||
root,
|
||||
&pending.action.tool,
|
||||
pending.input_summary.as_deref(),
|
||||
);
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.tool_action.executing",
|
||||
"agentId": pending.agent_id,
|
||||
"taskId": pending.task_id,
|
||||
"runId": pending.run_id,
|
||||
"actionId": pending.action_id,
|
||||
"actionFingerprint": pending.action_fingerprint,
|
||||
"tool": pending.action.tool,
|
||||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||||
"inputSummary": public_input_summary,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn append_game_creator_agent_runtime_auto_tool_action_observed_record(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
observation: &AgentRuntimeToolObservation,
|
||||
) -> Result<(), String> {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.tool_action.observed",
|
||||
"agentId": pending.agent_id,
|
||||
"taskId": pending.task_id,
|
||||
"runId": pending.run_id,
|
||||
"actionId": pending.action_id,
|
||||
"actionFingerprint": pending.action_fingerprint,
|
||||
"tool": pending.action.tool,
|
||||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||||
"observationStatus": observation.status,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn append_game_creator_agent_runtime_parallel_action_executing_record(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
) -> Result<(), String> {
|
||||
let record_type = "agent.runtime.tool_action.executing";
|
||||
if agent_db_record_exists_for_action(
|
||||
root,
|
||||
record_type,
|
||||
&pending.agent_id,
|
||||
&pending.run_id,
|
||||
&pending.action_id,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
append_game_creator_agent_runtime_auto_tool_action_executing_record(root, pending)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn append_game_creator_agent_runtime_parallel_action_observed_record(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
observation: &AgentRuntimeToolObservation,
|
||||
) -> Result<(), String> {
|
||||
let record_type = "agent.runtime.tool_action.observed";
|
||||
if agent_db_record_exists_for_action(
|
||||
root,
|
||||
record_type,
|
||||
&pending.agent_id,
|
||||
&pending.run_id,
|
||||
&pending.action_id,
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
append_game_creator_agent_runtime_auto_tool_action_observed_record(root, pending, observation)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
trigger: &str,
|
||||
estimated_tokens_before: u64,
|
||||
applied_steer_cursor: u64,
|
||||
allow_idle_context_compaction: bool,
|
||||
persist_transient_retry: bool,
|
||||
request_kind: &str,
|
||||
) -> Result<AgentRuntimeContextCompactionOutcome, String> {
|
||||
if !matches!(
|
||||
request_kind,
|
||||
"context-compaction" | "final-reply-context-compaction"
|
||||
) || (allow_idle_context_compaction && request_kind != "context-compaction")
|
||||
{
|
||||
return Err("上下文压缩 Provider requestKind 无效".to_string());
|
||||
}
|
||||
let (snapshot, source, llm, config_path, request) = {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.context_compaction.build",
|
||||
)?;
|
||||
let source = build_game_creator_agent_runtime_context_compaction_source(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
observations,
|
||||
trigger,
|
||||
)?;
|
||||
let request_slot = format!(
|
||||
"source-{}",
|
||||
source
|
||||
.source_fingerprint
|
||||
.chars()
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
);
|
||||
if !source.has_new_source {
|
||||
provider_handoff::remove_consumed_context_at(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
request_kind,
|
||||
&request_slot,
|
||||
)?;
|
||||
let previous = source
|
||||
.previous
|
||||
.as_ref()
|
||||
.map(|sidecar| Some(context_compaction_result(sidecar, true)))
|
||||
.ok_or_else(|| "当前 Session 没有可压缩的旧上下文".to_string());
|
||||
return previous.map(AgentRuntimeContextCompactionOutcome::Completed);
|
||||
}
|
||||
let template_agent_id = game_creator_runtime_template_agent_id_at(root, agent_id)?;
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id);
|
||||
let config_path = format!("agentLlm.{template_agent_id}");
|
||||
let request = build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?;
|
||||
let estimated_request_tokens = estimate_game_creator_llm_request_tokens(&request)?;
|
||||
validate_game_creator_llm_request_context_budget(
|
||||
&llm,
|
||||
&request,
|
||||
estimated_request_tokens,
|
||||
"上下文压缩请求",
|
||||
)?;
|
||||
let snapshot = if allow_idle_context_compaction {
|
||||
capture_idle_game_creator_agent_runtime_context_compaction_snapshot_at_locked(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
&request_slot,
|
||||
applied_steer_cursor,
|
||||
)?
|
||||
} else {
|
||||
capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
request_kind,
|
||||
&request_slot,
|
||||
applied_steer_cursor,
|
||||
)?
|
||||
};
|
||||
(snapshot, source, llm, config_path, request)
|
||||
};
|
||||
let handoff_identity =
|
||||
game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)?;
|
||||
let response = if !persist_transient_retry {
|
||||
AgentRuntimePersistedProviderRequestOutcome::Response(
|
||||
request_game_creator_agent_runtime_llm_with_transient_retries(
|
||||
root,
|
||||
&snapshot,
|
||||
&llm,
|
||||
&config_path,
|
||||
"上下文压缩",
|
||||
&request,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
request_game_creator_agent_runtime_llm_with_persisted_transient_retry(
|
||||
root,
|
||||
&snapshot,
|
||||
&llm,
|
||||
&config_path,
|
||||
"上下文压缩",
|
||||
&request,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let response = match response {
|
||||
AgentRuntimePersistedProviderRequestOutcome::Response(Some(response)) => response,
|
||||
AgentRuntimePersistedProviderRequestOutcome::Response(None) => {
|
||||
return Ok(AgentRuntimeContextCompactionOutcome::Completed(None));
|
||||
}
|
||||
AgentRuntimePersistedProviderRequestOutcome::Waiting(record) => {
|
||||
return Ok(AgentRuntimeContextCompactionOutcome::Waiting(record));
|
||||
}
|
||||
AgentRuntimePersistedProviderRequestOutcome::HandoffPrepared => {
|
||||
return Ok(AgentRuntimeContextCompactionOutcome::HandoffPrepared);
|
||||
}
|
||||
AgentRuntimePersistedProviderRequestOutcome::Superseded => {
|
||||
return Ok(AgentRuntimeContextCompactionOutcome::Superseded);
|
||||
}
|
||||
};
|
||||
|
||||
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.context_compaction.commit",
|
||||
)?;
|
||||
let current_source = build_game_creator_agent_runtime_context_compaction_source(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
observations,
|
||||
trigger,
|
||||
)?;
|
||||
let source_stable = current_source.source_fingerprint == source.source_fingerprint
|
||||
&& current_source.covered_agent_messages == source.covered_agent_messages
|
||||
&& current_source.covered_project_messages == source.covered_project_messages
|
||||
&& current_source.covered_observations == source.covered_observations
|
||||
&& current_source.previous.as_ref().map(|value| value.revision)
|
||||
== source.previous.as_ref().map(|value| value.revision);
|
||||
if !source_stable {
|
||||
let base_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
|
||||
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
|
||||
root,
|
||||
&base_request_id,
|
||||
)
|
||||
.map(|value| value.0)
|
||||
.unwrap_or(base_request_id);
|
||||
let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
|
||||
root,
|
||||
&snapshot,
|
||||
&request_id,
|
||||
);
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: context source drift"
|
||||
));
|
||||
}
|
||||
let sidecar = finalize_game_creator_agent_runtime_context_compaction(
|
||||
root,
|
||||
&source,
|
||||
&response,
|
||||
estimated_tokens_before,
|
||||
)?;
|
||||
if let Err(error) = write_game_creator_agent_runtime_context_compaction(root, &sidecar) {
|
||||
let base_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
|
||||
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
|
||||
root,
|
||||
&base_request_id,
|
||||
)
|
||||
.map(|value| value.0)
|
||||
.unwrap_or(base_request_id);
|
||||
let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
|
||||
root,
|
||||
&snapshot,
|
||||
&request_id,
|
||||
);
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {}",
|
||||
redact_agent_runtime_error(root, &error, 320)
|
||||
));
|
||||
}
|
||||
let persisted_sidecar = read_game_creator_agent_runtime_context_compaction(
|
||||
root,
|
||||
&sidecar.agent_id,
|
||||
&sidecar.session_id,
|
||||
)?
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: context compaction 写入后不存在"
|
||||
)
|
||||
})?;
|
||||
if persisted_sidecar != sidecar {
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: context compaction 写入后内容冲突"
|
||||
));
|
||||
}
|
||||
if let Err(error) = provider_handoff::remove_matching_at(root, &handoff_identity) {
|
||||
let base_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
|
||||
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
|
||||
root,
|
||||
&base_request_id,
|
||||
)
|
||||
.map(|value| value.0)
|
||||
.unwrap_or(base_request_id);
|
||||
let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
|
||||
root,
|
||||
&snapshot,
|
||||
&request_id,
|
||||
);
|
||||
return Err(format!(
|
||||
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {}",
|
||||
redact_agent_runtime_error(root, &error, 320)
|
||||
));
|
||||
}
|
||||
drop(control_lock);
|
||||
|
||||
if let Ok(runtime) =
|
||||
read_game_creator_agent_runtime_for_session_at(root, agent_id, Some(session_id))
|
||||
.map(|result| result.state)
|
||||
{
|
||||
let summary = format!("Agent 已完成第 {} 次持久上下文压缩。", sidecar.revision);
|
||||
let detail = format!(
|
||||
"trigger={} · agentMessages={} · projectMessages={} · observations={} · estimatedBefore={} · estimatedAfter={}",
|
||||
sidecar.trigger,
|
||||
sidecar.covered_agent_messages,
|
||||
sidecar.covered_project_messages,
|
||||
sidecar.covered_observations,
|
||||
sidecar.estimated_tokens_before,
|
||||
sidecar.estimated_tokens_after,
|
||||
);
|
||||
let _ = append_game_creator_agent_runtime_event(
|
||||
root,
|
||||
&runtime,
|
||||
"context.compacted",
|
||||
runtime.status.as_str(),
|
||||
runtime.phase.as_str(),
|
||||
&summary,
|
||||
Some(&detail),
|
||||
);
|
||||
let _ = append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.context.compacted",
|
||||
"agentId": runtime.agent_id,
|
||||
"taskId": runtime.task_id,
|
||||
"sessionId": runtime.session_id,
|
||||
"runId": runtime.run_id,
|
||||
"trigger": sidecar.trigger,
|
||||
"revision": sidecar.revision,
|
||||
"coveredAgentMessages": sidecar.covered_agent_messages,
|
||||
"coveredProjectMessages": sidecar.covered_project_messages,
|
||||
"coveredObservations": sidecar.covered_observations,
|
||||
"estimatedTokensBefore": sidecar.estimated_tokens_before,
|
||||
"estimatedTokensAfter": sidecar.estimated_tokens_after,
|
||||
"promptTokens": sidecar.prompt_tokens,
|
||||
"completionTokens": sidecar.completion_tokens,
|
||||
"totalTokens": sidecar.total_tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(AgentRuntimeContextCompactionOutcome::Completed(Some(
|
||||
context_compaction_result(&sidecar, false),
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn compact_game_creator_agent_runtime_session_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<AgentRuntimeContextCompactionResult, String> {
|
||||
validate_project_root(root)?;
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
let session_id = resolve_agent_conversation_session_id_at(root, &agent_id, session_id, false)?;
|
||||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, &agent_id)?
|
||||
.ok_or_else(|| "Agent 正在运行,当前不能手动压缩上下文".to_string())?;
|
||||
let runtime =
|
||||
read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?;
|
||||
if runtime.state.session_id != session_id
|
||||
|| runtime.state.status != "idle"
|
||||
|| !matches!(runtime.state.phase.as_str(), "idle" | "completed")
|
||||
|| runtime.state.pending_tool_action.is_some()
|
||||
|| runtime.task_queue.pending > 0
|
||||
|| runtime.task_queue.running > 0
|
||||
|| runtime.task_queue.waiting_for_confirmation > 0
|
||||
|| runtime.task_queue.waiting_for_user_input > 0
|
||||
|| game_creator_agent_runtime_has_pending_action_ledger(
|
||||
root,
|
||||
&agent_id,
|
||||
&runtime.state.run_id,
|
||||
)
|
||||
|| game_creator_agent_runtime_provider_request_is_active_at(
|
||||
root,
|
||||
&agent_id,
|
||||
&runtime.state.run_id,
|
||||
)?
|
||||
{
|
||||
return Err(
|
||||
"只有没有运行任务、Provider 请求或待确认动作的空闲 Session 才能手动压缩".to_string(),
|
||||
);
|
||||
}
|
||||
let observations =
|
||||
read_game_creator_agent_runtime_context_bundle_for_idle_compaction(root, &runtime.state)?
|
||||
.map(|bundle| bundle.observations)
|
||||
.unwrap_or_default();
|
||||
let source = build_game_creator_agent_runtime_context_compaction_source(
|
||||
root,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
&runtime.state.run_id,
|
||||
&observations,
|
||||
"manual",
|
||||
)?;
|
||||
let estimated_tokens_before = source.source_prompt_tokens.saturating_add(128);
|
||||
let result = match compact_game_creator_agent_runtime_context_at(
|
||||
root,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
&runtime.state.run_id,
|
||||
&observations,
|
||||
"manual",
|
||||
estimated_tokens_before,
|
||||
runtime.state.applied_steer_cursor,
|
||||
true,
|
||||
false,
|
||||
"context-compaction",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
AgentRuntimeContextCompactionOutcome::Completed(Some(result)) => result,
|
||||
AgentRuntimeContextCompactionOutcome::Completed(None) => {
|
||||
return Err("手动上下文压缩被新的控制指令中断".to_string());
|
||||
}
|
||||
AgentRuntimeContextCompactionOutcome::Waiting(_)
|
||||
| AgentRuntimeContextCompactionOutcome::HandoffPrepared
|
||||
| AgentRuntimeContextCompactionOutcome::Superseded => {
|
||||
return Err("手动上下文压缩不应进入后台 Provider 重试等待".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let mut state =
|
||||
read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?.state;
|
||||
if state.run_id != runtime.state.run_id || state.status != "idle" {
|
||||
return Err("手动压缩完成前 Runtime 身份或状态发生变化".to_string());
|
||||
}
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
let template_agent_id = game_creator_runtime_template_agent_id_at(root, &agent_id)?;
|
||||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id);
|
||||
state.context_usage.auto_compact_token_limit = llm.auto_compact_token_limit;
|
||||
state.context_usage.estimated_input_tokens = result.estimated_tokens_after;
|
||||
state.context_usage.last_prompt_tokens = result.prompt_tokens;
|
||||
state.context_usage.last_completion_tokens = result.completion_tokens;
|
||||
state.context_usage.last_total_tokens = result.total_tokens;
|
||||
state.context_usage.compaction_revision = result.revision;
|
||||
state.context_usage.compaction_count = result.revision;
|
||||
state.context_usage.last_compaction_trigger = Some(result.trigger.clone());
|
||||
state.context_usage.last_compacted_at = Some(result.compacted_at);
|
||||
state.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_state(root, &state)?;
|
||||
drop(runtime_lock);
|
||||
emit_game_creator_agent_runtime_update(root, &agent_id);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn agent_runtime_tool_is_parallel_safe_read(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool.trim(),
|
||||
"memory.read"
|
||||
| "conversation.read"
|
||||
| "asset.list"
|
||||
| "project.search"
|
||||
| "project.diff"
|
||||
| "git.inspect"
|
||||
| "file.list"
|
||||
| "file.read"
|
||||
| "task.list"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_parallel_read_batch_len(
|
||||
actions: &[AgentRuntimeToolAction],
|
||||
start_index: usize,
|
||||
) -> usize {
|
||||
let count = actions
|
||||
.iter()
|
||||
.skip(start_index)
|
||||
.take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT)
|
||||
.take_while(|action| agent_runtime_tool_is_parallel_safe_read(&action.tool))
|
||||
.count();
|
||||
if count >= 2 {
|
||||
count
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_parallel_read_batch_is_auto_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
run_profile: &str,
|
||||
run_profile_binding_fingerprint: &str,
|
||||
actions: &[AgentRuntimeToolAction],
|
||||
) -> bool {
|
||||
actions.iter().all(|action| {
|
||||
let Some(command_id) = game_creator_agent_runtime_tool_command_id(action.tool.trim())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
agent_runtime_tool_is_parallel_safe_read(&action.tool)
|
||||
&& game_creator_agent_runtime_tool_policy_rule_for_run(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
Some(run_profile),
|
||||
Some(run_profile_binding_fingerprint),
|
||||
command_id,
|
||||
)
|
||||
.is_none()
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
|
||||
tool: &str,
|
||||
) -> Option<&'static str> {
|
||||
match tool {
|
||||
"memory.read" => Some("memory.read"),
|
||||
"memory.write" => Some("memory.write"),
|
||||
"conversation.read" => Some("conversation.read"),
|
||||
"asset.list" => Some("asset.list"),
|
||||
"project.index" => Some("project.index"),
|
||||
"project.search" => Some("file.read"),
|
||||
"project.verify" => Some("project.verify"),
|
||||
"project.checkpoint" => Some("project.checkpoint"),
|
||||
"project.restore" => Some("project.restore"),
|
||||
"project.diff" => Some("project.diff"),
|
||||
"git.inspect" => Some("project.git_inspect"),
|
||||
"project.git_commit" => Some("project.git_commit"),
|
||||
"project.patchset" => Some("project.patchset"),
|
||||
"file.list" => Some("file.list"),
|
||||
"file.read" => Some("file.read"),
|
||||
"file.write" => Some("file.write"),
|
||||
"file.patch" => Some("file.write"),
|
||||
"file.delete" => Some("file.delete"),
|
||||
"task.list" => Some("task.list"),
|
||||
"task.create" => Some("task.create"),
|
||||
"task.update" => Some("task.update"),
|
||||
"command.exec" => Some("command.exec"),
|
||||
"command.output_read" => Some("command.output_read"),
|
||||
"command.start" => Some("command.start"),
|
||||
"command.poll" => Some("command.poll"),
|
||||
"command.stdin" => Some("command.stdin"),
|
||||
"command.terminate" => Some("command.terminate"),
|
||||
"command.run_limited" => Some("command.run_limited"),
|
||||
"preview.start" => Some("preview.start"),
|
||||
"preview.validate" => Some("preview.validate"),
|
||||
"image.inspect" => Some("image.inspect"),
|
||||
"canvas.asset_generate" => Some("canvas.asset_generate"),
|
||||
"blackboard.write" => Some("memory.write"),
|
||||
"agent.message" => Some("conversation.write"),
|
||||
"agent.delegate" => Some("agent.delegate"),
|
||||
"agent.spawn_isolated" => Some("agent.spawn_isolated"),
|
||||
"agent.schedule_ready" => Some("agent.schedule_ready"),
|
||||
"agent.action_history" => Some("agent.audit"),
|
||||
"agent.run_status" => Some("agent.run_status"),
|
||||
GAME_CREATOR_MCP_CALL_TOOL => Some(GAME_CREATOR_MCP_CALL_TOOL),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric()
|
||||
|| character == '-'
|
||||
|| character == '_'
|
||||
|| character == '.'
|
||||
{
|
||||
character
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let normalized = normalized.trim_matches('-');
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
truncate_agent_runtime_text(normalized, 160)
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_tool_confirmation_path(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
command_id: &str,
|
||||
) -> PathBuf {
|
||||
game_creator_agent_runtime_confirmation_dir(root, agent_id, run_id).join(format!(
|
||||
"{}.json",
|
||||
agent_runtime_confirmation_path_component(command_id, "command")
|
||||
))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_confirmation_dir(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> PathBuf {
|
||||
root.join(".agent/runtime/confirmations")
|
||||
.join(agent_runtime_confirmation_path_component(agent_id, "agent"))
|
||||
.join(agent_runtime_confirmation_path_component(run_id, "run"))
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_pending_tool_action_path(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> PathBuf {
|
||||
root.join(game_creator_agent_runtime_pending_tool_action_relative_path(agent_id, run_id))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_pending_tool_action_relative_path(
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
".agent/runtime/pending-actions/{}/{}.json",
|
||||
agent_runtime_confirmation_path_component(agent_id, "agent"),
|
||||
agent_runtime_confirmation_path_component(run_id, "run")
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_parallel_read_batch_relative_path(
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
".agent/runtime/parallel-read-batches/{}/{}.json",
|
||||
agent_runtime_confirmation_path_component(agent_id, "agent"),
|
||||
agent_runtime_confirmation_path_component(run_id, "run")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_parallel_read_batch_path(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> PathBuf {
|
||||
root.join(game_creator_agent_runtime_parallel_read_batch_relative_path(agent_id, run_id))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_parallel_read_batch_exists(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> bool {
|
||||
let path = game_creator_agent_runtime_parallel_read_batch_path(root, agent_id, run_id);
|
||||
path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists()
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_provider_action_batch_relative_path(
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
".agent/runtime/provider-action-batches/{}/{}.json",
|
||||
agent_runtime_confirmation_path_component(agent_id, "agent"),
|
||||
agent_runtime_confirmation_path_component(run_id, "run")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_provider_action_batch_path(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> PathBuf {
|
||||
root.join(game_creator_agent_runtime_provider_action_batch_relative_path(agent_id, run_id))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_provider_action_batch_exists(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> bool {
|
||||
let path = game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id);
|
||||
path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists()
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_has_pending_action_ledger(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> bool {
|
||||
game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id)
|
||||
|| game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id)
|
||||
|| game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_parallel_read_batch_id(
|
||||
project_id: &str,
|
||||
agent_id: &str,
|
||||
task_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
loop_iteration: u32,
|
||||
actions: &[AgentRuntimePendingToolAction],
|
||||
) -> Result<String, String> {
|
||||
let action_ids = actions
|
||||
.iter()
|
||||
.map(|pending| pending.action_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let identity = serde_json::to_vec(&serde_json::json!({
|
||||
"projectId": project_id,
|
||||
"agentId": agent_id,
|
||||
"taskId": task_id,
|
||||
"sessionId": session_id,
|
||||
"runId": run_id,
|
||||
"loopIteration": loop_iteration,
|
||||
"actionIds": action_ids,
|
||||
}))
|
||||
.map_err(|error| format!("序列化只读并行批次身份失败:{error}"))?;
|
||||
let fingerprint = format!("{:x}", Sha256::digest(identity));
|
||||
Ok(format!(
|
||||
"parallel-read-{}",
|
||||
fingerprint.chars().take(32).collect::<String>()
|
||||
))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_batch(
|
||||
root: &Path,
|
||||
batch: &AgentRuntimeParallelReadBatch,
|
||||
) -> Result<(), String> {
|
||||
if batch.schema_version != AGENT_RUNTIME_PARALLEL_READ_BATCH_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"不支持的 Agent Runtime 只读并行批次版本:{}",
|
||||
batch.schema_version
|
||||
));
|
||||
}
|
||||
if batch.project_id != game_creator_agent_runtime_context_project_id(root)? {
|
||||
return Err("Agent Runtime 只读并行批次项目身份不匹配".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
batch.status.as_str(),
|
||||
AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_EXECUTING
|
||||
| AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED
|
||||
) {
|
||||
return Err(format!(
|
||||
"Agent Runtime 只读并行批次状态无效:{}",
|
||||
batch.status
|
||||
));
|
||||
}
|
||||
if !(2..=AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT).contains(&batch.actions.len()) {
|
||||
return Err("Agent Runtime 只读并行批次动作数量必须在 2-3 之间".to_string());
|
||||
}
|
||||
if batch.status == AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_EXECUTING
|
||||
&& !batch.timings.is_empty()
|
||||
{
|
||||
return Err("执行中的 Agent Runtime 只读并行批次不能提前保存计时结果".to_string());
|
||||
}
|
||||
if batch.status == AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED
|
||||
&& batch.timings.len() != batch.actions.len()
|
||||
{
|
||||
return Err("已观察 Agent Runtime 只读并行批次缺少完整计时结果".to_string());
|
||||
}
|
||||
let mut previous_action_index = None;
|
||||
let mut action_ids = std::collections::BTreeSet::new();
|
||||
let first_pending = batch
|
||||
.actions
|
||||
.first()
|
||||
.ok_or_else(|| "Agent Runtime 只读并行批次缺少首个动作".to_string())?;
|
||||
for (index, pending) in batch.actions.iter().enumerate() {
|
||||
validate_agent_runtime_pending_tool_action_record(root, pending)?;
|
||||
if pending.agent_id != batch.agent_id
|
||||
|| pending.task_id != batch.task_id
|
||||
|| pending.session_id != batch.session_id
|
||||
|| pending.run_id != batch.run_id
|
||||
|| pending.source != batch.source
|
||||
|| pending.loop_iteration != batch.loop_iteration
|
||||
|| pending.planned_steer_cursor != batch.planned_steer_cursor
|
||||
|| !pending.is_auto()
|
||||
|| !agent_runtime_tool_is_parallel_safe_read(&pending.action.tool)
|
||||
{
|
||||
return Err("Agent Runtime 只读并行批次成员身份或工具分类不匹配".to_string());
|
||||
}
|
||||
if pending.task != first_pending.task
|
||||
|| pending.goal_id != first_pending.goal_id
|
||||
|| pending.goal_revision != first_pending.goal_revision
|
||||
|| pending.goal_snapshot_fingerprint != first_pending.goal_snapshot_fingerprint
|
||||
|| pending.thinking_summary != first_pending.thinking_summary
|
||||
|| pending.plan != first_pending.plan
|
||||
|| pending.fallback_response != first_pending.fallback_response
|
||||
|| pending.observations != first_pending.observations
|
||||
|| pending.project_revision_before != first_pending.project_revision_before
|
||||
|| pending.verification_gate_before != first_pending.verification_gate_before
|
||||
|| pending.planned_repository_context_fingerprint
|
||||
!= first_pending.planned_repository_context_fingerprint
|
||||
{
|
||||
return Err("Agent Runtime 只读并行批次成员的 planning 快照不一致".to_string());
|
||||
}
|
||||
if !action_ids.insert(pending.action_id.clone()) {
|
||||
return Err("Agent Runtime 只读并行批次包含重复 actionId".to_string());
|
||||
}
|
||||
if previous_action_index.is_some_and(|previous| pending.action_index != previous + 1) {
|
||||
return Err("Agent Runtime 只读并行批次 action index 必须连续递增".to_string());
|
||||
}
|
||||
previous_action_index = Some(pending.action_index);
|
||||
match batch.status.as_str() {
|
||||
AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_EXECUTING
|
||||
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
|
||||
&& pending.observation.is_none() => {}
|
||||
AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED
|
||||
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED
|
||||
&& pending.observation.as_ref().is_some_and(|observation| {
|
||||
observation.tool == pending.action.tool
|
||||
&& !observation.is_waiting_for_confirmation()
|
||||
&& !observation.requires_reconciliation()
|
||||
}) => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Agent Runtime 只读并行批次成员状态与批次不一致:index={index}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (timing, pending) in batch.timings.iter().zip(batch.actions.iter()) {
|
||||
if timing.action_id != pending.action_id
|
||||
|| timing.started_at_nanos == 0
|
||||
|| timing.finished_at_nanos < timing.started_at_nanos
|
||||
{
|
||||
return Err("Agent Runtime 只读并行批次计时身份或区间无效".to_string());
|
||||
}
|
||||
}
|
||||
let expected_batch_id = agent_runtime_parallel_read_batch_id(
|
||||
&batch.project_id,
|
||||
&batch.agent_id,
|
||||
&batch.task_id,
|
||||
&batch.session_id,
|
||||
&batch.run_id,
|
||||
batch.loop_iteration,
|
||||
&batch.actions,
|
||||
)?;
|
||||
if batch.batch_id != expected_batch_id {
|
||||
return Err("Agent Runtime 只读并行批次身份指纹已变化".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn write_game_creator_agent_runtime_parallel_read_batch(
|
||||
root: &Path,
|
||||
batch: &AgentRuntimeParallelReadBatch,
|
||||
) -> Result<(), String> {
|
||||
validate_game_creator_agent_runtime_parallel_read_batch(root, batch)?;
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
&game_creator_agent_runtime_parallel_read_batch_relative_path(
|
||||
&batch.agent_id,
|
||||
&batch.run_id,
|
||||
),
|
||||
"Agent Runtime 只读并行批次",
|
||||
batch,
|
||||
AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn read_game_creator_agent_runtime_parallel_read_batch(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<AgentRuntimeParallelReadBatch, String> {
|
||||
let relative_path =
|
||||
game_creator_agent_runtime_parallel_read_batch_relative_path(agent_id, run_id);
|
||||
let batch = read_agent_runtime_json_sidecar_with_max_bytes::<AgentRuntimeParallelReadBatch>(
|
||||
root,
|
||||
&relative_path,
|
||||
"Agent Runtime 只读并行批次",
|
||||
AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES,
|
||||
)?
|
||||
.ok_or_else(|| "Agent Runtime 只读并行批次不存在".to_string())?;
|
||||
if batch.agent_id != agent_id || batch.run_id != run_id {
|
||||
return Err("Agent Runtime 只读并行批次 Agent 或 run 身份不匹配".to_string());
|
||||
}
|
||||
validate_game_creator_agent_runtime_parallel_read_batch(root, &batch)?;
|
||||
Ok(batch)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn remove_game_creator_agent_runtime_parallel_read_batch(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = game_creator_agent_runtime_parallel_read_batch_path(root, agent_id, run_id);
|
||||
let backup_path = agent_runtime_json_sidecar_backup_path(&path);
|
||||
remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 只读并行批次")?;
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
|
||||
Err("Agent Runtime 只读并行批次必须是普通文件".to_string())
|
||||
}
|
||||
Ok(_) => fs::remove_file(&path).map_err(|error| {
|
||||
format!(
|
||||
"删除 Agent Runtime 只读并行批次失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
}),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!(
|
||||
"读取 Agent Runtime 只读并行批次元数据失败:{}: {error}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+533
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+955
File diff suppressed because it is too large
Load Diff
+568
File diff suppressed because it is too large
Load Diff
+234
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
task: &str,
|
||||
plan: &AgentRuntimeToolPlan,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
applied_steer_cursor: u64,
|
||||
request_slot: &str,
|
||||
response_revision: u64,
|
||||
) -> Result<RequestedAgentRuntimeFinalReplyOutcome, String> {
|
||||
let mut built_request = {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.provider_request.build.final_reply",
|
||||
)?;
|
||||
build_game_creator_agent_background_final_reply_request(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
task,
|
||||
plan,
|
||||
observations,
|
||||
)?
|
||||
};
|
||||
let mut estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?;
|
||||
let mut compaction = None;
|
||||
if estimated_input_tokens > built_request.0.auto_compact_token_limit {
|
||||
match compact_game_creator_agent_runtime_context_at(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
observations,
|
||||
"auto",
|
||||
estimated_input_tokens,
|
||||
applied_steer_cursor,
|
||||
false,
|
||||
true,
|
||||
"final-reply-context-compaction",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
AgentRuntimeContextCompactionOutcome::Completed(Some(result)) => {
|
||||
compaction = Some(result);
|
||||
}
|
||||
AgentRuntimeContextCompactionOutcome::Completed(None) => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(None));
|
||||
}
|
||||
AgentRuntimeContextCompactionOutcome::Waiting(record) => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::Waiting(record));
|
||||
}
|
||||
AgentRuntimeContextCompactionOutcome::HandoffPrepared => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::HandoffPrepared);
|
||||
}
|
||||
AgentRuntimeContextCompactionOutcome::Superseded => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::Superseded);
|
||||
}
|
||||
}
|
||||
built_request = {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.provider_request.rebuild.final_reply",
|
||||
)?;
|
||||
build_game_creator_agent_background_final_reply_request(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
task,
|
||||
plan,
|
||||
observations,
|
||||
)?
|
||||
};
|
||||
estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?;
|
||||
if estimated_input_tokens > built_request.0.auto_compact_token_limit {
|
||||
return Err(format!(
|
||||
"上下文压缩后 final-reply 预计输入仍为 {estimated_input_tokens} tokens,超过 autoCompactTokenLimit={};请提高阈值或新建 Session",
|
||||
built_request.0.auto_compact_token_limit
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_game_creator_llm_request_context_budget(
|
||||
&built_request.0,
|
||||
&built_request.2,
|
||||
estimated_input_tokens,
|
||||
"final-reply 请求",
|
||||
)?;
|
||||
if compaction.is_none() {
|
||||
compaction =
|
||||
read_game_creator_agent_runtime_context_compaction(root, agent_id, session_id)?
|
||||
.as_ref()
|
||||
.map(|sidecar| context_compaction_result(sidecar, true));
|
||||
}
|
||||
let provider_snapshot = {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"runtime.provider_request.capture.final_reply",
|
||||
)?;
|
||||
capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
|
||||
root,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
"final-reply",
|
||||
request_slot,
|
||||
applied_steer_cursor,
|
||||
)?
|
||||
};
|
||||
let (llm, config_path, request) = built_request;
|
||||
let auto_compact_token_limit = llm.auto_compact_token_limit;
|
||||
let stream_snapshot = provider_snapshot.clone();
|
||||
let suppress_private_process_output =
|
||||
agent_runtime_observations_contain_private_process_output(observations);
|
||||
let fallback_response = (!plan.response.trim().is_empty())
|
||||
.then(|| strip_llm_thinking_blocks(&plan.response))
|
||||
.map(|response| {
|
||||
redact_agent_runtime_private_process_output_from_response(&response, observations)
|
||||
})
|
||||
.filter(|response| !response.trim().is_empty());
|
||||
let stream_response = llm.stream;
|
||||
let request_stream_snapshot = stream_snapshot.clone();
|
||||
let response_result =
|
||||
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
|
||||
root,
|
||||
&provider_snapshot,
|
||||
&llm,
|
||||
&config_path,
|
||||
"后台 Agent 最终回复",
|
||||
&request,
|
||||
move |client, attempt_request| {
|
||||
let stream_snapshot = request_stream_snapshot.clone();
|
||||
async move {
|
||||
if stream_response {
|
||||
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
|
||||
root,
|
||||
&stream_snapshot,
|
||||
response_revision,
|
||||
);
|
||||
match client
|
||||
.stream_run(attempt_request, |delta| {
|
||||
if !suppress_private_process_output {
|
||||
publisher.push(delta);
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
publisher.handoff();
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => {
|
||||
publisher.failed();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client.run(attempt_request).await
|
||||
}
|
||||
}
|
||||
},
|
||||
|response| {
|
||||
let mut response = response.clone();
|
||||
response.text = redact_agent_runtime_private_process_output_from_response(
|
||||
&strip_llm_thinking_blocks(&response.text),
|
||||
observations,
|
||||
);
|
||||
response
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if response_result.is_err() {
|
||||
if let Some(fallback_response) = fallback_response.as_deref() {
|
||||
let _ = write_game_creator_agent_runtime_response_stream_ready_at(
|
||||
root,
|
||||
&stream_snapshot,
|
||||
response_revision,
|
||||
fallback_response,
|
||||
Some("fallback"),
|
||||
);
|
||||
}
|
||||
}
|
||||
let response = match response_result? {
|
||||
AgentRuntimePersistedProviderRequestOutcome::Response(Some(response)) => response,
|
||||
AgentRuntimePersistedProviderRequestOutcome::Response(None) => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(None));
|
||||
}
|
||||
AgentRuntimePersistedProviderRequestOutcome::Waiting(record) => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::Waiting(record));
|
||||
}
|
||||
AgentRuntimePersistedProviderRequestOutcome::HandoffPrepared => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::HandoffPrepared);
|
||||
}
|
||||
AgentRuntimePersistedProviderRequestOutcome::Superseded => {
|
||||
return Ok(RequestedAgentRuntimeFinalReplyOutcome::Superseded);
|
||||
}
|
||||
};
|
||||
let reply = redact_agent_runtime_private_process_output_from_response(
|
||||
&strip_llm_thinking_blocks(response.text.as_str()),
|
||||
observations,
|
||||
);
|
||||
if reply.trim().is_empty() {
|
||||
if let Some(fallback_response) = fallback_response.as_deref() {
|
||||
let _ = write_game_creator_agent_runtime_response_stream_ready_at(
|
||||
root,
|
||||
&stream_snapshot,
|
||||
response_revision,
|
||||
fallback_response,
|
||||
Some("fallback"),
|
||||
);
|
||||
}
|
||||
return Err(format!("{config_path} 后台 Agent 最终回复为空"));
|
||||
}
|
||||
write_game_creator_agent_runtime_response_stream_ready_at(
|
||||
root,
|
||||
&stream_snapshot,
|
||||
response_revision,
|
||||
&reply,
|
||||
response.finish_reason.as_deref(),
|
||||
)?;
|
||||
Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(Some(
|
||||
RequestedAgentRuntimeFinalReply {
|
||||
reply,
|
||||
estimated_input_tokens,
|
||||
auto_compact_token_limit,
|
||||
usage: response.usage,
|
||||
compaction,
|
||||
},
|
||||
)))
|
||||
}
|
||||
+354
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::agent) struct AgentRuntimeThinkingStreamFilter {
|
||||
pub(in crate::agent) pending: String,
|
||||
pub(in crate::agent) inside_thinking: bool,
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn ascii_marker_suffix_len(value: &str, marker: &str) -> usize {
|
||||
let max_len = value.len().min(marker.len().saturating_sub(1));
|
||||
(1..=max_len)
|
||||
.rev()
|
||||
.find(|length| {
|
||||
let start = value.len() - *length;
|
||||
value.is_char_boundary(start) && value[start..].eq_ignore_ascii_case(&marker[..*length])
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl AgentRuntimeThinkingStreamFilter {
|
||||
fn push(&mut self, delta: &str) -> String {
|
||||
const THINK_START: &str = "<think>";
|
||||
const THINK_END: &str = "</think>";
|
||||
|
||||
self.pending.push_str(delta);
|
||||
let mut visible = String::new();
|
||||
loop {
|
||||
if self.inside_thinking {
|
||||
if let Some(end) = self.pending.to_ascii_lowercase().find(THINK_END) {
|
||||
self.pending.drain(..end + THINK_END.len());
|
||||
self.inside_thinking = false;
|
||||
continue;
|
||||
}
|
||||
let keep = ascii_marker_suffix_len(&self.pending, THINK_END);
|
||||
if keep == 0 {
|
||||
self.pending.clear();
|
||||
} else {
|
||||
let start = self.pending.len() - keep;
|
||||
self.pending = self.pending[start..].to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(start) = self.pending.to_ascii_lowercase().find(THINK_START) {
|
||||
visible.push_str(&self.pending[..start]);
|
||||
self.pending.drain(..start + THINK_START.len());
|
||||
self.inside_thinking = true;
|
||||
continue;
|
||||
}
|
||||
let keep = ascii_marker_suffix_len(&self.pending, THINK_START);
|
||||
let split = self.pending.len() - keep;
|
||||
visible.push_str(&self.pending[..split]);
|
||||
self.pending = self.pending[split..].to_string();
|
||||
break;
|
||||
}
|
||||
visible
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn normalize_agent_runtime_response_stream_finish_reason(
|
||||
finish_reason: Option<&str>,
|
||||
) -> Option<String> {
|
||||
finish_reason.and_then(|reason| {
|
||||
let reason = reason.trim();
|
||||
(!reason.is_empty() && !reason.chars().any(char::is_control))
|
||||
.then(|| truncate_agent_runtime_text(reason, 80))
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_runtime_response_stream(
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
response_revision: u64,
|
||||
status: &str,
|
||||
sequence: u64,
|
||||
accumulated_text: String,
|
||||
finish_reason: Option<&str>,
|
||||
) -> AgentRuntimeResponseStream {
|
||||
let now = unix_timestamp();
|
||||
AgentRuntimeResponseStream {
|
||||
schema_version: AGENT_RUNTIME_RESPONSE_STREAM_SCHEMA_VERSION.to_string(),
|
||||
agent_id: snapshot.agent_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
session_id: snapshot.session_id.clone(),
|
||||
run_id: snapshot.run_id.clone(),
|
||||
request_kind: snapshot.request_kind.clone(),
|
||||
request_slot: snapshot.request_slot.clone(),
|
||||
applied_steer_cursor: snapshot.applied_steer_cursor,
|
||||
response_revision,
|
||||
sequence,
|
||||
status: status.to_string(),
|
||||
accumulated_text,
|
||||
finish_reason: normalize_agent_runtime_response_stream_finish_reason(finish_reason),
|
||||
started_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn write_game_creator_agent_runtime_response_stream_ready_at(
|
||||
root: &Path,
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
response_revision: u64,
|
||||
response: &str,
|
||||
finish_reason: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if response.trim().is_empty() {
|
||||
return Err("Agent Runtime 回复流 ready 正文不能为空".to_string());
|
||||
}
|
||||
if response.chars().count() > AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"Agent Runtime 回复流 ready 正文超过 {} 字符上限",
|
||||
AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS
|
||||
));
|
||||
}
|
||||
let previous = read_game_creator_agent_runtime_response_stream_at(
|
||||
root,
|
||||
&snapshot.agent_id,
|
||||
&snapshot.run_id,
|
||||
)?
|
||||
.filter(|stream| {
|
||||
stream.task_id == snapshot.task_id
|
||||
&& stream.session_id == snapshot.session_id
|
||||
&& stream.request_slot == snapshot.request_slot
|
||||
&& stream.applied_steer_cursor == snapshot.applied_steer_cursor
|
||||
&& stream.response_revision == response_revision
|
||||
});
|
||||
let mut stream = build_game_creator_agent_runtime_response_stream(
|
||||
snapshot,
|
||||
response_revision,
|
||||
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY,
|
||||
previous
|
||||
.as_ref()
|
||||
.map(|stream| stream.sequence.saturating_add(1))
|
||||
.unwrap_or(1),
|
||||
response.to_string(),
|
||||
finish_reason,
|
||||
);
|
||||
if let Some(previous) = previous {
|
||||
stream.started_at = previous.started_at;
|
||||
}
|
||||
write_game_creator_agent_runtime_response_stream_at(root, &stream)
|
||||
}
|
||||
|
||||
pub(in crate::agent) struct AgentRuntimeResponseStreamPublisher {
|
||||
pub(in crate::agent) root: PathBuf,
|
||||
pub(in crate::agent) stream: AgentRuntimeResponseStream,
|
||||
pub(in crate::agent) filter: AgentRuntimeThinkingStreamFilter,
|
||||
pub(in crate::agent) last_persisted_at: std::time::Instant,
|
||||
pub(in crate::agent) dirty: bool,
|
||||
pub(in crate::agent) terminal: bool,
|
||||
}
|
||||
|
||||
impl AgentRuntimeResponseStreamPublisher {
|
||||
pub(super) fn start(
|
||||
root: &Path,
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
response_revision: u64,
|
||||
) -> Self {
|
||||
let previous = read_game_creator_agent_runtime_response_stream_at(
|
||||
root,
|
||||
&snapshot.agent_id,
|
||||
&snapshot.run_id,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|stream| {
|
||||
stream.task_id == snapshot.task_id
|
||||
&& stream.session_id == snapshot.session_id
|
||||
&& stream.request_slot == snapshot.request_slot
|
||||
&& stream.applied_steer_cursor == snapshot.applied_steer_cursor
|
||||
&& stream.response_revision == response_revision
|
||||
});
|
||||
let mut stream = build_game_creator_agent_runtime_response_stream(
|
||||
snapshot,
|
||||
response_revision,
|
||||
AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING,
|
||||
previous
|
||||
.as_ref()
|
||||
.map(|stream| stream.sequence.saturating_add(1))
|
||||
.unwrap_or(0),
|
||||
String::new(),
|
||||
None,
|
||||
);
|
||||
if let Some(previous) = previous {
|
||||
stream.started_at = previous.started_at;
|
||||
}
|
||||
let _ = write_game_creator_agent_runtime_response_stream_at(root, &stream);
|
||||
let now = std::time::Instant::now();
|
||||
Self {
|
||||
root: root.to_path_buf(),
|
||||
stream,
|
||||
filter: AgentRuntimeThinkingStreamFilter::default(),
|
||||
last_persisted_at: now
|
||||
.checked_sub(AGENT_RUNTIME_RESPONSE_STREAM_PERSIST_INTERVAL)
|
||||
.unwrap_or(now),
|
||||
dirty: false,
|
||||
terminal: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, delta: &platform_llm::LlmStreamDelta) {
|
||||
let visible_delta = self.filter.push(&delta.delta_text);
|
||||
let remaining = AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS
|
||||
.saturating_sub(self.stream.accumulated_text.chars().count());
|
||||
let visible_delta = visible_delta.chars().take(remaining).collect::<String>();
|
||||
let finish_reason =
|
||||
normalize_agent_runtime_response_stream_finish_reason(delta.finish_reason.as_deref());
|
||||
if visible_delta.is_empty() && finish_reason == self.stream.finish_reason {
|
||||
return;
|
||||
}
|
||||
self.stream.accumulated_text.push_str(&visible_delta);
|
||||
self.stream.finish_reason = finish_reason;
|
||||
self.stream.sequence = self.stream.sequence.saturating_add(1);
|
||||
self.stream.updated_at = unix_timestamp();
|
||||
self.dirty = true;
|
||||
if self.last_persisted_at.elapsed() >= AGENT_RUNTIME_RESPONSE_STREAM_PERSIST_INTERVAL
|
||||
|| self.stream.finish_reason.is_some()
|
||||
{
|
||||
self.persist();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ready(&mut self, response: &str, finish_reason: Option<&str>) {
|
||||
if response.trim().is_empty()
|
||||
|| response.chars().count() > AGENT_RUNTIME_RESPONSE_STREAM_MAX_CHARS
|
||||
{
|
||||
self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED);
|
||||
return;
|
||||
}
|
||||
self.stream.accumulated_text = response.to_string();
|
||||
self.stream.finish_reason =
|
||||
normalize_agent_runtime_response_stream_finish_reason(finish_reason);
|
||||
self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY);
|
||||
}
|
||||
|
||||
pub(super) fn failed(&mut self) {
|
||||
self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_FAILED);
|
||||
}
|
||||
|
||||
pub(super) fn handoff(&mut self) {
|
||||
self.persist();
|
||||
self.terminal = true;
|
||||
}
|
||||
|
||||
fn finish_with_status(&mut self, status: &str) {
|
||||
self.stream.status = status.to_string();
|
||||
self.stream.sequence = self.stream.sequence.saturating_add(1);
|
||||
self.stream.updated_at = unix_timestamp();
|
||||
self.dirty = true;
|
||||
self.persist();
|
||||
self.terminal = true;
|
||||
}
|
||||
|
||||
pub(super) fn persist(&mut self) {
|
||||
if !self.dirty {
|
||||
return;
|
||||
}
|
||||
let _ = write_game_creator_agent_runtime_response_stream_at(&self.root, &self.stream);
|
||||
self.last_persisted_at = std::time::Instant::now();
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgentRuntimeResponseStreamPublisher {
|
||||
fn drop(&mut self) {
|
||||
if self.terminal {
|
||||
return;
|
||||
}
|
||||
self.finish_with_status(AGENT_RUNTIME_RESPONSE_STREAM_STATUS_DISCARDED);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn filter_agent_runtime_response_stream_for_test(chunks: &[&str]) -> String {
|
||||
let mut filter = AgentRuntimeThinkingStreamFilter::default();
|
||||
chunks
|
||||
.iter()
|
||||
.map(|chunk| filter.push(chunk))
|
||||
.collect::<String>()
|
||||
}
|
||||
+1653
File diff suppressed because it is too large
Load Diff
+179
@@ -0,0 +1,179 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::agent) fn mark_supervisor_delivery_claims_observed_for_pending_action_at(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
observation: &AgentRuntimeToolObservation,
|
||||
) -> Result<(), String> {
|
||||
if pending.action.tool != "agent.run_status" || observation.status != "ok" {
|
||||
return Ok(());
|
||||
}
|
||||
let observed_delegate_receipt_ids =
|
||||
observed_delegate_receipt_ids_from_run_status(observation.detail.as_deref())?;
|
||||
let observed_isolated_groups =
|
||||
observed_isolated_join_group_ids_from_run_status(observation.detail.as_deref())?;
|
||||
if pending.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
mark_static_delegate_claim_observed_for_receipts_at(
|
||||
root,
|
||||
&pending.agent_id,
|
||||
&pending.run_id,
|
||||
&pending.action_id,
|
||||
&observed_delegate_receipt_ids,
|
||||
)?;
|
||||
}
|
||||
mark_unobserved_isolated_join_claims_for_parent_at(
|
||||
root,
|
||||
&pending.agent_id,
|
||||
&pending.run_id,
|
||||
&observed_isolated_groups,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn run_status_prefixed_payload<'a>(
|
||||
detail: Option<&'a str>,
|
||||
prefix: &str,
|
||||
) -> Result<Option<&'a str>, String> {
|
||||
let Some(mut remaining) = detail else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut found = None;
|
||||
loop {
|
||||
let (block, next) = remaining
|
||||
.split_once("\n\n")
|
||||
.map_or((remaining, ""), |(block, next)| (block, next));
|
||||
if let Some(payload) = block.strip_prefix(prefix) {
|
||||
if found.replace(payload).is_some() {
|
||||
return Err(format!(
|
||||
"agent.run_status observation 含重复前置区块:{}",
|
||||
prefix.trim_end()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
block.split_once(':').map(|(name, _)| name),
|
||||
Some(
|
||||
"readyIsolatedJoins"
|
||||
| "readyDelegateReceipts"
|
||||
| "claimedDelegateContracts"
|
||||
| "claimedIsolatedJoins"
|
||||
)
|
||||
) {
|
||||
return Ok(found);
|
||||
}
|
||||
if next.is_empty() {
|
||||
return Ok(found);
|
||||
}
|
||||
remaining = next;
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn observed_delegate_receipt_ids_from_run_status(
|
||||
detail: Option<&str>,
|
||||
) -> Result<BTreeSet<String>, String> {
|
||||
let Some(payload) = run_status_prefixed_payload(detail, "readyDelegateReceipts: ")? else {
|
||||
return Ok(BTreeSet::new());
|
||||
};
|
||||
let payload = serde_json::from_str::<serde_json::Value>(payload)
|
||||
.map_err(|error| format!("解析已观察专业 Agent ready receipts 失败:{error}"))?;
|
||||
if payload.get("ready").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
return Err("已观察专业 Agent ready receipts 结果未标记 ready=true".to_string());
|
||||
}
|
||||
let receipts = payload
|
||||
.get("receipts")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "已观察专业 Agent ready receipts 结果缺少 receipts".to_string())?;
|
||||
let mut delegation_ids = BTreeSet::new();
|
||||
for receipt in receipts {
|
||||
let delegation_id = receipt
|
||||
.get("delegationId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "已观察专业 Agent ready receipts 结果缺少 delegationId".to_string())?;
|
||||
if !delegation_ids.insert(delegation_id.to_string()) {
|
||||
return Err(format!(
|
||||
"已观察专业 Agent ready receipts 结果含重复 delegationId:{delegation_id}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(delegation_ids)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn observed_isolated_join_group_ids_from_run_status(
|
||||
detail: Option<&str>,
|
||||
) -> Result<BTreeSet<String>, String> {
|
||||
let Some(payload) = run_status_prefixed_payload(detail, "readyIsolatedJoins: ")? else {
|
||||
return Ok(BTreeSet::new());
|
||||
};
|
||||
let payload = serde_json::from_str::<serde_json::Value>(payload)
|
||||
.map_err(|error| format!("解析已观察动态隔离 Agent join 结果失败:{error}"))?;
|
||||
if payload.get("ready").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
return Err("已观察动态隔离 Agent join 结果未标记 ready=true".to_string());
|
||||
}
|
||||
let joins = payload
|
||||
.get("joins")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "已观察动态隔离 Agent join 结果缺少 joins".to_string())?;
|
||||
let mut group_ids = BTreeSet::new();
|
||||
for join in joins {
|
||||
let group_id = join
|
||||
.get("delegationGroupId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "已观察动态隔离 Agent join 结果缺少 delegationGroupId".to_string())?;
|
||||
if !group_ids.insert(group_id.to_string()) {
|
||||
return Err(format!(
|
||||
"已观察动态隔离 Agent join 结果含重复 group:{group_id}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(group_ids)
|
||||
}
|
||||
|
||||
impl AgentRuntimeToolObservation {
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
format!("{}:{} · {}", self.tool, self.status, self.summary)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn is_waiting_for_confirmation(&self) -> bool {
|
||||
self.status == "waiting-for-confirmation"
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn requires_reconciliation(&self) -> bool {
|
||||
self.status == AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn is_repository_context_drift(&self) -> bool {
|
||||
self.status == "blocked"
|
||||
&& self
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.starts_with("repositoryContextDrift=true"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_local_observation_detail(
|
||||
observation: &AgentRuntimeToolObservation,
|
||||
) -> Option<&str> {
|
||||
if matches!(
|
||||
observation.tool.as_str(),
|
||||
"command.poll"
|
||||
| "command.stdin"
|
||||
| GAME_CREATOR_MCP_CALL_TOOL
|
||||
| GAME_CREATOR_USER_INPUT_REQUEST_TOOL
|
||||
) {
|
||||
None
|
||||
} else {
|
||||
observation.detail.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_public_observation_detail(
|
||||
root: &Path,
|
||||
observation: &AgentRuntimeToolObservation,
|
||||
) -> Option<String> {
|
||||
if observation.tool == "agent.action_history" && observation.status == "ok" {
|
||||
let detail = observation.detail.as_deref()?;
|
||||
validate_agent_runtime_pending_serialized_content(root, detail).ok()?;
|
||||
return Some(detail.to_string());
|
||||
}
|
||||
agent_runtime_action_receipt_safe_detail(root, observation)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mixed_ready_prefixes_parse_exact_static_and_isolated_ids() {
|
||||
let detail = concat!(
|
||||
"readyIsolatedJoins: {\"ready\":true,\"joins\":[{\"delegationGroupId\":\"group-a\"}]}\n\n",
|
||||
"readyDelegateReceipts: {\"ready\":true,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}\n\n",
|
||||
"agentId: project-supervisor"
|
||||
);
|
||||
assert_eq!(
|
||||
observed_isolated_join_group_ids_from_run_status(Some(detail))
|
||||
.expect("parse mixed isolated prefix"),
|
||||
BTreeSet::from(["group-a".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
observed_delegate_receipt_ids_from_run_status(Some(detail))
|
||||
.expect("parse mixed static prefix"),
|
||||
BTreeSet::from(["delivery-a".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_prefix_parser_rejects_false_and_duplicate_blocks() {
|
||||
let not_ready =
|
||||
"readyDelegateReceipts: {\"ready\":false,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}";
|
||||
assert!(
|
||||
observed_delegate_receipt_ids_from_run_status(Some(not_ready))
|
||||
.expect_err("ready=false must fail closed")
|
||||
.contains("ready=true")
|
||||
);
|
||||
|
||||
let duplicate = concat!(
|
||||
"readyDelegateReceipts: {\"ready\":true,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}\n\n",
|
||||
"readyDelegateReceipts: {\"ready\":true,\"receipts\":[{\"delegationId\":\"delivery-a\"}]}"
|
||||
);
|
||||
assert!(
|
||||
observed_delegate_receipt_ids_from_run_status(Some(duplicate))
|
||||
.expect_err("duplicate ready receipt blocks must fail closed")
|
||||
.contains("重复前置区块")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_has_structured_plan(runtime: &AgentRuntimeState) -> bool {
|
||||
runtime.plan_revision > 0
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_agent_runtime_structured_plan_snapshot(
|
||||
plan_revision: u64,
|
||||
plan_explanation: &str,
|
||||
plan: &[String],
|
||||
plan_steps: &[AgentRuntimePlanStep],
|
||||
active_plan_step_index: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
if plan_revision == 0 {
|
||||
if !plan_explanation.trim().is_empty() {
|
||||
return Err("Agent 旧计划不能携带结构化计划说明".to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if plan_explanation.trim().is_empty() {
|
||||
return Err("Agent 结构化计划缺少 explanation".to_string());
|
||||
}
|
||||
if plan_steps.is_empty() || plan_steps.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT {
|
||||
return Err(format!(
|
||||
"Agent 结构化计划步骤数量必须在 1..={AGENT_RUNTIME_PLAN_STEP_LIMIT}"
|
||||
));
|
||||
}
|
||||
if plan.len() != plan_steps.len() {
|
||||
return Err("Agent 结构化计划文本与步骤数量不匹配".to_string());
|
||||
}
|
||||
|
||||
let mut seen_steps = std::collections::BTreeSet::new();
|
||||
let mut in_progress_index = None;
|
||||
for (index, step) in plan_steps.iter().enumerate() {
|
||||
if step.index != index as u32
|
||||
|| step.title.trim().is_empty()
|
||||
|| plan.get(index) != Some(&step.title)
|
||||
{
|
||||
return Err("Agent 结构化计划步骤索引或标题不匹配".to_string());
|
||||
}
|
||||
if !seen_steps.insert(step.title.as_str()) {
|
||||
return Err("Agent 结构化计划包含重复步骤".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
step.status.as_str(),
|
||||
AGENT_RUNTIME_PLAN_STATUS_PENDING
|
||||
| AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS
|
||||
| AGENT_RUNTIME_PLAN_STATUS_COMPLETED
|
||||
| AGENT_RUNTIME_PLAN_STATUS_FAILED
|
||||
) {
|
||||
return Err("Agent 结构化计划持久状态无效".to_string());
|
||||
}
|
||||
if step.status == AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS {
|
||||
if in_progress_index.replace(step.index).is_some() {
|
||||
return Err("Agent 结构化计划同时存在多个 in_progress 步骤".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if active_plan_step_index != in_progress_index {
|
||||
return Err("Agent 结构化计划 activePlanStepIndex 与 in_progress 步骤不匹配".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_agent_runtime_plan_update(
|
||||
update: &AgentRuntimePlanUpdate,
|
||||
) -> Result<AgentRuntimePlanUpdate, String> {
|
||||
let explanation = sanitize_agent_runtime_text(update.explanation.trim(), 240);
|
||||
if explanation.is_empty() {
|
||||
return Err("Agent 结构化计划更新的 explanation 不能为空".to_string());
|
||||
}
|
||||
if update.steps.is_empty() {
|
||||
return Err("Agent 结构化计划更新必须包含至少一个步骤".to_string());
|
||||
}
|
||||
if update.steps.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT {
|
||||
return Err(format!(
|
||||
"Agent 结构化计划更新最多包含 {AGENT_RUNTIME_PLAN_STEP_LIMIT} 个步骤"
|
||||
));
|
||||
}
|
||||
|
||||
let mut seen_steps = std::collections::BTreeSet::new();
|
||||
let mut in_progress_count = 0usize;
|
||||
let mut steps = Vec::with_capacity(update.steps.len());
|
||||
for raw_step in &update.steps {
|
||||
let step = sanitize_agent_runtime_text(raw_step.step.trim(), 180);
|
||||
if step.is_empty() {
|
||||
return Err("Agent 结构化计划更新不能包含空步骤".to_string());
|
||||
}
|
||||
if !seen_steps.insert(step.clone()) {
|
||||
return Err("Agent 结构化计划更新包含重复步骤".to_string());
|
||||
}
|
||||
let status = raw_step.status.trim();
|
||||
if !matches!(
|
||||
status,
|
||||
AGENT_RUNTIME_PLAN_STATUS_PENDING
|
||||
| AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS
|
||||
| AGENT_RUNTIME_PLAN_STATUS_COMPLETED
|
||||
) {
|
||||
return Err(
|
||||
"Agent 结构化计划步骤状态无效,只允许 pending、in_progress、completed".to_string(),
|
||||
);
|
||||
}
|
||||
if status == AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS {
|
||||
in_progress_count += 1;
|
||||
if in_progress_count > 1 {
|
||||
return Err("Agent 结构化计划同时最多只能有一个 in_progress 步骤".to_string());
|
||||
}
|
||||
}
|
||||
steps.push(AgentRuntimePlanUpdateStep {
|
||||
step,
|
||||
status: status.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AgentRuntimePlanUpdate { explanation, steps })
|
||||
}
|
||||
|
||||
pub(crate) fn apply_agent_runtime_plan_update(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
update: &AgentRuntimePlanUpdate,
|
||||
) -> Result<bool, String> {
|
||||
let update = sanitize_agent_runtime_plan_update(update)?;
|
||||
let mut terminal_steps = std::collections::BTreeMap::new();
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
for step in &runtime.plan_steps {
|
||||
if matches!(
|
||||
step.status.as_str(),
|
||||
AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED
|
||||
) {
|
||||
terminal_steps.insert(step.title.clone(), step.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for step in &update.steps {
|
||||
let Some(existing) = terminal_steps.get(&step.step) else {
|
||||
continue;
|
||||
};
|
||||
if existing.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED
|
||||
&& step.status != AGENT_RUNTIME_PLAN_STATUS_COMPLETED
|
||||
{
|
||||
return Err("Agent 结构化计划不能让已完成步骤回退".to_string());
|
||||
}
|
||||
if existing.status == AGENT_RUNTIME_PLAN_STATUS_FAILED {
|
||||
return Err("Agent 结构化计划不能改写已失败步骤".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let incoming_titles = update
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| step.step.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let mut merged = runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.filter(|step| {
|
||||
matches!(
|
||||
step.status.as_str(),
|
||||
AGENT_RUNTIME_PLAN_STATUS_COMPLETED | AGENT_RUNTIME_PLAN_STATUS_FAILED
|
||||
) && !incoming_titles.contains(step.title.as_str())
|
||||
})
|
||||
.map(|step| (step.title.clone(), step.status.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
merged.extend(update.steps.iter().map(|step| {
|
||||
let status = terminal_steps
|
||||
.get(&step.step)
|
||||
.map(|existing| existing.status.clone())
|
||||
.unwrap_or_else(|| step.status.clone());
|
||||
(step.step.clone(), status)
|
||||
}));
|
||||
if merged.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT {
|
||||
return Err(format!(
|
||||
"Agent 结构化计划保留终态步骤后超过 {AGENT_RUNTIME_PLAN_STEP_LIMIT} 步上限"
|
||||
));
|
||||
}
|
||||
|
||||
let unchanged = agent_runtime_has_structured_plan(runtime)
|
||||
&& runtime.plan_explanation == update.explanation
|
||||
&& runtime.plan_steps.len() == merged.len()
|
||||
&& runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.zip(merged.iter())
|
||||
.all(|(existing, (title, status))| {
|
||||
existing.title == *title && existing.status == *status
|
||||
});
|
||||
if unchanged {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let now = unix_timestamp();
|
||||
let previous_steps = runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.map(|step| (step.title.clone(), step.clone()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
runtime.plan_steps = merged
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (title, status))| {
|
||||
let previous = previous_steps.get(&title);
|
||||
let unchanged_status = previous.is_some_and(|step| step.status == status);
|
||||
AgentRuntimePlanStep {
|
||||
index: index as u32,
|
||||
title,
|
||||
status,
|
||||
detail: previous.and_then(|step| step.detail.clone()),
|
||||
updated_at: if unchanged_status {
|
||||
previous.map(|step| step.updated_at).unwrap_or(now)
|
||||
} else {
|
||||
now
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
runtime.plan = runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.map(|step| step.title.clone())
|
||||
.collect();
|
||||
runtime.active_plan_step_index = runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.find(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS)
|
||||
.map(|step| step.index);
|
||||
runtime.plan_explanation = update.explanation;
|
||||
runtime.plan_revision = runtime.plan_revision.saturating_add(1).max(1);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn update_agent_runtime_plan_steps(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
plan: Vec<String>,
|
||||
) {
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
return;
|
||||
}
|
||||
runtime.plan = plan
|
||||
.into_iter()
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
.take(AGENT_RUNTIME_PLAN_STEP_LIMIT)
|
||||
.map(|item| sanitize_agent_runtime_text(&item, 180))
|
||||
.collect();
|
||||
runtime.plan_steps = runtime
|
||||
.plan
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, title)| AgentRuntimePlanStep {
|
||||
index: index as u32,
|
||||
title: title.clone(),
|
||||
status: if index == 0 { "active" } else { "pending" }.to_string(),
|
||||
detail: None,
|
||||
updated_at: unix_timestamp(),
|
||||
})
|
||||
.collect();
|
||||
runtime.active_plan_step_index = if runtime.plan_steps.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn activate_agent_runtime_plan_step(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
step_index: usize,
|
||||
detail: &str,
|
||||
) {
|
||||
if agent_runtime_has_structured_plan(runtime) || runtime.plan_steps.is_empty() {
|
||||
return;
|
||||
}
|
||||
let target_index = step_index.min(runtime.plan_steps.len().saturating_sub(1));
|
||||
let now = unix_timestamp();
|
||||
for step in runtime.plan_steps.iter_mut() {
|
||||
if step.index as usize == target_index {
|
||||
step.status = "active".to_string();
|
||||
step.detail = Some(sanitize_agent_runtime_text(detail, 180));
|
||||
step.updated_at = now;
|
||||
} else if step.status == "active" {
|
||||
step.status = "pending".to_string();
|
||||
step.updated_at = now;
|
||||
}
|
||||
}
|
||||
runtime.active_plan_step_index = Some(target_index as u32);
|
||||
}
|
||||
|
||||
pub(crate) fn complete_agent_runtime_active_plan_step(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
status: &str,
|
||||
detail: &str,
|
||||
) {
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
return;
|
||||
}
|
||||
let Some(active_index) = runtime.active_plan_step_index else {
|
||||
return;
|
||||
};
|
||||
let status = match status {
|
||||
"failed" => "failed",
|
||||
"waiting-for-confirmation" => "waiting-for-confirmation",
|
||||
_ => "completed",
|
||||
};
|
||||
let now = unix_timestamp();
|
||||
for step in runtime.plan_steps.iter_mut() {
|
||||
if step.index == active_index {
|
||||
step.status = status.to_string();
|
||||
step.detail = Some(sanitize_agent_runtime_text(detail, 220));
|
||||
step.updated_at = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
runtime.active_plan_step_index = None;
|
||||
}
|
||||
|
||||
pub(crate) fn retry_agent_runtime_active_plan_step(runtime: &mut AgentRuntimeState, detail: &str) {
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
return;
|
||||
}
|
||||
let Some(active_index) = runtime.active_plan_step_index else {
|
||||
return;
|
||||
};
|
||||
let now = unix_timestamp();
|
||||
for step in runtime.plan_steps.iter_mut() {
|
||||
if step.index == active_index {
|
||||
step.status = "pending".to_string();
|
||||
step.detail = Some(sanitize_agent_runtime_text(detail, 220));
|
||||
step.updated_at = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
runtime.active_plan_step_index = None;
|
||||
}
|
||||
|
||||
pub(crate) fn activate_agent_runtime_response_plan_step(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
detail: &str,
|
||||
) {
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
return;
|
||||
}
|
||||
let target_index = runtime
|
||||
.plan_steps
|
||||
.iter()
|
||||
.find(|step| step.status == "pending" || step.status == "active")
|
||||
.map(|step| step.index as usize);
|
||||
if let Some(target_index) = target_index {
|
||||
activate_agent_runtime_plan_step(runtime, target_index, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if runtime.plan_steps.len() >= AGENT_RUNTIME_PLAN_STEP_LIMIT {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = runtime.plan_steps.len() as u32;
|
||||
let title = "生成最终回复".to_string();
|
||||
runtime.plan.push(title.clone());
|
||||
runtime.plan_steps.push(AgentRuntimePlanStep {
|
||||
index,
|
||||
title,
|
||||
status: "active".to_string(),
|
||||
detail: Some(sanitize_agent_runtime_text(detail, 180)),
|
||||
updated_at: unix_timestamp(),
|
||||
});
|
||||
runtime.active_plan_step_index = Some(index);
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn fail_agent_runtime_remaining_plan_steps(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
detail: &str,
|
||||
) {
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
return;
|
||||
}
|
||||
if !agent_runtime_has_structured_plan(runtime) && runtime.active_plan_step_index.is_some() {
|
||||
complete_agent_runtime_active_plan_step(runtime, "failed", detail);
|
||||
return;
|
||||
}
|
||||
|
||||
let now = unix_timestamp();
|
||||
let detail = sanitize_agent_runtime_text(detail, 220);
|
||||
let mut marked_failed = false;
|
||||
for step in runtime.plan_steps.iter_mut() {
|
||||
if matches!(
|
||||
step.status.as_str(),
|
||||
"pending" | "active" | "in_progress" | "waiting-for-confirmation"
|
||||
) {
|
||||
step.status = "failed".to_string();
|
||||
step.detail = Some(detail.clone());
|
||||
step.updated_at = now;
|
||||
marked_failed = true;
|
||||
}
|
||||
}
|
||||
if !marked_failed {
|
||||
if let Some(step) = runtime
|
||||
.plan_steps
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|step| step.status != "failed")
|
||||
{
|
||||
step.status = "failed".to_string();
|
||||
step.detail = Some(detail);
|
||||
step.updated_at = now;
|
||||
}
|
||||
}
|
||||
runtime.active_plan_step_index = None;
|
||||
}
|
||||
|
||||
pub(crate) fn complete_agent_runtime_remaining_plan_steps(
|
||||
runtime: &mut AgentRuntimeState,
|
||||
detail: &str,
|
||||
) {
|
||||
if agent_runtime_has_structured_plan(runtime) {
|
||||
return;
|
||||
}
|
||||
let now = unix_timestamp();
|
||||
for step in runtime.plan_steps.iter_mut() {
|
||||
if step.status != "failed" {
|
||||
step.status = "completed".to_string();
|
||||
if step
|
||||
.detail
|
||||
.as_deref()
|
||||
.map_or(true, |value| value.trim().is_empty())
|
||||
{
|
||||
step.detail = Some(sanitize_agent_runtime_text(detail, 180));
|
||||
}
|
||||
step.updated_at = now;
|
||||
}
|
||||
}
|
||||
runtime.active_plan_step_index = None;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn parse_game_creator_agent_tool_plan_response(
|
||||
content: &str,
|
||||
) -> Result<AgentRuntimeToolPlan, String> {
|
||||
parse_game_creator_agent_tool_plan_response_classified(content)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn parse_game_creator_agent_tool_plan_response_classified(
|
||||
content: &str,
|
||||
) -> Result<AgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
let stripped = strip_llm_thinking_blocks(content);
|
||||
let payload = extract_json_payload(stripped.as_str()).ok_or_else(|| {
|
||||
AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::ResponseShape,
|
||||
"Agent 工具计划协议错误:未返回完整 JSON 对象",
|
||||
)
|
||||
})?;
|
||||
parse_game_creator_agent_tool_plan_payload(payload, false)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_game_creator_agent_tool_plan_llm_response(
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
) -> Result<ParsedAgentRuntimeToolPlan, String> {
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog(
|
||||
response,
|
||||
&GameCreatorMcpCatalog {
|
||||
fingerprint: String::new(),
|
||||
servers: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog(
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<ParsedAgentRuntimeToolPlan, String> {
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(response, mcp_catalog)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<ParsedAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
if response.tool_calls.is_empty() {
|
||||
return parse_game_creator_agent_tool_plan_response_classified(response.text.as_str()).map(
|
||||
|plan| ParsedAgentRuntimeToolPlan {
|
||||
plan,
|
||||
protocol: "text_json",
|
||||
call_id: None,
|
||||
function_name: None,
|
||||
call_ids: Vec::new(),
|
||||
function_names: Vec::new(),
|
||||
normalization_kinds: Vec::new(),
|
||||
normalization_count: 0,
|
||||
normalized_text_chars: 0,
|
||||
normalized_text_sha256: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut text_normalization =
|
||||
normalize_game_creator_agent_tool_plan_function_text(&response.text);
|
||||
if !text_normalization.visible_text.is_empty() {
|
||||
if !text_normalization.invalid_thinking_wrapper
|
||||
&& response.tool_calls.iter().all(|call| {
|
||||
call.name != AGENT_RUNTIME_RESPOND_FUNCTION_NAME
|
||||
&& call.name != AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME
|
||||
})
|
||||
{
|
||||
text_normalization.normalize_planner_commentary(&response.text);
|
||||
} else {
|
||||
return Err(AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::ResponseShape,
|
||||
"Agent 原生工具协议错误:当前 function calls 响应不能同时携带普通文本正文(未闭合 thinking、最终回复或 legacy wrapper)",
|
||||
));
|
||||
}
|
||||
}
|
||||
if response.tool_calls.len() == 1
|
||||
&& response.tool_calls[0].name == AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME
|
||||
{
|
||||
let call = &response.tool_calls[0];
|
||||
let plan = parse_game_creator_agent_tool_plan_payload(call.arguments.as_str(), true)
|
||||
.map_err(|error| {
|
||||
AgentRuntimeToolPlanProtocolError::new(
|
||||
error.kind(),
|
||||
format!("{error};function arguments 解析失败"),
|
||||
)
|
||||
})?;
|
||||
return Ok(ParsedAgentRuntimeToolPlan {
|
||||
plan,
|
||||
protocol: "native_function",
|
||||
call_id: Some(call.id.clone()),
|
||||
function_name: Some(call.name.clone()),
|
||||
call_ids: vec![call.id.clone()],
|
||||
function_names: vec![call.name.clone()],
|
||||
normalization_kinds: text_normalization.kinds,
|
||||
normalization_count: text_normalization.count,
|
||||
normalized_text_chars: text_normalization.source_text_chars,
|
||||
normalized_text_sha256: text_normalization.source_text_sha256,
|
||||
});
|
||||
}
|
||||
let native = parse_agent_runtime_native_tool_calls(&response.tool_calls, mcp_catalog)?;
|
||||
let plan = normalize_game_creator_agent_tool_plan(native.plan)?;
|
||||
Ok(ParsedAgentRuntimeToolPlan {
|
||||
plan,
|
||||
protocol: "native_runtime_tools",
|
||||
call_id: native.call_ids.first().cloned(),
|
||||
function_name: native.function_names.first().cloned(),
|
||||
call_ids: native.call_ids,
|
||||
function_names: native.function_names,
|
||||
normalization_kinds: text_normalization.kinds,
|
||||
normalization_count: text_normalization.count,
|
||||
normalized_text_chars: text_normalization.source_text_chars,
|
||||
normalized_text_sha256: text_normalization.source_text_sha256,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::agent) struct AgentRuntimeToolPlanTextNormalization {
|
||||
pub(in crate::agent) visible_text: String,
|
||||
pub(in crate::agent) kinds: Vec<&'static str>,
|
||||
pub(in crate::agent) count: usize,
|
||||
pub(in crate::agent) source_text_chars: usize,
|
||||
pub(in crate::agent) source_text_sha256: Option<String>,
|
||||
pub(in crate::agent) invalid_thinking_wrapper: bool,
|
||||
}
|
||||
|
||||
impl AgentRuntimeToolPlanTextNormalization {
|
||||
fn normalize_planner_commentary(&mut self, source: &str) {
|
||||
if self.visible_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.visible_text.clear();
|
||||
if !self.kinds.contains(&"planner-commentary") {
|
||||
self.kinds.push("planner-commentary");
|
||||
}
|
||||
self.count = self.count.saturating_add(1);
|
||||
if self.source_text_sha256.is_none() {
|
||||
self.source_text_chars = source.chars().count();
|
||||
self.source_text_sha256 = Some(format!("{:x}", Sha256::digest(source.as_bytes())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn normalize_game_creator_agent_tool_plan_function_text(
|
||||
content: &str,
|
||||
) -> AgentRuntimeToolPlanTextNormalization {
|
||||
const THINK_START: &str = "<think>";
|
||||
const THINK_END: &str = "</think>";
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return AgentRuntimeToolPlanTextNormalization::default();
|
||||
}
|
||||
let lower = content.to_ascii_lowercase();
|
||||
let mut output = String::new();
|
||||
let mut cursor = 0usize;
|
||||
let mut scan = 0usize;
|
||||
let mut depth = 0usize;
|
||||
let mut count = 0usize;
|
||||
let mut invalid_thinking_wrapper = false;
|
||||
loop {
|
||||
let next_start = lower[scan..].find(THINK_START).map(|index| scan + index);
|
||||
let next_end = lower[scan..].find(THINK_END).map(|index| scan + index);
|
||||
match (next_start, next_end) {
|
||||
(Some(start), Some(end)) if start < end => {
|
||||
if depth == 0 {
|
||||
output.push_str(&content[cursor..start]);
|
||||
}
|
||||
depth = depth.saturating_add(1);
|
||||
scan = start + THINK_START.len();
|
||||
}
|
||||
(Some(start), None) => {
|
||||
if depth == 0 {
|
||||
output.push_str(&content[cursor..start]);
|
||||
}
|
||||
depth = depth.saturating_add(1);
|
||||
scan = start + THINK_START.len();
|
||||
}
|
||||
(_, Some(end)) => {
|
||||
scan = end + THINK_END.len();
|
||||
if depth == 0 {
|
||||
invalid_thinking_wrapper = true;
|
||||
continue;
|
||||
}
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
cursor = scan;
|
||||
count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
(None, None) => break,
|
||||
}
|
||||
}
|
||||
if depth != 0 || invalid_thinking_wrapper {
|
||||
return AgentRuntimeToolPlanTextNormalization {
|
||||
visible_text: content.trim().to_string(),
|
||||
invalid_thinking_wrapper: true,
|
||||
..AgentRuntimeToolPlanTextNormalization::default()
|
||||
};
|
||||
}
|
||||
output.push_str(&content[cursor..]);
|
||||
if count == 0 {
|
||||
return AgentRuntimeToolPlanTextNormalization {
|
||||
visible_text: content.trim().to_string(),
|
||||
..AgentRuntimeToolPlanTextNormalization::default()
|
||||
};
|
||||
}
|
||||
AgentRuntimeToolPlanTextNormalization {
|
||||
visible_text: output.trim().to_string(),
|
||||
kinds: vec!["complete-think-block"],
|
||||
count,
|
||||
source_text_chars: content.chars().count(),
|
||||
source_text_sha256: Some(format!("{:x}", Sha256::digest(content.as_bytes()))),
|
||||
invalid_thinking_wrapper: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_tool_plan_response_preview(
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
max_chars: usize,
|
||||
) -> String {
|
||||
if response.tool_calls.is_empty() {
|
||||
return sanitize_agent_runtime_text(response.text.as_str(), max_chars);
|
||||
}
|
||||
let serialized = serde_json::to_string(&response.tool_calls)
|
||||
.unwrap_or_else(|_| "无法序列化 function calls".to_string());
|
||||
sanitize_agent_runtime_text(&serialized, max_chars)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn parse_game_creator_agent_tool_plan_payload(
|
||||
payload: &str,
|
||||
require_plan_update_field: bool,
|
||||
) -> Result<AgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
validate_agent_runtime_protocol_json(payload, "解析 Agent 工具计划失败")?;
|
||||
let value = serde_json::from_str::<serde_json::Value>(payload).map_err(|error| {
|
||||
AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsJson,
|
||||
format!("解析 Agent 工具计划失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let plan = serde_json::from_str::<AgentRuntimeToolPlan>(payload).map_err(|error| {
|
||||
AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||||
format!("解析 Agent 工具计划 schema 失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
if require_plan_update_field {
|
||||
if !value
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("planUpdate"))
|
||||
{
|
||||
return Err(AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||||
"Agent 工具计划协议错误:native function arguments 必须显式包含 planUpdate",
|
||||
));
|
||||
}
|
||||
}
|
||||
normalize_game_creator_agent_tool_plan(plan)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn normalize_game_creator_agent_tool_plan(
|
||||
mut plan: AgentRuntimeToolPlan,
|
||||
) -> Result<AgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
if plan.thinking_summary.trim().is_empty() {
|
||||
return Err(AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
|
||||
"Agent 工具计划协议错误:thinkingSummary 不能为空",
|
||||
));
|
||||
}
|
||||
if plan
|
||||
.actions
|
||||
.iter()
|
||||
.any(|action| action.tool.trim().is_empty())
|
||||
{
|
||||
return Err(AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
|
||||
"Agent 工具计划协议错误:action.tool 不能为空",
|
||||
));
|
||||
}
|
||||
plan.thinking_summary = truncate_agent_runtime_text(&plan.thinking_summary, 240);
|
||||
plan.plan_update = plan
|
||||
.plan_update
|
||||
.as_ref()
|
||||
.map(sanitize_agent_runtime_plan_update)
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
plan.plan = plan
|
||||
.plan
|
||||
.into_iter()
|
||||
.map(|item| truncate_agent_runtime_text(&item, 160))
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
.take(AGENT_RUNTIME_PLAN_STEP_LIMIT)
|
||||
.collect();
|
||||
plan.response = truncate_agent_runtime_text(&plan.response, 1_200);
|
||||
validate_game_creator_agent_user_input_tool_plan(&plan).map_err(|error| {
|
||||
AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
Ok(plan)
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
|
||||
vec![
|
||||
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
|
||||
"memory.read",
|
||||
"memory.write",
|
||||
"conversation.read",
|
||||
"asset.list",
|
||||
"project.index",
|
||||
"project.search",
|
||||
"project.verify",
|
||||
"project.checkpoint",
|
||||
"project.restore",
|
||||
"project.diff",
|
||||
"git.inspect",
|
||||
"project.git_commit",
|
||||
"project.patchset",
|
||||
"file.list",
|
||||
"file.read",
|
||||
"file.write",
|
||||
"file.patch",
|
||||
"file.delete",
|
||||
"task.list",
|
||||
"task.create",
|
||||
"task.update",
|
||||
"command.exec",
|
||||
"command.output_read",
|
||||
"command.start",
|
||||
"command.poll",
|
||||
"command.stdin",
|
||||
"command.terminate",
|
||||
"command.run_limited",
|
||||
"preview.start",
|
||||
"preview.validate",
|
||||
"image.inspect",
|
||||
"canvas.asset_generate",
|
||||
"blackboard.write",
|
||||
"agent.message",
|
||||
"agent.delegate",
|
||||
"agent.spawn_isolated",
|
||||
"agent.schedule_ready",
|
||||
"agent.action_history",
|
||||
"agent.run_status",
|
||||
GAME_CREATOR_MCP_CALL_TOOL,
|
||||
]
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<AgentRuntimeToolPolicySnapshot, String> {
|
||||
let policy = agent_runtime_effective_tool_policy_at(root, agent_id)?;
|
||||
let isolated = agent_id.trim().starts_with("child-");
|
||||
let mut auto_tools = Vec::new();
|
||||
let mut confirm_tools = Vec::new();
|
||||
let mut denied_tools = Vec::new();
|
||||
for tool in agent_runtime_executable_tools() {
|
||||
if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
|
||||
denied_tools.push(tool.to_string());
|
||||
continue;
|
||||
}
|
||||
if tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
|
||||
auto_tools.push(tool.to_string());
|
||||
continue;
|
||||
}
|
||||
let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else {
|
||||
continue;
|
||||
};
|
||||
if policy
|
||||
.denied_commands
|
||||
.iter()
|
||||
.any(|command| command == command_id)
|
||||
{
|
||||
denied_tools.push(tool.to_string());
|
||||
} else if policy
|
||||
.confirm_commands
|
||||
.iter()
|
||||
.any(|command| command == command_id)
|
||||
{
|
||||
confirm_tools.push(tool.to_string());
|
||||
} else {
|
||||
auto_tools.push(tool.to_string());
|
||||
}
|
||||
}
|
||||
Ok(AgentRuntimeToolPolicySnapshot {
|
||||
run_profile: default_agent_runtime_run_profile(),
|
||||
run_profile_binding_fingerprint: String::new(),
|
||||
allowed_tools: agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
auto_tools,
|
||||
confirm_tools,
|
||||
denied_tools,
|
||||
updated_at: unix_timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
stored_profile: Option<&str>,
|
||||
stored_binding_fingerprint: Option<&str>,
|
||||
) -> Result<AgentRuntimeToolPolicySnapshot, String> {
|
||||
let mut snapshot = agent_runtime_tool_policy_snapshot_at(root, agent_id)?;
|
||||
let (run_profile, binding_fingerprint) = agent_runtime_run_profile_identity_at(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
stored_profile,
|
||||
stored_binding_fingerprint,
|
||||
)?;
|
||||
snapshot.run_profile = run_profile.clone();
|
||||
snapshot.run_profile_binding_fingerprint = binding_fingerprint;
|
||||
if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
snapshot
|
||||
.auto_tools
|
||||
.retain(|tool| tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL);
|
||||
snapshot
|
||||
.confirm_tools
|
||||
.retain(|tool| tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL);
|
||||
if !snapshot
|
||||
.denied_tools
|
||||
.iter()
|
||||
.any(|tool| tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL)
|
||||
{
|
||||
snapshot
|
||||
.denied_tools
|
||||
.push(GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string());
|
||||
}
|
||||
for tool in agent_runtime_executable_tools() {
|
||||
let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else {
|
||||
continue;
|
||||
};
|
||||
if !AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS.contains(&command_id)
|
||||
|| snapshot.denied_tools.iter().any(|denied| denied == tool)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
snapshot.confirm_tools.retain(|candidate| candidate != tool);
|
||||
if !snapshot
|
||||
.auto_tools
|
||||
.iter()
|
||||
.any(|candidate| candidate == tool)
|
||||
{
|
||||
snapshot.auto_tools.push(tool.to_string());
|
||||
}
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user