5822b64d7c
接入 Godot 原生桥、受控执行、Runner 回执与编辑器操作指南 保留主分支 Cocos 和 Unity 跨工程能力及外置提示词结构 解决插件生命周期、工具目录、前端启动和文档合并冲突
2193 lines
90 KiB
Rust
2193 lines
90 KiB
Rust
use std::collections::{BTreeSet, HashSet};
|
||
use std::fmt;
|
||
use std::sync::OnceLock;
|
||
|
||
use agent_runtime_core::{CapabilityDefinition, CapabilityRegistry};
|
||
use platform_llm::{LlmFunctionTool, LlmToolCall};
|
||
use serde::de::{DeserializeOwned, Error as _, MapAccess, SeqAccess, Visitor};
|
||
use serde::Deserialize;
|
||
use serde_json::{json, Value};
|
||
|
||
use crate::agent::{
|
||
agent_runtime_native_executable_tools, AgentRuntimePlanUpdate, AgentRuntimeToolAction,
|
||
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
|
||
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT,
|
||
};
|
||
#[cfg(test)]
|
||
use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
|
||
|
||
pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan";
|
||
pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user";
|
||
const AGENT_RUNTIME_NATIVE_TOOL_PREFIX: &str = "runtime_tool_";
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub(crate) enum AgentRuntimeToolPlanProtocolErrorKind {
|
||
ResponseShape,
|
||
CallIdentity,
|
||
UnknownFunction,
|
||
ArgumentsJson,
|
||
ArgumentsSchema,
|
||
BatchConstraint,
|
||
PlanSemantics,
|
||
}
|
||
|
||
impl AgentRuntimeToolPlanProtocolErrorKind {
|
||
pub(crate) fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::ResponseShape => "response-shape",
|
||
Self::CallIdentity => "call-identity",
|
||
Self::UnknownFunction => "unknown-function",
|
||
Self::ArgumentsJson => "arguments-json",
|
||
Self::ArgumentsSchema => "arguments-schema",
|
||
Self::BatchConstraint => "batch-constraint",
|
||
Self::PlanSemantics => "plan-semantics",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct AgentRuntimeToolPlanProtocolError {
|
||
kind: AgentRuntimeToolPlanProtocolErrorKind,
|
||
detail: String,
|
||
}
|
||
|
||
impl AgentRuntimeToolPlanProtocolError {
|
||
pub(crate) fn new(
|
||
kind: AgentRuntimeToolPlanProtocolErrorKind,
|
||
detail: impl Into<String>,
|
||
) -> Self {
|
||
Self {
|
||
kind,
|
||
detail: detail.into(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn kind(&self) -> AgentRuntimeToolPlanProtocolErrorKind {
|
||
self.kind
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for AgentRuntimeToolPlanProtocolError {
|
||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
formatter.write_str(&self.detail)
|
||
}
|
||
}
|
||
|
||
fn protocol_error(
|
||
kind: AgentRuntimeToolPlanProtocolErrorKind,
|
||
detail: impl Into<String>,
|
||
) -> AgentRuntimeToolPlanProtocolError {
|
||
AgentRuntimeToolPlanProtocolError::new(kind, detail)
|
||
}
|
||
|
||
struct DuplicateSafeJson;
|
||
|
||
impl<'de> Deserialize<'de> for DuplicateSafeJson {
|
||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
deserializer.deserialize_any(DuplicateSafeJsonVisitor)
|
||
}
|
||
}
|
||
|
||
struct DuplicateSafeJsonVisitor;
|
||
|
||
impl<'de> Visitor<'de> for DuplicateSafeJsonVisitor {
|
||
type Value = DuplicateSafeJson;
|
||
|
||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
formatter.write_str("不包含重复 object key 的 JSON value")
|
||
}
|
||
|
||
fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_none<E>(self) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_unit<E>(self) -> Result<Self::Value, E> {
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
DuplicateSafeJson::deserialize(deserializer)
|
||
}
|
||
|
||
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
|
||
where
|
||
A: SeqAccess<'de>,
|
||
{
|
||
while sequence.next_element::<DuplicateSafeJson>()?.is_some() {}
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
|
||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||
where
|
||
A: MapAccess<'de>,
|
||
{
|
||
let mut keys = HashSet::new();
|
||
while let Some(key) = map.next_key::<String>()? {
|
||
if !keys.insert(key.clone()) {
|
||
return Err(A::Error::custom(format!("重复 JSON object key:{key}")));
|
||
}
|
||
map.next_value::<DuplicateSafeJson>()?;
|
||
}
|
||
Ok(DuplicateSafeJson)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn validate_agent_runtime_protocol_json(
|
||
json: &str,
|
||
description: &str,
|
||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||
let mut deserializer = serde_json::Deserializer::from_str(json);
|
||
DuplicateSafeJson::deserialize(&mut deserializer)
|
||
.and_then(|_| deserializer.end())
|
||
.map_err(|error| {
|
||
let kind = match error.classify() {
|
||
serde_json::error::Category::Data => {
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema
|
||
}
|
||
serde_json::error::Category::Io
|
||
| serde_json::error::Category::Syntax
|
||
| serde_json::error::Category::Eof => {
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsJson
|
||
}
|
||
};
|
||
protocol_error(kind, format!("{description}:{error}"))
|
||
})
|
||
}
|
||
|
||
fn parse_native_arguments<T: DeserializeOwned>(
|
||
arguments: &str,
|
||
description: &str,
|
||
) -> Result<T, AgentRuntimeToolPlanProtocolError> {
|
||
validate_agent_runtime_protocol_json(arguments, description)?;
|
||
serde_json::from_str::<T>(arguments).map_err(|error| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("{description} schema 无效:{error}"),
|
||
)
|
||
})
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct NativeAgentRuntimeToolPlan {
|
||
pub(crate) plan: AgentRuntimeToolPlan,
|
||
pub(crate) call_ids: Vec<String>,
|
||
pub(crate) function_names: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
struct NativeActionArguments {
|
||
reason: String,
|
||
input: Value,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
struct NativeResponseArguments {
|
||
response: String,
|
||
}
|
||
|
||
pub(crate) fn native_runtime_function_name(tool: &str) -> Option<String> {
|
||
agent_runtime_native_capability_registry()
|
||
.ok()?
|
||
.get(tool)
|
||
.map(|definition| definition.function_name().to_string())
|
||
}
|
||
|
||
fn native_runtime_function_name_for_tool(tool: &str) -> String {
|
||
format!(
|
||
"{AGENT_RUNTIME_NATIVE_TOOL_PREFIX}{}",
|
||
tool.replace('.', "_")
|
||
)
|
||
}
|
||
|
||
fn build_agent_runtime_native_capability_registry(
|
||
tools: Vec<&'static str>,
|
||
) -> Result<CapabilityRegistry<String>, String> {
|
||
let definitions = tools
|
||
.into_iter()
|
||
.map(|tool| {
|
||
CapabilityDefinition::try_new(
|
||
tool,
|
||
native_runtime_function_name_for_tool(tool),
|
||
runtime_tool_description(tool),
|
||
runtime_tool_input_schema(tool),
|
||
tool.to_string(),
|
||
)
|
||
.map_err(|error| format!("Runtime capability {tool} 无效:{error}"))
|
||
})
|
||
.collect::<Result<Vec<_>, _>>()?;
|
||
CapabilityRegistry::try_new(definitions)
|
||
.map_err(|error| format!("Runtime capability registry 无效:{error}"))
|
||
}
|
||
|
||
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
|
||
{
|
||
// 三个独立开关产生八份目录,使用同一快照选缓存并构建。
|
||
static REGISTRIES: [OnceLock<Result<CapabilityRegistry<String>, String>>; 8] =
|
||
[const { OnceLock::new() }; 8];
|
||
let tools = agent_runtime_native_executable_tools();
|
||
let index = usize::from(tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME))
|
||
| (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1)
|
||
| (usize::from(tools.contains(&crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME)) << 2);
|
||
let cache = ®ISTRIES[index];
|
||
cache
|
||
.get_or_init(|| build_agent_runtime_native_capability_registry(tools))
|
||
.as_ref()
|
||
.map_err(Clone::clone)
|
||
}
|
||
|
||
/// 不带身份的全量目录,**只允许测试使用**。
|
||
///
|
||
/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是
|
||
/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实
|
||
/// `agentId`,否则按身份收窄的工具面会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」
|
||
/// 从运行时静默扩权变成编译期错误。
|
||
#[cfg(test)]
|
||
pub(crate) fn build_agent_runtime_native_function_tools() -> Result<Vec<LlmFunctionTool>, String> {
|
||
build_agent_runtime_native_function_tools_for_agent("__all_agents__")
|
||
}
|
||
|
||
/// Build the function catalog for a specific Agent identity.
|
||
pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
|
||
agent_id: &str,
|
||
) -> Result<Vec<LlmFunctionTool>, String> {
|
||
let mut functions = vec![plan_update_function_tool(), response_function_tool()];
|
||
let mut names = BTreeSet::from([
|
||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(),
|
||
AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
|
||
]);
|
||
|
||
for definition in agent_runtime_native_capability_registry()?.iter() {
|
||
let name = definition.function_name().to_string();
|
||
if !names.insert(name.clone()) {
|
||
return Err(format!("Runtime 原生函数名重复:{name}"));
|
||
}
|
||
let description = if let Some(reference) = editor_operation_reference(definition.id()) {
|
||
let reference = reference.replace("\r\n", "\n");
|
||
if reference.len() > 14 * 1024 {
|
||
return Err(format!(
|
||
"Runtime 编辑器操作参考超过随包预算:{}",
|
||
definition.id()
|
||
));
|
||
}
|
||
reference
|
||
} else {
|
||
definition.description().to_owned()
|
||
};
|
||
functions.push(
|
||
LlmFunctionTool::new(
|
||
name,
|
||
description,
|
||
action_function_parameters(definition.input_schema().clone()),
|
||
)
|
||
.with_strict(true),
|
||
);
|
||
}
|
||
|
||
Ok(functions)
|
||
}
|
||
|
||
pub(crate) fn build_agent_runtime_native_function_tools_for_project(
|
||
root: &std::path::Path,
|
||
agent_id: &str,
|
||
) -> Result<Vec<LlmFunctionTool>, String> {
|
||
let mut tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?;
|
||
if !crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root) {
|
||
let name = native_runtime_function_name_for_tool("godot.editor.execute");
|
||
tools.retain(|tool| tool.name != name);
|
||
}
|
||
Ok(tools)
|
||
}
|
||
|
||
pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool {
|
||
agent_runtime_native_capability_registry()
|
||
.ok()
|
||
.and_then(|registry| registry.get(tool.trim()))
|
||
.is_some()
|
||
}
|
||
|
||
fn validate_native_tool_identity(
|
||
runtime_tool: Option<&str>,
|
||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||
if let Some(tool) = runtime_tool {
|
||
if !agent_runtime_native_tool_allowed_for_agent(tool) {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||
format!("Agent 原生工具协议错误:未注册的原生工具 {tool}"),
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn parse_agent_runtime_native_tool_calls(
|
||
calls: &[LlmToolCall],
|
||
) -> Result<NativeAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||
if calls.is_empty() {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ResponseShape,
|
||
"Agent 原生工具协议错误:function calls 不能为空",
|
||
));
|
||
}
|
||
let mut seen_call_ids = HashSet::new();
|
||
let mut plan_update = None;
|
||
let mut response = None;
|
||
let mut actions = Vec::new();
|
||
let mut call_ids = Vec::with_capacity(calls.len());
|
||
let mut function_names = Vec::with_capacity(calls.len());
|
||
|
||
for call in calls {
|
||
let call_id = call.id.trim();
|
||
if call_id.is_empty() || !seen_call_ids.insert(call_id.to_string()) {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::CallIdentity,
|
||
"Agent 原生工具协议错误:call id 必须非空且唯一",
|
||
));
|
||
}
|
||
call_ids.push(call_id.to_string());
|
||
function_names.push(call.name.clone());
|
||
|
||
if call.name == AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME {
|
||
if plan_update.is_some() {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
|
||
"Agent 原生工具协议错误:一次响应只能更新一次计划",
|
||
));
|
||
}
|
||
plan_update = Some(parse_native_arguments::<AgentRuntimePlanUpdate>(
|
||
&call.arguments,
|
||
"解析原生计划更新失败",
|
||
)?);
|
||
continue;
|
||
}
|
||
if call.name == AGENT_RUNTIME_RESPOND_FUNCTION_NAME {
|
||
if response.is_some() {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
|
||
"Agent 原生工具协议错误:一次响应只能提交一个最终回复",
|
||
));
|
||
}
|
||
response = Some(
|
||
parse_native_arguments::<NativeResponseArguments>(
|
||
&call.arguments,
|
||
"解析原生最终回复失败",
|
||
)?
|
||
.response,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
let runtime_tool = runtime_tool_for_native_function(&call.name);
|
||
validate_native_tool_identity(runtime_tool.as_deref())?;
|
||
if runtime_tool.is_none() {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||
format!("Agent 原生工具协议错误:未知函数 {}", call.name),
|
||
));
|
||
}
|
||
let arguments = parse_native_arguments::<NativeActionArguments>(
|
||
&call.arguments,
|
||
&format!("解析原生工具 {} 参数失败", call.name),
|
||
)?;
|
||
if arguments.reason.trim().is_empty() {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("Agent 原生工具协议错误:{} reason 不能为空", call.name),
|
||
));
|
||
}
|
||
let mut input = arguments.input;
|
||
if runtime_tool.as_deref() == Some("agent.delegate") {
|
||
validate_native_agent_delegate_input(&input)?;
|
||
}
|
||
if runtime_tool.as_deref() == Some("project.patchset") {
|
||
input = normalize_native_project_patchset_input(input)?;
|
||
}
|
||
let tool = runtime_tool.expect("原生函数 binding 已在参数解析前验证");
|
||
let action = AgentRuntimeToolAction {
|
||
tool,
|
||
reason: Some(arguments.reason),
|
||
input,
|
||
};
|
||
actions.push(action);
|
||
if actions.len() > AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
|
||
format!(
|
||
"Agent 原生工具协议错误:一次最多调用 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个动作工具"
|
||
),
|
||
));
|
||
}
|
||
}
|
||
|
||
if response.is_some() && !actions.is_empty() {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
|
||
"Agent 原生工具协议错误:最终回复不能与动作工具同时提交",
|
||
));
|
||
}
|
||
if response
|
||
.as_deref()
|
||
.is_some_and(|value| value.trim().is_empty())
|
||
{
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
|
||
"Agent 原生工具协议错误:最终回复不能为空",
|
||
));
|
||
}
|
||
let response = response.unwrap_or_default();
|
||
let thinking_summary = plan_update
|
||
.as_ref()
|
||
.map(|update| update.explanation.clone())
|
||
.or_else(|| actions.first().and_then(|action| action.reason.clone()))
|
||
.unwrap_or_else(|| "根据现有观察整理最终回复".to_string());
|
||
|
||
Ok(NativeAgentRuntimeToolPlan {
|
||
plan: AgentRuntimeToolPlan {
|
||
thinking_summary,
|
||
plan_update,
|
||
plan: Vec::new(),
|
||
actions,
|
||
response,
|
||
},
|
||
call_ids,
|
||
function_names,
|
||
})
|
||
}
|
||
|
||
fn validate_native_agent_delegate_input(
|
||
input: &Value,
|
||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||
const REQUIRED_FIELDS: [&str; 6] = [
|
||
"agentId",
|
||
"task",
|
||
"acceptanceCriteria",
|
||
"expectedArtifacts",
|
||
"repairOfDelegationId",
|
||
"runId",
|
||
];
|
||
const ALLOWED_FIELDS: [&str; 9] = [
|
||
"agentId",
|
||
"task",
|
||
"acceptanceCriteria",
|
||
"expectedArtifacts",
|
||
"repairOfDelegationId",
|
||
"runId",
|
||
"continuationOfDelegationId",
|
||
"questionsSha256",
|
||
"answersSha256",
|
||
];
|
||
let object = input.as_object().ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:agent.delegate input 必须是 object",
|
||
)
|
||
})?;
|
||
for field in REQUIRED_FIELDS {
|
||
if !object.contains_key(field) {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("Agent 原生工具协议错误:agent.delegate 缺少 {field}"),
|
||
));
|
||
}
|
||
}
|
||
if object
|
||
.keys()
|
||
.any(|field| !ALLOWED_FIELDS.contains(&field.as_str()))
|
||
{
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:agent.delegate 包含未知字段",
|
||
));
|
||
}
|
||
validate_native_delegate_string(object.get("agentId"), "agentId", 96, false)?;
|
||
validate_native_delegate_string(object.get("task"), "task", 2_400, false)?;
|
||
// 返工/澄清续跑必须逐字继承原委派的两个数组,Runtime 从 repairOfDelegationId
|
||
// 指向的 delivery 直接读得到权威值。两个都传 null 时由 Runtime 补齐;手抄一遍
|
||
// 不带来任何信息增益,只制造反复失败的返工委派。初次委派仍然必须自己写。
|
||
let repair_hop = object
|
||
.get("repairOfDelegationId")
|
||
.is_some_and(Value::is_string);
|
||
let inherits_contract = repair_hop
|
||
&& object.get("acceptanceCriteria").is_some_and(Value::is_null)
|
||
&& object.get("expectedArtifacts").is_some_and(Value::is_null);
|
||
if !inherits_contract {
|
||
validate_native_delegate_string_list(
|
||
object.get("acceptanceCriteria"),
|
||
"acceptanceCriteria",
|
||
1,
|
||
8,
|
||
240,
|
||
)?;
|
||
validate_native_delegate_string_list(
|
||
object.get("expectedArtifacts"),
|
||
"expectedArtifacts",
|
||
0,
|
||
16,
|
||
240,
|
||
)?;
|
||
}
|
||
validate_native_delegate_string(
|
||
object.get("repairOfDelegationId"),
|
||
"repairOfDelegationId",
|
||
160,
|
||
true,
|
||
)?;
|
||
validate_native_delegate_string(object.get("runId"), "runId", 160, true)?;
|
||
if object.contains_key("continuationOfDelegationId") {
|
||
validate_native_delegate_string(
|
||
object.get("continuationOfDelegationId"),
|
||
"continuationOfDelegationId",
|
||
160,
|
||
true,
|
||
)?;
|
||
}
|
||
if object.contains_key("questionsSha256") {
|
||
validate_native_delegate_string(
|
||
object.get("questionsSha256"),
|
||
"questionsSha256",
|
||
64,
|
||
true,
|
||
)?;
|
||
}
|
||
if object.contains_key("answersSha256") {
|
||
validate_native_delegate_string(object.get("answersSha256"), "answersSha256", 64, true)?;
|
||
}
|
||
if object
|
||
.get("repairOfDelegationId")
|
||
.is_some_and(Value::is_string)
|
||
&& !object.get("runId").is_some_and(Value::is_null)
|
||
{
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null",
|
||
));
|
||
}
|
||
// 澄清 continuation 的锚点是 continuationOfDelegationId;两个指纹可以整体省略,
|
||
// 由 Runtime 从原 delivery 取权威值补齐。省略是为了不让 Supervisor 手抄 128 个
|
||
// 十六进制字符——抄错会打到硬失败,而抄对也不带来任何 Runtime 不知道的信息。
|
||
// 三个字段一律按“JSON null 等同于缺省”处理,与本函数其余可空字段一致。
|
||
let continuation_anchor = object
|
||
.get("continuationOfDelegationId")
|
||
.is_some_and(Value::is_string);
|
||
let digest_strings = ["questionsSha256", "answersSha256"]
|
||
.iter()
|
||
.filter(|field| object.get(**field).is_some_and(Value::is_string))
|
||
.count();
|
||
if digest_strings == 1 {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:agent.delegate 澄清 continuation 指纹必须成对提供,或整体省略交给 Runtime 补齐",
|
||
));
|
||
}
|
||
if digest_strings == 2 && !continuation_anchor {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:agent.delegate 澄清 continuation 指纹必须同时提供 continuationOfDelegationId",
|
||
));
|
||
}
|
||
for field in ["questionsSha256", "answersSha256"] {
|
||
if object.get(field).is_some_and(Value::is_string)
|
||
&& object
|
||
.get(field)
|
||
.and_then(Value::as_str)
|
||
.is_none_or(|value| {
|
||
value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
})
|
||
{
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:agent.delegate {field} 必须是 64 位十六进制 SHA-256"
|
||
),
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_native_project_patchset_input(
|
||
input: Value,
|
||
) -> Result<Value, AgentRuntimeToolPlanProtocolError> {
|
||
const CHANGE_FIELDS: [&str; 7] = [
|
||
"operation",
|
||
"path",
|
||
"content",
|
||
"expectedSha256",
|
||
"oldText",
|
||
"newText",
|
||
"expectedReplacements",
|
||
];
|
||
let object = input.as_object().ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:project.patchset input 必须是 object",
|
||
)
|
||
})?;
|
||
if object.len() != 1 || !object.contains_key("changes") {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:project.patchset input 字段无效",
|
||
));
|
||
}
|
||
let changes = object
|
||
.get("changes")
|
||
.and_then(Value::as_array)
|
||
.ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
"Agent 原生工具协议错误:project.patchset changes 必须是数组",
|
||
)
|
||
})?;
|
||
let mut normalized = Vec::with_capacity(changes.len());
|
||
for (index, change) in changes.iter().enumerate() {
|
||
let change = change.as_object().ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:project.patchset changes[{}] 必须是 object",
|
||
index + 1
|
||
),
|
||
)
|
||
})?;
|
||
if change.len() != CHANGE_FIELDS.len()
|
||
|| CHANGE_FIELDS
|
||
.iter()
|
||
.any(|field| !change.contains_key(*field))
|
||
|| change
|
||
.keys()
|
||
.any(|field| !CHANGE_FIELDS.contains(&field.as_str()))
|
||
{
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:project.patchset changes[{}] 字段无效",
|
||
index + 1
|
||
),
|
||
));
|
||
}
|
||
let required_string = |field: &str| {
|
||
change.get(field).and_then(Value::as_str).ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:project.patchset changes[{}].{field} 必须是字符串",
|
||
index + 1
|
||
),
|
||
)
|
||
})
|
||
};
|
||
let require_null = |fields: &[&str]| {
|
||
if let Some(field) = fields
|
||
.iter()
|
||
.find(|field| !change.get(**field).is_some_and(Value::is_null))
|
||
{
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:project.patchset changes[{}].{field} 必须为 null",
|
||
index + 1
|
||
),
|
||
));
|
||
}
|
||
Ok(())
|
||
};
|
||
let operation = required_string("operation")?;
|
||
let path = required_string("path")?;
|
||
normalized.push(match operation {
|
||
"create" => {
|
||
require_null(&[
|
||
"expectedSha256",
|
||
"oldText",
|
||
"newText",
|
||
"expectedReplacements",
|
||
])?;
|
||
json!({
|
||
"operation": operation,
|
||
"path": path,
|
||
"content": required_string("content")?,
|
||
})
|
||
}
|
||
"update" => {
|
||
require_null(&["content"])?;
|
||
let expected_replacements = change
|
||
.get("expectedReplacements")
|
||
.and_then(Value::as_u64)
|
||
.ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:project.patchset changes[{}].expectedReplacements 必须是整数",
|
||
index + 1
|
||
),
|
||
)
|
||
})?;
|
||
json!({
|
||
"operation": operation,
|
||
"path": path,
|
||
"expectedSha256": required_string("expectedSha256")?,
|
||
"oldText": required_string("oldText")?,
|
||
"newText": required_string("newText")?,
|
||
"expectedReplacements": expected_replacements,
|
||
})
|
||
}
|
||
"delete" => {
|
||
require_null(&["content", "oldText", "newText", "expectedReplacements"])?;
|
||
json!({
|
||
"operation": operation,
|
||
"path": path,
|
||
"expectedSha256": required_string("expectedSha256")?,
|
||
})
|
||
}
|
||
_ => {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!(
|
||
"Agent 原生工具协议错误:project.patchset changes[{}].operation 无效",
|
||
index + 1
|
||
),
|
||
));
|
||
}
|
||
});
|
||
}
|
||
Ok(json!({ "changes": normalized }))
|
||
}
|
||
|
||
fn validate_native_delegate_string(
|
||
value: Option<&Value>,
|
||
field: &str,
|
||
max_chars: usize,
|
||
nullable: bool,
|
||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||
if nullable && value.is_some_and(Value::is_null) {
|
||
return Ok(());
|
||
}
|
||
let value = value.and_then(Value::as_str).ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("Agent 原生工具协议错误:agent.delegate {field} 类型无效"),
|
||
)
|
||
})?;
|
||
let chars = value.chars().count();
|
||
if value.trim().is_empty() || chars > max_chars {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("Agent 原生工具协议错误:agent.delegate {field} 长度无效"),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_native_delegate_string_list(
|
||
value: Option<&Value>,
|
||
field: &str,
|
||
min_items: usize,
|
||
max_items: usize,
|
||
max_chars: usize,
|
||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||
let values = value.and_then(Value::as_array).ok_or_else(|| {
|
||
protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("Agent 原生工具协议错误:agent.delegate {field} 类型无效"),
|
||
)
|
||
})?;
|
||
if values.len() < min_items || values.len() > max_items {
|
||
return Err(protocol_error(
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
|
||
format!("Agent 原生工具协议错误:agent.delegate {field} 数量无效"),
|
||
));
|
||
}
|
||
for value in values {
|
||
validate_native_delegate_string(Some(value), field, max_chars, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn runtime_tool_for_native_function(name: &str) -> Option<String> {
|
||
agent_runtime_native_capability_registry()
|
||
.ok()?
|
||
.get_by_function_name(name)
|
||
.map(|definition| definition.dispatch().clone())
|
||
}
|
||
|
||
fn plan_update_function_tool() -> LlmFunctionTool {
|
||
LlmFunctionTool::new(
|
||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
|
||
prompt_text!("nativeTools.update_agent_plan.description"),
|
||
plan_update_schema(),
|
||
)
|
||
.with_strict(true)
|
||
}
|
||
|
||
fn response_function_tool() -> LlmFunctionTool {
|
||
LlmFunctionTool::new(
|
||
AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||
prompt_text!("nativeTools.respond_to_user.description"),
|
||
json!({
|
||
"type": "object",
|
||
"required": ["response"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"response": { "type": "string", "minLength": 1 }
|
||
}
|
||
}),
|
||
)
|
||
.with_strict(true)
|
||
}
|
||
|
||
fn plan_update_schema() -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"required": ["explanation", "steps"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"explanation": { "type": "string", "minLength": 1 },
|
||
"steps": {
|
||
"type": "array",
|
||
"minItems": 1,
|
||
"maxItems": AGENT_RUNTIME_PLAN_STEP_LIMIT,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["step", "status"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"step": { "type": "string", "minLength": 1 },
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["pending", "in_progress", "completed"]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
fn rebase_action_input_schema_refs_in_scope(value: &mut Value, has_local_resource_id: bool) {
|
||
let Value::Object(object) = value else {
|
||
return;
|
||
};
|
||
|
||
// `$id` 会建立独立 schema resource;其内部 fragment 应继续相对该 resource
|
||
// 解析,不能按外层 function parameters 根重定位。
|
||
let has_local_resource_id = has_local_resource_id || object.contains_key("$id");
|
||
let reference = object
|
||
.get("$ref")
|
||
.and_then(Value::as_str)
|
||
.map(ToString::to_string);
|
||
if let Some(reference) = reference {
|
||
// 只有空 fragment 和 JSON Pointer fragment 相对当前 document 根。
|
||
// `#Mode` 是命名 anchor,外部 URI 也有自己的解析范围,必须保持原样。
|
||
if !has_local_resource_id && (reference == "#" || reference.starts_with("#/")) {
|
||
let rebased = if reference == "#" {
|
||
"#/properties/input".to_string()
|
||
} else {
|
||
format!("#/properties/input{}", &reference[1..])
|
||
};
|
||
object.insert("$ref".to_string(), Value::String(rebased));
|
||
}
|
||
}
|
||
|
||
// 只进入 JSON Schema 明确定义为 subschema 的位置。default、const、examples、
|
||
// enum 等关键词承载普通 JSON 数据,其中即使出现 `$ref` 也不能改写。
|
||
for keyword in [
|
||
"additionalProperties",
|
||
"unevaluatedProperties",
|
||
"propertyNames",
|
||
"additionalItems",
|
||
"unevaluatedItems",
|
||
"contains",
|
||
"not",
|
||
"if",
|
||
"then",
|
||
"else",
|
||
"contentSchema",
|
||
] {
|
||
if let Some(child) = object.get_mut(keyword) {
|
||
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
|
||
}
|
||
}
|
||
|
||
for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
|
||
if let Some(Value::Array(children)) = object.get_mut(keyword) {
|
||
for child in children {
|
||
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// draft-07 的 tuple validation 允许 items 为 schema 数组;新版本则为单 schema。
|
||
if let Some(items) = object.get_mut("items") {
|
||
match items {
|
||
Value::Array(children) => {
|
||
for child in children {
|
||
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
|
||
}
|
||
}
|
||
child => rebase_action_input_schema_refs_in_scope(child, has_local_resource_id),
|
||
}
|
||
}
|
||
|
||
for keyword in [
|
||
"$defs",
|
||
"definitions",
|
||
"properties",
|
||
"patternProperties",
|
||
"dependentSchemas",
|
||
] {
|
||
if let Some(Value::Object(children)) = object.get_mut(keyword) {
|
||
for child in children.values_mut() {
|
||
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// draft-07 dependencies 的 value 可能是 subschema,也可能是属性名数组。
|
||
if let Some(Value::Object(dependencies)) = object.get_mut("dependencies") {
|
||
for dependency in dependencies.values_mut().filter(|value| value.is_object()) {
|
||
rebase_action_input_schema_refs_in_scope(dependency, has_local_resource_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn rebase_action_input_schema_refs(value: &mut Value) {
|
||
rebase_action_input_schema_refs_in_scope(value, false);
|
||
}
|
||
|
||
fn action_function_parameters(mut input_schema: Value) -> Value {
|
||
// Action input schema 会被包进 action.input。局部 JSON Pointer 仍从整个
|
||
// function parameters 根解析,因此必须同步重定位;否则 #/$defs/... 会悬空。
|
||
rebase_action_input_schema_refs(&mut input_schema);
|
||
json!({
|
||
"type": "object",
|
||
"required": ["reason", "input"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"reason": { "type": "string", "minLength": 1 },
|
||
"input": input_schema
|
||
}
|
||
})
|
||
}
|
||
|
||
fn empty_input_schema() -> Value {
|
||
json!({ "type": "object", "required": [], "additionalProperties": false, "properties": {} })
|
||
}
|
||
|
||
fn string_array_schema(max_items: usize) -> Value {
|
||
json!({
|
||
"type": "array",
|
||
"maxItems": max_items,
|
||
"items": { "type": "string" }
|
||
})
|
||
}
|
||
|
||
fn editor_operation_reference(tool: &str) -> Option<&'static str> {
|
||
match tool {
|
||
"unity.editor.execute" => Some(include_str!("../resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md")),
|
||
"godot.editor.execute" => Some(include_str!("../resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md")),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn runtime_tool_description(tool: &str) -> &'static str {
|
||
match tool {
|
||
"user.input_request" => prompt_text!("nativeTools.user.input_request.description"),
|
||
"memory.read" => prompt_text!("nativeTools.memory.read.description"),
|
||
"memory.write" => prompt_text!("nativeTools.memory.write.description"),
|
||
"conversation.read" => prompt_text!("nativeTools.conversation.read.description"),
|
||
"asset.list" => {
|
||
prompt_text!("nativeTools.asset.list.description")
|
||
}
|
||
"asset.library.list" => {
|
||
prompt_text!("nativeTools.asset.library.list.description")
|
||
}
|
||
"canvas.asset_import" => {
|
||
prompt_text!("nativeTools.canvas.asset_import.description")
|
||
}
|
||
"project.index" => prompt_text!("nativeTools.project.index.description"),
|
||
"project.search" => prompt_text!("nativeTools.project.search.description"),
|
||
"project.verify" => prompt_text!("nativeTools.project.verify.description"),
|
||
"project.bootstrap" => prompt_text!("nativeTools.project.bootstrap.description"),
|
||
"project.checkpoint" => prompt_text!("nativeTools.project.checkpoint.description"),
|
||
"project.restore" => prompt_text!("nativeTools.project.restore.description"),
|
||
"project.diff" => prompt_text!("nativeTools.project.diff.description"),
|
||
"git.inspect" => prompt_text!("nativeTools.git.inspect.description"),
|
||
"project.git_commit" => prompt_text!("nativeTools.project.git_commit.description"),
|
||
"project.patchset" => prompt_text!("nativeTools.project.patchset.description"),
|
||
"file.list" => prompt_text!("nativeTools.file.list.description"),
|
||
"file.read" => prompt_text!("nativeTools.file.read.description"),
|
||
"file.write" => prompt_text!("nativeTools.file.write.description"),
|
||
"file.patch" => prompt_text!("nativeTools.file.patch.description"),
|
||
"file.delete" => prompt_text!("nativeTools.file.delete.description"),
|
||
"task.list" => prompt_text!("nativeTools.task.list.description"),
|
||
"task.create" => prompt_text!("nativeTools.task.create.description"),
|
||
"task.update" => prompt_text!("nativeTools.task.update.description"),
|
||
"command.exec" => prompt_text!("nativeTools.command.exec.description"),
|
||
"command.output_read" => prompt_text!("nativeTools.command.output_read.description"),
|
||
"command.start" => prompt_text!("nativeTools.command.start.description"),
|
||
"command.poll" => prompt_text!("nativeTools.command.poll.description"),
|
||
"command.stdin" => prompt_text!("nativeTools.command.stdin.description"),
|
||
"command.terminate" => prompt_text!("nativeTools.command.terminate.description"),
|
||
"command.run_limited" => prompt_text!("nativeTools.command.run_limited.description"),
|
||
"preview.start" => prompt_text!("nativeTools.preview.start.description"),
|
||
"preview.validate" => prompt_text!("nativeTools.preview.validate.description"),
|
||
"image.inspect" => prompt_text!("nativeTools.image.inspect.description"),
|
||
"canvas.asset_generate" => {
|
||
prompt_text!("nativeTools.canvas.asset_generate.description")
|
||
}
|
||
"ui.workflow.run" => {
|
||
prompt_text!("nativeTools.ui.workflow.run.description")
|
||
}
|
||
"cocos.editor.execute" => {
|
||
prompt_text!("nativeTools.cocos.editor.execute.description")
|
||
}
|
||
"unity.editor.execute" => prompt_text!("nativeTools.unity.editor.execute.description"),
|
||
"godot.editor.execute" => prompt_text!("nativeTools.godot.editor.execute.description"),
|
||
"blackboard.write" => prompt_text!("nativeTools.blackboard.write.description"),
|
||
"agent.message" => prompt_text!("nativeTools.agent.message.description"),
|
||
"agent.delegate" => {
|
||
prompt_text!("nativeTools.agent.delegate.description")
|
||
}
|
||
"agent.spawn_isolated" => prompt_text!("nativeTools.agent.spawn_isolated.description"),
|
||
"agent.goal_contract" => {
|
||
prompt_text!("nativeTools.agent.goal_contract.description")
|
||
}
|
||
"agent.acceptance_update" => {
|
||
prompt_text!("nativeTools.agent.acceptance_update.description")
|
||
}
|
||
"agent.schedule_ready" => prompt_text!("nativeTools.agent.schedule_ready.description"),
|
||
"agent.action_history" => prompt_text!("nativeTools.agent.action_history.description"),
|
||
"agent.run_status" => prompt_text!("nativeTools.agent.run_status.description"),
|
||
_ => prompt_text!("nativeTools.fallback.description"),
|
||
}
|
||
}
|
||
|
||
fn runtime_tool_input_schema(tool: &str) -> Value {
|
||
match tool {
|
||
"user.input_request" => json!({
|
||
"type": "object",
|
||
"required": ["questions"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"questions": {
|
||
"type": "array", "minItems": 1, "maxItems": 3,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["id", "header", "question", "options"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"id": { "type": "string", "minLength": 1, "maxLength": 64 },
|
||
"header": { "type": "string", "minLength": 1, "maxLength": 12 },
|
||
"question": { "type": "string", "minLength": 1, "maxLength": 400 },
|
||
"options": {
|
||
"type": "array", "minItems": 2, "maxItems": 3,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["label", "description"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"label": { "type": "string", "minLength": 1, "maxLength": 60 },
|
||
"description": { "type": "string", "minLength": 1, "maxLength": 240 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}),
|
||
"memory.read" => json!({
|
||
"type": "object", "required": ["scope"], "additionalProperties": false,
|
||
"properties": { "scope": { "type": "string", "enum": ["session", "project", "blackboard", "agent"] } }
|
||
}),
|
||
"memory.write" => json!({
|
||
"type": "object", "required": ["scope", "title", "content", "mode"], "additionalProperties": false,
|
||
"properties": {
|
||
"scope": { "type": "string", "enum": ["agent", "project", "session", "blackboard"] },
|
||
"title": { "type": "string" },
|
||
"content": { "type": "string", "minLength": 1 },
|
||
"mode": { "type": "string", "enum": ["append", "overwrite"] }
|
||
}
|
||
}),
|
||
"conversation.read" | "asset.list" | "project.index" | "project.checkpoint"
|
||
| "task.list" | "preview.start" => empty_input_schema(),
|
||
"asset.library.list" => json!({
|
||
"type": "object",
|
||
// OpenAI strict function schemas require every declared property to
|
||
// appear in `required`. Optional values are represented as nullable
|
||
// fields and the runtime treats `null` as omitted.
|
||
"required": ["folderId", "query", "offset", "limit"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"folderId": { "type": ["string", "null"], "maxLength": 512 },
|
||
"query": { "type": ["string", "null"], "maxLength": 120 },
|
||
"offset": { "type": ["integer", "null"], "minimum": 0, "maximum": 500 },
|
||
"limit": { "type": ["integer", "null"], "minimum": 1, "maximum": 100 }
|
||
}
|
||
}),
|
||
"canvas.asset_import" => json!({
|
||
"type": "object",
|
||
// Both arrays are nullable so a strict-schema caller can select one
|
||
// source kind while still sending the complete object shape.
|
||
"required": ["assetIds", "localPaths"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"assetIds": {
|
||
"type": ["array", "null"],
|
||
"maxItems": 100,
|
||
"items": { "type": "string", "minLength": 1, "maxLength": 512 }
|
||
},
|
||
"localPaths": {
|
||
"type": ["array", "null"],
|
||
"maxItems": 100,
|
||
"items": { "type": "string", "minLength": 1, "maxLength": 512 }
|
||
}
|
||
}
|
||
}),
|
||
"project.search" => json!({
|
||
"type": "object", "required": ["query", "path", "maxResults", "caseSensitive"], "additionalProperties": false,
|
||
"properties": {
|
||
"query": { "type": "string", "minLength": 1, "maxLength": 256 },
|
||
"path": { "type": "string" },
|
||
"maxResults": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||
"caseSensitive": { "type": "boolean" }
|
||
}
|
||
}),
|
||
"project.verify" => json!({
|
||
"type": "object", "required": ["script", "expectedCommand", "timeoutSeconds", "cwd"], "additionalProperties": false,
|
||
"properties": {
|
||
"script": { "type": "string", "minLength": 1 },
|
||
"expectedCommand": { "type": "string", "minLength": 1 },
|
||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 },
|
||
"cwd": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9._/-]+$" }
|
||
}
|
||
}),
|
||
"project.bootstrap" => json!({
|
||
"type": "object", "required": ["cwd", "timeoutSeconds"], "additionalProperties": false,
|
||
"properties": {
|
||
"cwd": { "type": "string", "const": "game" },
|
||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 }
|
||
}
|
||
}),
|
||
"project.restore" => one_string_input_schema("checkpointId"),
|
||
"project.diff" => json!({
|
||
"type": "object", "required": ["checkpointId", "includeContent", "maxFiles", "maxChars"], "additionalProperties": false,
|
||
"properties": {
|
||
"checkpointId": { "type": "string", "minLength": 1 },
|
||
"includeContent": { "type": "boolean" },
|
||
"maxFiles": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||
"maxChars": { "type": "integer", "minimum": 1, "maximum": 24000 }
|
||
}
|
||
}),
|
||
"git.inspect" => json!({
|
||
"type": "object", "required": ["includeDiff", "maxFiles", "maxChars"], "additionalProperties": false,
|
||
"properties": {
|
||
"includeDiff": { "type": "boolean" },
|
||
"maxFiles": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||
"maxChars": { "type": "integer", "minimum": 1, "maximum": 24000 }
|
||
}
|
||
}),
|
||
"project.git_commit" => json!({
|
||
"type": "object", "required": ["message", "paths", "expectedHead", "expectedSnapshotFingerprint"], "additionalProperties": false,
|
||
"properties": {
|
||
"message": { "type": "string", "minLength": 1 },
|
||
"paths": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "type": "string", "minLength": 1 } },
|
||
"expectedHead": { "type": "string", "minLength": 1 },
|
||
"expectedSnapshotFingerprint": { "type": "string", "minLength": 1 }
|
||
}
|
||
}),
|
||
"project.patchset" => project_patchset_input_schema(),
|
||
"file.list" => one_string_input_schema("path"),
|
||
"file.read" => json!({
|
||
"type": "object", "required": ["path", "startLine", "maxLines"], "additionalProperties": false,
|
||
"properties": {
|
||
"path": { "type": "string", "minLength": 1 },
|
||
"startLine": { "type": "integer", "minimum": 1 },
|
||
"maxLines": { "type": "integer", "minimum": 1, "maximum": 240 }
|
||
}
|
||
}),
|
||
"file.write" => two_string_input_schema("path", "content"),
|
||
"file.patch" => json!({
|
||
"type": "object", "required": ["path", "oldText", "newText", "expectedReplacements"], "additionalProperties": false,
|
||
"properties": {
|
||
"path": { "type": "string", "minLength": 1 },
|
||
"oldText": { "type": "string", "minLength": 1 },
|
||
"newText": { "type": "string" },
|
||
"expectedReplacements": { "type": "integer", "minimum": 1 }
|
||
}
|
||
}),
|
||
"file.delete" => one_string_input_schema("path"),
|
||
"task.create" => json!({
|
||
"type": "object",
|
||
"required": ["taskId", "title", "group", "role", "dependencies", "artifacts", "acceptanceCriteria", "status"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"taskId": { "type": ["string", "null"] },
|
||
"title": { "type": "string", "minLength": 1 },
|
||
"group": { "type": "string", "enum": ["design", "art", "code", "balance", "audio", "publishing"] },
|
||
"role": { "type": "string", "minLength": 1 },
|
||
"dependencies": string_array_schema(32),
|
||
"artifacts": string_array_schema(32),
|
||
"acceptanceCriteria": string_array_schema(32),
|
||
"status": { "type": "string", "enum": ["pending", "running", "waiting-for-confirmation", "completed", "failed"] }
|
||
}
|
||
}),
|
||
"task.update" => json!({
|
||
"type": "object", "required": ["taskId", "status"], "additionalProperties": false,
|
||
"properties": {
|
||
"taskId": { "type": "string", "minLength": 1 },
|
||
"status": { "type": "string", "enum": ["pending", "running", "waiting-for-confirmation", "completed", "failed"] }
|
||
}
|
||
}),
|
||
"command.exec" | "command.start" => command_start_input_schema(),
|
||
"cocos.editor.execute" | "unity.editor.execute" | "godot.editor.execute" => json!({
|
||
"type": "object",
|
||
"required": ["code"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"code": { "type": "string", "minLength": 1, "maxLength": 131072 }
|
||
}
|
||
}),
|
||
"command.output_read" => json!({
|
||
"type": "object", "required": ["actionId", "startLine", "maxLines"], "additionalProperties": false,
|
||
"properties": {
|
||
"actionId": { "type": "string", "minLength": 1 },
|
||
"startLine": { "type": "integer", "minimum": 1 },
|
||
"maxLines": { "type": "integer", "minimum": 1, "maximum": 160 }
|
||
}
|
||
}),
|
||
"command.poll" => json!({
|
||
"type": "object", "required": ["processId", "cursor", "maxChars", "waitMs"], "additionalProperties": false,
|
||
"properties": {
|
||
"processId": { "type": "string", "minLength": 1 },
|
||
"cursor": { "type": ["string", "null"] },
|
||
"maxChars": { "type": "integer", "minimum": 1, "maximum": 32000 },
|
||
"waitMs": { "type": "integer", "minimum": 0, "maximum": 30000 }
|
||
}
|
||
}),
|
||
"command.stdin" => json!({
|
||
"type": "object", "required": ["processId", "data", "appendNewline", "eof"], "additionalProperties": false,
|
||
"properties": {
|
||
"processId": { "type": "string", "minLength": 1 },
|
||
"data": { "type": "string" },
|
||
"appendNewline": { "type": "boolean" },
|
||
"eof": { "type": "boolean" }
|
||
}
|
||
}),
|
||
"command.terminate" => json!({
|
||
"type": "object", "required": ["processId", "cursor"], "additionalProperties": false,
|
||
"properties": {
|
||
"processId": { "type": "string", "minLength": 1 },
|
||
"cursor": { "type": ["string", "null"] }
|
||
}
|
||
}),
|
||
"command.run_limited" => json!({
|
||
"type": "object", "required": ["commandId"], "additionalProperties": false,
|
||
"properties": { "commandId": { "type": "string", "enum": ["game.static_smoke"] } }
|
||
}),
|
||
"preview.validate" => json!({
|
||
"type": "object", "required": ["viewports", "expectedText", "settleMs", "failOnConsoleError", "playtestScenario"], "additionalProperties": false,
|
||
"properties": {
|
||
"viewports": { "type": "array", "minItems": 1, "maxItems": 2, "items": { "type": "string", "enum": ["desktop", "mobile"] } },
|
||
"expectedText": string_array_schema(16),
|
||
"settleMs": { "type": "integer", "minimum": 0, "maximum": 10000 },
|
||
"failOnConsoleError": { "type": "boolean" },
|
||
"playtestScenario": { "type": ["string", "null"], "enum": ["generic-v1", "tetris-v1", "lane-defense-v1", null] }
|
||
}
|
||
}),
|
||
"image.inspect" => json!({
|
||
"type": "object", "required": ["paths", "question"], "additionalProperties": false,
|
||
"properties": {
|
||
"paths": { "type": "array", "minItems": 1, "maxItems": 2, "items": { "type": "string", "minLength": 1 } },
|
||
"question": { "type": ["string", "null"], "maxLength": 1000 }
|
||
}
|
||
}),
|
||
"canvas.asset_generate" => {
|
||
let mut asset_kinds = AGENT_RUNTIME_CANVAS_ASSET_KINDS
|
||
.iter()
|
||
.map(|kind| Value::String(kind.as_str().to_string()))
|
||
.collect::<Vec<_>>();
|
||
asset_kinds.push(Value::Null);
|
||
json!({
|
||
"type": "object",
|
||
"required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting", "sliceMode", "gridX", "gridY", "sliceCount"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"prompt": { "type": "string", "minLength": 1, "maxLength": 4000 },
|
||
"outputPath": { "type": ["string", "null"], "maxLength": 240 },
|
||
"aspectRatio": { "type": ["string", "null"], "enum": ["1:1", "2:3", "3:2", "9:16", "16:9", null] },
|
||
"imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] },
|
||
"assetKind": { "type": ["string", "null"], "enum": asset_kinds },
|
||
"assetLabel": { "type": ["string", "null"], "maxLength": 80 },
|
||
"replaceExisting": { "type": "boolean" },
|
||
"sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": prompt_text!("nativeTools.canvas.asset_generate.parameters.sliceMode") },
|
||
"gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": prompt_text!("nativeTools.canvas.asset_generate.parameters.gridX") },
|
||
"gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": prompt_text!("nativeTools.canvas.asset_generate.parameters.gridY") },
|
||
"sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": prompt_text!("nativeTools.canvas.asset_generate.parameters.sliceCount") }
|
||
}
|
||
})
|
||
}
|
||
"ui.workflow.run" => json!({
|
||
"type": "object",
|
||
"required": ["operation", "sourceAssetId", "pages"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"operation": { "type": "string", "enum": ["discover", "prepare", "recognize", "status", "finalize"] },
|
||
"sourceAssetId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||
"pages": {
|
||
"type": "array",
|
||
"maxItems": 32,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["pageId", "title", "description", "designAssetId", "spriteAssetIds", "fontAssetIds", "applicationPath"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"pageId": { "type": "string", "minLength": 1, "maxLength": 80, "pattern": "^[A-Za-z0-9._-]+$" },
|
||
"title": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||
"description": { "type": "string", "maxLength": 400 },
|
||
"designAssetId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||
"spriteAssetIds": { "type": "array", "maxItems": 32, "items": { "type": "string", "minLength": 1, "maxLength": 160 } },
|
||
"fontAssetIds": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 160 } },
|
||
"applicationPath": { "type": ["string", "null"], "maxLength": 240 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}),
|
||
"blackboard.write" => two_string_input_schema("title", "content"),
|
||
"agent.message" => two_string_input_schema("agentId", "content"),
|
||
"agent.delegate" => json!({
|
||
"type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId", "continuationOfDelegationId", "questionsSha256", "answersSha256"], "additionalProperties": false,
|
||
"properties": {
|
||
"agentId": { "type": "string", "minLength": 1 },
|
||
"task": { "type": "string", "minLength": 1, "maxLength": 2400 },
|
||
"acceptanceCriteria": { "type": ["array", "null"], "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 240 }, "description": prompt_text!("nativeTools.agent.delegate.parameters.acceptanceCriteria") },
|
||
"expectedArtifacts": { "type": ["array", "null"], "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 }, "description": prompt_text!("nativeTools.agent.delegate.parameters.expectedArtifacts") },
|
||
"repairOfDelegationId": { "type": ["string", "null"] },
|
||
"runId": { "type": ["string", "null"] },
|
||
"continuationOfDelegationId": { "type": ["string", "null"] },
|
||
"questionsSha256": { "type": ["string", "null"] },
|
||
"answersSha256": { "type": ["string", "null"] }
|
||
}
|
||
}),
|
||
"agent.spawn_isolated" => json!({
|
||
"type": "object", "required": ["children", "joinMode"], "additionalProperties": false,
|
||
"properties": {
|
||
"children": {
|
||
"type": "array", "minItems": 1, "maxItems": 3,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["templateAgentId", "task", "acceptanceCriteria", "expectedArtifacts", "writeScopes"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"templateAgentId": { "type": "string", "minLength": 1 },
|
||
"task": { "type": "string", "minLength": 1 },
|
||
"acceptanceCriteria": string_array_schema(16),
|
||
"expectedArtifacts": string_array_schema(32),
|
||
"writeScopes": string_array_schema(16)
|
||
}
|
||
}
|
||
},
|
||
"joinMode": { "type": "string", "enum": ["all"] }
|
||
}
|
||
}),
|
||
"agent.goal_contract" => json!({
|
||
"type": "object",
|
||
"required": ["outcome", "nonNegotiables", "preferences", "forbiddenAssumptions", "openQuestions", "acceptanceNodes"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"outcome": { "type": "string", "minLength": 1, "maxLength": 4000 },
|
||
"nonNegotiables": string_array_schema(16),
|
||
"preferences": string_array_schema(16),
|
||
"forbiddenAssumptions": string_array_schema(16),
|
||
"openQuestions": string_array_schema(16),
|
||
"acceptanceNodes": {
|
||
"type": "array", "minItems": 1, "maxItems": 32,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["criterionId", "criterion", "required", "requiredEvidence", "dependsOn"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"criterionId": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||
"criterion": { "type": "string", "minLength": 1, "maxLength": 800 },
|
||
"required": { "type": "boolean" },
|
||
"requiredEvidence": {
|
||
"type": "array", "maxItems": 12,
|
||
"items": { "type": "string", "pattern": "^(tool:)?[a-z][a-z0-9._-]{0,79}$" }
|
||
},
|
||
"dependsOn": string_array_schema(16)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}),
|
||
"agent.acceptance_update" => json!({
|
||
"type": "object",
|
||
"required": ["contractFingerprint", "evaluations"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"contractFingerprint": { "type": "string", "minLength": 64, "maxLength": 64 },
|
||
"evaluations": {
|
||
"type": "array", "minItems": 1, "maxItems": 32,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["criterionId", "status", "evidence", "summary"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"criterionId": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||
"status": { "type": "string", "enum": ["passed", "failed", "not-observed"] },
|
||
"evidence": {
|
||
"type": "array", "maxItems": 16,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["agentId", "runId", "actionId"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"agentId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||
"runId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||
"actionId": { "type": "string", "minLength": 1, "maxLength": 96 }
|
||
}
|
||
}
|
||
},
|
||
"summary": { "type": "string", "minLength": 1, "maxLength": 1000 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}),
|
||
"agent.schedule_ready" => json!({
|
||
"type": "object", "required": ["limit"], "additionalProperties": false,
|
||
"properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 16 } }
|
||
}),
|
||
"agent.action_history" => json!({
|
||
"type": "object", "required": ["runId", "actionId", "tool", "status", "limit"], "additionalProperties": false,
|
||
"properties": {
|
||
"runId": { "type": ["string", "null"] },
|
||
"actionId": { "type": ["string", "null"] },
|
||
"tool": { "type": ["string", "null"] },
|
||
"status": { "type": ["string", "null"] },
|
||
"limit": { "type": "integer", "minimum": 1, "maximum": 10 }
|
||
}
|
||
}),
|
||
"agent.run_status" => json!({
|
||
"type": "object", "required": ["agentId", "scope", "delegationId"], "additionalProperties": false,
|
||
"properties": {
|
||
"agentId": { "type": ["string", "null"] },
|
||
"scope": { "type": "string", "enum": ["self", "all"] },
|
||
"delegationId": { "type": ["string", "null"] }
|
||
}
|
||
}),
|
||
_ => empty_input_schema(),
|
||
}
|
||
}
|
||
|
||
fn one_string_input_schema(field: &str) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"required": [field],
|
||
"additionalProperties": false,
|
||
"properties": { (field): { "type": "string" } }
|
||
})
|
||
}
|
||
|
||
fn two_string_input_schema(first: &str, second: &str) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"required": [first, second],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
(first): { "type": "string" },
|
||
(second): { "type": "string" }
|
||
}
|
||
})
|
||
}
|
||
|
||
fn command_start_input_schema() -> Value {
|
||
json!({
|
||
"type": "object", "required": ["program", "args", "cwd", "timeoutSeconds"], "additionalProperties": false,
|
||
"properties": {
|
||
"program": { "type": "string", "minLength": 1 },
|
||
"args": { "type": "array", "items": { "type": "string" } },
|
||
"cwd": { "type": "string" },
|
||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 }
|
||
}
|
||
})
|
||
}
|
||
|
||
fn project_patchset_input_schema() -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"required": ["changes"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"changes": {
|
||
"type": "array", "minItems": 1, "maxItems": 12,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["operation", "path", "content", "expectedSha256", "oldText", "newText", "expectedReplacements"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"operation": { "type": "string", "enum": ["create", "update", "delete"] },
|
||
"path": { "type": "string", "minLength": 1 },
|
||
"content": { "type": ["string", "null"] },
|
||
"expectedSha256": { "type": ["string", "null"], "minLength": 64, "maxLength": 64 },
|
||
"oldText": { "type": ["string", "null"], "minLength": 1 },
|
||
"newText": { "type": ["string", "null"] },
|
||
"expectedReplacements": { "type": ["integer", "null"], "minimum": 1, "maximum": 100 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn collect_openai_strict_schema_issues(schema: &Value, path: &str, issues: &mut Vec<String>) {
|
||
let Some(object) = schema.as_object() else {
|
||
return;
|
||
};
|
||
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
|
||
if object.contains_key(keyword) {
|
||
issues.push(format!(
|
||
"strict schema contains unsupported {keyword} at {path}"
|
||
));
|
||
}
|
||
}
|
||
if let Some(properties) = object.get("properties").and_then(Value::as_object) {
|
||
if object.get("additionalProperties") != Some(&Value::Bool(false)) {
|
||
issues.push(format!(
|
||
"strict object must reject additional properties at {path}"
|
||
));
|
||
}
|
||
let property_names = properties.keys().cloned().collect::<BTreeSet<_>>();
|
||
let required_names = object
|
||
.get("required")
|
||
.and_then(Value::as_array)
|
||
.map(|required| {
|
||
required
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.map(ToString::to_string)
|
||
.collect::<BTreeSet<_>>()
|
||
});
|
||
if required_names.as_ref() != Some(&property_names) {
|
||
issues.push(format!(
|
||
"strict object must require every property at {path}: required={required_names:?}, properties={property_names:?}"
|
||
));
|
||
}
|
||
for (name, child) in properties {
|
||
collect_openai_strict_schema_issues(
|
||
child,
|
||
&format!("{path}/properties/{name}"),
|
||
issues,
|
||
);
|
||
}
|
||
}
|
||
if let Some(items) = object.get("items") {
|
||
collect_openai_strict_schema_issues(items, &format!("{path}/items"), issues);
|
||
}
|
||
}
|
||
|
||
fn valid_delegate_input(repair_of_delegation_id: Value, run_id: Value) -> Value {
|
||
json!({
|
||
"agentId": "specialist",
|
||
"task": "完成委派任务",
|
||
"acceptanceCriteria": ["定向测试通过"],
|
||
"expectedArtifacts": [],
|
||
"repairOfDelegationId": repair_of_delegation_id,
|
||
"runId": run_id,
|
||
"continuationOfDelegationId": null,
|
||
"questionsSha256": null,
|
||
"answersSha256": null,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_accepts_complete_clarification_continuation_binding() {
|
||
let mut input = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
let object = input.as_object_mut().expect("delegate input object");
|
||
object.insert(
|
||
"continuationOfDelegationId".to_string(),
|
||
json!("delegation-id"),
|
||
);
|
||
object.insert("questionsSha256".to_string(), json!("a".repeat(64)));
|
||
object.insert("answersSha256".to_string(), json!("b".repeat(64)));
|
||
|
||
validate_native_agent_delegate_input(&input)
|
||
.expect("complete clarification continuation binding");
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_accepts_legacy_input_without_clarification_fields() {
|
||
let mut input = valid_delegate_input(Value::Null, Value::Null);
|
||
let object = input.as_object_mut().expect("delegate input object");
|
||
object.remove("continuationOfDelegationId");
|
||
object.remove("questionsSha256");
|
||
object.remove("answersSha256");
|
||
|
||
validate_native_agent_delegate_input(&input)
|
||
.expect("legacy delegate input without clarification fields");
|
||
}
|
||
|
||
/// 返工/续跑的两个数组必须逐字继承原委派,Runtime 读得到权威值。要求 Supervisor
|
||
/// 手抄它们只会反复失败:实测一次两轮澄清的 plan run 里,
|
||
/// 「静态委派返工必须完整继承原 acceptanceCriteria 和 expectedArtifacts」
|
||
/// 出现 4 次,每建一条 continuation 先白跑两轮工具调用。
|
||
#[test]
|
||
fn native_agent_delegate_repair_may_inherit_the_original_contract() {
|
||
let mut inherit = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
let object = inherit.as_object_mut().expect("delegate input object");
|
||
object.insert("acceptanceCriteria".to_string(), Value::Null);
|
||
object.insert("expectedArtifacts".to_string(), Value::Null);
|
||
|
||
validate_native_agent_delegate_input(&inherit).expect("repair may inherit the contract");
|
||
|
||
// 初次委派没有可继承的原 delivery,两个数组仍然必须自己写。
|
||
let mut initial = valid_delegate_input(Value::Null, json!("run-1"));
|
||
let object = initial.as_object_mut().expect("delegate input object");
|
||
object.insert("acceptanceCriteria".to_string(), Value::Null);
|
||
object.insert("expectedArtifacts".to_string(), Value::Null);
|
||
assert!(validate_native_agent_delegate_input(&initial)
|
||
.expect_err("initial delegate must still carry its own contract")
|
||
.to_string()
|
||
.contains("acceptanceCriteria"));
|
||
|
||
// 只省一半是有歧义的输入,不放行。
|
||
let mut half = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
let object = half.as_object_mut().expect("delegate input object");
|
||
object.insert("acceptanceCriteria".to_string(), Value::Null);
|
||
assert!(validate_native_agent_delegate_input(&half)
|
||
.expect_err("half-omitted contract must fail")
|
||
.to_string()
|
||
.contains("acceptanceCriteria"));
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_accepts_clarification_continuation_without_fingerprints() {
|
||
// 指纹由 Runtime 从原 delivery 补齐,Supervisor 只需要指出续跑的是哪个委派。
|
||
let mut anchor_only = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
anchor_only
|
||
.as_object_mut()
|
||
.expect("delegate input object")
|
||
.insert(
|
||
"continuationOfDelegationId".to_string(),
|
||
json!("delegation-id"),
|
||
);
|
||
|
||
validate_native_agent_delegate_input(&anchor_only)
|
||
.expect("clarification continuation without fingerprints");
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() {
|
||
let mut single_digest = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
let object = single_digest
|
||
.as_object_mut()
|
||
.expect("delegate input object");
|
||
object.insert(
|
||
"continuationOfDelegationId".to_string(),
|
||
json!("delegation-id"),
|
||
);
|
||
object.insert("questionsSha256".to_string(), json!("a".repeat(64)));
|
||
assert!(validate_native_agent_delegate_input(&single_digest)
|
||
.expect_err("single continuation fingerprint must fail")
|
||
.to_string()
|
||
.contains("必须成对提供"));
|
||
|
||
let mut orphan_digests = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
let object = orphan_digests
|
||
.as_object_mut()
|
||
.expect("delegate input object");
|
||
object.insert("questionsSha256".to_string(), json!("a".repeat(64)));
|
||
object.insert("answersSha256".to_string(), json!("b".repeat(64)));
|
||
assert!(validate_native_agent_delegate_input(&orphan_digests)
|
||
.expect_err("continuation fingerprints without anchor must fail")
|
||
.to_string()
|
||
.contains("必须同时提供 continuationOfDelegationId"));
|
||
|
||
let mut invalid_sha = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
let object = invalid_sha.as_object_mut().expect("delegate input object");
|
||
object.insert(
|
||
"continuationOfDelegationId".to_string(),
|
||
json!("delegation-id"),
|
||
);
|
||
object.insert("questionsSha256".to_string(), json!("z".repeat(64)));
|
||
object.insert("answersSha256".to_string(), json!("b".repeat(64)));
|
||
assert!(validate_native_agent_delegate_input(&invalid_sha)
|
||
.expect_err("invalid continuation sha must fail")
|
||
.to_string()
|
||
.contains("SHA-256"));
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_repair_rejects_string_run_id() {
|
||
let repair_id = "delegation-value-must-not-leak";
|
||
let run_id = "run-value-must-not-leak";
|
||
let error = validate_native_agent_delegate_input(&valid_delegate_input(
|
||
json!(repair_id),
|
||
json!(run_id),
|
||
))
|
||
.expect_err("repair delegate must not accept a string runId");
|
||
|
||
assert_eq!(
|
||
error.kind(),
|
||
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema
|
||
);
|
||
let detail = error.to_string();
|
||
assert_eq!(
|
||
detail,
|
||
"Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null"
|
||
);
|
||
assert!(!detail.contains(repair_id));
|
||
assert!(!detail.contains(run_id));
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_repair_accepts_null_run_id() {
|
||
let input = valid_delegate_input(json!("delegation-id"), Value::Null);
|
||
|
||
validate_native_agent_delegate_input(&input)
|
||
.expect("repair delegate should accept a null runId");
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_initial_accepts_string_run_id() {
|
||
let input = valid_delegate_input(Value::Null, json!("initial-run-id"));
|
||
|
||
validate_native_agent_delegate_input(&input)
|
||
.expect("initial delegate should accept a valid string runId");
|
||
}
|
||
|
||
#[test]
|
||
fn native_agent_delegate_description_explains_repair_run_identity() {
|
||
let description = runtime_tool_description("agent.delegate");
|
||
|
||
assert!(description.contains("repairOfDelegationId 指向原 delivery"));
|
||
assert!(description.contains("runId 必须为 null"));
|
||
// 广告给模型的 schema 一度把这两个数组写死成非空数组,模型于是根本无法
|
||
// 传 null,只能手抄——实测每条 continuation 固定先失败 2 次才抄对。
|
||
assert!(
|
||
description.contains("acceptanceCriteria 与 expectedArtifacts 一起传 null"),
|
||
"{description}"
|
||
);
|
||
let schema = runtime_tool_input_schema("agent.delegate");
|
||
for field in ["acceptanceCriteria", "expectedArtifacts"] {
|
||
let types = schema["properties"][field]["type"]
|
||
.as_array()
|
||
.unwrap_or_else(|| panic!("{field} 必须允许 null"));
|
||
assert!(
|
||
types.iter().any(|value| value == "null"),
|
||
"{field} 的广告 schema 必须允许 null,否则 Runtime 侧的继承分支永远走不到"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn native_goal_contract_and_acceptance_update_expose_dynamic_graph_contract() {
|
||
let goal = runtime_tool_input_schema("agent.goal_contract");
|
||
assert_eq!(
|
||
goal["required"],
|
||
json!([
|
||
"outcome",
|
||
"nonNegotiables",
|
||
"preferences",
|
||
"forbiddenAssumptions",
|
||
"openQuestions",
|
||
"acceptanceNodes"
|
||
])
|
||
);
|
||
assert_eq!(goal["properties"]["acceptanceNodes"]["minItems"], 1);
|
||
assert_eq!(goal["properties"]["acceptanceNodes"]["maxItems"], 32);
|
||
assert_eq!(
|
||
goal["properties"]["acceptanceNodes"]["items"]["properties"]["requiredEvidence"]
|
||
["items"]["pattern"],
|
||
"^(tool:)?[a-z][a-z0-9._-]{0,79}$"
|
||
);
|
||
assert_eq!(
|
||
goal["properties"]["acceptanceNodes"]["items"]["required"],
|
||
json!([
|
||
"criterionId",
|
||
"criterion",
|
||
"required",
|
||
"requiredEvidence",
|
||
"dependsOn"
|
||
])
|
||
);
|
||
|
||
let update = runtime_tool_input_schema("agent.acceptance_update");
|
||
assert_eq!(
|
||
update["required"],
|
||
json!(["contractFingerprint", "evaluations"])
|
||
);
|
||
assert_eq!(
|
||
update["properties"]["evaluations"]["items"]["properties"]["status"]["enum"],
|
||
json!(["passed", "failed", "not-observed"])
|
||
);
|
||
assert_eq!(
|
||
update["properties"]["evaluations"]["items"]["properties"]["evidence"]["items"]
|
||
["required"],
|
||
json!(["agentId", "runId", "actionId"])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn native_runtime_capability_registry_is_the_bidirectional_catalog() {
|
||
let registry = agent_runtime_native_capability_registry().expect("native registry");
|
||
let executable_tools = agent_runtime_native_executable_tools();
|
||
assert_eq!(registry.len(), executable_tools.len());
|
||
|
||
for tool in executable_tools {
|
||
let definition = registry.get(tool).expect("registered runtime tool");
|
||
assert_eq!(definition.id(), tool);
|
||
assert_eq!(definition.dispatch(), tool);
|
||
assert_eq!(
|
||
native_runtime_function_name(tool).as_deref(),
|
||
Some(definition.function_name())
|
||
);
|
||
assert_eq!(
|
||
runtime_tool_for_native_function(definition.function_name()).as_deref(),
|
||
Some(tool)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn godot_native_registry_follows_toggle_without_reusing_other_editor_cache() {
|
||
let _guard = crate::builtin_plugins::test_lock();
|
||
let config = tempfile::tempdir().unwrap();
|
||
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||
let project = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir(project.path().join("game")).unwrap();
|
||
std::fs::write(
|
||
project.path().join("game/project.godot"),
|
||
"config_version=5\n",
|
||
)
|
||
.unwrap();
|
||
let other_project = tempfile::tempdir().unwrap();
|
||
for enabled in [false, true, false, true] {
|
||
crate::builtin_plugins::set_enabled(
|
||
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
|
||
enabled,
|
||
)
|
||
.unwrap();
|
||
let expected = enabled
|
||
&& cfg!(all(
|
||
windows,
|
||
target_arch = "x86_64",
|
||
feature = "godot-editor-execute"
|
||
));
|
||
assert_eq!(
|
||
native_runtime_function_name("godot.editor.execute").is_some(),
|
||
expected
|
||
);
|
||
let name = native_runtime_function_name_for_tool("godot.editor.execute");
|
||
assert_eq!(
|
||
build_agent_runtime_native_function_tools_for_project(
|
||
project.path(),
|
||
"__all_agents__"
|
||
)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|tool| tool.name == name),
|
||
expected
|
||
);
|
||
assert!(!build_agent_runtime_native_function_tools_for_project(
|
||
other_project.path(),
|
||
"__all_agents__"
|
||
)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|tool| tool.name == name));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn godot_native_schema_cannot_override_execution_identity() {
|
||
let schema = runtime_tool_input_schema("godot.editor.execute");
|
||
assert_eq!(schema["additionalProperties"], false);
|
||
assert_eq!(schema["required"], json!(["code"]));
|
||
assert_eq!(schema["properties"].as_object().unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_guides_reach_native_tool_definitions_without_truncation() {
|
||
let _guard = crate::builtin_plugins::test_lock();
|
||
let config = tempfile::tempdir().unwrap();
|
||
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||
for id in [
|
||
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID,
|
||
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
|
||
] {
|
||
crate::builtin_plugins::set_enabled(id, true).unwrap();
|
||
}
|
||
let functions = build_agent_runtime_native_function_tools().unwrap();
|
||
for (engine, skill, tool) in [
|
||
("Unity", "agc-unity-editor", "unity.editor.execute"),
|
||
("Godot", "agc-godot-editor", "godot.editor.execute"),
|
||
] {
|
||
let reference = crate::agent::read_agc_skill_resource(&format!(
|
||
"{skill}/references/【操作指南】{engine}编辑器常用操作-2026-09-20.md"
|
||
))
|
||
.unwrap();
|
||
assert_eq!(
|
||
editor_operation_reference(tool)
|
||
.unwrap()
|
||
.replace("\r\n", "\n"),
|
||
reference
|
||
);
|
||
let emitted = functions
|
||
.iter()
|
||
.find(|function| function.name == native_runtime_function_name_for_tool(tool));
|
||
let expected = match engine {
|
||
"Unity" => cfg!(all(
|
||
windows,
|
||
target_arch = "x86_64",
|
||
feature = "unity-editor-execute"
|
||
)),
|
||
"Godot" => cfg!(all(
|
||
windows,
|
||
target_arch = "x86_64",
|
||
feature = "godot-editor-execute"
|
||
)),
|
||
_ => false,
|
||
};
|
||
assert_eq!(emitted.is_some(), expected);
|
||
if let Some(function) = emitted {
|
||
let wire = serde_json::to_value(function).unwrap();
|
||
assert_eq!(
|
||
wire["description"].as_str().unwrap().replace("\r\n", "\n"),
|
||
reference
|
||
);
|
||
} else {
|
||
assert!(!functions
|
||
.iter()
|
||
.any(|function| function.description.contains(&reference)));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn strict_native_function_schemas_match_openai_subset() {
|
||
let functions =
|
||
build_agent_runtime_native_function_tools().expect("build native function tools");
|
||
let mut issues = Vec::new();
|
||
for function in functions.iter().filter(|function| function.strict) {
|
||
collect_openai_strict_schema_issues(&function.parameters, &function.name, &mut issues);
|
||
}
|
||
assert!(issues.is_empty(), "{}", issues.join("\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn canvas_asset_generate_schema_uses_shared_asset_kind_catalog() {
|
||
let schema = runtime_tool_input_schema("canvas.asset_generate");
|
||
let mut expected = AGENT_RUNTIME_CANVAS_ASSET_KINDS
|
||
.iter()
|
||
.map(|kind| Value::String(kind.as_str().to_string()))
|
||
.collect::<Vec<_>>();
|
||
expected.push(Value::Null);
|
||
|
||
assert_eq!(
|
||
schema.pointer("/properties/assetKind/enum"),
|
||
Some(&Value::Array(expected))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn action_function_parameters_rebases_local_schema_refs_after_wrapping() {
|
||
let parameters = action_function_parameters(json!({
|
||
"type": "object",
|
||
"$defs": {
|
||
"Mode": {"type": "string", "enum": ["fast", "safe"]},
|
||
"Options": {
|
||
"type": "object",
|
||
"properties": {"mode": {"$ref": "#/$defs/Mode"}},
|
||
"required": ["mode"],
|
||
"additionalProperties": false
|
||
}
|
||
},
|
||
"properties": {
|
||
"options": {"$ref": "#/$defs/Options"},
|
||
"recursive": {"$ref": "#"},
|
||
"anchor": {"$ref": "#Mode"},
|
||
"scoped": {
|
||
"$id": "nested.json",
|
||
"$defs": {"Value": {"type": "string"}},
|
||
"properties": {"value": {"$ref": "#/$defs/Value"}}
|
||
},
|
||
"external": {"$ref": "https://schemas.example/tool.json"}
|
||
},
|
||
"required": ["options"],
|
||
"additionalProperties": false
|
||
}));
|
||
|
||
let input = ¶meters["properties"]["input"];
|
||
assert_eq!(
|
||
input["properties"]["options"]["$ref"],
|
||
"#/properties/input/$defs/Options"
|
||
);
|
||
assert_eq!(
|
||
input["$defs"]["Options"]["properties"]["mode"]["$ref"],
|
||
"#/properties/input/$defs/Mode"
|
||
);
|
||
assert_eq!(
|
||
input["properties"]["recursive"]["$ref"],
|
||
"#/properties/input"
|
||
);
|
||
assert_eq!(input["properties"]["anchor"]["$ref"], "#Mode");
|
||
assert_eq!(
|
||
input["properties"]["scoped"]["properties"]["value"]["$ref"],
|
||
"#/$defs/Value"
|
||
);
|
||
assert_eq!(
|
||
input["properties"]["external"]["$ref"],
|
||
"https://schemas.example/tool.json"
|
||
);
|
||
for reference in [
|
||
input["properties"]["options"]["$ref"]
|
||
.as_str()
|
||
.expect("options ref"),
|
||
input["$defs"]["Options"]["properties"]["mode"]["$ref"]
|
||
.as_str()
|
||
.expect("mode ref"),
|
||
input["properties"]["recursive"]["$ref"]
|
||
.as_str()
|
||
.expect("recursive ref"),
|
||
] {
|
||
assert!(
|
||
parameters
|
||
.pointer(reference.trim_start_matches('#'))
|
||
.is_some(),
|
||
"rebased ref must resolve: {reference}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn action_function_parameters_preserves_refs_inside_schema_data_keywords() {
|
||
let parameters = action_function_parameters(json!({
|
||
"type": "object",
|
||
"$defs": {
|
||
"Value": {"type": "string"}
|
||
},
|
||
"properties": {
|
||
"value": {
|
||
"$ref": "#/$defs/Value",
|
||
"default": {"$ref": "#/literal-default"},
|
||
"const": {
|
||
"nested": [{"$ref": "#/literal-const"}]
|
||
},
|
||
"examples": [
|
||
{"$ref": "#/literal-example"},
|
||
[{"$ref": "#/nested-literal-example"}]
|
||
]
|
||
}
|
||
},
|
||
"required": ["value"],
|
||
"additionalProperties": false
|
||
}));
|
||
|
||
let value = ¶meters["properties"]["input"]["properties"]["value"];
|
||
assert_eq!(value["$ref"], "#/properties/input/$defs/Value");
|
||
assert_eq!(value["default"]["$ref"], "#/literal-default");
|
||
assert_eq!(value["const"]["nested"][0]["$ref"], "#/literal-const");
|
||
assert_eq!(value["examples"][0]["$ref"], "#/literal-example");
|
||
assert_eq!(value["examples"][1][0]["$ref"], "#/nested-literal-example");
|
||
}
|
||
|
||
#[test]
|
||
fn native_project_patchset_normalizes_nullable_strict_shape() {
|
||
let arguments = json!({
|
||
"reason": "原子应用三类变更",
|
||
"input": {
|
||
"changes": [
|
||
{
|
||
"operation": "create",
|
||
"path": "game/new.txt",
|
||
"content": "created",
|
||
"expectedSha256": null,
|
||
"oldText": null,
|
||
"newText": null,
|
||
"expectedReplacements": null
|
||
},
|
||
{
|
||
"operation": "update",
|
||
"path": "game/main.txt",
|
||
"content": null,
|
||
"expectedSha256": "a".repeat(64),
|
||
"oldText": "before",
|
||
"newText": "after",
|
||
"expectedReplacements": 1
|
||
},
|
||
{
|
||
"operation": "delete",
|
||
"path": "game/old.txt",
|
||
"content": null,
|
||
"expectedSha256": "b".repeat(64),
|
||
"oldText": null,
|
||
"newText": null,
|
||
"expectedReplacements": null
|
||
}
|
||
]
|
||
}
|
||
});
|
||
let parsed = parse_agent_runtime_native_tool_calls(&[LlmToolCall {
|
||
id: "patchset-call".to_string(),
|
||
name: native_runtime_function_name("project.patchset")
|
||
.expect("native patchset function name"),
|
||
arguments: serde_json::to_string(&arguments).expect("serialize arguments"),
|
||
}])
|
||
.expect("parse strict patchset call");
|
||
|
||
assert_eq!(
|
||
parsed.plan.actions[0].input,
|
||
json!({
|
||
"changes": [
|
||
{"operation": "create", "path": "game/new.txt", "content": "created"},
|
||
{
|
||
"operation": "update",
|
||
"path": "game/main.txt",
|
||
"expectedSha256": "a".repeat(64),
|
||
"oldText": "before",
|
||
"newText": "after",
|
||
"expectedReplacements": 1
|
||
},
|
||
{"operation": "delete", "path": "game/old.txt", "expectedSha256": "b".repeat(64)}
|
||
]
|
||
})
|
||
);
|
||
}
|
||
}
|