0f829cd252
## 变更 单 HTML → Phaser 4 + Vite 的迁移链路现在有明确且可执行的工程合同:Phaser/npm 目标固定走 DirectProject,旧 JSON Generator 继续只处理单文件 HTML。 - 新增受控 `project.bootstrap`,仅允许项目内 `game` 目录执行无参数 `npm install`,校验 package/lock、路径、npm 管理器并记录依赖指纹。 - `project.verify` 支持相对 `cwd`,构建前诊断缺失依赖,`cwd=game` 的 build 必须产出 `game/dist/index.html`。 - npm bootstrap 使用受控网络沙箱;通用 `command.exec npm install` 仍被拒绝。 - 同步 Runtime 工具广告、执行分发、审计、权限策略、DirectProject/Generator 提示、前端权限面板、TypeScript/Rust 契约和项目文档。 - Phaser 迁移提示覆盖 Scene、输入、敌人/守卫、波次、胜负、重开和桌面/移动双视口验收。 ## 验证 - `npm run ai-game-creator-shell:typecheck` - `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` - `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` - `cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app` - 定向 `project.verify` Rust 测试 - `npm run check:encoding` - `npm run check:doc-index` - `git diff --check` - 真实 DirectProject:`npm install`、`npm run build` 成功,`game/dist/index.html` 存在。 - 浏览器试玩:桌面视口画布非空;移动视口触摸开火、键盘换道和重开逻辑均已观察确认。 真实项目没有纳入仓库提交;PR 只包含客户端合同、Runtime 能力和验证门禁。 Reviewed-on: #353
2045 lines
86 KiB
Rust
2045 lines
86 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>
|
||
{
|
||
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
|
||
// 生效,又不需要每次调用都重建 registry。
|
||
static ENABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
|
||
static DISABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> =
|
||
OnceLock::new();
|
||
// 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。
|
||
let tools = agent_runtime_native_executable_tools();
|
||
let cache = if tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) {
|
||
&ENABLED_REGISTRY
|
||
} else {
|
||
&DISABLED_REGISTRY
|
||
};
|
||
cache
|
||
.get_or_init(|| build_agent_runtime_native_capability_registry(tools))
|
||
.as_ref()
|
||
.map_err(Clone::clone)
|
||
}
|
||
|
||
/// 不带身份的全量目录,**只允许测试使用**。
|
||
///
|
||
/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是
|
||
/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实
|
||
/// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact
|
||
/// allowlist)会被静默绕开。这里用 `#[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}"));
|
||
}
|
||
functions.push(
|
||
LlmFunctionTool::new(
|
||
name,
|
||
definition.description(),
|
||
action_function_parameters(definition.input_schema().clone()),
|
||
)
|
||
.with_strict(true),
|
||
);
|
||
}
|
||
|
||
Ok(functions)
|
||
}
|
||
|
||
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,
|
||
"创建或更新当前 run 的持久计划。可单独调用作为持久进度 checkpoint,也可与本轮动作工具或最终回复一起调用;没有真实进度变化时不要调用。",
|
||
plan_update_schema(),
|
||
)
|
||
.with_strict(true)
|
||
}
|
||
|
||
fn response_function_tool() -> LlmFunctionTool {
|
||
LlmFunctionTool::new(
|
||
AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||
"已有观察足够且不再需要工具时,提交给用户的最终回复。不能与动作工具同时调用。",
|
||
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 runtime_tool_description(tool: &str) -> &'static str {
|
||
match tool {
|
||
"user.input_request" => "向用户提出一至三个结构化问题,并暂停当前 run 等待回答。",
|
||
"memory.read" => "读取当前 Agent、Session、项目或黑板记忆。",
|
||
"memory.write" => "写入当前 Agent 自己或项目范围的稳定记忆。",
|
||
"conversation.read" => "读取当前 Agent Session 的最近对话。",
|
||
"asset.list" => {
|
||
"读取项目 manifest 正式资产,并附带有界的项目内未登记媒体候选;未登记文件只有路径/大小/MIME 元数据,不具备 assetId 或 provenance,正式使用前须通过 canvas.asset_import 登记。"
|
||
}
|
||
"asset.library.list" => {
|
||
"读取当前登录账户的云端/网页素材库静态图片,以及当前项目已绑定网页画布 project.resources 图片安全投影;不返回 URL、objectKey、签名地址或凭据。"
|
||
}
|
||
"canvas.asset_import" => {
|
||
"把账户素材库/绑定网页项目画布中的 assetId(resourceId)或项目内 localPaths 资源导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地资源先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
|
||
}
|
||
"project.index" => "刷新并读取有界仓库启动上下文。",
|
||
"project.search" => "在项目文本文件中做有界字面量搜索。",
|
||
"project.verify" => "在项目或指定相对 cwd 中运行 package.json 原样声明的验证脚本,并检查构建产物。",
|
||
"project.bootstrap" => "仅在项目 game 目录受控执行无参数 npm install,并记录依赖文件指纹。",
|
||
"project.checkpoint" => "创建项目本地 checkpoint。",
|
||
"project.restore" => "从 checkpoint 恢复当前项目。",
|
||
"project.diff" => "读取 checkpoint 与当前项目之间的有界差异。",
|
||
"git.inspect" => "只读审阅当前 Git 工作树和有界 diff。",
|
||
"project.git_commit" => "在验证和审阅后创建只包含显式路径的本地 Git 提交。",
|
||
"project.patchset" => "在一把项目锁内原子应用最多十二项多文件变更。",
|
||
"file.list" => "列出项目内安全文件摘要。",
|
||
"file.read" => "按行读取项目内安全文本文件。",
|
||
"file.write" => "写入一个项目内文本文件的完整内容。",
|
||
"file.patch" => "用精确 oldText 匹配局部替换一个项目文件。",
|
||
"file.delete" => "删除一个项目内普通文件。",
|
||
"task.list" => "读取 manifest 任务图和 ready 任务。",
|
||
"task.create" => "向 manifest 追加一个经过校验的新任务。",
|
||
"task.update" => "更新一个已有 manifest 任务的状态。",
|
||
"command.exec" => "在工作区沙箱中执行一次受控命令并持久化输出。",
|
||
"command.output_read" => "分页读取已有 command.exec 的私有清洗输出。",
|
||
"command.start" => "在工作区沙箱中启动一个持久进程会话。",
|
||
"command.poll" => "按 cursor 增量读取持久进程输出和状态。",
|
||
"command.stdin" => "向当前 run 的持久进程写入 UTF-8 stdin。",
|
||
"command.terminate" => "请求终止当前 run 的持久进程。",
|
||
"command.run_limited" => "执行固定白名单中的本地项目命令。",
|
||
"preview.start" => "启动当前项目的 loopback HTTP 预览。",
|
||
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
|
||
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
|
||
"canvas.asset_generate" => {
|
||
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。"
|
||
}
|
||
"ui.workflow.run" => {
|
||
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
|
||
}
|
||
"cocos.editor.execute" => {
|
||
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
|
||
}
|
||
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
|
||
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
|
||
"agent.delegate" => {
|
||
"用持久验收合同把边界清晰的后台任务委派给另一个 Agent;返工时 repairOfDelegationId 指向原 delivery,runId 必须为 null,acceptanceCriteria 与 expectedArtifacts 一起传 null 由 Runtime 从原 delivery 继承。"
|
||
}
|
||
"agent.spawn_isolated" => "创建最多三个写范围互不重叠的隔离子 Agent。",
|
||
"agent.goal_contract" => {
|
||
"由根 Project Supervisor 提交本次根 Run 的结构化最终目标、约束、开放问题和动态验收图;同一根 Run 写入后不可改写。requiredEvidence 的每一项必须是可机读 Runtime 工具名(推荐 tool:<name>),passed 时必须由这些工具的当前 revision 成功回执逐项证明。"
|
||
}
|
||
"agent.acceptance_update" => {
|
||
"由根 Project Supervisor 依据当前根任务树中的持久证据更新动态验收节点;未提交的已通过节点保持不变。"
|
||
}
|
||
"agent.schedule_ready" => "调度依赖已完成的 ready manifest 任务。",
|
||
"agent.action_history" => "查询当前 Agent 的持久终态动作历史。",
|
||
"agent.run_status" => "读取自己或其他 Agent 的 Runtime 状态摘要;当前可信父 Run 可按 delegationId 取回自己已认领的权威返工合同。",
|
||
_ => "执行一个受 Runtime 白名单和项目策略保护的工具动作。",
|
||
}
|
||
}
|
||
|
||
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" => 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).to_string()))
|
||
.collect::<Vec<_>>();
|
||
asset_kinds.push(Value::Null);
|
||
json!({
|
||
"type": "object",
|
||
"required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting"],
|
||
"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" }
|
||
}
|
||
})
|
||
}
|
||
"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": "初次委派必填。带 repairOfDelegationId 的返工或澄清续跑传 null:Runtime 会从原 delivery 继承权威合同,手抄一遍不增加任何信息,抄错会被直接拒收。" },
|
||
"expectedArtifacts": { "type": ["array", "null"], "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 }, "description": "与 acceptanceCriteria 同进同出:初次委派必填,返工与澄清续跑一起传 null 由 Runtime 继承。" },
|
||
"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 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).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)}
|
||
]
|
||
})
|
||
);
|
||
}
|
||
}
|