9aa6f5efea
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m44s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m9s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 3m53s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m58s
Project CI / Repository checks (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 6m10s
Project CI / Backend tests (push) Successful in 7m7s
Project CI / AI game creator shell web tests (push) Successful in 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Reviewed-on: #350
720 lines
28 KiB
Rust
720 lines
28 KiB
Rust
use super::*;
|
|
use agent_runtime_core::{CapabilityDefinition, CapabilityRegistry};
|
|
|
|
const AGENT_INTERACTION_EXECUTE_TOOL: &str = "runtime_execute";
|
|
const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume";
|
|
const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location";
|
|
const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200;
|
|
const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction";
|
|
pub(crate) const AGENT_RUNTIME_STEER_DECISION_TOOL: &str = "runtime_steer_decision";
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum AgentInteractionToolKind {
|
|
Execute,
|
|
Resume,
|
|
ProjectLocation,
|
|
}
|
|
|
|
fn agent_interaction_tool_registry() -> Result<CapabilityRegistry<AgentInteractionToolKind>, String>
|
|
{
|
|
let empty_input_schema = || {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
"additionalProperties": false
|
|
})
|
|
};
|
|
CapabilityRegistry::try_new([
|
|
CapabilityDefinition::try_new(
|
|
AGENT_INTERACTION_EXECUTE_TOOL,
|
|
AGENT_INTERACTION_EXECUTE_TOOL,
|
|
"仅当用户明确要求执行需要读取、修改、生成、测试或调用项目工具的工作时,提交用户原始消息给持久 Runtime。否定、假设、解释、咨询或需求仍不明确时不要调用。",
|
|
empty_input_schema(),
|
|
AgentInteractionToolKind::Execute,
|
|
)
|
|
.map_err(|error| format!("Agent interaction capability 无效:{error}"))?,
|
|
CapabilityDefinition::try_new(
|
|
AGENT_INTERACTION_RESUME_TOOL,
|
|
AGENT_INTERACTION_RESUME_TOOL,
|
|
"仅当用户明确要求继续或恢复当前 Session 中未完成的持久 Runtime 时调用。",
|
|
empty_input_schema(),
|
|
AgentInteractionToolKind::Resume,
|
|
)
|
|
.map_err(|error| format!("Agent interaction capability 无效:{error}"))?,
|
|
CapabilityDefinition::try_new(
|
|
AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
|
|
AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
|
|
"当用户询问当前项目目录或项目在哪里时调用;宿主会直接返回真实本地目录,不要猜测路径。",
|
|
empty_input_schema(),
|
|
AgentInteractionToolKind::ProjectLocation,
|
|
)
|
|
.map_err(|error| format!("Agent interaction capability 无效:{error}"))?,
|
|
])
|
|
.map_err(|error| format!("Agent interaction registry 无效:{error}"))
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum AgentInteractionAction {
|
|
Reply(String),
|
|
Execute,
|
|
Resume,
|
|
ProjectLocation,
|
|
}
|
|
|
|
impl AgentInteractionAction {
|
|
pub(crate) fn label(&self) -> &'static str {
|
|
match self {
|
|
Self::Reply(_) => "reply",
|
|
Self::Execute => "execute",
|
|
Self::Resume => "resume",
|
|
Self::ProjectLocation => "project_location",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields, tag = "action", rename_all = "snake_case")]
|
|
enum AgentInteractionTextEnvelope {
|
|
Reply { reply: String },
|
|
Execute,
|
|
Resume,
|
|
ProjectLocation,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
struct AgentRuntimeSteerDecisionArguments {
|
|
reply: String,
|
|
interrupt_current_provider: bool,
|
|
reason: String,
|
|
}
|
|
|
|
fn agent_runtime_steer_decision_function_tool() -> platform_llm::LlmFunctionTool {
|
|
platform_llm::LlmFunctionTool::new(
|
|
AGENT_RUNTIME_STEER_DECISION_TOOL,
|
|
"回复用户,并判断是否必须中断当前正在进行的 LLM Provider 请求。",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"required": ["reply", "interruptCurrentProvider", "reason"],
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"reply": {
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 2000
|
|
},
|
|
"interruptCurrentProvider": { "type": "boolean" },
|
|
"reason": {
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 500
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.with_strict(true)
|
|
}
|
|
|
|
fn build_agent_runtime_steer_decision_request(
|
|
root: &Path,
|
|
state: &AgentRuntimeState,
|
|
instruction: &str,
|
|
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
|
|
let (llm, config_path, context) = build_game_creator_role_agent_context_for_session(
|
|
root,
|
|
&state.agent_id,
|
|
Some(&state.session_id),
|
|
)?;
|
|
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
|
let runtime_summary = serde_json::json!({
|
|
"status": state.status,
|
|
"phase": state.phase,
|
|
"currentAction": state.current_action,
|
|
"waitingOn": state.waiting_on,
|
|
"nextStep": state.next_step,
|
|
"plan": state.plan,
|
|
"appliedSteerCursor": state.applied_steer_cursor,
|
|
});
|
|
let system = format!(
|
|
"{}\n\n你现在是项目总控的非终态对话判定层。一个制作 Run 正在执行,你必须先自然回答用户,再判断是否需要中断当前正在进行的 LLM Provider 请求。不要因为收到新消息就默认中断:状态询问、解释、鼓励、确认以及不冲突的补充应保持 interruptCurrentProvider=false;只有用户明确停止/改向,或新要求会让当前 LLM 继续生成明显过期方案时才设为 true。宿主工具和已经开始的外部动作不会被此判定强杀,它们会在安全边界完成。你的回复不能宣称整个 Run 已完成,也不能把这次回复当作终态。必须调用且只调用 runtime_steer_decision。",
|
|
game_creator_project_supervisor_chat_system_prompt()
|
|
);
|
|
let user = format!(
|
|
"项目上下文如下,只作为背景,不要逐字复述:\n{context}\n\n当前 Runtime 摘要:\n{}\n\n用户在制作过程中发来的消息:\n{}",
|
|
serde_json::to_string_pretty(&runtime_summary)
|
|
.map_err(|error| format!("序列化 steer Runtime 摘要失败:{error}"))?,
|
|
instruction.trim(),
|
|
);
|
|
let request = LlmRunRequest::single_turn(system, user)
|
|
.with_api_kind(api_kind)
|
|
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
|
|
.with_function_tools(vec![agent_runtime_steer_decision_function_tool()])
|
|
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
|
let request = apply_game_creator_llm_reasoning_effort(request, &llm)?;
|
|
Ok((llm, config_path, request))
|
|
}
|
|
|
|
fn parse_agent_runtime_steer_decision_response(
|
|
response: &platform_llm::LlmRunResponse,
|
|
) -> Result<AgentRuntimeSteerDecision, String> {
|
|
if response.tool_calls.len() != 1 {
|
|
return Err("Supervisor steer decision 必须返回一个 runtime_steer_decision".to_string());
|
|
}
|
|
let call = &response.tool_calls[0];
|
|
if call.name != AGENT_RUNTIME_STEER_DECISION_TOOL {
|
|
return Err(format!(
|
|
"Supervisor steer decision 返回未知工具:{}",
|
|
call.name
|
|
));
|
|
}
|
|
let arguments = serde_json::from_str::<AgentRuntimeSteerDecisionArguments>(&call.arguments)
|
|
.map_err(|error| format!("runtime_steer_decision 参数无效:{error}"))?;
|
|
let reply = arguments.reply.trim();
|
|
let reason = arguments.reason.trim();
|
|
if reply.is_empty() || reason.is_empty() {
|
|
return Err("runtime_steer_decision reply/reason 不能为空".to_string());
|
|
}
|
|
Ok(AgentRuntimeSteerDecision {
|
|
reply: reply.to_string(),
|
|
interrupt_current_provider: arguments.interrupt_current_provider,
|
|
reason: reason.to_string(),
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn decide_game_creator_agent_runtime_steer_at(
|
|
root: &Path,
|
|
state: &AgentRuntimeState,
|
|
steer_id: &str,
|
|
sequence: u64,
|
|
instruction: &str,
|
|
) -> Result<AgentRuntimeSteerDecision, String> {
|
|
if let Some(decision) = read_game_creator_agent_runtime_steer_decision_at(
|
|
root,
|
|
&state.agent_id,
|
|
&state.run_id,
|
|
steer_id,
|
|
)? {
|
|
return Ok(decision);
|
|
}
|
|
if state.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
|
|| state.parent_agent_id.is_some()
|
|
|| state.parent_run_id.is_some()
|
|
{
|
|
return Err("只有根 Project Supervisor 支持运行中非终态回复".to_string());
|
|
}
|
|
let (llm, config_path, request) =
|
|
build_agent_runtime_steer_decision_request(root, state, instruction)?;
|
|
let decision_snapshot = {
|
|
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
|
root,
|
|
"runtime.steer_decision.snapshot",
|
|
)?;
|
|
let mut snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
|
|
root,
|
|
&state.agent_id,
|
|
&state.session_id,
|
|
&state.run_id,
|
|
"steer-decision",
|
|
&format!("steer-{sequence}"),
|
|
state.applied_steer_cursor,
|
|
)?;
|
|
// The main Runtime Provider request must keep running while this
|
|
// independent LLM turn decides whether it is stale. A distinct node
|
|
// identity gives Codex app-server a separate process/thread gate.
|
|
snapshot.agent_id = format!("{}-steer-decision", state.agent_id);
|
|
snapshot.task_id = snapshot.agent_id.clone();
|
|
snapshot
|
|
};
|
|
let agent_mode =
|
|
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
|
|
let response = match agent_mode.as_str() {
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
|
request_game_creator_agent_codex_app_server(&decision_snapshot, &llm, request).await
|
|
}
|
|
GAME_CREATOR_AGENT_MODE_CODEX_CLI => request_game_creator_agent_codex_cli(request).await,
|
|
GAME_CREATOR_AGENT_MODE_PROVIDER => {
|
|
build_game_creator_agent_runtime_llm_client(&llm, &config_path)?
|
|
.run(request)
|
|
.await
|
|
}
|
|
_ => unreachable!("agent mode is normalized"),
|
|
}
|
|
.map_err(|error| format!("Supervisor steer decision 调用 LLM 失败:{error}"))?;
|
|
let decision = parse_agent_runtime_steer_decision_response(&response)?;
|
|
persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
|
root, state, steer_id, sequence, decision,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool {
|
|
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
|
return true;
|
|
}
|
|
game_creator_agent_role_definition(agent_id).is_some_and(|(_group, role)| role.id == "director")
|
|
}
|
|
|
|
fn agent_interaction_function_tools() -> Result<Vec<platform_llm::LlmFunctionTool>, String> {
|
|
Ok(agent_interaction_tool_registry()?
|
|
.iter()
|
|
.map(|definition| {
|
|
platform_llm::LlmFunctionTool::new(
|
|
definition.function_name(),
|
|
definition.description(),
|
|
definition.input_schema().clone(),
|
|
)
|
|
.with_strict(true)
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn agent_interaction_system_prompt(agent_id: &str) -> String {
|
|
let role_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
|
game_creator_project_supervisor_chat_system_prompt()
|
|
} else {
|
|
game_creator_role_agent_chat_system_prompt()
|
|
};
|
|
let protocol = "普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。";
|
|
format!(
|
|
"{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。"
|
|
)
|
|
}
|
|
|
|
fn build_agent_interaction_llm_request(
|
|
agent_id: &str,
|
|
prompt: &str,
|
|
context: &str,
|
|
llm: &GameCreatorLlmConfig,
|
|
) -> Result<LlmRunRequest, String> {
|
|
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
|
let user_prompt = if context.trim().is_empty() {
|
|
format!("用户这轮输入:\n{prompt}")
|
|
} else {
|
|
format!(
|
|
"项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}"
|
|
)
|
|
};
|
|
let function_tools = agent_interaction_function_tools()?;
|
|
let request = LlmRunRequest::new(vec![
|
|
LlmMessage::system(agent_interaction_system_prompt(agent_id)),
|
|
LlmMessage::user(user_prompt),
|
|
])
|
|
.with_api_kind(api_kind)
|
|
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
|
|
.with_function_tools(function_tools)
|
|
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
|
|
apply_game_creator_llm_reasoning_effort(request, llm)
|
|
}
|
|
|
|
fn build_agent_interaction_request_for_session(
|
|
root: &Path,
|
|
agent_id: &str,
|
|
session_id: &str,
|
|
prompt: &str,
|
|
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
|
|
let prompt = prompt.trim();
|
|
if prompt.is_empty() {
|
|
return Err("交互内容不能为空".to_string());
|
|
}
|
|
if !game_creator_agent_uses_interaction_kernel(agent_id) {
|
|
return Err(format!("Agent 不启用交互决策层:{agent_id}"));
|
|
}
|
|
let (llm, config_path, context) =
|
|
build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?;
|
|
let request = build_agent_interaction_llm_request(agent_id, prompt, &context, &llm)?;
|
|
Ok((llm, config_path, request))
|
|
}
|
|
|
|
pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at<F>(
|
|
root: &Path,
|
|
agent_id: &str,
|
|
session_id: &str,
|
|
prompt: &str,
|
|
mut on_delta: F,
|
|
) -> Result<AgentInteractionAction, String>
|
|
where
|
|
F: FnMut(&platform_llm::LlmStreamDelta),
|
|
{
|
|
let (llm, config_path, request) =
|
|
build_agent_interaction_request_for_session(root, agent_id, session_id, prompt)?;
|
|
let api_kind = request.api_kind;
|
|
let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?;
|
|
let llm_provider = client.config().provider();
|
|
let request = platform_llm::provider_request_from_llm_request("agent-interaction", request)
|
|
.map_err(|error| format!("{config_path} Agent interaction Provider 请求无效:{error}"))?;
|
|
let (registry, target) = platform_llm::build_platform_llm_provider_registry_for_api_kind(
|
|
AGENT_INTERACTION_PROVIDER_INSTANCE_ID,
|
|
client,
|
|
api_kind,
|
|
)
|
|
.map_err(|error| format!("{config_path} Agent interaction Provider 注册失败:{error}"))?;
|
|
let response = if llm.stream {
|
|
let fallback_request = request.clone();
|
|
let sink = AgentInteractionProviderStreamSink {
|
|
on_delta: &mut on_delta,
|
|
};
|
|
match registry.stream(&target, request, Box::new(sink)).await {
|
|
Ok(response) => response,
|
|
Err(error)
|
|
if matches!(
|
|
error.kind(),
|
|
agent_runtime_core::ProviderErrorKind::StreamUnavailable
|
|
| agent_runtime_core::ProviderErrorKind::EmptyResponse
|
|
| agent_runtime_core::ProviderErrorKind::Deserialize
|
|
) =>
|
|
{
|
|
registry
|
|
.invoke(&target, fallback_request)
|
|
.await
|
|
.map_err(|fallback_error| {
|
|
format!(
|
|
"{config_path} Agent interaction 流式协议不可用且普通请求回退失败:流式错误:{error};普通请求错误:{fallback_error}"
|
|
)
|
|
})?
|
|
}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"{config_path} Agent interaction 调用 LLM 失败:{error}"
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
registry
|
|
.invoke(&target, request)
|
|
.await
|
|
.map_err(|error| format!("{config_path} Agent interaction 调用 LLM 失败:{error}"))?
|
|
};
|
|
let response = platform_llm::llm_response_from_provider_response(llm_provider, response)
|
|
.map_err(|error| format!("{config_path} Agent interaction Provider 响应无效:{error}"))?;
|
|
parse_agent_interaction_response(&response)
|
|
}
|
|
|
|
struct AgentInteractionProviderStreamSink<'a, F> {
|
|
on_delta: &'a mut F,
|
|
}
|
|
|
|
impl<F> agent_runtime_core::ProviderStreamSink for AgentInteractionProviderStreamSink<'_, F>
|
|
where
|
|
F: FnMut(&platform_llm::LlmStreamDelta),
|
|
{
|
|
fn emit(
|
|
&mut self,
|
|
event: agent_runtime_core::ProviderStreamEvent,
|
|
) -> Result<(), agent_runtime_core::ProviderError> {
|
|
if let agent_runtime_core::ProviderStreamEvent::TextDelta {
|
|
accumulated_text,
|
|
delta_text,
|
|
finish_reason,
|
|
} = event
|
|
{
|
|
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
|
accumulated_text,
|
|
delta_text,
|
|
accumulated_reasoning: String::new(),
|
|
reasoning_delta: String::new(),
|
|
finish_reason,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn parse_agent_interaction_response(
|
|
response: &platform_llm::LlmRunResponse,
|
|
) -> Result<AgentInteractionAction, String> {
|
|
if response.tool_calls.len() > 1 {
|
|
return Err("Agent interaction 一轮最多只能选择一个宿主工具".to_string());
|
|
}
|
|
if let Some(call) = response.tool_calls.first() {
|
|
let registry = agent_interaction_tool_registry()?;
|
|
let definition = registry
|
|
.get_by_function_name(&call.name)
|
|
.ok_or_else(|| format!("Agent interaction 返回未知工具:{}", call.name))?;
|
|
return match definition.dispatch() {
|
|
AgentInteractionToolKind::Execute => {
|
|
let arguments = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(
|
|
call.arguments.as_str(),
|
|
)
|
|
.map_err(|error| format!("runtime_execute 参数无效:{error}"))?;
|
|
if !arguments.is_empty() {
|
|
return Err("runtime_execute 不接受参数".to_string());
|
|
}
|
|
Ok(AgentInteractionAction::Execute)
|
|
}
|
|
AgentInteractionToolKind::Resume => {
|
|
validate_empty_interaction_tool_arguments(call)?;
|
|
Ok(AgentInteractionAction::Resume)
|
|
}
|
|
AgentInteractionToolKind::ProjectLocation => {
|
|
validate_empty_interaction_tool_arguments(call)?;
|
|
Ok(AgentInteractionAction::ProjectLocation)
|
|
}
|
|
};
|
|
}
|
|
|
|
let text = strip_llm_thinking_blocks(response.text.as_str());
|
|
if let Some(payload) = extract_json_payload(text.as_str()) {
|
|
if let Ok(envelope) = serde_json::from_str::<AgentInteractionTextEnvelope>(payload) {
|
|
return match envelope {
|
|
AgentInteractionTextEnvelope::Reply { reply } => {
|
|
let reply = reply.trim();
|
|
if reply.is_empty() {
|
|
Err("Agent interaction.reply 不能为空".to_string())
|
|
} else {
|
|
Ok(AgentInteractionAction::Reply(reply.to_string()))
|
|
}
|
|
}
|
|
AgentInteractionTextEnvelope::Execute => Ok(AgentInteractionAction::Execute),
|
|
AgentInteractionTextEnvelope::Resume => Ok(AgentInteractionAction::Resume),
|
|
AgentInteractionTextEnvelope::ProjectLocation => {
|
|
Ok(AgentInteractionAction::ProjectLocation)
|
|
}
|
|
};
|
|
}
|
|
}
|
|
if text.is_empty() {
|
|
return Err("Agent interaction 未返回回复或工具调用".to_string());
|
|
}
|
|
Ok(AgentInteractionAction::Reply(text))
|
|
}
|
|
|
|
fn validate_empty_interaction_tool_arguments(
|
|
call: &platform_llm::LlmToolCall,
|
|
) -> Result<(), String> {
|
|
let arguments =
|
|
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&call.arguments)
|
|
.map_err(|error| format!("{} 参数无效:{error}", call.name))?;
|
|
if !arguments.is_empty() {
|
|
return Err(format!("{} 不接受参数", call.name));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn response(
|
|
text: &str,
|
|
tool_calls: Vec<platform_llm::LlmToolCall>,
|
|
) -> platform_llm::LlmRunResponse {
|
|
platform_llm::LlmRunResponse {
|
|
provider: LlmProvider::OpenAiCompatible,
|
|
model: "interaction-test".to_string(),
|
|
text: text.to_string(),
|
|
reasoning: String::new(),
|
|
finish_reason: Some("stop".to_string()),
|
|
response_id: Some("interaction-response".to_string()),
|
|
usage: None,
|
|
tool_calls,
|
|
responses_output: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn tool_call(name: &str, arguments: &str) -> platform_llm::LlmToolCall {
|
|
platform_llm::LlmToolCall {
|
|
id: "call-interaction".to_string(),
|
|
name: name.to_string(),
|
|
arguments: arguments.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_kernel_is_limited_to_supervisor_and_department_directors() {
|
|
for agent_id in [
|
|
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
|
"design-director",
|
|
"balance-director",
|
|
"art-director",
|
|
"audio-director",
|
|
"code-director",
|
|
"publish-strategy",
|
|
] {
|
|
assert!(
|
|
game_creator_agent_uses_interaction_kernel(agent_id),
|
|
"{agent_id}"
|
|
);
|
|
}
|
|
for agent_id in ["design-foundation", "art-asset-plan", "code-prototype"] {
|
|
assert!(
|
|
!game_creator_agent_uses_interaction_kernel(agent_id),
|
|
"{agent_id}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_response_uses_natural_text_as_direct_reply() {
|
|
assert_eq!(
|
|
parse_agent_interaction_response(&response("我是项目总控。", Vec::new())).unwrap(),
|
|
AgentInteractionAction::Reply("我是项目总控。".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_response_dispatches_registered_execute_tool() {
|
|
assert_eq!(
|
|
parse_agent_interaction_response(&response(
|
|
"",
|
|
vec![tool_call(AGENT_INTERACTION_EXECUTE_TOOL, "{}",)],
|
|
))
|
|
.unwrap(),
|
|
AgentInteractionAction::Execute
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_response_supports_text_protocol_for_non_native_provider() {
|
|
assert_eq!(
|
|
parse_agent_interaction_response(&response(
|
|
r#"{"action":"project_location"}"#,
|
|
Vec::new(),
|
|
))
|
|
.unwrap(),
|
|
AgentInteractionAction::ProjectLocation
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_response_rejects_multiple_host_actions() {
|
|
let error = parse_agent_interaction_response(&response(
|
|
"",
|
|
vec![
|
|
tool_call(AGENT_INTERACTION_RESUME_TOOL, "{}"),
|
|
tool_call(AGENT_INTERACTION_PROJECT_LOCATION_TOOL, "{}"),
|
|
],
|
|
))
|
|
.expect_err("multiple actions must fail closed");
|
|
assert!(error.contains("最多只能选择一个"));
|
|
}
|
|
|
|
#[test]
|
|
fn steer_decision_replies_without_interrupting_status_questions() {
|
|
let decision = parse_agent_runtime_steer_decision_response(&response(
|
|
"",
|
|
vec![tool_call(
|
|
AGENT_RUNTIME_STEER_DECISION_TOOL,
|
|
r#"{"reply":"我正在完成玩法实现,当前任务会继续。","interruptCurrentProvider":false,"reason":"这是状态询问,不会使当前方案过期。"}"#,
|
|
)],
|
|
))
|
|
.expect("parse non-interrupting steer decision");
|
|
assert_eq!(
|
|
decision,
|
|
AgentRuntimeSteerDecision {
|
|
reply: "我正在完成玩法实现,当前任务会继续。".to_string(),
|
|
interrupt_current_provider: false,
|
|
reason: "这是状态询问,不会使当前方案过期。".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn steer_decision_can_request_safe_interrupt_for_conflicting_change() {
|
|
let decision = parse_agent_runtime_steer_decision_response(&response(
|
|
"",
|
|
vec![tool_call(
|
|
AGENT_RUNTIME_STEER_DECISION_TOOL,
|
|
r#"{"reply":"明白,我会停止旧方向并改成回合制。","interruptCurrentProvider":true,"reason":"用户明确改向,旧 Provider 仍在生成过期方案。"}"#,
|
|
)],
|
|
))
|
|
.expect("parse interrupting steer decision");
|
|
assert!(decision.interrupt_current_provider);
|
|
assert!(decision.reply.contains("改成回合制"));
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_registry_derives_unique_strict_function_tools() {
|
|
let registry = agent_interaction_tool_registry().expect("interaction registry");
|
|
let tools = agent_interaction_function_tools().expect("interaction tools");
|
|
assert_eq!(tools.len(), registry.len());
|
|
let names = tools
|
|
.iter()
|
|
.map(|tool| tool.name.as_str())
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
assert_eq!(names.len(), tools.len());
|
|
assert!(tools.iter().all(|tool| tool.strict));
|
|
assert!(tools.iter().all(|tool| {
|
|
tool.parameters["additionalProperties"] == serde_json::json!(false)
|
|
&& tool.parameters["properties"] == serde_json::json!({})
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_request_crosses_neutral_provider_contract_without_shape_drift() {
|
|
let mut llm = GameCreatorLlmConfig::default();
|
|
llm.api_kind = "openai_responses".to_string();
|
|
llm.reasoning_effort = "max".to_string();
|
|
let request = build_agent_interaction_llm_request(
|
|
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
|
"制作一个三消经营游戏",
|
|
"",
|
|
&llm,
|
|
)
|
|
.expect("build interaction request");
|
|
let request = platform_llm::provider_request_from_llm_request("agent-interaction", request)
|
|
.expect("neutral request");
|
|
assert_eq!(
|
|
request.max_output_tokens(),
|
|
Some(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
|
|
);
|
|
assert_eq!(request.tools().len(), 3);
|
|
assert_eq!(
|
|
request.reasoning_effort(),
|
|
Some(agent_runtime_core::ProviderReasoningEffort::Max)
|
|
);
|
|
assert!(request.tools().iter().all(|tool| tool.strict()));
|
|
assert_eq!(
|
|
request.tool_choice(),
|
|
&agent_runtime_core::ProviderToolChoice::Auto
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_request_propagates_invalid_reasoning_effort() {
|
|
let mut llm = GameCreatorLlmConfig::default();
|
|
llm.reasoning_effort = "maximum".to_string();
|
|
let error = build_agent_interaction_llm_request(
|
|
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
|
"制作一个三消经营游戏",
|
|
"",
|
|
&llm,
|
|
)
|
|
.expect_err("invalid reasoning effort must fail before Provider request");
|
|
assert!(error.contains("reasoning_effort"));
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_stream_sink_preserves_accumulation_delta_and_finish_reason() {
|
|
let mut observed = Vec::new();
|
|
let mut callback = |delta: &platform_llm::LlmStreamDelta| observed.push(delta.clone());
|
|
let mut sink = AgentInteractionProviderStreamSink {
|
|
on_delta: &mut callback,
|
|
};
|
|
agent_runtime_core::ProviderStreamSink::emit(
|
|
&mut sink,
|
|
agent_runtime_core::ProviderStreamEvent::TextDelta {
|
|
accumulated_text: "完成".to_string(),
|
|
delta_text: "成".to_string(),
|
|
finish_reason: Some("stop".to_string()),
|
|
},
|
|
)
|
|
.expect("emit");
|
|
assert_eq!(observed.len(), 1);
|
|
assert_eq!(observed[0].accumulated_text, "完成");
|
|
assert_eq!(observed[0].delta_text, "成");
|
|
assert_eq!(observed[0].finish_reason.as_deref(), Some("stop"));
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_response_rejects_arguments_for_host_selected_action() {
|
|
let error = parse_agent_interaction_response(&response(
|
|
"",
|
|
vec![tool_call(
|
|
AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
|
|
r#"{"path":"/tmp"}"#,
|
|
)],
|
|
))
|
|
.expect_err("host facts must not accept model-selected arguments");
|
|
assert!(error.contains("不接受参数"));
|
|
}
|
|
}
|