├── .gitignore
├── .tool-versions
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── essays
├── 01_09_2024_launch
│ ├── blog.md
│ ├── llegos_diagram.png
│ ├── message_email_semantics.png
│ ├── nested_contract_net.png
│ ├── strongly_typed_messages.png
│ ├── strongly_typed_messages_2.png
│ └── tweets.md
└── 09_07_2023.md
├── llegos
├── __init__.py
├── logger.py
└── research.py
├── poetry.lock
├── pyproject.toml
├── research
└── snoop.ipynb
├── tests
├── conftest.py
├── test_0_research.py
├── test_1_dialogue.py
├── test_2_human_console.py
├── test_3_debate.py
├── test_4_map_reduce.py
├── test_5_contract_net.py
├── test_6_inner_critic.py
├── test_7_student_teacher.py
├── test_8_reflexion.py
└── test_9_crewai.py
└── wizard.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | .*.db
6 | .DS_Store
7 |
8 | # C extensions
9 | *.so
10 |
11 | # Distribution / packaging
12 | .Python
13 | build/
14 | develop-eggs/
15 | dist/
16 | downloads/
17 | eggs/
18 | .eggs/
19 | lib/
20 | lib64/
21 | parts/
22 | sdist/
23 | var/
24 | wheels/
25 | pip-wheel-metadata/
26 | share/python-wheels/
27 | *.egg-info/
28 | .installed.cfg
29 | *.egg
30 | MANIFEST
31 |
32 | # PyInstaller
33 | # Usually these files are written by a python script from a template
34 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
35 | *.manifest
36 | *.spec
37 |
38 | # Installer logs
39 | pip-log.txt
40 | pip-delete-this-directory.txt
41 |
42 | # Unit test / coverage reports
43 | htmlcov/
44 | .tox/
45 | .nox/
46 | .coverage
47 | .coverage.*
48 | .cache
49 | nosetests.xml
50 | coverage.xml
51 | *.cover
52 | *.py,cover
53 | .hypothesis/
54 | .pytest_cache/
55 |
56 | # Translations
57 | *.mo
58 | *.pot
59 |
60 | # Django stuff:
61 | *.log
62 | local_settings.py
63 | db.sqlite3
64 | db.sqlite3-journal
65 |
66 | # Flask stuff:
67 | instance/
68 | .webassets-cache
69 |
70 | # Scrapy stuff:
71 | .scrapy
72 |
73 | # Sphinx documentation
74 | docs/_build/
75 |
76 | # PyBuilder
77 | target/
78 |
79 | # Jupyter Notebook
80 | .ipynb_checkpoints
81 |
82 | # IPython
83 | profile_default/
84 | ipython_config.py
85 |
86 | # pyenv
87 | .python-version
88 |
89 | # pipenv
90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
93 | # install all needed dependencies.
94 | #Pipfile.lock
95 |
96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
97 | __pypackages__/
98 |
99 | # Celery stuff
100 | celerybeat-schedule
101 | celerybeat.pid
102 |
103 | # SageMath parsed files
104 | *.sage.py
105 |
106 | # Environments
107 | .env
108 | .venv
109 | env/
110 | venv/
111 | ENV/
112 | env.bak/
113 | venv.bak/
114 |
115 | # Spyder project settings
116 | .spyderproject
117 | .spyproject
118 |
119 | # Rope project settings
120 | .ropeproject
121 |
122 | # mkdocs documentation
123 | /site
124 |
125 | # mypy
126 | .mypy_cache/
127 | .dmypy.json
128 | dmypy.json
129 |
130 | # Pyre type checker
131 | .pyre/
132 |
133 | .idea
134 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | python 3.10.12
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.exclude": {
3 | "**/.git": true,
4 | "**/.svn": true,
5 | "**/.hg": true,
6 | "**/CVS": true,
7 | "**/.DS_Store": true,
8 | "**/Thumbs.db": true,
9 | "**/.ruby-lsp": true,
10 | ".venv/": true,
11 | ".DS_Store": true,
12 | "**/__pycache__": true,
13 | ".pytest_cache/": true,
14 | ".ruff_cache/": true
15 | },
16 | "[python]": {
17 | "editor.defaultFormatter": "ms-python.black-formatter",
18 | "editor.formatOnSave": true,
19 | "editor.codeActionsOnSave": {
20 | "source.fixAll": "explicit",
21 | "source.organizeImports": "explicit"
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ### GNU LESSER GENERAL PUBLIC LICENSE
2 |
3 | Version 3, 29 June 2007
4 |
5 | Copyright (C) 2007 Free Software Foundation, Inc.
6 |
7 |
8 | Everyone is permitted to copy and distribute verbatim copies of this
9 | license document, but changing it is not allowed.
10 |
11 | This version of the GNU Lesser General Public License incorporates the
12 | terms and conditions of version 3 of the GNU General Public License,
13 | supplemented by the additional permissions listed below.
14 |
15 | #### 0. Additional Definitions.
16 |
17 | As used herein, "this License" refers to version 3 of the GNU Lesser
18 | General Public License, and the "GNU GPL" refers to version 3 of the
19 | GNU General Public License.
20 |
21 | "The Library" refers to a covered work governed by this License, other
22 | than an Application or a Combined Work as defined below.
23 |
24 | An "Application" is any work that makes use of an interface provided
25 | by the Library, but which is not otherwise based on the Library.
26 | Defining a subclass of a class defined by the Library is deemed a mode
27 | of using an interface provided by the Library.
28 |
29 | A "Combined Work" is a work produced by combining or linking an
30 | Application with the Library. The particular version of the Library
31 | with which the Combined Work was made is also called the "Linked
32 | Version".
33 |
34 | The "Minimal Corresponding Source" for a Combined Work means the
35 | Corresponding Source for the Combined Work, excluding any source code
36 | for portions of the Combined Work that, considered in isolation, are
37 | based on the Application, and not on the Linked Version.
38 |
39 | The "Corresponding Application Code" for a Combined Work means the
40 | object code and/or source code for the Application, including any data
41 | and utility programs needed for reproducing the Combined Work from the
42 | Application, but excluding the System Libraries of the Combined Work.
43 |
44 | #### 1. Exception to Section 3 of the GNU GPL.
45 |
46 | You may convey a covered work under sections 3 and 4 of this License
47 | without being bound by section 3 of the GNU GPL.
48 |
49 | #### 2. Conveying Modified Versions.
50 |
51 | If you modify a copy of the Library, and, in your modifications, a
52 | facility refers to a function or data to be supplied by an Application
53 | that uses the facility (other than as an argument passed when the
54 | facility is invoked), then you may convey a copy of the modified
55 | version:
56 |
57 | - a) under this License, provided that you make a good faith effort
58 | to ensure that, in the event an Application does not supply the
59 | function or data, the facility still operates, and performs
60 | whatever part of its purpose remains meaningful, or
61 | - b) under the GNU GPL, with none of the additional permissions of
62 | this License applicable to that copy.
63 |
64 | #### 3. Object Code Incorporating Material from Library Header Files.
65 |
66 | The object code form of an Application may incorporate material from a
67 | header file that is part of the Library. You may convey such object
68 | code under terms of your choice, provided that, if the incorporated
69 | material is not limited to numerical parameters, data structure
70 | layouts and accessors, or small macros, inline functions and templates
71 | (ten or fewer lines in length), you do both of the following:
72 |
73 | - a) Give prominent notice with each copy of the object code that
74 | the Library is used in it and that the Library and its use are
75 | covered by this License.
76 | - b) Accompany the object code with a copy of the GNU GPL and this
77 | license document.
78 |
79 | #### 4. Combined Works.
80 |
81 | You may convey a Combined Work under terms of your choice that, taken
82 | together, effectively do not restrict modification of the portions of
83 | the Library contained in the Combined Work and reverse engineering for
84 | debugging such modifications, if you also do each of the following:
85 |
86 | - a) Give prominent notice with each copy of the Combined Work that
87 | the Library is used in it and that the Library and its use are
88 | covered by this License.
89 | - b) Accompany the Combined Work with a copy of the GNU GPL and this
90 | license document.
91 | - c) For a Combined Work that displays copyright notices during
92 | execution, include the copyright notice for the Library among
93 | these notices, as well as a reference directing the user to the
94 | copies of the GNU GPL and this license document.
95 | - d) Do one of the following:
96 | - 0) Convey the Minimal Corresponding Source under the terms of
97 | this License, and the Corresponding Application Code in a form
98 | suitable for, and under terms that permit, the user to
99 | recombine or relink the Application with a modified version of
100 | the Linked Version to produce a modified Combined Work, in the
101 | manner specified by section 6 of the GNU GPL for conveying
102 | Corresponding Source.
103 | - 1) Use a suitable shared library mechanism for linking with
104 | the Library. A suitable mechanism is one that (a) uses at run
105 | time a copy of the Library already present on the user's
106 | computer system, and (b) will operate properly with a modified
107 | version of the Library that is interface-compatible with the
108 | Linked Version.
109 | - e) Provide Installation Information, but only if you would
110 | otherwise be required to provide such information under section 6
111 | of the GNU GPL, and only to the extent that such information is
112 | necessary to install and execute a modified version of the
113 | Combined Work produced by recombining or relinking the Application
114 | with a modified version of the Linked Version. (If you use option
115 | 4d0, the Installation Information must accompany the Minimal
116 | Corresponding Source and Corresponding Application Code. If you
117 | use option 4d1, you must provide the Installation Information in
118 | the manner specified by section 6 of the GNU GPL for conveying
119 | Corresponding Source.)
120 |
121 | #### 5. Combined Libraries.
122 |
123 | You may place library facilities that are a work based on the Library
124 | side by side in a single library together with other library
125 | facilities that are not Applications and are not covered by this
126 | License, and convey such a combined library under terms of your
127 | choice, if you do both of the following:
128 |
129 | - a) Accompany the combined library with a copy of the same work
130 | based on the Library, uncombined with any other library
131 | facilities, conveyed under the terms of this License.
132 | - b) Give prominent notice with the combined library that part of it
133 | is a work based on the Library, and explaining where to find the
134 | accompanying uncombined form of the same work.
135 |
136 | #### 6. Revised Versions of the GNU Lesser General Public License.
137 |
138 | The Free Software Foundation may publish revised and/or new versions
139 | of the GNU Lesser General Public License from time to time. Such new
140 | versions will be similar in spirit to the present version, but may
141 | differ in detail to address new problems or concerns.
142 |
143 | Each version is given a distinguishing version number. If the Library
144 | as you received it specifies that a certain numbered version of the
145 | GNU Lesser General Public License "or any later version" applies to
146 | it, you have the option of following the terms and conditions either
147 | of that published version or of any later version published by the
148 | Free Software Foundation. If the Library as you received it does not
149 | specify a version number of the GNU Lesser General Public License, you
150 | may choose any version of the GNU Lesser General Public License ever
151 | published by the Free Software Foundation.
152 |
153 | If the Library as you received it specifies that a proxy can decide
154 | whether future versions of the GNU Lesser General Public License shall
155 | apply, that proxy's public statement of acceptance of any version is
156 | permanent authorization for you to choose that version for the
157 | Library.
158 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | ## Table of Contents
4 |
5 | ### Goals
6 |
7 | 1. Actors are a container for your agents that isolate state.
8 | 2. Actors have a mailbox and process messages one by one (to prevent race conditions).
9 | 3. Actors can load state on startup, persist state, and preserve state on shutdown.
10 | 4. Actors can run periodic tasks.
11 | 5. Actors can be composed into static and dynamic graphs.
12 |
13 | - [Llegos: A strongly-typed Python DSL for multi-agent systems](#llegos-a-strongly-typed-python-dsl-for-multi-agent-systems)
14 | - [Features](#features)
15 | - [Quick Start Guide](#quick-start-guide)
16 | - [Installation](#installation)
17 | - [Need help?](#need-help)
18 | - [Core Concepts](#core-concepts)
19 | - [Objects](#objects)
20 | - [Messages](#messages)
21 | - [Actors](#actors)
22 | - [Networks](#networks)
23 | - [FAQ](#faq)
24 | - [Contributing](#contributing)
25 | - [Is it any good?](#is-it-any-good)
26 |
27 | # Llegos: A strongly-typed Python DSL for multi-agent systems
28 |
29 | > *"More of a PyTorch, less of a Keras"
30 |
31 | Llegos is a DSL for developing multi-agent systems in Python. It offers a set of building blocks that can be used to create complex, multi-layered environments where different groups of agents can interact within their sub-contexts. Llegos is designed to be flexible and generalizable, and it is not tied to any specific way of calling LLMs. Any logic, any combination of libraries in any order can be used in your agent implementation. Llegos elevates your agents into a multi-agent system.
32 |
33 | You only need these 2 sentences to get an intuitive understanding of Llegos:
34 |
35 | 1. **llegos.Actors are a container for your agents.**
36 | 2. **"llegos.Actors send llegos.Messages and share llegos.Objects in llegos.Networks."**
37 |
38 | ## Features
39 |
40 | 1. **Strongly typed message passing:** At the heart of Llegos is its use of Pydantic models for message passing. This ensures precision and clarity in communication, allowing for dynamically generated, well-structured messages crucial for complex multi-agent interactions.
41 |
42 | 2. **Messaging with email semantics** Llegos introduces an intuitive, email-like messaging system. Messages have parents, enabling functionalities like replying and forwarding, which enhances clarity and traceability in the system's communication.
43 |
44 | 3. **Bring your own libraries:** Whether that's Langchain, LlamaIndex, CamelAI, transformers... use Llegos Actors to elevate your agents into a multi-agent system, and coordinate them using a Network.
45 |
46 | 4. **Flexibility and generalizability:** Llegos Networks are themselves Actors, and can be nested within each other. This allows for the creation of complex, multi-layered environments where different groups of actors can interact within their sub-contexts.
47 |
48 | ## Quick Start Guide
49 |
50 | Read through the `tests/` folder, in order, it has examples with comments showcasing Llegos' flexibility.
51 |
52 | Requires: `python = ">=3.10,<=3.12"`
53 |
54 | ### Installation
55 |
56 | ```bash
57 | pip install llegos
58 | poetry add llegos
59 | ```
60 |
61 | ### Need help?
62 |
63 | [DM Cyrus](https://x.com/CyrusOfEden)
64 |
65 | ## Core Concepts
66 |
67 | Llegos is built upon several foundational elements that work together to enable complex interactions and behaviors within static and dynamic multi-agent systems. Here's an overview of the key concepts that form the backbone of Llegos:
68 |
69 | ### Objects
70 | - **Definition:** Objects are the fundamental entities in Llegos, defined using Pydantic models. They represent the base class from which other more specialized entities like Messages and Actors derive.
71 | - **Customization:** Users can extend the Object class to create their own network objects, complete with validation and serialization capabilities.
72 | - **Dynamic Generation:** Users can dynamically generate objects using OpenAI function calling, [Instructor](https://github.com/jxnl/instructor), [Outlines](https://github.com/outlines-dev/outlines), etc.
73 |
74 | ### Messages
75 |
76 | - **Purpose:** Messages serve as the primary means of communication between agents. They carry information, requests, commands, or any data that needs to be transmitted from one entity to another.
77 | - **Structure and Handling:** Each message has an identifiable structure and is designed to be flexible and extensible. The system provides mechanisms for message validation, forwarding, replying, and tracking within conversation threads.
78 | - **Email Semantics:** They are organized into hierarchical trees, allowing for the creation of complex communication patterns and protocols. Multiple replies can be sent to a single message, and each reply can have its own set of replies, and so on.
79 |
80 | ### Actors
81 |
82 | - **Roles:** Actors are specialized objects that encapsulate autonomous agents within the system. Each actor has its unique behavior, state, and communication abilities.
83 | - **Interactions:** Actors interact with each other and the environment primarily through strongly-typed messages, responding to received information and making decisions based on their internal logic and objectives.
84 |
85 | ### Networks
86 |
87 | - **Dynamic Actor Graphs:** Within networks, you can dynamically manage the actors' relationships. Actors operating within the context of the network can access the network directory and discover other actors that can receive particular message types.
88 | - **Infinitely Nestable:** Networks are themselves Actors, allowing for the creation of complex, multi-layered environments where different groups of actors can interact within their sub-contexts.
89 |
90 | ## FAQ
91 |
92 | **Q: What is the difference between Llegos and other multi-agent systems like AutoGen, CamelAI, ChatDev, Agent Actors?**
93 |
94 | All those works are innovating in their own right as implementations of different interaction paradigms. Llegos is unique in that it makes it easier for you to explore the design space of interaction paradigms, rather than offering you ready-built paradigms. It is a framework for building multi-agent systems, rather than configuring a specific multi-agent system. It is paint, not a painting.
95 |
96 | **Q: How does Llegos relate to libraries like Langchain, LlamaIndex, Outlines, DSPy, Instructor?**
97 |
98 | Llegos is a framework for building multi-agent systems, and it is designed to be flexible and generalizable, and it is not tied to any specific way of calling LLMs. Any combination of libraries in any order can be used in the implementation of your Llegos Actors, however you please.
99 |
100 | ## Contributing
101 |
102 | We welcome contributions to Llegos! Whether you're interested in fixing bugs, adding new features, or improving documentation, your help is appreciated. Here's how you can contribute:
103 |
104 | 1. **Having discussions:** You can use the discussions feature of this Github repo to ask questions, share ideas, and discuss Llegos.
105 | 2. **Reporting Issues:** If you find a bug or have a suggestion for improvement, please open an issue through our issue tracker.
106 | 3. **Submitting Pull Requests:** Contributions to the codebase are welcomed. Please submit pull requests with clear descriptions of your changes and the benefits they bring.
107 |
108 | ## Is it any good?
109 |
110 | Yes.
111 |
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/blog.md:
--------------------------------------------------------------------------------
1 | # Llegos: A strongly-typed Python DSL for multi-agent systems
2 |
3 | 
4 |
5 | > This research was spearheaded by [Cyrus](https://x.com/CyrusOfEden), our resident expert in multi-agent systems, as [an act of devotion](https://www.youtube.com/watch?v=YPytyPQ8HdI). He's been working on this project for a while now, and we're excited to share it with you.
6 |
7 | !!!!!! INTRO VIDEO !!!!!!!
8 |
9 | *"More of a PyTorch, less of a Keras"*
10 |
11 | We're excited to unveil the alpha release of Llegos, a Python DSL to catalyze innovation in multi-agent systems research and development.
12 |
13 | ## Exploring the Design Space of Multi-Agent Systems with Llegos
14 |
15 | Recent advances in multi-agent systems have opened up a world of possibilities for developers. But with these new opportunities come new challenges. How do you design a multi-agent system that's flexible, adaptable, and scalable? How do you ensure that your system can handle the complexity of real-world interactions?
16 |
17 | We recognize that we're still early in the journey of multi-agent systems. Projects like AutoGen, CamelAI, BabyAGI, and CrewAI have explored the uncharted waters of multi-agent systems. Llegos helps researchers navigate this uncharted territory by offering an expressive domain-specific language for implementing multi-agent interactions, so we can design innovative solutions to complex problems.
18 |
19 | Existing approaches offer a limited view of multi-agent systems. They focus on specific implementations of multi-agent systems, rather than providing a framework for building multi-agent systems. Llegos empowers developers with elegant building blocks to forge their own paths in the design space of multi-agent systems. This flexibility is crucial in a field as dynamic and diverse as multi-agent systems, where one size seldom fits all, and best practices have yet to be discovered.
20 |
21 | Whether it's simulating complex ecosystems, orchestrating intricate interactions, or developing advanced coordination protocols, Llegos provides the foundational elements and freedom necessary for such endeavors. Llegos is paint, not paintings.
22 |
23 | You only need these 2 sentences to get an intuitive understanding of Llegos:
24 |
25 | 1. **llegos.Actors are a container for your agents.**
26 | 2. **"llegos.Actors send llegos.Messages and share llegos.Objects in llegos.Networks."**
27 |
28 | ## Key Features of Llegos
29 |
30 | 1. **Strongly typed message passing:** At the heart of Llegos is its use of Pydantic models for message passing. This ensures precision and clarity in communication, allowing for dynamically generated, well-structured messages crucial for complex multi-agent interactions.
31 |
32 | 2. **Messaging with email semantics** Llegos introduces an intuitive, email-like messaging system. Messages have parents, enabling functionalities like replying and forwarding, which enhances clarity and traceability in the system's communication.
33 |
34 | 3. **Bring your own libraries:** Whether that's Langchain, LlamaIndex, CamelAI, transformers... use Llegos Actors to elevate your agents into a multi-agent system, and coordinate them using a Network.
35 |
36 | 4. **Flexibility and generalizability:** Llegos Networks are themselves Actors, and can be nested within each other. This allows for the creation of complex, multi-layered environments where different groups of actors can interact within their sub-contexts.
37 |
38 | ## Get Started with Llegos
39 |
40 | Eager to start your journey with Llegos? Check out our [Github repo](https://github.com/CyrusOfEden/llegos) and begin exploring the unlimited potential of multi-agent systems. We're particularly curious to see how you'll use Llegos to explore the design space of multi-agent systems, and will be highlighting some of the most innovative projects on our [Twitter](https://twitter.com/CyrusOfEden).
41 |
42 | ## The Future of Llegos
43 |
44 | The alpha release is just the beginning of our journey with Llegos. As the community grows and the project evolves, we look forward to expanding its capabilities and integrating user feedback.
45 |
46 | We're particularly excited to work with explorers like you to discover new ways of using Llegos. We believe that Llegos can be a catalyst for innovation in multi-agent systems, and we're excited to see what you create with it. Come join us in our [Discord](https://discord.gg/jqVphNsB4H), and let's build the future of multi-agent systems together.
47 |
48 | ## FAQ
49 |
50 | **Q: What is the difference between Llegos and other multi-agent systems like AutoGen, CamelAI, ChatDev, Agent Actors?**
51 |
52 | All those works are innovating in their own right as implementations of different interaction paradigms. Llegos is unique in that it makes it easier for you to explore the design space of interaction paradigms, rather than offering you ready-built paradigms. It is a framework for building multi-agent systems, rather than configuring a specific multi-agent system. It is paint, not a painting.
53 |
54 | **Q: How does Llegos relate to libraries like Langchain, LlamaIndex, Outlines, DSPy, Instructor?**
55 |
56 | Llegos is a framework for building multi-agent systems, and it is designed to be flexible and generalizable, and it is not tied to any specific way of calling LLMs. Any combination of libraries in any order can be used in the implementation of your Llegos Actors, however you please.
57 |
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/llegos_diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/essays/01_09_2024_launch/llegos_diagram.png
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/message_email_semantics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/essays/01_09_2024_launch/message_email_semantics.png
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/nested_contract_net.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/essays/01_09_2024_launch/nested_contract_net.png
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/strongly_typed_messages.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/essays/01_09_2024_launch/strongly_typed_messages.png
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/strongly_typed_messages_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/essays/01_09_2024_launch/strongly_typed_messages_2.png
--------------------------------------------------------------------------------
/essays/01_09_2024_launch/tweets.md:
--------------------------------------------------------------------------------
1 | **Tweet 1**
2 | We're thrilled to announce the alpha release of Llegos, a domain specific library for designing multi-agent systems in Python led by @CyrusofEden.
3 |
4 | Llegos in a nutshell: llegos.Actors are containers for your agents, and llegos.Actors send llegos.Messages and share llegos.Objects in llegos.Networks.
5 |
6 | Described by an early contributor as "More of a PyTorch, less of a Keras"
7 |
8 | 
9 |
10 | **Tweet 2**
11 | Let's dive into what makes Llegos a game-changer.
12 |
13 | First, Strongly Typed Message Passing - ensuring clarity and precision in communication between your agents. Llegos focuses on making complex interactions simpler and more reliable.
14 |
15 | 
16 | 
17 |
18 | **Tweet 4**
19 |
20 | Next, Email-like Messaging Semantics. Messages in Llegos are as intuitive as using email - with functionalities like replying and forwarding.
21 |
22 | 
23 |
24 | **Tweet 5**
25 | Bring Your Own Libraries. Llegos seamlessly integrates with tools like Langchain, LlamaIndex, Outlines, DSPy...modularity is vital.
26 | Enhance your agents and coordinate them in a multi-agent system with ease.
27 |
28 | **Tweet 6**
29 | Flexibility and generalizability are at the core of Llegos. The core primitives allow you to model agent hierarchies, networks, and more.
30 |
31 | 
32 |
33 | **Tweet 7**
34 |
35 | Jump into the GitHub repo to start exploring. We're excited to see what incredible systems the community will create wih Llegos. Let's build the future together.
36 |
37 | https://github.com/CyrusOfEden/llegos
38 |
39 | **Tweet 8**
40 | Stay tuned for updates, new features, and more as we continue this exciting journey. Come join us in our [Discord](https://discord.gg/jqVphNsB4H) to collaborate on multi-agent systems.
41 |
--------------------------------------------------------------------------------
/essays/09_07_2023.md:
--------------------------------------------------------------------------------
1 | Title: Walking Through the Valley of Generative Agents
2 |
3 | As a computer scientist, I'm constantly amazed by the diverse and interwoven paths that our field opens. Lately, I've been developing a toolkit called Llegos, designed for building advanced autonomous agent systems. And I've begun to notice some striking parallels between these two seemingly distinct areas.
4 |
5 | Today after talking with [Guohao Li](https://twitter.com/guohao_li), I decided to take a peek at Graph Neural Contexts (GNNs), a fascinating area that blends graphs, which represent relationships between entities, with neural networks, our go-to tool for learning from data. As I learned, I realized I could help others understand what I'm working on by drawing an analogy between Llegos and GNNs.
6 |
7 | In the world of GNNs, the entities (or nodes) are connected in intricate ways, and these connections (or edges) are what give the graph its structure. The nodes exchange information with their neighbors, allowing the whole network to learn and adapt. In my Llegos toolkit, I see a similar dance playing out, but the dancers here are agents, and their stage is a multi-agent system.
8 |
9 | The Behavior class in Llegos is like a node in a GNN. The way agents in Llegos interact with each other is strikingly reminiscent of the message-passing mechanism in GNNs. Each agent in Llegos can emit and receive events, allowing for asynchronous communication between agents. This reminded me of how nodes in a GNN aggregate information from their neighbors, although the process in GNNs is usually synchronous and happens in discrete steps.
10 |
11 | The Message class in Llegos serves as the medium of this communication. Each message carries a payload of information from one agent to another, much like an edge carries information from one node to another in a GNN. And just like nodes in a GNN update their features based on the information they receive, agents in Llegos can update their internal state based on the messages they receive.
12 |
13 | Behaviors can use a variety of methods to communicate with their world and update their internal state and memory. Most agents today have a single `run` function. Behaviors support a richer vocabulary of methods, like `chat`, `inform`, `request`, `step`, `query`, and more, which can be used to create complex interaction protocols between agents, like contract net.
14 |
15 | This ability of agents to update their state based on their interactions gives Llegos a dynamic quality that is both exciting and challenging. It's exciting because it allows for more nuanced and context-dependent interactions between agents. But it's also challenging because it adds a layer of complexity to the system, making it harder to predict and control.
16 |
17 | At the moment, I find myself captivated by the quest to deliver an exceptional developer experience when composing agent nodes into intricate graphs. I see this graph as the ultimate tool for building advanced multi-agent systems or even Artificial General Intelligences (AGIs). It's like discovering the recursive cousin of the pioneering skill library approach from MineDojo's Voyager project.
18 |
19 | But the real puzzle here isn't about finding the right agent. No, it's about unearthing the right update function for the agent. This subtle shift in focus opens a world of possibilities. Imagine a multi-agent system where each agent is made of multiple "minds" — multiple sub-agents. This generative intelligence can dynamically evolve. It can learn skills by spawning new sub-agents into existence, or sleep and perform sub-agent pruning, or rewire the connections between its sub-agents.
20 |
21 | I can't help but wonder: could we bring some of the tools and techniques from GNNs into the world of Llegos? Could we apply the attention mechanisms from GNNs, which allow nodes to pay more attention to important neighbors, to the agents in Llegos? Could we allow agents to pay more attention to important events, enabling them to make more informed decisions and adapt more effectively to their environment?
22 |
23 | These are the questions that I ponder from time to time. And while I don't have the answers yet, I'm excited to keep exploring this valley. Who knows what treasures I might find at the end of this journey? Whether it's a new way to design multi-agent systems, a deeper understanding of GNNs, or perhaps something else entirely, I'm eager to find out. And as I walk this path, I'll continue to share my thoughts and discoveries, hoping they might spark new ideas and conversations in this wonderfully complex world of computer science.
24 |
--------------------------------------------------------------------------------
/llegos/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/llegos/__init__.py
--------------------------------------------------------------------------------
/llegos/logger.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | LOG_LEVEL = logging.getLevelName(os.environ.get("LLEGOS_LOG_LEVEL", "INFO"))
5 |
6 | logger = logging.getLogger("llegos")
7 | logger.setLevel(LOG_LEVEL)
8 |
9 | getLogger = logger.getChild
10 |
--------------------------------------------------------------------------------
/llegos/research.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import typing as t
3 | from collections.abc import Iterable
4 | from contextvars import ContextVar, Token
5 | from datetime import datetime
6 | from time import time
7 |
8 | from beartype import beartype
9 | from beartype.typing import Callable, Iterator, Optional
10 | from deepmerge import always_merger
11 | from ksuid import Ksuid
12 | from networkx import DiGraph, MultiGraph
13 | from pydantic import BaseModel, ConfigDict, Field
14 | from pydash import snake_case
15 | from pyee import EventEmitter
16 | from sorcery import delegate_to_attr, maybe
17 |
18 | if t.TYPE_CHECKING:
19 | from pydantic.main import IncEx
20 |
21 | from .logger import getLogger
22 |
23 | logger = getLogger("research")
24 |
25 |
26 | def namespaced_ksuid(prefix: str):
27 | return f"{prefix}_{Ksuid()}"
28 |
29 |
30 | def namespaced_ksuid_generator(prefix: str):
31 | return lambda: namespaced_ksuid(prefix)
32 |
33 |
34 | class Object(BaseModel):
35 | """A Pydantic base class for Llegos entities.
36 |
37 | - **Definition:** Objects are the fundamental entities in Llegos, defined using Pydantic models.
38 | They represent the base class from which other more specialized entities like Messages and
39 | Actors derive.
40 | - **Customization:** Users can extend the Object class to create their own network objects,
41 | complete with validation and serialization capabilities.
42 | - **Dynamic Generation:** Users can dynamically generate objects using OpenAI function calling,
43 | [Instructor](https://github.com/jxnl/instructor),
44 | [Outlines](https://github.com/outlines-dev/outlines), etc.
45 |
46 | Attributes:
47 | id (str): A unique identifier for the object, generated using namespaced KSUID.
48 | metadata (dict): A dictionary to store additional metadata about the object.
49 | """
50 |
51 | model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
52 |
53 | def __init_subclass__(cls):
54 | super().__init_subclass__()
55 | cls.model_fields["id"].default_factory = namespaced_ksuid_generator(
56 | snake_case(cls.__name__)
57 | )
58 |
59 | id: str = Field(default_factory=namespaced_ksuid_generator("object"))
60 | metadata: dict = Field(default_factory=dict)
61 |
62 | def model_dump_json(
63 | self,
64 | *,
65 | indent: int | None = None,
66 | include: "IncEx" = None,
67 | exclude: "IncEx" = None,
68 | by_alias: bool = False,
69 | exclude_unset: bool = False,
70 | exclude_defaults: bool = False,
71 | exclude_none: bool = True, # updated default to True to reduce JSON noise sent to the LLM
72 | round_trip: bool = False,
73 | warnings: bool = True,
74 | ) -> str:
75 | return super().model_dump_json(
76 | indent=indent,
77 | include=include,
78 | exclude=exclude,
79 | by_alias=by_alias,
80 | exclude_unset=exclude_unset,
81 | exclude_defaults=exclude_defaults,
82 | exclude_none=exclude_none,
83 | round_trip=round_trip,
84 | warnings=warnings,
85 | )
86 |
87 | def __hash__(self):
88 | return hash(self.id)
89 |
90 | def __str__(self):
91 | return self.model_dump_json()
92 |
93 | @classmethod
94 | def lift(cls, instance: "Object", **kwargs):
95 | """Creates a new instance of the class using an existing object and additional attributes.
96 |
97 | This class method allows for creating a new object based on an existing one, optionally
98 | overriding or adding new attributes. It's particularly useful for creating derived objects
99 | with some shared properties.
100 |
101 | Args:
102 | instance (Object): The existing object to base the new instance on.
103 | **kwargs: Additional attributes to set on the new object.
104 |
105 | Returns:
106 | Object: A new instance of the class.
107 | """
108 | attrs = instance.model_dump(exclude={"id"})
109 | always_merger.merge(attrs, kwargs)
110 | return cls(**attrs)
111 |
112 |
113 | class MissingNetwork(ValueError):
114 | ...
115 |
116 |
117 | class InvalidMessage(ValueError):
118 | ...
119 |
120 |
121 | class Actor(Object):
122 | """Represents an actor in a network, capable of receiving and processing messages.
123 |
124 | This class extends the functionality of the Object class, incorporating message handling
125 | capabilities in a networked environment. Actors can receive messages, determine appropriate
126 | handling methods, and emit events. They are also aware of their network and relationships
127 | within that network.
128 |
129 | - **Roles:** Actors are specialized objects that encapsulate autonomous agents within the
130 | system. Each actor has its unique behavior, state, and communication abilities.
131 | - **Interactions:** Actors interact with each other and the environment primarily through
132 | strongly-typed messages, responding to received information and making decisions based on
133 | their internal logic and objectives.
134 |
135 | Design Goals:
136 | 1. Actors are a container for your agents that isolate state.
137 | 2. Actors have a mailbox and process messages one by one (to prevent race conditions).
138 | 3. Actors can load state on startup, persist state, and preserve state on shutdown.
139 | 4. Actors can run periodic tasks.
140 | 5. Actors can be composed into static and dynamic graphs.
141 |
142 | Attributes:
143 | _event_emitter (EventEmitter): An event emitter for handling events.
144 |
145 | Methods:
146 | can_receive: Checks if the actor can receive a specific type of message.
147 | receive_missing: Default handler for unrecognized messages.
148 | receive: Processes a received message and yields responses.
149 | network: Retrieves the network to which the actor belongs.
150 | relationships: Provides information about the actor's relationships in the network.
151 | receivers: Finds actors capable of receiving specified message types.
152 | add_listener, emit, event_names, listeners, on, once, remove_all_listeners, and
153 | remove_listener:
154 | Delegated methods for event handling, proxied to the internal EventEmitter.
155 | """
156 |
157 | _event_emitter = EventEmitter()
158 |
159 | def can_receive(self, message: t.Union["Message", type["Message"]]) -> bool:
160 | """
161 | Determines if the actor can receive a given message or message type.
162 |
163 | Args:
164 | message (Union[Message, type[Message]]): The message instance or type to be checked.
165 |
166 | Returns:
167 | bool: True if the actor has a method to receive the message, False otherwise.
168 | """
169 | return hasattr(self, self.receive_method_name(message))
170 |
171 | @staticmethod
172 | def receive_method_name(message: t.Union["Message", type["Message"]]):
173 | if isinstance(message, Message) and hasattr(message, "intent"):
174 | return f"receive_{message.intent}"
175 |
176 | intent = snake_case(
177 | (message.__class__ if isinstance(message, Message) else message).__name__
178 | )
179 | return f"receive_{intent}"
180 |
181 | def receive_method(self, message: "Message"):
182 | method = self.receive_method_name(message)
183 | if hasattr(self, method):
184 | return getattr(self, method)
185 | return self.receive_missing
186 |
187 | def receive_missing(self, message: "Message"):
188 | raise InvalidMessage(message)
189 |
190 | def __call__(self, message: "Message") -> Iterator["Message"]:
191 | return self.receive(message)
192 |
193 | def receive(self, message: "Message") -> Iterator["Message"]:
194 | """Processes a received message and yields response messages.
195 |
196 | This method logs the receipt, emits a 'before:receive' event, and delegates to the
197 | appropriate handling method based on the message type. The response can be a single message
198 | or an iterable of messages.
199 |
200 | Args:
201 | message (Message): The message to be processed.
202 |
203 | Yields:
204 | Iterator[Message]: An iterator over response messages.
205 | """
206 | logger.debug(f"{self.id} before:receive {message.id}")
207 | self.emit("before:receive", message)
208 |
209 | response = self.receive_method(message)(message)
210 |
211 | match response:
212 | case Message():
213 | yield response
214 | case Iterable():
215 | yield from response
216 |
217 | @property
218 | def network(self):
219 | """Retrieves the network to which this actor belongs.
220 |
221 | Returns:
222 | Network: The network instance.
223 |
224 | Raises:
225 | MissingNetwork: If the actor is not part of any network.
226 | """
227 | if network := network_context.get():
228 | return network
229 | raise MissingNetwork(self)
230 |
231 | @property
232 | def relationships(self) -> t.Sequence[t.Tuple["Actor", str | None, dict]]:
233 | """Provides information about this actor's relationships within the network.
234 |
235 | Returns:
236 | Sequence[Tuple[Actor, str | None, dict]]: A sorted list of tuples containing
237 | information about each relationship. Each tuple consists of the neighbor
238 | (Actor), relationship type (str or None), and relationship data (dict).
239 | """
240 | return sorted(
241 | [
242 | (neighbor, key, data)
243 | for (_self, neighbor, key, data) in self.network._graph.edges(
244 | self,
245 | keys=True,
246 | data=True,
247 | )
248 | ],
249 | key=lambda edge: edge[2].get("weight", 1),
250 | )
251 |
252 | def receivers(self, *messages: type["Message"]):
253 | """Finds actors in the network capable of receiving specified message types.
254 |
255 | Args:
256 | *messages (type[Message]): Variable number of message types to check for.
257 |
258 | Returns:
259 | List[Actor]: A list of actors capable of receiving all provided message types.
260 | """
261 | return [
262 | actor
263 | for (actor, _key, _data) in self.relationships
264 | if all(actor.can_receive(m) for m in messages)
265 | ]
266 |
267 | (
268 | add_listener,
269 | emit,
270 | event_names,
271 | listeners,
272 | on,
273 | once,
274 | remove_all_listeners,
275 | remove_listener,
276 | ) = delegate_to_attr("_event_emitter")
277 |
278 |
279 | class Network(Actor):
280 | """Represents a network of actors, extending the capabilities of an Actor.
281 |
282 | This class models a network as an actor itself, allowing it to participate in message handling
283 | and event emission. It maintains a collection of actors and the relationships between them,
284 | represented as a graph. The Network class provides methods for actor management and interaction
285 | within the network. The Network can be used as a context manager to limit the scope of Actors'
286 | relationships.
287 |
288 | - **Dynamic Actor Graphs:** Within networks, you can dynamically manage the actors'
289 | relationships. Actors operating within the context of the network can access the network
290 | directory and discover other actors that can receive particular message types.
291 | - **Infinitely Nestable:** Networks are themselves Actors, allowing for the creation of complex,
292 | multi-layered environments where different groups of actors can interact within their
293 | sub-contexts.
294 |
295 | Attributes:
296 | actors (Sequence[Actor]): A sequence of actors that are part of the network.
297 | _graph (MultiGraph): An internal representation of the network as a graph.
298 | """
299 |
300 | actors: t.Sequence[Actor] = Field(default_factory=list)
301 | _graph = MultiGraph()
302 |
303 | def __init__(self, actors: t.Sequence[Actor], **kwargs):
304 | super().__init__(actors=actors, **kwargs)
305 | for actor in actors:
306 | self._graph.add_edge(self, actor)
307 |
308 | def __getitem__(self, key: str | Actor | t.Any) -> Actor:
309 | """Retrieves an actor from the network by their ID."""
310 | match key:
311 | case str():
312 | return self.directory[key]
313 | case _:
314 | raise TypeError("__getitem__ accepts a key of str", key)
315 |
316 | def __contains__(self, key: str | Actor | t.Any) -> bool:
317 | """Checks if an actor or actor ID is part of the network."""
318 | match key:
319 | case str():
320 | return key in self.directory
321 | case Actor():
322 | return key in self.actors
323 | case _:
324 | raise TypeError("__contains__ accepts a key of str or Actor", key)
325 |
326 | @property
327 | def directory(self):
328 | """Provides a dictionary mapping actor IDs to actor instances."""
329 | return {a.id: a for a in self.actors}
330 |
331 | def __enter__(self):
332 | global network_token, network_context
333 | network_token = network_context.set(self)
334 | return self
335 |
336 | def __exit__(self, exc_type, exc_val, exc_tb):
337 | global network_token, network_context
338 | if network_token:
339 | network_context.reset(network_token)
340 | network_token = None
341 |
342 |
343 | # Global State for Network Context Management
344 | network_context = ContextVar[Network]("llegos.network")
345 | network_token: Optional[Token[Network]] = None
346 |
347 | """
348 | The 'network_context' and 'network_token' are used to manage the context of the current network
349 | globally. This is particularly useful for maintaining a reference to the active network instance
350 | across different parts of the application.
351 |
352 | - 'network_context': A ContextVar object that holds the current network instance. This variable
353 | manages the current network scope, through which actors can reliably discover peers that are part
354 | of that network.
355 |
356 | - 'network_token': An optional token that represents the state of the 'network_context' before a
357 | new network is set. This token is used to reset the 'network_context' to its previous state,
358 | ensuring proper context management, especially in scenarios where networks are dynamically
359 | created and switched within an application's lifecycle.
360 | """
361 |
362 |
363 | class Message(Object):
364 | """Represents a message in the network, capable of being sent and received by actors.
365 |
366 | - **Purpose:** Messages serve as the primary means of communication between agents. They carry
367 | information, requests, commands, or any data that needs to be transmitted from one entity
368 | to another.
369 | - **Structure and Handling:** Each message has an identifiable structure and is designed to be
370 | flexible and extensible. The system provides mechanisms for message validation, forwarding,
371 | replying, and tracking within conversation threads.
372 | - **Email Semantics:** They are organized into hierarchical trees, allowing for the creation of
373 | complex communication patterns and protocols. Multiple replies can be sent to a single
374 | message, and each reply can have its own set of replies, and so on.
375 |
376 | Attributes:
377 | created_at (datetime): The timestamp of when the message was created.
378 | sender (Optional[ForwardRef[Actor]]): The sender of the message.
379 | receiver (Optional[ForwardRef[Actor]]): The intended receiver of the message.
380 | parent (Optional[ForwardRef[Message]]): The parent message, if this is a reply or forward.
381 |
382 | Methods:
383 | reply_to: Class method to create a reply to this message.
384 | forward: Class method to forward this message to another actor.
385 | sender_id: Property to get the ID of the sender.
386 | receiver_id: Property to get the ID of the receiver.
387 | parent_id: Property to get the ID of the parent message.
388 | forward_to: Instance method to forward this message to another actor.
389 | reply: Instance method to create a reply to this message.
390 | """
391 |
392 | created_at: datetime = Field(default_factory=datetime.utcnow, frozen=True)
393 | sender: Optional[t.ForwardRef("Actor")] = None
394 | receiver: Optional[t.ForwardRef("Actor")] = None
395 | parent: Optional[t.ForwardRef("Message")] = None
396 |
397 | @classmethod
398 | def reply_to(cls, message: "Message", **kwargs) -> "Message":
399 | """Creates a reply to a given message.
400 |
401 | This class method constructs a new message that acts as a reply to the given message.
402 | The new message's sender is set to the original receiver, and its receiver is set to
403 | the original sender.
404 |
405 | Args:
406 | message (Message): The message to which a reply is being made.
407 | **kwargs: Additional attributes to be set on the new message.
408 |
409 | Returns:
410 | Message: The newly created reply message.
411 | """
412 | attrs = {
413 | "sender": message.receiver,
414 | "receiver": message.sender,
415 | "parent": message,
416 | }
417 | attrs.update(kwargs)
418 | return cls.lift(message, **attrs)
419 |
420 | @classmethod
421 | def forward(cls, message: "Message", receiver: Actor, **kwargs) -> "Message":
422 | """Forwards a message to a different receiver.
423 |
424 | This method creates a new message that is a forward of the given message. The new message's
425 | sender is set to the original message's receiver, and its receiver is the specified new
426 | receiver.
427 |
428 | Args:
429 | message (Message): The message to be forwarded.
430 | receiver (Actor): The actor to whom the message will be forwarded.
431 | **kwargs: Additional attributes to be set on the new forwarded message.
432 |
433 | Returns:
434 | Message: The newly created forwarded message.
435 | """
436 | attrs = {
437 | "sender": message.receiver,
438 | "receiver": receiver,
439 | "parent": message,
440 | }
441 | attrs.update(kwargs)
442 | return cls.lift(message, **attrs)
443 |
444 | @property
445 | def sender_id(self) -> Optional[str]:
446 | """Gets the ID of the sender of the message, if available."""
447 | return maybe(self.sender).id
448 |
449 | @property
450 | def receiver_id(self) -> Optional[str]:
451 | """Gets the ID of the receiver of the message, if available."""
452 | return maybe(self.receiver).id
453 |
454 | @property
455 | def parent_id(self) -> Optional[str]:
456 | """Gets the ID of the parent message, if this message is part of a chain."""
457 | return maybe(self.parent).id
458 |
459 | def __str__(self):
460 | return self.model_dump_json(exclude={"parent"})
461 |
462 | def forward_to(self, receiver: Actor, **kwargs) -> "Message":
463 | """Forward this message to another actor.
464 |
465 | Args:
466 | receiver (Actor): The actor to whom the message will be forwarded.
467 | **kwargs: Additional attributes to be set on the new forwarded message.
468 |
469 | Returns:
470 | Message: The newly created forwarded message.
471 | """
472 | return self.forward(self, receiver, **kwargs)
473 |
474 | def reply(self, **kwargs) -> "Message":
475 | """
476 | Instance method to create a reply to this message.
477 |
478 | Args:
479 | **kwargs: Additional attributes to be set on the new reply message.
480 |
481 | Returns:
482 | Message: The newly created reply message.
483 | """
484 | return self.reply_to(self, **kwargs)
485 |
486 |
487 | @beartype
488 | def message_chain(message: Message | None, height: int) -> Iterator[Message]:
489 | """Generates an iterator over a chain of messages up to a specified height.
490 |
491 | This function iterates over the ancestors of a given message, up to a specified height,
492 | creating a chain of messages. It starts from the given message and moves up to its parent
493 | messages recursively.
494 |
495 | Args:
496 | message (Message | None): The starting message from which to trace the chain.
497 | height (int): The maximum number of levels to trace back from the given message.
498 |
499 | Returns:
500 | Iterator[Message]: An iterator over the message chain.
501 | """
502 | if message is None:
503 | return []
504 | elif height > 1:
505 | yield from message_chain(message.parent, height - 1)
506 | yield message
507 |
508 |
509 | @beartype
510 | def message_list(message: Message, height: int) -> t.List[Message]:
511 | """Creates a list of messages in the message chain up to a specified height.
512 |
513 | This function constructs a list containing the chain of messages starting from a given
514 | message and moving up through its ancestors, limited by the specified height.
515 |
516 | Args:
517 | message (Message): The starting message for the list.
518 | height (int): The maximum number of levels to include in the list.
519 |
520 | Returns:
521 | List[Message]: A list of messages in the chain.
522 | """
523 | return list(message_chain(message, height))
524 |
525 |
526 | @beartype
527 | def message_tree(messages: Iterable[Message]):
528 | """Constructs a directed graph representing the tree structure of a collection of messages.
529 |
530 | This function builds a directed graph (DiGraph) from an iterable of messages, where edges
531 | represent the parent-child relationship between messages.
532 |
533 | Args:
534 | messages (Iterable[Message]): An iterable of messages to construct the tree from.
535 |
536 | Returns:
537 | DiGraph: A directed graph representing the message tree.
538 | """
539 |
540 | g = DiGraph()
541 | for message in messages:
542 | if message.parent:
543 | g.add_edge(message.parent, message)
544 | return g
545 |
546 |
547 | class MessageNotFound(ValueError):
548 | ...
549 |
550 |
551 | def message_ancestors(message: Message) -> Iterator[Message]:
552 | """Generates an iterator over the ancestors of a given message.
553 |
554 | This function iterates over all the parent messages of a given message, tracing back through
555 | its lineage.
556 |
557 | Args:
558 | message (Message): The message from which to start tracing back.
559 |
560 | Returns:
561 | Iterator[Message]: An iterator over the ancestors of the message.
562 | """
563 | while message := message.parent:
564 | yield message
565 |
566 |
567 | @beartype
568 | def message_closest(
569 | message: Message,
570 | cls_or_tuple: tuple[type[Message]] | type[Message],
571 | max_search_height: int = 256,
572 | ) -> Optional[Message]:
573 | """Finds the closest ancestor of a given message that matches a specified type.
574 |
575 | This function searches through the message's ancestors up to a maximum height, looking for a
576 | message that matches the specified type or tuple of types.
577 |
578 | Args:
579 | message (Message): The starting message for the search.
580 | cls_or_tuple (tuple[type[Message]] | type[Message]): The message type(s) to search for.
581 | max_search_height (int, optional): The maximum height to search. Defaults to 256.
582 |
583 | Returns:
584 | Optional[Message]: The closest matching ancestor, or None if not found.
585 |
586 | Raises:
587 | MessageNotFound: If no matching message is found within the max_search_height.
588 | """
589 | for parent, _ in zip(message_ancestors(message), range(max_search_height)):
590 | if isinstance(parent, cls_or_tuple):
591 | return parent
592 | else:
593 | raise MessageNotFound(cls_or_tuple)
594 |
595 |
596 | class MissingReceiver(ValueError):
597 | ...
598 |
599 |
600 | @beartype
601 | def message_send(message: Message) -> Iterator[Message]:
602 | """Sends a message to its intended receiver and yields any response messages.
603 |
604 | This function triggers the receipt and processing of the message by its receiver, assuming the
605 | receiver is specified. It yields any messages that are produced as a response by the receiver.
606 |
607 | Args:
608 | message (Message): The message to be sent.
609 |
610 | Returns:
611 | Iterator[Message]: An iterator over the response messages from the receiver.
612 |
613 | Raises:
614 | MissingReceiver: If the message does not have a specified receiver.
615 | """
616 | if not message.receiver:
617 | raise MissingReceiver(message)
618 | yield from message.receiver.receive(message)
619 |
620 |
621 | @beartype
622 | def message_propagate(
623 | message: Message,
624 | send_fn: Callable[[Message], Iterator[Message]] = message_send,
625 | ) -> Iterator[Message]:
626 | """Propagates a message through the network, yielding all response messages.
627 |
628 | This function sends a message (using the provided send function) and recursively propagates
629 | any response messages. It is useful for handling chains of messages that generate further
630 | messages as responses.
631 |
632 | Args:
633 | message (Message): The initial message to be propagated.
634 | send_fn (Callable[[Message], Iterator[Message]], optional): The function used to send
635 | messages. Defaults to message_send.
636 |
637 | Returns:
638 | Iterator[Message]: An iterator over all messages generated in the propagation process.
639 | """
640 | for reply in send_fn(message):
641 | if reply:
642 | yield reply
643 | yield from message_propagate(reply, send_fn)
644 |
645 |
646 | def throttle(seconds):
647 | """Decorator that limits the execution frequency of a function.
648 |
649 | This decorator ensures that the decorated function can only be called once every specified
650 | number of seconds. Subsequent calls within the throttle period are ignored.
651 |
652 | Args:
653 | seconds (float): The minimum number of seconds between successive calls to the function.
654 |
655 | Returns:
656 | Callable: The throttled function.
657 | """
658 |
659 | def decorate(f):
660 | t = None
661 |
662 | @functools.wraps(f)
663 | def wrapped(*args, **kwargs):
664 | nonlocal t
665 | start = time()
666 | if t is None or start - t >= seconds:
667 | result = f(*args, **kwargs)
668 | t = start
669 | return result
670 |
671 | return wrapped
672 |
673 | return decorate
674 |
675 |
676 | Object.model_rebuild()
677 | Message.model_rebuild()
678 | Actor.model_rebuild()
679 | Network.model_rebuild()
680 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "llegos"
3 | version = "0.1.0"
4 | description = "Where we explore the future of multi-agent systems"
5 | authors = ["Cyrus Nouroozi, "]
6 | license = "LGPLv3"
7 | readme = "README.md"
8 | packages = [{ include = "llegos" }]
9 |
10 | [tool.poetry.dependencies]
11 | python = ">=3.10,<=3.12"
12 | beartype = "^0.15.0"
13 | deepmerge = "^1.1.0"
14 | networkx = "^3.2"
15 | pydantic = "^2.0"
16 | pyee = "^11.1.0"
17 | python-statemachine = "^2.1.2"
18 | sorcery = "^0.2.2"
19 | pydash = "^7.0.6"
20 | svix-ksuid = "^0.6.2"
21 | match-ref = "^1.0.1"
22 | more-itertools = "^10.2.0"
23 |
24 | [tool.poetry.group.dev]
25 | optional = true
26 |
27 | [tool.poetry.group.dev.dependencies]
28 | black = "^23.3.0"
29 | faker = "^21.0.0"
30 | ipdb = "^0.13.13"
31 | ipython = "^8.12.0"
32 | jupyter = "^1.0.0"
33 | marvin = "2.0.1a1"
34 | pyee = "^11.0.0"
35 | pytest = "^7.3.1"
36 | pytest-asyncio = "^0.21.0"
37 | python-dotenv = "^1.0.0"
38 | ruff = "^0.0.261"
39 | setuptools = "^67.6.1"
40 | types-pyyaml = "^6.0.12.9"
41 | ray = {extras = ["default"], version = "^2.9.0"}
42 | snoop = "^0.4.3"
43 |
44 | [tool.isort]
45 | profile = "black"
46 | multi_line_output = 3
47 | src_paths = ["llegos"]
48 |
49 | [tool.pyright]
50 | include = ["llegos"]
51 | exclude = ["**/__pycache__", "**/.venv", "**/.mypy_cache", "**/.pytest_cache"]
52 | ignore = []
53 | stubPath = "stubs/"
54 | typeCheckingMode = "basic"
55 | reportMissingImports = true
56 | reportPrivateImportUsage = false
57 | reportMissingTypeStubs = false
58 | reportGeneralTypeIssues = false
59 | reportTypedDictNotRequiredAccess = false
60 | pythonVersion = "3.10"
61 | pythonPlatform = "Linux"
62 |
63 | [build-system]
64 | requires = ["poetry-core"]
65 | build-backend = "poetry.core.masonry.api"
66 |
67 | [tool.ruff]
68 | line-length = 100
69 |
--------------------------------------------------------------------------------
/research/snoop.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 49,
6 | "metadata": {},
7 | "outputs": [],
8 | "source": [
9 | "import snoop\n",
10 | "\n",
11 | "def tracer(store: list[str], **kwargs):\n",
12 | " def write(line: str):\n",
13 | " line = line.strip()\n",
14 | " if \"\\n\" in line:\n",
15 | " line = line.split(\"\\n\")[0]\n",
16 | " if line.startswith(\"...\"):\n",
17 | " store.append(line.lstrip(\".\"))\n",
18 | "\n",
19 | " return snoop.Config(out=write, columns=(), **kwargs).snoop\n"
20 | ]
21 | },
22 | {
23 | "cell_type": "code",
24 | "execution_count": 54,
25 | "metadata": {},
26 | "outputs": [
27 | {
28 | "name": "stdout",
29 | "output_type": "stream",
30 | "text": [
31 | " x = 84\n",
32 | " x = 42.0\n"
33 | ]
34 | }
35 | ],
36 | "source": [
37 | "trace = []\n",
38 | "\n",
39 | "@tracer(trace)(watch=[\"x\"])\n",
40 | "def hello():\n",
41 | " x = 42\n",
42 | " x *= 2\n",
43 | " x /= 2\n",
44 | " assert x == 42\n",
45 | "\n",
46 | "hello()\n",
47 | "for line in trace:\n",
48 | " print(line)"
49 | ]
50 | },
51 | {
52 | "cell_type": "code",
53 | "execution_count": 55,
54 | "metadata": {},
55 | "outputs": [
56 | {
57 | "name": "stdout",
58 | "output_type": "stream",
59 | "text": [
60 | " attrs = {'answer': 42}\n",
61 | " attrs = {'answer': 84}\n",
62 | " attrs = {'answer': 84, 'question': 'What is the answer?'}\n"
63 | ]
64 | }
65 | ],
66 | "source": [
67 | "trace = []\n",
68 | "\n",
69 | "@tracer(trace)(watch_explode=[\"attrs\"])\n",
70 | "def world():\n",
71 | " attrs = {}\n",
72 | " attrs[\"answer\"] = 42\n",
73 | " attrs[\"answer\"] *= 2\n",
74 | " attrs[\"question\"] = \"What is the answer?\"\n",
75 | " return attrs\n",
76 | "\n",
77 | "world()\n",
78 | "for line in trace:\n",
79 | " print(line)"
80 | ]
81 | },
82 | {
83 | "cell_type": "code",
84 | "execution_count": null,
85 | "metadata": {},
86 | "outputs": [],
87 | "source": []
88 | }
89 | ],
90 | "metadata": {
91 | "kernelspec": {
92 | "display_name": "llegos-5cS2_l5r-py3.10",
93 | "language": "python",
94 | "name": "python3"
95 | },
96 | "language_info": {
97 | "codemirror_mode": {
98 | "name": "ipython",
99 | "version": 3
100 | },
101 | "file_extension": ".py",
102 | "mimetype": "text/x-python",
103 | "name": "python",
104 | "nbconvert_exporter": "python",
105 | "pygments_lexer": "ipython3",
106 | "version": "3.10.12"
107 | }
108 | },
109 | "nbformat": 4,
110 | "nbformat_minor": 2
111 | }
112 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from pytest import Parser
2 |
3 |
4 | def pytest_addoption(parser: Parser):
5 | parser.addoption(
6 | "--shell",
7 | action="store_true",
8 | dest="shell",
9 | default=False,
10 | help="Run tests requiring shell input",
11 | )
12 |
--------------------------------------------------------------------------------
/tests/test_0_research.py:
--------------------------------------------------------------------------------
1 | """
2 | Verifying the core, conceptual functionality of the library.
3 | """
4 |
5 | import pickle
6 | import typing as t
7 | from itertools import combinations
8 |
9 | from faker import Faker
10 | from matchref import ref
11 | from more_itertools import take
12 | from pydash import sample
13 |
14 | from llegos.research import Actor, Message, Network, Object, message_propagate
15 |
16 |
17 | def test_message_hydration() -> None:
18 | a1 = Actor()
19 | a2 = Actor()
20 | m1 = Message(sender=a1, receiver=a2)
21 | m1_ = Message.model_validate(m1.model_dump())
22 |
23 | assert isinstance(m1_, Message)
24 | assert isinstance(m1_.sender, Actor)
25 | assert isinstance(m1_.receiver, Actor)
26 | assert m1.model_dump() == m1_.model_dump()
27 |
28 |
29 | class MyMessage(Message): ...
30 |
31 |
32 | def serialize(msg: Message):
33 | return pickle.dumps(msg)
34 |
35 |
36 | def deserialize(data: dict):
37 | return pickle.loads(data)
38 |
39 |
40 | def test_message_subclass_hydration() -> None:
41 | m1 = MyMessage()
42 | assert isinstance(deserialize(serialize(m1)), MyMessage)
43 |
44 | m2 = MyMessage.reply_to(m1)
45 | serde = deserialize(serialize(m2))
46 | assert isinstance(serde, MyMessage)
47 | # the line below fails
48 | assert isinstance(serde.parent, MyMessage)
49 |
50 |
51 | def test_message_reply_to() -> None:
52 | """
53 | Reply-to email semantics
54 | """
55 | a1 = Actor()
56 | a2 = Actor()
57 | m1 = Message(sender=a1, receiver=a2)
58 | m2 = Message.reply_to(m1)
59 | assert m2.parent == m1
60 | assert Message.model_validate(m2.model_dump()).parent_id == m1.id
61 |
62 |
63 | def test_message_forward() -> None:
64 | """
65 | Forward email semantics
66 | """
67 | a1 = Actor()
68 | a2 = Actor()
69 | a3 = Actor()
70 | m1 = Message(sender=a1, receiver=a2)
71 | m2 = m1.forward_to(a3)
72 | assert m2.parent == m1
73 | assert m2.receiver == a3
74 |
75 |
76 | class Ping(Message): ...
77 |
78 |
79 | class Pinger(Actor):
80 | def receive_ping(self, ping: Ping) -> "Pong":
81 | return Pong.reply_to(ping)
82 |
83 |
84 | class Pong(Message): ...
85 |
86 |
87 | class Ponger(Actor):
88 | def receive_pong(self, pong: Pong) -> "Ping":
89 | return Ping.reply_to(pong)
90 |
91 |
92 | def test_actor_can_receive() -> None:
93 | pinger = Pinger()
94 | ponger = Ponger()
95 |
96 | assert not pinger.can_receive(Pong)
97 | assert not ponger.can_receive(Ping)
98 | assert pinger.can_receive(Ping)
99 | assert ponger.can_receive(Pong)
100 |
101 |
102 | def test_ping_pong() -> None:
103 | """
104 | Test two actors sending messages to each other indefinitely.
105 | """
106 |
107 | pinger = Pinger()
108 | ponger = Ponger()
109 |
110 | """
111 | actor.receive(message), llegos.message_send(message), and llegos.message_propagate(message)
112 | all return a generator, you can iterate on it as much as you like.
113 |
114 | This generate yields all yielded and returned messages.
115 |
116 | In this case, we only want to iterate 4 times, so we use zip(..., range(4))
117 | """
118 | messages = message_propagate(Ping(sender=ponger, receiver=pinger))
119 |
120 | for m in take(4, messages):
121 | match m:
122 | case Ping(sender=ref.ponger, receiver=ref.pinger):
123 | ...
124 | case Pong(sender=ref.pinger, receiver=ref.ponger):
125 | ...
126 | case _:
127 | assert False, m
128 |
129 |
130 | def test_actor_callbacks() -> None:
131 | counter = 0
132 |
133 | def incr():
134 | nonlocal counter
135 | counter += 1
136 |
137 | pinger = Pinger()
138 | pinger.on("before:receive", lambda _: incr())
139 | ponger = Ponger()
140 | ponger.on("before:receive", lambda _: incr())
141 |
142 | message_chain = message_propagate(Ping(sender=ponger, receiver=pinger))
143 |
144 | take(2, message_chain)
145 | assert counter == 2
146 |
147 | take(1, message_chain)
148 | assert counter == 3
149 |
150 | take(7, message_chain)
151 | assert counter == 10
152 |
153 |
154 | class PingPonger(Pinger, Ponger): ...
155 |
156 |
157 | def test_actor_inheritance() -> None:
158 | a = PingPonger()
159 | b = PingPonger()
160 |
161 | for m in take(4, message_propagate(Ping(sender=a, receiver=b))):
162 | match m:
163 | case Ping():
164 | ...
165 | case Pong():
166 | ...
167 | case _:
168 | assert False, m
169 |
170 |
171 | class SoccerBall(Object):
172 | passes: int = 0
173 |
174 |
175 | class BallPass(Message):
176 | ball: SoccerBall
177 |
178 |
179 | class SoccerPlayer(Actor):
180 | name: str
181 | passes: int = 0
182 |
183 | def receive_ball_pass(self, message: BallPass) -> BallPass:
184 | receiver = sample(self.receivers(BallPass))
185 | self.passes += 1
186 | message.ball.passes += 1
187 | return message.forward_to(receiver)
188 |
189 |
190 | class SoccerGame(Network):
191 | def reset(self):
192 | self._graph.clear()
193 | for a, b in combinations(self.actors, 2):
194 | self._graph.add_edge(a, b)
195 |
196 | for player in self.actors:
197 | player.passes = 0
198 |
199 | def play(self):
200 | self.reset()
201 | return message_propagate(
202 | BallPass(
203 | ball=SoccerBall(),
204 | sender=self,
205 | receiver=sample(self.actors),
206 | )
207 | )
208 |
209 |
210 | def test_soccer_network(faker: Faker) -> None:
211 | total_passes = 42
212 | game = SoccerGame(
213 | actors=[SoccerPlayer(name=faker.name()) for _ in range(22)],
214 | )
215 |
216 | with game:
217 | for index, message in zip(range(1, total_passes + 1), game.play()):
218 | match message:
219 | case BallPass():
220 | assert message.ball.passes == index
221 | case _:
222 | assert False, message
223 |
224 | assert total_passes == sum(p.passes for p in game.actors)
225 |
226 |
227 | class Employee(Actor):
228 | name: str
229 |
230 |
231 | class OKR(Message):
232 | objective: str
233 | key_results: list[str]
234 |
235 |
236 | class Company(Network):
237 | def __init__(self, actors: t.Sequence[Employee]):
238 | super().__init__(actors=actors)
239 | """
240 | For systems with static relationships, you can define them in the constructor.
241 |
242 | For dynamic systems, you can use network.receivers(MessageClass, [*MessageClasses]) to
243 | get a list of actors in the network that can receive all the passed MessageClasses.
244 | """
245 | for a, b in combinations(actors, 2):
246 | self._graph.add_edge(a, b)
247 |
248 |
249 | class Direction(Message): ...
250 |
251 |
252 | class Department(Company): ...
253 |
254 |
255 | def test_office_network() -> None:
256 | dunder_mifflin = Company(
257 | actors=[
258 | Employee(name=name)
259 | for name in [
260 | "Michael Scott",
261 | "Dwight Schrute",
262 | "Jim Halpert",
263 | "Pam Beesly",
264 | "Ryan Howard",
265 | "Andy Bernard",
266 | "Robert California",
267 | "Stanley Hudson",
268 | "Kevin Malone",
269 | "Meredith Palmer",
270 | "Angela Martin",
271 | "Oscar Martinez",
272 | "Phyllis Vance",
273 | "Roy Anderson",
274 | "Jan Levinson",
275 | "Kelly Kapoor",
276 | "Toby Flenderson",
277 | "Creed Bratton",
278 | ]
279 | ]
280 | )
281 |
282 | for employee in dunder_mifflin.actors:
283 | assert employee in dunder_mifflin, "Could not find employee in network"
284 |
285 | # Define department membership
286 | sales = Department(
287 | actors=[
288 | e
289 | for e in dunder_mifflin.actors
290 | if e.name
291 | in {"Jim Halpert", "Dwight Schrute", "Stanley Hudson", "Phyllis Vance"}
292 | ]
293 | )
294 |
295 | accounting = Department(
296 | actors=[
297 | e
298 | for e in dunder_mifflin.actors
299 | if e.name in {"Angela Martin", "Oscar Martinez", "Kevin Malone"}
300 | ]
301 | )
302 | warehouse = Department(
303 | actors=[
304 | e
305 | for e in dunder_mifflin.actors
306 | if e.name in {"Darryl Philbin", "Roy Anderson"}
307 | ]
308 | )
309 |
310 | # Test nested contexts
311 | with dunder_mifflin:
312 | for e in dunder_mifflin.actors:
313 | assert e.network == dunder_mifflin
314 | with sales:
315 | for e in sales.actors:
316 | assert e.network == sales
317 | with accounting:
318 | for e in accounting.actors:
319 | assert e.network == accounting
320 | with warehouse:
321 | for e in warehouse.actors:
322 | assert e.network == warehouse
323 | assert e.network == warehouse
324 |
--------------------------------------------------------------------------------
/tests/test_1_dialogue.py:
--------------------------------------------------------------------------------
1 | """
2 | Autogen can be implemented in Llegos, but Llegos can't be implemented in Autogen.
3 | """
4 |
5 |
6 | from random import random
7 |
8 | # matchref lets use use ref.a1, ref.a2, etc. to match on patterns in case statements
9 | from matchref import ref
10 |
11 | from llegos import research as llegos
12 |
13 | """
14 | A message class that has some content.
15 | """
16 |
17 |
18 | class ChatMessage(llegos.Message):
19 | content: str
20 |
21 |
22 | class ChatBot(llegos.Actor):
23 | """
24 | Here we use response to mock the response in testing, but in a real
25 | application, you could use a model to generate a response.
26 | """
27 |
28 | response: str
29 |
30 | def receive_chat_message(self, message: ChatMessage):
31 | """
32 | `message`s of type `MessageClass` are dispatched
33 | to the `receive_{message_class}(message)` method.
34 | """
35 | if random() < 0.25:
36 | return None
37 | return ChatMessage.reply_to(message, content=self.response)
38 |
39 |
40 | class Dialogue(llegos.Network):
41 | def start(self):
42 | """
43 | Since actors can be a part of multiple networks, its important to
44 | scope their usage within the network by using `with {network}:`
45 | """
46 | return llegos.message_propagate(
47 | ChatMessage(
48 | content="Hello",
49 | sender=self.actors[0],
50 | receiver=self.actors[1],
51 | )
52 | )
53 |
54 |
55 | def test_dialogue():
56 | a1 = ChatBot(response="Hello")
57 | a2 = ChatBot(response="Hi")
58 | # Every network has a list of actors
59 | dialogue = Dialogue(actors=[a1, a2])
60 |
61 | with dialogue:
62 | # get the first 5 messages
63 | for msg, _ in zip(dialogue.start(), range(4)):
64 | print(f"{msg.sender.id}->{msg.receiver.id}: {msg.content}")
65 | assert a1.network == dialogue, "the actor's network is dialogue"
66 | match msg:
67 | case ChatMessage(sender=ref.a1, receiver=ref.a2):
68 | assert msg.content == a1.response
69 | case ChatMessage(sender=ref.a2, receiver=ref.a1):
70 | assert msg.content == a2.response
71 | assert msg.content == a2.response
72 |
--------------------------------------------------------------------------------
/tests/test_2_human_console.py:
--------------------------------------------------------------------------------
1 | """
2 | This test/example shows how to use Llegos to implement a human-in-the-loop pattern.
3 | """
4 |
5 | from pprint import pprint
6 |
7 | import pytest
8 |
9 | from llegos import research as llegos
10 |
11 |
12 | class ShellMessage(llegos.Message):
13 | content: str
14 |
15 |
16 | class ShellHuman(llegos.Actor):
17 | """
18 | The receive_missing method is a catch-all method that gets called
19 | if no matching receive_{message} method is found.
20 |
21 | By default, it raises an error, but here we override it so that we can
22 | respond in the shell to the system.
23 | """
24 |
25 | def receive_missing(self, message: llegos.Message):
26 | print(self.id, "received new message", message.__class__.__name__)
27 | pprint(message.model_dump())
28 |
29 | """
30 | Get human input and respond
31 | """
32 | response = input("\nEnter your response: ")
33 | return ShellMessage.reply_to(message, content=response)
34 |
35 |
36 | class ShellBot(llegos.Actor):
37 | def receive_shell_message(self, message: ShellMessage):
38 | return message.reply(content=f"Bot received: {message.content}")
39 |
40 |
41 | @pytest.mark.skipif("not config.getoption('shell')")
42 | def test_shell_input():
43 | user = ShellHuman()
44 | bot = ShellBot()
45 |
46 | initial_content = input("Enter the initial message: ")
47 |
48 | for _ in zip(
49 | llegos.message_propagate(
50 | ShellMessage(sender=user, receiver=bot, content=initial_content)
51 | ),
52 | range(2),
53 | ):
54 | """
55 | Run this test with pytest -sv --shell
56 | You can respond twice and the bot should reply with 'Bot received: {your response}'
57 | """
58 |
--------------------------------------------------------------------------------
/tests/test_3_debate.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to implement a more involved coordination loop,
3 | in the Debate.receive_proposition method.
4 |
5 | This follows the 'map_reduce' pattern, where the Debater is the mapper
6 | (generating responses) and the Judge is the reducer, reviewing all the
7 | generated responses.
8 | """
9 |
10 | from random import random
11 | from typing import Sequence, Union
12 |
13 | from pydantic import Field
14 |
15 | from llegos import research as llegos
16 |
17 |
18 | class Proposition(llegos.Message):
19 | content: str
20 |
21 |
22 | class Rebuttal(llegos.Message):
23 | content: str
24 |
25 |
26 | class Agreement(llegos.Message):
27 | content: str
28 |
29 |
30 | class Debater(llegos.Actor):
31 | """
32 | In real usage, you would probably want to use a model to generate
33 | responses, but for this example, we just use random.
34 | """
35 |
36 | def receive_proposition(self, message: Proposition):
37 | if random() < 0.5:
38 | return Rebuttal.reply_to(message, content="I disagree")
39 | else:
40 | return Agreement.reply_to(message, content="I agree")
41 |
42 | def receive_rebuttal(self, message: Rebuttal):
43 | if random() < 0.5:
44 | return Rebuttal.reply_to(message, content="I disagree")
45 | else:
46 | return Agreement.reply_to(message, content="I agree")
47 |
48 | def receive_argument(self, message: Agreement):
49 | if random() < 0.5:
50 | return Rebuttal.reply_to(message, content="I disagree")
51 | else:
52 | return Agreement.reply_to(message, content="I agree")
53 |
54 |
55 | class Review(llegos.Message):
56 | points: Sequence[Union[Agreement, Rebuttal]]
57 |
58 |
59 | class Verdict(Review):
60 | content: str
61 |
62 |
63 | class Judge(llegos.Actor):
64 | """
65 | In real usage, you would probably want to use a model to generate
66 | responses, but for this example, we just use random.
67 | """
68 |
69 | def receive_review(self, message: Review):
70 | agreements = sum(1 for point in message.points if isinstance(point, Agreement))
71 | rebuttals = sum(1 for point in message.points if isinstance(point, Rebuttal))
72 |
73 | return Verdict.reply_to(
74 | message,
75 | content="I agree" if agreements >= rebuttals else "I disagree",
76 | )
77 |
78 |
79 | class Debate(llegos.Network):
80 | rounds: int = Field(ge=1, le=5)
81 | judge: Judge
82 | debaters: Sequence[Debater]
83 |
84 | def __init__(self, judge: Judge, debaters: Sequence[Debater], **kwargs):
85 | super().__init__(
86 | judge=judge,
87 | debaters=debaters,
88 | **kwargs,
89 | actors=[judge, *debaters],
90 | )
91 |
92 | def receive_proposition(self, message: Proposition):
93 | responses: Sequence[llegos.Message] = []
94 |
95 | """
96 | Initiate {N} rounds of debate
97 | """
98 | for _round in range(self.rounds):
99 | for debater in self.debaters:
100 | response = next(debater.receive(message.forward_to(debater)))
101 | responses.append(response)
102 |
103 | verdict = next(
104 | self.judge.receive(
105 | Review(points=responses, sender=self, receiver=self.judge)
106 | )
107 | )
108 | return verdict.forward_to(message.sender)
109 |
110 |
111 | def test_debate(num_rounds=3):
112 | user = llegos.Actor()
113 | judge = Judge()
114 | debaters = [Debater(), Debater(), Debater()]
115 | debate = Debate(judge=judge, debaters=debaters, rounds=num_rounds)
116 | proposition = Proposition(
117 | sender=user,
118 | receiver=debate,
119 | content="Apple pie is the best",
120 | )
121 |
122 | verdict = next(debate.receive(proposition))
123 | assert isinstance(verdict, Verdict)
124 | assert verdict.content in ("I agree", "I disagree")
125 | assert len(verdict.points) == num_rounds * len(debaters)
126 |
--------------------------------------------------------------------------------
/tests/test_4_map_reduce.py:
--------------------------------------------------------------------------------
1 | from pydantic import Field
2 |
3 | from llegos import research as llegos
4 |
5 |
6 | class MapRequest(llegos.Message):
7 | query: str
8 |
9 |
10 | class MapResponse(llegos.Message):
11 | sources: list[str]
12 |
13 |
14 | class Mapper(llegos.Actor):
15 | sources: list[str] = Field(
16 | description="A list of sources that this actor can query"
17 | )
18 |
19 | def can_receive(self, message):
20 | """
21 | This method is called when receiving a message to determine if the
22 | actor can receive the message. If this method returns False, the
23 | actor will raise a llegos.InvalidMessage error.
24 |
25 | You can use this method to implement custom logic, like checking
26 | if the message is of a certain type, or if the message is from
27 | a certain sender, or if the sender has the proper authorization, etc.
28 |
29 | By default, can_receive verifies that the message.recipient is the
30 | actor, and that the message.sender is not the actor.
31 | """
32 | return super().can_receive(message)
33 |
34 | def receive_map_request(self, request: MapRequest):
35 | """
36 | Method names are f"receive_{camel_case(MessageClassName)}", so the
37 | SourcesRequest message dispatches to this receive_sources_request method.
38 | """
39 | return MapResponse.reply_to(request, sources=self.sources)
40 |
41 |
42 | class ReduceRequest(llegos.Message):
43 | sources: list[str]
44 |
45 |
46 | class ReduceResponse(llegos.Message):
47 | unique_sources: list[str]
48 |
49 |
50 | class Reducer(llegos.Actor):
51 | """
52 | This example reducer just does the set overlap of all the sources returned,
53 | but you can imagine a more complex reducer that does some sort of ranking,
54 | or synthesis/summarization, etc.
55 | """
56 |
57 | def receive_reduce_request(self, request: ReduceRequest):
58 | """
59 | Messages are immutable, so you can't just append to the sources list.
60 | Instead, you have to create a new message with the new sources list.
61 |
62 | MessageClass.reply_to(msg, **kwargs) will create a new message of type
63 | MessageClass by copying over the attributes of msg, and updating (or adding)
64 | to them with the kwargs. In this case, we're creating a new Response message
65 | """
66 | unique_sources = list(set(request.sources))
67 | return ReduceRequest.reply_to(request, unique_sources=unique_sources)
68 |
69 |
70 | class MapReducer(llegos.Network):
71 | """
72 | Networks compose multiple Actors together. Networks are also Actors, so you can
73 | compose Networks together to create more complex Networks.
74 | """
75 |
76 | reducer: Reducer
77 |
78 | def __init__(self, reducer: Reducer, sources: list[Mapper]):
79 | """
80 | The constructor accepts a list of actors which are used to initialize the
81 | underlying graph. This is important! Do not forget to pass actors=[a1, a2, ...]
82 | """
83 | super().__init__(reducer=reducer, actors=[reducer, *sources])
84 | for source in sources:
85 | """
86 | Here's where the magic happens. The llegos.Network class has a private
87 | networkx.MultiGraph that keeps track of all the actors and their relationships.
88 |
89 | The intuition behind this is simple — as humans we have different relationships
90 | in different networks, simultaneously. For example, you might be a friend to
91 | someone in your family, and a sibling to someone in your friend group.
92 |
93 | Llegos lets you model the Family as a Network, and the Friend Group as a Network,
94 | and the same Actor can be in both Networks, and have different relationships
95 | in each Network.
96 | """
97 | self._graph.add_edge(reducer, source, metadata={"key": "value"})
98 |
99 | def receive_map_request(self, request: MapRequest):
100 | """
101 | Use 'with {network}:' to enter the network's context.
102 | Here, all relationships are scoped to that network.
103 | """
104 | with self:
105 | """
106 | First, we use .receivers(MessageClass) to get all the actors in the network that
107 | can receive the SourcesRequest message.
108 | """
109 | sourcers = self.receivers(MapRequest)
110 | sources: list[str] = []
111 | for s in sourcers:
112 | """
113 | Messages have email semantics, so you can use .forward_to(new_actor)
114 | to send a message to another actor, and the message will be of the
115 | same type, but with sender=receiver, and receiver=new_actor
116 | """
117 | msg = request.forward_to(s)
118 | for response in llegos.message_send(msg):
119 | match response:
120 | case MapResponse():
121 | sources.extend(response.sources)
122 |
123 | fuse_req = ReduceRequest(
124 | sources=sources, sender=self, receiver=self.reducer
125 | )
126 | """
127 | message_send returns an iterator, because a receiver can return or yield
128 | multiple messages in response to a single message.
129 |
130 | By using llegos.message_send(message) or Actor.receive(message)
131 | receive_{message} methods that return a single message will be wrapped in
132 |
133 |
134 | Here we use next() to get the first message in the iterator, since we know
135 | that there will only be one.
136 | """
137 | fuse_resp = next(llegos.message_send(fuse_req))
138 | return MapResponse.reply_to(request, sources=fuse_resp.unique_sources)
139 |
140 |
141 | def test_map_reducer():
142 | test_actor = llegos.Actor() # a simple, dummy actor with no utility
143 | sources_map_reducer = MapReducer(
144 | Reducer(),
145 | [
146 | Mapper(sources=["The Hitchhiker's Guide to the Galaxy", "Star Wars"]),
147 | Mapper(sources=["Doctor Who", "Star Wars"]),
148 | ],
149 | )
150 |
151 | request = MapRequest(
152 | sender=test_actor,
153 | receiver=sources_map_reducer,
154 | query="Query?",
155 | )
156 |
157 | response = next(llegos.message_send(request))
158 | assert isinstance(response, MapResponse)
159 | assert sorted(response.sources) == sorted(
160 | [
161 | "The Hitchhiker's Guide to the Galaxy",
162 | "Star Wars",
163 | "Doctor Who",
164 | ]
165 | )
166 |
167 |
168 | def test_nested_map_reducer():
169 | """
170 | Since Networks are Actors, you can compose Networks together to create more complex
171 | Networks. Here, we compose two SourcesMapReducers together in another .
172 |
173 | This works because the MapReducer implements the same interface as a Sources,
174 | so it acts as a drop-in replacement.
175 | """
176 |
177 | test_actor = llegos.Actor() # a simple, dummy actor with no utility
178 | nested_map_reducer = MapReducer(
179 | Reducer(),
180 | [
181 | Mapper(sources=["The Hitchhiker's Guide to the Galaxy", "Star Wars"]),
182 | Mapper(sources=["Doctor Who", "Star Wars"]),
183 | ],
184 | )
185 |
186 | root_map_reducer = MapReducer(
187 | Reducer(),
188 | [
189 | nested_map_reducer,
190 | Mapper(sources=["Star Trek", "Star Wars"]),
191 | ],
192 | )
193 |
194 | request = MapRequest(
195 | sender=test_actor,
196 | receiver=root_map_reducer,
197 | query="Query?",
198 | )
199 |
200 | with root_map_reducer:
201 | """
202 | Use 'with {network}:' to enter the network's context.
203 | Here, all relationships are scoped to that network.
204 | """
205 |
206 | response = next(llegos.message_send(request))
207 | assert isinstance(response, MapResponse)
208 | assert sorted(response.sources) == sorted(
209 | [
210 | "The Hitchhiker's Guide to the Galaxy",
211 | "Star Wars",
212 | "Doctor Who",
213 | "Star Trek",
214 | ]
215 | )
216 |
--------------------------------------------------------------------------------
/tests/test_5_contract_net.py:
--------------------------------------------------------------------------------
1 | """
2 | This example is meant to highlight how easy it is to go from a waterfall diagram
3 | to a working implementation with llegos.
4 |
5 | We are going to implement the Iterative Contract Net protocol as depicted in this diagram:
6 | https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Icnp.svg/880px-Icnp.svg.png
7 |
8 | Contract Net is a task-sharing protocol developed by Reid G. Smith in 1980. It was standardized
9 | by the Foundation for Intelligent Physical Agents as a multi-agent communication protocol.
10 |
11 | Learn more about it here: https://en.m.wikipedia.org/wiki/Contract_Net_Protocol
12 | """
13 |
14 | from typing import Sequence
15 |
16 | from matchref import ref
17 | from pydantic import BaseModel
18 |
19 | from llegos import research as llegos
20 |
21 | """
22 | The first step is to define the messages that will be passed between the actors.
23 | We look at the chart, and for every arrow, we define a message.
24 |
25 | These messages are fully typed pydantic models, so we can make them as complex as required.
26 |
27 | It's recommended you use a library like Instructor or Outlines to generate these with an LLM.
28 | """
29 |
30 |
31 | class CallForProposal(llegos.Message):
32 | task: str
33 |
34 |
35 | class Reject(llegos.Message):
36 | reason: str
37 |
38 |
39 | class Step(BaseModel):
40 | id: int
41 | action: str
42 |
43 |
44 | class Propose(llegos.Message):
45 | "Propose a plan to achieve the objective with the requirements and constraints"
46 | plan: list[Step]
47 |
48 |
49 | class Accept(llegos.Message):
50 | "Accept the proposal from the contractor"
51 | feedback: str
52 |
53 |
54 | class Cancel(llegos.Message):
55 | "Notify the manager that the task has been cancelled"
56 | reason: str
57 |
58 |
59 | class Inform(llegos.Message):
60 | "Inform the manager that the task has been completed"
61 | content: str
62 |
63 |
64 | class Response(llegos.Message):
65 | "Response from the ephemeral network"
66 | content: str
67 |
68 |
69 | """
70 | Now we're going to implement the actors. Let's start with the Contractor.
71 |
72 | For every message that has an arrow pointing to the contractor, we define a method
73 | called `receive_`. This method will be called when the
74 | contractor receives a message of that type.
75 | """
76 |
77 |
78 | class Contractor(llegos.Actor):
79 | """
80 | In reality you'd generate the plan based on the task,
81 | but for this example, we'll just hard-code it.
82 | """
83 |
84 | plan: list[Step]
85 |
86 | def receive_call_for_proposal(self, message: CallForProposal) -> Propose | Reject:
87 | return Propose.reply_to(message, plan=self.plan)
88 |
89 | def receive_accept(self, message: Accept) -> Inform | Cancel:
90 | return Inform.reply_to(message, content="The answer is 42")
91 |
92 | def receive_reject(self, message: Reject):
93 | """
94 | This would be a good opportunity to do some reflection.
95 | """
96 |
97 |
98 | class Manager(llegos.Actor):
99 | """
100 | Sometimes you don't need to support 1 actor existing in multiple networks.
101 | In that case, you can just store references to the relevant actors (contractors here).
102 | """
103 |
104 | contractors: Sequence[llegos.Actor]
105 |
106 | def receive_call_for_proposal(self, message: CallForProposal) -> Propose | Reject:
107 | """
108 | First, we gather all actors in the network that can receive the CallForProposal message
109 | """
110 | for c in self.contractors:
111 | """
112 | Here we yield the different messages we want to send to the contractors.
113 | Why? Because when we yield, it stops the execution of this method, yields
114 | the message, the message will be sent to the contractor, the interaction
115 | will follow, until there are no more messages to send, then we'll continue
116 | to yield the next message to the next contractor, and so on.
117 |
118 | The caller can then iterate on the generated messages until an `Inform` message
119 | is yielded, and then stop the iteration and return the message to its caller.
120 | """
121 | yield message.forward_to(c)
122 | else:
123 | """
124 | If the caller iterates on the generator until it's exhausted, then we know
125 | that no contractor was available to receive the message, so we return a Reject
126 | message to the caller.
127 | """
128 | return Reject.reply_to(message, reason="No contractors available")
129 |
130 | def receive_propose(self, message: Propose) -> Reject | CallForProposal | Accept:
131 | match len(message.plan):
132 | case 1:
133 | return Reject.reply_to(message, reason="I don't like it")
134 | case 2:
135 | return CallForProposal.reply_to(message, task="do something else")
136 | case 3:
137 | return Accept.reply_to(message, feedback="Sounds good")
138 |
139 | def receive_accept(self, message: Accept) -> Inform | Cancel:
140 | """
141 | Find the closest message of type Propose in the ancestors.
142 | """
143 | proposal = next(
144 | m
145 | for m in llegos.message_ancestors(message)
146 | if isinstance(m, Propose) and message.receiver == self
147 | )
148 | return message.forward_to(proposal.sender)
149 |
150 | def receive_reject(self, result: Reject):
151 | ...
152 |
153 | def receive_inform(self, result: Inform):
154 | call_for_proposal = next(
155 | m
156 | for m in llegos.message_ancestors(result)
157 | if isinstance(m, CallForProposal) and m.receiver == self
158 | )
159 | return result.forward_to(call_for_proposal.sender)
160 |
161 | def receive_cancel(self, cancel: Cancel):
162 | ...
163 |
164 |
165 | def test_contract_net():
166 | user = llegos.Actor() # a simple, dummy actor with no utility
167 | manager = Manager(
168 | contractors=[
169 | Contractor(plan=[Step(id=1, action="do the thing")]),
170 | Contractor(
171 | plan=[
172 | Step(id=1, action="do the thing"),
173 | Step(id=2, action="do the other thing"),
174 | ]
175 | ),
176 | Contractor(
177 | plan=[
178 | Step(id=1, action="do the other thing"),
179 | Step(id=2, action="do the thing"),
180 | Step(id=3, action="tell you about it"),
181 | ]
182 | ),
183 | ]
184 | )
185 |
186 | req = CallForProposal(
187 | sender=user,
188 | receiver=manager,
189 | task="do the thing",
190 | )
191 |
192 | """
193 | We can use matchref to match on the reference of the actor that sent the message.
194 | """
195 | for msg in llegos.message_propagate(req):
196 | """
197 | message_propagate that keeps calling message_send on yielded messages.
198 | """
199 | match msg:
200 | case Inform(sender=ref.manager, receiver=ref.user):
201 | assert msg.content == "The answer is 42"
202 | break
203 |
204 |
205 | def test_nested_contract_net():
206 | user = llegos.Actor() # a simple, dummy actor with no utility
207 | manager = Manager(
208 | contractors=[
209 | Contractor(plan=[Step(id=1, action="do the thing")]),
210 | Contractor(
211 | plan=[
212 | Step(id=1, action="do the thing"),
213 | Step(id=2, action="do the other thing"),
214 | ]
215 | ),
216 | Manager(
217 | contractors=[
218 | Contractor(plan=[Step(id=1, action="do the thing")]),
219 | Contractor(
220 | plan=[
221 | Step(id=1, action="do the other thing"),
222 | Step(id=2, action="do the thing"),
223 | Step(id=3, action="tell you about it"),
224 | ]
225 | ),
226 | ]
227 | ),
228 | ]
229 | )
230 |
231 | req = CallForProposal(
232 | sender=user,
233 | receiver=manager,
234 | task="do the thing",
235 | )
236 |
237 | for msg in llegos.message_propagate(req):
238 | """
239 | message_propagate that keeps calling message_send on yielded messages.
240 | """
241 | match msg:
242 | case Inform(receiver=ref.user, sender=ref.manager):
243 | """
244 | This is the Inform message returned to the user.
245 | """
246 | assert msg.content == "The answer is 42"
247 | break
248 | case Reject(receiver=ref.user):
249 | """
250 | This is the Reject message returned to the user.
251 | """
252 | assert msg.reason == "No contractors available"
253 | break
254 |
--------------------------------------------------------------------------------
/tests/test_6_inner_critic.py:
--------------------------------------------------------------------------------
1 | from more_itertools import take
2 |
3 | from llegos import research as llegos
4 |
5 |
6 | class Thought(llegos.Message):
7 | content: str
8 |
9 |
10 | class Criticism(llegos.Message):
11 | content: str
12 |
13 |
14 | class CriticState(llegos.Object):
15 | """
16 | The CriticState is a simple counter that keeps track of how many criticisms it has made.
17 | """
18 |
19 | counter: int = 0
20 |
21 |
22 | class Critic(llegos.Actor):
23 | state: CriticState = CriticState()
24 |
25 | def receive_thought(self, msg: Thought):
26 | """
27 | Process the proposal and return a Criticism
28 | """
29 | self.state.counter += 1
30 | return Criticism.reply_to(msg, content=f"Here's criticism {self.state.counter}")
31 |
32 |
33 | class ThinkerState(llegos.Object):
34 | """
35 | The ThinkerState is a simple counter that keeps track of how many thoughts it has made.
36 | """
37 |
38 | counter: int = 0
39 |
40 |
41 | class Thinker(llegos.Actor):
42 | critic: Critic = Critic()
43 | state: ThinkerState = ThinkerState()
44 |
45 | def receive_thought(self, msg: Thought):
46 | """
47 | Process the thought and return a Proposal
48 | """
49 | return msg.forward_to(self.critic)
50 |
51 | def receive_criticism(self, msg: Criticism):
52 | """
53 | Process the criticism and return an improved Proposal
54 | """
55 | self.state.counter += 1
56 | return Thought.reply_to(
57 | msg,
58 | content=f"You cannot defeat me! {self.state.counter}",
59 | )
60 |
61 |
62 | def test_inner_critic():
63 | user = llegos.Actor()
64 | thinker = Thinker()
65 |
66 | train_of_thought = llegos.message_propagate(
67 | Thought(
68 | sender=user,
69 | receiver=thinker,
70 | content="Here's an idea I want to do!",
71 | )
72 | )
73 |
74 | # let's take the first 5 messages
75 | messages = take(5, train_of_thought)
76 | assert sorted([msg.content for msg in messages]) == sorted(
77 | [
78 | "Here's an idea I want to do!",
79 | "Here's criticism 1",
80 | "You cannot defeat me! 1",
81 | "Here's criticism 2",
82 | "You cannot defeat me! 2",
83 | ]
84 | )
85 |
--------------------------------------------------------------------------------
/tests/test_7_student_teacher.py:
--------------------------------------------------------------------------------
1 | import pydash
2 | from pydantic import Field
3 |
4 | from llegos import research as llegos
5 |
6 |
7 | class Ack(llegos.Message):
8 | content: str
9 |
10 |
11 | class Teaching(llegos.Message):
12 | content: str
13 |
14 |
15 | class Question(llegos.Message):
16 | content: str
17 |
18 |
19 | class StudentState(llegos.Object):
20 | learnings: list[str] = Field(default_factory=list)
21 |
22 |
23 | class Student(llegos.Actor):
24 | reflect_every: int
25 | state: StudentState = Field(default_factory=StudentState)
26 |
27 | def __init__(self, **kwargs):
28 | super().__init__(**kwargs)
29 | """
30 | We can use the before:receive and after:receive events to implement
31 | a simple reflection mechanism.
32 | """
33 |
34 | def before_receive(msg: llegos.Message):
35 | if (
36 | any(self.state.learnings)
37 | and len(self.state.learnings) % self.reflect_every == 0
38 | ):
39 | self.reflect()
40 |
41 | self.on("before:receive", before_receive)
42 |
43 | def reflect(self):
44 | self.state.learnings.pop(0)
45 |
46 | def receive_teaching(self, msg: Teaching):
47 | self.state.learnings.append(msg.content)
48 |
49 | learnings_count = len(self.state.learnings)
50 | return Ack.reply_to(
51 | msg,
52 | content=f"I have learned {learnings_count} things!",
53 | )
54 |
55 |
56 | class TeacherState(llegos.Object):
57 | teachings: list[str] = Field(min_length=1)
58 |
59 |
60 | class Teacher(llegos.Actor):
61 | state: TeacherState
62 |
63 | def receive_question(self, msg: Question):
64 | return Teaching.reply_to(
65 | msg,
66 | content=pydash.sample(self.state.teachings),
67 | )
68 |
69 | def receive_ack(self, msg: Ack):
70 | ...
71 |
72 |
73 | def test_student_question() -> None:
74 | student = Student(reflect_every=2)
75 | teacher = Teacher(
76 | state=TeacherState(teachings=["math", "science", "history"]),
77 | )
78 | msg = Question(sender=student, receiver=teacher, content="What should I learn?")
79 |
80 | messages = list(llegos.message_propagate(msg))
81 | assert len(messages) == 2, "Did not terminate after Ack"
82 | assert messages[0].content in {"math", "science", "history"}
83 | assert messages[0].sender == teacher
84 | assert messages[0].receiver == student
85 | assert messages[1].content == "I have learned 1 things!"
86 | assert messages[1].sender == student
87 | assert messages[1].receiver == teacher
88 |
89 |
90 | def test_student_reflection() -> None:
91 | teachings = ["zen", "art", "motorcycle maintenace"]
92 | teacher = Teacher(
93 | state=TeacherState(teachings=teachings),
94 | )
95 | student = Student(reflect_every=2)
96 |
97 | def teach(content: str):
98 | return Teaching(sender=teacher, receiver=student, content=content)
99 |
100 | list(student.receive(teach(teachings[0])))
101 | list(student.receive(teach(teachings[1])))
102 | assert len(student.state.learnings) == 2
103 | assert student.state.learnings == teachings[:-1]
104 |
105 | list(student.receive(teach(teachings[2])))
106 | assert len(student.state.learnings) == 2, "Should have trimmed learnings"
107 |
108 |
109 | class LearningTeacher(llegos.Actor):
110 | student: Student
111 | teacher: Teacher
112 |
113 | def receive_question(self, msg: Question):
114 | return self.teacher.receive_question(msg)
115 |
116 | def receive_teaching(self, msg: Teaching):
117 | return self.student.receive_teaching(msg)
118 |
119 | def receive_ack(self, msg: Ack):
120 | return self.teacher.receive_ack(msg)
121 |
122 |
123 | def test_learning_teacher() -> None:
124 | science_teacher = LearningTeacher(
125 | student=Student(reflect_every=2),
126 | teacher=Teacher(
127 | state=TeacherState(teachings=["biology", "chemistry", "physics"]),
128 | ),
129 | )
130 | maths_teacher = LearningTeacher(
131 | student=Student(reflect_every=2),
132 | teacher=Teacher(
133 | state=TeacherState(teachings=["algebra", "geometry", "calculus"]),
134 | ),
135 | )
136 |
137 | msg = Question(
138 | sender=maths_teacher,
139 | receiver=science_teacher,
140 | content="What should I learn?",
141 | )
142 |
143 | messages = list(llegos.message_propagate(msg))
144 | assert len(messages) == 2, "Did not terminate after Ack"
145 | assert messages[0].content in {"biology", "chemistry", "physics"}
146 | assert messages[0].sender == science_teacher
147 | assert messages[1].content == "I have learned 1 things!"
148 | assert messages[1].sender == maths_teacher
149 |
150 | msg = Question(
151 | sender=science_teacher,
152 | receiver=maths_teacher,
153 | content="What should I learn?",
154 | )
155 |
156 | messages = list(llegos.message_propagate(msg))
157 | assert len(messages) == 2, "Did not terminate after Ack"
158 | assert messages[0].content in {"algebra", "geometry", "calculus"}
159 | assert messages[0].sender == maths_teacher
160 | assert messages[1].content == "I have learned 1 things!"
161 | assert messages[1].sender == science_teacher
162 |
--------------------------------------------------------------------------------
/tests/test_8_reflexion.py:
--------------------------------------------------------------------------------
1 | """
2 | https://github.com/noahshinn/reflexion
3 | """
4 |
5 | from pydantic import Field
6 |
7 | from llegos import research as llegos
8 |
9 |
10 | class Action(llegos.Message):
11 | text: str
12 |
13 |
14 | class Observation(llegos.Message):
15 | text: str
16 |
17 |
18 | class Reward(llegos.Message):
19 | value: float
20 |
21 |
22 | class Feedback(llegos.Message):
23 | text: str
24 |
25 |
26 | class Trajectory(llegos.Object):
27 | """
28 | Short-term memories
29 | """
30 |
31 | memories: list[str] = Field(default_factory=list)
32 |
33 |
34 | class Experience(llegos.Object):
35 | """
36 | Long-term memories
37 | """
38 |
39 | memories: list[str] = Field(default_factory=list)
40 |
41 |
42 | class EvaluatorLM(llegos.Actor):
43 | trajectory: Trajectory
44 |
45 |
46 | class ActorLM(llegos.Actor):
47 | trajectory: Trajectory
48 | experience: Experience
49 |
50 |
51 | class SelfReflectionLM(llegos.Actor):
52 | experience: Experience
53 |
54 | def receive_feedback(self, feedback: Feedback):
55 | self.experience.memories.append(feedback.text)
56 |
57 |
58 | class Agent(llegos.Actor):
59 | actor: ActorLM
60 | evaluator: EvaluatorLM
61 | self_reflection: SelfReflectionLM
62 |
--------------------------------------------------------------------------------
/tests/test_9_crewai.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | from beartype.typing import Iterator, Optional
4 | from matchref import ref
5 | from pydantic import Field, computed_field
6 | from sorcery import maybe
7 |
8 | from llegos import research as llegos
9 |
10 |
11 | class Task(llegos.Message):
12 | description: str
13 | context: Optional[str] = None
14 |
15 | @computed_field
16 | @property
17 | def content(self) -> str:
18 | if self.context:
19 | return f"{self.context}\n\n{self.description}"
20 | return self.description
21 |
22 |
23 | class Result(llegos.Message):
24 | content: str
25 |
26 |
27 | class Agent(llegos.Actor):
28 | name: str
29 | role: str
30 | goal: str
31 | backstory: str
32 | executor: t.Any = Field(description="AgentExecutor")
33 | counter: int = Field(default=0)
34 |
35 | def receive_task(self, task: Task) -> str:
36 | self.counter += 1
37 | return Result.reply_to(task, content="42" + ("!" * self.counter))
38 |
39 |
40 | class Crew(llegos.Network):
41 | tasks: list[Task]
42 |
43 | def perform(self) -> Iterator[Result]:
44 | result = None
45 | for task in self.tasks:
46 | agent = next(a for a in self.actors if a.can_receive(task))
47 | result = next(
48 | agent.receive(
49 | task.forward_to(
50 | agent,
51 | sender=self,
52 | context=maybe(result).content,
53 | )
54 | )
55 | )
56 | yield result
57 |
58 | def receive_task(self, task: Task) -> Iterator[Result]:
59 | # Plan some tasks
60 | self.tasks = [task]
61 | for result in self.perform():
62 | yield result.forward_to(task.sender)
63 |
64 |
65 | def test_crewai():
66 | crew = Crew(
67 | actors=[
68 | Crew(
69 | actors=[
70 | Agent(
71 | name="Copywriter",
72 | role="copywriter",
73 | goal="",
74 | backstory="",
75 | executor=None,
76 | ),
77 | ],
78 | tasks=[],
79 | ),
80 | Agent(
81 | name="Copywriter",
82 | role="copywriter",
83 | goal="",
84 | backstory="",
85 | executor=None,
86 | ),
87 | Agent(
88 | name="Brand Strategist",
89 | role="brand-strategist",
90 | goal="",
91 | backstory="",
92 | executor=None,
93 | ),
94 | ],
95 | tasks=[
96 | Task(
97 | description="Task 1",
98 | ),
99 | Task(
100 | description="Task 2",
101 | ),
102 | Task(
103 | description="Task 3",
104 | ),
105 | ],
106 | )
107 |
108 | nested_crew = crew.actors[0]
109 | assert isinstance(nested_crew, Crew)
110 | assert nested_crew.tasks == []
111 |
112 | # We can use the generator to keep running tasks until they are all done
113 | for result in crew.perform():
114 | assert result.parent, "Result should have a parent"
115 |
116 | match result:
117 | case Result(sender=ref.nested_crew, content="42!"):
118 | assert result.parent.description == crew.tasks[0].description
119 | case Result(sender=ref.nested_crew, content="42!!"):
120 | assert result.parent.description == crew.tasks[1].description
121 | case Result(sender=ref.nested_crew, content="42!!!"):
122 | assert result.parent.description == crew.tasks[2].description
123 |
--------------------------------------------------------------------------------
/wizard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyrusNuevoDia/llegos/58827163751279dec04247e0519742f78c85ec73/wizard.png
--------------------------------------------------------------------------------