├── .formatter.exs ├── .github └── workflows │ └── elixir.yaml ├── .gitignore ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── lib ├── avalon.ex └── avalon │ ├── application.ex │ ├── conversation.ex │ ├── conversation │ └── message.ex │ ├── error.ex │ ├── provider.ex │ ├── tool.ex │ ├── workflow.ex │ └── workflow │ ├── node.ex │ ├── router.ex │ └── visualiser.ex ├── mix.exs ├── mix.lock └── test ├── avalon_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yaml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | # Define workflow that runs when changes are pushed to the 4 | # `main` branch or pushed to a PR branch that targets the `main` 5 | # branch. Change the branch name if your project uses a 6 | # different name for the main branch like "master" or "production". 7 | on: 8 | push: 9 | branches: [ "main" ] # adapt branch for project 10 | pull_request: 11 | branches: [ "main" ] # adapt branch for project 12 | 13 | # Sets the ENV `MIX_ENV` to `test` for running tests 14 | env: 15 | MIX_ENV: test 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-latest 23 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 24 | strategy: 25 | # Specify the OTP and Elixir versions to use when building 26 | # and running the workflow steps. 27 | matrix: 28 | otp: ['27.3.1'] 29 | elixir: ['1.18.3'] 30 | steps: 31 | # Step: Setup Elixir + Erlang image as the base. 32 | - name: Set up Elixir 33 | uses: erlef/setup-beam@v1 34 | with: 35 | otp-version: ${{matrix.otp}} 36 | elixir-version: ${{matrix.elixir}} 37 | 38 | # Step: Check out the code. 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | 42 | # Step: Define how to cache deps. Restores existing cache if present. 43 | - name: Cache deps 44 | id: cache-deps 45 | uses: actions/cache@v3 46 | env: 47 | cache-name: cache-elixir-deps 48 | with: 49 | path: deps 50 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 51 | restore-keys: | 52 | ${{ runner.os }}-mix-${{ env.cache-name }}- 53 | 54 | # Step: Define how to cache the `_build` directory. After the first run, 55 | # this speeds up tests runs a lot. This includes not re-compiling our 56 | # project's downloaded deps every run. 57 | - name: Cache compiled build 58 | id: cache-build 59 | uses: actions/cache@v3 60 | env: 61 | cache-name: cache-compiled-build 62 | with: 63 | path: _build 64 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 65 | restore-keys: | 66 | ${{ runner.os }}-mix-${{ env.cache-name }}- 67 | ${{ runner.os }}-mix- 68 | 69 | # Step: Conditionally bust the cache when job is re-run. 70 | # Sometimes, we may have issues with incremental builds that are fixed by 71 | # doing a full recompile. In order to not waste dev time on such trivial 72 | # issues (while also reaping the time savings of incremental builds for 73 | # *most* day-to-day development), force a full recompile only on builds 74 | # that are retried. 75 | - name: Clean to rule out incremental build as a source of flakiness 76 | if: github.run_attempt != '1' 77 | run: | 78 | mix deps.clean --all 79 | mix clean 80 | shell: sh 81 | 82 | # Step: Download project dependencies. If unchanged, uses 83 | # the cached version. 84 | - name: Install dependencies 85 | run: mix deps.get 86 | 87 | # Step: Compile the project treating any warnings as errors. 88 | # Customize this step if a different behavior is desired. 89 | - name: Compiles without warnings 90 | run: mix compile --warnings-as-errors 91 | 92 | # Step: Check that the checked in code has already been formatted. 93 | # This step fails if something was found unformatted. 94 | # Customize this step as desired. 95 | - name: Check Formatting 96 | run: mix format --check-formatted 97 | 98 | # Step: Execute the tests. 99 | - name: Run tests 100 | run: mix test 101 | 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | avalon-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cigrainger 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | chris@amplified.ai. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Avalon 2 | 3 | Avalon is a standardisation framework for building agentic workflows in Elixir. It provides composable primitive building blocks that enable the creation of flexible, modular AI agent systems. In Elixir, the language is the framework. 4 | 5 | ## Overview 6 | 7 | Avalon focuses on standardisation rather than batteries-included functionality. The library defines behaviors, structs, and protocols that serve as the foundation for building agentic workflows. It's designed to be highly pluggable, allowing you to integrate with various LLM providers, tools, and custom components. 8 | 9 | Taking inspiration from established frameworks like LangGraph and SmolAgents, Avalon brings structured agentic workflows to the Elixir ecosystem while embracing Elixir's strengths in building concurrent, fault-tolerant systems. 10 | 11 | ## Table of Contents 12 | 13 | - [Installation](#installation) 14 | - [Architecture](#architecture) 15 | - [Core Components](#core-components) 16 | - [Design Philosophy](#design-philosophy) 17 | - [Roadmap](#roadmap) 18 | - [Contributing](#contributing) 19 | - [License](#license) 20 | 21 | ## Installation 22 | 23 | If available in Hex, the package can be installed by adding `avalon` to your list of dependencies in `mix.exs`: 24 | 25 | ```elixir 26 | def deps do 27 | [ 28 | {:avalon, "~> 0.1.0"} 29 | ] 30 | end 31 | ``` 32 | 33 | ## Architecture 34 | 35 | Avalon is architected around the concept of composable, standardised components for agentic workflows. The key architectural patterns include: 36 | 37 | ### Conversational Agents 38 | 39 | Provides standardised structures for representing, storing, and manipulating conversations with LLMs through a `Conversation` module and related components. 40 | 41 | ### Provider Abstraction 42 | 43 | A pluggable system for interacting with different LLM providers (OpenAI, Anthropic, etc.) through a unified interface. 44 | 45 | ### Tool Integration 46 | 47 | Defines standards for tool creation, allowing agents to interact with external systems and data sources. 48 | 49 | ### Workflows 50 | 51 | A graph-based workflow engine that enables complex, multi-step agent processes with: 52 | - Nodes representing individual units of work 53 | - Edges defining the flow between nodes 54 | - Routers determining conditional paths 55 | - Visualisers for workflow inspection and debugging 56 | 57 | ## Core Components 58 | 59 | Avalon consists of several core components: 60 | 61 | **Conversations and Messages**: Structured representations of agent-LLM interactions. 62 | 63 | **Providers**: Abstractions for different LLM services. 64 | 65 | **Tools**: Standardised interfaces for agent capabilities, like the included Calculator tool. 66 | 67 | **Workflows**: Directed graphs of operations that can be composed into complex agent behaviors. 68 | 69 | **Hooks**: Extensible points for custom behavior at various stages of execution. 70 | 71 | ## Design Philosophy 72 | 73 | Avalon's design follows these key principles: 74 | 75 | 1. **Standardisation over Implementation**: Focus on defining clear interfaces that can be implemented in various ways. 76 | 77 | 2. **Composability**: All components should be easily composed into more complex systems. 78 | 79 | 3. **Pluggability**: Support for easy integration with different LLM providers, tools, and custom components. 80 | 81 | 4. **Elixir-native**: Embracing Elixir's concurrency model and OTP principles for reliable agentic systems. 82 | 83 | 5. **Separation of Concerns**: Clear boundaries between different aspects of agent functionality. 84 | 85 | ## Roadmap 86 | 87 | Avalon is a work in progress. The following features are planned for future releases: 88 | 89 | - **Process-based agents**: Leveraging OTP for agent lifecycles. 90 | - **Persistent Storage**: Standard building blocks for conversation and state persistence. 91 | - **Multi-agent Coordination**: Better support for multiple agents working together. 92 | - **Observability and Monitoring**: Tools for tracking and debugging agent operations. 93 | - **Memory Management**: More sophisticated context and knowledge management. 94 | - **Advanced Workflow Patterns**: Additional workflow patterns and templates. 95 | 96 | ## Contributing 97 | 98 | Contributions are welcome! Since Avalon is focused on standardisation, contributions that enhance the interfaces, documentation, and examples are particularly valuable. 99 | 100 | ## License 101 | 102 | Copyright 2025 Christopher Grainger 103 | 104 | Licensed under the Apache License, Version 2.0 (the "License"); 105 | you may not use this file except in compliance with the License. 106 | You may obtain a copy of the License at 107 | 108 | http://www.apache.org/licenses/LICENSE-2.0 109 | 110 | Unless required by applicable law or agreed to in writing, software 111 | distributed under the License is distributed on an "AS IS" BASIS, 112 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 113 | See the License for the specific language governing permissions and 114 | limitations under the License. 115 | -------------------------------------------------------------------------------- /lib/avalon.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon do 2 | @moduledoc """ 3 | Documentation for `Avalon`. 4 | """ 5 | alias Avalon.Conversation.Message 6 | 7 | @chat_schema [ 8 | provider: [ 9 | type: :atom, 10 | required: true, 11 | doc: "The provider to use for chat completion", 12 | type_spec: :module 13 | ], 14 | provider_opts: [ 15 | type: :keyword_list, 16 | default: [], 17 | doc: "Options to pass to the provider" 18 | ] 19 | ] 20 | @doc """ 21 | Send an array of `Message.t()` to an LLM and get back a `Message.t()`. 22 | 23 | ## Options 24 | #{NimbleOptions.docs(@chat_schema)} 25 | """ 26 | @spec chat([Messages.t()], keyword) :: 27 | {:ok, Message.t()} | {:error, NimbleOptions.error()} 28 | def chat(messages, opts \\ []) do 29 | case NimbleOptions.validate(opts, @chat_schema) do 30 | {:ok, opts} -> opts[:provider].chat(messages, opts[:provider_opts]) 31 | {:error, error} -> {:error, error} 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/avalon/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Starts a worker by calling: Avalon.Worker.start_link(arg) 12 | # {Avalon.Worker, arg} 13 | ] 14 | 15 | # See https://hexdocs.pm/elixir/Supervisor.html 16 | # for other strategies and supported options 17 | opts = [strategy: :one_for_one, name: Avalon.Supervisor] 18 | Supervisor.start_link(children, opts) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/avalon/conversation.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Conversation do 2 | @moduledoc """ 3 | Represents a conversation history with an LLM, supporting message management, 4 | branching, persistence, and analytics. 5 | """ 6 | 7 | alias Avalon.Conversation.Message 8 | 9 | defstruct [ 10 | # Unique identifier for the conversation 11 | id: nil, 12 | # List of messages in chronological order 13 | messages: [], 14 | # Current total token count 15 | token_count: 0, 16 | # Conversation-level metadata 17 | metadata: %{}, 18 | # Optional system prompt 19 | system_prompt: nil, 20 | # For branching conversations 21 | parent_id: nil, 22 | # Child conversation IDs 23 | branches: [], 24 | # Hooks that run before adding a message 25 | pre_hooks: [], 26 | # Hooks that run after adding a message 27 | post_hooks: [], 28 | # Analytics data 29 | analytics: %{} 30 | ] 31 | 32 | @type hook :: 33 | (t(), Message.t() -> {:ok, t()} | {:error, term()}) 34 | | {module(), atom()} 35 | 36 | @type t :: %__MODULE__{ 37 | id: String.t() | nil, 38 | messages: [Message.t()], 39 | token_count: non_neg_integer(), 40 | metadata: map(), 41 | system_prompt: Message.t() | nil, 42 | parent_id: String.t() | nil, 43 | branches: [String.t()], 44 | pre_hooks: [hook()], 45 | post_hooks: [hook()], 46 | analytics: map() 47 | } 48 | 49 | @doc """ 50 | Creates a new conversation with optional system prompt and hooks. 51 | 52 | ## Options 53 | * `:system_prompt` - Initial system prompt 54 | * `:messages` - List of messages to initialize the conversation with 55 | * `:pre_hooks` - List of hooks to run before adding messages 56 | * `:post_hooks` - List of hooks to run after adding messages 57 | """ 58 | def new(opts \\ []) do 59 | %__MODULE__{ 60 | id: generate_id(), 61 | messages: opts[:messages] || [], 62 | system_prompt: opts[:system_prompt] && ensure_system_message(opts[:system_prompt]), 63 | pre_hooks: opts[:pre_hooks] || [], 64 | post_hooks: opts[:post_hooks] || [] 65 | } 66 | end 67 | 68 | @doc """ 69 | Adds a message to the conversation, running hooks and updating analytics. 70 | """ 71 | def add_message(%__MODULE__{} = conv, %Message{} = message) do 72 | with {:ok, conv} <- run_pre_message_hooks(conv, message), 73 | {:ok, conv} <- do_add_message(conv, message), 74 | {:ok, conv} <- run_post_message_hooks(conv, message) do 75 | {:ok, conv} 76 | end 77 | end 78 | 79 | defp run_pre_message_hooks(conv, message) do 80 | Enum.reduce_while(conv.pre_hooks, {:ok, conv}, fn 81 | hook when is_function(hook, 2) -> 82 | case hook.(conv, message) do 83 | {:ok, new_conv} -> {:cont, {:ok, new_conv}} 84 | {:error, _} = err -> {:halt, err} 85 | end 86 | 87 | {module, function} when is_atom(module) and is_atom(function) -> 88 | case apply(module, function, [conv, message]) do 89 | {:ok, new_conv} -> {:cont, {:ok, new_conv}} 90 | {:error, _} = err -> {:halt, err} 91 | end 92 | end) 93 | end 94 | 95 | defp run_post_message_hooks(conv, original_message) do 96 | Enum.reduce_while(conv.post_hooks, {:ok, conv}, fn 97 | hook when is_function(hook, 3) -> 98 | case hook.(conv, original_message, conv.messages) do 99 | {:ok, new_conv} -> {:cont, {:ok, new_conv}} 100 | {:error, _} = err -> {:halt, err} 101 | end 102 | 103 | {module, function} when is_atom(module) and is_atom(function) -> 104 | case apply(module, function, [conv, original_message, conv.messages]) do 105 | {:ok, new_conv} -> {:cont, {:ok, new_conv}} 106 | {:error, _} = err -> {:halt, err} 107 | end 108 | end) 109 | end 110 | 111 | defp do_add_message(conv, message), do: %__MODULE__{conv | messages: conv.messages ++ [message]} 112 | 113 | defp generate_id, do: UUID.uuid4() 114 | 115 | # Ensures a system message is properly formatted, converting strings to system messages 116 | # and validating existing system messages. 117 | # 118 | # ## Examples 119 | # 120 | # iex> ensure_system_message("You are a helpful assistant") 121 | # %Message{role: :system, content: "You are a helpful assistant"} 122 | # 123 | # iex> ensure_system_message(%Message{role: :system, content: "Be helpful"}) 124 | # %Message{role: :system, content: "Be helpful"} 125 | # 126 | # iex> ensure_system_message(%Message{role: :user, content: "Hi"}) 127 | # ** (ArgumentError) System messages must have role: :system 128 | defp ensure_system_message(prompt) when is_binary(prompt) do 129 | Message.new(role: :system, content: prompt) 130 | end 131 | 132 | defp ensure_system_message(%Message{role: :system, content: content} = message) 133 | when is_binary(content) do 134 | if is_nil(message.id), do: Message.new(Map.from_struct(message)), else: message 135 | end 136 | 137 | defp ensure_system_message(%Message{role: role}) do 138 | raise ArgumentError, "System messages must have role: :system, got: #{inspect(role)}" 139 | end 140 | 141 | defp ensure_system_message(invalid) do 142 | raise ArgumentError, 143 | "System prompt must be a string or system message, got: #{inspect(invalid)}" 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/avalon/conversation/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Conversation.Message do 2 | @moduledoc """ 3 | Represents a message in a conversation with an LLM. 4 | """ 5 | 6 | @derive {JSON.Encoder, 7 | only: [:id, :role, :content, :name, :tool_calls, :tool_call_id, :created_at, :metadata]} 8 | defstruct [ 9 | # UUID for the message 10 | :id, 11 | # Required - no default 12 | :role, 13 | # Can be nil if there are tool_calls 14 | :content, 15 | # Optional - for tool identification 16 | :name, 17 | # Optional - list of tool calls from assistant 18 | :tool_calls, 19 | # Optional - for matching tool responses 20 | :tool_call_id, 21 | # DateTime when message was created 22 | :created_at, 23 | # Optional - for provider-specific extensions 24 | :metadata 25 | ] 26 | 27 | @type role :: :system | :user | :assistant | :tool 28 | 29 | @type tool_call :: %{ 30 | id: String.t(), 31 | type: :function, 32 | function: %{ 33 | name: String.t(), 34 | # JSON string 35 | arguments: String.t() 36 | } 37 | } 38 | 39 | @type t :: %__MODULE__{ 40 | id: String.t(), 41 | role: role(), 42 | content: String.t() | map() | [map()] | nil, 43 | name: String.t() | nil, 44 | tool_calls: [tool_call()] | nil, 45 | tool_call_id: String.t() | nil, 46 | created_at: DateTime.t(), 47 | metadata: map() 48 | } 49 | 50 | @new_opts_schema [ 51 | role: [ 52 | type: :atom, 53 | required: true, 54 | doc: "The role of the message", 55 | type_spec: {:in, ~w[system user assistant tool]a} 56 | ], 57 | content: [ 58 | type: {:or, [:map, :string, {:list, :map}]}, 59 | doc: "The content of the message" 60 | ], 61 | name: [ 62 | type: :string, 63 | doc: "Optional name for tool identification" 64 | ], 65 | tool_calls: [ 66 | type: {:list, {:map, :any, :any}}, 67 | doc: "Optional list of tool calls" 68 | ], 69 | tool_call_id: [ 70 | type: :string, 71 | doc: "Optional tool call ID" 72 | ], 73 | metadata: [ 74 | type: :map, 75 | doc: "Optional provider-specific metadata" 76 | ] 77 | ] 78 | 79 | @doc """ 80 | Creates a new message with an auto-generated ID and timestamp. 81 | 82 | ## Options 83 | 84 | #{NimbleOptions.docs(@new_opts_schema)} 85 | """ 86 | def new(attrs) do 87 | case NimbleOptions.validate(attrs, @new_opts_schema) do 88 | {:ok, attrs} -> 89 | %__MODULE__{ 90 | id: UUID.uuid4(), 91 | created_at: DateTime.utc_now(), 92 | role: attrs[:role], 93 | content: attrs[:content], 94 | name: attrs[:name], 95 | tool_calls: attrs[:tool_calls], 96 | tool_call_id: attrs[:tool_call_id], 97 | metadata: attrs[:metadata] || %{} 98 | } 99 | 100 | {:error, errors} -> 101 | {:error, errors} 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/avalon/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Error do 2 | @moduledoc """ 3 | Standardized error structure for Avalon operations. 4 | """ 5 | defstruct [ 6 | # Error category (e.g. :provider, :validation, :tool) 7 | :type, 8 | # Human-readable error description 9 | :message, 10 | # Additional context (raw response, params, provider, etc.) 11 | :metadata 12 | ] 13 | 14 | @type t :: %__MODULE__{ 15 | type: atom(), 16 | message: String.t(), 17 | metadata: map() 18 | } 19 | end 20 | -------------------------------------------------------------------------------- /lib/avalon/provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Provider do 2 | @moduledoc """ 3 | Defines the standardized interface for LLM service providers in Avalon. 4 | 5 | ## Overview 6 | 7 | The Provider behavior establishes a contract for integrating different Large Language Model 8 | services (OpenAI, Anthropic, Azure, etc.) into Avalon's framework. It breaks down the LLM 9 | interaction process into discrete, composable steps that enable: 10 | 11 | - Consistent error handling across providers 12 | - Fine-grained telemetry and observability 13 | - Standardized message formatting 14 | - Schema validation for structured outputs 15 | - Tool/function calling capabilities 16 | 17 | ## Provider Lifecycle 18 | 19 | The standard chat flow follows these steps: 20 | 21 | 1. **Validation** - Verify options against provider-specific requirements 22 | 2. **Preparation** - Transform messages into provider-specific format 23 | 3. **Request** - Execute the API call to the LLM service 24 | 4. **Response Processing** - Convert provider response to standard Message format 25 | 5. **Structure Validation** - Ensure response conforms to expected schema (if specified) 26 | 27 | ## Usage 28 | 29 | To implement a provider, use the `Avalon.Provider` behavior and implement the required 30 | callbacks: 31 | 32 | ``` 33 | defmodule MyApp.CustomProvider do 34 | use Avalon.Provider 35 | 36 | @impl true 37 | def validate_options(opts) do 38 | # Validate provider-specific options 39 | end 40 | 41 | @impl true 42 | def prepare_chat_body(messages, opts) do 43 | # Transform messages to provider format 44 | end 45 | 46 | # Implement other required callbacks... 47 | end 48 | ``` 49 | 50 | Then use your provider with the conversation API: 51 | 52 | ``` 53 | Avalon.chat(conversation, provider: MyApp.CustomProvider, provider_opts: [temperature: 0.7]) 54 | ``` 55 | 56 | ## Model Discovery 57 | 58 | Providers expose their available models through the `list_models/0` and `get_model/1` callbacks, 59 | allowing clients to discover capabilities and constraints without hardcoding provider-specific 60 | knowledge. 61 | 62 | ## Structured Output 63 | 64 | The `ensure_structure/2` callback enables validation of LLM responses against a schema, 65 | supporting use cases that require structured data rather than free-form text. 66 | 67 | ## Tool Integration 68 | 69 | Providers can expose external tools to LLMs through the `format_tool/1` callback, which 70 | transforms Avalon tool modules into provider-specific function specifications. 71 | 72 | ## Error Handling 73 | 74 | All callbacks return either `{:ok, result}` or `{:error, Error.t()}`, providing consistent 75 | error propagation throughout the framework. The default implementation of `chat/2` handles 76 | this error chain automatically. 77 | 78 | ## Extensibility 79 | 80 | The behavior's design allows for extension through: 81 | 82 | - Custom telemetry spans 83 | - Provider-specific message metadata 84 | - Capability-based feature detection 85 | - Streaming responses (when supported) 86 | """ 87 | 88 | alias Avalon.Error 89 | alias Avalon.Conversation.Message 90 | alias Avalon.Provider.Model 91 | 92 | @doc """ 93 | Returns all available models from this provider. 94 | 95 | ## Expected Behavior 96 | - Should return a list of `Avalon.Provider.Model` structs 97 | - Each model must include capability metadata 98 | - Models should be sorted by recommended usage 99 | """ 100 | @callback list_models() :: [Model.t()] 101 | 102 | @doc """ 103 | Retrieves detailed model information by name. 104 | 105 | ## Parameters 106 | - `name`: String representing the model identifier 107 | 108 | ## Returns 109 | - `{:ok, Model.t()}` if found 110 | - `{:error, :not_found}` for unknown models 111 | 112 | ## Implementation Notes 113 | - Should validate model availability against provider's current offerings 114 | """ 115 | @callback get_model(name :: String.t()) :: {:ok, Model.t()} | {:error, :not_found} 116 | 117 | @doc """ 118 | Converts provider-specific response format to standardized Message struct. 119 | 120 | ## Parameters 121 | - `response`: Raw API response from provider 122 | 123 | ## Expected Behavior 124 | - Handles both success and error response formats 125 | - Extracts tool calls when present 126 | - Preserves provider-specific metadata in message struct 127 | 128 | ## Error Handling 129 | - Must return `{:error, Error.t()}` for malformed responses 130 | """ 131 | @callback response_to_message(response :: map()) :: {:ok, Message.t()} | {:error, Error.t()} 132 | 133 | @doc """ 134 | Validates provider-specific options and configuration. 135 | 136 | ## Parameters 137 | - `opts`: Keyword list of options passed to chat/2 138 | 139 | ## Returns 140 | - `{:ok, validated_opts}` with normalized options 141 | - `{:error, Error.t()}` for invalid configurations 142 | """ 143 | @callback validate_options(opts :: keyword()) :: {:ok, keyword()} | {:error, Error.t()} 144 | 145 | @doc """ 146 | Transforms conversation messages into provider-specific API request body. 147 | 148 | ## Parameters 149 | - `messages`: List of `Message.t()` structs 150 | - `opts`: Validated provider options 151 | 152 | ## Expected Behavior 153 | - Converts message structs to provider's required format 154 | - Handles special cases like system prompts and tool messages 155 | - Adds provider-specific parameters from options 156 | """ 157 | @callback prepare_chat_body(messages :: [Message.t()], opts :: keyword()) :: 158 | {:ok, map()} | {:error, Error.t()} 159 | 160 | @doc """ 161 | Executes the API request to the LLM provider. 162 | 163 | ## Parameters 164 | - `body`: Prepared request body from prepare_chat_body/2 165 | - `opts`: Validated provider options 166 | 167 | ## Expected Behavior 168 | - Handles HTTP transport and network errors 169 | - Implements retry logic for rate limits 170 | - Returns raw provider response for processing 171 | """ 172 | @callback request_chat(body :: map(), opts :: keyword()) :: 173 | {:ok, map()} | {:error, Error.t()} 174 | 175 | @doc """ 176 | Validates and transforms message structure against output schema. 177 | 178 | ## Parameters 179 | - `message`: Message.t() from response_to_message/1 180 | - `opts`: Original provider options containing output_schema 181 | 182 | ## Expected Behavior 183 | - When output_schema is present: 184 | - Validates message content against schema 185 | - Transforms content to match schema types 186 | - Returns original message when no schema specified 187 | 188 | ## Error Handling 189 | - Returns {:error, Error.t()} with validation details on failure 190 | """ 191 | @callback ensure_structure(message :: Message.t(), opts :: keyword()) :: 192 | {:ok, Message.t()} | {:error, Error.t()} 193 | 194 | @doc """ 195 | Converts a tool module into provider-specific schema format. 196 | 197 | ## Parameters 198 | - `tool_module`: Module implementing Avalon.Tool behavior 199 | 200 | ## Expected Behavior 201 | - Returns tool specification in provider's required format 202 | - Should handle parameter validation schemas 203 | """ 204 | @callback format_tool(tool_module :: module()) :: map() 205 | 206 | @doc """ 207 | Generates vector embeddings for text inputs using the provider's embedding model. 208 | 209 | ## Parameters 210 | - `text`: Either a single string or a list of strings to be embedded 211 | - `opts`: Keyword list of provider-specific options 212 | 213 | ## Returns 214 | - `{:ok, embeddings}` where embeddings is either: 215 | - A single vector (list of floats) for a single text input 216 | - A list of vectors for multiple text inputs 217 | - `{:error, Error.t()}` on failure 218 | 219 | ## Options 220 | Provider implementations may support options such as: 221 | - `:model` - Specific embedding model to use 222 | - `:dimensions` - Desired vector dimensionality 223 | - `:normalize` - Whether to normalize vectors 224 | 225 | ## Example 226 | ```elixir 227 | {:ok, embedding} = Provider.embed("Sample text", model: "text-embedding-3-large") 228 | {:ok, embeddings} = Provider.embed(["Text one", "Text two"], dimensions: 1536) 229 | """ 230 | @callback embed(text :: String.t() | [String.t()], opts :: keyword()) :: 231 | {:ok, embeddings :: [float()] | [[float()]]} | {:error, Error.t()} 232 | 233 | @doc """ 234 | Transcribes audio content into text using the provider's speech-to-text capabilities. 235 | 236 | ## Parameters 237 | - `audio`: Audio data in one of the following formats: 238 | - Binary content of an audio file 239 | - `opts`: Keyword list of provider-specific options 240 | 241 | ## Returns 242 | - `{:ok, transcript}` where transcript is a map containing: 243 | - `:text` - The full transcribed text 244 | - `:segments` - (Optional) List of timed segments with speaker identification 245 | - `:metadata` - (Optional) Additional provider-specific information 246 | - `{:error, Error.t()}` on failure 247 | 248 | ## Options 249 | Provider implementations may support options such as: 250 | - `:model` - Specific transcription model to use 251 | - `:language` - Target language code (e.g., "en", "fr") 252 | - `:prompt` - Context hint to improve transcription accuracy 253 | - `:format` - Audio format specification if not auto-detected 254 | - `:timestamps` - Whether to include word or segment timestamps 255 | - `:speakers` - Number of speakers to identify (for diarization) 256 | 257 | ## Example 258 | ```elixir 259 | {:ok, transcript} = Provider.transcribe("recording.mp3", language: "en", speakers: 2) 260 | IO.puts(transcript.text) 261 | ``` 262 | 263 | ## Notes 264 | - Supported audio formats vary by provider but typically include MP3, WAV, FLAC, etc. 265 | - Maximum audio duration and file size limits are provider-specific 266 | - For long audio files, some providers may require streaming implementations 267 | """ 268 | @callback transcribe(audio :: binary() | String.t(), opts :: keyword()) :: 269 | {:ok, map()} | {:error, Error.t()} 270 | 271 | @optional_callbacks [ 272 | format_tool: 1, 273 | embed: 2, 274 | transcribe: 2 275 | ] 276 | 277 | defmacro __using__(_opts) do 278 | quote do 279 | @behaviour Avalon.Provider 280 | 281 | @doc """ 282 | Processes a complete chat interaction with the LLM provider, including telemetry instrumentation. 283 | 284 | This function orchestrates the entire chat lifecycle by sequentially calling the provider's 285 | implementation of each required callback: 286 | 287 | 1. `validate_options/1` - Validates and normalizes provider options 288 | 2. `prepare_chat_body/2` - Transforms messages into provider-specific format 289 | 3. `request_chat/2` - Executes the API request to the LLM 290 | 4. `response_to_message/1` - Converts provider response to standard Message format 291 | 5. `ensure_structure/2` - Validates response against schema (if specified) 292 | 293 | ## Parameters 294 | - `messages`: List of `Message.t()` structs representing the conversation history 295 | - `opts`: Keyword list of provider-specific options 296 | 297 | ## Returns 298 | - `{:ok, message, metadata}` - Successful response with timing information 299 | - `{:error, error, metadata}` - Error with details and timing information 300 | 301 | ## Telemetry 302 | Emits the following telemetry events: 303 | - `[:avalon, :provider, :chat, :start]` - When chat begins 304 | - `[:avalon, :provider, :chat, :stop]` - When chat completes 305 | - `[:avalon, :provider, :chat, :exception]` - If an exception occurs 306 | 307 | Each step in the process also emits its own telemetry events. 308 | """ 309 | def chat(messages, opts) do 310 | start_time = System.monotonic_time() 311 | 312 | :telemetry.span([:avalon, :provider, :chat], %{messages: length(messages)}, fn -> 313 | result = 314 | with {:ok, opts} <- with_telemetry(:validate_options, [opts], &validate_options/1), 315 | {:ok, body} <- 316 | with_telemetry(:prepare_chat_body, [messages, opts], &prepare_chat_body/2), 317 | {:ok, response} <- with_telemetry(:request_chat, [body, opts], &request_chat/2), 318 | {:ok, message} <- 319 | with_telemetry(:response_to_message, [response], &response_to_message/1), 320 | {:ok, message} <- 321 | with_telemetry(:ensure_structure, [message, opts], &ensure_structure/2) do 322 | {:ok, message} 323 | else 324 | {:error, error} -> {:error, error} 325 | end 326 | 327 | # Calculate duration for telemetry but don't include it in the return value 328 | duration = System.monotonic_time() - start_time 329 | {result, %{duration: duration}} 330 | end) 331 | end 332 | 333 | defp with_telemetry(:validate_options, [opts], fun) do 334 | metadata = %{ 335 | provider: __MODULE__, 336 | step: :validate_options, 337 | opts: opts 338 | } 339 | 340 | :telemetry.span( 341 | [:avalon, :provider, :validate_options], 342 | metadata, 343 | fn -> 344 | result = fun.(opts) 345 | {result, metadata} 346 | end 347 | ) 348 | end 349 | 350 | defp with_telemetry(step, [arg1, arg2], fun) do 351 | metadata = %{ 352 | provider: __MODULE__, 353 | step: step, 354 | opts: arg2 355 | } 356 | 357 | :telemetry.span( 358 | [:avalon, :provider, step], 359 | metadata, 360 | fn -> 361 | result = fun.(arg1, arg2) 362 | {result, metadata} 363 | end 364 | ) 365 | end 366 | 367 | defp with_telemetry(step, [arg], fun) do 368 | metadata = %{ 369 | provider: __MODULE__, 370 | step: step, 371 | opts: %{} 372 | } 373 | 374 | :telemetry.span( 375 | [:avalon, :provider, step], 376 | metadata, 377 | fn -> 378 | result = fun.(arg) 379 | {result, metadata} 380 | end 381 | ) 382 | end 383 | end 384 | end 385 | end 386 | -------------------------------------------------------------------------------- /lib/avalon/tool.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Tool do 2 | @moduledoc """ 3 | Defines the behaviour for tools that can be called by LLMs. 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | @behaviour Avalon.Tool 9 | Module.register_attribute(__MODULE__, :tool_parameters, accumulate: false, persist: true) 10 | 11 | # Add compile-time validation hook 12 | @after_compile __MODULE__ 13 | 14 | def __after_compile__(_env, _bytecode) do 15 | _ = NimbleOptions.new!(@tool_parameters) 16 | end 17 | 18 | @before_compile Avalon.Tool 19 | end 20 | end 21 | 22 | defmacro __before_compile__(_env) do 23 | quote do 24 | @impl true 25 | def parameters, do: @tool_parameters 26 | end 27 | end 28 | 29 | @callback name() :: String.t() 30 | @callback description() :: String.t() 31 | @callback parameters() :: Keyword.t() 32 | @callback run(args :: map()) :: {:ok, String.t()} | {:error, term()} 33 | end 34 | -------------------------------------------------------------------------------- /lib/avalon/workflow.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Workflow do 2 | alias Avalon.Workflow 3 | alias Avalon.Workflow.Node 4 | alias Avalon.Workflow.Router 5 | 6 | defstruct id: nil, 7 | # Map of node_id => node_module 8 | nodes: %{}, 9 | # List of {from_id, to_id} tuples 10 | edges: [], 11 | # Map of router_id => routes 12 | routes: %{}, 13 | # Workflow-level metadata 14 | metadata: %{}, 15 | # Shared execution context 16 | context: %{} 17 | 18 | def new(opts \\ []) do 19 | %__MODULE__{ 20 | id: UUID.uuid4(), 21 | metadata: opts[:metadata] || %{}, 22 | context: opts[:context] || %{} 23 | } 24 | end 25 | 26 | @doc """ 27 | Adds a node to the workflow. Validates that the module implements the Node behaviour. 28 | """ 29 | def add_node(%Workflow{} = workflow, id, module, opts \\ []) do 30 | with :ok <- validate_node_module(module), 31 | :ok <- validate_unique_node(workflow, id) do 32 | nodes = Map.put(workflow.nodes, id, {module, opts}) 33 | %{workflow | nodes: nodes} 34 | end 35 | end 36 | 37 | @doc """ 38 | Adds a directed edge between two nodes. Validates nodes exist. 39 | """ 40 | def add_edge(%Workflow{} = workflow, from_id, :halt) do 41 | with :ok <- validate_node_exists(workflow, from_id) do 42 | %{workflow | edges: [{from_id, :halt} | workflow.edges]} 43 | end 44 | end 45 | 46 | def add_edge(%Workflow{} = workflow, from_id, to_id) do 47 | with :ok <- validate_node_exists(workflow, from_id), 48 | :ok <- validate_node_exists(workflow, to_id) do 49 | %{workflow | edges: [{from_id, to_id} | workflow.edges]} 50 | end 51 | end 52 | 53 | def add_router(%Workflow{} = workflow, id, module, routes) do 54 | with :ok <- validate_router_module(module), 55 | :ok <- validate_unique_node(workflow, id), 56 | :ok <- validate_routes(workflow, routes) do 57 | nodes = Map.put(workflow.nodes, id, {module, []}) 58 | %{workflow | nodes: nodes, routes: Map.put(workflow.routes, id, routes)} 59 | end 60 | end 61 | 62 | @doc """ 63 | Validates the entire workflow DAG structure and configuration. 64 | """ 65 | def validate(%Workflow{} = workflow) do 66 | with :ok <- validate_all_nodes_connected(workflow), 67 | :ok <- validate_single_root(workflow), 68 | :ok <- validate_no_orphans(workflow) do 69 | {:ok, workflow} 70 | end 71 | end 72 | 73 | @doc """ 74 | Builds and validates a complete workflow. 75 | """ 76 | def build(%Workflow{} = workflow) do 77 | case validate(workflow) do 78 | {:ok, workflow} -> {:ok, workflow} 79 | {:error, reason} -> {:error, reason} 80 | end 81 | end 82 | 83 | # Private validation functions 84 | 85 | defp validate_node_module(module) do 86 | if implements?(module, Node) do 87 | :ok 88 | else 89 | {:error, "Module #{inspect(module)} must implement Node behaviour"} 90 | end 91 | end 92 | 93 | defp validate_router_module(module) do 94 | if implements?(module, Router) do 95 | :ok 96 | else 97 | {:error, "Module #{inspect(module)} must implement Router behaviour"} 98 | end 99 | end 100 | 101 | defp validate_unique_node(%Workflow{} = workflow, id) do 102 | if Map.has_key?(workflow.nodes, id) do 103 | {:error, "Node #{id} already exists"} 104 | else 105 | :ok 106 | end 107 | end 108 | 109 | defp validate_node_exists(%Workflow{} = workflow, id) when id != :halt do 110 | if Map.has_key?(workflow.nodes, id) do 111 | :ok 112 | else 113 | {:error, "Node #{id} does not exist"} 114 | end 115 | end 116 | 117 | defp validate_all_nodes_connected(%Workflow{} = workflow) do 118 | nodes = Map.keys(workflow.nodes) 119 | 120 | edge_nodes = 121 | Enum.flat_map(workflow.edges, fn {from, to} -> 122 | if to == :halt, do: [from], else: [from, to] 123 | end) 124 | |> MapSet.new() 125 | 126 | if MapSet.size(MapSet.new(nodes)) == MapSet.size(edge_nodes) do 127 | :ok 128 | else 129 | {:error, "Not all nodes are connected"} 130 | end 131 | end 132 | 133 | defp validate_single_root(%Workflow{} = workflow) do 134 | incoming_edges = 135 | workflow.edges 136 | |> Enum.map(fn {_from, to} -> to end) 137 | |> MapSet.new() 138 | 139 | root_nodes = 140 | workflow.nodes 141 | |> Map.keys() 142 | |> Enum.reject(fn node -> MapSet.member?(incoming_edges, node) end) 143 | 144 | case root_nodes do 145 | [_root] -> :ok 146 | [] -> {:error, "Workflow has no root node"} 147 | _multiple -> {:error, "Workflow has multiple root nodes"} 148 | end 149 | end 150 | 151 | defp validate_no_orphans(%Workflow{} = workflow) do 152 | nodes = MapSet.new(Map.keys(workflow.nodes)) 153 | 154 | edge_nodes = 155 | workflow.edges 156 | |> Enum.flat_map(fn {from, to} -> 157 | if to == :halt, do: [from], else: [from, to] 158 | end) 159 | |> MapSet.new() 160 | 161 | orphans = MapSet.difference(nodes, edge_nodes) 162 | 163 | if MapSet.size(orphans) == 0 do 164 | :ok 165 | else 166 | {:error, "Orphaned nodes: #{inspect(MapSet.to_list(orphans))}"} 167 | end 168 | end 169 | 170 | defp implements?(module, behaviour) do 171 | behaviours = 172 | module.module_info(:attributes) 173 | |> Keyword.get(:behaviour, []) 174 | 175 | Enum.member?(behaviours, behaviour) 176 | end 177 | 178 | def execute(%Workflow{} = workflow) do 179 | with {:ok, start_node} <- find_start_node(workflow), 180 | {:ok, workflow} <- run_workflow_hooks(workflow, :pre_workflow), 181 | {:ok, workflow} <- execute_from_node(workflow, start_node), 182 | {:ok, workflow} <- run_workflow_hooks(workflow, :post_workflow) do 183 | {:ok, workflow} 184 | end 185 | end 186 | 187 | defp execute_from_node(workflow, node_id) do 188 | case execute_node(workflow, node_id) do 189 | {:ok, workflow} -> continue_execution(workflow, node_id) 190 | {:halt, workflow} -> {:ok, workflow} 191 | {:error, reason} -> {:error, reason} 192 | end 193 | end 194 | 195 | defp continue_execution(workflow, node_id) do 196 | {module, _opts} = workflow.nodes[node_id] 197 | 198 | if implements?(module, Avalon.Workflow.Router) do 199 | # For routers, use their routing decision 200 | case workflow.context[:router_decision] do 201 | nil -> {:error, "Router did not provide a routing decision"} 202 | :halt -> {:halt, workflow} 203 | next_node -> execute_from_node(workflow, next_node) 204 | end 205 | else 206 | # For regular nodes, continue with edge-based navigation 207 | case get_next_nodes(workflow, node_id) do 208 | [] -> 209 | {:ok, workflow} 210 | 211 | next_nodes -> 212 | # Execute each next node in sequence 213 | Enum.reduce_while(next_nodes, {:ok, workflow}, fn next_id, {:ok, acc} -> 214 | case execute_from_node(acc, next_id) do 215 | {:ok, new_acc} -> {:cont, {:ok, new_acc}} 216 | {:halt, new_acc} -> {:halt, {:halt, new_acc}} 217 | {:error, reason} -> {:halt, {:error, reason}} 218 | end 219 | end) 220 | end 221 | end 222 | end 223 | 224 | defp find_start_node(workflow) do 225 | incoming_edges = 226 | workflow.edges 227 | |> Enum.map(fn {_from, to} -> to end) 228 | |> MapSet.new() 229 | 230 | case workflow.nodes 231 | |> Map.keys() 232 | |> Enum.reject(&MapSet.member?(incoming_edges, &1)) do 233 | [start_node] -> {:ok, start_node} 234 | [] -> {:error, "No start node found"} 235 | multiple -> {:error, "Multiple start nodes found: #{inspect(multiple)}"} 236 | end 237 | end 238 | 239 | defp get_next_nodes(workflow, node_id) do 240 | workflow.edges 241 | |> Enum.filter(fn {from, to} -> from == node_id and to != :halt end) 242 | |> Enum.map(fn {_from, to} -> to end) 243 | end 244 | 245 | defp execute_node(workflow, node_id) do 246 | {module, _opts} = workflow.nodes[node_id] 247 | 248 | # Check if this is a router or a regular node 249 | if implements?(module, Avalon.Workflow.Router) do 250 | # Execute as router 251 | case module.route(workflow) do 252 | {:cont, next_node} -> 253 | # Store routing decision for later use 254 | updated_workflow = put_in(workflow.context[:router_decision], next_node) 255 | {:ok, updated_workflow} 256 | 257 | {:halt, result} -> 258 | {:halt, put_in(workflow.context[:halt_reason], result)} 259 | 260 | {:error, reason} -> 261 | {:error, reason} 262 | end 263 | else 264 | # Execute as regular node 265 | module.run(workflow, node_id) 266 | end 267 | end 268 | 269 | defp validate_routes(%Workflow{} = workflow, routes) do 270 | invalid_nodes = 271 | routes 272 | |> Map.values() 273 | |> Enum.reject(&(&1 == :halt)) 274 | |> Enum.reject(&Map.has_key?(workflow.nodes, &1)) 275 | 276 | if invalid_nodes == [] do 277 | :ok 278 | else 279 | {:error, "Invalid route targets: #{inspect(invalid_nodes)}"} 280 | end 281 | end 282 | 283 | defp run_workflow_hooks(workflow, hook_type) do 284 | hooks = Map.get(workflow, hook_type, []) 285 | 286 | Enum.reduce_while(hooks, {:ok, workflow}, fn hook, {:ok, acc} -> 287 | case hook_fun(hook, hook_type).(acc) do 288 | {:ok, new_workflow} -> {:cont, {:ok, new_workflow}} 289 | error -> {:halt, error} 290 | end 291 | end) 292 | end 293 | 294 | defp hook_fun(hook, hook_type) do 295 | case hook_type do 296 | :pre_workflow -> hook.pre_workflow 297 | :post_workflow -> hook.post_workflow 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /lib/avalon/workflow/node.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Workflow.Node do 2 | @moduledoc """ 3 | Behaviour for workflow nodes, operating on workflows. 4 | """ 5 | 6 | alias Avalon.Workflow 7 | 8 | @callback execute(workflow :: Workflow.t()) :: 9 | {:ok, Workflow.t()} | {:error, term()} 10 | 11 | @callback validate_input(workflow :: Workflow.t()) :: 12 | :ok | {:error, term()} 13 | 14 | @optional_callbacks [validate_input: 1] 15 | 16 | defmacro __using__(_opts) do 17 | quote do 18 | @behaviour Avalon.Workflow.Node 19 | 20 | def run(workflow, node_id) do 21 | with :ok <- validate_input(workflow), 22 | {:ok, workflow} <- run_hooks(workflow, node_id, :pre_hooks), 23 | {:ok, workflow} <- execute(workflow), 24 | {:ok, workflow} <- run_hooks(workflow, node_id, :post_hooks) do 25 | {:ok, workflow} 26 | end 27 | end 28 | 29 | def validate_input(_workflow), do: :ok 30 | 31 | defp run_hooks(workflow, node_id, hook_type) do 32 | {__MODULE__, node_opts} = workflow.nodes[node_id] 33 | 34 | case Keyword.validate(node_opts, pre_hooks: [], post_hooks: []) do 35 | {:ok, node_opts} -> 36 | hooks = Keyword.get(node_opts, hook_type, []) 37 | 38 | Enum.reduce_while(hooks, {:ok, workflow}, fn hook, {:ok, acc} -> 39 | case fun(hook, hook_type).(acc) do 40 | {:ok, new_workflow} -> {:cont, {:ok, new_workflow}} 41 | error -> {:halt, error} 42 | end 43 | end) 44 | 45 | {:error, error} -> 46 | {:halt, error} 47 | end 48 | end 49 | 50 | defp fun(hook, hook_type) do 51 | case hook_type do 52 | :pre_hooks -> hook.pre_node 53 | :post_hooks -> hook.post_node 54 | end 55 | end 56 | 57 | defoverridable validate_input: 1 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/avalon/workflow/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Workflow.Router do 2 | @moduledoc """ 3 | Defines the routing behaviour for workflow nodes, allowing for conditional 4 | execution paths and workflow termination. 5 | """ 6 | 7 | @doc """ 8 | Routes the input to the next node or terminates the workflow. 9 | 10 | ## Returns 11 | * `{:cont, next_node}` - Continue to the specified node 12 | * `{:halt, result}` - Terminate workflow with result 13 | * `{:error, reason}` - Stop workflow with error 14 | """ 15 | @callback route(workflow :: Workflow.t()) :: 16 | {:cont, atom()} | {:halt, term()} | {:error, term()} 17 | 18 | defmacro __using__(_opts) do 19 | quote do 20 | @behaviour Avalon.Workflow.Router 21 | 22 | def route(_workflow), do: {:error, "route/2 not implemented"} 23 | 24 | defoverridable route: 1 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/avalon/workflow/visualiser.ex: -------------------------------------------------------------------------------- 1 | defmodule Avalon.Workflow.Visualizer do 2 | @moduledoc """ 3 | Provides visualization capabilities for Avalon workflows. 4 | 5 | This module converts workflow structures into Mermaid flowchart diagrams, 6 | allowing easy visualization of workflow nodes, routers, and their connections. 7 | The generated diagrams display: 8 | 9 | - Nodes (as rectangles) 10 | - Routers (as rhombuses/diamonds) 11 | - Static connections between nodes 12 | - Dynamic routing paths with condition labels 13 | - Terminal points (halt conditions) 14 | """ 15 | 16 | @doc """ 17 | Converts an Avalon workflow into a Mermaid.js flowchart diagram. 18 | 19 | Takes a workflow structure and generates a string representation of the workflow 20 | as a Mermaid flowchart. The diagram shows the complete workflow topology including 21 | both static edges and dynamic routing decisions. 22 | 23 | ## Visual Representation 24 | 25 | * Regular nodes appear as rectangles `[Node]` 26 | * Router nodes appear as rhombuses/diamonds `{Router}` 27 | * Static connections are shown as simple arrows `-->` 28 | * Router paths are shown with condition labels `-- "condition" -->` 29 | * Terminal points (halt) appear as circles `((halt))` 30 | 31 | ## Examples 32 | 33 | iex> workflow = %Avalon.Workflow{ 34 | ...> nodes: %{ 35 | ...> start: {MyApp.StartNode, []}, 36 | ...> router: {MyApp.DecisionRouter, []}, 37 | ...> success: {MyApp.SuccessNode, []} 38 | ...> }, 39 | ...> edges: [{:start, :router}], 40 | ...> routes: %{ 41 | ...> router: %{ 42 | ...> :ok => :success, 43 | ...> :error => :halt 44 | ...> } 45 | ...> } 46 | ...> } 47 | iex> Avalon.Workflow.Visualizer.to_mermaid(workflow) 48 | \"\"\" 49 | flowchart TD 50 | start[\"Elixir.MyApp.StartNode\"] 51 | router{{\"Elixir.MyApp.DecisionRouter\"}} 52 | success[\"Elixir.MyApp.SuccessNode\"] 53 | start-->router 54 | router -- \"ok\" -->success 55 | router -- \"error\" -->((halt)) 56 | \"\"\" 57 | """ 58 | def to_mermaid(%Avalon.Workflow{nodes: nodes, edges: edges, routes: routes}) do 59 | """ 60 | flowchart TD 61 | #{generate_nodes(nodes)} 62 | #{generate_static_edges(edges)} 63 | #{generate_router_edges(routes)} 64 | """ 65 | end 66 | 67 | defp generate_nodes(nodes) do 68 | nodes 69 | |> Enum.map(fn {id, {module, _opts}} -> 70 | # Check if module is a router 71 | if implements_router?(module) do 72 | # Rhombus shape for routers 73 | " #{id}{{\"#{inspect(module)}\"}}" 74 | else 75 | # Rectangle for regular nodes 76 | " #{id}[\"#{inspect(module)}\"]" 77 | end 78 | end) 79 | |> Enum.join("\n") 80 | end 81 | 82 | defp generate_static_edges(edges) do 83 | edges 84 | |> Enum.map(fn {from, to} -> 85 | case to do 86 | :halt -> " #{from}-->((halt))" 87 | _ -> " #{from}-->#{to}" 88 | end 89 | end) 90 | |> Enum.join("\n") 91 | end 92 | 93 | defp generate_router_edges(routes) do 94 | routes 95 | |> Enum.flat_map(fn {router_id, route_map} -> 96 | route_map 97 | |> Enum.map(fn {result, target} -> 98 | # Non-breaking spaces 99 | label = result |> to_string() |> String.replace(" ", " ") 100 | 101 | case target do 102 | :halt -> "#{router_id} -- \"#{label}\" -->((halt))" 103 | _ -> "#{router_id} -- \"#{label}\" -->#{target}" 104 | end 105 | end) 106 | end) 107 | |> Enum.join("\n") 108 | end 109 | 110 | defp implements_router?(module) do 111 | :behaviour in module.module_info()[:attributes] && 112 | Avalon.Workflow.Router in module.module_info()[:attributes][:behaviour] 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Avalon.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :avalon, 7 | version: "0.1.0", 8 | elixir: "~> 1.18", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger], 17 | mod: {Avalon.Application, []} 18 | ] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:elixir_uuid, "~> 1.2"}, 24 | {:ex_doc, "~> 0.36", only: :dev, runtime: false}, 25 | {:nimble_json_schema, github: "elixir-avalon/nimble_json_schema"}, 26 | {:nimble_options, "~> 1.0"}, 27 | {:telemetry, "~> 1.0"} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 4 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 5 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 8 | "nimble_json_schema": {:git, "https://github.com/elixir-avalon/nimble_json_schema.git", "5b12ee3155d08347c823ce61b445c4e1b2a2d372", []}, 9 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 10 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 11 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 12 | } 13 | -------------------------------------------------------------------------------- /test/avalon_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AvalonTest do 2 | use ExUnit.Case 3 | doctest Avalon 4 | end 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------