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.
@@ -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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .DS_Store
@@ -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
+ [![ci](https://github.com/tarmacagent/tarmac-python/actions/workflows/ci.yml/badge.svg)](https://github.com/tarmacagent/tarmac-python/actions/workflows/ci.yml) ![python](https://img.shields.io/badge/python-3.10%2B-black) ![deps](https://img.shields.io/badge/dependencies-0-black) ![license](https://img.shields.io/badge/license-MIT-black)
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
+ [![ci](https://github.com/tarmacagent/tarmac-python/actions/workflows/ci.yml/badge.svg)](https://github.com/tarmacagent/tarmac-python/actions/workflows/ci.yml) ![python](https://img.shields.io/badge/python-3.10%2B-black) ![deps](https://img.shields.io/badge/dependencies-0-black) ![license](https://img.shields.io/badge/license-MIT-black)
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()