diff --git a/src/Platform/Message/AssistantMessage.php b/src/Platform/Message/AssistantMessage.php index be60bc00..721fdade 100644 --- a/src/Platform/Message/AssistantMessage.php +++ b/src/Platform/Message/AssistantMessage.php @@ -5,6 +5,7 @@ namespace PhpLlm\LlmChain\Platform\Message; use PhpLlm\LlmChain\Platform\Response\ToolCall; +use Symfony\Component\Uid\Uuid; /** * @author Denis Zunke @@ -25,6 +26,33 @@ public function getRole(): Role return Role::Assistant; } + public function getId(): Uuid + { + // Generate deterministic UUID based on content, role, and tool calls + $toolCallsData = ''; + if ($this->toolCalls !== null) { + $toolCallsData = serialize(array_map( + static fn (ToolCall $toolCall) => [ + 'id' => $toolCall->id, + 'name' => $toolCall->name, + 'arguments' => $toolCall->arguments, + ], + $this->toolCalls + )); + } + + $data = sprintf('assistant:%s:%s', $this->content ?? '', $toolCallsData); + + return Uuid::v5(self::getNamespace(), $data); + } + + private static function getNamespace(): Uuid + { + // Use a fixed namespace UUID for the LLM Chain message system + // This ensures deterministic IDs across application runs + return Uuid::fromString('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); + } + public function hasToolCalls(): bool { return null !== $this->toolCalls && 0 !== \count($this->toolCalls); diff --git a/src/Platform/Message/MessageBag.php b/src/Platform/Message/MessageBag.php index 45d4db35..1966357c 100644 --- a/src/Platform/Message/MessageBag.php +++ b/src/Platform/Message/MessageBag.php @@ -4,6 +4,8 @@ namespace PhpLlm\LlmChain\Platform\Message; +use Symfony\Component\Uid\Uuid; + /** * @final * @@ -102,6 +104,76 @@ public function containsImage(): bool return false; } + /** + * Get all messages that come after a message with the specified ID. + * If the ID is not found, returns all messages. + * + * @return list + */ + public function messagesAfterId(Uuid $id): array + { + $found = false; + $messagesAfter = []; + + foreach ($this->messages as $message) { + if ($found) { + $messagesAfter[] = $message; + } elseif ($message->getId()->equals($id)) { + $found = true; + } + } + + // If ID not found, return all messages + return $found ? $messagesAfter : $this->messages; + } + + /** + * Get messages newer than (excluding) the specified ID. + * + * @return self + */ + public function messagesNewerThan(Uuid $id): self + { + $messagesAfter = $this->messagesAfterId($id); + + return new self(...$messagesAfter); + } + + /** + * Find a message by its ID. + */ + public function findById(Uuid $id): ?MessageInterface + { + foreach ($this->messages as $message) { + if ($message->getId()->equals($id)) { + return $message; + } + } + + return null; + } + + /** + * Check if a message with the specified ID exists in the bag. + */ + public function hasMessageWithId(Uuid $id): bool + { + return $this->findById($id) !== null; + } + + /** + * Get all IDs in the message bag in order. + * + * @return list + */ + public function getIds(): array + { + return array_map( + static fn (MessageInterface $message) => $message->getId(), + $this->messages + ); + } + public function count(): int { return \count($this->messages); diff --git a/src/Platform/Message/MessageBagInterface.php b/src/Platform/Message/MessageBagInterface.php index d806bd31..87bba416 100644 --- a/src/Platform/Message/MessageBagInterface.php +++ b/src/Platform/Message/MessageBagInterface.php @@ -4,6 +4,8 @@ namespace PhpLlm\LlmChain\Platform\Message; +use Symfony\Component\Uid\Uuid; + /** * @author Oskar Stark */ @@ -29,4 +31,34 @@ public function prepend(MessageInterface $message): self; public function containsAudio(): bool; public function containsImage(): bool; + + /** + * Get all messages that come after a message with the specified ID. + * If the ID is not found, returns all messages. + * + * @return list + */ + public function messagesAfterId(Uuid $id): array; + + /** + * Get messages newer than (excluding) the specified ID. + */ + public function messagesNewerThan(Uuid $id): self; + + /** + * Find a message by its ID. + */ + public function findById(Uuid $id): ?MessageInterface; + + /** + * Check if a message with the specified ID exists in the bag. + */ + public function hasMessageWithId(Uuid $id): bool; + + /** + * Get all IDs in the message bag in order. + * + * @return list + */ + public function getIds(): array; } diff --git a/src/Platform/Message/MessageInterface.php b/src/Platform/Message/MessageInterface.php index eabf13fc..0b9f0865 100644 --- a/src/Platform/Message/MessageInterface.php +++ b/src/Platform/Message/MessageInterface.php @@ -4,10 +4,14 @@ namespace PhpLlm\LlmChain\Platform\Message; +use Symfony\Component\Uid\Uuid; + /** * @author Denis Zunke */ interface MessageInterface { public function getRole(): Role; + + public function getId(): Uuid; } diff --git a/src/Platform/Message/SystemMessage.php b/src/Platform/Message/SystemMessage.php index 59c3e3b2..c759b02e 100644 --- a/src/Platform/Message/SystemMessage.php +++ b/src/Platform/Message/SystemMessage.php @@ -4,6 +4,8 @@ namespace PhpLlm\LlmChain\Platform\Message; +use Symfony\Component\Uid\Uuid; + /** * @author Denis Zunke */ @@ -17,4 +19,19 @@ public function getRole(): Role { return Role::System; } + + public function getId(): Uuid + { + // Generate deterministic UUID based on content and role + $data = sprintf('system:%s', $this->content); + + return Uuid::v5(self::getNamespace(), $data); + } + + private static function getNamespace(): Uuid + { + // Use a fixed namespace UUID for the LLM Chain message system + // This ensures deterministic IDs across application runs + return Uuid::fromString('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); + } } diff --git a/src/Platform/Message/ToolCallMessage.php b/src/Platform/Message/ToolCallMessage.php index 09876007..3e6e31d0 100644 --- a/src/Platform/Message/ToolCallMessage.php +++ b/src/Platform/Message/ToolCallMessage.php @@ -5,6 +5,7 @@ namespace PhpLlm\LlmChain\Platform\Message; use PhpLlm\LlmChain\Platform\Response\ToolCall; +use Symfony\Component\Uid\Uuid; /** * @author Denis Zunke @@ -21,4 +22,20 @@ public function getRole(): Role { return Role::ToolCall; } + + public function getId(): Uuid + { + // Generate deterministic UUID based on tool call and content + $toolCallData = sprintf('%s:%s:%s', $this->toolCall->id, $this->toolCall->name, serialize($this->toolCall->arguments)); + $data = sprintf('toolcall:%s:%s', $toolCallData, $this->content); + + return Uuid::v5(self::getNamespace(), $data); + } + + private static function getNamespace(): Uuid + { + // Use a fixed namespace UUID for the LLM Chain message system + // This ensures deterministic IDs across application runs + return Uuid::fromString('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); + } } diff --git a/src/Platform/Message/UserMessage.php b/src/Platform/Message/UserMessage.php index f379f503..28a3811b 100644 --- a/src/Platform/Message/UserMessage.php +++ b/src/Platform/Message/UserMessage.php @@ -8,6 +8,7 @@ use PhpLlm\LlmChain\Platform\Message\Content\ContentInterface; use PhpLlm\LlmChain\Platform\Message\Content\Image; use PhpLlm\LlmChain\Platform\Message\Content\ImageUrl; +use Symfony\Component\Uid\Uuid; /** * @author Denis Zunke @@ -30,6 +31,22 @@ public function getRole(): Role return Role::User; } + public function getId(): Uuid + { + // Generate deterministic UUID based on content and role + $contentData = serialize($this->content); + $data = sprintf('user:%s', $contentData); + + return Uuid::v5(self::getNamespace(), $data); + } + + private static function getNamespace(): Uuid + { + // Use a fixed namespace UUID for the LLM Chain message system + // This ensures deterministic IDs across application runs + return Uuid::fromString('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); + } + public function hasAudioContent(): bool { foreach ($this->content as $content) { diff --git a/tests/Platform/Message/MessageIdTest.php b/tests/Platform/Message/MessageIdTest.php new file mode 100644 index 00000000..4344797e --- /dev/null +++ b/tests/Platform/Message/MessageIdTest.php @@ -0,0 +1,211 @@ +getId()); + self::assertTrue($message1->getId()->equals($message2->getId())); + self::assertFalse($message1->getId()->equals($message3->getId())); + } + + #[Test] + public function assistantMessageHasDeterministicId(): void + { + $message1 = Message::ofAssistant('Hello there'); + $message2 = Message::ofAssistant('Hello there'); + $message3 = Message::ofAssistant('Different content'); + + self::assertNotEmpty($message1->getId()); + self::assertTrue($message1->getId()->equals($message2->getId())); + self::assertFalse($message1->getId()->equals($message3->getId())); + } + + #[Test] + public function assistantMessageWithToolCallsHasDeterministicId(): void + { + $toolCall1 = new ToolCall('call_123', 'test_tool', ['param' => 'value']); + $toolCall2 = new ToolCall('call_456', 'other_tool'); + + $message1 = Message::ofAssistant('Content', [$toolCall1]); + $message2 = Message::ofAssistant('Content', [$toolCall1]); + $message3 = Message::ofAssistant('Content', [$toolCall2]); + $message4 = Message::ofAssistant('Different content', [$toolCall1]); + + self::assertNotEmpty($message1->getId()); + self::assertTrue($message1->getId()->equals($message2->getId())); + self::assertFalse($message1->getId()->equals($message3->getId())); + self::assertFalse($message1->getId()->equals($message4->getId())); + } + + #[Test] + public function userMessageHasDeterministicId(): void + { + $message1 = Message::ofUser('Hello'); + $message2 = Message::ofUser('Hello'); + $message3 = Message::ofUser('Different message'); + + self::assertNotEmpty($message1->getId()); + self::assertTrue($message1->getId()->equals($message2->getId())); + self::assertFalse($message1->getId()->equals($message3->getId())); + } + + #[Test] + public function toolCallMessageHasDeterministicId(): void + { + $toolCall1 = new ToolCall('call_123', 'test_tool', ['param' => 'value']); + $toolCall2 = new ToolCall('call_456', 'other_tool'); + + $message1 = Message::ofToolCall($toolCall1, 'Result 1'); + $message2 = Message::ofToolCall($toolCall1, 'Result 1'); + $message3 = Message::ofToolCall($toolCall2, 'Result 1'); + $message4 = Message::ofToolCall($toolCall1, 'Result 2'); + + self::assertNotEmpty($message1->getId()); + self::assertTrue($message1->getId()->equals($message2->getId())); + self::assertFalse($message1->getId()->equals($message3->getId())); + self::assertFalse($message1->getId()->equals($message4->getId())); + } + + #[Test] + public function differentMessageTypesHaveDifferentIds(): void + { + $content = 'Same content'; + + $systemMessage = Message::forSystem($content); + $assistantMessage = Message::ofAssistant($content); + $userMessage = Message::ofUser($content); + + self::assertFalse($systemMessage->getId()->equals($assistantMessage->getId())); + self::assertFalse($systemMessage->getId()->equals($userMessage->getId())); + self::assertFalse($assistantMessage->getId()->equals($userMessage->getId())); + } + + #[Test] + public function messageBagCanFindMessageById(): void + { + $message1 = Message::forSystem('System'); + $message2 = Message::ofUser('User message'); + $message3 = Message::ofAssistant('Assistant response'); + + $bag = new MessageBag($message1, $message2, $message3); + + self::assertSame($message1, $bag->findById($message1->getId())); + self::assertSame($message2, $bag->findById($message2->getId())); + self::assertSame($message3, $bag->findById($message3->getId())); + self::assertNull($bag->findById(Uuid::v4())); // Random UUID + } + + #[Test] + public function messageBagCanCheckIfIdExists(): void + { + $message1 = Message::forSystem('System'); + $message2 = Message::ofUser('User message'); + + $bag = new MessageBag($message1, $message2); + + self::assertTrue($bag->hasMessageWithId($message1->getId())); + self::assertTrue($bag->hasMessageWithId($message2->getId())); + self::assertFalse($bag->hasMessageWithId(Uuid::v4())); // Random UUID + } + + #[Test] + public function messageBagCanGetAllIds(): void + { + $message1 = Message::forSystem('System'); + $message2 = Message::ofUser('User message'); + $message3 = Message::ofAssistant('Assistant response'); + + $bag = new MessageBag($message1, $message2, $message3); + $ids = $bag->getIds(); + + self::assertCount(3, $ids); + self::assertTrue($message1->getId()->equals($ids[0])); + self::assertTrue($message2->getId()->equals($ids[1])); + self::assertTrue($message3->getId()->equals($ids[2])); + } + + #[Test] + public function messageBagCanGetMessagesAfterId(): void + { + $message1 = Message::forSystem('System'); + $message2 = Message::ofUser('User message'); + $message3 = Message::ofAssistant('Assistant response'); + $message4 = Message::ofUser('Another user message'); + + $bag = new MessageBag($message1, $message2, $message3, $message4); + + $messagesAfterMessage2 = $bag->messagesAfterId($message2->getId()); + self::assertCount(2, $messagesAfterMessage2); + self::assertSame($message3, $messagesAfterMessage2[0]); + self::assertSame($message4, $messagesAfterMessage2[1]); + + // If ID not found, should return all messages + $allMessages = $bag->messagesAfterId(Uuid::v4()); // Random UUID + self::assertCount(4, $allMessages); + } + + #[Test] + public function messageBagCanGetMessagesNewerThan(): void + { + $message1 = Message::forSystem('System'); + $message2 = Message::ofUser('User message'); + $message3 = Message::ofAssistant('Assistant response'); + $message4 = Message::ofUser('Another user message'); + + $bag = new MessageBag($message1, $message2, $message3, $message4); + + $newerBag = $bag->messagesNewerThan($message1->getId()); + $newerMessages = $newerBag->getMessages(); + + self::assertCount(3, $newerMessages); + self::assertSame($message2, $newerMessages[0]); + self::assertSame($message3, $newerMessages[1]); + self::assertSame($message4, $newerMessages[2]); + } + + #[Test] + public function idIsValidUuid(): void + { + $message = Message::forSystem('Test message'); + $id = $message->getId(); + + // Should be a valid UUID + self::assertInstanceOf(Uuid::class, $id); + } +}