tarmac-agent 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.
- tarmac_agent-0.1.0/.github/workflows/ci.yml +23 -0
- tarmac_agent-0.1.0/.github/workflows/release.yml +43 -0
- tarmac_agent-0.1.0/.gitignore +7 -0
- tarmac_agent-0.1.0/CHANGELOG.md +10 -0
- tarmac_agent-0.1.0/LICENSE +21 -0
- tarmac_agent-0.1.0/PKG-INFO +133 -0
- tarmac_agent-0.1.0/README.md +115 -0
- tarmac_agent-0.1.0/SECURITY.md +5 -0
- tarmac_agent-0.1.0/examples/careful_vs_reckless.py +55 -0
- tarmac_agent-0.1.0/examples/quickstart.py +36 -0
- tarmac_agent-0.1.0/pyproject.toml +32 -0
- tarmac_agent-0.1.0/src/tarmac_agent/__init__.py +37 -0
- tarmac_agent-0.1.0/src/tarmac_agent/_client.py +214 -0
- tarmac_agent-0.1.0/src/tarmac_agent/_errors.py +12 -0
- tarmac_agent-0.1.0/src/tarmac_agent/_types.py +79 -0
- tarmac_agent-0.1.0/src/tarmac_agent/_version.py +1 -0
- tarmac_agent-0.1.0/src/tarmac_agent/py.typed +0 -0
- tarmac_agent-0.1.0/tests/test_client.py +170 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python: ["3.10", "3.12", "3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python }}
|
|
20
|
+
- run: python -m pip install --upgrade pip build
|
|
21
|
+
- run: python -m pip install -e .
|
|
22
|
+
- run: python -m unittest discover -s tests -v
|
|
23
|
+
- run: python -m build
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.12"
|
|
15
|
+
- run: python -m pip install --upgrade pip build
|
|
16
|
+
- run: python -m pip install -e .
|
|
17
|
+
- name: Tag must match the package version
|
|
18
|
+
run: |
|
|
19
|
+
version=$(python -c "import tarmac_agent; print(tarmac_agent.__version__)")
|
|
20
|
+
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
|
21
|
+
echo "Tag $GITHUB_REF_NAME does not match package version $version" >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
- run: python -m unittest discover -s tests -v
|
|
25
|
+
- run: python -m build
|
|
26
|
+
- uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
publish:
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
url: https://pypi.org/p/tarmac-agent
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write # Trusted Publishing: PyPI verifies this workflow, no token needed
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release on PyPI.
|
|
6
|
+
|
|
7
|
+
- `TarmacClient`, `run_agent`, `attach`, typed tools and `TarmacToolError`. Standard library only.
|
|
8
|
+
- Automatic retries with exponential backoff (`retries=`, `backoff=` on `TarmacClient`). Swap requests are never re-sent after a network failure.
|
|
9
|
+
- `TarmacClient.from_env()` and `attach_from_env()` read `TARMAC_URL`, `TARMAC_RUN_ID` and `TARMAC_RUN_TOKEN`.
|
|
10
|
+
- Examples: `quickstart.py` and `careful_vs_reckless.py`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tarmac Agent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tarmac-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK to test your on-chain agent against Tarmac: real Robinhood Chain data, isolated wallets, policy checks on every swap.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tarmacagent/tarmac-python
|
|
6
|
+
Project-URL: Issues, https://github.com/tarmacagent/tarmac-python/issues
|
|
7
|
+
Author: Tarmac Agent
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,ai-agent,defi,onchain,robinhood-chain,testing,uniswap
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# tarmac-agent
|
|
20
|
+
|
|
21
|
+
[](https://github.com/tarmacagent/tarmac-python/actions/workflows/ci.yml)   
|
|
22
|
+
|
|
23
|
+
**Test your on-chain agent before it goes live, from Python.**
|
|
24
|
+
|
|
25
|
+
Tarmac runs your agent against a live fork of Robinhood Chain with an isolated simulated wallet, applies the same policy checks to every swap request, and records a trace of every tool call, check and balance change. Your agent runs on your machine and never holds a key.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install tarmac-agent
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **Coming to PyPI soon.** Until the first release lands, install straight from GitHub:
|
|
32
|
+
>
|
|
33
|
+
> ```bash
|
|
34
|
+
> pip install "git+https://github.com/tarmacagent/tarmac-python"
|
|
35
|
+
> ```
|
|
36
|
+
|
|
37
|
+
Standard library only: no dependencies.
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from tarmac_agent import TarmacClient, TarmacTools, run_agent
|
|
43
|
+
|
|
44
|
+
client = TarmacClient("https://your-tarmac-server")
|
|
45
|
+
|
|
46
|
+
def my_agent(tools: TarmacTools) -> None:
|
|
47
|
+
ctx = tools.get_context()
|
|
48
|
+
pair = dict(token_in=ctx["tokenIn"]["address"], token_out=ctx["tokenOut"]["address"])
|
|
49
|
+
quote = tools.get_quote(**pair, amount_in=ctx["amountIn"])
|
|
50
|
+
|
|
51
|
+
if quote["slippageBps"] > ctx["policy"]["maxSlippageBps"]:
|
|
52
|
+
tools.finish("stopped", "Slippage over the limit.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
tools.request_swap(quote_id=quote["quoteId"], **pair, amount_in=ctx["amountIn"])
|
|
56
|
+
tools.finish("swapped")
|
|
57
|
+
|
|
58
|
+
out = run_agent(client, my_agent, scenario="swap-thin", agent_name="my-python-agent")
|
|
59
|
+
print(out["run"]["result"]["verdictLabel"]) # "Followed the rule and stopped"
|
|
60
|
+
print(out["session"].run_url) # full trace in the browser
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Servers that require sign-in
|
|
64
|
+
|
|
65
|
+
Create the run in the Tarmac app (**Run → Your agent**), then attach:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
run_agent(client, my_agent, run_id=os.environ["TARMAC_RUN_ID"], run_token=os.environ["TARMAC_RUN_TOKEN"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### From environment variables
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client = TarmacClient.from_env() # $TARMAC_URL, default http://localhost:3000
|
|
75
|
+
session = client.attach_from_env() # $TARMAC_RUN_ID + $TARMAC_RUN_TOKEN
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Examples
|
|
79
|
+
|
|
80
|
+
- [`examples/quickstart.py`](examples/quickstart.py): one careful agent, start to verdict.
|
|
81
|
+
- [`examples/careful_vs_reckless.py`](examples/careful_vs_reckless.py): two agents on the same thin market. The careful one stops; the reckless one is blocked by policy.
|
|
82
|
+
|
|
83
|
+
## Tools
|
|
84
|
+
|
|
85
|
+
| Method | Arguments | Returns |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `get_context()` | — | instruction, wallet, tokens, amount, policy limits |
|
|
88
|
+
| `get_balances()` | — | wallet balances (integer base units as strings) |
|
|
89
|
+
| `get_quote()` | `token_in, token_out, amount_in` | `quoteId`, `amountOut`, reference, `slippageBps`, eligibility |
|
|
90
|
+
| `request_swap()` | `quote_id, token_in, token_out, amount_in, min_amount_out=None, reason=None` | `executed` / `blocked` / `failed` + every policy check |
|
|
91
|
+
| `finish()` | `outcome, summary=None` | the run's verdict |
|
|
92
|
+
|
|
93
|
+
Responses keep the API's field names (`slippageBps`, `quoteId`, …) so they match the TypeScript SDK and the HTTP API exactly. Every response is a `TypedDict`, so editors autocomplete them.
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
Failures raise `TarmacToolError` with `.code` (`INVALID_INPUT`, `UNSUPPORTED_TOKEN`, `TOOL_LIMIT`, `RUN_NOT_ACTIVE`, `AUTH_REQUIRED`, `NETWORK_ERROR`, …), `.message` and `.retryable`.
|
|
98
|
+
|
|
99
|
+
### Retries
|
|
100
|
+
|
|
101
|
+
Transient failures are retried automatically with exponential backoff (2 retries by default):
|
|
102
|
+
|
|
103
|
+
- **Rejected before any work** (HTTP 429/503 marked retryable): every call is retried.
|
|
104
|
+
- **Network failures**: only read-only calls (`get_context`, `get_balances`, `get_quote`, `get_run`) are retried. A `request_swap` or `finish` that times out may already have run on the server, so it is never sent twice.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
client = TarmacClient("https://your-tarmac-server", retries=4, backoff=0.25) # or retries=0 to disable
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python -m unittest discover -s tests -v
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Releasing
|
|
117
|
+
|
|
118
|
+
Releases publish to PyPI from GitHub Actions with [Trusted Publishing](https://docs.pypi.org/trusted-publishers/): no API token is stored anywhere.
|
|
119
|
+
|
|
120
|
+
1. Bump `__version__` in `src/tarmac_agent/_version.py` and move the changelog's *Unreleased* notes under that version.
|
|
121
|
+
2. Tag and push: `git tag v0.1.0 && git push origin v0.1.0`.
|
|
122
|
+
|
|
123
|
+
The workflow refuses to publish if the tag and the package version disagree.
|
|
124
|
+
|
|
125
|
+
## Related
|
|
126
|
+
|
|
127
|
+
- [tarmac-sdk-ts](https://github.com/tarmacagent/tarmac-sdk-ts): the TypeScript SDK
|
|
128
|
+
- [tarmac-mcp](https://github.com/tarmacagent/tarmac-mcp): use Tarmac from Claude, Cursor or any MCP client
|
|
129
|
+
- [tarmac-examples](https://github.com/tarmacagent/tarmac-examples): ready-to-run agents
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# tarmac-agent
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tarmacagent/tarmac-python/actions/workflows/ci.yml)   
|
|
4
|
+
|
|
5
|
+
**Test your on-chain agent before it goes live, from Python.**
|
|
6
|
+
|
|
7
|
+
Tarmac runs your agent against a live fork of Robinhood Chain with an isolated simulated wallet, applies the same policy checks to every swap request, and records a trace of every tool call, check and balance change. Your agent runs on your machine and never holds a key.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install tarmac-agent
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> **Coming to PyPI soon.** Until the first release lands, install straight from GitHub:
|
|
14
|
+
>
|
|
15
|
+
> ```bash
|
|
16
|
+
> pip install "git+https://github.com/tarmacagent/tarmac-python"
|
|
17
|
+
> ```
|
|
18
|
+
|
|
19
|
+
Standard library only: no dependencies.
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from tarmac_agent import TarmacClient, TarmacTools, run_agent
|
|
25
|
+
|
|
26
|
+
client = TarmacClient("https://your-tarmac-server")
|
|
27
|
+
|
|
28
|
+
def my_agent(tools: TarmacTools) -> None:
|
|
29
|
+
ctx = tools.get_context()
|
|
30
|
+
pair = dict(token_in=ctx["tokenIn"]["address"], token_out=ctx["tokenOut"]["address"])
|
|
31
|
+
quote = tools.get_quote(**pair, amount_in=ctx["amountIn"])
|
|
32
|
+
|
|
33
|
+
if quote["slippageBps"] > ctx["policy"]["maxSlippageBps"]:
|
|
34
|
+
tools.finish("stopped", "Slippage over the limit.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
tools.request_swap(quote_id=quote["quoteId"], **pair, amount_in=ctx["amountIn"])
|
|
38
|
+
tools.finish("swapped")
|
|
39
|
+
|
|
40
|
+
out = run_agent(client, my_agent, scenario="swap-thin", agent_name="my-python-agent")
|
|
41
|
+
print(out["run"]["result"]["verdictLabel"]) # "Followed the rule and stopped"
|
|
42
|
+
print(out["session"].run_url) # full trace in the browser
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Servers that require sign-in
|
|
46
|
+
|
|
47
|
+
Create the run in the Tarmac app (**Run → Your agent**), then attach:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
run_agent(client, my_agent, run_id=os.environ["TARMAC_RUN_ID"], run_token=os.environ["TARMAC_RUN_TOKEN"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### From environment variables
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
client = TarmacClient.from_env() # $TARMAC_URL, default http://localhost:3000
|
|
57
|
+
session = client.attach_from_env() # $TARMAC_RUN_ID + $TARMAC_RUN_TOKEN
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Examples
|
|
61
|
+
|
|
62
|
+
- [`examples/quickstart.py`](examples/quickstart.py): one careful agent, start to verdict.
|
|
63
|
+
- [`examples/careful_vs_reckless.py`](examples/careful_vs_reckless.py): two agents on the same thin market. The careful one stops; the reckless one is blocked by policy.
|
|
64
|
+
|
|
65
|
+
## Tools
|
|
66
|
+
|
|
67
|
+
| Method | Arguments | Returns |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `get_context()` | — | instruction, wallet, tokens, amount, policy limits |
|
|
70
|
+
| `get_balances()` | — | wallet balances (integer base units as strings) |
|
|
71
|
+
| `get_quote()` | `token_in, token_out, amount_in` | `quoteId`, `amountOut`, reference, `slippageBps`, eligibility |
|
|
72
|
+
| `request_swap()` | `quote_id, token_in, token_out, amount_in, min_amount_out=None, reason=None` | `executed` / `blocked` / `failed` + every policy check |
|
|
73
|
+
| `finish()` | `outcome, summary=None` | the run's verdict |
|
|
74
|
+
|
|
75
|
+
Responses keep the API's field names (`slippageBps`, `quoteId`, …) so they match the TypeScript SDK and the HTTP API exactly. Every response is a `TypedDict`, so editors autocomplete them.
|
|
76
|
+
|
|
77
|
+
## Errors
|
|
78
|
+
|
|
79
|
+
Failures raise `TarmacToolError` with `.code` (`INVALID_INPUT`, `UNSUPPORTED_TOKEN`, `TOOL_LIMIT`, `RUN_NOT_ACTIVE`, `AUTH_REQUIRED`, `NETWORK_ERROR`, …), `.message` and `.retryable`.
|
|
80
|
+
|
|
81
|
+
### Retries
|
|
82
|
+
|
|
83
|
+
Transient failures are retried automatically with exponential backoff (2 retries by default):
|
|
84
|
+
|
|
85
|
+
- **Rejected before any work** (HTTP 429/503 marked retryable): every call is retried.
|
|
86
|
+
- **Network failures**: only read-only calls (`get_context`, `get_balances`, `get_quote`, `get_run`) are retried. A `request_swap` or `finish` that times out may already have run on the server, so it is never sent twice.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
client = TarmacClient("https://your-tarmac-server", retries=4, backoff=0.25) # or retries=0 to disable
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
python -m unittest discover -s tests -v
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Releasing
|
|
99
|
+
|
|
100
|
+
Releases publish to PyPI from GitHub Actions with [Trusted Publishing](https://docs.pypi.org/trusted-publishers/): no API token is stored anywhere.
|
|
101
|
+
|
|
102
|
+
1. Bump `__version__` in `src/tarmac_agent/_version.py` and move the changelog's *Unreleased* notes under that version.
|
|
103
|
+
2. Tag and push: `git tag v0.1.0 && git push origin v0.1.0`.
|
|
104
|
+
|
|
105
|
+
The workflow refuses to publish if the tag and the package version disagree.
|
|
106
|
+
|
|
107
|
+
## Related
|
|
108
|
+
|
|
109
|
+
- [tarmac-sdk-ts](https://github.com/tarmacagent/tarmac-sdk-ts): the TypeScript SDK
|
|
110
|
+
- [tarmac-mcp](https://github.com/tarmacagent/tarmac-mcp): use Tarmac from Claude, Cursor or any MCP client
|
|
111
|
+
- [tarmac-examples](https://github.com/tarmacagent/tarmac-examples): ready-to-run agents
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Please report vulnerabilities privately through GitHub's "Report a vulnerability" (Security tab) rather than a public issue.
|
|
4
|
+
|
|
5
|
+
The SDK never handles private keys. It sends a per-run credential (`trt_…`) to the Tarmac server you configure; treat it like a password for that one run.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Two agents, one thin market: which one follows the rule?
|
|
2
|
+
|
|
3
|
+
Runs a careful agent (checks slippage first) and a reckless one (swaps no matter
|
|
4
|
+
what) against the same scenario, then prints both verdicts side by side.
|
|
5
|
+
|
|
6
|
+
TARMAC_URL=http://localhost:3000 python examples/careful_vs_reckless.py
|
|
7
|
+
|
|
8
|
+
Expected on swap-thin: the careful agent stops, the reckless one is blocked by
|
|
9
|
+
policy. Nothing is ever broadcast; both runs use simulated funds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from tarmac_agent import TarmacClient, TarmacTools, run_agent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pair(ctx):
|
|
18
|
+
return {"token_in": ctx["tokenIn"]["address"], "token_out": ctx["tokenOut"]["address"]}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def careful(tools: TarmacTools) -> None:
|
|
22
|
+
ctx = tools.get_context()
|
|
23
|
+
quote = tools.get_quote(**pair(ctx), amount_in=ctx["amountIn"])
|
|
24
|
+
if quote["slippageBps"] > ctx["policy"]["maxSlippageBps"]:
|
|
25
|
+
tools.finish("stopped", f"Slippage {quote['slippageBps']} bps is over the {ctx['policy']['maxSlippageBps']} bps limit.")
|
|
26
|
+
return
|
|
27
|
+
tools.request_swap(quote_id=quote["quoteId"], **pair(ctx), amount_in=ctx["amountIn"], reason="Within the slippage limit.")
|
|
28
|
+
tools.finish("swapped")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reckless(tools: TarmacTools) -> None:
|
|
32
|
+
ctx = tools.get_context()
|
|
33
|
+
quote = tools.get_quote(**pair(ctx), amount_in=ctx["amountIn"])
|
|
34
|
+
# Never looks at slippage. Tarmac's policy checks still apply.
|
|
35
|
+
tools.request_swap(quote_id=quote["quoteId"], **pair(ctx), amount_in=ctx["amountIn"], reason="Swap regardless.")
|
|
36
|
+
tools.finish("swapped")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
client = TarmacClient.from_env()
|
|
41
|
+
scenario = os.environ.get("SCENARIO", "swap-thin")
|
|
42
|
+
rows = []
|
|
43
|
+
for name, agent in (("careful", careful), ("reckless", reckless)):
|
|
44
|
+
out = run_agent(client, agent, scenario=scenario, agent_name=f"python-{name}")
|
|
45
|
+
result = out["run"].get("result") or {}
|
|
46
|
+
rows.append((name, result.get("verdictLabel", out["run"]["status"]), out["session"].run_url))
|
|
47
|
+
|
|
48
|
+
print(f"\nScenario: {scenario}\n")
|
|
49
|
+
for name, verdict, url in rows:
|
|
50
|
+
print(f" {name:<9} {verdict}")
|
|
51
|
+
print(f" {'':<9} {url}\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Run a careful agent against a Tarmac server.
|
|
2
|
+
|
|
3
|
+
TARMAC_URL=http://localhost:3000 SCENARIO=swap-thin python examples/quickstart.py
|
|
4
|
+
|
|
5
|
+
If the server requires sign-in, create the run in the app ("Your agent") and set
|
|
6
|
+
TARMAC_RUN_ID and TARMAC_RUN_TOKEN instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from tarmac_agent import TarmacClient, TarmacTools, run_agent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def my_agent(tools: TarmacTools) -> None:
|
|
15
|
+
ctx = tools.get_context()
|
|
16
|
+
print("Instruction:", ctx["instruction"])
|
|
17
|
+
quote = tools.get_quote(token_in=ctx["tokenIn"]["address"], token_out=ctx["tokenOut"]["address"], amount_in=ctx["amountIn"])
|
|
18
|
+
print(f"Quote: slippage {quote['slippageBps']} bps (limit {ctx['policy']['maxSlippageBps']})")
|
|
19
|
+
if quote["slippageBps"] > ctx["policy"]["maxSlippageBps"]:
|
|
20
|
+
tools.finish("stopped", "Slippage over the limit.")
|
|
21
|
+
return
|
|
22
|
+
swap = tools.request_swap(quote_id=quote["quoteId"], token_in=ctx["tokenIn"]["address"], token_out=ctx["tokenOut"]["address"], amount_in=ctx["amountIn"])
|
|
23
|
+
print("Swap:", swap["status"])
|
|
24
|
+
tools.finish("swapped" if swap["status"] == "executed" else "stopped")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
client = TarmacClient.from_env()
|
|
28
|
+
if os.environ.get("TARMAC_RUN_ID") and os.environ.get("TARMAC_RUN_TOKEN"):
|
|
29
|
+
out = run_agent(client, my_agent, run_id=os.environ["TARMAC_RUN_ID"], run_token=os.environ["TARMAC_RUN_TOKEN"])
|
|
30
|
+
else:
|
|
31
|
+
out = run_agent(client, my_agent, scenario=os.environ.get("SCENARIO", "swap-normal"), token=os.environ.get("TOKEN", "WETH"), agent_name="python-quickstart")
|
|
32
|
+
|
|
33
|
+
result = out["run"].get("result") or {}
|
|
34
|
+
print("\nVerdict:", result.get("verdictLabel", out["run"]["status"]))
|
|
35
|
+
print("Reason: ", result.get("reason", ""))
|
|
36
|
+
print("Trace: ", out["session"].run_url)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tarmac-agent"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK to test your on-chain agent against Tarmac: real Robinhood Chain data, isolated wallets, policy checks on every swap."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Tarmac Agent" }]
|
|
14
|
+
keywords = ["ai-agent", "agents", "onchain", "robinhood-chain", "uniswap", "defi", "testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/tarmacagent/tarmac-python"
|
|
26
|
+
Issues = "https://github.com/tarmacagent/tarmac-python/issues"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.version]
|
|
29
|
+
path = "src/tarmac_agent/_version.py"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/tarmac_agent"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tarmac: test your on-chain agent before it goes live.
|
|
2
|
+
|
|
3
|
+
The same typed tools as the TypeScript SDK, for Python agents. Standard library only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._client import RunSession, TarmacClient, TarmacTools, run_agent
|
|
7
|
+
from ._errors import TarmacToolError
|
|
8
|
+
from ._types import (
|
|
9
|
+
BalanceEntry,
|
|
10
|
+
BalancesResult,
|
|
11
|
+
FinishResult,
|
|
12
|
+
PolicyCheck,
|
|
13
|
+
PolicyLimits,
|
|
14
|
+
QuoteResult,
|
|
15
|
+
RunContext,
|
|
16
|
+
SwapResult,
|
|
17
|
+
TokenInfo,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"TarmacClient",
|
|
23
|
+
"TarmacTools",
|
|
24
|
+
"RunSession",
|
|
25
|
+
"run_agent",
|
|
26
|
+
"TarmacToolError",
|
|
27
|
+
"RunContext",
|
|
28
|
+
"TokenInfo",
|
|
29
|
+
"PolicyLimits",
|
|
30
|
+
"BalanceEntry",
|
|
31
|
+
"BalancesResult",
|
|
32
|
+
"QuoteResult",
|
|
33
|
+
"PolicyCheck",
|
|
34
|
+
"SwapResult",
|
|
35
|
+
"FinishResult",
|
|
36
|
+
]
|
|
37
|
+
from ._version import __version__
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Callable, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from ._errors import TarmacToolError
|
|
12
|
+
from ._types import BalancesResult, FinishResult, QuoteResult, RunContext, SwapResult
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
|
|
15
|
+
_USER_AGENT = f"tarmac-agent-python/{__version__}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Statuses the server uses when it rejected a request before doing any work.
|
|
19
|
+
_REJECTED_BEFORE_WORK = {429, 503}
|
|
20
|
+
# Tools that only read state: safe to repeat if a response was lost in transit.
|
|
21
|
+
_READ_ONLY_TOOLS = {"get_context", "get_balances", "get_quote"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Http:
|
|
25
|
+
def __init__(self, base_url: str, timeout: float, retries: int = 2, backoff: float = 0.5) -> None:
|
|
26
|
+
self.base_url = base_url.rstrip("/")
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
self.retries = max(0, retries)
|
|
29
|
+
self.backoff = max(0.0, backoff)
|
|
30
|
+
|
|
31
|
+
def request(self, method: str, path: str, body: Any = None, token: Optional[str] = None, *, idempotent: bool = False) -> Any:
|
|
32
|
+
"""Send a request, retrying transient failures.
|
|
33
|
+
|
|
34
|
+
A request the server *rejected before doing any work* (429/503 marked retryable) is
|
|
35
|
+
always safe to retry. A network failure is retried only for ``idempotent`` requests:
|
|
36
|
+
if a swap request times out, it may already have run, so it is never sent twice.
|
|
37
|
+
"""
|
|
38
|
+
attempt = 0
|
|
39
|
+
while True:
|
|
40
|
+
try:
|
|
41
|
+
return self._once(method, path, body, token)
|
|
42
|
+
except _Retryable as r:
|
|
43
|
+
if attempt >= self.retries or (r.network and not idempotent):
|
|
44
|
+
raise r.error from None
|
|
45
|
+
time.sleep(self.backoff * (2**attempt))
|
|
46
|
+
attempt += 1
|
|
47
|
+
|
|
48
|
+
def _once(self, method: str, path: str, body: Any, token: Optional[str]) -> Any:
|
|
49
|
+
data = None if body is None else json.dumps(body).encode()
|
|
50
|
+
req = urllib.request.Request(self.base_url + path, data=data, method=method)
|
|
51
|
+
req.add_header("accept", "application/json")
|
|
52
|
+
req.add_header("user-agent", _USER_AGENT)
|
|
53
|
+
if data is not None:
|
|
54
|
+
req.add_header("content-type", "application/json")
|
|
55
|
+
if token:
|
|
56
|
+
req.add_header("authorization", f"Bearer {token}")
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
59
|
+
return json.loads(res.read() or b"null")
|
|
60
|
+
except urllib.error.HTTPError as e:
|
|
61
|
+
payload: Dict[str, Any] = {}
|
|
62
|
+
try:
|
|
63
|
+
payload = json.loads(e.read() or b"{}")
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
err = payload.get("error") or {}
|
|
67
|
+
error = TarmacToolError(err.get("code") or f"HTTP_{e.code}", err.get("message") or e.reason, bool(err.get("retryable")))
|
|
68
|
+
if e.code in _REJECTED_BEFORE_WORK and error.retryable:
|
|
69
|
+
raise _Retryable(error, network=False) from None
|
|
70
|
+
raise error from None
|
|
71
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
72
|
+
reason = getattr(e, "reason", e)
|
|
73
|
+
raise _Retryable(TarmacToolError("NETWORK_ERROR", str(reason), True), network=True) from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Retryable(Exception):
|
|
77
|
+
def __init__(self, error: TarmacToolError, network: bool) -> None:
|
|
78
|
+
super().__init__(str(error))
|
|
79
|
+
self.error = error
|
|
80
|
+
self.network = network
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TarmacTools:
|
|
84
|
+
"""The five tools an agent can call. Same contract as the TypeScript SDK."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, http: _Http, run_id: str, token: str) -> None:
|
|
87
|
+
self._http, self._run_id, self._token = http, run_id, token
|
|
88
|
+
|
|
89
|
+
def _call(self, tool: str, payload: Optional[Dict[str, Any]] = None) -> Any:
|
|
90
|
+
return self._http.request("POST", f"/api/runs/{self._run_id}/tools/{tool}", payload or {}, self._token, idempotent=tool in _READ_ONLY_TOOLS)
|
|
91
|
+
|
|
92
|
+
def get_context(self) -> RunContext:
|
|
93
|
+
return self._call("get_context")
|
|
94
|
+
|
|
95
|
+
def get_balances(self) -> BalancesResult:
|
|
96
|
+
return self._call("get_balances")
|
|
97
|
+
|
|
98
|
+
def get_quote(self, *, token_in: str, token_out: str, amount_in: str) -> QuoteResult:
|
|
99
|
+
return self._call("get_quote", {"tokenIn": token_in, "tokenOut": token_out, "amountIn": str(amount_in)})
|
|
100
|
+
|
|
101
|
+
def request_swap(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
quote_id: str,
|
|
105
|
+
token_in: str,
|
|
106
|
+
token_out: str,
|
|
107
|
+
amount_in: str,
|
|
108
|
+
min_amount_out: Optional[str] = None,
|
|
109
|
+
reason: Optional[str] = None,
|
|
110
|
+
) -> SwapResult:
|
|
111
|
+
body: Dict[str, Any] = {"quoteId": quote_id, "tokenIn": token_in, "tokenOut": token_out, "amountIn": str(amount_in)}
|
|
112
|
+
if min_amount_out is not None:
|
|
113
|
+
body["minAmountOut"] = str(min_amount_out)
|
|
114
|
+
if reason is not None:
|
|
115
|
+
body["reason"] = reason
|
|
116
|
+
return self._call("request_swap", body)
|
|
117
|
+
|
|
118
|
+
def finish(self, outcome: str, summary: Optional[str] = None) -> FinishResult:
|
|
119
|
+
body: Dict[str, Any] = {"outcome": outcome}
|
|
120
|
+
if summary is not None:
|
|
121
|
+
body["summary"] = summary
|
|
122
|
+
return self._call("finish", body)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class RunSession:
|
|
127
|
+
run_id: str
|
|
128
|
+
run_token: str
|
|
129
|
+
run_url: str
|
|
130
|
+
tools: TarmacTools
|
|
131
|
+
_http: _Http
|
|
132
|
+
|
|
133
|
+
def get_run(self) -> Dict[str, Any]:
|
|
134
|
+
"""The current run record: status, verdict, trace."""
|
|
135
|
+
return self._http.request("GET", f"/api/runs/{self.run_id}", token=self.run_token, idempotent=True)["run"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TarmacClient:
|
|
139
|
+
"""Client for a Tarmac server.
|
|
140
|
+
|
|
141
|
+
>>> client = TarmacClient("http://localhost:3000")
|
|
142
|
+
>>> session = client.start_run(scenario="swap-thin", agent_name="my-agent")
|
|
143
|
+
|
|
144
|
+
``retries`` and ``backoff`` control how transient failures are retried (exponential:
|
|
145
|
+
0.5s, 1s, ...). Swap requests are never re-sent after a network failure.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, base_url: str, timeout: float = 120.0, *, retries: int = 2, backoff: float = 0.5) -> None:
|
|
149
|
+
self._http = _Http(base_url, timeout, retries, backoff)
|
|
150
|
+
|
|
151
|
+
def start_run(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
scenario: str,
|
|
155
|
+
agent_name: str,
|
|
156
|
+
token: str = "WETH",
|
|
157
|
+
mode: str = "fixture",
|
|
158
|
+
policy: Optional[Dict[str, int]] = None,
|
|
159
|
+
) -> RunSession:
|
|
160
|
+
created = self._http.request(
|
|
161
|
+
"POST",
|
|
162
|
+
"/api/runs",
|
|
163
|
+
{"scenarioId": scenario, "token": token, "mode": mode, "agent": {"kind": "external", "name": agent_name}, "policy": policy or {}},
|
|
164
|
+
)
|
|
165
|
+
return RunSession(created["run"]["id"], created["runToken"], self._http.base_url + created["runUrl"], TarmacTools(self._http, created["run"]["id"], created["runToken"]), self._http)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_env(cls, **options: Any) -> "TarmacClient":
|
|
169
|
+
"""Client for ``$TARMAC_URL`` (default ``http://localhost:3000``). Extra options go to ``__init__``."""
|
|
170
|
+
return cls(os.environ.get("TARMAC_URL") or "http://localhost:3000", **options)
|
|
171
|
+
|
|
172
|
+
def attach_from_env(self) -> RunSession:
|
|
173
|
+
"""Attach using ``$TARMAC_RUN_ID`` and ``$TARMAC_RUN_TOKEN`` (from "Your agent" in the Tarmac app)."""
|
|
174
|
+
run_id, run_token = os.environ.get("TARMAC_RUN_ID"), os.environ.get("TARMAC_RUN_TOKEN")
|
|
175
|
+
if not run_id or not run_token:
|
|
176
|
+
raise TarmacToolError("AUTH_REQUIRED", "Set TARMAC_RUN_ID and TARMAC_RUN_TOKEN (create the run in the app with \"Your agent\").")
|
|
177
|
+
return self.attach(run_id, run_token)
|
|
178
|
+
|
|
179
|
+
def attach(self, run_id: str, run_token: str) -> RunSession:
|
|
180
|
+
"""Attach to a run created elsewhere (e.g. "Your agent" in the Tarmac app)."""
|
|
181
|
+
return RunSession(run_id, run_token, f"{self._http.base_url}/runs/{run_id}#token={run_token}", TarmacTools(self._http, run_id, run_token), self._http)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def run_agent(
|
|
185
|
+
client: TarmacClient,
|
|
186
|
+
agent: Callable[[TarmacTools], None],
|
|
187
|
+
*,
|
|
188
|
+
run_id: Optional[str] = None,
|
|
189
|
+
run_token: Optional[str] = None,
|
|
190
|
+
**start: Any,
|
|
191
|
+
) -> Dict[str, Any]:
|
|
192
|
+
"""Start (or attach to) a run, hand the tools to ``agent``, and always finish the run.
|
|
193
|
+
|
|
194
|
+
Returns ``{"session": RunSession, "run": dict, "finish": dict | None}``.
|
|
195
|
+
"""
|
|
196
|
+
session = client.attach(run_id, run_token) if run_id and run_token else client.start_run(**start)
|
|
197
|
+
finished: Dict[str, Any] = {}
|
|
198
|
+
real_finish = session.tools.finish
|
|
199
|
+
|
|
200
|
+
def finish(outcome: str, summary: Optional[str] = None) -> FinishResult:
|
|
201
|
+
result = real_finish(outcome, summary)
|
|
202
|
+
finished["result"] = result
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
session.tools.finish = finish # type: ignore[method-assign]
|
|
206
|
+
try:
|
|
207
|
+
agent(session.tools)
|
|
208
|
+
finally:
|
|
209
|
+
if "result" not in finished:
|
|
210
|
+
try:
|
|
211
|
+
finish("stopped", "Agent returned without calling finish.")
|
|
212
|
+
except TarmacToolError:
|
|
213
|
+
pass
|
|
214
|
+
return {"session": session, "run": session.get_run(), "finish": finished.get("result")}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class TarmacToolError(Exception):
|
|
2
|
+
"""Raised when a Tarmac tool call or API request fails.
|
|
3
|
+
|
|
4
|
+
``code`` is stable (e.g. ``INVALID_INPUT``, ``UNSUPPORTED_TOKEN``, ``TOOL_LIMIT``,
|
|
5
|
+
``RUN_NOT_ACTIVE``, ``AUTH_REQUIRED``); ``message`` is human-readable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, code: str, message: str, retryable: bool = False) -> None:
|
|
9
|
+
super().__init__(f"{code}: {message}")
|
|
10
|
+
self.code = code
|
|
11
|
+
self.message = message
|
|
12
|
+
self.retryable = retryable
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shapes returned by the Tarmac tools. Amounts are decimal strings of integer base units."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Literal, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TokenInfo(TypedDict):
|
|
7
|
+
symbol: str
|
|
8
|
+
address: str
|
|
9
|
+
decimals: int
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PolicyLimits(TypedDict):
|
|
13
|
+
version: str
|
|
14
|
+
maxSlippageBps: int
|
|
15
|
+
spendCap: str
|
|
16
|
+
deadlineSeconds: int
|
|
17
|
+
slippageReference: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunContext(TypedDict):
|
|
21
|
+
runId: str
|
|
22
|
+
mode: Literal["fixture", "fork"]
|
|
23
|
+
instruction: str
|
|
24
|
+
chain: dict
|
|
25
|
+
venue: dict
|
|
26
|
+
wallet: dict
|
|
27
|
+
tokenIn: TokenInfo
|
|
28
|
+
tokenOut: TokenInfo
|
|
29
|
+
amountIn: str
|
|
30
|
+
policy: PolicyLimits
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BalanceEntry(TypedDict):
|
|
34
|
+
symbol: str
|
|
35
|
+
address: str
|
|
36
|
+
decimals: int
|
|
37
|
+
raw: str
|
|
38
|
+
formatted: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BalancesResult(TypedDict):
|
|
42
|
+
wallet: str
|
|
43
|
+
block: Optional[str]
|
|
44
|
+
balances: List[BalanceEntry]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class QuoteResult(TypedDict):
|
|
48
|
+
quoteId: str
|
|
49
|
+
tokenIn: str
|
|
50
|
+
tokenOut: str
|
|
51
|
+
amountIn: str
|
|
52
|
+
amountOut: str
|
|
53
|
+
venue: dict
|
|
54
|
+
reference: dict
|
|
55
|
+
slippageBps: int
|
|
56
|
+
policy: dict
|
|
57
|
+
block: Optional[str]
|
|
58
|
+
expiresAt: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PolicyCheck(TypedDict):
|
|
62
|
+
id: str
|
|
63
|
+
label: str
|
|
64
|
+
passed: bool
|
|
65
|
+
expected: str
|
|
66
|
+
actual: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SwapResult(TypedDict, total=False):
|
|
70
|
+
status: Literal["executed", "blocked", "failed"]
|
|
71
|
+
checks: List[PolicyCheck]
|
|
72
|
+
reason: str
|
|
73
|
+
execution: dict
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FinishResult(TypedDict):
|
|
77
|
+
runId: str
|
|
78
|
+
status: str
|
|
79
|
+
verdict: Optional[Literal["executed", "stopped", "blocked", "failed"]]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
from unittest import mock
|
|
5
|
+
import unittest
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
|
|
8
|
+
import tarmac_agent
|
|
9
|
+
from tarmac_agent import TarmacClient, TarmacToolError, run_agent
|
|
10
|
+
from tarmac_agent._client import _USER_AGENT
|
|
11
|
+
|
|
12
|
+
RUN = "run_abcdefghijkmnpqr"
|
|
13
|
+
TOKEN = "trt_secret_token_value_1234"
|
|
14
|
+
SEEN = []
|
|
15
|
+
# path -> how many more times to answer 503 before succeeding
|
|
16
|
+
FLAKY = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeTarmac(BaseHTTPRequestHandler):
|
|
20
|
+
def log_message(self, *args): # quiet
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def _send(self, status, payload):
|
|
24
|
+
body = json.dumps(payload).encode()
|
|
25
|
+
self.send_response(status)
|
|
26
|
+
self.send_header("content-type", "application/json")
|
|
27
|
+
self.send_header("content-length", str(len(body)))
|
|
28
|
+
self.end_headers()
|
|
29
|
+
self.wfile.write(body)
|
|
30
|
+
|
|
31
|
+
def do_POST(self):
|
|
32
|
+
length = int(self.headers.get("content-length") or 0)
|
|
33
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
34
|
+
SEEN.append((self.path, self.headers.get("authorization"), body))
|
|
35
|
+
if FLAKY.get(self.path, 0) > 0:
|
|
36
|
+
FLAKY[self.path] -= 1
|
|
37
|
+
return self._send(503, {"error": {"code": "BUSY", "message": "try again", "retryable": True}})
|
|
38
|
+
if self.path == "/api/runs":
|
|
39
|
+
return self._send(201, {"run": {"id": RUN, "status": "running"}, "runToken": TOKEN, "runUrl": f"/runs/{RUN}#token={TOKEN}"})
|
|
40
|
+
if self.headers.get("authorization") != f"Bearer {TOKEN}":
|
|
41
|
+
return self._send(401, {"error": {"code": "UNAUTHORISED", "message": "credential required", "retryable": False}})
|
|
42
|
+
tool = self.path.rsplit("/", 1)[-1]
|
|
43
|
+
if tool == "get_quote":
|
|
44
|
+
if body.get("tokenIn") != "0xUSDG":
|
|
45
|
+
return self._send(422, {"error": {"code": "UNSUPPORTED_TOKEN", "message": "USDG only", "retryable": False}})
|
|
46
|
+
return self._send(200, {"quoteId": "q_1", "slippageBps": 273, "amountOut": "1", "policy": {"maxSlippageBps": 100, "eligible": False}})
|
|
47
|
+
if tool == "finish":
|
|
48
|
+
return self._send(200, {"runId": RUN, "status": "completed", "verdict": "stopped"})
|
|
49
|
+
if tool == "get_context":
|
|
50
|
+
return self._send(200, {"tokenIn": {"address": "0xUSDG"}, "tokenOut": {"address": "0xWETH"}, "amountIn": "100000000", "policy": {"maxSlippageBps": 100}})
|
|
51
|
+
return self._send(200, {"ok": True})
|
|
52
|
+
|
|
53
|
+
def do_GET(self):
|
|
54
|
+
return self._send(200, {"run": {"id": RUN, "status": "completed", "result": {"verdict": "stopped"}}})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClientTests(unittest.TestCase):
|
|
58
|
+
@classmethod
|
|
59
|
+
def setUpClass(cls):
|
|
60
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeTarmac)
|
|
61
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
62
|
+
cls.client = TarmacClient(f"http://127.0.0.1:{cls.server.server_port}/", backoff=0)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def tearDownClass(cls):
|
|
66
|
+
cls.server.shutdown()
|
|
67
|
+
|
|
68
|
+
def setUp(self):
|
|
69
|
+
SEEN.clear()
|
|
70
|
+
FLAKY.clear()
|
|
71
|
+
|
|
72
|
+
def test_start_run_and_credential(self):
|
|
73
|
+
s = self.client.start_run(scenario="swap-thin", agent_name="unit")
|
|
74
|
+
self.assertEqual(s.run_id, RUN)
|
|
75
|
+
self.assertTrue(s.run_url.endswith(f"/runs/{RUN}#token={TOKEN}"))
|
|
76
|
+
self.assertEqual(SEEN[0][2]["agent"], {"kind": "external", "name": "unit"})
|
|
77
|
+
s.tools.get_balances()
|
|
78
|
+
self.assertEqual(SEEN[1][0], f"/api/runs/{RUN}/tools/get_balances")
|
|
79
|
+
self.assertEqual(SEEN[1][1], f"Bearer {TOKEN}")
|
|
80
|
+
|
|
81
|
+
def test_typed_errors(self):
|
|
82
|
+
s = self.client.attach(RUN, TOKEN)
|
|
83
|
+
with self.assertRaises(TarmacToolError) as cm:
|
|
84
|
+
s.tools.get_quote(token_in="0xNOPE", token_out="0xWETH", amount_in="1")
|
|
85
|
+
self.assertEqual(cm.exception.code, "UNSUPPORTED_TOKEN")
|
|
86
|
+
self.assertFalse(cm.exception.retryable)
|
|
87
|
+
|
|
88
|
+
def test_snake_case_maps_to_api_fields(self):
|
|
89
|
+
s = self.client.attach(RUN, TOKEN)
|
|
90
|
+
s.tools.request_swap(quote_id="q_1", token_in="0xUSDG", token_out="0xWETH", amount_in=100, min_amount_out=5, reason="test")
|
|
91
|
+
self.assertEqual(SEEN[0][2], {"quoteId": "q_1", "tokenIn": "0xUSDG", "tokenOut": "0xWETH", "amountIn": "100", "minAmountOut": "5", "reason": "test"})
|
|
92
|
+
|
|
93
|
+
def test_run_agent_always_finishes(self):
|
|
94
|
+
def crashing(tools):
|
|
95
|
+
tools.get_context()
|
|
96
|
+
raise RuntimeError("agent crashed")
|
|
97
|
+
|
|
98
|
+
with self.assertRaises(RuntimeError):
|
|
99
|
+
run_agent(self.client, crashing, run_id=RUN, run_token=TOKEN)
|
|
100
|
+
self.assertTrue(any(p.endswith("/finish") for p, _, _ in SEEN))
|
|
101
|
+
|
|
102
|
+
def test_run_agent_returns_verdict(self):
|
|
103
|
+
def careful(tools):
|
|
104
|
+
ctx = tools.get_context()
|
|
105
|
+
q = tools.get_quote(token_in=ctx["tokenIn"]["address"], token_out=ctx["tokenOut"]["address"], amount_in=ctx["amountIn"])
|
|
106
|
+
if q["slippageBps"] > ctx["policy"]["maxSlippageBps"]:
|
|
107
|
+
tools.finish("stopped", "over the limit")
|
|
108
|
+
|
|
109
|
+
out = run_agent(self.client, careful, scenario="swap-thin", agent_name="unit")
|
|
110
|
+
self.assertEqual(out["finish"]["verdict"], "stopped")
|
|
111
|
+
self.assertEqual(out["run"]["status"], "completed")
|
|
112
|
+
|
|
113
|
+
def test_retries_requests_rejected_before_work(self):
|
|
114
|
+
s = self.client.attach(RUN, TOKEN)
|
|
115
|
+
FLAKY[f"/api/runs/{RUN}/tools/request_swap"] = 2
|
|
116
|
+
out = s.tools.request_swap(quote_id="q_1", token_in="0xUSDG", token_out="0xWETH", amount_in="1")
|
|
117
|
+
self.assertEqual(out, {"ok": True})
|
|
118
|
+
self.assertEqual(len(SEEN), 3)
|
|
119
|
+
|
|
120
|
+
def test_gives_up_after_retries(self):
|
|
121
|
+
s = self.client.attach(RUN, TOKEN)
|
|
122
|
+
FLAKY[f"/api/runs/{RUN}/tools/get_balances"] = 5
|
|
123
|
+
with self.assertRaises(TarmacToolError) as cm:
|
|
124
|
+
s.tools.get_balances()
|
|
125
|
+
self.assertEqual(cm.exception.code, "BUSY")
|
|
126
|
+
self.assertEqual(len(SEEN), 3) # first try + 2 retries
|
|
127
|
+
|
|
128
|
+
def test_does_not_retry_non_retryable_errors(self):
|
|
129
|
+
s = self.client.attach(RUN, TOKEN)
|
|
130
|
+
with self.assertRaises(TarmacToolError):
|
|
131
|
+
s.tools.get_quote(token_in="0xNOPE", token_out="0xWETH", amount_in="1")
|
|
132
|
+
self.assertEqual(len(SEEN), 1)
|
|
133
|
+
|
|
134
|
+
def test_network_errors_never_resend_a_swap(self):
|
|
135
|
+
dead = TarmacClient("http://127.0.0.1:9", retries=3, backoff=0) # nothing listens on port 9
|
|
136
|
+
calls = []
|
|
137
|
+
real = dead._http._once
|
|
138
|
+
dead._http._once = lambda *a: (calls.append(a[1]), real(*a))[1]
|
|
139
|
+
s = dead.attach(RUN, TOKEN)
|
|
140
|
+
with self.assertRaises(TarmacToolError) as cm:
|
|
141
|
+
s.tools.request_swap(quote_id="q_1", token_in="0xUSDG", token_out="0xWETH", amount_in="1")
|
|
142
|
+
self.assertEqual(cm.exception.code, "NETWORK_ERROR")
|
|
143
|
+
self.assertEqual(len(calls), 1)
|
|
144
|
+
calls.clear()
|
|
145
|
+
with self.assertRaises(TarmacToolError):
|
|
146
|
+
s.tools.get_balances()
|
|
147
|
+
self.assertEqual(len(calls), 4) # read-only: first try + 3 retries
|
|
148
|
+
|
|
149
|
+
def test_from_env(self):
|
|
150
|
+
with mock.patch.dict(os.environ, {"TARMAC_URL": "https://tarmac.example/"}):
|
|
151
|
+
self.assertEqual(TarmacClient.from_env()._http.base_url, "https://tarmac.example")
|
|
152
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
153
|
+
self.assertEqual(TarmacClient.from_env(retries=0)._http.base_url, "http://localhost:3000")
|
|
154
|
+
|
|
155
|
+
def test_attach_from_env(self):
|
|
156
|
+
with mock.patch.dict(os.environ, {"TARMAC_RUN_ID": RUN, "TARMAC_RUN_TOKEN": TOKEN}):
|
|
157
|
+
s = self.client.attach_from_env()
|
|
158
|
+
s.tools.get_balances()
|
|
159
|
+
self.assertEqual(SEEN[0][1], f"Bearer {TOKEN}")
|
|
160
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
161
|
+
with self.assertRaises(TarmacToolError) as cm:
|
|
162
|
+
self.client.attach_from_env()
|
|
163
|
+
self.assertEqual(cm.exception.code, "AUTH_REQUIRED")
|
|
164
|
+
|
|
165
|
+
def test_user_agent_carries_the_package_version(self):
|
|
166
|
+
self.assertEqual(_USER_AGENT, f"tarmac-agent-python/{tarmac_agent.__version__}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
unittest.main()
|