fix: editor agent llm resp treat null as missing
This commit is contained in:
@@ -592,6 +592,56 @@ mod tests {
|
||||
assert!(!messages[0].text.contains("等待用户确认"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_video_with_null_defaults_persists_and_displays_concrete_values() {
|
||||
let pricing =
|
||||
crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
|
||||
.expect("default editor pricing should load");
|
||||
let messages = build_delta_messages(
|
||||
Ok(vec![PromptOutput::Tool(ToolCallOutput {
|
||||
tool_call: ToolCall {
|
||||
id: "tool-call-1".to_string(),
|
||||
name: GenerateVideoTool::NAME.to_string(),
|
||||
args: json!({
|
||||
"prompt": "镜头向前推进",
|
||||
"aspect_ratio": null,
|
||||
"duration_seconds": null,
|
||||
"resolution": null,
|
||||
"sound": null
|
||||
}),
|
||||
},
|
||||
output: json!({ "message": "runner pending output" }),
|
||||
})]),
|
||||
"2026-07-23T00:00:00Z",
|
||||
0,
|
||||
&EditorToolContext::default(),
|
||||
&pricing,
|
||||
)
|
||||
.expect("pending video with null defaults should build");
|
||||
|
||||
let tool_call = messages[0]
|
||||
.tool_call
|
||||
.as_ref()
|
||||
.expect("pending message should retain its tool call");
|
||||
assert_eq!(tool_call.args["aspect_ratio"], "16:9");
|
||||
assert_eq!(tool_call.args["duration_seconds"], 4);
|
||||
assert_eq!(tool_call.args["resolution"], "720p");
|
||||
assert_eq!(tool_call.args["sound"], "on");
|
||||
|
||||
let display_value = |name: &str| {
|
||||
tool_call
|
||||
.display_args
|
||||
.string_args
|
||||
.iter()
|
||||
.find(|arg| arg.name == name)
|
||||
.map(|arg| arg.value.as_str())
|
||||
};
|
||||
assert_eq!(display_value("aspect_ratio"), Some("16:9"));
|
||||
assert_eq!(display_value("duration_seconds"), Some("4"));
|
||||
assert_eq!(display_value("resolution"), Some("720p"));
|
||||
assert_eq!(display_value("sound"), Some("on"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_timeout_applies_to_the_whole_agent_run() {
|
||||
let error = run_editor_agent_prompt_with_timeout(
|
||||
@@ -1004,6 +1054,7 @@ pub async fn confirm_editor_agent_tool_call(
|
||||
.tool_call
|
||||
.as_mut()
|
||||
.ok_or_else(|| editor_agent_bad_request("message has no tool call"))?;
|
||||
tool_call.args = normalized_args;
|
||||
tool_call.external_job_id = Some(job.job_id);
|
||||
tool_call.status = EditorAgentToolCallStatus::NotCompleted;
|
||||
write_messages_document(&state, &conversation, &document).await?;
|
||||
|
||||
@@ -210,6 +210,8 @@ fn reconcile_completed_editor_agent_tool_call(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEffectTool;
|
||||
use platform_editor_agent::framework::tool::Tool;
|
||||
use serde_json::json;
|
||||
|
||||
fn pending_tool_message() -> EditorAgentMessage {
|
||||
@@ -298,4 +300,59 @@ mod tests {
|
||||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::NotCompleted);
|
||||
assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_legacy_tool_call_with_null_defaults_still_reconciles() {
|
||||
let mut message: EditorAgentMessage = serde_json::from_value(json!({
|
||||
"id": 1,
|
||||
"role": "system",
|
||||
"text": "waiting",
|
||||
"attachments": [],
|
||||
"toolCall": {
|
||||
"toolName": GenerateSoundEffectTool::NAME,
|
||||
"status": "not_completed",
|
||||
"args": {
|
||||
"prompt": "按钮点击声",
|
||||
"model": null,
|
||||
"duration": null
|
||||
},
|
||||
"displayArgs": {
|
||||
"stringArgs": [],
|
||||
"imageArgs": [],
|
||||
"extras": { "priceMudPoints": 5 }
|
||||
},
|
||||
"externalJobId": "job-1",
|
||||
"images": [],
|
||||
"audios": []
|
||||
},
|
||||
"createdAt": "2026-07-16T00:00:00Z"
|
||||
}))
|
||||
.expect("legacy pending sound message should deserialize");
|
||||
let payload = json!({
|
||||
"editor-agent-tool-call-result": {
|
||||
"ok": true,
|
||||
"audioSrc": "/generated/click.mp3",
|
||||
"objectKey": "generated/click.mp3",
|
||||
"assetObjectId": "asset-1",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"sourceType": "generated",
|
||||
"prompt": "按钮点击声",
|
||||
"model": "audio1.0",
|
||||
"provider": "vectorengine",
|
||||
"taskId": "task-1",
|
||||
"priceMudPoints": 5,
|
||||
"audioKind": "sound-effect"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
reconcile_completed_editor_agent_tool_call(&mut message, Some(payload.as_str()))
|
||||
.expect("legacy null defaults should use current tool defaults during reconciliation");
|
||||
|
||||
let tool_call = message.tool_call.expect("tool call should remain present");
|
||||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
|
||||
assert_eq!(tool_call.audios.len(), 1);
|
||||
assert!(message.text.contains("\"duration\":5"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use platform_editor_agent::agent::tools::generate_video::{
|
||||
GenerateVideoTool, GenerateVideoToolArgs,
|
||||
};
|
||||
use platform_editor_agent::framework::error::PromptError;
|
||||
use platform_editor_agent::framework::tool::{Tool, ToolDyn};
|
||||
use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing};
|
||||
use platform_image::GPT_IMAGE_2_MODEL;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -198,7 +198,7 @@ fn parse_invalid_args<T: DeserializeOwned>(
|
||||
tool_name: &str,
|
||||
value: &Value,
|
||||
) -> Result<T, EditorAgentToolError> {
|
||||
serde_json::from_value(value.clone()).map_err(|error| {
|
||||
serde_json::from_value(null_tool_args_as_missing(value.clone())).map_err(|error| {
|
||||
EditorAgentToolError::invalid_args(format!(
|
||||
"failed to deserialize {tool_name} args: {error}"
|
||||
))
|
||||
@@ -209,7 +209,7 @@ fn parse_internal<T: DeserializeOwned>(
|
||||
label: &str,
|
||||
value: &Value,
|
||||
) -> Result<T, EditorAgentToolError> {
|
||||
serde_json::from_value(value.clone()).map_err(|error| {
|
||||
serde_json::from_value(null_tool_args_as_missing(value.clone())).map_err(|error| {
|
||||
EditorAgentToolError::internal(format!("failed to deserialize {label}: {error}"))
|
||||
})
|
||||
}
|
||||
@@ -1295,6 +1295,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyn_validation_treats_explicit_null_as_missing_before_canonical_persistence() {
|
||||
let image = editor_agent_tool(GenerateImageTool::NAME, &EditorToolContext::default())
|
||||
.expect("image tool should resolve");
|
||||
let image_args = image
|
||||
.validate_args(&json!({
|
||||
"prompt": "生成森林场景",
|
||||
"model": null,
|
||||
"reference_image_ids": null,
|
||||
"aspect_ratio": null,
|
||||
"image_size": null
|
||||
}))
|
||||
.expect("null image defaults should normalize");
|
||||
assert_eq!(image_args["model"], platform_image::NANOBANANA_2_MODEL);
|
||||
assert_eq!(image_args["reference_image_ids"], json!([]));
|
||||
assert_eq!(image_args["aspect_ratio"], "1:1");
|
||||
assert_eq!(image_args["image_size"], "1K");
|
||||
|
||||
let video = editor_agent_tool(GenerateVideoTool::NAME, &EditorToolContext::default())
|
||||
.expect("video tool should resolve");
|
||||
let video_args = video
|
||||
.validate_args(&json!({
|
||||
"prompt": "镜头向前推进",
|
||||
"model": null,
|
||||
"aspect_ratio": null,
|
||||
"duration_seconds": null,
|
||||
"resolution": null,
|
||||
"sound": null
|
||||
}))
|
||||
.expect("null video defaults should normalize");
|
||||
assert_eq!(video_args["model"], GenerateVideoTool::DEFAULT_VIDEO_MODEL);
|
||||
assert_eq!(video_args["aspect_ratio"], "16:9");
|
||||
assert_eq!(video_args["duration_seconds"], 4);
|
||||
assert_eq!(video_args["resolution"], "720p");
|
||||
assert_eq!(video_args["sound"], "on");
|
||||
|
||||
let sound = editor_agent_tool(GenerateSoundEffectTool::NAME, &EditorToolContext::default())
|
||||
.expect("sound tool should resolve");
|
||||
let sound_args = sound
|
||||
.validate_args(&json!({
|
||||
"prompt": "按钮点击声",
|
||||
"model": null,
|
||||
"duration": null
|
||||
}))
|
||||
.expect("null sound defaults should normalize");
|
||||
assert_eq!(sound_args["model"], GenerateSoundEffectTool::DEFAULT_MODEL);
|
||||
assert_eq!(sound_args["duration"], 5);
|
||||
|
||||
assert!(
|
||||
sound
|
||||
.validate_args(&json!({ "prompt": null, "duration": null }))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyn_validation_uses_the_context_bound_to_the_concrete_tool() {
|
||||
let tool = editor_agent_tool(EditImageTool::NAME, &context_with_image("image-1"))
|
||||
|
||||
@@ -89,28 +89,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_backed_tool_args_reject_null() {
|
||||
assert!(
|
||||
serde_json::from_value::<GenerateImageToolArgs>(json!({
|
||||
fn default_backed_tool_args_treat_null_as_missing_at_the_tool_boundary() {
|
||||
use crate::framework::tool::null_tool_args_as_missing;
|
||||
|
||||
let image: GenerateImageToolArgs =
|
||||
serde_json::from_value(null_tool_args_as_missing(json!({
|
||||
"prompt": "生成森林场景",
|
||||
"aspect_ratio": null
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<GenerateVideoToolArgs>(json!({
|
||||
"aspect_ratio": null,
|
||||
"image_size": null
|
||||
})))
|
||||
.expect("null image defaults should deserialize as omitted fields");
|
||||
let video: GenerateVideoToolArgs =
|
||||
serde_json::from_value(null_tool_args_as_missing(json!({
|
||||
"prompt": "镜头向前推进",
|
||||
"duration_seconds": null
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<GenerateSoundEffectToolArgs>(json!({
|
||||
"aspect_ratio": null,
|
||||
"duration_seconds": null,
|
||||
"resolution": null,
|
||||
"sound": null
|
||||
})))
|
||||
.expect("null video defaults should deserialize as omitted fields");
|
||||
let sound: GenerateSoundEffectToolArgs =
|
||||
serde_json::from_value(null_tool_args_as_missing(json!({
|
||||
"prompt": "按钮点击声",
|
||||
"duration": null
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
})))
|
||||
.expect("null sound defaults should deserialize as omitted fields");
|
||||
|
||||
assert_eq!(image.aspect_ratio, "1:1");
|
||||
assert_eq!(image.image_size, "1K");
|
||||
assert_eq!(video.aspect_ratio, "16:9");
|
||||
assert_eq!(video.duration_seconds, 4);
|
||||
assert_eq!(video.resolution, "720p");
|
||||
assert_eq!(video.sound, "on");
|
||||
assert_eq!(sound.duration, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
|
||||
// Treat explicit top-level JSON `null` tool arguments as omitted fields, for compatibility with llm
|
||||
pub fn null_tool_args_as_missing(mut args: serde_json::Value) -> serde_json::Value {
|
||||
if let serde_json::Value::Object(fields) = &mut args {
|
||||
fields.retain(|_, value| !value.is_null());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
@@ -183,7 +191,7 @@ impl<T: Tool + Send + Sync> ToolDyn for T {
|
||||
args: serde_json::Value,
|
||||
) -> Pin<Box<dyn Future<Output = ToolExecutionResult> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let parsed: T::Args = match serde_json::from_value(args) {
|
||||
let parsed: T::Args = match serde_json::from_value(null_tool_args_as_missing(args)) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
return ToolExecutionResult::failed(
|
||||
@@ -213,3 +221,55 @@ impl<T: Tool + Send + Sync> ToolDyn for T {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::convert::Infallible;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DefaultBackedArgs {
|
||||
#[serde(default = "default_duration")]
|
||||
duration: u32,
|
||||
}
|
||||
|
||||
fn default_duration() -> u32 {
|
||||
4
|
||||
}
|
||||
|
||||
struct DefaultBackedTool;
|
||||
|
||||
impl Tool for DefaultBackedTool {
|
||||
const NAME: &'static str = "default-backed-tool";
|
||||
type Error = Infallible;
|
||||
type Args = DefaultBackedArgs;
|
||||
type Output = u32;
|
||||
|
||||
fn description(&self) -> String {
|
||||
"test default-backed tool".to_string()
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({ "type": "object" })
|
||||
}
|
||||
|
||||
fn call(
|
||||
&self,
|
||||
args: Self::Args,
|
||||
) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send {
|
||||
async move { Ok(args.duration) }
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dyn_tool_treats_explicit_null_as_an_omitted_default_backed_field() {
|
||||
let result =
|
||||
<DefaultBackedTool as ToolDyn>::call(&DefaultBackedTool, json!({ "duration": null }))
|
||||
.await;
|
||||
|
||||
assert_eq!(result.output, json!(4));
|
||||
assert_eq!(result.outcome, ToolOutcome::InternalOk);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user