引数なしでToolCallすると構造エラーになる問題の修正

This commit is contained in:
2026-04-19 08:39:16 +09:00
parent 879858dc94
commit 68885a03d8
8 changed files with 104 additions and 91 deletions
@@ -6,7 +6,7 @@ use serde::Serialize;
use crate::llm_client::{
Request,
types::{ContentPart, Item, Role, ToolDefinition},
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
};
use super::AnthropicScheme;
@@ -170,9 +170,9 @@ impl AnthropicScheme {
});
}
// Parse arguments JSON string to Value
let input = serde_json::from_str(arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
// Parse arguments JSON string to Value (defensive: normalize
// non-object / legacy "null" payloads to {} so Anthropic API accepts it)
let input = parse_tool_arguments(arguments);
pending_assistant_parts.push(AnthropicContentPart::ToolUse {
id: call_id.clone(),
@@ -7,7 +7,7 @@ use serde_json::Value;
use crate::llm_client::{
Request,
types::{Item, Role, ToolDefinition},
types::{Item, Role, ToolDefinition, parse_tool_arguments},
};
use super::GeminiScheme;
@@ -244,9 +244,8 @@ impl GeminiScheme {
});
}
// Parse arguments
let args = serde_json::from_str(arguments)
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
// Parse arguments (normalize non-object / legacy "null" payloads to {})
let args = parse_tool_arguments(arguments);
pending_model_parts.push(GeminiPart::FunctionCall {
function_call: GeminiFunctionCall {
@@ -7,7 +7,7 @@ use serde_json::Value;
use crate::llm_client::{
Request,
types::{Item, Role, ToolDefinition},
types::{Item, Role, ToolDefinition, parse_tool_arguments},
};
use super::OpenAIScheme;
@@ -201,12 +201,15 @@ impl OpenAIScheme {
arguments,
..
} => {
// Normalize non-object / legacy "null" payloads to "{}" so
// OpenAI gets a valid JSON object string.
let normalized_args = parse_tool_arguments(arguments).to_string();
pending_tool_calls.push(OpenAIToolCall {
id: call_id.clone(),
r#type: "function".to_string(),
function: OpenAIToolCallFunction {
name: name.clone(),
arguments: arguments.clone(),
arguments: normalized_args,
},
});
}
+64
View File
@@ -317,6 +317,19 @@ impl Item {
}
}
/// Parse a ToolCall `arguments` string into a JSON object.
///
/// Tool call arguments must be a JSON object at the provider API level
/// (Anthropic rejects non-object `tool_use.input`). This helper normalizes
/// anything that is not a JSON object — empty string, the literal `"null"`,
/// arrays, scalars, or parse failures — to an empty object `{}`.
pub fn parse_tool_arguments(arguments: &str) -> serde_json::Value {
match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(value) if value.is_object() => value,
_ => serde_json::Value::Object(serde_json::Map::new()),
}
}
// ============================================================================
// Content Parts - Components within message items
// ============================================================================
@@ -583,3 +596,54 @@ impl RequestConfig {
self
}
}
#[cfg(test)]
mod parse_tool_arguments_tests {
use super::parse_tool_arguments;
use serde_json::{Value, json};
fn empty_object() -> Value {
Value::Object(serde_json::Map::new())
}
#[test]
fn empty_string_normalizes_to_object() {
assert_eq!(parse_tool_arguments(""), empty_object());
}
#[test]
fn literal_null_normalizes_to_object() {
// 既存セッションに残っている "null" が resume 時に復旧できること
assert_eq!(parse_tool_arguments("null"), empty_object());
}
#[test]
fn array_normalizes_to_object() {
assert_eq!(parse_tool_arguments("[1, 2, 3]"), empty_object());
}
#[test]
fn scalar_normalizes_to_object() {
assert_eq!(parse_tool_arguments("42"), empty_object());
assert_eq!(parse_tool_arguments("\"str\""), empty_object());
assert_eq!(parse_tool_arguments("true"), empty_object());
}
#[test]
fn invalid_json_normalizes_to_object() {
assert_eq!(parse_tool_arguments("{not json"), empty_object());
}
#[test]
fn valid_object_passes_through() {
assert_eq!(
parse_tool_arguments(r#"{"city":"Tokyo","days":3}"#),
json!({"city": "Tokyo", "days": 3}),
);
}
#[test]
fn empty_object_passes_through() {
assert_eq!(parse_tool_arguments("{}"), empty_object());
}
}
@@ -5,6 +5,7 @@
use crate::{
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
llm_client::types::parse_tool_arguments,
tool::ToolCall,
};
use std::sync::{Arc, Mutex};
@@ -84,8 +85,7 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
// ブロック完了時にToolCallを確定
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
{
let input = serde_json::from_str(&scope.input_json_buffer)
.unwrap_or(serde_json::Value::Null);
let input = parse_tool_arguments(&scope.input_json_buffer);
let tool_call = ToolCall { id, name, input };
@@ -123,6 +123,27 @@ mod tests {
assert_eq!(calls[0].input["city"], "Tokyo");
}
#[test]
fn test_collect_empty_buffer_returns_object() {
// 引数なしツール呼び出し: input_json_delta が一度も来ないケース
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListPods"));
timeline.dispatch(&Event::tool_use_stop(0));
let calls = collector.take_collected();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tool_empty");
assert_eq!(calls[0].name, "ListPods");
assert!(calls[0].input.is_object());
assert_eq!(
calls[0].input,
serde_json::Value::Object(serde_json::Map::new())
);
}
#[test]
fn test_collect_multiple_tool_calls() {
let collector = ToolCallCollector::new();
+5 -3
View File
@@ -16,7 +16,10 @@ use crate::{
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction,
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction,
},
llm_client::{ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ToolDefinition},
llm_client::{
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ToolDefinition,
types::parse_tool_arguments,
},
state::{Locked, Mutable, WorkerState},
timeline::event::{ErrorEvent, StatusEvent, UsageEvent},
timeline::{TextBlockCollector, Timeline, ToolCallCollector},
@@ -573,8 +576,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
} = item
{
if !answered_call_ids.contains(call_id) {
let input = serde_json::from_str(arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
let input = parse_tool_arguments(arguments);
pending_calls.push(ToolCall {
id: call_id.clone(),
name: name.clone(),