Repository: proxystore/aeris

Issue:
Add simple demo apps
Add simple demo apps for:
- client & single-actor
- single-agent
- multi-agent

Discussion:

Solution patch:
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
new file mode 100644
index 0000000..1c27874
--- /dev/null
+++ b/.github/workflows/examples.yml
@@ -0,0 +1,48 @@
+name: examples
+
+on:
+  push:
+    branches: [main, test-me-*]
+    tags:
+  pull_request:
+  workflow_dispatch:
+
+jobs:
+  run:
+    timeout-minutes: 10
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up Python 3.12
+        uses: actions/setup-python@v5
+        with:
+          python-version: 3.12
+
+      - name: Get pip cache dir
+        id: pip-cache-dir
+        run: echo "PIP_CACHE_DIR=$(pip cache dir)" >> $GITHUB_ENV
+
+      - name: Use pip cache
+        id: pip-cache
+        uses: actions/cache@v4
+        with:
+          path: ${{ env.PIP_CACHE_DIR }}
+          key: examples-ubuntu-latest-pip-3.12-${{ hashFiles('pyproject.toml') }}
+          restore-keys: |
+            examples-ubuntu-latest-pip-3.12-
+
+      - name: Install Package
+        run: python -mpip install .
+
+      - name: Run Example 01
+        run: python examples/01-actor-client/run-01.py
+
+      - name: Run Example 02
+        run: python examples/02-agent-loop/run-02.py
+
+      - name: Run Example 03
+        run: python examples/03-agent-agent/run-03.py
diff --git a/aeris/behavior.py b/aeris/behavior.py
index 7df6ba3..d45af77 100644
--- a/aeris/behavior.py
+++ b/aeris/behavior.py
@@ -56,6 +56,23 @@ def shutdown(self) -> None:
         ...
 
 
+class BehaviorMixin:
+    """Mixin class that add no-op setup and shutdown methods to a class.
+
+    This is a shortcut for writing out empty setup and shutdown methods
+    to satisfy the [`Behavior`][aeris.behavior.Behavior] protocol for
+    behaviors that do not need to specify setup or shutdown.
+    """
+
+    def setup(self) -> None:
+        """No-op setup."""
+        pass
+
+    def shutdown(self) -> None:
+        """No-op shutdown."""
+        pass
+
+
 class Action(Generic[P, R_co], Protocol):
     """Action method protocol."""
 
diff --git a/aeris/handle.py b/aeris/handle.py
index af67fb9..9455819 100644
--- a/aeris/handle.py
+++ b/aeris/handle.py
@@ -42,7 +42,6 @@
 
 P = ParamSpec('P')
 R_co = TypeVar('R_co', covariant=True)
-T_co = TypeVar('T_co', covariant=True)
 
 
 def _validate_state(
@@ -190,7 +189,7 @@ def action(
         /,
         *args: Any,
         **kwargs: Any,
-    ) -> Future[T_co]:
+    ) -> Future[R_co]:
         """Invoke an action on the agent.
 
         Args:
@@ -208,7 +207,7 @@ def action(
             args=args,
             kwargs=kwargs,
         )
-        future: Future[T_co] = Future()
+        future: Future[R_co] = Future()
         self._futures[request.mid] = future
         self._agent_mailbox.send(request)
         logger.debug(f'{self} sent {request}')
diff --git a/examples/01-actor-client/README.md b/examples/01-actor-client/README.md
new file mode 100644
index 0000000..6bb6471
--- /dev/null
+++ b/examples/01-actor-client/README.md
@@ -0,0 +1,3 @@
+# 01: Actor Client
+
+Simple example of launching a stateful actor and invoking actions from a client.
diff --git a/examples/01-actor-client/run-01.py b/examples/01-actor-client/run-01.py
new file mode 100644
index 0000000..866520c
--- /dev/null
+++ b/examples/01-actor-client/run-01.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import logging
+from concurrent.futures import Future
+
+from aeris.behavior import action
+from aeris.behavior import BehaviorMixin
+from aeris.exchange.thread import ThreadExchange
+from aeris.launcher.thread import ThreadLauncher
+
+
+class Counter(BehaviorMixin):
+    count: int
+
+    def setup(self) -> None:
+        self.count = 0
+
+    @action
+    def increment(self, value: int = 1) -> None:
+        self.count += value
+
+    @action
+    def get_count(self) -> int:
+        return self.count
+
+
+def main() -> int:
+    logging.basicConfig(level=logging.DEBUG)
+
+    behavior = Counter()
+    exchange = ThreadExchange()
+
+    with ThreadLauncher(exchange) as launcher:
+        agent = launcher.launch(behavior)
+
+        future: Future[int] = agent.action('get_count')
+        assert future.result() == 0
+
+        agent.action('increment').result()
+
+        future = agent.action('get_count')
+        assert future.result() == 1
+
+        agent.close()
+
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
diff --git a/examples/02-agent-loop/README.md b/examples/02-agent-loop/README.md
new file mode 100644
index 0000000..3d9b50e
--- /dev/null
+++ b/examples/02-agent-loop/README.md
@@ -0,0 +1,3 @@
+# 02: Agent Loops
+
+Example of an agent with a control loop for autonomous behavior.
diff --git a/examples/02-agent-loop/run-02.py b/examples/02-agent-loop/run-02.py
new file mode 100644
index 0000000..f37719e
--- /dev/null
+++ b/examples/02-agent-loop/run-02.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import logging
+import threading
+import time
+from concurrent.futures import Future
+
+from aeris.behavior import action
+from aeris.behavior import BehaviorMixin
+from aeris.behavior import loop
+from aeris.exchange.thread import ThreadExchange
+from aeris.launcher.thread import ThreadLauncher
+
+
+class Counter(BehaviorMixin):
+    count: int
+
+    def setup(self) -> None:
+        self.count = 0
+
+    @loop
+    def increment(self, shutdown: threading.Event) -> None:
+        while not shutdown.is_set():
+            time.sleep(1)
+            self.count += 1
+
+    @action
+    def get_count(self) -> int:
+        return self.count
+
+
+def main() -> int:
+    logging.basicConfig(level=logging.DEBUG)
+
+    behavior = Counter()
+    exchange = ThreadExchange()
+
+    with ThreadLauncher(exchange) as launcher:
+        agent = launcher.launch(behavior)
+
+        time.sleep(2)
+
+        future: Future[int] = agent.action('get_count')
+        assert future.result() >= 1
+
+        agent.close()
+
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
diff --git a/examples/03-agent-agent/README.md b/examples/03-agent-agent/README.md
new file mode 100644
index 0000000..d55f039
--- /dev/null
+++ b/examples/03-agent-agent/README.md
@@ -0,0 +1,3 @@
+# 02: Agent to Agent Interaction
+
+Trivial text processing pipeline where one agent delegates tasks to other agents.
diff --git a/examples/03-agent-agent/run-03.py b/examples/03-agent-agent/run-03.py
new file mode 100644
index 0000000..eb7f4ac
--- /dev/null
+++ b/examples/03-agent-agent/run-03.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import logging
+from concurrent.futures import Future
+
+from aeris.behavior import action
+from aeris.behavior import BehaviorMixin
+from aeris.exchange.thread import ThreadExchange
+from aeris.handle import Handle
+from aeris.launcher.thread import ThreadLauncher
+
+
+class Coordinator(BehaviorMixin):
+    def __init__(self, lowerer: Handle, reverser: Handle) -> None:
+        self.lowerer = lowerer
+        self.reverser = reverser
+
+    @action
+    def process(self, text: str) -> str:
+        text = self.lowerer.action('lower', text).result()
+        text = self.reverser.action('reverse', text).result()
+        return text
+
+
+class Lowerer(BehaviorMixin):
+    @action
+    def lower(self, text: str) -> str:
+        return text.lower()
+
+
+class Reverser(BehaviorMixin):
+    @action
+    def reverse(self, text: str) -> str:
+        return text[::-1]
+
+
+def main() -> int:
+    logging.basicConfig(level=logging.DEBUG)
+
+    with ThreadLauncher(ThreadExchange()) as launcher:
+        lowerer = launcher.launch(Lowerer())
+        reverser = launcher.launch(Reverser())
+        coordinator = launcher.launch(Coordinator(lowerer, reverser))
+
+        text = 'DEADBEEF'
+        expected = 'feebdaed'
+
+        future: Future[str] = coordinator.action('process', text)
+        assert future.result() == expected
+
+        coordinator.close()
+        reverser.close()
+        lowerer.close()
+
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..0446ad1
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,3 @@
+# Examples
+
+Examples of patterns and techniques for building agents.
diff --git a/pyproject.toml b/pyproject.toml
index 583846a..eeca76c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -161,6 +161,7 @@ required-imports = ["from __future__ import annotations"]
 [tool.ruff.lint.per-file-ignores]
 "*/__init__.py" = ["F401"]
 "*/*_test.py" = ["D10"]
+"examples/*" = ["D"]
 
 [tool.ruff.lint.pydocstyle]
 convention = "google"


Test patch:
diff --git a/tests/agent_test.py b/tests/agent_test.py
index 181d688..45e9b4b 100644
--- a/tests/agent_test.py
+++ b/tests/agent_test.py
@@ -6,6 +6,7 @@
 
 from aeris.agent import Agent
 from aeris.behavior import action
+from aeris.behavior import BehaviorMixin
 from aeris.behavior import loop
 from aeris.exception import BadMessageTypeError
 from aeris.exchange.thread import ThreadExchange
@@ -72,16 +73,10 @@ def test_agent_shutdown() -> None:
     assert agent.behavior.shutdown_event.is_set()
 
 
-class Counter:
+class Counter(BehaviorMixin):
     def __init__(self) -> None:
         self._count = 0
 
-    def setup(self) -> None:
-        pass
-
-    def shutdown(self) -> None:
-        pass
-
     @action
     def add(self, value: int) -> None:
         self._count += value
diff --git a/tests/handle_test.py b/tests/handle_test.py
index 4a20fab..cdcc986 100644
--- a/tests/handle_test.py
+++ b/tests/handle_test.py
@@ -7,6 +7,7 @@
 import pytest
 
 from aeris.behavior import action
+from aeris.behavior import BehaviorMixin
 from aeris.exception import HandleClosedError
 from aeris.exchange.thread import ThreadExchange
 from aeris.handle import Handle
@@ -15,16 +16,10 @@
 from testing.constant import TEST_SLEEP
 
 
-class Counter:
+class Counter(BehaviorMixin):
     def __init__(self) -> None:
         self._count = 0
 
-    def setup(self) -> None:
-        pass
-
-    def shutdown(self) -> None:
-        pass
-
     @action
     def add(self, value: int) -> None:
         self._count += value
@@ -116,13 +111,7 @@ def test_handle_operations() -> None:
     exchange.close()
 
 
-class Sleeper:
-    def setup(self) -> None:
-        pass
-
-    def shutdown(self) -> None:
-        pass
-
+class Sleeper(BehaviorMixin):
     @action
     def sleep(self, sleep: float) -> None:
         time.sleep(sleep)