引数なしでToolCallすると構造エラーになる問題の修正
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user