From f56959d02017f6b0412fa911c6cd82cb5a31a61b Mon Sep 17 00:00:00 2001
From: Mateusz Charytoniuk <mateusz.charytoniuk@protonmail.com>
Date: Wed, 3 Apr 2024 15:21:40 +0200
Subject: [PATCH] feat: dialogue nodes

---
 composer.json                                 |   4 +-
 composer.lock                                 | 288 +++++++++++++-----
 .../features/ai/server/llama-cpp/index.md     |   8 +-
 docs/pages/index.md                           |   2 +-
 .../index.md                                  |   4 +-
 .../how-to-serve-llm-completions/index.md     |   4 +-
 phpunit.xml                                   |   2 +-
 src/Attribute/RespondsToHttp.php              |   2 +-
 src/Command/LlamaCppGenerate.php              |   4 +-
 src/Command/LlamaCppGenerate/Embedding.php    |   4 +-
 src/Command/LlamaCppHealth.php                |   4 +-
 src/Command/LlamaCppInfill.php                |   4 +-
 src/Command/StaticPagesMakeEmbeddings.php     |   4 +-
 src/DialogueInput.php                         |   7 +
 src/DialogueInput/UserInput.php               |  19 ++
 src/DialogueInputInterface.php                |  10 +
 src/DialogueMessageChunk.php                  |  21 ++
 src/DialogueMessageProducer.php               |   7 +
 .../ConstMessageProducer.php                  |  28 ++
 .../ConstMessageProducerTest.php              |  34 +++
 .../EmptyMessageProducer.php                  |  23 ++
 src/DialogueMessageProducerInterface.php      |  12 +
 src/DialogueNode.php                          |  37 +++
 src/DialogueNodeInterface.php                 |  14 +
 src/DialogueNodeTest.php                      |  62 ++++
 src/DialogueResponse.php                      |  23 ++
 src/DialogueResponseCondition.php             |   7 +
 .../ExactInputCondition.php                   |  25 ++
 .../LlamaCppInputCondition.php                |  24 ++
 src/DialogueResponseConditionInterface.php    |  12 +
 src/DialogueResponseDiscriminator.php         |  27 ++
 ...DialogueResponseDiscriminatorInterface.php |  16 +
 src/DialogueResponseInterface.php             |  12 +
 src/DialogueResponseResolution.php            |  17 ++
 src/DialogueResponseResolutionInterface.php   |  10 +
 src/DialogueResponseResolutionStatus.php      |   7 +
 src/DialogueResponseSortedIterator.php        |  54 ++++
 src/DialogueResponseSortedIteratorTest.php    |  54 ++++
 src/EsbuildMetaBuilder.php                    |   6 +-
 src/HttpInterceptor.php                       |   4 +-
 src/HttpResponder.php                         |   4 +-
 src/LlamaCppClient.php                        |   4 +-
 src/LlamaCppClientInterface.php               |  21 ++
 src/PsrStringStream.php                       |  12 +-
 src/StaticPageInternalLinkNodeRenderer.php    |   8 +-
 .../LlamaCppSubjectActionPromptResponder.php  |   4 +-
 46 files changed, 839 insertions(+), 120 deletions(-)
 create mode 100644 src/DialogueInput.php
 create mode 100644 src/DialogueInput/UserInput.php
 create mode 100644 src/DialogueInputInterface.php
 create mode 100644 src/DialogueMessageChunk.php
 create mode 100644 src/DialogueMessageProducer.php
 create mode 100644 src/DialogueMessageProducer/ConstMessageProducer.php
 create mode 100644 src/DialogueMessageProducer/ConstMessageProducerTest.php
 create mode 100644 src/DialogueMessageProducer/EmptyMessageProducer.php
 create mode 100644 src/DialogueMessageProducerInterface.php
 create mode 100644 src/DialogueNode.php
 create mode 100644 src/DialogueNodeInterface.php
 create mode 100644 src/DialogueNodeTest.php
 create mode 100644 src/DialogueResponse.php
 create mode 100644 src/DialogueResponseCondition.php
 create mode 100644 src/DialogueResponseCondition/ExactInputCondition.php
 create mode 100644 src/DialogueResponseCondition/LlamaCppInputCondition.php
 create mode 100644 src/DialogueResponseConditionInterface.php
 create mode 100644 src/DialogueResponseDiscriminator.php
 create mode 100644 src/DialogueResponseDiscriminatorInterface.php
 create mode 100644 src/DialogueResponseInterface.php
 create mode 100644 src/DialogueResponseResolution.php
 create mode 100644 src/DialogueResponseResolutionInterface.php
 create mode 100644 src/DialogueResponseResolutionStatus.php
 create mode 100644 src/DialogueResponseSortedIterator.php
 create mode 100644 src/DialogueResponseSortedIteratorTest.php
 create mode 100644 src/LlamaCppClientInterface.php

diff --git a/composer.json b/composer.json
index 7a5d9ed0..5e96603c 100644
--- a/composer.json
+++ b/composer.json
@@ -26,6 +26,7 @@
         "dragonmantank/cron-expression": "^3.3",
         "ezyang/htmlpurifier": "^4.16",
         "grpc/grpc": "^1.57",
+        "hyperf/coroutine": "^3.1",
         "hyperf/grpc-client": "^3.1",
         "league/commonmark": "^2.4",
         "league/oauth2-client": "^2.7",
@@ -60,7 +61,8 @@
     "require-dev": {
         "phpunit/phpunit": "^11.0",
         "swoole/ide-helper": "^5.1",
-        "symfony/var-dumper": "^7.0"
+        "symfony/var-dumper": "^7.0",
+        "mockery/mockery": "^1.6"
     },
     "suggest": {
         "ext-ds": "For better memory management",
diff --git a/composer.lock b/composer.lock
index 2de4e957..0cafaf88 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "6cc5c7a01d9bb04a890e15a3e326d795",
+    "content-hash": "2e42788f572c165ae18374fbd867a9b7",
     "packages": [
         {
             "name": "brick/math",
@@ -1841,16 +1841,16 @@
         },
         {
             "name": "hyperf/code-parser",
-            "version": "v3.1.4",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/code-parser.git",
-                "reference": "e36ac337cf7852aaa817db61ade0941d8826f0d8"
+                "reference": "820e8e6680f0d04e4187a3037a2a3eaf7141913d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/code-parser/zipball/e36ac337cf7852aaa817db61ade0941d8826f0d8",
-                "reference": "e36ac337cf7852aaa817db61ade0941d8826f0d8",
+                "url": "https://api.github.com/repos/hyperf/code-parser/zipball/820e8e6680f0d04e4187a3037a2a3eaf7141913d",
+                "reference": "820e8e6680f0d04e4187a3037a2a3eaf7141913d",
                 "shasum": ""
             },
             "require": {
@@ -1902,20 +1902,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-12-28T08:46:40+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/collection",
-            "version": "v3.1.7",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/collection.git",
-                "reference": "40e25ca34f798e173a1f9a6871d56a6abae43dbb"
+                "reference": "d0ac957987a704c8b2a16de4333b81f1f56a724a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/collection/zipball/40e25ca34f798e173a1f9a6871d56a6abae43dbb",
-                "reference": "40e25ca34f798e173a1f9a6871d56a6abae43dbb",
+                "url": "https://api.github.com/repos/hyperf/collection/zipball/d0ac957987a704c8b2a16de4333b81f1f56a724a",
+                "reference": "d0ac957987a704c8b2a16de4333b81f1f56a724a",
                 "shasum": ""
             },
             "require": {
@@ -1965,20 +1965,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-01-26T01:52:03+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/conditionable",
-            "version": "v3.1.0",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/conditionable.git",
-                "reference": "18da1405ae39a775bd3fae8cec98841eaa22f013"
+                "reference": "2c1555891d136904b890ba6d812d9ff50ca13ae3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/conditionable/zipball/18da1405ae39a775bd3fae8cec98841eaa22f013",
-                "reference": "18da1405ae39a775bd3fae8cec98841eaa22f013",
+                "url": "https://api.github.com/repos/hyperf/conditionable/zipball/2c1555891d136904b890ba6d812d9ff50ca13ae3",
+                "reference": "2c1555891d136904b890ba6d812d9ff50ca13ae3",
                 "shasum": ""
             },
             "require": {
@@ -2023,20 +2023,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-11-24T03:10:53+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/context",
-            "version": "v3.1.0",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/context.git",
-                "reference": "8307d6c924ed67c7abd47874ec14f0e2e3e4b732"
+                "reference": "ad913fd50eb5f738c038e172c120bc6956c0da69"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/context/zipball/8307d6c924ed67c7abd47874ec14f0e2e3e4b732",
-                "reference": "8307d6c924ed67c7abd47874ec14f0e2e3e4b732",
+                "url": "https://api.github.com/repos/hyperf/context/zipball/ad913fd50eb5f738c038e172c120bc6956c0da69",
+                "reference": "ad913fd50eb5f738c038e172c120bc6956c0da69",
                 "shasum": ""
             },
             "require": {
@@ -2085,20 +2085,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-11-24T03:10:53+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/contract",
-            "version": "v3.1.2",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/contract.git",
-                "reference": "f5379df6df65363d645506f373888372135ac0c6"
+                "reference": "9950abe963cc6b30c6d3506fa5b3adbd58cb1945"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/contract/zipball/f5379df6df65363d645506f373888372135ac0c6",
-                "reference": "f5379df6df65363d645506f373888372135ac0c6",
+                "url": "https://api.github.com/repos/hyperf/contract/zipball/9950abe963cc6b30c6d3506fa5b3adbd58cb1945",
+                "reference": "9950abe963cc6b30c6d3506fa5b3adbd58cb1945",
                 "shasum": ""
             },
             "require": {
@@ -2142,20 +2142,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-12-11T03:14:01+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/coroutine",
-            "version": "v3.1.1",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/coroutine.git",
-                "reference": "cd5bad67724c5c7a7ad749d8e9eb045470488d75"
+                "reference": "8f4c573a9457646db3e629dacabe064eebaf8cc1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/coroutine/zipball/cd5bad67724c5c7a7ad749d8e9eb045470488d75",
-                "reference": "cd5bad67724c5c7a7ad749d8e9eb045470488d75",
+                "url": "https://api.github.com/repos/hyperf/coroutine/zipball/8f4c573a9457646db3e629dacabe064eebaf8cc1",
+                "reference": "8f4c573a9457646db3e629dacabe064eebaf8cc1",
                 "shasum": ""
             },
             "require": {
@@ -2206,7 +2206,7 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-12-01T06:59:45+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/engine",
@@ -2357,16 +2357,16 @@
         },
         {
             "name": "hyperf/event",
-            "version": "v3.1.0",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/event.git",
-                "reference": "6eada5f74ce80786c567d5aed0361d51175217bb"
+                "reference": "8d008682c028e958197589e90232bb2a1d3c77d9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/event/zipball/6eada5f74ce80786c567d5aed0361d51175217bb",
-                "reference": "6eada5f74ce80786c567d5aed0361d51175217bb",
+                "url": "https://api.github.com/repos/hyperf/event/zipball/8d008682c028e958197589e90232bb2a1d3c77d9",
+                "reference": "8d008682c028e958197589e90232bb2a1d3c77d9",
                 "shasum": ""
             },
             "require": {
@@ -2420,20 +2420,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-11-24T03:10:53+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/grpc",
-            "version": "v3.1.11",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/grpc.git",
-                "reference": "63d8c8aac9a9fd7e586aa1298d9928107e6e0e27"
+                "reference": "7b0424ce1b9af5016e2c8580d6de9eb9c6ec4bed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/grpc/zipball/63d8c8aac9a9fd7e586aa1298d9928107e6e0e27",
-                "reference": "63d8c8aac9a9fd7e586aa1298d9928107e6e0e27",
+                "url": "https://api.github.com/repos/hyperf/grpc/zipball/7b0424ce1b9af5016e2c8580d6de9eb9c6ec4bed",
+                "reference": "7b0424ce1b9af5016e2c8580d6de9eb9c6ec4bed",
                 "shasum": ""
             },
             "require": {
@@ -2483,20 +2483,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-02-28T03:13:03+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/grpc-client",
-            "version": "v3.1.7",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/grpc-client.git",
-                "reference": "ce4cbf105f5f890f43bdd1997b9d69d4d09f5549"
+                "reference": "4f8f1aeef090d7789020e87a058ed35dcf3fc127"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/grpc-client/zipball/ce4cbf105f5f890f43bdd1997b9d69d4d09f5549",
-                "reference": "ce4cbf105f5f890f43bdd1997b9d69d4d09f5549",
+                "url": "https://api.github.com/repos/hyperf/grpc-client/zipball/4f8f1aeef090d7789020e87a058ed35dcf3fc127",
+                "reference": "4f8f1aeef090d7789020e87a058ed35dcf3fc127",
                 "shasum": ""
             },
             "require": {
@@ -2555,20 +2555,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-01-22T08:45:43+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/macroable",
-            "version": "v3.1.0",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/macroable.git",
-                "reference": "de5b07be74d666f04ecef4ce5ee6ceb97d846cfa"
+                "reference": "8912b5de69d25451b8ca103e4e47f0935e81072b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/macroable/zipball/de5b07be74d666f04ecef4ce5ee6ceb97d846cfa",
-                "reference": "de5b07be74d666f04ecef4ce5ee6ceb97d846cfa",
+                "url": "https://api.github.com/repos/hyperf/macroable/zipball/8912b5de69d25451b8ca103e4e47f0935e81072b",
+                "reference": "8912b5de69d25451b8ca103e4e47f0935e81072b",
                 "shasum": ""
             },
             "require": {
@@ -2613,20 +2613,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-11-24T03:10:53+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/stdlib",
-            "version": "v3.1.0",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/stdlib.git",
-                "reference": "25b73da235551d0d71d9157324709abaea36c455"
+                "reference": "636fdc1f15d9357b4747fa649874725f2276b118"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/stdlib/zipball/25b73da235551d0d71d9157324709abaea36c455",
-                "reference": "25b73da235551d0d71d9157324709abaea36c455",
+                "url": "https://api.github.com/repos/hyperf/stdlib/zipball/636fdc1f15d9357b4747fa649874725f2276b118",
+                "reference": "636fdc1f15d9357b4747fa649874725f2276b118",
                 "shasum": ""
             },
             "require": {
@@ -2671,20 +2671,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-11-24T03:10:53+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/stringable",
-            "version": "v3.1.13",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/stringable.git",
-                "reference": "b79f7204c35874a4ea0e917239b74fd230c93e1f"
+                "reference": "6cbd6f220d833c3f2c28c8263dccffee48021dcb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/stringable/zipball/b79f7204c35874a4ea0e917239b74fd230c93e1f",
-                "reference": "b79f7204c35874a4ea0e917239b74fd230c93e1f",
+                "url": "https://api.github.com/repos/hyperf/stringable/zipball/6cbd6f220d833c3f2c28c8263dccffee48021dcb",
+                "reference": "6cbd6f220d833c3f2c28c8263dccffee48021dcb",
                 "shasum": ""
             },
             "require": {
@@ -2742,20 +2742,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-03-10T09:19:40+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/support",
-            "version": "v3.1.13",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/support.git",
-                "reference": "cd5d284d83d0c031be38e3c5d07f6b96b837a42f"
+                "reference": "3fb5c6c5a4f795cb0304a032f6f5b85f62a5f872"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/support/zipball/cd5d284d83d0c031be38e3c5d07f6b96b837a42f",
-                "reference": "cd5d284d83d0c031be38e3c5d07f6b96b837a42f",
+                "url": "https://api.github.com/repos/hyperf/support/zipball/3fb5c6c5a4f795cb0304a032f6f5b85f62a5f872",
+                "reference": "3fb5c6c5a4f795cb0304a032f6f5b85f62a5f872",
                 "shasum": ""
             },
             "require": {
@@ -2812,20 +2812,20 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-03-13T08:47:07+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "hyperf/tappable",
-            "version": "v3.1.0",
+            "version": "v3.1.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hyperf/tappable.git",
-                "reference": "f640e37006dad09ca6f2b9a4cf047907aaebf002"
+                "reference": "69f22bbc8ecb5b930cc95a49ae9bf0ca0efbfdf1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hyperf/tappable/zipball/f640e37006dad09ca6f2b9a4cf047907aaebf002",
-                "reference": "f640e37006dad09ca6f2b9a4cf047907aaebf002",
+                "url": "https://api.github.com/repos/hyperf/tappable/zipball/69f22bbc8ecb5b930cc95a49ae9bf0ca0efbfdf1",
+                "reference": "69f22bbc8ecb5b930cc95a49ae9bf0ca0efbfdf1",
                 "shasum": ""
             },
             "require": {
@@ -2873,7 +2873,7 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2023-11-24T03:10:53+00:00"
+            "time": "2024-03-23T11:28:51+00:00"
         },
         {
             "name": "jean85/pretty-package-versions",
@@ -7667,6 +7667,140 @@
         }
     ],
     "packages-dev": [
+        {
+            "name": "hamcrest/hamcrest-php",
+            "version": "v2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hamcrest/hamcrest-php.git",
+                "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
+                "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3|^7.0|^8.0"
+            },
+            "replace": {
+                "cordoval/hamcrest-php": "*",
+                "davedevelopment/hamcrest-php": "*",
+                "kodova/hamcrest-php": "*"
+            },
+            "require-dev": {
+                "phpunit/php-file-iterator": "^1.4 || ^2.0",
+                "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "hamcrest"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "description": "This is the PHP port of Hamcrest Matchers",
+            "keywords": [
+                "test"
+            ],
+            "support": {
+                "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+                "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1"
+            },
+            "time": "2020-07-09T08:09:16+00:00"
+        },
+        {
+            "name": "mockery/mockery",
+            "version": "1.6.11",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/mockery/mockery.git",
+                "reference": "81a161d0b135df89951abd52296adf97deb0723d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/mockery/mockery/zipball/81a161d0b135df89951abd52296adf97deb0723d",
+                "reference": "81a161d0b135df89951abd52296adf97deb0723d",
+                "shasum": ""
+            },
+            "require": {
+                "hamcrest/hamcrest-php": "^2.0.1",
+                "lib-pcre": ">=7.0",
+                "php": ">=7.3"
+            },
+            "conflict": {
+                "phpunit/phpunit": "<8.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5 || ^9.6.17",
+                "symplify/easy-coding-standard": "^12.1.14"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "library/helpers.php",
+                    "library/Mockery.php"
+                ],
+                "psr-4": {
+                    "Mockery\\": "library/Mockery"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Pádraic Brady",
+                    "email": "padraic.brady@gmail.com",
+                    "homepage": "https://github.com/padraic",
+                    "role": "Author"
+                },
+                {
+                    "name": "Dave Marshall",
+                    "email": "dave.marshall@atstsolutions.co.uk",
+                    "homepage": "https://davedevelopment.co.uk",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Nathanael Esayeas",
+                    "email": "nathanael.esayeas@protonmail.com",
+                    "homepage": "https://github.com/ghostwriter",
+                    "role": "Lead Developer"
+                }
+            ],
+            "description": "Mockery is a simple yet flexible PHP mock object framework",
+            "homepage": "https://github.com/mockery/mockery",
+            "keywords": [
+                "BDD",
+                "TDD",
+                "library",
+                "mock",
+                "mock objects",
+                "mockery",
+                "stub",
+                "test",
+                "test double",
+                "testing"
+            ],
+            "support": {
+                "docs": "https://docs.mockery.io/",
+                "issues": "https://github.com/mockery/mockery/issues",
+                "rss": "https://github.com/mockery/mockery/releases.atom",
+                "security": "https://github.com/mockery/mockery/security/advisories",
+                "source": "https://github.com/mockery/mockery"
+            },
+            "time": "2024-03-21T18:34:15+00:00"
+        },
         {
             "name": "myclabs/deep-copy",
             "version": "1.11.1",
@@ -8227,16 +8361,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "11.0.8",
+            "version": "11.0.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "48ea58408879a9aad630022186398364051482fc"
+                "reference": "591bbfe416400385527d5086b346b92c06de404b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/48ea58408879a9aad630022186398364051482fc",
-                "reference": "48ea58408879a9aad630022186398364051482fc",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/591bbfe416400385527d5086b346b92c06de404b",
+                "reference": "591bbfe416400385527d5086b346b92c06de404b",
                 "shasum": ""
             },
             "require": {
@@ -8307,7 +8441,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.8"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.9"
             },
             "funding": [
                 {
@@ -8323,7 +8457,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-03-22T04:21:01+00:00"
+            "time": "2024-03-28T10:09:42+00:00"
         },
         {
             "name": "sebastian/cli-parser",
diff --git a/docs/pages/docs/features/ai/server/llama-cpp/index.md b/docs/pages/docs/features/ai/server/llama-cpp/index.md
index 88047a59..30f63337 100644
--- a/docs/pages/docs/features/ai/server/llama-cpp/index.md
+++ b/docs/pages/docs/features/ai/server/llama-cpp/index.md
@@ -46,14 +46,14 @@ you are serving. In the following example we will use
 
 namespace App;
 
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppCompletionRequest;
 use Distantmagic\Resonance\LlamaCppPromptTemplate\MistralInstructChat;
 
 #[Singleton]
 class LlamaCppGenerate 
 {
-    public function __construct(protected LlamaCppClient $llamaCppClient) 
+    public function __construct(protected LlamaCppClientInterface $llamaCppClient) 
     {
     }
 
@@ -104,13 +104,13 @@ foreach ($completion as $token) {
 
 namespace App;
 
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppEmbeddingRequest;
 
 #[Singleton]
 class LlamaCppGenerate 
 {
-    public function __construct(protected LlamaCppClient $llamaCppClient) 
+    public function __construct(protected LlamaCppClientInterface $llamaCppClient) 
     {
     }
 
diff --git a/docs/pages/index.md b/docs/pages/index.md
index 663aca8f..b165868b 100644
--- a/docs/pages/index.md
+++ b/docs/pages/index.md
@@ -109,7 +109,7 @@ readonly class CatAdopt implements PromptSubjectResponderInterface
                         data-hljs-language-value="php"
                     >class LlamaCppGenerate 
 {
-    public function __construct(protected LlamaCppClient $llamaCppClient) 
+    public function __construct(protected LlamaCppClientInterface $llamaCppClient) 
     {
     }
 
diff --git a/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md b/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md
index a02b430c..81406a4d 100644
--- a/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md
+++ b/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md
@@ -212,7 +212,7 @@ use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\JsonRPCNotification;
 use Distantmagic\Resonance\JsonRPCRequest;
 use Distantmagic\Resonance\JsonRPCResponse;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppCompletionRequest;
 use Distantmagic\Resonance\SingletonCollection;
 use Distantmagic\Resonance\WebSocketAuthResolution;
@@ -225,7 +225,7 @@ use Distantmagic\Resonance\WebSocketJsonRPCResponder;
 final readonly class LlmChatPromptResponder extends WebSocketJsonRPCResponder
 {
     public function __construct(
-        private LlamaCppClient $llamaCppClient,
+        private LlamaCppClientInterface $llamaCppClient,
     ) {}
 
     public function getConstraint(): Constraint
diff --git a/docs/pages/tutorials/how-to-serve-llm-completions/index.md b/docs/pages/tutorials/how-to-serve-llm-completions/index.md
index 4305e4a8..09a12072 100644
--- a/docs/pages/tutorials/how-to-serve-llm-completions/index.md
+++ b/docs/pages/tutorials/how-to-serve-llm-completions/index.md
@@ -111,13 +111,13 @@ inject `LlamaCppClient`:
 
 namespace App;
 
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppCompletionRequest;
 
 #[Singleton]
 class LlamaCppGenerate 
 {
-    public function __construct(protected LlamaCppClient $llamaCppClient) 
+    public function __construct(protected LlamaCppClientInterface $llamaCppClient) 
     {
     }
 
diff --git a/phpunit.xml b/phpunit.xml
index f960f454..3658f468 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -2,7 +2,7 @@
 <phpunit
     beStrictAboutCoverageMetadata="true"
     beStrictAboutChangesToGlobalState="true"
-    beStrictAboutTestsThatDoNotTestAnything="false"
+    beStrictAboutTestsThatDoNotTestAnything="true"
     bootstrap="phpunit_bootstrap.php"
     colors="true"
     displayDetailsOnTestsThatTriggerWarnings="true"
diff --git a/src/Attribute/RespondsToHttp.php b/src/Attribute/RespondsToHttp.php
index 49890d77..befd9cbe 100644
--- a/src/Attribute/RespondsToHttp.php
+++ b/src/Attribute/RespondsToHttp.php
@@ -9,7 +9,7 @@ use Distantmagic\Resonance\Attribute as BaseAttribute;
 use Distantmagic\Resonance\HttpRouteSymbolInterface;
 use Distantmagic\Resonance\RequestMethod;
 
-#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS)]
+#[Attribute(Attribute::TARGET_CLASS)]
 final readonly class RespondsToHttp extends BaseAttribute
 {
     /**
diff --git a/src/Command/LlamaCppGenerate.php b/src/Command/LlamaCppGenerate.php
index 2fab6a39..fe2468ee 100644
--- a/src/Command/LlamaCppGenerate.php
+++ b/src/Command/LlamaCppGenerate.php
@@ -5,7 +5,7 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\CoroutineCommand;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\SwooleConfiguration;
 use RuntimeException;
 use Symfony\Component\Console\Input\InputArgument;
@@ -20,7 +20,7 @@ abstract class LlamaCppGenerate extends CoroutineCommand
     abstract protected function executeLlamaCppCommand(InputInterface $input, OutputInterface $output, string $prompt): int;
 
     public function __construct(
-        protected LlamaCppClient $llamaCppClient,
+        protected LlamaCppClientInterface $llamaCppClient,
         SwooleConfiguration $swooleConfiguration,
     ) {
         parent::__construct($swooleConfiguration);
diff --git a/src/Command/LlamaCppGenerate/Embedding.php b/src/Command/LlamaCppGenerate/Embedding.php
index 7d5d376a..3a9e0545 100644
--- a/src/Command/LlamaCppGenerate/Embedding.php
+++ b/src/Command/LlamaCppGenerate/Embedding.php
@@ -8,7 +8,7 @@ use Distantmagic\Resonance\Attribute\ConsoleCommand;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\Command\LlamaCppGenerate;
 use Distantmagic\Resonance\JsonSerializer;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppEmbeddingRequest;
 use Distantmagic\Resonance\SwooleConfiguration;
 use Symfony\Component\Console\Input\InputInterface;
@@ -22,7 +22,7 @@ final class Embedding extends LlamaCppGenerate
 {
     public function __construct(
         private readonly JsonSerializer $jsonSerializer,
-        LlamaCppClient $llamaCppClient,
+        LlamaCppClientInterface $llamaCppClient,
         SwooleConfiguration $swooleConfiguration,
     ) {
         parent::__construct($llamaCppClient, $swooleConfiguration);
diff --git a/src/Command/LlamaCppHealth.php b/src/Command/LlamaCppHealth.php
index a5b64759..388391c7 100644
--- a/src/Command/LlamaCppHealth.php
+++ b/src/Command/LlamaCppHealth.php
@@ -7,7 +7,7 @@ namespace Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\CoroutineCommand;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\SwooleConfiguration;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Output\OutputInterface;
 final class LlamaCppHealth extends CoroutineCommand
 {
     public function __construct(
-        private readonly LlamaCppClient $llamaCppClient,
+        private readonly LlamaCppClientInterface $llamaCppClient,
         SwooleConfiguration $swooleConfiguration,
     ) {
         parent::__construct($swooleConfiguration);
diff --git a/src/Command/LlamaCppInfill.php b/src/Command/LlamaCppInfill.php
index 16f8908e..ecb5222f 100644
--- a/src/Command/LlamaCppInfill.php
+++ b/src/Command/LlamaCppInfill.php
@@ -8,7 +8,7 @@ use Distantmagic\Resonance\Attribute\ConsoleCommand;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\CoroutineCommand;
 use Distantmagic\Resonance\JsonSerializer;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppInfillRequest;
 use Distantmagic\Resonance\SwooleConfiguration;
 use Symfony\Component\Console\Input\InputInterface;
@@ -22,7 +22,7 @@ final class LlamaCppInfill extends CoroutineCommand
 {
     public function __construct(
         private readonly JsonSerializer $jsonSerializer,
-        private readonly LlamaCppClient $llamaCppClient,
+        private readonly LlamaCppClientInterface $llamaCppClient,
         SwooleConfiguration $swooleConfiguration,
     ) {
         parent::__construct($swooleConfiguration);
diff --git a/src/Command/StaticPagesMakeEmbeddings.php b/src/Command/StaticPagesMakeEmbeddings.php
index b948cfc6..9b6f8ad1 100644
--- a/src/Command/StaticPagesMakeEmbeddings.php
+++ b/src/Command/StaticPagesMakeEmbeddings.php
@@ -9,7 +9,7 @@ use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\JsonSerializer;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppEmbeddingRequest;
 use Distantmagic\Resonance\SQLiteVSSConnectionBuilder;
 use Distantmagic\Resonance\StaticPageChunkIterator;
@@ -29,7 +29,7 @@ final class StaticPagesMakeEmbeddings extends Command
 
     public function __construct(
         private readonly JsonSerializer $jsonSerializer,
-        private readonly LlamaCppClient $llamaCppClient,
+        private readonly LlamaCppClientInterface $llamaCppClient,
         private readonly SQLiteVSSConnectionBuilder $sqliteVSSConnectionBuilder,
         private readonly StaticPageChunkIterator $staticPageChunkIterator,
     ) {
diff --git a/src/DialogueInput.php b/src/DialogueInput.php
new file mode 100644
index 00000000..f22e1acb
--- /dev/null
+++ b/src/DialogueInput.php
@@ -0,0 +1,7 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+abstract readonly class DialogueInput implements DialogueInputInterface {}
diff --git a/src/DialogueInput/UserInput.php b/src/DialogueInput/UserInput.php
new file mode 100644
index 00000000..10bcb293
--- /dev/null
+++ b/src/DialogueInput/UserInput.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DialogueInput;
+
+use Distantmagic\Resonance\DialogueInputInterface;
+
+readonly class UserInput implements DialogueInputInterface
+{
+    public function __construct(
+        private string $content,
+    ) {}
+
+    public function getContent(): string
+    {
+        return $this->content;
+    }
+}
diff --git a/src/DialogueInputInterface.php b/src/DialogueInputInterface.php
new file mode 100644
index 00000000..d68037a5
--- /dev/null
+++ b/src/DialogueInputInterface.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+interface DialogueInputInterface
+{
+    public function getContent(): string;
+}
diff --git a/src/DialogueMessageChunk.php b/src/DialogueMessageChunk.php
new file mode 100644
index 00000000..7df4c922
--- /dev/null
+++ b/src/DialogueMessageChunk.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Stringable;
+
+readonly class DialogueMessageChunk implements Stringable
+{
+    public function __construct(
+        public string $content,
+        public bool $isFailed,
+        public bool $isLastToken,
+    ) {}
+
+    public function __toString(): string
+    {
+        return $this->content;
+    }
+}
diff --git a/src/DialogueMessageProducer.php b/src/DialogueMessageProducer.php
new file mode 100644
index 00000000..c4cfacec
--- /dev/null
+++ b/src/DialogueMessageProducer.php
@@ -0,0 +1,7 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+abstract readonly class DialogueMessageProducer implements DialogueMessageProducerInterface {}
diff --git a/src/DialogueMessageProducer/ConstMessageProducer.php b/src/DialogueMessageProducer/ConstMessageProducer.php
new file mode 100644
index 00000000..4d3a24c8
--- /dev/null
+++ b/src/DialogueMessageProducer/ConstMessageProducer.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DialogueMessageProducer;
+
+use Distantmagic\Resonance\DialogueMessageChunk;
+use Distantmagic\Resonance\DialogueMessageProducer;
+use Generator;
+
+readonly class ConstMessageProducer extends DialogueMessageProducer
+{
+    public function __construct(
+        public string $content,
+    ) {}
+
+    /**
+     * @return Generator<DialogueMessageChunk>
+     */
+    public function getIterator(): Generator
+    {
+        yield new DialogueMessageChunk(
+            content: $this->content,
+            isFailed: false,
+            isLastToken: true,
+        );
+    }
+}
diff --git a/src/DialogueMessageProducer/ConstMessageProducerTest.php b/src/DialogueMessageProducer/ConstMessageProducerTest.php
new file mode 100644
index 00000000..bbae0441
--- /dev/null
+++ b/src/DialogueMessageProducer/ConstMessageProducerTest.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DialogueMessageProducer;
+
+use Distantmagic\Resonance\DialogueMessageChunk;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @internal
+ */
+#[CoversClass(ConstMessageProducer::class)]
+final class ConstMessageProducerTest extends TestCase
+{
+    public function test_message_is_produced(): void
+    {
+        $inputMessage = 'What is your current role?';
+        $messageProducer = new ConstMessageProducer($inputMessage);
+
+        $message = '';
+
+        foreach ($messageProducer as $messageChunk) {
+            self::assertInstanceOf(DialogueMessageChunk::class, $messageChunk);
+            self::assertFalse($messageChunk->isFailed);
+            self::assertTrue($messageChunk->isLastToken);
+
+            $message .= $messageChunk->content;
+        }
+
+        self::assertSame($inputMessage, $message);
+    }
+}
diff --git a/src/DialogueMessageProducer/EmptyMessageProducer.php b/src/DialogueMessageProducer/EmptyMessageProducer.php
new file mode 100644
index 00000000..b3f1fba3
--- /dev/null
+++ b/src/DialogueMessageProducer/EmptyMessageProducer.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DialogueMessageProducer;
+
+use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\DialogueMessageChunk;
+use Distantmagic\Resonance\DialogueMessageProducer;
+use Generator;
+
+#[Singleton]
+readonly class EmptyMessageProducer extends DialogueMessageProducer
+{
+    public function getIterator(): Generator
+    {
+        yield new DialogueMessageChunk(
+            content: '',
+            isFailed: false,
+            isLastToken: true,
+        );
+    }
+}
diff --git a/src/DialogueMessageProducerInterface.php b/src/DialogueMessageProducerInterface.php
new file mode 100644
index 00000000..7cdfec89
--- /dev/null
+++ b/src/DialogueMessageProducerInterface.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use IteratorAggregate;
+
+/**
+ * @template-extends IteratorAggregate<DialogueMessageChunk>
+ */
+interface DialogueMessageProducerInterface extends IteratorAggregate {}
diff --git a/src/DialogueNode.php b/src/DialogueNode.php
new file mode 100644
index 00000000..5943acf3
--- /dev/null
+++ b/src/DialogueNode.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Ds\Set;
+
+readonly class DialogueNode implements DialogueNodeInterface
+{
+    /**
+     * @var Set<DialogueResponseInterface>
+     */
+    private Set $responses;
+
+    public function __construct(
+        private DialogueMessageProducerInterface $message,
+        private DialogueResponseDiscriminatorInterface $responseDiscriminator,
+    ) {
+        $this->responses = new Set();
+    }
+
+    public function addResponse(DialogueResponseInterface $response): void
+    {
+        $this->responses->add($response);
+    }
+
+    public function getMessageProducer(): DialogueMessageProducerInterface
+    {
+        return $this->message;
+    }
+
+    public function respondTo(DialogueInputInterface $prompt): ?DialogueNodeInterface
+    {
+        return $this->responseDiscriminator->discriminate($this->responses, $prompt);
+    }
+}
diff --git a/src/DialogueNodeInterface.php b/src/DialogueNodeInterface.php
new file mode 100644
index 00000000..875b1c9a
--- /dev/null
+++ b/src/DialogueNodeInterface.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+interface DialogueNodeInterface
+{
+    public function addResponse(DialogueResponseInterface $response): void;
+
+    public function getMessageProducer(): DialogueMessageProducerInterface;
+
+    public function respondTo(DialogueInputInterface $prompt): ?self;
+}
diff --git a/src/DialogueNodeTest.php b/src/DialogueNodeTest.php
new file mode 100644
index 00000000..64b86b70
--- /dev/null
+++ b/src/DialogueNodeTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Distantmagic\Resonance\DialogueInput\UserInput;
+use Distantmagic\Resonance\DialogueMessageProducer\ConstMessageProducer;
+use Distantmagic\Resonance\DialogueResponseCondition\ExactInputCondition;
+use Distantmagic\Resonance\DialogueResponseCondition\LlamaCppInputCondition;
+use Mockery;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @internal
+ */
+#[CoversClass(DialogueNode::class)]
+final class DialogueNodeTest extends TestCase
+{
+    public function test_dialogue_produces_no_response(): void
+    {
+        $responseDiscriminator = new DialogueResponseDiscriminator();
+
+        $rootNode = new DialogueNode(
+            message: new ConstMessageProducer('What is your current role?'),
+            responseDiscriminator: $responseDiscriminator,
+        );
+
+        $marketingNode = new DialogueNode(
+            message: new ConstMessageProducer('Hello, marketer!'),
+            responseDiscriminator: $responseDiscriminator,
+        );
+
+        $rootNode->addResponse(new DialogueResponse(
+            when: new LlamaCppInputCondition(
+                Mockery::mock(LlamaCppClientInterface::class),
+                'marketing'
+            ),
+            followUp: $marketingNode,
+        ));
+
+        $rootNode->addResponse(new DialogueResponse(
+            when: new ExactInputCondition('marketing'),
+            followUp: $marketingNode,
+        ));
+
+        $invalidNode = new DialogueNode(
+            message: new ConstMessageProducer('nope :('),
+            responseDiscriminator: $responseDiscriminator,
+        );
+
+        $rootNode->addResponse(new DialogueResponse(
+            when: new ExactInputCondition('not_a_marketing'),
+            followUp: $invalidNode,
+        ));
+
+        $response = $rootNode->respondTo(new UserInput('marketing'));
+
+        self::assertSame($response, $marketingNode);
+    }
+}
diff --git a/src/DialogueResponse.php b/src/DialogueResponse.php
new file mode 100644
index 00000000..8dfb37d4
--- /dev/null
+++ b/src/DialogueResponse.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+readonly class DialogueResponse implements DialogueResponseInterface
+{
+    public function __construct(
+        private DialogueNodeInterface $followUp,
+        private DialogueResponseConditionInterface $when,
+    ) {}
+
+    public function getCondition(): DialogueResponseConditionInterface
+    {
+        return $this->when;
+    }
+
+    public function getFollowUp(): DialogueNodeInterface
+    {
+        return $this->followUp;
+    }
+}
diff --git a/src/DialogueResponseCondition.php b/src/DialogueResponseCondition.php
new file mode 100644
index 00000000..9196226c
--- /dev/null
+++ b/src/DialogueResponseCondition.php
@@ -0,0 +1,7 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+abstract readonly class DialogueResponseCondition implements DialogueResponseConditionInterface {}
diff --git a/src/DialogueResponseCondition/ExactInputCondition.php b/src/DialogueResponseCondition/ExactInputCondition.php
new file mode 100644
index 00000000..20449344
--- /dev/null
+++ b/src/DialogueResponseCondition/ExactInputCondition.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DialogueResponseCondition;
+
+use Distantmagic\Resonance\DialogueInputInterface;
+use Distantmagic\Resonance\DialogueResponseCondition;
+
+readonly class ExactInputCondition extends DialogueResponseCondition
+{
+    public function __construct(
+        public string $content,
+    ) {}
+
+    public function getCost(): int
+    {
+        return 2;
+    }
+
+    public function isMetBy(DialogueInputInterface $dialogueInput): bool
+    {
+        return $dialogueInput->getContent() === $this->content;
+    }
+}
diff --git a/src/DialogueResponseCondition/LlamaCppInputCondition.php b/src/DialogueResponseCondition/LlamaCppInputCondition.php
new file mode 100644
index 00000000..97d99fe5
--- /dev/null
+++ b/src/DialogueResponseCondition/LlamaCppInputCondition.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\DialogueResponseCondition;
+
+use Distantmagic\Resonance\DialogueInputInterface;
+use Distantmagic\Resonance\DialogueResponseCondition;
+use Distantmagic\Resonance\LlamaCppClientInterface;
+
+readonly class LlamaCppInputCondition extends DialogueResponseCondition
+{
+    public function __construct(
+        public LlamaCppClientInterface $llamaCppClient,
+        public string $content,
+    ) {}
+
+    public function getCost(): int
+    {
+        return 50;
+    }
+
+    public function isMetBy(DialogueInputInterface $dialogueInput): bool {}
+}
diff --git a/src/DialogueResponseConditionInterface.php b/src/DialogueResponseConditionInterface.php
new file mode 100644
index 00000000..80e50ff9
--- /dev/null
+++ b/src/DialogueResponseConditionInterface.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+interface DialogueResponseConditionInterface
+{
+    public function getCost(): int;
+
+    public function isMetBy(DialogueInputInterface $dialogueInput): bool;
+}
diff --git a/src/DialogueResponseDiscriminator.php b/src/DialogueResponseDiscriminator.php
new file mode 100644
index 00000000..628af587
--- /dev/null
+++ b/src/DialogueResponseDiscriminator.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Distantmagic\Resonance\Attribute\Singleton;
+
+#[Singleton]
+readonly class DialogueResponseDiscriminator implements DialogueResponseDiscriminatorInterface
+{
+    /**
+     * @param iterable<DialogueResponseInterface> $responses
+     */
+    public function discriminate(
+        iterable $responses,
+        DialogueInputInterface $dialogueInput,
+    ): ?DialogueNodeInterface {
+        foreach (new DialogueResponseSortedIterator($responses) as $response) {
+            if ($response->getCondition()->isMetBy($dialogueInput)) {
+                return $response->getFollowUp();
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/src/DialogueResponseDiscriminatorInterface.php b/src/DialogueResponseDiscriminatorInterface.php
new file mode 100644
index 00000000..60002459
--- /dev/null
+++ b/src/DialogueResponseDiscriminatorInterface.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+interface DialogueResponseDiscriminatorInterface
+{
+    /**
+     * @param iterable<DialogueResponseInterface> $responses
+     */
+    public function discriminate(
+        iterable $responses,
+        DialogueInputInterface $dialogueInput,
+    ): ?DialogueNodeInterface;
+}
diff --git a/src/DialogueResponseInterface.php b/src/DialogueResponseInterface.php
new file mode 100644
index 00000000..df2c6784
--- /dev/null
+++ b/src/DialogueResponseInterface.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+interface DialogueResponseInterface
+{
+    public function getCondition(): DialogueResponseConditionInterface;
+
+    public function getFollowUp(): DialogueNodeInterface;
+}
diff --git a/src/DialogueResponseResolution.php b/src/DialogueResponseResolution.php
new file mode 100644
index 00000000..3dcfa0d8
--- /dev/null
+++ b/src/DialogueResponseResolution.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+readonly class DialogueResponseResolution implements DialogueResponseResolutionInterface
+{
+    public function __construct(
+        private DialogueResponseResolutionStatus $status,
+    ) {}
+
+    public function getStatus(): DialogueResponseResolutionStatus
+    {
+        return $this->status;
+    }
+}
diff --git a/src/DialogueResponseResolutionInterface.php b/src/DialogueResponseResolutionInterface.php
new file mode 100644
index 00000000..6ee9d1b4
--- /dev/null
+++ b/src/DialogueResponseResolutionInterface.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+interface DialogueResponseResolutionInterface
+{
+    public function getStatus(): DialogueResponseResolutionStatus;
+}
diff --git a/src/DialogueResponseResolutionStatus.php b/src/DialogueResponseResolutionStatus.php
new file mode 100644
index 00000000..136588fa
--- /dev/null
+++ b/src/DialogueResponseResolutionStatus.php
@@ -0,0 +1,7 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+enum DialogueResponseResolutionStatus {}
diff --git a/src/DialogueResponseSortedIterator.php b/src/DialogueResponseSortedIterator.php
new file mode 100644
index 00000000..de0aa579
--- /dev/null
+++ b/src/DialogueResponseSortedIterator.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Ds\PriorityQueue;
+use Ds\Stack;
+use Generator;
+use IteratorAggregate;
+
+/**
+ * @template-implements IteratorAggregate<DialogueResponseInterface>
+ */
+readonly class DialogueResponseSortedIterator implements IteratorAggregate
+{
+    /**
+     * @param iterable<DialogueResponseInterface> $responses
+     */
+    public function __construct(
+        private iterable $responses,
+    ) {}
+
+    /**
+     * @return Generator<DialogueResponseInterface>
+     */
+    public function getIterator(): Generator
+    {
+        /**
+         * @var PriorityQueue<DialogueResponseInterface> $responsesPriorityQueue
+         */
+        $responsesPriorityQueue = new PriorityQueue();
+
+        foreach ($this->responses as $response) {
+            $responsesPriorityQueue->push(
+                $response,
+                $response->getCondition()->getCost(),
+            );
+        }
+
+        /**
+         * @var Stack<DialogueResponseInterface> $sortedResponses
+         */
+        $sortedResponses = new Stack();
+
+        foreach ($responsesPriorityQueue as $response) {
+            $sortedResponses->push($response);
+        }
+
+        foreach ($sortedResponses as $response) {
+            yield $response;
+        }
+    }
+}
diff --git a/src/DialogueResponseSortedIteratorTest.php b/src/DialogueResponseSortedIteratorTest.php
new file mode 100644
index 00000000..82fa3b26
--- /dev/null
+++ b/src/DialogueResponseSortedIteratorTest.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Distantmagic\Resonance\DialogueMessageProducer\ConstMessageProducer;
+use Distantmagic\Resonance\DialogueResponseCondition\ExactInputCondition;
+use Distantmagic\Resonance\DialogueResponseCondition\LlamaCppInputCondition;
+use Mockery;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @internal
+ */
+#[CoversClass(DialogueResponseSortedIterator::class)]
+final class DialogueResponseSortedIteratorTest extends TestCase
+{
+    public function test_dialogue_responses_are_sorted_by_cost(): void
+    {
+        $responseDiscriminator = new DialogueResponseDiscriminator();
+
+        $marketingNode = new DialogueNode(
+            message: new ConstMessageProducer('Hello, marketer!'),
+            responseDiscriminator: $responseDiscriminator,
+        );
+
+        $response1 = new DialogueResponse(
+            when: new LlamaCppInputCondition(
+                Mockery::mock(LlamaCppClientInterface::class),
+                'test'
+            ),
+            followUp: $marketingNode,
+        );
+
+        $response2 = new DialogueResponse(
+            when: new ExactInputCondition('marketing'),
+            followUp: $marketingNode,
+        );
+
+        $responses = [
+            $response1,
+            $response2,
+        ];
+
+        $sortedResponses = iterator_to_array(new DialogueResponseSortedIterator($responses));
+
+        self::assertEquals([
+            $response2,
+            $response1,
+        ], $sortedResponses);
+    }
+}
diff --git a/src/EsbuildMetaBuilder.php b/src/EsbuildMetaBuilder.php
index 3c872062..91565d5f 100644
--- a/src/EsbuildMetaBuilder.php
+++ b/src/EsbuildMetaBuilder.php
@@ -151,13 +151,13 @@ readonly class EsbuildMetaBuilder
             throw new RuntimeException('Esbuild meta manifest is not readable: '.$esbuildMetafile);
         }
 
-        $contents = Coroutine::readFile($esbuildMetafile);
+        $content = Coroutine::readFile($esbuildMetafile);
 
-        if (!is_string($contents)) {
+        if (!is_string($content)) {
             throw new RuntimeException('Unable to read esbuild manifest: '.$esbuildMetafile);
         }
 
-        return $contents;
+        return $content;
     }
 
     private function getEsbuildMetaDecoded(string $esbuildMetafile): object
diff --git a/src/HttpInterceptor.php b/src/HttpInterceptor.php
index d0aad021..d0f4e5a6 100644
--- a/src/HttpInterceptor.php
+++ b/src/HttpInterceptor.php
@@ -14,8 +14,8 @@ use Stringable;
  */
 abstract readonly class HttpInterceptor implements HttpInterceptorInterface
 {
-    public function createStream(string|Stringable $contents): StreamInterface
+    public function createStream(string|Stringable $content): StreamInterface
     {
-        return new PsrStringStream($contents);
+        return new PsrStringStream($content);
     }
 }
diff --git a/src/HttpResponder.php b/src/HttpResponder.php
index 85b40c6d..a0004514 100644
--- a/src/HttpResponder.php
+++ b/src/HttpResponder.php
@@ -9,8 +9,8 @@ use Stringable;
 
 abstract readonly class HttpResponder implements HttpResponderInterface
 {
-    public function createStream(string|Stringable $contents): StreamInterface
+    public function createStream(string|Stringable $content): StreamInterface
     {
-        return new PsrStringStream($contents);
+        return new PsrStringStream($content);
     }
 }
diff --git a/src/LlamaCppClient.php b/src/LlamaCppClient.php
index b991ee4e..831878ab 100644
--- a/src/LlamaCppClient.php
+++ b/src/LlamaCppClient.php
@@ -15,8 +15,8 @@ use Swoole\Coroutine;
 use Swoole\Coroutine\Channel;
 
 #[RequiresPhpExtension('curl')]
-#[Singleton]
-readonly class LlamaCppClient
+#[Singleton(provides: LlamaCppClientInterface::class)]
+readonly class LlamaCppClient implements LlamaCppClientInterface
 {
     public function __construct(
         private JsonSerializer $jsonSerializer,
diff --git a/src/LlamaCppClientInterface.php b/src/LlamaCppClientInterface.php
new file mode 100644
index 00000000..c2ce4b46
--- /dev/null
+++ b/src/LlamaCppClientInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Generator;
+
+interface LlamaCppClientInterface
+{
+    public function generateCompletion(LlamaCppCompletionRequest $request): LlamaCppCompletionIterator;
+
+    public function generateEmbedding(LlamaCppEmbeddingRequest $request): LlamaCppEmbedding;
+
+    /**
+     * @return Generator<LlamaCppInfill>
+     */
+    public function generateInfill(LlamaCppInfillRequest $request): Generator;
+
+    public function getHealth(): LlamaCppHealthStatus;
+}
diff --git a/src/PsrStringStream.php b/src/PsrStringStream.php
index 8c9dc8a3..94d5b0dd 100644
--- a/src/PsrStringStream.php
+++ b/src/PsrStringStream.php
@@ -9,16 +9,16 @@ use Stringable;
 
 readonly class PsrStringStream implements StreamInterface
 {
-    private string $contents;
+    private string $content;
 
-    public function __construct(string|Stringable $contents)
+    public function __construct(string|Stringable $content)
     {
-        $this->contents = (string) $contents;
+        $this->content = (string) $content;
     }
 
     public function __toString(): string
     {
-        return $this->contents;
+        return $this->content;
     }
 
     public function close(): void {}
@@ -32,7 +32,7 @@ readonly class PsrStringStream implements StreamInterface
 
     public function getContents(): string
     {
-        return $this->contents;
+        return $this->content;
     }
 
     public function getMetadata($key = null)
@@ -42,7 +42,7 @@ readonly class PsrStringStream implements StreamInterface
 
     public function getSize(): ?int
     {
-        return strlen($this->contents);
+        return strlen($this->content);
     }
 
     public function isReadable(): bool
diff --git a/src/StaticPageInternalLinkNodeRenderer.php b/src/StaticPageInternalLinkNodeRenderer.php
index b0ba59a1..9df9cf1c 100644
--- a/src/StaticPageInternalLinkNodeRenderer.php
+++ b/src/StaticPageInternalLinkNodeRenderer.php
@@ -103,9 +103,9 @@ readonly class StaticPageInternalLinkNodeRenderer implements NodeRendererInterfa
     private function renderStaticPageBlockLink(StaticPage $staticPage): Stringable
     {
         /**
-         * @list<HtmlElement> $contents
+         * @list<HtmlElement> $content
          */
-        $contents = [
+        $content = [
             new HtmlElement(
                 'div',
                 [
@@ -135,7 +135,7 @@ readonly class StaticPageInternalLinkNodeRenderer implements NodeRendererInterfa
                 );
             }
 
-            $contents[] = new HtmlElement(
+            $content[] = new HtmlElement(
                 'ol',
                 [
                     'class' => 'document-links-group__link__tags',
@@ -150,7 +150,7 @@ readonly class StaticPageInternalLinkNodeRenderer implements NodeRendererInterfa
                 'class' => 'document-links-group__link',
                 'href' => $staticPage->getHref(),
             ],
-            $contents,
+            $content,
         );
     }
 
diff --git a/src/WebSocketJsonRPCResponder/LlamaCppSubjectActionPromptResponder.php b/src/WebSocketJsonRPCResponder/LlamaCppSubjectActionPromptResponder.php
index 8c3603de..bf45ed90 100644
--- a/src/WebSocketJsonRPCResponder/LlamaCppSubjectActionPromptResponder.php
+++ b/src/WebSocketJsonRPCResponder/LlamaCppSubjectActionPromptResponder.php
@@ -6,7 +6,7 @@ namespace Distantmagic\Resonance\WebSocketJsonRPCResponder;
 
 use Distantmagic\Resonance\BackusNaurFormGrammar\SubjectActionGrammar;
 use Distantmagic\Resonance\JsonRPCRequest;
-use Distantmagic\Resonance\LlamaCppClient;
+use Distantmagic\Resonance\LlamaCppClientInterface;
 use Distantmagic\Resonance\LlamaCppCompletionIterator;
 use Distantmagic\Resonance\LlamaCppCompletionRequest;
 use Distantmagic\Resonance\LlmPrompt\SubjectActionPrompt;
@@ -59,7 +59,7 @@ abstract readonly class LlamaCppSubjectActionPromptResponder extends WebSocketJs
     abstract protected function toPromptTemplate(string $prompt): LlmPromptTemplate;
 
     public function __construct(
-        private LlamaCppClient $llamaCppClient,
+        private LlamaCppClientInterface $llamaCppClient,
         private LoggerInterface $logger,
         private ObservableTaskTable $observableTaskTable,
         private PromptSubjectResponderAggregate $promptSubjectResponderAggregate,
-- 
GitLab