Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs
T
lhk229 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
解决platform-llm和策划agent reasoning问题,并修复若干bug (#350)
Reviewed-on: #350
2026-09-15 00:50:36 +08:00

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("不接受参数"));
}
}