├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── ci-main.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CodeOfConduct.md ├── Contributing.md ├── LICENSES ├── Apache-2.0.txt ├── CC-BY-4.0.txt └── CC0-1.0.txt ├── README.md ├── REUSE.toml ├── benchmarks ├── ReadMe.md ├── benchmark.ipynb ├── benchmark.ipynb.license ├── build.gradle.kts ├── docs │ ├── llm_analysis.png │ ├── llm_analysis.png.license │ ├── llm_confusion_matrix.png │ ├── llm_confusion_matrix.png.license │ ├── llm_prediction.csv │ ├── llm_prediction.csv.license │ ├── vector_analysis.png │ ├── vector_analysis.png.license │ ├── vector_confusion_matrix.png │ ├── vector_confusion_matrix.png.license │ ├── vector_prediction.csv │ └── vector_prediction.csv.license └── src │ └── main │ ├── kotlin │ ├── llm │ │ └── LLMResolverBenchmark.kt │ └── vector │ │ └── VectorResolverBenchmark.kt │ └── resources │ ├── agentRoutingSpecs.json │ ├── agentRoutingSpecs.json.license │ ├── test.csv │ ├── test.csv.license │ ├── train.csv │ └── train.csv.license ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lmos-router-core ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── core │ │ ├── AgentRoutingSpec.kt │ │ ├── AgentRoutingSpecsProvider.kt │ │ ├── AgentRoutingSpecsResolver.kt │ │ ├── Context.kt │ │ ├── Input.kt │ │ ├── Result.kt │ │ └── SpecFilter.kt │ └── test │ ├── kotlin │ ├── SampleFlow.kt │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── core │ │ ├── AgentRoutingSpecBuilderTest.kt │ │ ├── AgentRoutingSpecsProviderTest.kt │ │ ├── AgentRoutingSpecsResolverTest.kt │ │ ├── ChatMessageTest.kt │ │ ├── ContextTest.kt │ │ ├── ResultTest.kt │ │ └── SpecFilterTest.kt │ └── resources │ ├── agentRoutingSpecs.json │ └── invalid_agentRoutingSpecs.json ├── lmos-router-hybrid-spring-boot-starter ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── org │ │ │ └── eclipse │ │ │ └── lmos │ │ │ └── router │ │ │ └── hybrid │ │ │ └── starter │ │ │ ├── HybridAgentRoutingSpecsResolverAutoConfiguration.kt │ │ │ ├── HybridAgentRoutingSpecsResolverProperties.kt │ │ │ ├── SpringModelClient.kt │ │ │ ├── SpringVectorSearchClient.kt │ │ │ ├── SpringVectorSearchClientProperties.kt │ │ │ └── SpringVectorSeedClient.kt │ └── resources │ │ └── META-INF │ │ └── spring │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ └── test │ ├── kotlin │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── hybrid │ │ └── starter │ │ ├── HybridAgentRoutingSpecsResolverAutoConfigurationTest.kt │ │ ├── SpringVectorSearchClientPropertiesTest.kt │ │ ├── SpringVectorSearchClientTest.kt │ │ └── SpringVectorSeedClientTest.kt │ └── resources │ └── agentRoutingSpecs.json ├── lmos-router-hybrid ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── hybrid │ │ ├── HybridAgentRoutingSpecsResolver.kt │ │ └── ModelToVectorQueryConverter.kt │ └── test │ ├── kotlin │ ├── SampleHybridFlow.kt │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── hybrid │ │ ├── HybridAgentRoutingSpecsResolverTest.kt │ │ ├── ModelToVectorQueryConverterTest.kt │ │ └── NoOpModelToVectorQueryConverterTest.kt │ └── resources │ ├── agentRoutingSpecs.json │ ├── prompt.txt │ └── seed.json ├── lmos-router-llm-in-spring-cloud-gateway-demo ├── Demo-setup.jpg ├── Demo-setup.jpg.license ├── Readme.md ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── demo │ │ ├── agent │ │ ├── AgentsApplication.kt │ │ └── inbound │ │ │ └── AgentsController.kt │ │ └── gateway │ │ └── LmosRouterGatewayApplication.kt │ └── resources │ └── application.yml ├── lmos-router-llm-spring-boot-starter ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── org │ │ │ └── eclipse │ │ │ └── lmos │ │ │ └── router │ │ │ └── llm │ │ │ └── starter │ │ │ ├── LLMAgentRoutingSpecsResolverAutoConfiguration.kt │ │ │ ├── LLMAgentRoutingSpecsResolverProperties.kt │ │ │ └── SpringModelClient.kt │ └── resources │ │ └── META-INF │ │ └── spring │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ └── test │ ├── kotlin │ ├── SpringLLMAgentResolverFlow.kt │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── llm │ │ └── starter │ │ └── LLMAgentRoutingSpecsResolverAutoConfigurationTest.kt │ └── resources │ ├── agentRoutingSpecs.json │ └── application.yml ├── lmos-router-llm ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── llm │ │ ├── LLMAgentRoutingSpecsResolver.kt │ │ ├── LangChainModelClient.kt │ │ ├── ModelClient.kt │ │ ├── ModelClientResponse.kt │ │ └── ModelPromptProvider.kt │ └── test │ ├── kotlin │ ├── SampleLLMFlow.kt │ └── ai │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── org │ │ ├── DefaultModelClientTest.kt │ │ ├── LLMAgentRoutingSpecsResolverTest.kt │ │ ├── LangChainChatModelFactoryTest.kt │ │ └── LangChainModelClientTest.kt │ └── resources │ ├── agentRoutingSpecs.json │ ├── prompt.txt │ └── prompt_agentRoutingSpec_json.txt ├── lmos-router-vector-spring-boot-starter ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── org │ │ │ └── eclipse │ │ │ └── lmos │ │ │ └── router │ │ │ └── vector │ │ │ └── starter │ │ │ ├── SpringVectorSearchClient.kt │ │ │ ├── SpringVectorSearchClientProperties.kt │ │ │ ├── SpringVectorSeedClient.kt │ │ │ ├── VectorAgentRoutingSpecsResolverAutoConfiguration.kt │ │ │ └── VectorAgentRoutingSpecsResolverProperties.kt │ └── resources │ │ └── META-INF │ │ └── spring │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ └── test │ ├── kotlin │ ├── SpringVectorAgentRoutingSpecsResolverFlow.kt │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── vector │ │ └── starter │ │ ├── SpringVectorSearchClientPropertiesTest.kt │ │ ├── SpringVectorSearchClientTest.kt │ │ ├── SpringVectorSeedClientTest.kt │ │ ├── VectorAgentRoutingSpecsResolverAutoConfigurationTest.kt │ │ └── VectorAgentRoutingSpecsResolverPropertiesTest.kt │ └── resources │ ├── META-INF │ └── spring.factories │ ├── agentRoutingSpecs.json │ ├── application.yml │ └── seed.json ├── lmos-router-vector ├── ReadMe.md ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── vector │ │ ├── EmbeddingClient.kt │ │ ├── Models.kt │ │ ├── Utils.kt │ │ ├── VectorAgentRoutingSpecsResolver.kt │ │ └── VectorClient.kt │ └── test │ ├── kotlin │ ├── SampleVectorFlow.kt │ └── org │ │ └── eclipse │ │ └── lmos │ │ └── router │ │ └── vector │ │ ├── CosineSimilarityTest.kt │ │ ├── VectorAgentRoutingSpecsResolverTest.kt │ │ └── VectorClientTest.kt │ └── resources │ ├── agentRoutingSpecs.json │ └── seed.json └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ktlint_standard_no-wildcard-imports = disabled -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | version: 2 6 | updates: 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | open-pull-requests-limit: 10 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/ci-main.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | name: CI 6 | 7 | on: 8 | push: 9 | branches: [ main ] 10 | 11 | jobs: 12 | CI: 13 | uses: eclipse-lmos/.github/.github/workflows/gradle-ci-main.yml@main 14 | permissions: 15 | contents: write 16 | packages: write 17 | secrets: 18 | oss-username: ${{ secrets.OSSRH_USERNAME }} 19 | oss-password: ${{ secrets.OSSRH_PASSWORD }} 20 | signing-key-id: ${{ secrets.GPG_SUBKEY_ID }} 21 | signing-key: ${{ secrets.GPG_PRIVATE_KEY }} 22 | signing-key-password: ${{ secrets.GPG_PASSPHRASE }} 23 | registry-username: ${{ github.actor }} 24 | registry-password: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | name: CI 6 | 7 | on: 8 | push: 9 | branches-ignore: 10 | - 'main' 11 | 12 | jobs: 13 | CI: 14 | uses: eclipse-lmos/.github/.github/workflows/gradle-ci.yml@main 15 | permissions: 16 | contents: read 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | name: Release 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | release-type: 11 | type: choice 12 | description: What do you want to release? 13 | options: 14 | - Milestone 15 | - Release 16 | 17 | jobs: 18 | CI: 19 | uses: eclipse-lmos/.github/.github/workflows/gradle-release.yml@main 20 | permissions: 21 | contents: write 22 | packages: write 23 | secrets: 24 | oss-username: ${{ secrets.OSSRH_USERNAME }} 25 | oss-password: ${{ secrets.OSSRH_PASSWORD }} 26 | bot-token: ${{ secrets.LMOS_BOT_TOKEN }} 27 | signing-key-id: ${{ secrets.GPG_SUBKEY_ID }} 28 | signing-key: ${{ secrets.GPG_PRIVATE_KEY }} 29 | signing-key-password: ${{ secrets.GPG_PASSPHRASE }} 30 | with: 31 | release-type: ${{ github.event.inputs.release-type }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/libraries/ 12 | *.iws 13 | *.iml 14 | *.ipr 15 | out/ 16 | !**/src/main/**/out/ 17 | !**/src/test/**/out/ 18 | 19 | ### Eclipse ### 20 | .apt_generated 21 | .classpath 22 | .factorypath 23 | .project 24 | .settings 25 | .springBeans 26 | .sts4-cache 27 | bin/ 28 | !**/src/main/**/bin/ 29 | !**/src/test/**/bin/ 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | 41 | ### Mac OS ### 42 | .DS_Store 43 | /.idea/ 44 | .kotlin 45 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | 6 | # Contributing to Intelligent Agent Routing System 7 | 8 | We welcome contributions to the Intelligent Agent Routing System! Whether you're fixing bugs, adding new features, improving documentation, or providing feedback, your help is appreciated. Please follow the guidelines below to ensure a smooth contribution process. 9 | 10 | ## Table of Contents 11 | 12 | 1. [How to Contribute](#how-to-contribute) 13 | 2. [Getting Started](#getting-started) 14 | 3. [Development Workflow](#development-workflow) 15 | 4. [Commit Messages](#commit-messages) 16 | 5. [Pull Request Guidelines](#pull-request-guidelines) 17 | 6. [Style Guide](#style-guide) 18 | 7. [Reporting Issues](#reporting-issues) 19 | 20 | ## How to Contribute 21 | 22 | ### Reporting Bugs 23 | 24 | If you find a bug, please report it by creating an issue in the [issue tracker](https://github.com/eclipse-lmos/lmos-router/issues). Include as much detail as possible to help us diagnose and fix the problem quickly. 25 | 26 | ### Suggesting Enhancements 27 | 28 | If you have an idea for a new feature or an improvement, please open an issue in the [issue tracker](https://github.com/eclipse-lmos/lmos-router/issues) to discuss it before starting any work. This helps us coordinate efforts and avoid duplicate work. 29 | 30 | ### Submitting Pull Requests 31 | 32 | 1. Fork the repository. 33 | 2. Create a new branch for your feature or bugfix. 34 | 3. Make your changes. 35 | 4. Ensure all tests pass. 36 | 5. Submit a pull request. 37 | 38 | ## Getting Started 39 | 40 | ### Prerequisites 41 | 42 | - Java Development Kit (JDK) 17 or higher 43 | - Gradle 44 | - Git 45 | 46 | ### Setup 47 | 48 | 1. **Clone the repository**: 49 | ```bash 50 | git clone https://github.com/eclipse-lmos/lmos-router.git 51 | cd lmos-router 52 | ``` 53 | 54 | 2. **Set environment variables**: 55 | - `OPENAI_API_KEY`: Your OpenAI API key. 56 | - `VECTOR_SEED_JSON_FILE_PATH`: Path to the JSON file containing seed vectors. 57 | 58 | 3. **Build the project**: 59 | ```bash 60 | ./gradlew build 61 | ``` 62 | 63 | ## Development Workflow 64 | 65 | 1. **Create a branch**: 66 | ```bash 67 | git checkout -b feature/your-feature-name 68 | ``` 69 | 70 | 2. **Make changes**: Implement your feature or bugfix. 71 | 72 | 3. **Run tests**: Ensure all tests pass. 73 | ```bash 74 | ./gradlew test 75 | ``` 76 | 77 | 4. **Commit changes**: Follow the [commit message guidelines](#commit-messages). 78 | 79 | 5. **Push to your fork**: 80 | ```bash 81 | git push origin feature/your-feature-name 82 | ``` 83 | 84 | 6. **Open a pull request**: Go to the repository on GitHub and open a pull request. 85 | 86 | ## Commit Messages 87 | 88 | - Use the present tense ("Add feature" not "Added feature"). 89 | - Use the imperative mood ("Move cursor to..." not "Moves cursor to..."). 90 | - Limit the first line to 72 characters or less. 91 | - Reference issues and pull requests liberally. 92 | - You can also follow [conventional commit messages](https://www.conventionalcommits.org/) for better readability. For example: 93 | - `feat`: A new feature. 94 | - `fix`: A bugfix. 95 | - `docs`: Documentation changes. 96 | - `style`: Code style changes. 97 | - `refactor`: Code refactoring. 98 | - `test`: Add or modify tests. 99 | - `chore`: Maintenance tasks. 100 | 101 | ## Pull Request Guidelines 102 | 103 | - Ensure your pull request (PR) adheres to the project's coding standards. 104 | - Include tests for new features or bugfixes. 105 | - Update the documentation if necessary. 106 | - Describe your changes in the PR description. 107 | - Link to any relevant issues or pull requests. 108 | 109 | ## Style Guide 110 | 111 | - Follow the [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html). 112 | - Use meaningful variable and function names. 113 | - Write clear and concise comments where necessary. 114 | 115 | ## Reporting Issues 116 | 117 | If you encounter any issues, please report them in the [issue tracker](https://github.com/eclipse-lmos/lmos-router/issues). Provide as much detail as possible, including steps to reproduce the issue, your environment, and any relevant logs or screenshots. 118 | 119 | Thank you for contributing to the Intelligent Agent Routing System! -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | version = 1 6 | SPDX-PackageName = "arc" 7 | SPDX-PackageSupplier = "Deutsche Telekom AG " 8 | SPDX-PackageDownloadLocation = "https://github.com/eclipse-lmos/lmos-router" 9 | 10 | [[annotations]] 11 | path = [".gitignore", ".editorconfig", "**/main/resources/META-INF/**", "**/test/resources/META-INF/**", "**/test/resources/*.json", "**/test/resources/*.txt", "**/test/resources/*.yml"] 12 | precedence = "aggregate" 13 | SPDX-FileCopyrightText = "none" 14 | SPDX-License-Identifier = "CC0-1.0" 15 | 16 | [[annotations]] 17 | path = ["gradle/**", "gradlew", "gradle.properties", "gradlew.bat"] 18 | precedence = "aggregate" 19 | SPDX-FileCopyrightText = "Copyright 2015 the original author or authors." 20 | SPDX-License-Identifier = "Apache-2.0" 21 | -------------------------------------------------------------------------------- /benchmarks/ReadMe.md: -------------------------------------------------------------------------------- 1 | 6 | # Model Benchmarking Framework 7 | 8 | This project provides a framework for benchmarking various models, including but not limited to Language Models (LLMs). The benchmarking process involves preparing datasets for classification measurement and generating classification metrics. 9 | 10 | ## Table of Contents 11 | 12 | - [Prerequisites](#prerequisites) 13 | - [Setup](#setup) 14 | - [Step 1: Preparing Dataset for Classification Measurement](#step-1-preparing-dataset-for-classification-measurement) 15 | - [Step 2: Generating Classification Metrics](#step-2-generating-classification-metrics) 16 | - [Usage](#usage) 17 | 18 | ## Prerequisites 19 | 20 | Before you begin, ensure you have met the following requirements: 21 | 22 | - You have installed [Kotlin](https://kotlinlang.org/docs/tutorials/command-line.html) and [Jupyter Notebook](https://jupyter.org/install). 23 | - You have an OpenAI API key (if benchmarking LLM AgentRoutingSpec Resolver). Set the `OPENAI_API_KEY` environment variable with your OpenAI API key. 24 | - You have Ollama installed(if benchmarking Vector AgentRoutingSpec Resolver) on your local machine. The default model is "all-minilm". 25 | - 26 | ## Setup 27 | 28 | 1. Clone the repository: 29 | 30 | ```sh 31 | git clone https://github.com/eclipse-lmos/lmos-router.git 32 | cd lmos-router 33 | ``` 34 | 35 | 2. Set the `OPENAI_API_KEY` environment variable (if applicable): 36 | 37 | ```sh 38 | export OPENAI_API_KEY=your_openai_api_key 39 | ``` 40 | 41 | ## Step 1: Preparing Dataset for Classification Measurement 42 | 43 | The first step involves preparing the dataset by running the `LLMResolverBenchmark.kt` or `VectorResolverBenchmark.kt` script. This script reads an input CSV file, processes each record to generate predictions using the specified model, and writes the results to an output CSV file. 44 | 45 | ## Step 2: Generating Classification Metrics 46 | 47 | The second step involves using the prediction file generated in Step 1 to compute classification metrics. This is done using a Jupyter Notebook. 48 | 49 | ### Notebook: `benchmarks/benchmark.ipynb` 50 | 51 | 1. Open the Jupyter Notebook: 52 | 53 | ```sh 54 | jupyter notebook benchmarks/benchmark.ipynb 55 | ``` 56 | 57 | 2. Follow the instructions in the notebook to load the prediction file and generate classification metrics. 58 | 59 | ## Usage 60 | 61 | 1. Run the respective resolver script to generate the prediction file: 62 | 63 | - For LLM AgentRoutingSpec Resolver: 64 | ```sh 65 | kotlinc src/main/kotlin/llm/LLMResolverBenchmark.kt -include-runtime -d LLMResolverBenchmark.jar 66 | java -jar LLMResolverBenchmark.jar 67 | ``` 68 | - For Vector AgentRoutingSpec Resolver: 69 | ```sh 70 | kotlinc src/main/kotlin/vector/VectorResolverBenchmark.kt -include-runtime -d VectorResolverBenchmark.jar 71 | java -jar VectorResolverBenchmark.jar 72 | ``` 73 | 74 | 2. Open the Jupyter Notebook to generate classification metrics: 75 | 76 | ```sh 77 | jupyter notebook benchmarks/benchmark.ipynb 78 | ``` -------------------------------------------------------------------------------- /benchmarks/benchmark.ipynb.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | dependencies { 6 | implementation("org.apache.commons:commons-csv:1.14.0") 7 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 8 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") 9 | implementation("io.ktor:ktor-client-cio-jvm:3.1.2") 10 | implementation(project(":lmos-router-core")) 11 | implementation(project(":lmos-router-llm")) 12 | implementation(project(":lmos-router-vector")) 13 | } 14 | -------------------------------------------------------------------------------- /benchmarks/docs/llm_analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eclipse-lmos/lmos-router/4de4f3c096efbbd0d0481e984f10f294bf91f1b6/benchmarks/docs/llm_analysis.png -------------------------------------------------------------------------------- /benchmarks/docs/llm_analysis.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/docs/llm_confusion_matrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eclipse-lmos/lmos-router/4de4f3c096efbbd0d0481e984f10f294bf91f1b6/benchmarks/docs/llm_confusion_matrix.png -------------------------------------------------------------------------------- /benchmarks/docs/llm_confusion_matrix.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/docs/llm_prediction.csv.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/docs/vector_analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eclipse-lmos/lmos-router/4de4f3c096efbbd0d0481e984f10f294bf91f1b6/benchmarks/docs/vector_analysis.png -------------------------------------------------------------------------------- /benchmarks/docs/vector_analysis.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/docs/vector_confusion_matrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eclipse-lmos/lmos-router/4de4f3c096efbbd0d0481e984f10f294bf91f1b6/benchmarks/docs/vector_confusion_matrix.png -------------------------------------------------------------------------------- /benchmarks/docs/vector_confusion_matrix.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/docs/vector_prediction.csv.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/src/main/kotlin/llm/LLMResolverBenchmark.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package llm 6 | 7 | import kotlinx.coroutines.* 8 | import kotlinx.coroutines.sync.Semaphore 9 | import kotlinx.coroutines.sync.withPermit 10 | import org.apache.commons.csv.CSVFormat 11 | import org.apache.commons.csv.CSVParser 12 | import org.apache.commons.csv.CSVPrinter 13 | import org.apache.commons.csv.CSVRecord 14 | import org.eclipse.lmos.router.core.Context 15 | import org.eclipse.lmos.router.core.JsonAgentRoutingSpecsProvider 16 | import org.eclipse.lmos.router.core.UserMessage 17 | import org.eclipse.lmos.router.core.getOrNull 18 | import org.eclipse.lmos.router.llm.DefaultModelClient 19 | import org.eclipse.lmos.router.llm.DefaultModelClientProperties 20 | import org.eclipse.lmos.router.llm.LLMAgentRoutingSpecsResolver 21 | import java.io.File 22 | import java.io.FileReader 23 | import java.io.FileWriter 24 | 25 | fun main() { 26 | require(System.getenv("OPENAI_API_KEY") != null) { "Please set the OPENAI_API_KEY environment variable to run the tests" } 27 | 28 | val samplesToAnnotate = 2000 29 | // read from src/main/resources/test.csv 30 | val inputFilePath = ClassLoader.getSystemResource("test.csv").file 31 | val outputFilePath = "benchmarks/llm_prediction.csv" 32 | val jsonFilePath = ClassLoader.getSystemResource("agentRoutingSpecs.json").file 33 | 34 | val agentSpecsProvider = JsonAgentRoutingSpecsProvider(jsonFilePath = jsonFilePath) 35 | val agentSpecResolver = 36 | LLMAgentRoutingSpecsResolver( 37 | agentRoutingSpecsProvider = agentSpecsProvider, 38 | modelClient = 39 | DefaultModelClient( 40 | defaultModelClientProperties = 41 | DefaultModelClientProperties( 42 | openAiApiKey = System.getenv("OPENAI_API_KEY"), 43 | ), 44 | ), 45 | ) 46 | 47 | runBlocking { 48 | processCsvInParallel(inputFilePath, outputFilePath, samplesToAnnotate, agentSpecResolver) 49 | } 50 | } 51 | 52 | suspend fun processCsvInParallel( 53 | inputFilePath: String, 54 | outputFilePath: String, 55 | samplesToAnnotate: Int, 56 | agentSpecResolver: LLMAgentRoutingSpecsResolver, 57 | ) = coroutineScope { 58 | val inputFile = File(inputFilePath) 59 | val outputFile = File(outputFilePath) 60 | val semaphore = Semaphore(4) // Limit the number of concurrent coroutines 61 | 62 | FileReader(inputFile).use { reader -> 63 | FileWriter(outputFile).use { writer -> 64 | // Parse the CSV file 65 | val csvFormat = 66 | CSVFormat.DEFAULT 67 | .builder() 68 | .setHeader() 69 | .setSkipHeaderRecord(true) 70 | .build() 71 | val csvParser = CSVParser(reader, csvFormat) 72 | 73 | // Get the headers and add the "prediction" column 74 | val headers = csvParser.headerNames.plus("prediction").toTypedArray() 75 | 76 | // Create the CSV printer with the updated headers 77 | val csvPrinter = CSVPrinter(writer, CSVFormat.DEFAULT.builder().setHeader(*headers).build()) 78 | 79 | val jobs = mutableListOf() 80 | var count = 0 81 | 82 | for (record: CSVRecord in csvParser) { 83 | if (count >= samplesToAnnotate) { 84 | break 85 | } 86 | count++ 87 | 88 | val job = 89 | launch(Dispatchers.IO) { 90 | semaphore.withPermit { 91 | val instruction = record.get("instruction") 92 | val prediction = getAgentName(instruction, agentSpecResolver) 93 | val recordWithPrediction = record.toMap().plus("prediction" to prediction) 94 | synchronized(csvPrinter) { 95 | csvPrinter.printRecord(recordWithPrediction.values) 96 | } 97 | } 98 | } 99 | jobs.add(job) 100 | } 101 | 102 | jobs.forEach { it.join() } 103 | csvPrinter.flush() 104 | } 105 | } 106 | } 107 | 108 | fun getAgentName( 109 | inputString: String, 110 | agentSpecResolver: LLMAgentRoutingSpecsResolver, 111 | ): String { 112 | val context = Context(listOf()) 113 | val input = UserMessage(inputString) 114 | val result = agentSpecResolver.resolve(context, input) 115 | return result.getOrNull()?.name ?: "No agent found" 116 | } 117 | -------------------------------------------------------------------------------- /benchmarks/src/main/resources/agentRoutingSpecs.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/src/main/resources/test.csv.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /benchmarks/src/main/resources/train.csv.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | version=0.3.0-SNAPSHOT 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eclipse-lmos/lmos-router/4de4f3c096efbbd0d0481e984f10f294bf91f1b6/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jul 18 20:24:33 IST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /lmos-router-core/ReadMe.md: -------------------------------------------------------------------------------- 1 | 6 | # Core Module 7 | 8 | ## Overview 9 | 10 | The Core module contains foundational classes and interfaces essential for the Intelligent Agent Routing System. It provides the basic building blocks for representing chat messages, agent routing specifications, context, and result handling. 11 | 12 | ## Table of Contents 13 | 14 | 1. [Introduction](#introduction) 15 | 2. [Classes and Interfaces](#classes-and-interfaces) 16 | 3. [Usage](#usage) 17 | 18 | ## Introduction 19 | 20 | The Core module is the backbone of the Intelligent Agent Routing System. It defines the essential components required for creating, managing, and resolving agent routing specifications. This module is designed to be flexible and extensible, allowing for easy integration with other modules such as LLM and Vector. 21 | 22 | ## Classes and Interfaces 23 | 24 | ### ChatMessage 25 | 26 | Represents a chat message. It is a sealed class with the following subclasses: 27 | - `UserMessage`: Represents a message from the user. 28 | - `SystemMessage`: Represents a system-generated message. 29 | - `AssistantMessage`: Represents a message from the assistant. 30 | 31 | ### ChatMessageFactory 32 | 33 | A factory class for creating chat messages based on the role (user, system, assistant). 34 | 35 | ### ChatMessageBuilder 36 | 37 | A builder class for creating chat messages with specified content and role. 38 | 39 | ### AgentRoutingSpecsResolver 40 | 41 | An interface for resolving agent routing specifications based on the context and input messages. It provides methods to resolve specifications with or without filters. 42 | 43 | ### AgentRoutingSpecResolverException 44 | 45 | An exception class thrown when an agent spec resolver fails. 46 | 47 | ### Result 48 | 49 | A sealed class representing the result of an operation. It can be either: 50 | - `Success`: Contains the successful result. 51 | - `Failure`: Contains the exception that caused the failure. 52 | 53 | ### Context 54 | 55 | Represents the context of a conversation, containing the previous messages exchanged. 56 | 57 | ### AgentRoutingSpecsProvider 58 | 59 | An interface for providing agent routing specifications. It includes methods to provide specifications with or without filters. 60 | 61 | ### AgentRoutingSpecsProviderException 62 | 63 | An exception class thrown when an error occurs while providing agent routing specifications. 64 | 65 | ### JsonAgentRoutingSpecsProvider 66 | 67 | A provider class that reads agent routing specifications from a JSON file. 68 | 69 | ### SimpleAgentRoutingSpecProvider 70 | 71 | A simple provider class for managing agent routing specifications in memory. 72 | 73 | ### SpecFilter 74 | 75 | A marker interface for filtering agent routing specifications. 76 | 77 | ### NameSpecFilter 78 | 79 | A filter class that filters agent routing specifications by name. 80 | 81 | ### VersionSpecFilter 82 | 83 | A filter class that filters agent routing specifications by version. 84 | 85 | ### AgentRoutingSpec 86 | 87 | Represents the routing specification of an agent, including its name, description, version, capabilities, and addresses. 88 | 89 | ### Capability 90 | 91 | Represents the capabilities of an agent. 92 | 93 | ### Address 94 | 95 | Represents the address of an agent, including the protocol and URI. 96 | 97 | ### CapabilitiesBuilder 98 | 99 | A builder class for creating an agent's capabilities. 100 | 101 | ### AgentRoutingSpecBuilder 102 | 103 | A builder class for creating an agent specification. 104 | 105 | ## Usage 106 | 107 | ### Creating Chat Messages 108 | 109 | ```kotlin 110 | val userMessage = ChatMessageBuilder() 111 | .content("Hello, I need help with my order.") 112 | .role("user") 113 | .build() 114 | 115 | val systemMessage = ChatMessageBuilder() 116 | .content("System maintenance scheduled at midnight.") 117 | .role("system") 118 | .build() 119 | 120 | val assistantMessage = ChatMessageBuilder() 121 | .content("Sure, I can help you with that.") 122 | .role("assistant") 123 | .build() 124 | ``` 125 | 126 | -------------------------------------------------------------------------------- /lmos-router-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | dependencies { 6 | implementation("org.slf4j:slf4j-api:2.0.17") 7 | api("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 8 | } 9 | -------------------------------------------------------------------------------- /lmos-router-core/src/main/kotlin/org/eclipse/lmos/router/core/AgentRoutingSpec.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | /** 10 | * Represents the routing specification of an agent. 11 | * The name field specifies the name of the agent. 12 | * The description field specifies a description of the agent. 13 | * The version field specifies the version of the agent. 14 | * The capabilities field specifies the capabilities of the agent. 15 | * The addresses field specifies the addresses of the agent. 16 | */ 17 | @Serializable 18 | open class AgentRoutingSpec( 19 | val name: String, 20 | val description: String, 21 | val version: String, 22 | val capabilities: Set, 23 | val addresses: Set
, 24 | ) 25 | 26 | /** 27 | * Represents the capabilities of an agent. 28 | * The name field specifies the name of the capability. 29 | * The description field specifies a description of the capability. 30 | * The version field specifies the version of the capability. 31 | */ 32 | @Serializable 33 | open class Capability( 34 | val name: String, 35 | val description: String, 36 | val version: String, 37 | ) 38 | 39 | /** 40 | * Represents the address of an agent. 41 | * The address is a URI that can be used to communicate with the agent. 42 | * The protocol field specifies the protocol to use when communicating with the agent. 43 | */ 44 | @Serializable 45 | open class Address( 46 | val protocol: String = "http", 47 | val uri: String, 48 | ) 49 | 50 | /** 51 | * Represents a builder for creating an agent's capabilities. 52 | */ 53 | class CapabilitiesBuilder { 54 | private var name: String = "" 55 | private var description: String = "" 56 | private var version: String = "" 57 | 58 | fun name(name: String) = apply { this.name = name } 59 | 60 | fun description(description: String) = apply { this.description = description } 61 | 62 | fun version(version: String) = apply { this.version = version } 63 | 64 | fun build(): Capability { 65 | return Capability(name, description, version) 66 | } 67 | } 68 | 69 | /** 70 | * Represents a builder for creating an agent specification. 71 | */ 72 | class AgentRoutingSpecBuilder { 73 | private var name: String = "" 74 | private var description: String = "" 75 | private var version: String = "" 76 | private var capabilities: MutableSet = mutableSetOf() 77 | private var address: MutableSet
= mutableSetOf() 78 | 79 | fun name(name: String) = apply { this.name = name } 80 | 81 | fun description(description: String) = apply { this.description = description } 82 | 83 | fun version(version: String) = apply { this.version = version } 84 | 85 | fun address(address: Address) = apply { this.address.add(address) } 86 | 87 | fun addCapability(capability: Capability) = apply { this.capabilities.add(capability) } 88 | 89 | fun build(): AgentRoutingSpec { 90 | require(name.isNotBlank()) { "name cannot be blank" } 91 | require(version.isNotBlank()) { "version cannot be blank" } 92 | require(address.isNotEmpty()) { "address cannot be empty" } 93 | 94 | return AgentRoutingSpec(name, description, version, capabilities, address) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lmos-router-core/src/main/kotlin/org/eclipse/lmos/router/core/AgentRoutingSpecsProvider.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import kotlinx.serialization.json.Json 8 | import java.io.File 9 | 10 | /** 11 | * Base interface for providing agent routing specifications. 12 | * 13 | * The [provide] method returns a set of agent routing specifications. 14 | * The [provide] method with filters returns a set of agent routing specifications that match the filters. 15 | * 16 | * @see AgentRoutingSpec 17 | * @see SpecFilter 18 | */ 19 | interface AgentRoutingSpecsProvider { 20 | fun provide(): Result, AgentRoutingSpecsProviderException> = provide(emptySet()) 21 | 22 | fun provide(filters: Set): Result, AgentRoutingSpecsProviderException> 23 | } 24 | 25 | /** 26 | * Exception thrown when an error occurs while providing agent routing specifications. 27 | * 28 | * @param msg The error message. 29 | * @param cause The cause of the exception. 30 | */ 31 | class AgentRoutingSpecsProviderException(msg: String, cause: Exception? = null) : Exception(msg, cause) 32 | 33 | /** 34 | * A provider that reads agent routing specifications from a JSON file. 35 | * 36 | * @param jsonFilePath The path to the JSON file containing the agent routing specifications. 37 | */ 38 | class JsonAgentRoutingSpecsProvider(jsonFilePath: String) : AgentRoutingSpecsProvider { 39 | private val agentRoutingSpecs = mutableSetOf() 40 | 41 | init { 42 | val jsonFile = File(jsonFilePath) 43 | val json = jsonFile.readText() 44 | val agentRoutingSpecs = Json.decodeFromString>(json) 45 | this.agentRoutingSpecs.addAll(agentRoutingSpecs) 46 | } 47 | 48 | override fun provide(filters: Set): Result, AgentRoutingSpecsProviderException> = 49 | try { 50 | Success(filters.fold(agentRoutingSpecs) { acc, specFilter -> specFilter.filter(acc).toMutableSet() }) 51 | } catch (e: Exception) { 52 | Failure(AgentRoutingSpecsProviderException("Failed to provide agent specs", e)) 53 | } 54 | } 55 | 56 | class SimpleAgentRoutingSpecProvider() : AgentRoutingSpecsProvider { 57 | private var agentRoutingSpecs: MutableSet = mutableSetOf() 58 | 59 | constructor(agentRoutingSpecs: MutableSet) : this() { 60 | this.agentRoutingSpecs = agentRoutingSpecs 61 | } 62 | 63 | override fun provide(filters: Set): Result, AgentRoutingSpecsProviderException> = 64 | try { 65 | Success(filters.fold(agentRoutingSpecs) { acc, specFilter -> specFilter.filter(acc).toMutableSet() }) 66 | } catch (e: Exception) { 67 | Failure(AgentRoutingSpecsProviderException("Failed to provide agent specs", e)) 68 | } 69 | 70 | fun add(agentRoutingSpec: AgentRoutingSpec): SimpleAgentRoutingSpecProvider { 71 | agentRoutingSpecs.add(agentRoutingSpec) 72 | return this 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lmos-router-core/src/main/kotlin/org/eclipse/lmos/router/core/AgentRoutingSpecsResolver.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | /** 8 | * Interface for resolving agent routing specifications. 9 | * 10 | * The [resolve] method returns an agent specification based on the context and input. 11 | * The [resolve] method with filters returns an agent specification after applying the filters and resolving based on the context and input. 12 | * 13 | * @see AgentRoutingSpec 14 | * @see SpecFilter 15 | */ 16 | interface AgentRoutingSpecsResolver { 17 | val agentRoutingSpecsProvider: AgentRoutingSpecsProvider 18 | 19 | fun resolve( 20 | context: Context, 21 | input: UserMessage, 22 | ): Result 23 | 24 | fun resolve( 25 | filters: Set, 26 | context: Context, 27 | input: UserMessage, 28 | ): Result 29 | } 30 | 31 | /** 32 | * Exception thrown when an agent spec resolver fail. 33 | * 34 | * @param msg The error message. 35 | * @param cause The cause of the exception. 36 | */ 37 | open class AgentRoutingSpecResolverException(msg: String, cause: Exception? = null) : Exception(msg, cause) 38 | -------------------------------------------------------------------------------- /lmos-router-core/src/main/kotlin/org/eclipse/lmos/router/core/Context.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | /** 8 | * Represents the context of a conversation. 9 | * 10 | * The previousMessages field contains the messages that have been exchanged in the conversation so far. 11 | */ 12 | open class Context( 13 | val previousMessages: List, 14 | ) 15 | -------------------------------------------------------------------------------- /lmos-router-core/src/main/kotlin/org/eclipse/lmos/router/core/Input.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | /** 8 | * Represents a chat message. 9 | * 10 | * The [content] field contains the content of the message. 11 | */ 12 | sealed class ChatMessage { 13 | abstract val content: String 14 | } 15 | 16 | /** 17 | * Represents a user message. 18 | * 19 | * The [content] field contains the content of the message. 20 | */ 21 | data class UserMessage(override val content: String) : ChatMessage() 22 | 23 | /** 24 | * Represents a system message. 25 | * 26 | * The [content] field contains the content of the message. 27 | */ 28 | data class SystemMessage(override val content: String) : ChatMessage() 29 | 30 | /** 31 | * Represents an assistant message. 32 | * 33 | * The [content] field contains the content of the message. 34 | */ 35 | data class AssistantMessage(override val content: String) : ChatMessage() 36 | 37 | /** 38 | * Factory for creating chat messages. 39 | */ 40 | class ChatMessageFactory { 41 | fun getChatMessage( 42 | content: String, 43 | role: String, 44 | ): ChatMessage { 45 | return when (role) { 46 | "user" -> UserMessage(content) 47 | "system" -> SystemMessage(content) 48 | "assistant" -> AssistantMessage(content) 49 | else -> throw IllegalArgumentException("Unknown message type") 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * Represents a builder for creating chat messages. 56 | */ 57 | class ChatMessageBuilder { 58 | private var content: String = "" 59 | private var role: String = "" 60 | 61 | fun content(content: String) = apply { this.content = content } 62 | 63 | fun role(role: String) = apply { this.role = role } 64 | 65 | fun build(): ChatMessage { 66 | return ChatMessageFactory().getChatMessage(content, role) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lmos-router-core/src/main/kotlin/org/eclipse/lmos/router/core/SpecFilter.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | /** 8 | * This is a marker interface for filtering agent specs. 9 | */ 10 | interface SpecFilter { 11 | fun filter(agentRoutingSpecs: Set): Set 12 | } 13 | 14 | /** 15 | * This is a filter that filters agent specs by name. 16 | */ 17 | class NameSpecFilter(private val value: String) : SpecFilter { 18 | override fun filter(agentRoutingSpecs: Set): Set { 19 | return agentRoutingSpecs.filter { it.name == value }.toSet() 20 | } 21 | } 22 | 23 | /** 24 | * This is a filter that filters agent specs by version. 25 | */ 26 | class VersionSpecFilter(private val value: String) : SpecFilter { 27 | override fun filter(agentRoutingSpecs: Set): Set { 28 | return agentRoutingSpecs.filter { it.version == value }.toSet() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/AgentRoutingSpecBuilderTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import org.junit.jupiter.api.Assertions.assertEquals 8 | import org.junit.jupiter.api.Assertions.assertTrue 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.assertThrows 11 | 12 | class AgentRoutingSpecBuilderTest { 13 | @Test 14 | fun `test build with valid data`() { 15 | val capability1 = Capability("capability1", "desc1", "1.0") 16 | val capability2 = Capability("capability2", "desc2", "1.1") 17 | val address1 = Address("http", "http://address1") 18 | 19 | val agentSpec = 20 | AgentRoutingSpecBuilder() 21 | .name("agent1") 22 | .description("agent description") 23 | .version("1.0") 24 | .address(address1) 25 | .addCapability(capability1) 26 | .addCapability(capability2) 27 | .build() 28 | 29 | assertEquals("agent1", agentSpec.name) 30 | assertEquals("agent description", agentSpec.description) 31 | assertEquals("1.0", agentSpec.version) 32 | assertTrue(agentSpec.capabilities.contains(capability1)) 33 | assertTrue(agentSpec.capabilities.contains(capability2)) 34 | assertTrue(agentSpec.addresses.contains(address1)) 35 | } 36 | 37 | @Test 38 | fun `test build with missing name should throw exception`() { 39 | val address1 = Address("http", "http://address1") 40 | 41 | val exception = 42 | assertThrows { 43 | AgentRoutingSpecBuilder() 44 | .description("agent description") 45 | .version("1.0") 46 | .address(address1) 47 | .build() 48 | } 49 | assertEquals("name cannot be blank", exception.message) 50 | } 51 | 52 | @Test 53 | fun `test build with missing version should throw exception`() { 54 | val address1 = Address("http", "http://address1") 55 | 56 | val exception = 57 | assertThrows { 58 | AgentRoutingSpecBuilder() 59 | .name("agent1") 60 | .description("agent description") 61 | .address(address1) 62 | .build() 63 | } 64 | assertEquals("version cannot be blank", exception.message) 65 | } 66 | 67 | @Test 68 | fun `test build with missing address should throw exception`() { 69 | val exception = 70 | assertThrows { 71 | AgentRoutingSpecBuilder() 72 | .name("agent1") 73 | .description("agent description") 74 | .version("1.0") 75 | .build() 76 | } 77 | assertEquals("address cannot be empty", exception.message) 78 | } 79 | 80 | @Test 81 | fun `test build with blank fields`() { 82 | val address1 = Address("http", "http://address1") 83 | val exception = 84 | assertThrows { 85 | AgentRoutingSpecBuilder() 86 | .name("") 87 | .description("agent description") 88 | .version("1.0") 89 | .address(address1) 90 | .build() 91 | } 92 | assertEquals("name cannot be blank", exception.message) 93 | } 94 | 95 | @Test 96 | fun `test valid Capability creation with builder`() { 97 | val capability = 98 | CapabilitiesBuilder() 99 | .name("capability1") 100 | .description("description1") 101 | .version("1.0") 102 | .build() 103 | 104 | assertEquals("capability1", capability.name) 105 | assertEquals("description1", capability.description) 106 | assertEquals("1.0", capability.version) 107 | } 108 | 109 | @Test 110 | fun `test valid AgentSpec creation with multiple addresses`() { 111 | val capability = Capability("capability1", "desc1", "1.0") 112 | val address1 = Address("http", "http://address1") 113 | val address2 = Address("https", "https://address2") 114 | 115 | val agentSpec = 116 | AgentRoutingSpecBuilder() 117 | .name("agent1") 118 | .description("agent description") 119 | .version("1.0") 120 | .address(address1) 121 | .address(address2) 122 | .addCapability(capability) 123 | .build() 124 | 125 | assertEquals("agent1", agentSpec.name) 126 | assertEquals("agent description", agentSpec.description) 127 | assertEquals("1.0", agentSpec.version) 128 | assertEquals(2, agentSpec.addresses.size) 129 | assertTrue(agentSpec.addresses.contains(address1)) 130 | assertTrue(agentSpec.addresses.contains(address2)) 131 | assertTrue(agentSpec.capabilities.contains(capability)) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/AgentRoutingSpecsProviderTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import kotlinx.serialization.ExperimentalSerializationApi 11 | import kotlinx.serialization.MissingFieldException 12 | import org.junit.jupiter.api.Assertions.* 13 | import org.junit.jupiter.api.BeforeEach 14 | import org.junit.jupiter.api.Test 15 | import java.io.FileNotFoundException 16 | 17 | class JsonAgentRoutingSpecsProviderTest { 18 | @Test 19 | fun `test provide with no filters`() { 20 | val jsonAgentRoutingSpecsProvider = JsonAgentRoutingSpecsProvider("src/test/resources/agentRoutingSpecs.json") 21 | val result = jsonAgentRoutingSpecsProvider.provide() 22 | assertTrue(result is Success) 23 | val agentRoutingSpecs = result.getOrNull() 24 | assertEquals(2, agentRoutingSpecs?.size) 25 | } 26 | 27 | @Test 28 | fun `test provide with filters`() { 29 | val jsonAgentRoutingSpecsProvider = JsonAgentRoutingSpecsProvider("src/test/resources/agentRoutingSpecs.json") 30 | val filter = mockk() 31 | every { filter.filter(any()) } answers { 32 | firstArg>().filter { it.name == "agent1" }.toMutableSet() 33 | } 34 | 35 | val result = jsonAgentRoutingSpecsProvider.provide(setOf(filter)) 36 | assertTrue(result is Success) 37 | val agentRoutingSpecs = result.getOrThrow() 38 | assertEquals(1, agentRoutingSpecs.size) 39 | assertEquals("agent1", agentRoutingSpecs.first().name) 40 | 41 | verify { filter.filter(any()) } 42 | } 43 | 44 | @Test 45 | fun `test provide when file reading fails`() { 46 | assertThrows(FileNotFoundException::class.java) { 47 | JsonAgentRoutingSpecsProvider("src/test/resources/invalid_file.json").provide() 48 | } 49 | } 50 | 51 | @OptIn(ExperimentalSerializationApi::class) 52 | @Test 53 | fun `test provide when JSON parsing fails`() { 54 | assertThrows(MissingFieldException::class.java) { 55 | JsonAgentRoutingSpecsProvider("src/test/resources/invalid_agentRoutingSpecs.json").provide() 56 | } 57 | } 58 | } 59 | 60 | class SimpleAgentRoutingSpecProviderTest { 61 | private lateinit var simpleAgentSpecProvider: SimpleAgentRoutingSpecProvider 62 | 63 | @BeforeEach 64 | fun setUp() { 65 | simpleAgentSpecProvider = 66 | SimpleAgentRoutingSpecProvider( 67 | mutableSetOf( 68 | AgentRoutingSpec( 69 | "Agent1", 70 | addresses = setOf(), 71 | capabilities = setOf(), 72 | description = "", 73 | version = "", 74 | ), 75 | AgentRoutingSpec("Agent2", addresses = setOf(), capabilities = setOf(), description = "", version = ""), 76 | ), 77 | ) 78 | } 79 | 80 | @Test 81 | fun `test provide with no filters`() { 82 | val result = simpleAgentSpecProvider.provide() 83 | assertTrue(result is Success) 84 | val agentSpecs = result.getOrNull() 85 | assertEquals(2, agentSpecs?.size) 86 | } 87 | 88 | @Test 89 | fun `test provide with filters`() { 90 | val filter = mockk() 91 | every { filter.filter(any()) } answers { 92 | firstArg>().filter { it.name == "Agent1" }.toMutableSet() 93 | } 94 | 95 | val result = simpleAgentSpecProvider.provide(setOf(filter)) 96 | assertTrue(result is Success) 97 | val agentSpecs = result.getOrNull() 98 | assertEquals(1, agentSpecs?.size) 99 | assertEquals("Agent1", agentSpecs?.first()?.name) 100 | 101 | verify { filter.filter(any()) } 102 | } 103 | 104 | @Test 105 | fun `test provide when filter throws exception`() { 106 | val filter = mockk() 107 | every { filter.filter(any()) } throws Exception("Filter failed") 108 | 109 | val result = simpleAgentSpecProvider.provide(setOf(filter)) 110 | assertTrue(result is Failure) 111 | val exception = result.exceptionOrNull() 112 | assertTrue(exception is AgentRoutingSpecsProviderException) 113 | assertEquals("Failed to provide agent specs", exception?.message) 114 | } 115 | 116 | @Test 117 | fun `test add agent spec`() { 118 | val newAgentRoutingSpec = 119 | AgentRoutingSpec("Agent3", addresses = setOf(), capabilities = setOf(), description = "", version = "") 120 | simpleAgentSpecProvider.add(newAgentRoutingSpec) 121 | 122 | val result = simpleAgentSpecProvider.provide() 123 | assertTrue(result is Success) 124 | val agentSpecs = result.getOrNull() 125 | assertEquals(3, agentSpecs?.size) 126 | assertTrue(agentSpecs?.any { it.name == "Agent3" } == true) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/AgentRoutingSpecsResolverTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import org.junit.jupiter.api.Assertions.* 11 | import org.junit.jupiter.api.BeforeEach 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.assertThrows 14 | 15 | class AgentRoutingSpecsResolverTest { 16 | private lateinit var agentRoutingSpecsProvider: AgentRoutingSpecsProvider 17 | private lateinit var agentRoutingSpecsResolver: AgentRoutingSpecsResolver 18 | private lateinit var context: Context 19 | private lateinit var input: UserMessage 20 | private lateinit var filters: Set 21 | 22 | @BeforeEach 23 | fun setUp() { 24 | agentRoutingSpecsProvider = mockk() 25 | agentRoutingSpecsResolver = mockk() 26 | context = mockk() 27 | input = mockk() 28 | filters = setOf(mockk(), mockk()) 29 | } 30 | 31 | @Test 32 | fun `resolve should return AgentSpec`() { 33 | val expectedAgentRoutingSpec = mockk() 34 | every { agentRoutingSpecsResolver.resolve(any(), any()) } returns Success(expectedAgentRoutingSpec) 35 | 36 | val result = agentRoutingSpecsResolver.resolve(context, input) 37 | 38 | assertTrue(result is Success) 39 | assertEquals(expectedAgentRoutingSpec, result.getOrThrow()) 40 | verify { agentRoutingSpecsResolver.resolve(context, input) } 41 | } 42 | 43 | @Test 44 | fun `resolve should return null AgentSpec`() { 45 | every { agentRoutingSpecsResolver.resolve(any(), any()) } returns Success(null) 46 | 47 | val result = agentRoutingSpecsResolver.resolve(context, input) 48 | 49 | assertTrue(result is Success) 50 | assertNull(result.getOrNull()) 51 | verify { agentRoutingSpecsResolver.resolve(context, input) } 52 | } 53 | 54 | @Test 55 | fun `resolve should throw AgentSpecResolverException`() { 56 | val exception = AgentRoutingSpecResolverException("Error") 57 | every { agentRoutingSpecsResolver.resolve(any(), any()) } returns Failure(exception) 58 | 59 | val result = agentRoutingSpecsResolver.resolve(context, input) 60 | 61 | assertTrue(result is Failure) 62 | assertThrows { result.getOrThrow() } 63 | verify { agentRoutingSpecsResolver.resolve(context, input) } 64 | } 65 | 66 | @Test 67 | fun `resolve with filters should return AgentSpec`() { 68 | val expectedAgentRoutingSpec = mockk() 69 | every { agentRoutingSpecsResolver.resolve(any(), any(), any()) } returns Success(expectedAgentRoutingSpec) 70 | 71 | val result = agentRoutingSpecsResolver.resolve(filters, context, input) 72 | 73 | assertTrue(result is Success) 74 | assertEquals(expectedAgentRoutingSpec, result.getOrThrow()) 75 | verify { agentRoutingSpecsResolver.resolve(filters, context, input) } 76 | } 77 | 78 | @Test 79 | fun `resolve with filters should return null AgentSpec`() { 80 | every { agentRoutingSpecsResolver.resolve(any(), any(), any()) } returns Success(null) 81 | 82 | val result = agentRoutingSpecsResolver.resolve(filters, context, input) 83 | 84 | assertTrue(result is Success) 85 | assertNull(result.getOrNull()) 86 | verify { agentRoutingSpecsResolver.resolve(filters, context, input) } 87 | } 88 | 89 | @Test 90 | fun `resolve with filters should throw AgentSpecResolverException`() { 91 | val exception = AgentRoutingSpecResolverException("Error") 92 | every { agentRoutingSpecsResolver.resolve(any(), any(), any()) } returns Failure(exception) 93 | 94 | val result = agentRoutingSpecsResolver.resolve(filters, context, input) 95 | 96 | assertTrue(result is Failure) 97 | assertThrows { result.getOrThrow() } 98 | verify { agentRoutingSpecsResolver.resolve(filters, context, input) } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/ChatMessageTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import org.junit.jupiter.api.Assertions.assertEquals 8 | import org.junit.jupiter.api.Assertions.assertTrue 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.assertThrows 11 | 12 | class ChatMessageFactoryTest { 13 | private val factory = ChatMessageFactory() 14 | 15 | @Test 16 | fun `getChatMessage should return UserMessage when role is user`() { 17 | val content = "Hello, user!" 18 | val role = "user" 19 | val result = factory.getChatMessage(content, role) 20 | assertTrue(result is UserMessage) 21 | assertEquals(content, result.content) 22 | } 23 | 24 | @Test 25 | fun `getChatMessage should return SystemMessage when role is system`() { 26 | val content = "System update available." 27 | val role = "system" 28 | val result = factory.getChatMessage(content, role) 29 | assertTrue(result is SystemMessage) 30 | assertEquals(content, result.content) 31 | } 32 | 33 | @Test 34 | fun `getChatMessage should return AssistantMessage when role is assistant`() { 35 | val content = "How can I assist you?" 36 | val role = "assistant" 37 | val result = factory.getChatMessage(content, role) 38 | assertTrue(result is AssistantMessage) 39 | assertEquals(content, result.content) 40 | } 41 | 42 | @Test 43 | fun `getChatMessage should throw IllegalArgumentException when role is unknown`() { 44 | val content = "Unknown role test" 45 | val role = "unknown" 46 | val exception = 47 | assertThrows { 48 | factory.getChatMessage(content, role) 49 | } 50 | assertEquals("Unknown message type", exception.message) 51 | } 52 | } 53 | 54 | class ChatMessageBuilderTest { 55 | @Test 56 | fun `build should return UserMessage when role is user`() { 57 | val content = "Hello, user!" 58 | val role = "user" 59 | 60 | val builder = 61 | ChatMessageBuilder() 62 | .content(content) 63 | .role(role) 64 | 65 | val result = builder.build() 66 | 67 | assertTrue(result is UserMessage) 68 | assertEquals(content, result.content) 69 | } 70 | 71 | @Test 72 | fun `build should return SystemMessage when role is system`() { 73 | val content = "System update available." 74 | val role = "system" 75 | 76 | val builder = 77 | ChatMessageBuilder() 78 | .content(content) 79 | .role(role) 80 | 81 | val result = builder.build() 82 | 83 | assertTrue(result is SystemMessage) 84 | assertEquals(content, result.content) 85 | } 86 | 87 | @Test 88 | fun `build should return AssistantMessage when role is assistant`() { 89 | val content = "How can I assist you?" 90 | val role = "assistant" 91 | 92 | val builder = 93 | ChatMessageBuilder() 94 | .content(content) 95 | .role(role) 96 | 97 | val result = builder.build() 98 | 99 | assertTrue(result is AssistantMessage) 100 | assertEquals(content, result.content) 101 | } 102 | 103 | @Test 104 | fun `build should throw IllegalArgumentException when role is unknown`() { 105 | val content = "Unknown role test" 106 | val role = "unknown" 107 | 108 | val builder = 109 | ChatMessageBuilder() 110 | .content(content) 111 | .role(role) 112 | 113 | val exception = 114 | assertThrows { 115 | builder.build() 116 | } 117 | assertEquals("Unknown message type", exception.message) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/ContextTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import org.junit.jupiter.api.Assertions.assertEquals 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.Test 12 | 13 | // Test class for Context 14 | class ContextTest { 15 | private lateinit var chatMessage1: ChatMessage 16 | private lateinit var chatMessage2: ChatMessage 17 | private lateinit var context: Context 18 | 19 | @BeforeEach 20 | fun setUp() { 21 | // Creating mock objects for ChatMessage 22 | chatMessage1 = mockk() 23 | chatMessage2 = mockk() 24 | 25 | // Setting up mock responses 26 | every { chatMessage1.content } returns "Hello" 27 | every { chatMessage2.content } returns "World" 28 | 29 | // Initializing the context with mocked chat messages 30 | context = Context(previousMessages = listOf(chatMessage1, chatMessage2)) 31 | } 32 | 33 | @Test 34 | fun `test context initialization with previous messages`() { 35 | // Verifying the size of previous messages 36 | assertEquals(2, context.previousMessages.size) 37 | 38 | // Verifying the contents of previous messages 39 | assertEquals("Hello", context.previousMessages[0].content) 40 | assertEquals("World", context.previousMessages[1].content) 41 | } 42 | 43 | @Test 44 | fun `test context with empty previous messages`() { 45 | // Creating a new context with no previous messages 46 | val emptyContext = Context(previousMessages = emptyList()) 47 | 48 | // Verifying the size of previous messages 49 | assertEquals(0, emptyContext.previousMessages.size) 50 | } 51 | 52 | @Test 53 | fun `test context with a single previous message`() { 54 | // Creating a new context with one previous message 55 | val singleMessageContext = Context(previousMessages = listOf(chatMessage1)) 56 | 57 | // Verifying the size of previous messages 58 | assertEquals(1, singleMessageContext.previousMessages.size) 59 | 60 | // Verifying the content of the single previous message 61 | assertEquals("Hello", singleMessageContext.previousMessages[0].content) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/ResultTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import org.junit.jupiter.api.Assertions.* 8 | import org.junit.jupiter.api.Test 9 | 10 | class ResultTest { 11 | @Test 12 | fun `should return Success result`() { 13 | val result = 14 | "test".result { 15 | "success" 16 | } 17 | assertTrue(result is Success) 18 | assertEquals("success", (result as Success).value) 19 | } 20 | 21 | @Test 22 | fun `should return Failure result on expected exception`() { 23 | val result = 24 | "test".result { 25 | failWith { IllegalArgumentException("failure") } 26 | } 27 | assertTrue(result is Failure) 28 | assertEquals("failure", (result as Failure).reason.message) 29 | } 30 | 31 | @Test 32 | fun `should call finally blocks`() { 33 | var finallyCalled = false 34 | "test".result { 35 | finally { finallyCalled = true } 36 | "success" 37 | } 38 | assertTrue(finallyCalled) 39 | } 40 | 41 | @Test 42 | fun `should throw exception with failWith`() { 43 | assertThrows(IllegalArgumentException::class.java) { 44 | val context = BasicResultBlock() 45 | context.failWith { IllegalArgumentException("failure") } 46 | } 47 | } 48 | 49 | @Test 50 | fun `should ensure predicate is true`() { 51 | assertThrows(IllegalArgumentException::class.java) { 52 | val context = BasicResultBlock() 53 | context.ensure(false) { IllegalArgumentException("failure") } 54 | } 55 | } 56 | 57 | @Test 58 | fun `should ensure predicate is not null`() { 59 | assertThrows(IllegalArgumentException::class.java) { 60 | val context = BasicResultBlock() 61 | context.ensureNotNull(null) { IllegalArgumentException("failure") } 62 | } 63 | } 64 | 65 | @Test 66 | fun `should handle onFailure block`() { 67 | val result = Failure(IllegalArgumentException("failure")) as Result 68 | var onFailureCalled = false 69 | result.onFailure { onFailureCalled = true } 70 | assertTrue(onFailureCalled) 71 | } 72 | 73 | @Test 74 | fun `should get value or throw exception`() { 75 | val successResult = Success("success") as Result 76 | assertEquals("success", successResult.getOrThrow()) 77 | 78 | val failureResult = Failure(IllegalArgumentException("failure")) as Result 79 | assertThrows(IllegalArgumentException::class.java) { failureResult.getOrThrow() } 80 | } 81 | 82 | @Test 83 | fun `should get value or return null`() { 84 | val successResult = Success("success") as Result 85 | assertEquals("success", successResult.getOrNull()) 86 | 87 | val failureResult = Failure(IllegalArgumentException("failure")) as Result 88 | assertNull(failureResult.getOrNull()) 89 | } 90 | 91 | @Test 92 | fun `should mapFailure to a new type`() { 93 | val failureResult = Failure(IllegalArgumentException("failure")) as Result 94 | val mappedResult = failureResult.mapFailure { IllegalStateException(it.message) } 95 | assertTrue(mappedResult is Failure) 96 | assertEquals("failure", (mappedResult as Failure).reason.message) 97 | } 98 | 99 | @Test 100 | fun `should map success value to a new type`() { 101 | val successResult = Success("success") as Result 102 | val mappedResult = successResult.map { it.length } 103 | assertTrue(mappedResult is Success) 104 | assertEquals(7, (mappedResult as Success).value) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/kotlin/org/eclipse/lmos/router/core/SpecFilterTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.core 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import org.junit.jupiter.api.Assertions.assertEquals 10 | import org.junit.jupiter.api.DisplayName 11 | import org.junit.jupiter.api.Test 12 | 13 | class NameSpecFilterTest { 14 | @Test 15 | @DisplayName("Test NameSpecFilter filters by name") 16 | fun testNameSpecFilterFiltersByName() { 17 | // Arrange 18 | val agentRoutingSpec1 = mockk() 19 | val agentRoutingSpec2 = mockk() 20 | val agentRoutingSpec3 = mockk() 21 | 22 | every { agentRoutingSpec1.name } returns "agentA" 23 | every { agentRoutingSpec2.name } returns "agentB" 24 | every { agentRoutingSpec3.name } returns "agentA" 25 | 26 | val filter = NameSpecFilter("agentA") 27 | 28 | val agentSpecs = setOf(agentRoutingSpec1, agentRoutingSpec2, agentRoutingSpec3) 29 | 30 | // Act 31 | val result = filter.filter(agentSpecs) 32 | 33 | // Assert 34 | val expected = setOf(agentRoutingSpec1, agentRoutingSpec3) 35 | assertEquals(expected, result) 36 | } 37 | 38 | @Test 39 | @DisplayName("Test NameSpecFilter filters by name with no matches") 40 | fun testNameSpecFilterFiltersByNameWithNoMatches() { 41 | // Arrange 42 | val agentRoutingSpec1 = mockk() 43 | val agentRoutingSpec2 = mockk() 44 | 45 | every { agentRoutingSpec1.name } returns "agentA" 46 | every { agentRoutingSpec2.name } returns "agentB" 47 | 48 | val filter = NameSpecFilter("agentC") 49 | 50 | val agentSpecs = setOf(agentRoutingSpec1, agentRoutingSpec2) 51 | 52 | // Act 53 | val result = filter.filter(agentSpecs) 54 | 55 | // Assert 56 | val expected = emptySet() 57 | assertEquals(expected, result) 58 | } 59 | } 60 | 61 | class VersionSpecFilterTest { 62 | @Test 63 | @DisplayName("Test VersionSpecFilter filters by version") 64 | fun testVersionSpecFilterFiltersByVersion() { 65 | // Arrange 66 | val agentRoutingSpec1 = mockk() 67 | val agentRoutingSpec2 = mockk() 68 | val agentRoutingSpec3 = mockk() 69 | 70 | every { agentRoutingSpec1.version } returns "1.0.0" 71 | every { agentRoutingSpec2.version } returns "2.0.0" 72 | every { agentRoutingSpec3.version } returns "1.0.0" 73 | 74 | val filter = VersionSpecFilter("1.0.0") 75 | 76 | val agentSpecs = setOf(agentRoutingSpec1, agentRoutingSpec2, agentRoutingSpec3) 77 | 78 | // Act 79 | val result = filter.filter(agentSpecs) 80 | 81 | // Assert 82 | val expected = setOf(agentRoutingSpec1, agentRoutingSpec3) 83 | assertEquals(expected, result) 84 | } 85 | 86 | @Test 87 | @DisplayName("Test VersionSpecFilter filters by version with no matches") 88 | fun testVersionSpecFilterFiltersByVersionWithNoMatches() { 89 | // Arrange 90 | val agentRoutingSpec1 = mockk() 91 | val agentRoutingSpec2 = mockk() 92 | 93 | every { agentRoutingSpec1.version } returns "1.0.0" 94 | every { agentRoutingSpec2.version } returns "2.0.0" 95 | 96 | val filter = VersionSpecFilter("3.0.0") 97 | 98 | val agentSpecs = setOf(agentRoutingSpec1, agentRoutingSpec2) 99 | 100 | // Act 101 | val result = filter.filter(agentSpecs) 102 | 103 | // Assert 104 | val expected = emptySet() 105 | assertEquals(expected, result) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lmos-router-core/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "agent1", 4 | "description": "agent1 description", 5 | "version": "1.0.0", 6 | "addresses": [{ 7 | "protocol": "http", 8 | "uri": "http://localhost:8080" 9 | }], 10 | "capabilities": [ 11 | { 12 | "name": "capability1", 13 | "description": "capability1 description", 14 | "version": "1.0.0" 15 | }, 16 | { 17 | "name": "capability2", 18 | "description": "capability2 description", 19 | "version": "1.0.0" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "agent2", 25 | "description": "agent2 description", 26 | "version": "1.0.1", 27 | "addresses": [{ 28 | "protocol": "http", 29 | "uri": "http://localhost:8080" 30 | }], 31 | "capabilities": [ 32 | { 33 | "name": "capability1", 34 | "description": "capability1 description", 35 | "version": "1.0.0" 36 | } 37 | ] 38 | } 39 | ] -------------------------------------------------------------------------------- /lmos-router-core/src/test/resources/invalid_agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "agent1", 4 | "description": "agent1 description", 5 | "version": "1.0.0" 6 | }, 7 | { 8 | "name": "agent2", 9 | "description": "agent2 description", 10 | "version": "1.0.1" 11 | } 12 | ] -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | val springBootVersion: String by rootProject.extra 6 | 7 | dependencies { 8 | api(project(":lmos-router-hybrid")) 9 | implementation("org.springframework.boot:spring-boot-autoconfigure:$springBootVersion") 10 | implementation("org.springframework.ai:spring-ai-core:1.0.0-M6") 11 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 12 | testImplementation("org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6") 13 | testImplementation("org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter:1.0.0-M6") 14 | testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") 15 | testImplementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") 16 | testImplementation("org.testcontainers:qdrant:1.21.0") 17 | } 18 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/hybrid/starter/HybridAgentRoutingSpecsResolverProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | 9 | /** 10 | * Properties for the VectorAgentRoutingSpecsResolver. 11 | * 12 | * @param specFilePath The path to the file containing the agent specs. 13 | */ 14 | @ConfigurationProperties(prefix = "route.agent.hybrid") 15 | class HybridAgentRoutingSpecsResolverProperties( 16 | var specFilePath: String = "", 17 | var resolverPromptFilePath: String = "", 18 | ) 19 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringModelClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import org.eclipse.lmos.router.core.* 8 | import org.eclipse.lmos.router.llm.ModelClient 9 | import org.springframework.ai.chat.model.ChatModel 10 | import org.springframework.ai.chat.prompt.Prompt 11 | 12 | /** 13 | * This is a model client that uses the Spring chat model to resolve agent routing specifications. 14 | * 15 | * @param chatModel The chat model. 16 | */ 17 | class SpringModelClient( 18 | private val chatModel: ChatModel, 19 | ) : ModelClient { 20 | /** 21 | * Calls the chat model with the given messages. 22 | * 23 | * @param messages The messages. 24 | * @return The result of the call. 25 | */ 26 | override fun call(messages: List): Result { 27 | return try { 28 | val response = 29 | chatModel.call( 30 | Prompt( 31 | messages.map { 32 | when (it) { 33 | is UserMessage -> org.springframework.ai.chat.messages.UserMessage(it.content) 34 | is AssistantMessage -> org.springframework.ai.chat.messages.AssistantMessage(it.content) 35 | is SystemMessage -> org.springframework.ai.chat.messages.SystemMessage(it.content) 36 | else -> throw IllegalArgumentException("Unsupported message type: ${it::class.simpleName}") 37 | } 38 | }, 39 | ), 40 | ).result.output.text 41 | Success(AssistantMessage(response)) 42 | } catch (e: Exception) { 43 | Failure(AgentRoutingSpecResolverException(e.message ?: "An error occurred", e)) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringVectorSearchClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import org.eclipse.lmos.router.core.AgentRoutingSpec 8 | import org.eclipse.lmos.router.core.Failure 9 | import org.eclipse.lmos.router.core.Result 10 | import org.eclipse.lmos.router.core.Success 11 | import org.eclipse.lmos.router.vector.VectorClientException 12 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 13 | import org.eclipse.lmos.router.vector.VectorSearchClient 14 | import org.eclipse.lmos.router.vector.VectorSearchClientRequest 15 | import org.eclipse.lmos.router.vector.VectorSearchClientResponse 16 | import org.springframework.ai.vectorstore.SearchRequest 17 | import org.springframework.ai.vectorstore.VectorStore 18 | import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder 19 | 20 | /** 21 | * A Spring implementation of the VectorSearchClient. 22 | * 23 | * @param vectorStore The vector store to search in. 24 | */ 25 | class SpringVectorSearchClient( 26 | private val vectorStore: VectorStore, 27 | private val springVectorSearchClientProperties: SpringVectorSearchClientProperties, 28 | ) : VectorSearchClient { 29 | /** 30 | * Finds the most similar vector to the given query. 31 | * 32 | * @param request The search request. 33 | * @param agentRoutingSpecs The agent specs to filter by. 34 | * @return A result containing the most similar vector or null if no similar vectors were found. 35 | */ 36 | override fun find( 37 | request: VectorSearchClientRequest, 38 | agentRoutingSpecs: Set, 39 | ): Result { 40 | return try { 41 | val documents = 42 | vectorStore.similaritySearch( 43 | SearchRequest.builder().query(request.query) 44 | .similarityThreshold(springVectorSearchClientProperties.threshold) 45 | .topK(springVectorSearchClientProperties.topK) 46 | .filterExpression( 47 | FilterExpressionBuilder().`in`( 48 | AGENT_FIELD_NAME, 49 | *agentRoutingSpecs.map { it.name }.toTypedArray(), 50 | ).build(), 51 | ).build(), 52 | ) 53 | if (documents?.isEmpty() == true) { 54 | Success(null) 55 | } else { 56 | val grouped = documents?.groupBy { it.metadata[AGENT_FIELD_NAME] as String } 57 | val agentName = grouped?.maxByOrNull { it.value.size }?.key 58 | if (agentName != null) { 59 | Success( 60 | VectorSearchClientResponse( 61 | grouped.getValue(agentName).joinToString("\n") { it.text ?: "" }, 62 | agentName, 63 | ), 64 | ) 65 | } else { 66 | Success(null) 67 | } 68 | } 69 | } catch (e: Exception) { 70 | Failure(VectorClientException("Failed to find similar vectors", e)) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringVectorSearchClientProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | 9 | /** 10 | * Properties for the SpringVectorSearchClient. 11 | * 12 | * @param threshold The similarity threshold. 13 | * @param topK The number of similar vectors to return. 14 | */ 15 | @ConfigurationProperties("route.llm.vector.search") 16 | data class SpringVectorSearchClientProperties( 17 | var threshold: Double = 0.5, 18 | var topK: Int = 1, 19 | ) { 20 | init { 21 | require(threshold in 0.0..1.0) { "threshold must be between 0.0 and 1.0" } 22 | require(topK > 0) { "topK must be a positive integer" } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringVectorSeedClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import org.eclipse.lmos.router.core.Failure 8 | import org.eclipse.lmos.router.core.Result 9 | import org.eclipse.lmos.router.core.Success 10 | import org.eclipse.lmos.router.vector.VectorClientException 11 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 12 | import org.eclipse.lmos.router.vector.VectorSeedClient 13 | import org.eclipse.lmos.router.vector.VectorSeedRequest 14 | import org.springframework.ai.document.Document 15 | import org.springframework.ai.vectorstore.VectorStore 16 | 17 | /** 18 | * A Spring implementation of the VectorSeedClient. 19 | * 20 | * @param vectorStore The vector store to seed. 21 | */ 22 | class SpringVectorSeedClient( 23 | private val vectorStore: VectorStore, 24 | ) : VectorSeedClient { 25 | /** 26 | * Seeds the vector store with the given documents. 27 | * 28 | * @param documents The documents to seed the vector store with. 29 | * @return A result indicating success or failure. 30 | */ 31 | override fun seed(documents: List): Result { 32 | try { 33 | val vectorDocuments = 34 | documents.map { 35 | Document( 36 | it.text, 37 | mapOf(AGENT_FIELD_NAME to it.agentName), 38 | ) 39 | } 40 | 41 | return Success(vectorStore.add(vectorDocuments)) 42 | } catch (e: Exception) { 43 | return Failure(VectorClientException(e.message ?: "An error occurred while seeding the vector store")) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | org.eclipse.lmos.router.vector.starter.HybridAgentRoutingSpecsResolverAutoConfiguration -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/hybrid/starter/HybridAgentRoutingSpecsResolverAutoConfigurationTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import org.eclipse.lmos.router.core.AgentRoutingSpecsProvider 10 | import org.eclipse.lmos.router.hybrid.ModelToVectorQueryConverter 11 | import org.eclipse.lmos.router.llm.ModelClient 12 | import org.eclipse.lmos.router.llm.ModelPromptProvider 13 | import org.eclipse.lmos.router.vector.VectorSearchClient 14 | import org.junit.jupiter.api.Assertions.assertNotNull 15 | import org.junit.jupiter.api.BeforeEach 16 | import org.junit.jupiter.api.Test 17 | import org.springframework.ai.chat.model.ChatModel 18 | import org.springframework.ai.vectorstore.VectorStore 19 | 20 | class HybridAgentRoutingSpecsResolverAutoConfigurationTest { 21 | private lateinit var springVectorSearchClientProperties: SpringVectorSearchClientProperties 22 | private lateinit var properties: HybridAgentRoutingSpecsResolverProperties 23 | private lateinit var configuration: HybridAgentRoutingSpecsResolverAutoConfiguration 24 | 25 | @BeforeEach 26 | fun setUp() { 27 | springVectorSearchClientProperties = mockk() 28 | properties = mockk() 29 | configuration = 30 | HybridAgentRoutingSpecsResolverAutoConfiguration(springVectorSearchClientProperties, properties) 31 | } 32 | 33 | @Test 34 | fun `test vectorSearchClient`() { 35 | val vectorStore = mockk() 36 | val result = configuration.vectorSearchClient(vectorStore) 37 | assertNotNull(result) 38 | } 39 | 40 | @Test 41 | fun `test vectorSeedClient`() { 42 | val vectorStore = mockk() 43 | val result = configuration.vectorSeedClient(vectorStore) 44 | assertNotNull(result) 45 | } 46 | 47 | @Test 48 | fun `test springAgentResolverCompletionProvider`() { 49 | val chatModel = mockk() 50 | val result = configuration.springAgentResolverCompletionProvider(chatModel) 51 | assertNotNull(result) 52 | } 53 | 54 | @Test 55 | fun `test agentRoutingSpecsProvider`() { 56 | val specFilePath = "src/test/resources/agentRoutingSpecs.json" 57 | every { properties.specFilePath } returns specFilePath 58 | val result = configuration.agentRoutingSpecsProvider() 59 | assertNotNull(result) 60 | } 61 | 62 | @Test 63 | fun `test agentResolverPromptProvider`() { 64 | val resolverPromptFilePath = "src/test/resources/resolverPrompt.txt" 65 | every { properties.resolverPromptFilePath } returns resolverPromptFilePath 66 | val result = configuration.agentResolverPromptProvider() 67 | assertNotNull(result) 68 | } 69 | 70 | @Test 71 | fun `test modelToVectorQueryConverter`() { 72 | val result = configuration.modelToVectorQueryConverter() 73 | assertNotNull(result) 74 | } 75 | 76 | @Test 77 | fun `test vectorAgentRoutingSpecsResolver`() { 78 | val agentRoutingSpecsProvider = mockk() 79 | val vectorSearchClient = mockk() 80 | val modelClient = mockk() 81 | val promptProvider = mockk() 82 | val modelToVectorQueryConverter = mockk() 83 | val result = 84 | configuration.hybridAgentRoutingSpecsResolver( 85 | agentRoutingSpecsProvider, 86 | vectorSearchClient, 87 | modelClient, 88 | promptProvider, 89 | modelToVectorQueryConverter, 90 | ) 91 | assertNotNull(result) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringVectorSearchClientPropertiesTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import io.mockk.junit5.MockKExtension 8 | import org.junit.jupiter.api.Assertions.assertEquals 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.assertThrows 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | 13 | @ExtendWith(MockKExtension::class) 14 | class SpringVectorSearchClientPropertiesTest { 15 | @Test 16 | fun `should initialize with default values`() { 17 | val properties = SpringVectorSearchClientProperties() 18 | 19 | assertEquals(0.5, properties.threshold) 20 | assertEquals(1, properties.topK) 21 | } 22 | 23 | @Test 24 | fun `should allow setting custom threshold value`() { 25 | val properties = SpringVectorSearchClientProperties(threshold = 0.75) 26 | 27 | assertEquals(0.75, properties.threshold) 28 | } 29 | 30 | @Test 31 | fun `should allow setting custom topK value`() { 32 | val properties = SpringVectorSearchClientProperties(topK = 5) 33 | 34 | assertEquals(5, properties.topK) 35 | } 36 | 37 | @Test 38 | fun `should allow setting both custom values`() { 39 | val properties = SpringVectorSearchClientProperties(threshold = 0.85, topK = 10) 40 | 41 | assertEquals(0.85, properties.threshold) 42 | assertEquals(10, properties.topK) 43 | } 44 | 45 | @Test 46 | fun `should throw an exception for invalid threshold`() { 47 | val exception = 48 | assertThrows { 49 | SpringVectorSearchClientProperties(threshold = -1.0) 50 | } 51 | assertEquals("threshold must be between 0.0 and 1.0", exception.message) 52 | } 53 | 54 | @Test 55 | fun `should throw an exception for invalid topK`() { 56 | val exception = 57 | assertThrows { 58 | SpringVectorSearchClientProperties(topK = -1) 59 | } 60 | assertEquals("topK must be a positive integer", exception.message) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringVectorSearchClientTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import org.eclipse.lmos.router.core.* 11 | import org.eclipse.lmos.router.vector.VectorClientException 12 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 13 | import org.eclipse.lmos.router.vector.VectorSearchClientRequest 14 | import org.junit.jupiter.api.Assertions.assertEquals 15 | import org.junit.jupiter.api.Assertions.assertTrue 16 | import org.junit.jupiter.api.BeforeEach 17 | import org.junit.jupiter.api.Test 18 | import org.springframework.ai.document.Document 19 | import org.springframework.ai.vectorstore.SearchRequest 20 | import org.springframework.ai.vectorstore.VectorStore 21 | 22 | class SpringVectorSearchClientTest { 23 | private lateinit var vectorStore: VectorStore 24 | private lateinit var springVectorSearchClientProperties: SpringVectorSearchClientProperties 25 | private lateinit var springVectorSearchClient: SpringVectorSearchClient 26 | 27 | @BeforeEach 28 | fun setUp() { 29 | vectorStore = mockk() 30 | springVectorSearchClientProperties = mockk() 31 | springVectorSearchClient = SpringVectorSearchClient(vectorStore, springVectorSearchClientProperties) 32 | } 33 | 34 | @Test 35 | fun `find should return Success with null when no similar vectors are found`() { 36 | val request = VectorSearchClientRequest("test_query", context = Context(listOf())) 37 | val agentRoutingSpecs = 38 | setOf( 39 | AgentRoutingSpec( 40 | name = "test_agent", 41 | addresses = setOf(), 42 | capabilities = setOf(), 43 | description = "", 44 | version = "", 45 | ), 46 | ) 47 | 48 | every { springVectorSearchClientProperties.threshold } returns 0.5 49 | every { springVectorSearchClientProperties.topK } returns 10 50 | every { 51 | vectorStore.similaritySearch(any()) 52 | } returns emptyList() 53 | 54 | val result = springVectorSearchClient.find(request, agentRoutingSpecs) 55 | 56 | assertTrue(result is Success) 57 | assertEquals(null, (result as Success).value) 58 | verify { vectorStore.similaritySearch(any()) } 59 | } 60 | 61 | @Test 62 | fun `find should return Success with VectorSearchClientResponse when similar vectors are found`() { 63 | val request = VectorSearchClientRequest("test_query", context = Context(listOf())) 64 | val agentRoutingSpecs = 65 | setOf( 66 | AgentRoutingSpec( 67 | name = "test_agent", 68 | addresses = setOf(), 69 | capabilities = setOf(), 70 | description = "", 71 | version = "", 72 | ), 73 | ) 74 | val mockDocument = mockk() 75 | 76 | every { springVectorSearchClientProperties.threshold } returns 0.5 77 | every { springVectorSearchClientProperties.topK } returns 10 78 | every { 79 | vectorStore.similaritySearch(any()) 80 | } returns listOf(mockDocument) 81 | every { mockDocument.text } returns "document_content" 82 | every { mockDocument.metadata[AGENT_FIELD_NAME] } returns "test_agent" 83 | 84 | val result = springVectorSearchClient.find(request, agentRoutingSpecs) 85 | 86 | assertTrue(result is Success) 87 | with(result as Success) { 88 | val response = result.value ?: throw AssertionError("Expected non-null response") 89 | assertEquals("document_content", response.text) 90 | assertEquals("test_agent", response.agentName) 91 | } 92 | verify { vectorStore.similaritySearch(any()) } 93 | } 94 | 95 | @Test 96 | fun `find should throw VectorClientException when exception occurs`() { 97 | val request = VectorSearchClientRequest("test_query", context = Context(listOf())) 98 | val agentRoutingSpecs = 99 | setOf( 100 | AgentRoutingSpec( 101 | name = "test_agent", 102 | addresses = setOf(), 103 | capabilities = setOf(), 104 | description = "", 105 | version = "", 106 | ), 107 | ) 108 | 109 | every { springVectorSearchClientProperties.threshold } returns 0.5 110 | every { springVectorSearchClientProperties.topK } returns 10 111 | every { 112 | vectorStore.similaritySearch(any()) 113 | } throws VectorClientException("Vector store error") 114 | 115 | val result = springVectorSearchClient.find(request, agentRoutingSpecs) 116 | 117 | assertTrue(result is Failure) 118 | assertTrue((result as Failure).exceptionOrNull() is VectorClientException) 119 | verify { vectorStore.similaritySearch(any()) } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/hybrid/starter/SpringVectorSeedClientTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid.starter 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.slot 10 | import io.mockk.verify 11 | import org.eclipse.lmos.router.core.Failure 12 | import org.eclipse.lmos.router.core.Success 13 | import org.eclipse.lmos.router.core.exceptionOrNull 14 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 15 | import org.eclipse.lmos.router.vector.VectorSeedRequest 16 | import org.junit.jupiter.api.Assertions.assertEquals 17 | import org.junit.jupiter.api.Assertions.assertTrue 18 | import org.junit.jupiter.api.Test 19 | import org.springframework.ai.document.Document 20 | import org.springframework.ai.vectorstore.VectorStore 21 | 22 | class SpringVectorSeedClientTest { 23 | private val vectorStore = mockk() 24 | private val springVectorSeedClient = SpringVectorSeedClient(vectorStore) 25 | 26 | @Test 27 | fun `should seed vector store successfully`() { 28 | // Arrange 29 | val documents = 30 | listOf( 31 | VectorSeedRequest("agent1", "text1"), 32 | VectorSeedRequest("agent2", "text2"), 33 | ) 34 | 35 | // Mock 36 | val vectorDocumentsSlot = slot>() 37 | every { vectorStore.add(capture(vectorDocumentsSlot)) } returns Unit 38 | 39 | // Act 40 | val result = springVectorSeedClient.seed(documents) 41 | 42 | // Assert 43 | assertTrue(result is Success) 44 | assertEquals(2, vectorDocumentsSlot.captured.size) 45 | assertEquals("text1", vectorDocumentsSlot.captured[0].text) 46 | assertEquals("agent1", vectorDocumentsSlot.captured[0].metadata[AGENT_FIELD_NAME]) 47 | assertEquals("text2", vectorDocumentsSlot.captured[1].text) 48 | assertEquals("agent2", vectorDocumentsSlot.captured[1].metadata[AGENT_FIELD_NAME]) 49 | verify { vectorStore.add(any()) } 50 | } 51 | 52 | @Test 53 | fun `should return failure when exception is thrown`() { 54 | // Arrange 55 | val documents = 56 | listOf( 57 | VectorSeedRequest("agent1", "text1"), 58 | VectorSeedRequest("agent2", "text2"), 59 | ) 60 | 61 | // Mock 62 | every { vectorStore.add(any()) } throws RuntimeException("Mock exception") 63 | 64 | // Act 65 | val result = springVectorSeedClient.seed(documents) 66 | 67 | // Assert 68 | assertTrue(result is Failure) 69 | val failure = result as Failure 70 | assertEquals("Mock exception", failure.exceptionOrNull()?.message) 71 | verify { vectorStore.add(any()) } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lmos-router-hybrid-spring-boot-starter/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "order-agent", 4 | "description": "This agent is responsible for order management", 5 | "version": "1.0.0", 6 | "addresses": [ 7 | { 8 | "protocol": "http", 9 | "uri": "http://localhost:8080" 10 | } 11 | ], 12 | "capabilities": [ 13 | { 14 | "name": "order-status", 15 | "description": "This capability is responsible for order status tracking.", 16 | "version": "1.0.0" 17 | }, 18 | { 19 | "name": "order-cancel", 20 | "description": "This capability is responsible for order cancellation.", 21 | "version": "1.0.0" 22 | } 23 | ] 24 | }, 25 | { 26 | "name": "offer-agent", 27 | "description": "This agent is responsible for offer recommendation, cross-sell, and up-sell", 28 | "version": "1.0.0", 29 | "addresses": [ 30 | { 31 | "protocol": "http", 32 | "uri": "http://localhost:8080" 33 | } 34 | ], 35 | "capabilities": [ 36 | { 37 | "name": "offer-recommendation", 38 | "description": "This capability is responsible for offer recommendation.", 39 | "version": "1.0.0" 40 | } 41 | ] 42 | } 43 | ] -------------------------------------------------------------------------------- /lmos-router-hybrid/ReadMe.md: -------------------------------------------------------------------------------- 1 | 6 | # Hybrid Submodule 7 | 8 | ## Overview 9 | 10 | The Hybrid submodule is responsible for combining the capabilities of the LLM and Vector-based submodules to resolve agent routing specifications. It includes classes and interfaces for combining the results of the LLM and Vector-based resolvers to provide a more accurate and reliable routing solution. 11 | 12 | ## Table of Contents 13 | 14 | 1. [Introduction](#introduction) 15 | 2. [Classes and Interfaces](#classes-and-interfaces) 16 | 17 | ## Introduction 18 | 19 | The Hybrid submodule combines the capabilities of the LLM and Vector-based resolvers to provide a more accurate and reliable routing solution. It uses the results of both resolvers to resolve agent routing specifications based on the combined responses. 20 | 21 | ## Classes and Interfaces 22 | 23 | ### HybridAgentRoutingSpecsResolver 24 | 25 | The `HybridAgentRoutingSpecsResolver` class combines the results of the LLM and Vector-based resolvers to resolve agent routing specifications. 26 | 27 | -------------------------------------------------------------------------------- /lmos-router-hybrid/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | dependencies { 6 | api(project(":lmos-router-core")) 7 | api(project(":lmos-router-llm")) 8 | api(project(":lmos-router-vector")) 9 | implementation("org.slf4j:slf4j-api:2.0.17") 10 | implementation("com.azure:azure-ai-openai:1.0.0-beta.16") 11 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 12 | implementation("io.ktor:ktor-client-cio-jvm:3.1.2") 13 | testImplementation("org.testcontainers:ollama:1.20.6") 14 | } 15 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/main/kotlin/org/eclipse/lmos/router/hybrid/ModelToVectorQueryConverter.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid 6 | 7 | import org.eclipse.lmos.router.core.Context 8 | import org.eclipse.lmos.router.vector.VectorSearchClientRequest 9 | 10 | /** 11 | * A model to vector query converter. Converts a model response to a vector search client request. 12 | */ 13 | abstract class ModelToVectorQueryConverter { 14 | abstract fun convert( 15 | modelResponse: String, 16 | context: Context, 17 | ): VectorSearchClientRequest 18 | } 19 | 20 | /** 21 | * A no-op model to vector query converter. 22 | */ 23 | class NoOpModelToVectorQueryConverter : ModelToVectorQueryConverter() { 24 | override fun convert( 25 | modelResponse: String, 26 | context: Context, 27 | ): VectorSearchClientRequest { 28 | return VectorSearchClientRequest(query = modelResponse, context = context) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/kotlin/SampleHybridFlow.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import io.ktor.client.HttpClient 6 | import kotlinx.serialization.Serializable 7 | import kotlinx.serialization.json.Json 8 | import org.eclipse.lmos.router.core.* 9 | import org.eclipse.lmos.router.hybrid.HybridAgentRoutingSpecsResolver 10 | import org.eclipse.lmos.router.hybrid.ModelToVectorQueryConverter 11 | import org.eclipse.lmos.router.llm.DefaultModelClient 12 | import org.eclipse.lmos.router.llm.DefaultModelClientProperties 13 | import org.eclipse.lmos.router.llm.ExternalModelPromptProvider 14 | import org.eclipse.lmos.router.vector.DefaultEmbeddingClient 15 | import org.eclipse.lmos.router.vector.DefaultEmbeddingClientProperties 16 | import org.eclipse.lmos.router.vector.DefaultVectorClient 17 | import org.eclipse.lmos.router.vector.DefaultVectorClientProperties 18 | import org.eclipse.lmos.router.vector.EmbeddingClient 19 | import org.eclipse.lmos.router.vector.VectorSearchClientRequest 20 | import org.junit.jupiter.api.AfterAll 21 | import org.junit.jupiter.api.BeforeAll 22 | import org.junit.jupiter.api.Test 23 | import org.testcontainers.ollama.OllamaContainer 24 | 25 | class SampleHybridFlow { 26 | @Test 27 | fun `test sample flow`() { 28 | val agentRoutingSpecsProvider = 29 | JsonAgentRoutingSpecsProvider(jsonFilePath = "src/test/resources/agentRoutingSpecs.json") 30 | val modelPromptProvider = ExternalModelPromptProvider(promptFilePath = "src/test/resources/prompt.txt") 31 | val modelClient = 32 | DefaultModelClient( 33 | DefaultModelClientProperties( 34 | openAiApiKey = System.getenv("OPENAI_API_KEY") ?: throw Exception("OPENAI_API_KEY is not set"), 35 | ), 36 | ) 37 | 38 | val converter = 39 | object : ModelToVectorQueryConverter() { 40 | override fun convert( 41 | modelResponse: String, 42 | context: Context, 43 | ): VectorSearchClientRequest { 44 | val parsed = Json.decodeFromString(modelResponse) 45 | return VectorSearchClientRequest(parsed.primaryRequirements.first(), context) 46 | } 47 | } 48 | 49 | val vectorClient = 50 | DefaultVectorClient( 51 | DefaultVectorClientProperties( 52 | seedJsonFilePath = "src/test/resources/seed.json", 53 | ), 54 | embeddingClient, 55 | ) 56 | 57 | val hybridAgentRoutingSpecsResolver = 58 | HybridAgentRoutingSpecsResolver( 59 | agentRoutingSpecsProvider, 60 | modelClient, 61 | modelPromptProvider, 62 | vectorClient, 63 | converter, 64 | ) 65 | 66 | val context = Context(listOf(AssistantMessage("Hello"))) 67 | 68 | // The input to test whether offer-agent is resolved 69 | val input = UserMessage("Everytime I try to pay. I get an error") 70 | val result = hybridAgentRoutingSpecsResolver.resolve(context, input) 71 | assert(result is Success) 72 | assert((result as Success).getOrNull()?.name == "payment-agent") 73 | } 74 | 75 | companion object { 76 | private lateinit var container: OllamaContainer 77 | private lateinit var embeddingClient: EmbeddingClient 78 | 79 | @JvmStatic 80 | @BeforeAll 81 | fun setup() { 82 | container = OllamaContainer("ollama/ollama:0.1.26") 83 | container.start() 84 | container.execInContainer( 85 | "ollama", 86 | "pull", 87 | DefaultEmbeddingClientProperties().model, 88 | ) 89 | embeddingClient = 90 | DefaultEmbeddingClient( 91 | HttpClient(), 92 | DefaultEmbeddingClientProperties(container.endpoint + "/api/embeddings"), 93 | ) 94 | val ping = embeddingClient.embed("Hello").getOrNull() 95 | require(ping != null) { 96 | "Ollama is not running. Please start Ollama to run this test. " + 97 | "Also, make sure you have 'all-minilm' installed in your Ollama." 98 | } 99 | } 100 | 101 | @JvmStatic 102 | @AfterAll 103 | fun tearDownAll() { 104 | container.stop() 105 | } 106 | } 107 | } 108 | 109 | @Serializable 110 | data class ModelResponse(val primaryRequirements: List) 111 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/kotlin/org/eclipse/lmos/router/hybrid/HybridAgentRoutingSpecsResolverTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.spyk 10 | import io.mockk.verify 11 | import org.eclipse.lmos.router.core.AgentRoutingSpec 12 | import org.eclipse.lmos.router.core.AgentRoutingSpecsProvider 13 | import org.eclipse.lmos.router.core.AssistantMessage 14 | import org.eclipse.lmos.router.core.ChatMessage 15 | import org.eclipse.lmos.router.core.Context 16 | import org.eclipse.lmos.router.core.SpecFilter 17 | import org.eclipse.lmos.router.core.Success 18 | import org.eclipse.lmos.router.core.UserMessage 19 | import org.eclipse.lmos.router.core.getOrThrow 20 | import org.eclipse.lmos.router.llm.ModelClient 21 | import org.eclipse.lmos.router.llm.ModelPromptProvider 22 | import org.eclipse.lmos.router.vector.VectorSearchClient 23 | import org.eclipse.lmos.router.vector.VectorSearchClientResponse 24 | import org.junit.jupiter.api.Assertions.* 25 | import org.junit.jupiter.api.BeforeEach 26 | import org.junit.jupiter.api.Test 27 | 28 | class HybridAgentRoutingSpecsResolverTest { 29 | private lateinit var agentRoutingSpecsProvider: AgentRoutingSpecsProvider 30 | private lateinit var modelClient: ModelClient 31 | private lateinit var modelPromptProvider: ModelPromptProvider 32 | private lateinit var vectorSearchClient: VectorSearchClient 33 | private lateinit var modelToVectorQueryConverter: ModelToVectorQueryConverter 34 | private lateinit var hybridAgentRoutingSpecsResolver: HybridAgentRoutingSpecsResolver 35 | 36 | @BeforeEach 37 | fun setUp() { 38 | agentRoutingSpecsProvider = mockk() 39 | modelClient = mockk() 40 | modelPromptProvider = mockk() 41 | vectorSearchClient = mockk() 42 | modelToVectorQueryConverter = spyk() 43 | hybridAgentRoutingSpecsResolver = 44 | HybridAgentRoutingSpecsResolver( 45 | agentRoutingSpecsProvider, 46 | modelClient, 47 | modelPromptProvider, 48 | vectorSearchClient, 49 | modelToVectorQueryConverter, 50 | ) 51 | } 52 | 53 | @Test 54 | fun `resolve without filters returns agent spec`() { 55 | // Arrange 56 | val context = mockk { every { previousMessages } returns listOf() } 57 | val input = mockk() 58 | val agentRoutingSpec1 = mockk { every { name } returns "Agent1" } 59 | val agentSpecs = setOf(agentRoutingSpec1) 60 | val vectorSearchClientResponse = mockk { every { agentName } returns "Agent1" } 61 | 62 | every { agentRoutingSpecsProvider.provide() } returns Success(agentSpecs) 63 | every { modelPromptProvider.providePrompt(context, agentSpecs, input) } returns Success("Model prompt") 64 | every { modelClient.call(any()) } returns Success(AssistantMessage("Model response")) 65 | every { vectorSearchClient.find(any(), any()) } returns Success(vectorSearchClientResponse) 66 | 67 | // Act 68 | val result = hybridAgentRoutingSpecsResolver.resolve(context, input) 69 | 70 | // Assert 71 | assertTrue(result is Success) 72 | assertEquals(agentRoutingSpec1, result.getOrThrow()) 73 | verify { agentRoutingSpecsProvider.provide() } 74 | verify { modelPromptProvider.providePrompt(context, agentSpecs, input) } 75 | verify { modelClient.call(any()) } 76 | verify { vectorSearchClient.find(any(), any()) } 77 | verify(exactly = 1) { modelToVectorQueryConverter.convert(any(), any()) } 78 | } 79 | 80 | @Test 81 | fun `resolve with filters returns agent spec`() { 82 | // Arrange 83 | val filters = setOf(mockk()) 84 | val context = mockk { every { previousMessages } returns listOf() } 85 | val input = mockk() 86 | val agentRoutingSpec1 = mockk { every { name } returns "Agent1" } 87 | val agentSpecs = setOf(agentRoutingSpec1) 88 | val vectorSearchClientResponse = mockk { every { agentName } returns "Agent1" } 89 | 90 | every { agentRoutingSpecsProvider.provide(filters) } returns Success(agentSpecs) 91 | every { modelPromptProvider.providePrompt(context, agentSpecs, input) } returns Success("Model prompt") 92 | every { modelClient.call(any()) } returns Success(AssistantMessage("Model response")) 93 | every { vectorSearchClient.find(any(), any()) } returns Success(vectorSearchClientResponse) 94 | 95 | // Act 96 | val result = hybridAgentRoutingSpecsResolver.resolve(filters, context, input) 97 | 98 | // Assert 99 | assertTrue(result is Success) 100 | assertEquals(agentRoutingSpec1, result.getOrThrow()) 101 | verify { agentRoutingSpecsProvider.provide(filters) } 102 | verify { modelPromptProvider.providePrompt(context, agentSpecs, input) } 103 | verify { modelClient.call(any()) } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/kotlin/org/eclipse/lmos/router/hybrid/ModelToVectorQueryConverterTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import org.eclipse.lmos.router.core.Context 11 | import org.eclipse.lmos.router.vector.VectorSearchClientRequest 12 | import org.junit.jupiter.api.Assertions.assertEquals 13 | import org.junit.jupiter.api.Test 14 | 15 | class ModelToVectorQueryConverterTest { 16 | @Test 17 | fun `test convert method is called with correct parameters`() { 18 | // Arrange 19 | val modelResponse = "test response" 20 | val context = mockk() 21 | val mockConverter = mockk() 22 | 23 | every { mockConverter.convert(modelResponse, context) } returns VectorSearchClientRequest(query = modelResponse, context = context) 24 | 25 | // Act 26 | val result = mockConverter.convert(modelResponse, context) 27 | 28 | // Assert 29 | verify { mockConverter.convert(modelResponse, context) } 30 | assertEquals(modelResponse, result.query) 31 | assertEquals(context, result.context) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/kotlin/org/eclipse/lmos/router/hybrid/NoOpModelToVectorQueryConverterTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.hybrid 6 | 7 | import io.mockk.mockk 8 | import org.eclipse.lmos.router.core.Context 9 | import org.junit.jupiter.api.Assertions.assertEquals 10 | import org.junit.jupiter.api.Test 11 | 12 | class NoOpModelToVectorQueryConverterTest { 13 | @Test 14 | fun `test convert method returns correct VectorSearchClientRequest`() { 15 | // Arrange 16 | val modelResponse = "test response" 17 | val context = mockk() 18 | val converter = NoOpModelToVectorQueryConverter() 19 | 20 | // Act 21 | val result = converter.convert(modelResponse, context) 22 | 23 | // Assert 24 | assertEquals(modelResponse, result.query) 25 | assertEquals(context, result.context) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "order-agent", 4 | "description": "This agent is responsible for order management", 5 | "version": "1.0.0", 6 | "addresses": [ 7 | { 8 | "protocol": "http", 9 | "uri": "localhost:8080" 10 | } 11 | ], 12 | "capabilities": [ 13 | { 14 | "name": "cancel_order", 15 | "description": "This capability is responsible for order cancellation.", 16 | "version": "1.0.0" 17 | }, 18 | { 19 | "name": "change_order", 20 | "description": "This capability is responsible for order change or purchased item change related queries.", 21 | "version": "1.0.0" 22 | }, 23 | { 24 | "name": "place_order", 25 | "description": "This capability is responsible for order placement, shopping and acquisition related queries.", 26 | "version": "1.0.0" 27 | }, 28 | { 29 | "name": "track_order", 30 | "description": "This capability is responsible for order tracking related queries such as ETA (Estimated Time of Arrival) etc", 31 | "version": "1.0.0" 32 | } 33 | ] 34 | }, 35 | { 36 | "name": "payment-agent", 37 | "description": "This agent is responsible for payment management", 38 | "version": "1.0.0", 39 | "addresses": [ 40 | { 41 | "protocol": "http", 42 | "uri": "localhost:8080" 43 | } 44 | ], 45 | "capabilities": [ 46 | { 47 | "name": "check_payment_methods", 48 | "description": "This capability is responsible for checking payment methods.", 49 | "version": "1.0.0" 50 | }, 51 | { 52 | "name": "payment_issue", 53 | "description": "This capability is responsible for payment issue.", 54 | "version": "1.0.0" 55 | } 56 | ] 57 | }, 58 | { 59 | "name": "subscription-agent", 60 | "description": "This agent is responsible for subscription management and related queries", 61 | "version": "1.0.0", 62 | "addresses": [ 63 | { 64 | "protocol": "http", 65 | "uri": "localhost:8080" 66 | } 67 | ], 68 | "capabilities": [ 69 | { 70 | "name": "newsletter_subscription", 71 | "description": "This capability is responsible for newsletter subscription.", 72 | "version": "1.0.0" 73 | } 74 | ] 75 | } 76 | ] -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/resources/prompt.txt: -------------------------------------------------------------------------------- 1 | You will be given a user query. Your task is to extract the primary requirements of the user from this query and present them as a valid JSON object. 2 | 3 | To complete this task, follow these steps: 4 | 5 | 1. Carefully read and analyze the user query. 6 | 2. Identify the main requirements or requests made by the user. 7 | 3. For each primary requirement, formulate a clear and concise statement that starts with "The user". 8 | 4. Create a JSON object with a single key "primaryRequirements" whose value is an array of these requirement statements. 9 | 10 | The JSON should be formatted as follows: 11 | 12 | ```json 13 | { 14 | "primaryRequirements": [ 15 | "The user [first requirement]", 16 | "The user [second requirement]" 17 | ] 18 | } 19 | ``` 20 | 21 | 22 | Here's an example of what your output might look like: 23 | 24 | 25 | User Query: "I need a website that allows customers to browse our product catalog and make purchases online. It should also have a customer review section." 26 | 27 | { 28 | "primaryRequirements": [ 29 | "The user needs a website for their business", 30 | "The user wants customers to be able to browse a product catalog", 31 | "The user wants customers to be able to make purchases online", 32 | "The user wants the website to include a customer review section" 33 | ] 34 | } 35 | 36 | 37 | Ensure that your JSON is valid and properly formatted. Each requirement should be a separate string in the array. 38 | -------------------------------------------------------------------------------- /lmos-router-hybrid/src/test/resources/seed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "agentName": "order-agent", 4 | "text": "The user is unable to change some items in order 123456" 5 | }, 6 | { 7 | "agentName": "order-agent", 8 | "text": "The user is unsure about the steps needed to purchase an article" 9 | }, 10 | { 11 | "agentName": "invoice-agent", 12 | "text": "The user wants to quickly view invoice #00108" 13 | }, 14 | { 15 | "agentName": "order-agent", 16 | "text": "The user is unable to pay for purchase 123456" 17 | }, 18 | { 19 | "agentName": "payment-agent", 20 | "text": "The user needs help reporting an error with payments" 21 | }, 22 | { 23 | "agentName": "payment-agent", 24 | "text": "The user needs assistance with reporting an error The user specifically needs help with a payment-related issue" 25 | }, 26 | { 27 | "agentName": "payment-agent", 28 | "text": "The user needs help with a payment-related issue" 29 | }, 30 | { 31 | "agentName": "payment-agent", 32 | "text": "The user is looking to change their payment method" 33 | }, 34 | { 35 | "agentName": "account-agent", 36 | "text": "The user needs assistance with resetting their profile PIN" 37 | }, 38 | { 39 | "agentName": "account-agent", 40 | "text": "The user is looking to switch to the platinum account" 41 | }, 42 | { 43 | "agentName": "account-agent", 44 | "text": "The user is looking to change their account type" 45 | }, 46 | { 47 | "agentName": "account-agent", 48 | "text": "The user is looking to change their account information" 49 | }, 50 | { 51 | "agentName": "order-agent", 52 | "text": "The user wants to purchase an article" 53 | }, 54 | { 55 | "agentName": "subscription-agent", 56 | "text": "The user wants to view the status of their newsletter subscription" 57 | }, 58 | { 59 | "agentName": "subscription-agent", 60 | "text": "The user wants assistance unsubscribing from the corporate newsletter" 61 | }, 62 | { 63 | "agentName": "order-agent", 64 | "text": "The user needs help canceling a specific purchase with ID 123456" 65 | } 66 | ] -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/Demo-setup.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eclipse-lmos/lmos-router/4de4f3c096efbbd0d0481e984f10f294bf91f1b6/lmos-router-llm-in-spring-cloud-gateway-demo/Demo-setup.jpg -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/Demo-setup.jpg.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | 3 | SPDX-License-Identifier: Apache-2.0 4 | -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/Readme.md: -------------------------------------------------------------------------------- 1 | 6 | # lmos-router-llm-in-spring-cloud-gateway 7 | 8 | This project demonstrate the usage of super route core 9 | abstraction [AgentRoutingSpecsResolver](https://github.com/eclipse-lmos/lmos-router/blob/main/lmos-router-core/src/main/kotlin/ai/ancf/lmos/router/core/AgentRoutingSpecsResolver.kt) 10 | 11 | ## Demonstration Setup View 12 | 13 | ![Screenshot](Demo-setup.jpg) 14 | 15 | ## Steps to run the demo 16 | 1. Configure environment variable OPENAI_API_KEY={YOUR_API_KEY} 17 | 2. Boot up `SuperRouteGatewayApplication` on port:8080 18 | 3. Boot up `AgentsApplication` on port:9090 with option: `-Dserver.port=9090` 19 | 4. Now ask queries to the gateway on either of the categories = offers or service. 20 | As per the intent in the user query the request will be forwarded by the gateway to either of the endpoint 21 | `http://localhost:9090/agents/offer-agent` or `http://localhost:9090/agents/service-agent` 22 | 23 | ## Examples to test setup 24 | ``` 25 | curl --location 'http://localhost:8080/agents?userQuery=I%20have%20some%20issue%20with%20my%20services' 26 | ``` 27 | ``` 28 | curl --location 'http://localhost:8080/agents?userQuery=what%20are%20the%20best%20offers%20for%20me' 29 | ``` 30 | 31 | ## Configuration Used 32 | 33 | ### AgentRoutingSpecsProvider 34 | 35 | [AgentRoutingSpecsProvider](https://github.com/eclipse-lmos/lmos-router/blob/main/lmos-router-core/src/main/kotlin//ai/ancf/lmos/router/core/AgentRoutingSpecsProvider.kt) 36 | has been configured with `SimpleAgentRoutingSpecProvider` 37 | ``` 38 | SimpleAgentRoutingSpecProvider().add( 39 | AgentRoutingSpecBuilder().name("offer-agent").description("This agent is responsible for offer management") 40 | .version("1.0.0").address(Address(uri = "/agents/offer-agent")).build() 41 | ) 42 | .add( 43 | AgentRoutingSpecBuilder().name("service-agent").description("This agent is responsible for service management") 44 | .version("1.0.0").address(Address(uri = "/agents/service-agent")).build() 45 | ) 46 | ``` 47 | 48 | ### AgentRoutingSpecsResolver 49 | 50 | `LLMAgentRoutingSpecResolver` has been used to resolve Agent Spec. 51 | 52 | Refer `GatewayConfiguration` for more details. -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | val springBootVersion: String by rootProject.extra 6 | 7 | dependencies { 8 | api(project(":lmos-router-core")) 9 | api(project(":lmos-router-llm")) 10 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 11 | implementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") 12 | implementation("org.springframework.cloud:spring-cloud-starter-gateway:4.2.2") 13 | } 14 | -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/src/main/kotlin/org/eclipse/lmos/router/demo/agent/AgentsApplication.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.demo.agent 6 | 7 | import org.springframework.boot.autoconfigure.SpringBootApplication 8 | import org.springframework.boot.runApplication 9 | 10 | @SpringBootApplication 11 | open class AgentsApplication 12 | 13 | fun main(args: Array) { 14 | runApplication(*args) 15 | } 16 | -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/src/main/kotlin/org/eclipse/lmos/router/demo/agent/inbound/AgentsController.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.demo.agent.inbound 6 | 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.RequestMapping 9 | import org.springframework.web.bind.annotation.RestController 10 | 11 | @RestController 12 | @RequestMapping("/agents") 13 | class AgentsController { 14 | @GetMapping("/offer-agent") 15 | fun offerAgentResponse(): String { 16 | return "Offer agent responding!" 17 | } 18 | 19 | @GetMapping("/service-agent") 20 | fun serviceAgentResponse(): String { 21 | return "Service agent responding!" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/src/main/kotlin/org/eclipse/lmos/router/demo/gateway/LmosRouterGatewayApplication.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.demo.gateway 6 | 7 | import org.eclipse.lmos.router.core.* 8 | import org.eclipse.lmos.router.llm.LLMAgentRoutingSpecsResolver 9 | import org.springframework.boot.autoconfigure.SpringBootApplication 10 | import org.springframework.boot.runApplication 11 | import org.springframework.cloud.gateway.route.RouteLocator 12 | import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder 13 | import org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR 14 | import org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl 15 | import org.springframework.context.annotation.Bean 16 | import org.springframework.context.annotation.Configuration 17 | 18 | @SpringBootApplication 19 | open class LmosRouterGatewayApplication 20 | 21 | fun main(args: Array) { 22 | runApplication(*args) 23 | } 24 | 25 | @Configuration 26 | open class GatewayConfiguration { 27 | @Bean 28 | open fun routeLocator( 29 | builder: RouteLocatorBuilder, 30 | agentRoutingSpecsResolver: AgentRoutingSpecsResolver, 31 | ): RouteLocator { 32 | return builder.routes() 33 | .route("lmos-router-demo") { r -> 34 | r.path("/agents") 35 | .filters { f -> 36 | f.filter { exchange, chain -> 37 | val req = exchange.request 38 | addOriginalRequestUrl(exchange, req.uri) 39 | val agentRoutingSpec = 40 | resolveAgentRoutingSpec( 41 | req.queryParams.get("userQuery")?.firstOrNull(), 42 | agentRoutingSpecsResolver, 43 | ) 44 | val newPath = 45 | agentRoutingSpec?.addresses?.first()?.uri 46 | ?: throw RuntimeException("Unable to find agent URI.") 47 | val request = req.mutate().path(newPath).build() 48 | exchange.attributes[GATEWAY_REQUEST_URL_ATTR] = request.uri 49 | chain.filter(exchange.mutate().request(request).build()) 50 | } 51 | } 52 | .uri("http://localhost:9090") 53 | } 54 | .build() 55 | } 56 | 57 | private fun resolveAgentRoutingSpec( 58 | userQuery: String?, 59 | agentRoutingSpecsResolver: AgentRoutingSpecsResolver, 60 | ): AgentRoutingSpec? { 61 | if (userQuery == null) { 62 | throw IllegalStateException("User query not found. Can't route request to any agent.") 63 | } 64 | 65 | val result = agentRoutingSpecsResolver.resolve(Context(emptyList()), UserMessage(userQuery)) 66 | return result.getOrThrow() 67 | } 68 | 69 | @Bean 70 | open fun agentRoutingSpecsResolver(): AgentRoutingSpecsResolver { 71 | val provider = 72 | SimpleAgentRoutingSpecProvider().add( 73 | AgentRoutingSpecBuilder().name("offer-agent").description("This agent is responsible for offer management") 74 | .version("1.0.0").address(Address(uri = "/agents/offer-agent")).build(), 75 | ) 76 | .add( 77 | AgentRoutingSpecBuilder().name("service-agent") 78 | .description("This agent is responsible for service management") 79 | .version("1.0.0").address(Address(uri = "/agents/service-agent")).build(), 80 | ) 81 | return LLMAgentRoutingSpecsResolver(provider) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lmos-router-llm-in-spring-cloud-gateway-demo/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | spring: 6 | main: 7 | web-application-type: reactive -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/ReadMe.md: -------------------------------------------------------------------------------- 1 | 6 | # LLM Agent Routing Starter Module 7 | 8 | ## Overview 9 | 10 | The `LLM Agent Routing Starter` module provides auto-configuration for the LLM-based agent resolver in a Spring Boot application. It integrates with the core module to resolve agent routing specifications using a language model. 11 | 12 | ## Table of Contents 13 | 14 | 1. [Introduction](#introduction) 15 | 2. [Features](#features) 16 | 3. [Setup and Installation](#setup-and-installation) 17 | 4. [Configuration](#configuration) 18 | 5. [Usage](#usage) 19 | 20 | ## Introduction 21 | 22 | The `LLM Agent Routing Starter` module simplifies the integration of the LLM-based agent resolver into a Spring Boot application. It provides auto-configuration for essential beans and properties required to resolve agent routing specifications using a language model. 23 | 24 | ## Features 25 | 26 | - **Auto-Configuration**: Automatically configures the necessary beans for the LLM-based agent resolver. 27 | - **Customizable Properties**: Allows customization of the agent routing specifications file path through application properties. 28 | - **Spring Integration**: Seamlessly integrates with Spring Boot's auto-configuration mechanism. 29 | 30 | ## Setup and Installation 31 | 32 | ### Installation 33 | 34 | 1. **Clone the repository**: 35 | 36 | ```bash 37 | git clone https://github.com/eclipse-lmos/lmos-router.git 38 | cd lmos-router 39 | ``` 40 | 41 | 2. **Include the module in your project**: 42 | 43 | Add the following dependency to your `build.gradle` file: 44 | 45 | ```groovy 46 | implementation 'ai.ancf:lmos-router-llm-spring-boot-starter:x.x.x' 47 | ``` 48 | 49 | Or, if using Maven, add the following dependency to your `pom.xml`: 50 | 51 | ```xml 52 | 53 | ai.ancf 54 | lmos-router-llm-spring-boot-starter 55 | x.x.x 56 | 57 | ``` 58 | 59 | ## Configuration 60 | 61 | ### Application Properties 62 | 63 | Configure the path to the JSON file containing agent routing specifications in your `application.properties` or `application.yml` file: 64 | 65 | ```properties 66 | route.agent.llm.specFilePath=path/to/your/agent-specs.json 67 | ``` 68 | 69 | ### Beans Provided 70 | 71 | - **ModelClient**: Uses the `ChatModel` to resolve agent routing specifications. 72 | - **AgentRoutingSpecsProvider**: Reads agent routing specifications from a JSON file by default. You can provide a custom implementation by extending the `AgentRoutingSpecsProvider` interface. 73 | - **ModelPromptProvider**: Uses the default model prompt provider. 74 | - **LLMAgentRoutingSpecsResolver**: Uses the `AgentRoutingSpecsProvider`, `ModelPromptProvider`, and `ModelClient` to resolve agent routing specifications. 75 | 76 | ## Usage 77 | 78 | ### Example 79 | 80 | 1. **Define the Chat Model**: 81 | 82 | ```kotlin 83 | @Bean 84 | fun chatModel(): ChatModel { 85 | // Define and return your ChatModel implementation or use Spring's default implementation and supported models 86 | } 87 | ``` 88 | 89 | 2. **Use the LLM Agent Routing Specs Resolver**: 90 | 91 | ```kotlin 92 | @Autowired 93 | lateinit var llmAgentRoutingSpecsResolver: LLMAgentRoutingSpecsResolver 94 | 95 | fun resolveAgent(context: Context, input: UserMessage): Result { 96 | return llmAgentRoutingSpecsResolver.resolve(context, input) 97 | } 98 | ``` -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | val springBootVersion: String by rootProject.extra 6 | 7 | dependencies { 8 | api(project(":lmos-router-core")) 9 | api(project(":lmos-router-llm")) 10 | implementation("org.springframework.boot:spring-boot-autoconfigure:$springBootVersion") 11 | implementation("org.springframework.boot:spring-boot-configuration-processor:$springBootVersion") 12 | implementation("org.springframework.ai:spring-ai-core:1.0.0-M6") 13 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 14 | testImplementation("org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6") 15 | testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") 16 | testImplementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") 17 | } 18 | -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/llm/starter/LLMAgentRoutingSpecsResolverAutoConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm.starter 6 | 7 | import org.eclipse.lmos.router.core.AgentRoutingSpecsProvider 8 | import org.eclipse.lmos.router.core.JsonAgentRoutingSpecsProvider 9 | import org.eclipse.lmos.router.llm.DefaultModelPromptProvider 10 | import org.eclipse.lmos.router.llm.LLMAgentRoutingSpecsResolver 11 | import org.eclipse.lmos.router.llm.ModelClient 12 | import org.eclipse.lmos.router.llm.ModelPromptProvider 13 | import org.springframework.ai.chat.model.ChatModel 14 | import org.springframework.boot.autoconfigure.AutoConfiguration 15 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean 16 | import org.springframework.boot.context.properties.EnableConfigurationProperties 17 | import org.springframework.context.annotation.Bean 18 | 19 | /** 20 | * Autoconfiguration for the LLM agent resolver. 21 | * 22 | * The [springAgentResolverCompletionProvider] bean provides a [ModelClient] that uses the [ChatModel] to resolve agent routing specifications. 23 | * The [agentRoutingSpecsProvider] bean provides an [AgentRoutingSpecsProvider] that reads agent routing specifications from a JSON file. 24 | * The [agentResolverPromptProvider] bean provides a [ModelPromptProvider] that uses the default model prompt provider. 25 | * The [llmAgentRoutingSpecsResolver] bean provides an [LLMAgentRoutingSpecsResolver] that uses the [AgentRoutingSpecsProvider], [ModelPromptProvider], and [ModelClient] to resolve agent routing specifications. 26 | * 27 | * @param properties The properties for the LLM agent resolver. 28 | */ 29 | @AutoConfiguration 30 | @EnableConfigurationProperties(LLMAgentRoutingSpecsResolverProperties::class) 31 | open class LLMAgentRoutingSpecsResolverAutoConfiguration( 32 | private val properties: LLMAgentRoutingSpecsResolverProperties, 33 | ) { 34 | /** 35 | * Provides a [ModelClient] that uses the [ChatModel] to resolve agent routing specifications. 36 | * 37 | * @param chatModel The chat model. 38 | * @return The model client. 39 | */ 40 | @Bean 41 | @ConditionalOnMissingBean(ModelClient::class) 42 | open fun springAgentResolverCompletionProvider(chatModel: ChatModel): ModelClient { 43 | return SpringModelClient(chatModel) 44 | } 45 | 46 | /** 47 | * Provides an [AgentRoutingSpecsProvider] that reads agent routing specifications from a JSON file. 48 | * 49 | * @return The agent specs provider. 50 | */ 51 | @Bean 52 | @ConditionalOnMissingBean(AgentRoutingSpecsProvider::class) 53 | open fun agentRoutingSpecsProvider(): AgentRoutingSpecsProvider { 54 | return JsonAgentRoutingSpecsProvider(properties.specFilePath) 55 | } 56 | 57 | /** 58 | * Provides a [ModelPromptProvider] that uses the default model prompt provider. 59 | * 60 | * @return The model prompt provider. 61 | */ 62 | @Bean 63 | @ConditionalOnMissingBean(ModelPromptProvider::class) 64 | open fun agentResolverPromptProvider(): ModelPromptProvider { 65 | return DefaultModelPromptProvider() 66 | } 67 | 68 | /** 69 | * Provides an [LLMAgentRoutingSpecsResolver] that uses the [AgentRoutingSpecsProvider], [ModelPromptProvider], and [ModelClient] to resolve agent routing specifications. 70 | * 71 | * @param agentRoutingSpecsProvider The provider of agent routing specifications. 72 | * @param modelPromptProvider The provider of model prompts. 73 | * @param modelClient The client for the language model. 74 | * @return The LLM agent spec resolver. 75 | */ 76 | @Bean 77 | @ConditionalOnMissingBean(LLMAgentRoutingSpecsResolver::class) 78 | open fun llmAgentRoutingSpecsResolver( 79 | agentRoutingSpecsProvider: AgentRoutingSpecsProvider, 80 | modelPromptProvider: ModelPromptProvider, 81 | modelClient: ModelClient, 82 | ): LLMAgentRoutingSpecsResolver { 83 | return LLMAgentRoutingSpecsResolver(agentRoutingSpecsProvider, modelPromptProvider, modelClient) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/llm/starter/LLMAgentRoutingSpecsResolverProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm.starter 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | 9 | /** 10 | * Properties for the LLM agent resolver. 11 | * 12 | * The [specFilePath] field contains the path to the JSON file that contains the agent routing specifications. 13 | * 14 | * @param specFilePath The path to the JSON file that contains the agent routing specifications. 15 | */ 16 | @ConfigurationProperties(prefix = "route.agent.llm") 17 | data class LLMAgentRoutingSpecsResolverProperties(var specFilePath: String = "") 18 | -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/llm/starter/SpringModelClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm.starter 6 | 7 | import org.eclipse.lmos.router.core.* 8 | import org.eclipse.lmos.router.llm.ModelClient 9 | import org.springframework.ai.chat.model.ChatModel 10 | import org.springframework.ai.chat.prompt.Prompt 11 | 12 | /** 13 | * This is a model client that uses the Spring chat model to resolve agent routing specifications. 14 | * 15 | * @param chatModel The chat model. 16 | */ 17 | class SpringModelClient( 18 | private val chatModel: ChatModel, 19 | ) : ModelClient { 20 | /** 21 | * Calls the chat model with the given messages. 22 | * 23 | * @param messages The messages. 24 | * @return The result of the call. 25 | */ 26 | override fun call(messages: List): Result { 27 | return try { 28 | val response = 29 | chatModel.call( 30 | Prompt( 31 | messages.map { 32 | when (it) { 33 | is UserMessage -> org.springframework.ai.chat.messages.UserMessage(it.content) 34 | is AssistantMessage -> org.springframework.ai.chat.messages.AssistantMessage(it.content) 35 | is SystemMessage -> org.springframework.ai.chat.messages.SystemMessage(it.content) 36 | else -> throw IllegalArgumentException("Unsupported message type: ${it::class.simpleName}") 37 | } 38 | }, 39 | ), 40 | ).result.output.text 41 | Success(AssistantMessage(response)) 42 | } catch (e: Exception) { 43 | Failure(AgentRoutingSpecResolverException(e.message ?: "An error occurred", e)) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | org.eclipse.lmos.router.llm.starter.LLMAgentRoutingSpecsResolverAutoConfiguration -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/test/kotlin/SpringLLMAgentResolverFlow.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import org.eclipse.lmos.router.core.AgentRoutingSpecsResolver 6 | import org.eclipse.lmos.router.core.Context 7 | import org.eclipse.lmos.router.core.UserMessage 8 | import org.eclipse.lmos.router.core.getOrThrow 9 | import org.eclipse.lmos.router.llm.starter.LLMAgentRoutingSpecsResolverAutoConfiguration 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration 12 | import org.springframework.beans.factory.annotation.Autowired 13 | import org.springframework.boot.test.context.SpringBootTest 14 | import org.springframework.test.context.ContextConfiguration 15 | 16 | @SpringBootTest 17 | @ContextConfiguration(classes = [LLMAgentRoutingSpecsResolverAutoConfiguration::class, OpenAiAutoConfiguration::class]) 18 | class SpringLLMAgentResolverFlow( 19 | @Autowired private val agentResolver: AgentRoutingSpecsResolver, 20 | ) { 21 | @Test 22 | fun `test agent detection using openai`() { 23 | require(System.getenv("OPENAI_API_KEY") != null) { 24 | "Please set the OPENAI_API_KEY environment variable to run the tests" 25 | } 26 | val context = Context(emptyList()) 27 | val input = UserMessage("I want to buy a car") 28 | val result = agentResolver.resolve(context, input).getOrThrow() 29 | 30 | assert(result?.name == "offer-agent") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/llm/starter/LLMAgentRoutingSpecsResolverAutoConfigurationTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm.starter 6 | 7 | import io.mockk.mockk 8 | import org.eclipse.lmos.router.core.AgentRoutingSpecsProvider 9 | import org.eclipse.lmos.router.core.JsonAgentRoutingSpecsProvider 10 | import org.eclipse.lmos.router.llm.DefaultModelPromptProvider 11 | import org.eclipse.lmos.router.llm.LLMAgentRoutingSpecsResolver 12 | import org.eclipse.lmos.router.llm.ModelClient 13 | import org.eclipse.lmos.router.llm.ModelPromptProvider 14 | import org.junit.jupiter.api.Assertions.assertEquals 15 | import org.junit.jupiter.api.BeforeEach 16 | import org.junit.jupiter.api.Test 17 | import org.springframework.ai.chat.model.ChatModel 18 | 19 | class LLMAgentRoutingSpecsResolverAutoConfigurationTest { 20 | private lateinit var properties: LLMAgentRoutingSpecsResolverProperties 21 | private lateinit var autoConfiguration: LLMAgentRoutingSpecsResolverAutoConfiguration 22 | 23 | @BeforeEach 24 | fun setup() { 25 | properties = LLMAgentRoutingSpecsResolverProperties(specFilePath = "src/test/resources/agentRoutingSpecs.json") 26 | autoConfiguration = LLMAgentRoutingSpecsResolverAutoConfiguration(properties) 27 | } 28 | 29 | @Test 30 | fun `test springAgentResolverCompletionProvider creates ModelClient`() { 31 | // Arrange 32 | val chatModel = mockk() 33 | 34 | // Act 35 | val modelClient = autoConfiguration.springAgentResolverCompletionProvider(chatModel) 36 | 37 | // Assert 38 | assertEquals(modelClient::class, SpringModelClient::class) 39 | } 40 | 41 | @Test 42 | fun `test agentRoutingSpecsProvider creates JsonAgentRoutingSpecsProvider`() { 43 | // Act 44 | val agentSpecsProvider = autoConfiguration.agentRoutingSpecsProvider() 45 | 46 | // Assert 47 | assertEquals(agentSpecsProvider::class, JsonAgentRoutingSpecsProvider::class) 48 | } 49 | 50 | @Test 51 | fun `test agentResolverPromptProvider creates DefaultModelPromptProvider`() { 52 | // Act 53 | val modelPromptProvider = autoConfiguration.agentResolverPromptProvider() 54 | 55 | // Assert 56 | assertEquals(modelPromptProvider::class, DefaultModelPromptProvider::class) 57 | } 58 | 59 | @Test 60 | fun `test llmAgentRoutingSpecResolver creates LLMAgentRoutingSpecResolver with correct dependencies`() { 61 | // Arrange 62 | val agentRoutingSpecsProvider = mockk() 63 | val modelPromptProvider = mockk() 64 | val modelClient = mockk() 65 | 66 | // Act 67 | val llmAgentRoutingSpecResolver = 68 | autoConfiguration.llmAgentRoutingSpecsResolver(agentRoutingSpecsProvider, modelPromptProvider, modelClient) 69 | 70 | // Assert 71 | assertEquals(llmAgentRoutingSpecResolver::class, LLMAgentRoutingSpecsResolver::class) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "order-agent", 4 | "description": "This agent is responsible for order management", 5 | "version": "1.0.0", 6 | "addresses": [{ 7 | "protocol": "http", 8 | "uri": "http://localhost:8080" 9 | }], 10 | "capabilities": [ 11 | { 12 | "name": "order-status", 13 | "description": "This capability is responsible for order status tracking.", 14 | "version": "1.0.0" 15 | }, 16 | { 17 | "name": "order-cancel", 18 | "description": "This capability is responsible for order cancellation.", 19 | "version": "1.0.0" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "offer-agent", 25 | "description": "This agent is responsible for offer recommendation, cross-sell, and up-sell", 26 | "version": "1.0.0", 27 | "addresses": [{ 28 | "protocol": "http", 29 | "uri": "http://localhost:8080" 30 | }], 31 | "capabilities": [ 32 | { 33 | "name": "offer-recommendation", 34 | "description": "This capability is responsible for offer recommendation.", 35 | "version": "1.0.0" 36 | } 37 | ] 38 | } 39 | ] -------------------------------------------------------------------------------- /lmos-router-llm-spring-boot-starter/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | ai: 3 | openai: 4 | chat: 5 | options: 6 | model: gpt-4o-mini 7 | response-format: json_object 8 | enabled: true 9 | api-key: ${OPENAI_API_KEY} 10 | route: 11 | agent: 12 | llm: 13 | spec-file-path: src/test/resources/agentRoutingSpecs.json -------------------------------------------------------------------------------- /lmos-router-llm/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | dependencies { 6 | api(project(":lmos-router-core")) 7 | implementation("org.slf4j:slf4j-api:2.0.17") 8 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 9 | implementation("dev.langchain4j:langchain4j-open-ai:0.36.2") 10 | implementation("dev.langchain4j:langchain4j-anthropic:0.36.2") 11 | implementation("dev.langchain4j:langchain4j-azure-open-ai:0.36.2") 12 | implementation("dev.langchain4j:langchain4j-google-ai-gemini:0.36.2") 13 | implementation("dev.langchain4j:langchain4j-ollama:0.36.2") 14 | } 15 | -------------------------------------------------------------------------------- /lmos-router-llm/src/main/kotlin/org/eclipse/lmos/router/llm/LLMAgentRoutingSpecsResolver.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm 6 | 7 | import kotlinx.serialization.json.Json 8 | import kotlinx.serialization.serializer 9 | import org.eclipse.lmos.router.core.* 10 | import org.slf4j.LoggerFactory 11 | 12 | /** 13 | * An agent spec resolver that uses a language model to resolve agent specs. 14 | * 15 | * @param agentRoutingSpecsProvider The provider of agent routing specifications. 16 | * @param modelPromptProvider The provider of model prompts. 17 | * @param modelClient The client for the language model. 18 | * @param serializer The JSON serializer. 19 | * @param modelClientResponseProcessor The processor for the model client response. 20 | */ 21 | class LLMAgentRoutingSpecsResolver( 22 | override val agentRoutingSpecsProvider: AgentRoutingSpecsProvider, 23 | private val modelPromptProvider: ModelPromptProvider = DefaultModelPromptProvider(), 24 | private val modelClient: ModelClient = 25 | DefaultModelClient( 26 | DefaultModelClientProperties( 27 | openAiApiKey = 28 | System.getenv( 29 | "OPENAI_API_KEY", 30 | ), 31 | ), 32 | ), 33 | private val serializer: Json = 34 | Json { 35 | ignoreUnknownKeys = true 36 | isLenient = true 37 | }, 38 | private val modelClientResponseProcessor: ModelClientResponseProcessor = DefaultModelClientResponseProcessor(), 39 | ) : AgentRoutingSpecsResolver { 40 | private val log = LoggerFactory.getLogger(LLMAgentRoutingSpecsResolver::class.java) 41 | 42 | override fun resolve( 43 | context: Context, 44 | input: UserMessage, 45 | ): Result { 46 | return resolve(emptySet(), context, input) 47 | } 48 | 49 | override fun resolve( 50 | filters: Set, 51 | context: Context, 52 | input: UserMessage, 53 | ): Result { 54 | try { 55 | log.trace("Resolving agent spec") 56 | val agentRoutingSpecs = agentRoutingSpecsProvider.provide(filters).getOrThrow() 57 | 58 | log.trace("Fetching agent spec prompt") 59 | val prompt = modelPromptProvider.providePrompt(context, agentRoutingSpecs, input) 60 | 61 | val messages = mutableListOf() 62 | messages.add(SystemMessage(prompt.getOrThrow())) 63 | messages.addAll(context.previousMessages) 64 | messages.add(input) 65 | 66 | log.trace("Fetching agent spec completion") 67 | var response: String = modelClient.call(messages).getOrThrow().content 68 | 69 | response = modelClientResponseProcessor.process(response) 70 | 71 | val agent: ModelClientResponse = serializer.decodeFromString(serializer(), response) 72 | 73 | log.trace("Agent resolved: ${agent.agentName}") 74 | return Success(agentRoutingSpecs.firstOrNull { it.name == agent.agentName }) 75 | } catch (e: Exception) { 76 | log.error("Failed to resolve agent spec", e) 77 | return Failure(AgentRoutingSpecResolverException("Failed to resolve agent spec", e)) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lmos-router-llm/src/main/kotlin/org/eclipse/lmos/router/llm/ModelClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm 6 | 7 | import org.eclipse.lmos.router.core.* 8 | 9 | /** 10 | * The [ModelClient] interface represents a client that can call a model. 11 | * 12 | * The [call] method calls the model with the given messages. 13 | * 14 | * @see ChatMessage 15 | * @see AgentRoutingSpecResolverException 16 | * @see Result 17 | */ 18 | interface ModelClient { 19 | fun call(messages: List): Result 20 | } 21 | 22 | /** 23 | * The [DefaultModelClient] class is a default implementation of the [ModelClient] interface. 24 | * 25 | * The [call] method calls the OpenAI model with the given messages. 26 | * 27 | * @param defaultModelClientProperties The properties for the default model client. 28 | */ 29 | class DefaultModelClient( 30 | private val defaultModelClientProperties: DefaultModelClientProperties, 31 | private val delegate: LangChainModelClient = 32 | LangChainModelClient(LangChainChatModelFactory.createClient(defaultModelClientProperties)), 33 | ) : ModelClient { 34 | override fun call(messages: List): Result { 35 | return delegate.call(messages) 36 | } 37 | } 38 | 39 | open class ModelClientProperties( 40 | open val provider: String, 41 | open val apiKey: String? = null, 42 | open val baseUrl: String? = null, 43 | open val model: String, 44 | open val maxTokens: Int = 2000, 45 | open val temperature: Double = 0.0, 46 | open val format: String? = null, 47 | open val topK: Int? = null, 48 | open val topP: Double? = null, 49 | ) 50 | 51 | /** 52 | * The [DefaultModelClientProperties] data class represents the properties for the default model client. 53 | * 54 | * @param openAiUrl The OpenAI URL. 55 | * @param openAiApiKey The OpenAI API key. 56 | * @param model The model. 57 | * @param maxTokens The maximum number of tokens. 58 | * @param temperature The temperature. 59 | * @param format The format. 60 | */ 61 | data class DefaultModelClientProperties( 62 | val openAiUrl: String = "https://api.openai.com/v1", 63 | val openAiApiKey: String, 64 | override val model: String = "gpt-4o-mini", 65 | override val maxTokens: Int = 200, 66 | override val temperature: Double = 0.0, 67 | override val format: String = "json_object", 68 | override val apiKey: String? = openAiApiKey, 69 | override val baseUrl: String = openAiUrl, 70 | override val provider: String = "openai", 71 | ) : ModelClientProperties( 72 | provider, 73 | apiKey, 74 | baseUrl, 75 | model, 76 | maxTokens, 77 | temperature, 78 | format, 79 | ) 80 | 81 | /** 82 | * This interface represents a model response processor. 83 | * 84 | * The objective is to process the response from the model and return agentSpec compliant json. 85 | */ 86 | interface ModelClientResponseProcessor { 87 | fun process(modelResponse: String): String 88 | } 89 | 90 | /** 91 | * This class is a default implementation of the ModelResponseProcessor interface. 92 | * 93 | * The processResponse method processes the response from the model. 94 | * 95 | * By default, it cleans the response and remove ```json and tags. Refer default prompt for more information. 96 | */ 97 | class DefaultModelClientResponseProcessor : ModelClientResponseProcessor { 98 | override fun process(modelResponse: String): String { 99 | var response = modelResponse.trim() 100 | 101 | if (response.contains("```json")) { 102 | response = response.substringAfter("```json").substringBefore("```").trim() 103 | } 104 | 105 | if (response.contains("")) { 106 | response = response.substringAfter("").substringBefore("").trim() 107 | } 108 | return response 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /lmos-router-llm/src/main/kotlin/org/eclipse/lmos/router/llm/ModelClientResponse.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | /** 10 | * Represents a model client response. 11 | * 12 | * The [agentName] field contains the name of the agent. 13 | */ 14 | @Serializable 15 | open class ModelClientResponse(val agentName: String) 16 | -------------------------------------------------------------------------------- /lmos-router-llm/src/test/kotlin/ai/eclipse/lmos/router/org/DefaultModelClientTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.llm 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import org.eclipse.lmos.router.core.* 10 | import org.junit.jupiter.api.Assertions.assertEquals 11 | import org.junit.jupiter.api.BeforeEach 12 | import org.junit.jupiter.api.Test 13 | 14 | class DefaultModelClientTest { 15 | private lateinit var defaultModelClientProperties: DefaultModelClientProperties 16 | private lateinit var defaultModelClient: DefaultModelClient 17 | private lateinit var delegate: LangChainModelClient 18 | 19 | @BeforeEach 20 | fun setUp() { 21 | defaultModelClientProperties = 22 | DefaultModelClientProperties( 23 | openAiApiKey = "fake-api-key", 24 | openAiUrl = "https://api.openai.com/v1/chat/completions", 25 | model = "gpt-4o-mini", 26 | maxTokens = 200, 27 | temperature = 0.0, 28 | format = "json_object", 29 | ) 30 | 31 | delegate = mockk() 32 | defaultModelClient = DefaultModelClient(defaultModelClientProperties, delegate) 33 | } 34 | 35 | @Test 36 | fun `call should return AssistantMessage when valid messages are passed`() { 37 | val messages = 38 | listOf( 39 | UserMessage("Hello, how can I assist you today?"), 40 | AssistantMessage("I am here to help you."), 41 | ) 42 | 43 | every { delegate.call(any()) } returns Success(AssistantMessage("This is a response from the assistant.")) 44 | val result = defaultModelClient.call(messages) 45 | 46 | assertEquals(result, Success(AssistantMessage("This is a response from the assistant."))) 47 | } 48 | 49 | @Test 50 | fun `call should throw AgentRoutingSpecResolverException for API call failure`() { 51 | val messages = 52 | listOf( 53 | UserMessage("Hello, how can I assist you today?"), 54 | ) 55 | 56 | every { delegate.call(any()) } returns Failure(AgentRoutingSpecResolverException("Failed to call language model")) 57 | val result = defaultModelClient.call(messages) 58 | 59 | assert(result is Failure) 60 | assert((result as Failure).exceptionOrNull()?.message == "Failed to call language model") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lmos-router-llm/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "order-agent", 4 | "description": "This agent is responsible for order management", 5 | "version": "1.0.0", 6 | "addresses": [{ 7 | "protocol": "http", 8 | "uri": "localhost:8080" 9 | }], 10 | "capabilities": [ 11 | { 12 | "name": "order-status", 13 | "description": "This capability is responsible for order status tracking.", 14 | "version": "1.0.0" 15 | }, 16 | { 17 | "name": "order-cancel", 18 | "description": "This capability is responsible for order cancellation.", 19 | "version": "1.0.0" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "offer-agent", 25 | "description": "This agent is responsible for offer recommendation, cross-sell, and up-sell", 26 | "version": "1.0.0", 27 | "addresses": [{ 28 | "protocol": "http", 29 | "uri": "localhost:8080" 30 | }], 31 | "capabilities": [ 32 | { 33 | "name": "offer-recommendation", 34 | "description": "This capability is responsible for offer recommendation.", 35 | "version": "1.0.0" 36 | } 37 | ] 38 | } 39 | ] -------------------------------------------------------------------------------- /lmos-router-llm/src/test/resources/prompt.txt: -------------------------------------------------------------------------------- 1 | You are an AI tasked with selecting the most suitable agent to address a user query based on the agents' capabilities. 2 | You will be provided with a list of agents and their capabilities, followed by a user query. 3 | Your goal is to analyze the query and match it with the most appropriate agent. 4 | 5 | First, here is the list of agents and their capabilities: 6 | ${agents_list_xml} 7 | 8 | To select the most suitable agent, follow these steps: 9 | 10 | 1. Carefully read and understand the user query. 11 | 2. Review the list of agents and their capabilities. 12 | 3. Analyze how well each agent's capabilities match the requirements of the user query. 13 | 4. Consider factors such as relevance, expertise, and specificity of the agent's capabilities in relation to the query. 14 | 5. Select the agent whose capabilities best align with the user's needs. 15 | 16 | Once you have determined the most suitable agent, provide your answer in the following JSON format: 17 | 18 | 19 | ```json 20 | {"agentName": "name-of-agent"} 21 | ``` 22 | 23 | 24 | Ensure that the agent name you provide exactly matches the name given in the agents list. 25 | Do not include any additional explanation or justification in your response; only provide the JSON object as specified. -------------------------------------------------------------------------------- /lmos-router-llm/src/test/resources/prompt_agentRoutingSpec_json.txt: -------------------------------------------------------------------------------- 1 | You are an AI tasked with selecting the most suitable agent to address a user query based on the agents' capabilities. 2 | You will be provided with a list of agents and their capabilities, followed by a user query. 3 | Your goal is to analyze the query and match it with the most appropriate agent. 4 | 5 | First, here is the list of agents and their capabilities: 6 | ${agents_list_json} 7 | 8 | To select the most suitable agent, follow these steps: 9 | 10 | 1. Carefully read and understand the user query. 11 | 2. Review the list of agents and their capabilities. 12 | 3. Analyze how well each agent's capabilities match the requirements of the user query. 13 | 4. Consider factors such as relevance, expertise, and specificity of the agent's capabilities in relation to the query. 14 | 5. Select the agent whose capabilities best align with the user's needs. 15 | 16 | Once you have determined the most suitable agent, provide your answer in the following JSON format: 17 | 18 | 19 | ```json 20 | {"agentName": "name-of-agent"} 21 | ``` 22 | 23 | 24 | Ensure that the agent name you provide exactly matches the name given in the agents list. 25 | Do not include any additional explanation or justification in your response; only provide the JSON object as specified. -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/ReadMe.md: -------------------------------------------------------------------------------- 1 | 6 | # Vector Agent Routing Module 7 | 8 | This module is part of the Intelligent Agent Routing System and focuses on resolving agent routing specifications using vector embeddings. It provides auto-configuration for Spring Boot applications to easily integrate vector-based agent routing. 9 | 10 | ## Overview 11 | 12 | The Vector Agent Routing Module uses vector embeddings to represent queries and agent capabilities, comparing them using cosine similarity. This approach is efficient for large-scale data and is independent of external APIs. 13 | 14 | ## Features 15 | 16 | - **Vector Search Client**: Searches for similar vectors in a vector store. 17 | - **Vector Seed Client**: Seeds the vector store with agent routing specifications. 18 | - **Agent Routing Specs Provider**: Reads agent routing specifications from a JSON file. 19 | - **Vector Agent Routing Specs Resolver**: Resolves agent routing specifications using the vector search client. 20 | 21 | ## Quickstart Guide 22 | 23 | ### Step 1: Add Dependencies 24 | 25 | Ensure you have the necessary dependencies in your `build.gradle` or `pom.xml` file. 26 | 27 | ```kotlin 28 | dependencies { 29 | implementation("org.eclipse.lmos:lmos-router-vector-spring-boot-starter:1.0.0") 30 | } 31 | ``` 32 | 33 | ### Step 2: Configure Properties 34 | 35 | Set the required properties in your `application.yml` or `application.properties` file. 36 | 37 | ```yaml 38 | route: 39 | agent: 40 | vector: 41 | specFilePath: "path/to/your/agent-specs.json" 42 | llm: 43 | vector: 44 | search: 45 | threshold: 0.5 46 | topK: 1 47 | ``` 48 | 49 | ### Step 3: Initialize the Vector Agent Routing Specs Resolver 50 | 51 | The module provides auto-configuration, so you only need to inject the `VectorAgentRoutingSpecsResolver` where needed. 52 | 53 | ```kotlin 54 | import org.eclipse.lmos.router.vector.VectorAgentRoutingSpecsResolver 55 | import org.springframework.beans.factory.annotation.Autowired 56 | import org.springframework.stereotype.Service 57 | 58 | @Service 59 | class AgentRoutingService { 60 | 61 | @Autowired 62 | private lateinit var vectorAgentRoutingSpecsResolver: VectorAgentRoutingSpecsResolver 63 | 64 | fun resolveAgent(query: String): String? { 65 | val context = Context(listOf(AssistantMessage("Hello"))) 66 | val input = UserMessage(query) 67 | val result = vectorAgentRoutingSpecsResolver.resolve(context, input) 68 | return result.agentName 69 | } 70 | } 71 | ``` 72 | 73 | ## Configuration 74 | 75 | ### Properties 76 | 77 | - **route.agent.vector.specFilePath**: Path to the JSON file containing the agent routing specifications. 78 | - **route.llm.vector.search.threshold**: Similarity threshold for vector search (default: 0.5). 79 | - **route.llm.vector.search.topK**: Number of similar vectors to return (default: 1). 80 | 81 | ### Beans 82 | 83 | The module provides the following beans: 84 | 85 | - **VectorSearchClient**: Uses the vector store to search for similar vectors. 86 | - **VectorSeedClient**: Uses the vector store to seed vectors. 87 | - **AgentRoutingSpecsProvider**: Reads agent routing specifications from a JSON file. 88 | - **VectorAgentRoutingSpecsResolver**: Resolves agent routing specifications using the vector search client. 89 | 90 | ## Usage 91 | 92 | ### Seeding the Vector Store 93 | 94 | To seed the vector store with agent routing specifications, use the `VectorSeedClient`. 95 | 96 | ```kotlin 97 | import org.eclipse.lmos.router.vector.VectorSeedClient 98 | import org.eclipse.lmos.router.vector.VectorSeedRequest 99 | import org.springframework.beans.factory.annotation.Autowired 100 | import org.springframework.stereotype.Service 101 | 102 | @Service 103 | class VectorSeedService { 104 | 105 | @Autowired 106 | private lateinit var vectorSeedClient: VectorSeedClient 107 | 108 | fun seedVectors() { 109 | val seedRequests = listOf( 110 | VectorSeedRequest("Agent 1", "This is the description for agent 1."), 111 | VectorSeedRequest("Agent 2", "This is the description for agent 2.") 112 | ) 113 | vectorSeedClient.seed(seedRequests) 114 | } 115 | } 116 | ``` 117 | 118 | ### Resolving Agents 119 | 120 | To resolve agents based on user queries, use the `VectorAgentRoutingSpecsResolver`. 121 | 122 | ```kotlin 123 | import org.eclipse.lmos.router.vector.VectorAgentRoutingSpecsResolver 124 | import org.springframework.beans.factory.annotation.Autowired 125 | import org.springframework.stereotype.Service 126 | 127 | @Service 128 | class AgentRoutingService { 129 | 130 | @Autowired 131 | private lateinit var vectorAgentRoutingSpecsResolver: VectorAgentRoutingSpecsResolver 132 | 133 | fun resolveAgent(query: String): String? { 134 | val context = Context(listOf(AssistantMessage("Hello"))) 135 | val input = UserMessage(query) 136 | val result = vectorAgentRoutingSpecsResolver.resolve(context, input) 137 | return result.agentName 138 | } 139 | } 140 | ``` -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | val springBootVersion: String by rootProject.extra 6 | 7 | dependencies { 8 | api(project(":lmos-router-core")) 9 | api(project(":lmos-router-vector")) 10 | implementation("org.springframework.boot:spring-boot-autoconfigure:$springBootVersion") 11 | implementation("org.springframework.boot:spring-boot-configuration-processor:$springBootVersion") 12 | implementation("org.springframework.ai:spring-ai-core:1.0.0-M6") 13 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 14 | testImplementation("org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6") 15 | testImplementation("org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter:1.0.0-M6") 16 | testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") 17 | testImplementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") 18 | testImplementation("org.testcontainers:qdrant:1.21.0") 19 | } 20 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/vector/starter/SpringVectorSearchClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import org.eclipse.lmos.router.core.AgentRoutingSpec 8 | import org.eclipse.lmos.router.core.Failure 9 | import org.eclipse.lmos.router.core.Result 10 | import org.eclipse.lmos.router.core.Success 11 | import org.eclipse.lmos.router.vector.VectorClientException 12 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 13 | import org.eclipse.lmos.router.vector.VectorSearchClient 14 | import org.eclipse.lmos.router.vector.VectorSearchClientRequest 15 | import org.eclipse.lmos.router.vector.VectorSearchClientResponse 16 | import org.springframework.ai.vectorstore.SearchRequest 17 | import org.springframework.ai.vectorstore.VectorStore 18 | import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder 19 | 20 | /** 21 | * A Spring implementation of the VectorSearchClient. 22 | * 23 | * @param vectorStore The vector store to search in. 24 | */ 25 | class SpringVectorSearchClient( 26 | private val vectorStore: VectorStore, 27 | private val springVectorSearchClientProperties: SpringVectorSearchClientProperties, 28 | ) : VectorSearchClient { 29 | /** 30 | * Finds the most similar vector to the given query. 31 | * 32 | * @param request The search request. 33 | * @param agentRoutingSpecs The agent specs to filter by. 34 | * @return A result containing the most similar vector or null if no similar vectors were found. 35 | */ 36 | override fun find( 37 | request: VectorSearchClientRequest, 38 | agentRoutingSpecs: Set, 39 | ): Result { 40 | return try { 41 | val documents = 42 | vectorStore.similaritySearch( 43 | SearchRequest.builder().query(request.query) 44 | .similarityThreshold(springVectorSearchClientProperties.threshold) 45 | .topK(springVectorSearchClientProperties.topK) 46 | .filterExpression( 47 | FilterExpressionBuilder().`in`( 48 | AGENT_FIELD_NAME, 49 | *agentRoutingSpecs.map { it.name }.toTypedArray(), 50 | ).build(), 51 | ).build(), 52 | ) 53 | if (documents?.isEmpty() == true) { 54 | Success(null) 55 | } else { 56 | val grouped = documents?.groupBy { it.metadata[AGENT_FIELD_NAME] as String } 57 | val agentName = grouped?.maxByOrNull { it.value.size }?.key 58 | if (agentName != null) { 59 | Success( 60 | VectorSearchClientResponse( 61 | grouped.getValue(agentName).joinToString("\n") { it.text ?: "" }, 62 | agentName, 63 | ), 64 | ) 65 | } else { 66 | Success(null) 67 | } 68 | } 69 | } catch (e: Exception) { 70 | Failure(VectorClientException("Failed to find similar vectors", e)) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/vector/starter/SpringVectorSearchClientProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | 9 | /** 10 | * Properties for the SpringVectorSearchClient. 11 | * 12 | * @param threshold The similarity threshold. 13 | * @param topK The number of similar vectors to return. 14 | */ 15 | @ConfigurationProperties("route.llm.vector.search") 16 | data class SpringVectorSearchClientProperties( 17 | var threshold: Double = 0.5, 18 | var topK: Int = 1, 19 | ) { 20 | init { 21 | require(threshold in 0.0..1.0) { "threshold must be between 0.0 and 1.0" } 22 | require(topK > 0) { "topK must be a positive integer" } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/vector/starter/SpringVectorSeedClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import org.eclipse.lmos.router.core.Failure 8 | import org.eclipse.lmos.router.core.Result 9 | import org.eclipse.lmos.router.core.Success 10 | import org.eclipse.lmos.router.vector.VectorClientException 11 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 12 | import org.eclipse.lmos.router.vector.VectorSeedClient 13 | import org.eclipse.lmos.router.vector.VectorSeedRequest 14 | import org.springframework.ai.document.Document 15 | import org.springframework.ai.vectorstore.VectorStore 16 | 17 | /** 18 | * A Spring implementation of the VectorSeedClient. 19 | * 20 | * @param vectorStore The vector store to seed. 21 | */ 22 | class SpringVectorSeedClient( 23 | private val vectorStore: VectorStore, 24 | ) : VectorSeedClient { 25 | /** 26 | * Seeds the vector store with the given documents. 27 | * 28 | * @param documents The documents to seed the vector store with. 29 | * @return A result indicating success or failure. 30 | */ 31 | override fun seed(documents: List): Result { 32 | try { 33 | val vectorDocuments = 34 | documents.map { 35 | Document( 36 | it.text, 37 | mapOf(AGENT_FIELD_NAME to it.agentName), 38 | ) 39 | } 40 | 41 | return Success(vectorStore.add(vectorDocuments)) 42 | } catch (e: Exception) { 43 | return Failure(VectorClientException(e.message ?: "An error occurred while seeding the vector store")) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/vector/starter/VectorAgentRoutingSpecsResolverAutoConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import org.eclipse.lmos.router.core.AgentRoutingSpecsProvider 8 | import org.eclipse.lmos.router.core.JsonAgentRoutingSpecsProvider 9 | import org.eclipse.lmos.router.vector.VectorAgentRoutingSpecsResolver 10 | import org.eclipse.lmos.router.vector.VectorSearchClient 11 | import org.eclipse.lmos.router.vector.VectorSeedClient 12 | import org.springframework.ai.vectorstore.VectorStore 13 | import org.springframework.boot.autoconfigure.AutoConfiguration 14 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean 15 | import org.springframework.boot.context.properties.EnableConfigurationProperties 16 | import org.springframework.context.annotation.Bean 17 | 18 | @AutoConfiguration 19 | @EnableConfigurationProperties( 20 | SpringVectorSearchClientProperties::class, 21 | VectorAgentRoutingSpecsResolverProperties::class, 22 | ) 23 | open class VectorAgentRoutingSpecsResolverAutoConfiguration( 24 | private val springVectorSearchClientProperties: SpringVectorSearchClientProperties, 25 | private val properties: VectorAgentRoutingSpecsResolverProperties, 26 | ) { 27 | /** 28 | * Provides a [VectorSearchClient] that uses the vector store to search for similar vectors. 29 | * 30 | * @param vectorStore The vector store to search in. 31 | * @return The vector search client. 32 | */ 33 | @Bean 34 | @ConditionalOnMissingBean(VectorSearchClient::class) 35 | open fun vectorSearchClient(vectorStore: VectorStore): VectorSearchClient { 36 | return SpringVectorSearchClient(vectorStore, springVectorSearchClientProperties) 37 | } 38 | 39 | /** 40 | * Provides a [VectorSeedClient] that uses the vector store to seed vectors. 41 | * 42 | * @param vectorStore The vector store to seed. 43 | * @return The vector seed client. 44 | */ 45 | @Bean 46 | @ConditionalOnMissingBean(VectorSeedClient::class) 47 | open fun vectorSeedClient(vectorStore: VectorStore): VectorSeedClient { 48 | return SpringVectorSeedClient(vectorStore) 49 | } 50 | 51 | /** 52 | * Provides an [AgentRoutingSpecsProvider] that reads agent routing specifications from a JSON file. 53 | * 54 | * @return The agent specs provider. 55 | */ 56 | @Bean 57 | @ConditionalOnMissingBean(AgentRoutingSpecsProvider::class) 58 | open fun agentRoutingSpecsProvider(): AgentRoutingSpecsProvider { 59 | return JsonAgentRoutingSpecsProvider(properties.specFilePath) 60 | } 61 | 62 | /** 63 | * Provides a [VectorAgentRoutingSpecsResolver] that resolves agent routing specifications using the vector search client. 64 | * 65 | * @param agentRoutingSpecsProvider The agent specs provider. 66 | * @param vectorSearchClient The vector search client. 67 | * @return The vector agent spec resolver. 68 | */ 69 | @Bean 70 | @ConditionalOnMissingBean(VectorAgentRoutingSpecsResolver::class) 71 | open fun vectorAgentRoutingSpecsResolver( 72 | agentRoutingSpecsProvider: AgentRoutingSpecsProvider, 73 | vectorSearchClient: VectorSearchClient, 74 | ): VectorAgentRoutingSpecsResolver { 75 | return VectorAgentRoutingSpecsResolver(agentRoutingSpecsProvider, vectorSearchClient) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/main/kotlin/org/eclipse/lmos/router/vector/starter/VectorAgentRoutingSpecsResolverProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | 9 | /** 10 | * Properties for the VectorAgentRoutingSpecsResolver. 11 | * 12 | * @param specFilePath The path to the file containing the agent specs. 13 | */ 14 | @ConfigurationProperties(prefix = "route.agent.vector") 15 | class VectorAgentRoutingSpecsResolverProperties( 16 | var specFilePath: String = "", 17 | ) 18 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | org.eclipse.lmos.router.vector.starter.VectorAgentRoutingSpecsResolverAutoConfiguration -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/kotlin/SpringVectorAgentRoutingSpecsResolverFlow.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import io.qdrant.client.QdrantClient 6 | import io.qdrant.client.grpc.Collections 7 | import kotlinx.serialization.json.Json 8 | import org.eclipse.lmos.router.core.* 9 | import org.eclipse.lmos.router.vector.VectorAgentRoutingSpecsResolver 10 | import org.eclipse.lmos.router.vector.VectorSeedClient 11 | import org.eclipse.lmos.router.vector.VectorSeedRequest 12 | import org.eclipse.lmos.router.vector.starter.VectorAgentRoutingSpecsResolverAutoConfiguration 13 | import org.junit.jupiter.api.AfterEach 14 | import org.junit.jupiter.api.BeforeEach 15 | import org.junit.jupiter.api.Test 16 | import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration 17 | import org.springframework.ai.autoconfigure.vectorstore.qdrant.QdrantVectorStoreAutoConfiguration 18 | import org.springframework.beans.factory.DisposableBean 19 | import org.springframework.beans.factory.annotation.Autowired 20 | import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent 21 | import org.springframework.boot.test.context.SpringBootTest 22 | import org.springframework.context.ApplicationListener 23 | import org.springframework.core.env.PropertiesPropertySource 24 | import org.springframework.stereotype.Component 25 | import org.springframework.test.context.ContextConfiguration 26 | import org.testcontainers.qdrant.QdrantContainer 27 | import java.io.File 28 | import java.util.Properties 29 | 30 | @SpringBootTest 31 | @ContextConfiguration( 32 | classes = [ 33 | VectorAgentRoutingSpecsResolverAutoConfiguration::class, 34 | OpenAiAutoConfiguration::class, 35 | QdrantVectorStoreAutoConfiguration::class, QdrantVectorConfigurationListener::class, 36 | ], 37 | ) 38 | class SpringVectorAgentRoutingSpecsResolverFlow( 39 | @Autowired 40 | private val vectorAgentRoutingSpecsResolver: VectorAgentRoutingSpecsResolver, 41 | @Autowired 42 | private val vectorSeedClient: VectorSeedClient, 43 | @Autowired 44 | private val qdrantClient: QdrantClient, 45 | ) { 46 | @Test 47 | fun `test sample flow`() { 48 | require(System.getenv("OPENAI_API_KEY") != null) { 49 | "Please set the OPENAI_API_KEY environment variable to run the tests" 50 | } 51 | 52 | val context = Context(listOf(AssistantMessage("Hello"))) 53 | 54 | val jsonSeedFileContent = File("src/test/resources/seed.json").readText() 55 | val vectorSeedRequests = Json.decodeFromString>(jsonSeedFileContent) 56 | vectorSeedClient.seed(vectorSeedRequests).getOrThrow() 57 | 58 | // The input to test whether offer-agent is resolved 59 | val input = UserMessage("Can you help me find a new phone?") 60 | val result = vectorAgentRoutingSpecsResolver.resolve(context, input) 61 | assert(result is Success) 62 | assert((result as Success).getOrNull()?.name == "offer-agent") 63 | 64 | // The input to test whether order-agent is resolved 65 | val input2 = UserMessage("I would like to cancel my pizza order") 66 | val result2 = vectorAgentRoutingSpecsResolver.resolve(context, input2) 67 | assert(result2 is Success) 68 | assert((result2 as Success).getOrNull()?.name == "order-agent") 69 | } 70 | 71 | @BeforeEach 72 | fun setUp() { 73 | qdrantClient.createCollectionAsync( 74 | "test", 75 | Collections.VectorParams.newBuilder() 76 | .setDistance(Collections.Distance.Cosine) 77 | .setSize(1536) 78 | .build(), 79 | ).get() 80 | } 81 | 82 | @AfterEach 83 | fun tearDown() { 84 | qdrantClient.deleteCollectionAsync("test").get() 85 | } 86 | } 87 | 88 | @Component 89 | class QdrantVectorConfigurationListener : ApplicationListener, DisposableBean { 90 | private lateinit var container: QdrantContainer 91 | 92 | override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) { 93 | container = QdrantContainer("qdrant/qdrant:v1.7.4") 94 | container.start() 95 | val qdrantVectorStoreProperties = Properties() 96 | qdrantVectorStoreProperties.put("spring.ai.vectorstore.qdrant.host", container.host) 97 | qdrantVectorStoreProperties.put("spring.ai.vectorstore.qdrant.port", container.grpcPort) 98 | event.environment.propertySources.addFirst( 99 | PropertiesPropertySource( 100 | "qdrantVectorStoreProperties", 101 | qdrantVectorStoreProperties, 102 | ), 103 | ) 104 | } 105 | 106 | override fun destroy() { 107 | container.stop() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/vector/starter/SpringVectorSearchClientPropertiesTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import io.mockk.junit5.MockKExtension 8 | import org.junit.jupiter.api.Assertions.assertEquals 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.assertThrows 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | 13 | @ExtendWith(MockKExtension::class) 14 | class SpringVectorSearchClientPropertiesTest { 15 | @Test 16 | fun `should initialize with default values`() { 17 | val properties = SpringVectorSearchClientProperties() 18 | 19 | assertEquals(0.5, properties.threshold) 20 | assertEquals(1, properties.topK) 21 | } 22 | 23 | @Test 24 | fun `should allow setting custom threshold value`() { 25 | val properties = SpringVectorSearchClientProperties(threshold = 0.75) 26 | 27 | assertEquals(0.75, properties.threshold) 28 | } 29 | 30 | @Test 31 | fun `should allow setting custom topK value`() { 32 | val properties = SpringVectorSearchClientProperties(topK = 5) 33 | 34 | assertEquals(5, properties.topK) 35 | } 36 | 37 | @Test 38 | fun `should allow setting both custom values`() { 39 | val properties = SpringVectorSearchClientProperties(threshold = 0.85, topK = 10) 40 | 41 | assertEquals(0.85, properties.threshold) 42 | assertEquals(10, properties.topK) 43 | } 44 | 45 | @Test 46 | fun `should throw an exception for invalid threshold`() { 47 | val exception = 48 | assertThrows { 49 | SpringVectorSearchClientProperties(threshold = -1.0) 50 | } 51 | assertEquals("threshold must be between 0.0 and 1.0", exception.message) 52 | } 53 | 54 | @Test 55 | fun `should throw an exception for invalid topK`() { 56 | val exception = 57 | assertThrows { 58 | SpringVectorSearchClientProperties(topK = -1) 59 | } 60 | assertEquals("topK must be a positive integer", exception.message) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/vector/starter/SpringVectorSeedClientTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.slot 10 | import io.mockk.verify 11 | import org.eclipse.lmos.router.core.Failure 12 | import org.eclipse.lmos.router.core.Success 13 | import org.eclipse.lmos.router.core.exceptionOrNull 14 | import org.eclipse.lmos.router.vector.VectorRouteConstants.Companion.AGENT_FIELD_NAME 15 | import org.eclipse.lmos.router.vector.VectorSeedRequest 16 | import org.junit.jupiter.api.Assertions.assertEquals 17 | import org.junit.jupiter.api.Assertions.assertTrue 18 | import org.junit.jupiter.api.Test 19 | import org.springframework.ai.document.Document 20 | import org.springframework.ai.vectorstore.VectorStore 21 | 22 | class SpringVectorSeedClientTest { 23 | private val vectorStore = mockk() 24 | private val springVectorSeedClient = SpringVectorSeedClient(vectorStore) 25 | 26 | @Test 27 | fun `should seed vector store successfully`() { 28 | // Arrange 29 | val documents = 30 | listOf( 31 | VectorSeedRequest("agent1", "text1"), 32 | VectorSeedRequest("agent2", "text2"), 33 | ) 34 | 35 | // Mock 36 | val vectorDocumentsSlot = slot>() 37 | every { vectorStore.add(capture(vectorDocumentsSlot)) } returns Unit 38 | 39 | // Act 40 | val result = springVectorSeedClient.seed(documents) 41 | 42 | // Assert 43 | assertTrue(result is Success) 44 | assertEquals(2, vectorDocumentsSlot.captured.size) 45 | assertEquals("text1", vectorDocumentsSlot.captured[0].text) 46 | assertEquals("agent1", vectorDocumentsSlot.captured[0].metadata[AGENT_FIELD_NAME]) 47 | assertEquals("text2", vectorDocumentsSlot.captured[1].text) 48 | assertEquals("agent2", vectorDocumentsSlot.captured[1].metadata[AGENT_FIELD_NAME]) 49 | verify { vectorStore.add(any()) } 50 | } 51 | 52 | @Test 53 | fun `should return failure when exception is thrown`() { 54 | // Arrange 55 | val documents = 56 | listOf( 57 | VectorSeedRequest("agent1", "text1"), 58 | VectorSeedRequest("agent2", "text2"), 59 | ) 60 | 61 | // Mock 62 | every { vectorStore.add(any()) } throws RuntimeException("Mock exception") 63 | 64 | // Act 65 | val result = springVectorSeedClient.seed(documents) 66 | 67 | // Assert 68 | assertTrue(result is Failure) 69 | val failure = result as Failure 70 | assertEquals("Mock exception", failure.exceptionOrNull()?.message) 71 | verify { vectorStore.add(any()) } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/vector/starter/VectorAgentRoutingSpecsResolverAutoConfigurationTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import org.eclipse.lmos.router.core.AgentRoutingSpecsProvider 10 | import org.eclipse.lmos.router.vector.VectorSearchClient 11 | import org.junit.jupiter.api.Assertions.assertNotNull 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | import org.springframework.ai.vectorstore.VectorStore 15 | 16 | class VectorAgentRoutingSpecsResolverAutoConfigurationTest { 17 | private lateinit var springVectorSearchClientProperties: SpringVectorSearchClientProperties 18 | private lateinit var properties: VectorAgentRoutingSpecsResolverProperties 19 | private lateinit var vectorAgentRoutingSpecsResolverAutoConfiguration: VectorAgentRoutingSpecsResolverAutoConfiguration 20 | 21 | @BeforeEach 22 | fun setUp() { 23 | springVectorSearchClientProperties = mockk() 24 | properties = mockk() 25 | vectorAgentRoutingSpecsResolverAutoConfiguration = 26 | VectorAgentRoutingSpecsResolverAutoConfiguration(springVectorSearchClientProperties, properties) 27 | } 28 | 29 | @Test 30 | fun `test vectorSearchClient`() { 31 | val vectorStore = mockk() 32 | val result = vectorAgentRoutingSpecsResolverAutoConfiguration.vectorSearchClient(vectorStore) 33 | 34 | assertNotNull(result) 35 | } 36 | 37 | @Test 38 | fun `test vectorSeedClient`() { 39 | val vectorStore = mockk() 40 | val result = vectorAgentRoutingSpecsResolverAutoConfiguration.vectorSeedClient(vectorStore) 41 | 42 | assertNotNull(result) 43 | } 44 | 45 | @Test 46 | fun `test agentRoutingSpecsProvider`() { 47 | val specFilePath = "src/test/resources/agentRoutingSpecs.json" 48 | every { properties.specFilePath } returns specFilePath 49 | val result = vectorAgentRoutingSpecsResolverAutoConfiguration.agentRoutingSpecsProvider() 50 | 51 | assertNotNull(result) 52 | } 53 | 54 | @Test 55 | fun `test vectorAgentRoutingSpecsResolver`() { 56 | val agentRoutingSpecsProvider = mockk() 57 | val vectorSearchClient = mockk() 58 | val result = 59 | vectorAgentRoutingSpecsResolverAutoConfiguration.vectorAgentRoutingSpecsResolver( 60 | agentRoutingSpecsProvider, 61 | vectorSearchClient, 62 | ) 63 | 64 | assertNotNull(result) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/kotlin/org/eclipse/lmos/router/vector/starter/VectorAgentRoutingSpecsResolverPropertiesTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector.starter 6 | 7 | import org.junit.jupiter.api.Assertions.assertEquals 8 | import org.junit.jupiter.api.Test 9 | 10 | class VectorAgentRoutingSpecsResolverPropertiesTest { 11 | @Test 12 | fun `test default property value`() { 13 | val properties = VectorAgentRoutingSpecsResolverProperties() 14 | assertEquals("", properties.specFilePath, "Default specFilePath should be an empty string") 15 | } 16 | 17 | @Test 18 | fun `test property value set correctly`() { 19 | val expectedPath = "/path/to/spec/file" 20 | val properties = VectorAgentRoutingSpecsResolverProperties(expectedPath) 21 | assertEquals(expectedPath, properties.specFilePath, "specFilePath should be set correctly") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.context.ApplicationListener=QdrantVectorConfigurationListener -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "order-agent", 4 | "description": "This agent is responsible for order management", 5 | "version": "1.0.0", 6 | "addresses": [ 7 | { 8 | "protocol": "http", 9 | "uri": "http://localhost:8080" 10 | } 11 | ], 12 | "capabilities": [ 13 | { 14 | "name": "order-status", 15 | "description": "This capability is responsible for order status tracking.", 16 | "version": "1.0.0" 17 | }, 18 | { 19 | "name": "order-cancel", 20 | "description": "This capability is responsible for order cancellation.", 21 | "version": "1.0.0" 22 | } 23 | ] 24 | }, 25 | { 26 | "name": "offer-agent", 27 | "description": "This agent is responsible for offer recommendation, cross-sell, and up-sell", 28 | "version": "1.0.0", 29 | "addresses": [ 30 | { 31 | "protocol": "http", 32 | "uri": "http://localhost:8080" 33 | } 34 | ], 35 | "capabilities": [ 36 | { 37 | "name": "offer-recommendation", 38 | "description": "This capability is responsible for offer recommendation.", 39 | "version": "1.0.0" 40 | } 41 | ] 42 | } 43 | ] -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | ai: 3 | openai: 4 | api-key: ${OPENAI_API_KEY} 5 | embedding: 6 | options: 7 | model: text-embedding-3-small 8 | metadata-mode: none 9 | vectorstore: 10 | qdrant: 11 | collection-name: test 12 | host: localhost 13 | port: 6334 14 | route: 15 | agent: 16 | vector: 17 | spec-file-path: src/test/resources/agentRoutingSpecs.json -------------------------------------------------------------------------------- /lmos-router-vector-spring-boot-starter/src/test/resources/seed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "text": "I want to buy a car", 4 | "agentName": "offer-agent" 5 | }, 6 | { 7 | "text": "Suggest me a new phone", 8 | "agentName": "offer-agent" 9 | }, 10 | { 11 | "text": "I am looking for a new laptop", 12 | "agentName": "offer-agent" 13 | }, 14 | { 15 | "text": "I want to cancel my order", 16 | "agentName": "order-agent" 17 | }, 18 | { 19 | "text": "What is the status of my order", 20 | "agentName": "order-agent" 21 | } 22 | ] -------------------------------------------------------------------------------- /lmos-router-vector/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | dependencies { 6 | api(project(":lmos-router-core")) 7 | implementation("org.slf4j:slf4j-api:2.0.17") 8 | implementation("io.ktor:ktor-client-cio-jvm:3.1.2") 9 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1") 10 | testImplementation("org.testcontainers:ollama:1.20.6") 11 | } 12 | -------------------------------------------------------------------------------- /lmos-router-vector/src/main/kotlin/org/eclipse/lmos/router/vector/Models.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import kotlinx.serialization.ExperimentalSerializationApi 8 | import kotlinx.serialization.Serializable 9 | import kotlinx.serialization.json.JsonNames 10 | import org.eclipse.lmos.router.core.Context 11 | 12 | /** 13 | * A class representing a request to the VectorSearchClient. 14 | */ 15 | open class VectorSearchClientRequest(val query: String, val context: Context) 16 | 17 | /** 18 | * A class representing a response from the VectorSearchClient. 19 | */ 20 | open class VectorSearchClientResponse(val text: String, val agentName: String) 21 | 22 | /** 23 | * A class representing a request to the VectorSeedClient. 24 | */ 25 | @Serializable 26 | open class VectorSeedRequest(val agentName: String, val text: String) 27 | 28 | /** 29 | * An exception thrown by the VectorClient. 30 | * 31 | * @param message The exception message. 32 | */ 33 | class VectorClientException(message: String, reason: Exception? = null) : Exception(message, reason) 34 | 35 | /** 36 | * Constants for the VectorRoute. 37 | */ 38 | class VectorRouteConstants { 39 | companion object { 40 | const val AGENT_FIELD_NAME = "agentName" 41 | } 42 | } 43 | 44 | /** 45 | * The default properties for the EmbeddingClient. 46 | * 47 | * @property url The URL of the embedding service. It defaults to "http://localhost:11434/api/embeddings" of Ollama. 48 | * @property model The model to use for embedding. It defaults to "all-minilm" of Ollama. 49 | */ 50 | data class DefaultEmbeddingClientProperties( 51 | val url: String = "http://localhost:11434/api/embeddings", 52 | val model: String = "all-minilm", 53 | ) 54 | 55 | /** 56 | * An implementation of the EmbeddingClient that uses OpenAI embeddings. 57 | * 58 | * @property apiKey The API key for OpenAI. It defaults to the OPENAI_API_KEY environment variable. 59 | * @property model The model to use for embedding. It defaults to "text-embedding-3-large". 60 | * @property batchSize The batch size for embedding. It defaults to 300. 61 | */ 62 | class OpenAIEmbeddingClientProperties( 63 | val url: String = "https://api.openai.com/v1/embeddings", 64 | val model: String = "text-embedding-3-large", 65 | val batchSize: Int = 300, 66 | val apiKey: String = System.getenv("OPENAI_API_KEY"), 67 | ) 68 | 69 | /** 70 | * Request to embed text using the OpenAI API. 71 | * 72 | * @property model The model to use for embedding. 73 | * @property input The list of strings to embed. 74 | */ 75 | @Serializable 76 | class OpenAIEmbeddingRequest(val model: String, val input: List) 77 | 78 | /** 79 | * Response from the OpenAI API for embedding text. 80 | * 81 | * @property object The object type. It is "list" for a list of embeddings. 82 | * @property data The list of embeddings. 83 | * @property model The model used for embedding. 84 | * @property usage The usage statistics. 85 | */ 86 | @Serializable 87 | class OpenAIEmbeddingResponse(val `object`: String, val data: List, val model: String, val usage: OpenAIEmbeddingUsage) 88 | 89 | /** 90 | * Data class for the embedding response from the OpenAI API. 91 | * 92 | * @property embedding The embedding. 93 | * @property index The index of the embedding. 94 | * @property object The object type. It is "embedding" for an embedding. 95 | */ 96 | @Serializable 97 | class OpenAIEmbeddingData(val embedding: List, val index: Int, val `object`: String) 98 | 99 | /** 100 | * Usage class for the embedding response from the OpenAI API. 101 | * 102 | * @property promptTokens The number of tokens in the prompt. 103 | * @property totalTokens The total number of tokens. 104 | */ 105 | @Serializable 106 | class OpenAIEmbeddingUsage 107 | @OptIn(ExperimentalSerializationApi::class) 108 | constructor( 109 | @OptIn(ExperimentalSerializationApi::class)@JsonNames("prompt_tokens")val promptTokens: Int, 110 | @OptIn(ExperimentalSerializationApi::class)@JsonNames("total_tokens") val totalTokens: Int, 111 | ) 112 | -------------------------------------------------------------------------------- /lmos-router-vector/src/main/kotlin/org/eclipse/lmos/router/vector/Utils.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import kotlin.math.sqrt 8 | 9 | /** 10 | * Calculates the cosine similarity between two vectors. 11 | * 12 | * @param other The other vector. 13 | * @return The cosine similarity. 14 | */ 15 | fun List.cosineSimilarity(other: List): Double { 16 | require(this.size == other.size) { "Vectors must be of the same length" } 17 | 18 | val dotProduct = this.zip(other).sumOf { (a, b) -> a * b } 19 | val magnitudeA = sqrt(this.sumOf { it * it }) 20 | val magnitudeB = sqrt(other.sumOf { it * it }) 21 | 22 | return if (magnitudeA != 0.0 && magnitudeB != 0.0) { 23 | dotProduct / (magnitudeA * magnitudeB) 24 | } else { 25 | 0.0 // If either vector is zero, the similarity is undefined; return 0.0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lmos-router-vector/src/main/kotlin/org/eclipse/lmos/router/vector/VectorAgentRoutingSpecsResolver.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import org.eclipse.lmos.router.core.* 8 | 9 | /** 10 | * An implementation of AgentSpecResolver that resolves agent specs using a vector similarity search. 11 | * 12 | * @param agentRoutingSpecsProvider The agent specs provider. 13 | * @param vectorSearchClient The vector search client. 14 | */ 15 | open class VectorAgentRoutingSpecsResolver( 16 | override val agentRoutingSpecsProvider: AgentRoutingSpecsProvider, 17 | private val vectorSearchClient: VectorSearchClient = 18 | DefaultVectorClient( 19 | DefaultVectorClientProperties( 20 | seedJsonFilePath = System.getenv("VECTOR_SEED_JSON_FILE_PATH"), 21 | ), 22 | ), 23 | ) : AgentRoutingSpecsResolver { 24 | override fun resolve( 25 | context: Context, 26 | input: UserMessage, 27 | ): Result { 28 | return resolve(setOf(), context, input) 29 | } 30 | 31 | override fun resolve( 32 | filters: Set, 33 | context: Context, 34 | input: UserMessage, 35 | ): Result { 36 | return try { 37 | val agentSpecs = agentRoutingSpecsProvider.provide(filters).getOrThrow() 38 | val result = 39 | vectorSearchClient.find( 40 | VectorSearchClientRequest(input.content, context), 41 | agentSpecs, 42 | ).getOrThrow() 43 | 44 | Success(agentSpecs.firstOrNull { it.name == result?.agentName }) 45 | } catch (e: Exception) { 46 | Failure(AgentRoutingSpecResolverException("Failed to resolve agent spec", e)) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lmos-router-vector/src/main/kotlin/org/eclipse/lmos/router/vector/VectorClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import io.ktor.client.* 8 | import kotlinx.serialization.Serializable 9 | import kotlinx.serialization.json.Json 10 | import org.eclipse.lmos.router.core.* 11 | import java.io.File 12 | 13 | /** 14 | * A client for searching similar vectors. 15 | * 16 | * The [find] method takes a [VectorSearchClientRequest] and a set of [AgentRoutingSpec]s and returns the most similar document based on the cosine similarity of the embeddings. 17 | */ 18 | interface VectorSearchClient { 19 | fun find( 20 | request: VectorSearchClientRequest, 21 | agentRoutingSpecs: Set, 22 | ): Result 23 | } 24 | 25 | /** 26 | * A client for seeding vectors. 27 | * 28 | * The [seed] method takes a list of [VectorSeedRequest]s and seeds the documents. 29 | */ 30 | interface VectorSeedClient { 31 | fun seed(documents: List): Result 32 | } 33 | 34 | /** 35 | * The default implementation of the VectorSearchClient and VectorSeedClient. 36 | * 37 | * This implementation keeps a list of documents and searches for the most similar document based on the cosine similarity of the embeddings. 38 | * The documents are seeded using the seed method and kept in memory. 39 | * This implementation is not meant for production use and is only for demonstration purposes. 40 | * 41 | * @param vectorClientProperties The properties for the client. 42 | * @param embeddingClient The embedding client. 43 | * 44 | * @see VectorSearchClient 45 | * @see VectorSeedClient 46 | * @see EmbeddingClient 47 | */ 48 | class DefaultVectorClient( 49 | private val vectorClientProperties: DefaultVectorClientProperties, 50 | private val embeddingClient: EmbeddingClient = 51 | DefaultEmbeddingClient( 52 | HttpClient(), 53 | DefaultEmbeddingClientProperties(), 54 | ), 55 | ) : VectorSearchClient, VectorSeedClient { 56 | private val documents = mutableListOf() 57 | 58 | init { 59 | if (vectorClientProperties.seedJsonFilePath.isNotEmpty()) { 60 | val jsonFile = File(vectorClientProperties.seedJsonFilePath) 61 | val json = jsonFile.readText() 62 | val vectorSeedRequests = Json.decodeFromString>(json) 63 | seed(vectorSeedRequests).getOrThrow() 64 | } 65 | } 66 | 67 | override fun find( 68 | request: VectorSearchClientRequest, 69 | agentRoutingSpecs: Set, 70 | ): Result { 71 | return try { 72 | val embeddings = embeddingClient.embed(request.query).getOrThrow() 73 | val result = 74 | documents.filter { agentRoutingSpecs.any { agentRoutingSpec -> agentRoutingSpec.name == it.agentName } } 75 | .sortedByDescending { embeddings.cosineSimilarity(it.vector) } 76 | .take(vectorClientProperties.limit) 77 | .map { VectorSearchClientResponse(it.text, it.agentName) } 78 | Success(result.groupBy { it.agentName }.maxByOrNull { it.value.size }?.value?.first()) 79 | } catch (e: Exception) { 80 | Failure(VectorClientException("Failed to find documents", e)) 81 | } 82 | } 83 | 84 | override fun seed(documents: List): Result { 85 | return try { 86 | val batchEmbeddings = embeddingClient.batchEmbed(documents.map { it.text }).getOrThrow() 87 | Success( 88 | batchEmbeddings.forEachIndexed { index, embeddings -> 89 | this.documents.add( 90 | DefaultVectorDocument( 91 | documents[index].text, 92 | embeddings, 93 | documents[index].agentName, 94 | ), 95 | ) 96 | }, 97 | ) 98 | } catch (e: Exception) { 99 | Failure(VectorClientException("Failed to seed documents", e)) 100 | } 101 | } 102 | } 103 | 104 | /** 105 | * The default properties for the VectorClient. 106 | */ 107 | data class DefaultVectorClientProperties( 108 | val seedJsonFilePath: String, 109 | val limit: Int = 5, 110 | ) 111 | 112 | /** 113 | * The default vector document. 114 | */ 115 | data class DefaultVectorDocument( 116 | val text: String, 117 | val vector: List, 118 | val agentName: String, 119 | ) 120 | 121 | /** 122 | * The default embedding response. 123 | */ 124 | @Serializable 125 | data class DefaultEmbeddingResponse( 126 | val embedding: List, 127 | ) 128 | -------------------------------------------------------------------------------- /lmos-router-vector/src/test/kotlin/SampleVectorFlow.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import io.ktor.client.* 8 | import org.eclipse.lmos.router.core.* 9 | import org.junit.jupiter.api.AfterAll 10 | import org.junit.jupiter.api.BeforeAll 11 | import org.junit.jupiter.api.Test 12 | import org.testcontainers.ollama.OllamaContainer 13 | 14 | class SampleVectorFlow { 15 | @Test 16 | fun `test sample flow`() { 17 | val agentRoutingSpecsProvider = 18 | JsonAgentRoutingSpecsProvider(jsonFilePath = "src/test/resources/agentRoutingSpecs.json") 19 | val vectorAgentRoutingSpecsResolver = 20 | VectorAgentRoutingSpecsResolver( 21 | agentRoutingSpecsProvider, 22 | DefaultVectorClient( 23 | DefaultVectorClientProperties(seedJsonFilePath = "src/test/resources/seed.json"), 24 | embeddingClient, 25 | ), 26 | ) 27 | val context = Context(listOf(AssistantMessage("Hello"))) 28 | 29 | // The input to test whether offer-agent is resolved 30 | val input = UserMessage("Can you help me find a new phone?") 31 | val result = vectorAgentRoutingSpecsResolver.resolve(context, input) 32 | assert(result is Success) 33 | assert((result as Success).getOrNull()?.name == "offer-agent") 34 | 35 | // The input to test whether order-agent is resolved 36 | val input2 = UserMessage("I would like to cancel my pizza order") 37 | val result2 = vectorAgentRoutingSpecsResolver.resolve(context, input2) 38 | assert(result2 is Success) 39 | assert((result2 as Success).getOrNull()?.name == "order-agent") 40 | } 41 | 42 | companion object { 43 | private lateinit var container: OllamaContainer 44 | private lateinit var embeddingClient: EmbeddingClient 45 | 46 | @JvmStatic 47 | @BeforeAll 48 | fun setup() { 49 | container = OllamaContainer("ollama/ollama:0.1.26") 50 | container.start() 51 | container.execInContainer( 52 | "ollama", 53 | "pull", 54 | DefaultEmbeddingClientProperties().model, 55 | ) 56 | embeddingClient = 57 | DefaultEmbeddingClient( 58 | HttpClient(), 59 | DefaultEmbeddingClientProperties(container.endpoint + "/api/embeddings"), 60 | ) 61 | val ping = embeddingClient.embed("Hello").getOrNull() 62 | require(ping != null) { 63 | "Ollama is not running. Please start Ollama to run this test. " + 64 | "Also, make sure you have 'all-minilm' installed in your Ollama." 65 | } 66 | } 67 | 68 | @JvmStatic 69 | @AfterAll 70 | fun tearDownAll() { 71 | container.stop() 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lmos-router-vector/src/test/kotlin/org/eclipse/lmos/router/vector/CosineSimilarityTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import org.junit.jupiter.api.Assertions.assertEquals 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.assertThrows 10 | import kotlin.math.sqrt 11 | 12 | class CosineSimilarityTest { 13 | @Test 14 | fun `cosine similarity of vectors with different lengths throws exception`() { 15 | // Given 16 | val vectorA = listOf(1.0, 2.0, 3.0) 17 | val vectorB = listOf(4.0, 5.0) 18 | 19 | // When & Then 20 | val exception = 21 | assertThrows { 22 | vectorA.cosineSimilarity(vectorB) 23 | } 24 | assertEquals("Vectors must be of the same length", exception.message) 25 | } 26 | 27 | @Test 28 | fun `cosine similarity of orthogonal vectors is zero`() { 29 | // Given 30 | val vectorA = listOf(1.0, 0.0) 31 | val vectorB = listOf(0.0, 1.0) 32 | 33 | // When 34 | val result = vectorA.cosineSimilarity(vectorB) 35 | 36 | // Then 37 | assertEquals(0.0, result, 1e-9) 38 | } 39 | 40 | @Test 41 | fun `cosine similarity of parallel vectors is one`() { 42 | // Given 43 | val vectorA = listOf(1.0, 1.0) 44 | val vectorB = listOf(2.0, 2.0) 45 | 46 | // When 47 | val result = vectorA.cosineSimilarity(vectorB) 48 | 49 | // Then 50 | assertEquals(1.0, result, 1e-9) 51 | } 52 | 53 | @Test 54 | fun `cosine similarity of zero vector and any vector is zero`() { 55 | // Given 56 | val vectorA = listOf(0.0, 0.0) 57 | val vectorB = listOf(1.0, 1.0) 58 | 59 | // When 60 | val result = vectorA.cosineSimilarity(vectorB) 61 | 62 | // Then 63 | assertEquals(0.0, result, 1e-9) 64 | } 65 | 66 | @Test 67 | fun `cosine similarity of two non-zero vectors`() { 68 | // Given 69 | val vectorA = listOf(1.0, 2.0, 3.0) 70 | val vectorB = listOf(4.0, -5.0, 6.0) 71 | 72 | // When 73 | val result = vectorA.cosineSimilarity(vectorB) 74 | 75 | // Then 76 | val expectedDotProduct = 1 * 4 + 2 * -5 + 3 * 6 // 4 - 10 + 18 = 12.0 77 | val expectedMagnitudeA = sqrt((1 * 1 + 2 * 2 + 3 * 3).toDouble()) // sqrt(1+4+9) = sqrt(14) 78 | val expectedMagnitudeB = sqrt((4 * 4 + -5 * -5 + 6 * 6).toDouble()) // sqrt(16+25+36) = sqrt(77) 79 | val expectedResult = expectedDotProduct / (expectedMagnitudeA * expectedMagnitudeB) 80 | 81 | assertEquals(expectedResult, result, 1e-9) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lmos-router-vector/src/test/kotlin/org/eclipse/lmos/router/vector/VectorClientTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package org.eclipse.lmos.router.vector 6 | 7 | import io.mockk.mockk 8 | import kotlinx.serialization.json.Json 9 | import org.eclipse.lmos.router.core.Context 10 | import org.junit.jupiter.api.Assertions.assertEquals 11 | import org.junit.jupiter.api.BeforeEach 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.assertThrows 14 | 15 | class VectorSearchClientRequestTest { 16 | private lateinit var context: Context 17 | 18 | @BeforeEach 19 | fun setUp() { 20 | context = mockk() 21 | } 22 | 23 | @Test 24 | fun `test initialization`() { 25 | val query = "sample query" 26 | val request = VectorSearchClientRequest(query, context) 27 | 28 | assertEquals(query, request.query) 29 | assertEquals(context, request.context) 30 | } 31 | } 32 | 33 | class VectorSearchClientResponseTest { 34 | @Test 35 | fun `test initialization`() { 36 | val text = "sample text" 37 | val agentName = "agent007" 38 | val response = VectorSearchClientResponse(text, agentName) 39 | 40 | assertEquals(text, response.text) 41 | assertEquals(agentName, response.agentName) 42 | } 43 | } 44 | 45 | class VectorSeedRequestTest { 46 | @Test 47 | fun `test initialization and serialization`() { 48 | val text = "sample text" 49 | val agentName = "agent007" 50 | val request = VectorSeedRequest(agentName, text) 51 | 52 | assertEquals(agentName, request.agentName) 53 | assertEquals(text, request.text) 54 | 55 | val jsonString = Json.encodeToString(VectorSeedRequest.serializer(), request) 56 | val decodedRequest = Json.decodeFromString(jsonString) 57 | 58 | assertEquals(request.agentName, decodedRequest.agentName) 59 | assertEquals(request.text, decodedRequest.text) 60 | } 61 | } 62 | 63 | class VectorClientExceptionTest { 64 | @Test 65 | fun `test exception message`() { 66 | val message = "An error occurred" 67 | val exception = VectorClientException(message) 68 | 69 | assertEquals(message, exception.message) 70 | } 71 | 72 | @Test 73 | fun `test exception throwing`() { 74 | val message = "An error occurred" 75 | 76 | val exception = 77 | assertThrows { 78 | throw VectorClientException(message) 79 | } 80 | 81 | assertEquals(message, exception.message) 82 | } 83 | } 84 | 85 | class VectorRouteConstantsTest { 86 | @Test 87 | fun `test constants`() { 88 | assertEquals("agentName", VectorRouteConstants.AGENT_FIELD_NAME) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /lmos-router-vector/src/test/resources/agentRoutingSpecs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "order-agent", 4 | "description": "This agent is responsible for order management", 5 | "version": "1.0.0", 6 | "addresses": [{ 7 | "protocol": "http", 8 | "uri": "http://localhost:8080" 9 | }], 10 | "capabilities": [ 11 | { 12 | "name": "order-status", 13 | "description": "This capability is responsible for order status tracking.", 14 | "version": "1.0.0" 15 | }, 16 | { 17 | "name": "order-cancel", 18 | "description": "This capability is responsible for order cancellation.", 19 | "version": "1.0.0" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "offer-agent", 25 | "description": "This agent is responsible for offer recommendation, cross-sell, and up-sell", 26 | "version": "1.0.0", 27 | "addresses": [{ 28 | "protocol": "http", 29 | "uri": "http://localhost:8080" 30 | }], 31 | "capabilities": [ 32 | { 33 | "name": "offer-recommendation", 34 | "description": "This capability is responsible for offer recommendation.", 35 | "version": "1.0.0" 36 | } 37 | ] 38 | } 39 | ] -------------------------------------------------------------------------------- /lmos-router-vector/src/test/resources/seed.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "text": "I want to buy a car", 3 | "agentName": "offer-agent" 4 | }, 5 | { 6 | "text": "Suggest me a new phone", 7 | "agentName": "offer-agent" 8 | }, 9 | { 10 | "text": "I am looking for a new laptop", 11 | "agentName": "offer-agent" 12 | }, 13 | { 14 | "text": "I want to cancel my order", 15 | "agentName": "order-agent" 16 | }, 17 | { 18 | "text": "What is the status of my order", 19 | "agentName": "order-agent" 20 | }, 21 | { 22 | "text": "I am planning to return my order", 23 | "agentName": "order-agent" 24 | }, 25 | { 26 | "text": "Could you please help me with my order", 27 | "agentName": "order-agent" 28 | }] -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Deutsche Telekom AG and others 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | plugins { 6 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" 7 | } 8 | 9 | rootProject.name = "org.eclipse.lmos-router" 10 | include("lmos-router-core") 11 | include("lmos-router-llm") 12 | include("lmos-router-llm-spring-boot-starter") 13 | include("lmos-router-vector") 14 | include("lmos-router-vector-spring-boot-starter") 15 | include("lmos-router-llm-in-spring-cloud-gateway-demo") 16 | include("benchmarks") 17 | include("lmos-router-hybrid") 18 | include("lmos-router-hybrid-spring-boot-starter") 19 | --------------------------------------------------------------------------------