Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
T
kdletters 0f829cd252
Project CI / Repository checks (push) Failing after 2m44s
Project CI / Frontend tests (push) Failing after 2m53s
Project CI / Native shell tests (push) Failing after 6m39s
Project CI / Backend tests (push) Successful in 8m7s
完成 DirectProject Phaser 迁移闭环 (#353)
## 变更

单 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
2026-09-14 13:57:24 +08:00

2045 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = &parameters["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 = &parameters["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)}
]
})
);
}
}