uagent-testkit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uagent_testkit-0.1.0/.github/workflows/ci.yml +21 -0
- uagent_testkit-0.1.0/.gitignore +12 -0
- uagent_testkit-0.1.0/LICENSE +15 -0
- uagent_testkit-0.1.0/PKG-INFO +255 -0
- uagent_testkit-0.1.0/README.md +228 -0
- uagent_testkit-0.1.0/examples/__init__.py +0 -0
- uagent_testkit-0.1.0/examples/quote_agents.py +62 -0
- uagent_testkit-0.1.0/examples/test_quote_agents.py +67 -0
- uagent_testkit-0.1.0/pyproject.toml +49 -0
- uagent_testkit-0.1.0/tests/__init__.py +0 -0
- uagent_testkit-0.1.0/tests/conftest.py +35 -0
- uagent_testkit-0.1.0/tests/test_chat_protocol.py +161 -0
- uagent_testkit-0.1.0/tests/test_harness.py +210 -0
- uagent_testkit-0.1.0/tests/test_network.py +112 -0
- uagent_testkit-0.1.0/tests/test_no_network.py +71 -0
- uagent_testkit-0.1.0/tests/test_verification.py +64 -0
- uagent_testkit-0.1.0/uagent_testkit/__init__.py +48 -0
- uagent_testkit-0.1.0/uagent_testkit/_loop.py +30 -0
- uagent_testkit-0.1.0/uagent_testkit/_recording.py +130 -0
- uagent_testkit-0.1.0/uagent_testkit/_storage.py +58 -0
- uagent_testkit-0.1.0/uagent_testkit/harness.py +396 -0
- uagent_testkit-0.1.0/uagent_testkit/network.py +201 -0
- uagent_testkit-0.1.0/uagent_testkit/plugin.py +57 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[test]"
|
|
21
|
+
- run: pytest -q
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uagent-testkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Test Fetch.ai uAgents without a network, a wallet, or a sleep().
|
|
5
|
+
Project-URL: Homepage, https://github.com/pgalyen1987/fetchai_harness
|
|
6
|
+
Project-URL: Repository, https://github.com/pgalyen1987/fetchai_harness
|
|
7
|
+
Project-URL: Issues, https://github.com/pgalyen1987/fetchai_harness/issues
|
|
8
|
+
Author-email: pgalyen1987 <pgalyen87@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agentverse,asi,fetchai,pytest,testing,uagents
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: uagents>=0.22
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# uagent-testkit
|
|
29
|
+
|
|
30
|
+
Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from uagent_testkit import harness
|
|
34
|
+
|
|
35
|
+
async def test_echo():
|
|
36
|
+
h = harness(my_agent)
|
|
37
|
+
result = await h.deliver(Ping(text="hi"))
|
|
38
|
+
|
|
39
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
40
|
+
assert h.storage["seen"] == 1
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
The uAgents framework has no testing story. To exercise a handler today you generally
|
|
46
|
+
have to stand the agent up for real — which means Almanac registration, endpoint
|
|
47
|
+
resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled through
|
|
48
|
+
the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
|
|
49
|
+
run in CI.
|
|
50
|
+
|
|
51
|
+
Worse, `Agent._handle_message` catches every exception a handler raises and writes it to
|
|
52
|
+
a log. A handler that crashes on every message still looks like a green test. The most
|
|
53
|
+
common agent bug is invisible to the most common agent test.
|
|
54
|
+
|
|
55
|
+
`uagent-testkit` routes messages straight to the registered handler with a context that
|
|
56
|
+
records instead of transmitting. No sockets, no registration, no waiting.
|
|
57
|
+
|
|
58
|
+
- **Handler exceptions are raised**, not swallowed into a log.
|
|
59
|
+
- **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
|
|
60
|
+
stock `KeyValueStore` writes to your working directory.
|
|
61
|
+
- **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
|
|
62
|
+
`Pong`, that's an assertion failure instead of a log line nobody reads.
|
|
63
|
+
- **Interval and lifecycle handlers run on demand**, so an `@on_interval(period=3600)`
|
|
64
|
+
is testable without waiting an hour.
|
|
65
|
+
- **Multi-agent conversations replay deterministically**, with a loop guard instead of
|
|
66
|
+
a hung test.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install uagent-testkit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Requires Python 3.10+ and `uagents>=0.22`. The pytest fixtures register automatically.
|
|
75
|
+
|
|
76
|
+
## Testing one agent
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from uagents import Agent, Context, Model
|
|
80
|
+
from uagent_testkit import harness
|
|
81
|
+
|
|
82
|
+
class Ping(Model):
|
|
83
|
+
text: str
|
|
84
|
+
|
|
85
|
+
class Pong(Model):
|
|
86
|
+
text: str
|
|
87
|
+
|
|
88
|
+
agent = Agent(name="echo", seed="echo seed")
|
|
89
|
+
|
|
90
|
+
@agent.on_message(model=Ping, replies=Pong)
|
|
91
|
+
async def on_ping(ctx: Context, sender: str, msg: Ping):
|
|
92
|
+
ctx.storage.set("seen", (ctx.storage.get("seen") or 0) + 1)
|
|
93
|
+
await ctx.send(sender, Pong(text=f"pong:{msg.text}"))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
async def test_ping():
|
|
98
|
+
h = harness(agent)
|
|
99
|
+
result = await h.deliver(Ping(text="hi"), sender="agent1qexample")
|
|
100
|
+
|
|
101
|
+
result.assert_replied_with(Pong)
|
|
102
|
+
result.assert_reply_contract() # honoured its replies= declaration
|
|
103
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
104
|
+
assert result.sent[0].destination == "agent1qexample"
|
|
105
|
+
assert h.storage["seen"] == 1
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Asserting on failure
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
async def test_bad_input_is_handled():
|
|
112
|
+
result = await h.deliver(Ping(text=""), raise_errors=False)
|
|
113
|
+
assert isinstance(result.error, ValueError)
|
|
114
|
+
assert "empty" in result.logs.errors[0]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
By default `deliver()` re-raises whatever the handler raised, so a broken handler fails
|
|
118
|
+
the test loudly. Pass `raise_errors=False` when the failure is the thing under test.
|
|
119
|
+
|
|
120
|
+
### Sender verification
|
|
121
|
+
|
|
122
|
+
Handlers without `allow_unverified=True` are only reachable from a verified agent
|
|
123
|
+
address. The harness enforces that, so a test can't pass for a flow production
|
|
124
|
+
rejects:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from uagent_testkit import UnverifiedSender, user_sender
|
|
128
|
+
|
|
129
|
+
async def test_strict_handler_refuses_users():
|
|
130
|
+
with pytest.raises(UnverifiedSender):
|
|
131
|
+
await h.deliver(Ping(text="hi"), sender=user_sender())
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Intervals and lifecycle
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
await h.startup() # runs @on_event("startup") handlers
|
|
138
|
+
result = await h.tick() # runs @on_interval handlers once, ignoring period
|
|
139
|
+
result = await h.tick(only="heartbeat") # just one, by function name
|
|
140
|
+
await h.shutdown()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Testing agents against each other
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from uagent_testkit import AgentNetwork
|
|
147
|
+
|
|
148
|
+
async def test_conversation():
|
|
149
|
+
net = AgentNetwork(alice, bob)
|
|
150
|
+
transcript = await net.send(Ping(text="hello"), to=bob, sender=alice)
|
|
151
|
+
|
|
152
|
+
transcript.assert_delivered(Pong, to=alice.address)
|
|
153
|
+
assert transcript.of(Pong)[0].text == "pong:hello"
|
|
154
|
+
assert net.harness(alice).storage["last_pong"] == "pong:hello"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Every reply addressed to another agent in the network is delivered in turn until the
|
|
158
|
+
conversation goes quiet. Messages sent to addresses outside the network are collected on
|
|
159
|
+
`transcript.undelivered` rather than silently dropped:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
transcript.assert_all_delivered() # fails if anything went to an unknown address
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Agents that answer each other forever raise `ConversationTooLong` after `max_rounds`
|
|
166
|
+
deliveries instead of hanging the suite. This catches echo loops, which are easy to
|
|
167
|
+
write and expensive to discover in production:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# a keyword responder whose answer contains its own trigger word
|
|
171
|
+
if "menu" in msg.text().lower():
|
|
172
|
+
await ctx.send(sender, chat("here is the menu")) # -> contains "menu"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Two agents like that will talk to each other indefinitely. On a live network that is
|
|
176
|
+
two agents spamming each other; here it fails in milliseconds with the transcript
|
|
177
|
+
so far attached.
|
|
178
|
+
|
|
179
|
+
## Protocols and the chat protocol
|
|
180
|
+
|
|
181
|
+
Agents assembled from `Protocol` objects work the same way, including the shared chat
|
|
182
|
+
protocol from `uagents_core` that Agentverse and Agent Launch agents use:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
chat = Protocol(spec=chat_protocol_spec)
|
|
186
|
+
|
|
187
|
+
@chat.on_message(ChatMessage)
|
|
188
|
+
async def on_chat(ctx: Context, sender: str, msg: ChatMessage):
|
|
189
|
+
...
|
|
190
|
+
|
|
191
|
+
agent.include(chat)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
async def test_chat():
|
|
196
|
+
h = harness(agent)
|
|
197
|
+
result = await h.deliver(
|
|
198
|
+
ChatMessage(
|
|
199
|
+
timestamp=datetime.now(timezone.utc),
|
|
200
|
+
msg_id=uuid4(),
|
|
201
|
+
content=[TextContent(type="text", text="show me the menu")],
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
assert result.replies(ChatAcknowledgement)
|
|
205
|
+
assert result.replies(ChatMessage)[0].text() == "here is what we have"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## pytest fixtures
|
|
209
|
+
|
|
210
|
+
Installing the package registers two factory fixtures:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
def test_with_fixture(agent_harness, agent_network):
|
|
214
|
+
h = agent_harness(my_agent)
|
|
215
|
+
net = agent_network(alice, bob)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## API
|
|
219
|
+
|
|
220
|
+
| | |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `harness(agent)` | Wrap an agent. Swaps in in-memory storage and merges the internal protocol. |
|
|
223
|
+
| `h.deliver(msg, sender=…, raise_errors=True)` | Route a message to its handler. Returns `Delivery`. |
|
|
224
|
+
| `h.tick(only=None)` | Run `@on_interval` handlers once. |
|
|
225
|
+
| `h.startup()` / `h.shutdown()` | Run `@on_event` handlers. |
|
|
226
|
+
| `h.storage` | In-memory store, subscriptable. |
|
|
227
|
+
| `h.handles(Model)` | Whether a handler is registered for a type. |
|
|
228
|
+
| `Delivery.sent` | List of `Sent(destination, schema_digest, body)`. |
|
|
229
|
+
| `Delivery.reply(Model)` | The single reply of that type; raises otherwise. |
|
|
230
|
+
| `Delivery.replies(Model, to=…)` | Replies of that type to the sender; `to=ANY` for any destination. |
|
|
231
|
+
| `user_sender()` | A user (non-agent) address, for `allow_unverified` paths. |
|
|
232
|
+
| `Delivery.error` / `.logs` | Handler exception and captured log records. |
|
|
233
|
+
| `Delivery.assert_replied_with` / `assert_silent` / `assert_no_errors` / `assert_reply_contract` | Chainable assertions. |
|
|
234
|
+
| `AgentNetwork(*agents)` | Multi-agent network. |
|
|
235
|
+
| `net.send(msg, to=…, sender=…, max_rounds=20)` | Play out a conversation. Returns `Transcript`. |
|
|
236
|
+
| `Transcript.of(Model)` / `.to(addr)` / `.undelivered` | Inspect what was exchanged. |
|
|
237
|
+
|
|
238
|
+
## Scope
|
|
239
|
+
|
|
240
|
+
This exercises your handler logic: routing, replies, storage, protocol contracts, and
|
|
241
|
+
inter-agent flow. It deliberately does not simulate Almanac resolution, envelope
|
|
242
|
+
signing, or on-chain settlement — those are integration concerns and mocking them would
|
|
243
|
+
give you false confidence. Keep a thin integration test on a real testnet for those, and
|
|
244
|
+
use this for everything else.
|
|
245
|
+
|
|
246
|
+
## Running the tests
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
python -m venv .venv && .venv/bin/pip install -e ".[test]"
|
|
250
|
+
.venv/bin/python -m pytest
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
Apache-2.0
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# uagent-testkit
|
|
2
|
+
|
|
3
|
+
Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from uagent_testkit import harness
|
|
7
|
+
|
|
8
|
+
async def test_echo():
|
|
9
|
+
h = harness(my_agent)
|
|
10
|
+
result = await h.deliver(Ping(text="hi"))
|
|
11
|
+
|
|
12
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
13
|
+
assert h.storage["seen"] == 1
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
The uAgents framework has no testing story. To exercise a handler today you generally
|
|
19
|
+
have to stand the agent up for real — which means Almanac registration, endpoint
|
|
20
|
+
resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled through
|
|
21
|
+
the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
|
|
22
|
+
run in CI.
|
|
23
|
+
|
|
24
|
+
Worse, `Agent._handle_message` catches every exception a handler raises and writes it to
|
|
25
|
+
a log. A handler that crashes on every message still looks like a green test. The most
|
|
26
|
+
common agent bug is invisible to the most common agent test.
|
|
27
|
+
|
|
28
|
+
`uagent-testkit` routes messages straight to the registered handler with a context that
|
|
29
|
+
records instead of transmitting. No sockets, no registration, no waiting.
|
|
30
|
+
|
|
31
|
+
- **Handler exceptions are raised**, not swallowed into a log.
|
|
32
|
+
- **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
|
|
33
|
+
stock `KeyValueStore` writes to your working directory.
|
|
34
|
+
- **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
|
|
35
|
+
`Pong`, that's an assertion failure instead of a log line nobody reads.
|
|
36
|
+
- **Interval and lifecycle handlers run on demand**, so an `@on_interval(period=3600)`
|
|
37
|
+
is testable without waiting an hour.
|
|
38
|
+
- **Multi-agent conversations replay deterministically**, with a loop guard instead of
|
|
39
|
+
a hung test.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install uagent-testkit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requires Python 3.10+ and `uagents>=0.22`. The pytest fixtures register automatically.
|
|
48
|
+
|
|
49
|
+
## Testing one agent
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from uagents import Agent, Context, Model
|
|
53
|
+
from uagent_testkit import harness
|
|
54
|
+
|
|
55
|
+
class Ping(Model):
|
|
56
|
+
text: str
|
|
57
|
+
|
|
58
|
+
class Pong(Model):
|
|
59
|
+
text: str
|
|
60
|
+
|
|
61
|
+
agent = Agent(name="echo", seed="echo seed")
|
|
62
|
+
|
|
63
|
+
@agent.on_message(model=Ping, replies=Pong)
|
|
64
|
+
async def on_ping(ctx: Context, sender: str, msg: Ping):
|
|
65
|
+
ctx.storage.set("seen", (ctx.storage.get("seen") or 0) + 1)
|
|
66
|
+
await ctx.send(sender, Pong(text=f"pong:{msg.text}"))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
async def test_ping():
|
|
71
|
+
h = harness(agent)
|
|
72
|
+
result = await h.deliver(Ping(text="hi"), sender="agent1qexample")
|
|
73
|
+
|
|
74
|
+
result.assert_replied_with(Pong)
|
|
75
|
+
result.assert_reply_contract() # honoured its replies= declaration
|
|
76
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
77
|
+
assert result.sent[0].destination == "agent1qexample"
|
|
78
|
+
assert h.storage["seen"] == 1
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Asserting on failure
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
async def test_bad_input_is_handled():
|
|
85
|
+
result = await h.deliver(Ping(text=""), raise_errors=False)
|
|
86
|
+
assert isinstance(result.error, ValueError)
|
|
87
|
+
assert "empty" in result.logs.errors[0]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
By default `deliver()` re-raises whatever the handler raised, so a broken handler fails
|
|
91
|
+
the test loudly. Pass `raise_errors=False` when the failure is the thing under test.
|
|
92
|
+
|
|
93
|
+
### Sender verification
|
|
94
|
+
|
|
95
|
+
Handlers without `allow_unverified=True` are only reachable from a verified agent
|
|
96
|
+
address. The harness enforces that, so a test can't pass for a flow production
|
|
97
|
+
rejects:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from uagent_testkit import UnverifiedSender, user_sender
|
|
101
|
+
|
|
102
|
+
async def test_strict_handler_refuses_users():
|
|
103
|
+
with pytest.raises(UnverifiedSender):
|
|
104
|
+
await h.deliver(Ping(text="hi"), sender=user_sender())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Intervals and lifecycle
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
await h.startup() # runs @on_event("startup") handlers
|
|
111
|
+
result = await h.tick() # runs @on_interval handlers once, ignoring period
|
|
112
|
+
result = await h.tick(only="heartbeat") # just one, by function name
|
|
113
|
+
await h.shutdown()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Testing agents against each other
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from uagent_testkit import AgentNetwork
|
|
120
|
+
|
|
121
|
+
async def test_conversation():
|
|
122
|
+
net = AgentNetwork(alice, bob)
|
|
123
|
+
transcript = await net.send(Ping(text="hello"), to=bob, sender=alice)
|
|
124
|
+
|
|
125
|
+
transcript.assert_delivered(Pong, to=alice.address)
|
|
126
|
+
assert transcript.of(Pong)[0].text == "pong:hello"
|
|
127
|
+
assert net.harness(alice).storage["last_pong"] == "pong:hello"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Every reply addressed to another agent in the network is delivered in turn until the
|
|
131
|
+
conversation goes quiet. Messages sent to addresses outside the network are collected on
|
|
132
|
+
`transcript.undelivered` rather than silently dropped:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
transcript.assert_all_delivered() # fails if anything went to an unknown address
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Agents that answer each other forever raise `ConversationTooLong` after `max_rounds`
|
|
139
|
+
deliveries instead of hanging the suite. This catches echo loops, which are easy to
|
|
140
|
+
write and expensive to discover in production:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
# a keyword responder whose answer contains its own trigger word
|
|
144
|
+
if "menu" in msg.text().lower():
|
|
145
|
+
await ctx.send(sender, chat("here is the menu")) # -> contains "menu"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Two agents like that will talk to each other indefinitely. On a live network that is
|
|
149
|
+
two agents spamming each other; here it fails in milliseconds with the transcript
|
|
150
|
+
so far attached.
|
|
151
|
+
|
|
152
|
+
## Protocols and the chat protocol
|
|
153
|
+
|
|
154
|
+
Agents assembled from `Protocol` objects work the same way, including the shared chat
|
|
155
|
+
protocol from `uagents_core` that Agentverse and Agent Launch agents use:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
chat = Protocol(spec=chat_protocol_spec)
|
|
159
|
+
|
|
160
|
+
@chat.on_message(ChatMessage)
|
|
161
|
+
async def on_chat(ctx: Context, sender: str, msg: ChatMessage):
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
agent.include(chat)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
async def test_chat():
|
|
169
|
+
h = harness(agent)
|
|
170
|
+
result = await h.deliver(
|
|
171
|
+
ChatMessage(
|
|
172
|
+
timestamp=datetime.now(timezone.utc),
|
|
173
|
+
msg_id=uuid4(),
|
|
174
|
+
content=[TextContent(type="text", text="show me the menu")],
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
assert result.replies(ChatAcknowledgement)
|
|
178
|
+
assert result.replies(ChatMessage)[0].text() == "here is what we have"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## pytest fixtures
|
|
182
|
+
|
|
183
|
+
Installing the package registers two factory fixtures:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
def test_with_fixture(agent_harness, agent_network):
|
|
187
|
+
h = agent_harness(my_agent)
|
|
188
|
+
net = agent_network(alice, bob)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## API
|
|
192
|
+
|
|
193
|
+
| | |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `harness(agent)` | Wrap an agent. Swaps in in-memory storage and merges the internal protocol. |
|
|
196
|
+
| `h.deliver(msg, sender=…, raise_errors=True)` | Route a message to its handler. Returns `Delivery`. |
|
|
197
|
+
| `h.tick(only=None)` | Run `@on_interval` handlers once. |
|
|
198
|
+
| `h.startup()` / `h.shutdown()` | Run `@on_event` handlers. |
|
|
199
|
+
| `h.storage` | In-memory store, subscriptable. |
|
|
200
|
+
| `h.handles(Model)` | Whether a handler is registered for a type. |
|
|
201
|
+
| `Delivery.sent` | List of `Sent(destination, schema_digest, body)`. |
|
|
202
|
+
| `Delivery.reply(Model)` | The single reply of that type; raises otherwise. |
|
|
203
|
+
| `Delivery.replies(Model, to=…)` | Replies of that type to the sender; `to=ANY` for any destination. |
|
|
204
|
+
| `user_sender()` | A user (non-agent) address, for `allow_unverified` paths. |
|
|
205
|
+
| `Delivery.error` / `.logs` | Handler exception and captured log records. |
|
|
206
|
+
| `Delivery.assert_replied_with` / `assert_silent` / `assert_no_errors` / `assert_reply_contract` | Chainable assertions. |
|
|
207
|
+
| `AgentNetwork(*agents)` | Multi-agent network. |
|
|
208
|
+
| `net.send(msg, to=…, sender=…, max_rounds=20)` | Play out a conversation. Returns `Transcript`. |
|
|
209
|
+
| `Transcript.of(Model)` / `.to(addr)` / `.undelivered` | Inspect what was exchanged. |
|
|
210
|
+
|
|
211
|
+
## Scope
|
|
212
|
+
|
|
213
|
+
This exercises your handler logic: routing, replies, storage, protocol contracts, and
|
|
214
|
+
inter-agent flow. It deliberately does not simulate Almanac resolution, envelope
|
|
215
|
+
signing, or on-chain settlement — those are integration concerns and mocking them would
|
|
216
|
+
give you false confidence. Keep a thin integration test on a real testnet for those, and
|
|
217
|
+
use this for everything else.
|
|
218
|
+
|
|
219
|
+
## Running the tests
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
python -m venv .venv && .venv/bin/pip install -e ".[test]"
|
|
223
|
+
.venv/bin/python -m pytest
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
Apache-2.0
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""A two-agent quote flow, written the way the uAgents docs write agents.
|
|
2
|
+
|
|
3
|
+
Note the module-level `Agent(...)` calls — the standard idiom. A trader asks an
|
|
4
|
+
oracle for a price; the oracle answers from its cache or records a miss.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from uagents import Agent, Context, Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuoteRequest(Model):
|
|
11
|
+
symbol: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Quote(Model):
|
|
15
|
+
symbol: str
|
|
16
|
+
price: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QuoteUnavailable(Model):
|
|
20
|
+
symbol: str
|
|
21
|
+
reason: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PRICES = {"FET": 1.42, "BNB": 604.10}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
oracle = Agent(name="oracle", seed="uagent-testkit example oracle seed")
|
|
28
|
+
trader = Agent(name="trader", seed="uagent-testkit example trader seed")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@oracle.on_message(model=QuoteRequest, replies={Quote, QuoteUnavailable})
|
|
32
|
+
async def on_quote_request(ctx: Context, sender: str, msg: QuoteRequest):
|
|
33
|
+
symbol = msg.symbol.upper()
|
|
34
|
+
served = ctx.storage.get("served") or 0
|
|
35
|
+
ctx.storage.set("served", served + 1)
|
|
36
|
+
|
|
37
|
+
price = PRICES.get(symbol)
|
|
38
|
+
if price is None:
|
|
39
|
+
misses = ctx.storage.get("misses") or []
|
|
40
|
+
ctx.storage.set("misses", [*misses, symbol])
|
|
41
|
+
await ctx.send(
|
|
42
|
+
sender, QuoteUnavailable(symbol=symbol, reason="symbol not tracked")
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
await ctx.send(sender, Quote(symbol=symbol, price=price))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@trader.on_message(model=Quote)
|
|
50
|
+
async def on_quote(ctx: Context, sender: str, msg: Quote):
|
|
51
|
+
ctx.storage.set("last_price", msg.price)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@trader.on_message(model=QuoteUnavailable)
|
|
55
|
+
async def on_quote_unavailable(ctx: Context, sender: str, msg: QuoteUnavailable):
|
|
56
|
+
ctx.storage.set("last_error", msg.reason)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@trader.on_interval(period=60.0, messages=QuoteRequest)
|
|
60
|
+
async def poll_price(ctx: Context):
|
|
61
|
+
"""Would fire once a minute in production; the harness runs it on demand."""
|
|
62
|
+
await ctx.send(oracle.address, QuoteRequest(symbol="FET"))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The same flow, tested end to end.
|
|
2
|
+
|
|
3
|
+
Every test here executes in under 5ms; the only real cost in the file is deriving
|
|
4
|
+
the agents' keys from their seeds once at import.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from uagent_testkit import AgentNetwork, harness
|
|
8
|
+
|
|
9
|
+
from .quote_agents import (
|
|
10
|
+
Quote,
|
|
11
|
+
QuoteRequest,
|
|
12
|
+
QuoteUnavailable,
|
|
13
|
+
oracle,
|
|
14
|
+
trader,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def test_oracle_answers_a_known_symbol():
|
|
19
|
+
h = harness(oracle)
|
|
20
|
+
result = await h.deliver(QuoteRequest(symbol="FET"))
|
|
21
|
+
|
|
22
|
+
assert result.reply(Quote).price == 1.42
|
|
23
|
+
result.assert_reply_contract()
|
|
24
|
+
assert h.storage["served"] == 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_oracle_reports_an_unknown_symbol():
|
|
28
|
+
h = harness(oracle)
|
|
29
|
+
result = await h.deliver(QuoteRequest(symbol="DOGE"))
|
|
30
|
+
|
|
31
|
+
assert result.reply(QuoteUnavailable).reason == "symbol not tracked"
|
|
32
|
+
assert h.storage["misses"] == ["DOGE"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_symbols_are_normalised():
|
|
36
|
+
h = harness(oracle)
|
|
37
|
+
result = await h.deliver(QuoteRequest(symbol="bnb"))
|
|
38
|
+
|
|
39
|
+
assert result.reply(Quote).symbol == "BNB"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_full_round_trip_between_two_agents():
|
|
43
|
+
net = AgentNetwork(trader, oracle)
|
|
44
|
+
transcript = await net.send(
|
|
45
|
+
QuoteRequest(symbol="FET"), to=oracle, sender=trader
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
transcript.assert_delivered(Quote, to=trader.address)
|
|
49
|
+
transcript.assert_all_delivered()
|
|
50
|
+
assert net.harness(trader).storage["last_price"] == 1.42
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_failure_path_reaches_the_trader():
|
|
54
|
+
net = AgentNetwork(trader, oracle)
|
|
55
|
+
transcript = await net.send(
|
|
56
|
+
QuoteRequest(symbol="NOPE"), to=oracle, sender=trader
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
transcript.assert_delivered(QuoteUnavailable, to=trader.address)
|
|
60
|
+
assert net.harness(trader).storage["last_error"] == "symbol not tracked"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_the_minute_poll_without_waiting_a_minute():
|
|
64
|
+
h = harness(trader)
|
|
65
|
+
result = await h.tick(only="poll_price")
|
|
66
|
+
|
|
67
|
+
assert result.reply(QuoteRequest, to=oracle.address).symbol == "FET"
|