diff --git a/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php b/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php index 68d0593..906b7e5 100644 --- a/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php +++ b/src/Schemas/Anthropic/Concerns/ExtractsToolCalls.php @@ -14,10 +14,12 @@ protected function extractToolCalls(array $data): array { $toolCalls = array_map(function ($content): ?ToolCall { if (data_get($content, 'type') === 'tool_use') { + $input = data_get($content, 'input'); + return new ToolCall( id: data_get($content, 'id'), name: data_get($content, 'name'), - arguments: data_get($content, 'input') + arguments: is_string($input) ? (json_decode($input, true) ?? []) : ($input ?? []) ); } diff --git a/src/Schemas/Anthropic/Maps/MessageMap.php b/src/Schemas/Anthropic/Maps/MessageMap.php index 4cbfa48..fcf5142 100644 --- a/src/Schemas/Anthropic/Maps/MessageMap.php +++ b/src/Schemas/Anthropic/Maps/MessageMap.php @@ -150,7 +150,7 @@ protected static function mapAssistantMessage(AssistantMessage $message): array 'type' => 'tool_use', 'id' => $toolCall->id, 'name' => $toolCall->name, - 'input' => $toolCall->arguments(), + 'input' => $toolCall->arguments() === [] ? new \stdClass : $toolCall->arguments(), ], $message->toolCalls) : []; diff --git a/src/Schemas/Anthropic/Maps/ToolMap.php b/src/Schemas/Anthropic/Maps/ToolMap.php index c51f4cc..6e72840 100644 --- a/src/Schemas/Anthropic/Maps/ToolMap.php +++ b/src/Schemas/Anthropic/Maps/ToolMap.php @@ -23,7 +23,7 @@ public static function map(array $tools): array 'description' => $tool->description(), 'input_schema' => [ 'type' => 'object', - 'properties' => $tool->parametersAsArray(), + 'properties' => $tool->parametersAsArray() ?: (object) [], 'required' => $tool->requiredParameters(), ], 'cache_control' => $cacheType diff --git a/src/Schemas/Converse/Concerns/ExtractsToolCalls.php b/src/Schemas/Converse/Concerns/ExtractsToolCalls.php index 5fbac60..d42d770 100644 --- a/src/Schemas/Converse/Concerns/ExtractsToolCalls.php +++ b/src/Schemas/Converse/Concerns/ExtractsToolCalls.php @@ -18,10 +18,12 @@ protected function extractToolCalls(array $data): array return; } + $input = data_get($use, 'input'); + return new ToolCall( id: data_get($use, 'toolUseId'), name: data_get($use, 'name'), - arguments: data_get($use, 'input') + arguments: is_string($input) ? (json_decode($input, true) ?? []) : ($input ?? []) ); }, data_get($data, 'output.message.content', [])); diff --git a/src/Schemas/Converse/Maps/MessageMap.php b/src/Schemas/Converse/Maps/MessageMap.php index 6c57bdf..9d24158 100644 --- a/src/Schemas/Converse/Maps/MessageMap.php +++ b/src/Schemas/Converse/Maps/MessageMap.php @@ -142,7 +142,7 @@ protected static function mapToolCalls(array $parts): array 'toolUse' => [ 'toolUseId' => $toolCall->id, 'name' => $toolCall->name, - 'input' => $toolCall->arguments(), + 'input' => $toolCall->arguments() === [] ? new \stdClass : $toolCall->arguments(), ], ], $parts); } diff --git a/tests/Schemas/Anthropic/Concerns/ExtractsToolCallsTest.php b/tests/Schemas/Anthropic/Concerns/ExtractsToolCallsTest.php new file mode 100644 index 0000000..06b5e5c --- /dev/null +++ b/tests/Schemas/Anthropic/Concerns/ExtractsToolCallsTest.php @@ -0,0 +1,159 @@ +extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_123', + 'name' => 'search', + 'input' => [ + 'query' => 'Laravel docs', + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_123'); + expect($result[0]->name)->toBe('search'); + expect($result[0]->arguments())->toBe(['query' => 'Laravel docs']); +}); + +it('extracts tool calls with string JSON input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_456', + 'name' => 'weather', + 'input' => '{"city": "Detroit"}', + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_456'); + expect($result[0]->name)->toBe('weather'); + expect($result[0]->arguments())->toBe(['city' => 'Detroit']); +}); + +it('extracts tool calls with invalid JSON string input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_789', + 'name' => 'get_time', + 'input' => 'invalid json', + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_789'); + expect($result[0]->name)->toBe('get_time'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with null input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_abc', + 'name' => 'parameterless_tool', + 'input' => null, + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_abc'); + expect($result[0]->name)->toBe('parameterless_tool'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with empty array input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'tool_def', + 'name' => 'no_params', + 'input' => [], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_def'); + expect($result[0]->name)->toBe('no_params'); + expect($result[0]->arguments())->toBe([]); +}); diff --git a/tests/Schemas/Anthropic/Maps/MessageMapTest.php b/tests/Schemas/Anthropic/Maps/MessageMapTest.php index 8be9954..a66c587 100644 --- a/tests/Schemas/Anthropic/Maps/MessageMapTest.php +++ b/tests/Schemas/Anthropic/Maps/MessageMapTest.php @@ -114,6 +114,34 @@ ]); }); +it('maps assistant message with tool calls with empty arguments as stdClass', function (): void { + expect(MessageMap::map([ + new AssistantMessage('Running tool', [ + new ToolCall( + 'tool_5678', + 'get_time', + [] + ), + ]), + ]))->toEqual([ + [ + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Running tool', + ], + [ + 'type' => 'tool_use', + 'id' => 'tool_5678', + 'name' => 'get_time', + 'input' => new \stdClass, + ], + ], + ], + ]); +}); + it('maps tool result messages', function (): void { expect(MessageMap::map([ new ToolResultMessage([ diff --git a/tests/Schemas/Anthropic/Maps/ToolMapTest.php b/tests/Schemas/Anthropic/Maps/ToolMapTest.php index a0a1cab..31a398a 100644 --- a/tests/Schemas/Anthropic/Maps/ToolMapTest.php +++ b/tests/Schemas/Anthropic/Maps/ToolMapTest.php @@ -31,6 +31,23 @@ ]]); }); +it('maps parameterless tools with empty object properties', function (): void { + $tool = (new Tool) + ->as('get_time') + ->for('Get the current time') + ->using(fn (): string => '12:00 PM'); + + expect(ToolMap::map([$tool]))->toEqual([[ + 'name' => 'get_time', + 'description' => 'Get the current time', + 'input_schema' => [ + 'type' => 'object', + 'properties' => (object) [], + 'required' => [], + ], + ]]); +}); + it('sets the cache typeif cacheType providerOptions is set on tool', function (mixed $cacheType): void { $tool = (new Tool) ->as('search') diff --git a/tests/Schemas/Converse/Concerns/ExtractsToolCallsTest.php b/tests/Schemas/Converse/Concerns/ExtractsToolCallsTest.php new file mode 100644 index 0000000..b4f8007 --- /dev/null +++ b/tests/Schemas/Converse/Concerns/ExtractsToolCallsTest.php @@ -0,0 +1,220 @@ +extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_123', + 'name' => 'search', + 'input' => [ + 'query' => 'Laravel docs', + ], + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_123'); + expect($result[0]->name)->toBe('search'); + expect($result[0]->arguments())->toBe(['query' => 'Laravel docs']); +}); + +it('extracts tool calls with string JSON input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_456', + 'name' => 'weather', + 'input' => '{"city": "Detroit"}', + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_456'); + expect($result[0]->name)->toBe('weather'); + expect($result[0]->arguments())->toBe(['city' => 'Detroit']); +}); + +it('extracts tool calls with invalid JSON string input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_789', + 'name' => 'get_time', + 'input' => 'invalid json', + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_789'); + expect($result[0]->name)->toBe('get_time'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with null input defaults to empty array', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_abc', + 'name' => 'parameterless_tool', + 'input' => null, + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_abc'); + expect($result[0]->name)->toBe('parameterless_tool'); + expect($result[0]->arguments())->toBe([]); +}); + +it('extracts tool calls with empty array input', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => 'tool_def', + 'name' => 'no_params', + 'input' => [], + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->id)->toBe('tool_def'); + expect($result[0]->name)->toBe('no_params'); + expect($result[0]->arguments())->toBe([]); +}); + +it('filters out non-tool-use content blocks', function (): void { + $extractor = new class + { + use ExtractsToolCalls; + + public function extract(array $data): array + { + return $this->extractToolCalls($data); + } + }; + + $data = [ + 'output' => [ + 'message' => [ + 'content' => [ + [ + 'text' => 'Some text response', + ], + [ + 'toolUse' => [ + 'toolUseId' => 'tool_xyz', + 'name' => 'search', + 'input' => ['query' => 'test'], + ], + ], + ], + ], + ], + ]; + + $result = $extractor->extract($data); + + expect($result)->toHaveCount(1); + expect($result[0]->name)->toBe('search'); +}); diff --git a/tests/Schemas/Converse/Maps/MessageMapTest.php b/tests/Schemas/Converse/Maps/MessageMapTest.php index ce03ec7..766f637 100644 --- a/tests/Schemas/Converse/Maps/MessageMapTest.php +++ b/tests/Schemas/Converse/Maps/MessageMapTest.php @@ -127,6 +127,32 @@ ]); }); +it('maps assistant message with tool calls with empty arguments as stdClass', function (): void { + expect(MessageMap::map([ + new AssistantMessage('Running tool', [ + new ToolCall( + 'tool_5678', + 'get_time', + [] + ), + ]), + ]))->toEqual([ + [ + 'role' => 'assistant', + 'content' => [ + ['text' => 'Running tool'], + [ + 'toolUse' => [ + 'toolUseId' => 'tool_5678', + 'name' => 'get_time', + 'input' => new \stdClass, + ], + ], + ], + ], + ]); +}); + it('maps tool result messages', function (): void { expect(MessageMap::map([ new ToolResultMessage([ diff --git a/tests/Schemas/Converse/Maps/ToolMapTest.php b/tests/Schemas/Converse/Maps/ToolMapTest.php index 1dc5ac0..8fc98b8 100644 --- a/tests/Schemas/Converse/Maps/ToolMapTest.php +++ b/tests/Schemas/Converse/Maps/ToolMapTest.php @@ -35,3 +35,26 @@ ], ]); }); + +it('maps parameterless tools with empty object properties', function (): void { + $tool = (new Tool) + ->as('get_time') + ->for('Get the current time') + ->using(fn (): string => '12:00 PM'); + + expect(ToolMap::map([$tool]))->toEqual([ + [ + 'toolSpec' => [ + 'name' => 'get_time', + 'description' => 'Get the current time', + 'inputSchema' => [ + 'json' => [ + 'type' => 'object', + 'properties' => (object) [], + 'required' => [], + ], + ], + ], + ], + ]); +});