From f6773d804b6a3d595b7dc83572f979b7b5d0c917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=87=B3=E5=8D=9A?= Date: Fri, 7 Aug 2026 14:30:46 +0800 Subject: [PATCH] feat: support sampling with tools --- src/Schema/ClientCapabilities.php | 23 ++++- src/Schema/Content/SamplingMessage.php | 75 +++++++++++--- src/Schema/Content/ToolResultContent.php | 99 +++++++++++++++++++ src/Schema/Content/ToolUseContent.php | 75 ++++++++++++++ src/Schema/Enum/SamplingStopReason.php | 20 ++++ src/Schema/Enum/ToolChoiceMode.php | 19 ++++ .../Request/CreateSamplingMessageRequest.php | 53 +++++++++- .../Result/CreateSamplingMessageResult.php | 61 +++++++++--- src/Schema/ToolChoice.php | 51 ++++++++++ src/Server/ClientGateway.php | 6 ++ .../Schema/ClientCapabilitiesSamplingTest.php | 32 ++++++ .../Content/SamplingToolContentTest.php | 68 +++++++++++++ .../CreateSamplingMessageRequestTest.php | 22 +++++ .../CreateSamplingMessageResultTest.php | 54 ++++++++++ 14 files changed, 632 insertions(+), 26 deletions(-) create mode 100644 src/Schema/Content/ToolResultContent.php create mode 100644 src/Schema/Content/ToolUseContent.php create mode 100644 src/Schema/Enum/SamplingStopReason.php create mode 100644 src/Schema/Enum/ToolChoiceMode.php create mode 100644 src/Schema/ToolChoice.php create mode 100644 tests/Unit/Schema/ClientCapabilitiesSamplingTest.php create mode 100644 tests/Unit/Schema/Content/SamplingToolContentTest.php create mode 100644 tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 3bb6081b..4c7c451e 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -30,6 +30,8 @@ public function __construct( public readonly ?bool $elicitation = null, public readonly ?array $experimental = null, public readonly ?array $extensions = null, + public readonly ?bool $samplingContext = null, + public readonly ?bool $samplingTools = null, ) { } @@ -38,7 +40,7 @@ public function __construct( * roots?: array{ * listChanged?: bool, * }, - * sampling?: bool, + * sampling?: array{context?: mixed, tools?: mixed}|object, * elicitation?: bool, * experimental?: array, * extensions?: array, @@ -57,8 +59,17 @@ public static function fromArray(array $data): self } $sampling = null; + $samplingContext = null; + $samplingTools = null; if (isset($data['sampling'])) { $sampling = true; + if (\is_array($data['sampling'])) { + $samplingContext = isset($data['sampling']['context']); + $samplingTools = isset($data['sampling']['tools']); + } elseif (\is_object($data['sampling'])) { + $samplingContext = property_exists($data['sampling'], 'context'); + $samplingTools = property_exists($data['sampling'], 'tools'); + } } $elicitation = null; @@ -73,6 +84,8 @@ public static function fromArray(array $data): self $elicitation, \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, + $samplingContext, + $samplingTools, ); } @@ -95,8 +108,14 @@ public function jsonSerialize(): array|object } } - if ($this->sampling) { + if ($this->sampling || $this->samplingContext || $this->samplingTools) { $data['sampling'] = new \stdClass(); + if ($this->samplingContext) { + $data['sampling']->context = new \stdClass(); + } + if ($this->samplingTools) { + $data['sampling']->tools = new \stdClass(); + } } if ($this->elicitation) { diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 48aaa713..62215c9c 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -19,17 +19,41 @@ * * @phpstan-type SamplingMessageData = array{ * role: 'user'|'assistant', - * content: TextContent|ImageContent|AudioContent + * content: array|array>, + * _meta?: array * } * * @author Kyrian Obikwelu */ class SamplingMessage extends Content { + /** + * @param TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|list $content + * @param ?array $meta + */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, + public readonly ?array $meta = null, ) { + $contents = \is_array($content) ? $content : [$content]; + foreach ($contents as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) { + throw new InvalidArgumentException('Sampling message content contains an unsupported content block.'); + } + if (Role::User === $role && $item instanceof ToolUseContent) { + throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.'); + } + if (Role::Assistant === $role && $item instanceof ToolResultContent) { + throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.'); + } + } + + if (array_filter($contents, static fn ($item): bool => $item instanceof ToolResultContent) + && array_filter($contents, static fn ($item): bool => !$item instanceof ToolResultContent)) { + throw new InvalidArgumentException('Tool result messages must not contain other content types.'); + } + parent::__construct('sampling'); } @@ -47,16 +71,22 @@ public static function fromArray(array $data): self $role = Role::from($data['role']); $contentData = $data['content']; - $contentType = $contentData['type'] ?? null; + $isSingleContent = isset($contentData['type']); + $contentItems = $isSingleContent ? [$contentData] : $contentData; + $content = []; - $contentInstance = match ($contentType) { - 'text' => TextContent::fromArray($contentData), - 'image' => ImageContent::fromArray($contentData), - 'audio' => AudioContent::fromArray($contentData), - default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), - }; + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in SamplingMessage data.'); + } + $content[] = self::hydrateContent($item); + } - return new self($role, $contentInstance); + return new self( + $role, + $isSingleContent ? $content[0] : $content, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** @@ -64,9 +94,32 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { - return [ + $data = [ 'role' => $this->role->value, 'content' => $this->content, ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } + + /** + * @param array $contentData + */ + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent + { + $contentType = $contentData['type'] ?? null; + + return match ($contentType) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), + 'tool_result' => ToolResultContent::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), + }; } } diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php new file mode 100644 index 00000000..2d0a8c61 --- /dev/null +++ b/src/Schema/Content/ToolResultContent.php @@ -0,0 +1,99 @@ + $structuredContent + * @param ?array $meta + */ + public function __construct( + public readonly string $toolUseId, + public readonly array $content, + public readonly ?array $structuredContent = null, + public readonly bool $isError = false, + public readonly ?array $meta = null, + ) { + foreach ($content as $item) { + if (!$item instanceof Content || $item instanceof self || $item instanceof ToolUseContent) { + throw new InvalidArgumentException('Tool result content must contain standard content blocks.'); + } + } + + parent::__construct('tool_result'); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['toolUseId']) || !\is_string($data['toolUseId'])) { + throw new InvalidArgumentException('Missing or invalid "toolUseId" in ToolResultContent data.'); + } + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in ToolResultContent data.'); + } + + $content = []; + foreach ($data['content'] as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in ToolResultContent data.'); + } + + $content[] = match ($item['type'] ?? null) { + 'text' => TextContent::fromArray($item), + 'image' => ImageContent::fromArray($item), + 'audio' => AudioContent::fromArray($item), + 'resource' => EmbeddedResource::fromArray($item), + default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)), + }; + } + + return new self( + $data['toolUseId'], + $content, + isset($data['structuredContent']) && \is_array($data['structuredContent']) ? $data['structuredContent'] : null, + isset($data['isError']) && true === $data['isError'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'toolUseId' => $this->toolUseId, + 'content' => $this->content, + 'isError' => $this->isError, + ]; + + if (null !== $this->structuredContent) { + $data['structuredContent'] = $this->structuredContent; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php new file mode 100644 index 00000000..6acdc439 --- /dev/null +++ b/src/Schema/Content/ToolUseContent.php @@ -0,0 +1,75 @@ + $input + * @param ?array $meta + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $input, + public readonly ?array $meta = null, + ) { + parent::__construct('tool_use'); + } + + /** + * @param array{id?: mixed, name?: mixed, input?: mixed, _meta?: mixed} $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['id']) || !\is_string($data['id'])) { + throw new InvalidArgumentException('Missing or invalid "id" in ToolUseContent data.'); + } + if (!isset($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Missing or invalid "name" in ToolUseContent data.'); + } + if (!isset($data['input']) || !\is_array($data['input'])) { + throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.'); + } + + return new self( + $data['id'], + $data['name'], + $data['input'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array{type: 'tool_use', id: string, name: string, input: array|\stdClass, _meta?: array} + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'id' => $this->id, + 'name' => $this->name, + 'input' => $this->input ?: new \stdClass(), + ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Enum/SamplingStopReason.php b/src/Schema/Enum/SamplingStopReason.php new file mode 100644 index 00000000..540a1dab --- /dev/null +++ b/src/Schema/Enum/SamplingStopReason.php @@ -0,0 +1,20 @@ + $metadata Optional metadata to pass through to the LLM provider. The format of * this metadata is provider-specific. + * @param ?Tool[] $tools tools that the model may use during generation + * @param ?ToolChoice $toolChoice controls how the model uses tools */ public function __construct( public readonly array $messages, @@ -51,12 +55,19 @@ public function __construct( public readonly ?float $temperature = null, public readonly ?array $stopSequences = null, public readonly ?array $metadata = null, + public readonly ?array $tools = null, + public readonly ?ToolChoice $toolChoice = null, ) { foreach ($this->messages as $message) { if (!$message instanceof SamplingMessage) { throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); } } + foreach ($this->tools ?? [] as $tool) { + if (!$tool instanceof Tool) { + throw new InvalidArgumentException('Tools must be instances of Tool.'); + } + } } public static function getMethod(): string @@ -95,6 +106,34 @@ protected static function fromParams(?array $params): static $includeContext = SamplingContext::tryFrom($params['includeContext']); } + $tools = null; + if (isset($params['tools'])) { + if (!\is_array($params['tools'])) { + throw new InvalidArgumentException('Invalid "tools" parameter for sampling/createMessage.'); + } + $tools = []; + foreach ($params['tools'] as $toolData) { + if ($toolData instanceof Tool) { + $tools[] = $toolData; + } elseif (\is_array($toolData)) { + $tools[] = Tool::fromArray($toolData); + } else { + throw new InvalidArgumentException('Invalid tool format in sampling/createMessage.'); + } + } + } + + $toolChoice = null; + if (isset($params['toolChoice'])) { + if ($params['toolChoice'] instanceof ToolChoice) { + $toolChoice = $params['toolChoice']; + } elseif (\is_array($params['toolChoice'])) { + $toolChoice = ToolChoice::fromArray($params['toolChoice']); + } else { + throw new InvalidArgumentException('Invalid "toolChoice" parameter for sampling/createMessage.'); + } + } + return new self( $messages, $params['maxTokens'], @@ -104,6 +143,8 @@ protected static function fromParams(?array $params): static $params['temperature'] ?? null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, + $tools, + $toolChoice, ); } @@ -116,7 +157,9 @@ protected static function fromParams(?array $params): static * includeContext?: string, * temperature?: float, * stopSequences?: string[], - * metadata?: array + * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } */ protected function getParams(): array @@ -150,6 +193,14 @@ protected function getParams(): array $params['metadata'] = $this->metadata; } + if (null !== $this->tools) { + $params['tools'] = $this->tools; + } + + if (null !== $this->toolChoice) { + $params['toolChoice'] = $this->toolChoice; + } + return $params; } } diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 986d6291..574cee62 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -15,7 +15,9 @@ use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\SamplingStopReason; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -28,17 +30,28 @@ class CreateSamplingMessageResult implements ResultInterface { /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent $content the content of the message - * @param string $model the name of the model that generated the message - * @param string|null $stopReason the reason why sampling stopped, if known + * @param Role $role the role of the message + * @param TextContent|ImageContent|AudioContent|ToolUseContent|list $content the content of the message + * @param string $model the name of the model that generated the message + * @param SamplingStopReason|string|null $stopReason the reason why sampling stopped, if known + * @param ?array $meta optional message metadata */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content, public readonly string $model, - public readonly ?string $stopReason = null, + public readonly SamplingStopReason|string|null $stopReason = null, + public readonly ?array $meta = null, ) { + if (Role::Assistant !== $role) { + throw new InvalidArgumentException('CreateSamplingMessageResult role must be "assistant".'); + } + + foreach (\is_array($content) ? $content : [$content] as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) { + throw new InvalidArgumentException('CreateSamplingMessageResult contains an unsupported content block.'); + } + } } /** @@ -61,16 +74,34 @@ public static function fromArray(array $data): self $role = Role::from($data['role']); $contentPayload = $data['content']; - $content = self::hydrateContent($contentPayload); - $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; + $isSingleContent = isset($contentPayload['type']); + $contentItems = $isSingleContent ? [$contentPayload] : $contentPayload; + $content = []; + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in CreateSamplingMessageResult data.'); + } + $content[] = self::hydrateContent($item); + } + + $stopReason = null; + if (isset($data['stopReason']) && \is_string($data['stopReason'])) { + $stopReason = SamplingStopReason::tryFrom($data['stopReason']) ?? $data['stopReason']; + } - return new self($role, $content, $data['model'], $stopReason); + return new self( + $role, + $isSingleContent ? $content[0] : $content, + $data['model'], + $stopReason, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** * @param array $contentData */ - private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent { $type = $contentData['type'] ?? null; @@ -82,6 +113,7 @@ private static function hydrateContent(array $contentData): TextContent|ImageCon 'text' => TextContent::fromArray($contentData), 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)), }; } @@ -89,9 +121,10 @@ private static function hydrateContent(array $contentData): TextContent|ImageCon /** * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent, + * content: TextContent|ImageContent|AudioContent|ToolUseContent|list, * model: string, * stopReason?: string, + * _meta?: array, * } */ public function jsonSerialize(): array @@ -103,7 +136,11 @@ public function jsonSerialize(): array ]; if (null !== $this->stopReason) { - $result['stopReason'] = $this->stopReason; + $result['stopReason'] = $this->stopReason instanceof SamplingStopReason ? $this->stopReason->value : $this->stopReason; + } + + if (null !== $this->meta) { + $result['_meta'] = $this->meta; } return $result; diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php new file mode 100644 index 00000000..4b55bac3 --- /dev/null +++ b/src/Schema/ToolChoice.php @@ -0,0 +1,51 @@ + $this->mode->value]; + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 46860715..d0d7ebb6 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -34,6 +34,8 @@ use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use Mcp\Server\Session\SessionInterface; /** @@ -65,6 +67,8 @@ * includeContext?: SamplingContext, * stopSequences?: string[], * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } * * @author Kyrian Obikwelu @@ -150,6 +154,8 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int temperature: $options['temperature'] ?? null, stopSequences: $options['stopSequences'] ?? null, metadata: $options['metadata'] ?? null, + tools: $options['tools'] ?? null, + toolChoice: $options['toolChoice'] ?? null, ); $response = $this->request($request, $timeout); diff --git a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php new file mode 100644 index 00000000..7cd97f7a --- /dev/null +++ b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php @@ -0,0 +1,32 @@ +jsonSerialize(); + + $this->assertObjectHasProperty('context', $serialized['sampling']); + $this->assertObjectHasProperty('tools', $serialized['sampling']); + + $hydrated = ClientCapabilities::fromArray(json_decode(json_encode($serialized, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertTrue($hydrated->sampling); + $this->assertTrue($hydrated->samplingContext); + $this->assertTrue($hydrated->samplingTools); + } +} diff --git a/tests/Unit/Schema/Content/SamplingToolContentTest.php b/tests/Unit/Schema/Content/SamplingToolContentTest.php new file mode 100644 index 00000000..06a4875c --- /dev/null +++ b/tests/Unit/Schema/Content/SamplingToolContentTest.php @@ -0,0 +1,68 @@ + 'assistant', + '_meta' => ['provider' => 'test'], + 'content' => [ + ['type' => 'text', 'text' => 'I will check.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + ]); + $user = SamplingMessage::fromArray([ + 'role' => 'user', + 'content' => [[ + 'type' => 'tool_result', + 'toolUseId' => 'call-1', + 'content' => [['type' => 'text', 'text' => '21 C']], + 'structuredContent' => ['temperature' => 21], + ]], + ]); + + $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); + $this->assertSame(['provider' => 'test'], $assistant->meta); + $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); + $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); + $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); + $textContent = $user->content[0]->content[0]; + $this->assertInstanceOf(TextContent::class, $textContent); + $this->assertSame('21 C', $textContent->text); + } + + public function testToolUseIsRejectedInUserMessage(): void + { + $this->expectException(InvalidArgumentException::class); + new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])); + } + + public function testToolResultCannotBeMixedWithOtherContent(): void + { + $this->expectException(InvalidArgumentException::class); + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('done')]), + new TextContent('extra'), + ]); + } +} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 57f790a7..4254336f 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -15,7 +15,10 @@ use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\ToolChoiceMode; use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use PHPUnit\Framework\TestCase; final class CreateSamplingMessageRequestTest extends TestCase @@ -48,4 +51,23 @@ public function testConstructorWithInvalidSetOfMessages(): void /* @phpstan-ignore argument.type */ new CreateSamplingMessageRequest($messages, 150); } + + public function testToolsAndToolChoiceRoundTrip(): void + { + $tool = new Tool('weather', null, ['type' => 'object', 'properties' => [], 'required' => null], 'Get weather', null); + $request = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Weather in Paris?'))], + 150, + tools: [$tool], + toolChoice: new ToolChoice(ToolChoiceMode::Required), + ); + + $payload = $request->withId(1)->jsonSerialize(); + $this->assertSame('weather', $payload['params']['tools'][0]->name); + $this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode); + + $hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertSame('weather', $hydrated->tools[0]->name); + $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); + } } diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php new file mode 100644 index 00000000..90949134 --- /dev/null +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -0,0 +1,54 @@ + 'assistant', + 'content' => [ + ['type' => 'text', 'text' => 'Checking weather.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + 'model' => 'test-model', + 'stopReason' => 'toolUse', + '_meta' => ['traceId' => 'trace-1'], + ]); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); + $this->assertSame(SamplingStopReason::ToolUse, $result->stopReason); + $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); + $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); + } + + public function testProviderSpecificStopReasonIsPreserved(): void + { + $result = new CreateSamplingMessageResult( + Role::Assistant, + new TextContent('Done'), + 'test-model', + 'provider-specific', + ); + + $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); + } +}