upoch 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.
- upoch-0.1.0/LICENSE +21 -0
- upoch-0.1.0/PKG-INFO +137 -0
- upoch-0.1.0/README.md +119 -0
- upoch-0.1.0/pyproject.toml +32 -0
- upoch-0.1.0/setup.cfg +4 -0
- upoch-0.1.0/tests/test_upoch.py +195 -0
- upoch-0.1.0/upoch/__init__.py +16 -0
- upoch-0.1.0/upoch/guard.py +235 -0
- upoch-0.1.0/upoch/langchain.py +216 -0
- upoch-0.1.0/upoch/py.typed +0 -0
- upoch-0.1.0/upoch.egg-info/PKG-INFO +137 -0
- upoch-0.1.0/upoch.egg-info/SOURCES.txt +13 -0
- upoch-0.1.0/upoch.egg-info/dependency_links.txt +1 -0
- upoch-0.1.0/upoch.egg-info/requires.txt +3 -0
- upoch-0.1.0/upoch.egg-info/top_level.txt +1 -0
upoch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Upoch
|
|
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.
|
upoch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: upoch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Upoch Guard for Python agents: verifiable checkpoints, recovery and rollback on Robinhood Chain, with a one-line LangChain and LangGraph callback.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://upoch.tech
|
|
7
|
+
Project-URL: Documentation, https://upoch.tech/docs
|
|
8
|
+
Keywords: upoch,ai-agents,checkpoint,recovery,rollback,robinhood-chain,langchain,langgraph
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core<2,>=1.0; extra == "langchain"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# upoch: Upoch Guard for Python agents
|
|
20
|
+
|
|
21
|
+
Give your Python agent a memory and an undo button.
|
|
22
|
+
|
|
23
|
+
Upoch Guard saves verifiable checkpoints of an agent's work on Robinhood Chain. If the agent crashes, gets a bad
|
|
24
|
+
prompt or makes a bad trade, it restores the last good state, checked against the chain, or rolls back to the
|
|
25
|
+
checkpoint before the mistake and keeps going. It also knows which of its actions already happened.
|
|
26
|
+
|
|
27
|
+
Website and debugger: https://upoch.tech. Contract (Robinhood Chain, 4663): `0x6179d9123c3c1f9e82f948101a4350cb55aa8c27`
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
Once, in your own terminal. You need Node.js 24 or later, because the `upoch` command holds the keys and does the
|
|
32
|
+
signing:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install -g @upoch/guard
|
|
36
|
+
upoch setup
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`upoch setup` creates your keys in `~/.upoch`, waits while you send the owner address a little ETH on Robinhood
|
|
40
|
+
Chain (0.001 ETH is plenty), and registers your agent. Then:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install upoch # or: pip install "upoch[langchain]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## LangChain and LangGraph
|
|
47
|
+
|
|
48
|
+
One line: every consequential tool call (swaps, transfers, bridges, staking, lending, trades, mints, launches and
|
|
49
|
+
approvals) is checkpointed before and after it runs. The pre-tool checkpoint is committed before the tool runs,
|
|
50
|
+
with `invoke` and `ainvoke`.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from upoch.langchain import UpochCallbackHandler, upoch_tools
|
|
54
|
+
|
|
55
|
+
upoch = UpochCallbackHandler()
|
|
56
|
+
|
|
57
|
+
# Automatic checkpoints around consequential tool calls:
|
|
58
|
+
agent.invoke(inputs, config={"callbacks": [upoch]})
|
|
59
|
+
|
|
60
|
+
# Optional: let the model restore, checkpoint and roll back itself.
|
|
61
|
+
tools = [*your_tools, *upoch_tools(handler=upoch)]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Tool | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `upoch_restore` | Latest checkpoint, verified against the chain, and which actions completed |
|
|
67
|
+
| `upoch_checkpoint` | Save the current state |
|
|
68
|
+
| `upoch_rollback` | Go back to an earlier checkpoint and continue. By default, the one right before the last swap or transfer |
|
|
69
|
+
| `upoch_history` | Recent checkpoints |
|
|
70
|
+
| `upoch_complete` | Mark the task finished |
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
UpochCallbackHandler(
|
|
76
|
+
watch=["swap_tokens", "send_eth"], # exact tool names, or a compiled regex (default: value-moving names)
|
|
77
|
+
state=lambda: my_agent_state, # what each checkpoint holds (default: the log of watched tool calls)
|
|
78
|
+
strict=True, # stop the tool when its pre-tool checkpoint fails (default False)
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Any Python agent
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from upoch import Guard
|
|
86
|
+
|
|
87
|
+
with Guard() as guard:
|
|
88
|
+
restored = guard.restore() # where the last run stopped, verified against the chain
|
|
89
|
+
state = restored.get("state") or {"step": 0}
|
|
90
|
+
|
|
91
|
+
guard.record_action("swap-1", "swap", "planned", input={"sell": "WETH", "qty": 0.1})
|
|
92
|
+
guard.checkpoint(state, note="before swap", kind="before_action")
|
|
93
|
+
tx = do_the_swap()
|
|
94
|
+
guard.record_action("swap-1", "swap", "confirmed", transaction_hash=tx)
|
|
95
|
+
guard.checkpoint({**state, "step": 1}, note="after swap", kind="after_action")
|
|
96
|
+
|
|
97
|
+
# Something went wrong? Go back to the checkpoint before it:
|
|
98
|
+
guard.rollback(0, reason="the swap was the wrong size")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Method | What it does | On-chain |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `restore(sequence=None)` | Latest (or a given) checkpoint after verifying it: state, note, completed and unresolved actions | read |
|
|
104
|
+
| `checkpoint(state, note, kind)` | Saves the state (`progress`, `before_action`, `after_action`, `manual`) | 1 transaction |
|
|
105
|
+
| `record_action(...)` | Records an external action (planned, submitted, confirmed, failed) with its transaction hash | no (goes into the next checkpoint) |
|
|
106
|
+
| `rollback(sequence, reason)` | Goes back to an earlier checkpoint and continues from it; lists actions since then that were not undone on-chain | 1 transaction |
|
|
107
|
+
| `complete(state, note)` | Saves a final checkpoint and marks the run finished | 1 transaction |
|
|
108
|
+
| `history(limit)` | Recent checkpoints | read |
|
|
109
|
+
| `verify(sequence)` | Verifies one checkpoint without restoring it | read |
|
|
110
|
+
| `status()` | Workflow, executor funds and today's usage | read |
|
|
111
|
+
|
|
112
|
+
Each method returns the result as a dict. Refusals and errors raise `upoch.UpochError` (with a `code`).
|
|
113
|
+
|
|
114
|
+
A rollback restores the agent's state. It cannot reverse transactions that already happened on-chain; those are
|
|
115
|
+
listed so the agent can account for them.
|
|
116
|
+
|
|
117
|
+
## How it works
|
|
118
|
+
|
|
119
|
+
`Guard` starts the `upoch mcp` command from `@upoch/guard` and talks to it over stdio. That process holds the keys:
|
|
120
|
+
it encrypts each checkpoint on your machine, stores it on IPFS, commits its fingerprint to the Upoch contract, and
|
|
121
|
+
reads it back to verify it. Only someone with your checkpoint key can read it, and anyone can check that it has
|
|
122
|
+
not been changed. Every checkpoint opens in the [Time-Travel Debugger](https://upoch.tech/docs/debugger).
|
|
123
|
+
|
|
124
|
+
Settings live in `UPOCH_HOME` (default `~/.upoch`); `Guard(home=...)` picks another one. If `upoch` is not on your
|
|
125
|
+
`PATH`, set `UPOCH_COMMAND` (for example `node /path/to/cli.js mcp`) or pass `Guard(command=[...])`.
|
|
126
|
+
|
|
127
|
+
## Safety
|
|
128
|
+
|
|
129
|
+
- Each checkpoint is one transaction from the agent's executor key. That key can only submit checkpoints. There is a
|
|
130
|
+
daily limit (100 by default, `maxCheckpointsPerDay` in `~/.upoch/config.json`).
|
|
131
|
+
- State that looks like private keys, seed phrases or API tokens is refused, because checkpoints are permanent.
|
|
132
|
+
- Keys stay on your machine, in `~/.upoch/secrets.json`, and never pass through Python. Back that file up.
|
|
133
|
+
- The Upoch contracts have not been audited.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
upoch-0.1.0/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# upoch: Upoch Guard for Python agents
|
|
2
|
+
|
|
3
|
+
Give your Python agent a memory and an undo button.
|
|
4
|
+
|
|
5
|
+
Upoch Guard saves verifiable checkpoints of an agent's work on Robinhood Chain. If the agent crashes, gets a bad
|
|
6
|
+
prompt or makes a bad trade, it restores the last good state, checked against the chain, or rolls back to the
|
|
7
|
+
checkpoint before the mistake and keeps going. It also knows which of its actions already happened.
|
|
8
|
+
|
|
9
|
+
Website and debugger: https://upoch.tech. Contract (Robinhood Chain, 4663): `0x6179d9123c3c1f9e82f948101a4350cb55aa8c27`
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Once, in your own terminal. You need Node.js 24 or later, because the `upoch` command holds the keys and does the
|
|
14
|
+
signing:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install -g @upoch/guard
|
|
18
|
+
upoch setup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`upoch setup` creates your keys in `~/.upoch`, waits while you send the owner address a little ETH on Robinhood
|
|
22
|
+
Chain (0.001 ETH is plenty), and registers your agent. Then:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install upoch # or: pip install "upoch[langchain]"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## LangChain and LangGraph
|
|
29
|
+
|
|
30
|
+
One line: every consequential tool call (swaps, transfers, bridges, staking, lending, trades, mints, launches and
|
|
31
|
+
approvals) is checkpointed before and after it runs. The pre-tool checkpoint is committed before the tool runs,
|
|
32
|
+
with `invoke` and `ainvoke`.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from upoch.langchain import UpochCallbackHandler, upoch_tools
|
|
36
|
+
|
|
37
|
+
upoch = UpochCallbackHandler()
|
|
38
|
+
|
|
39
|
+
# Automatic checkpoints around consequential tool calls:
|
|
40
|
+
agent.invoke(inputs, config={"callbacks": [upoch]})
|
|
41
|
+
|
|
42
|
+
# Optional: let the model restore, checkpoint and roll back itself.
|
|
43
|
+
tools = [*your_tools, *upoch_tools(handler=upoch)]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Tool | |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `upoch_restore` | Latest checkpoint, verified against the chain, and which actions completed |
|
|
49
|
+
| `upoch_checkpoint` | Save the current state |
|
|
50
|
+
| `upoch_rollback` | Go back to an earlier checkpoint and continue. By default, the one right before the last swap or transfer |
|
|
51
|
+
| `upoch_history` | Recent checkpoints |
|
|
52
|
+
| `upoch_complete` | Mark the task finished |
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
UpochCallbackHandler(
|
|
58
|
+
watch=["swap_tokens", "send_eth"], # exact tool names, or a compiled regex (default: value-moving names)
|
|
59
|
+
state=lambda: my_agent_state, # what each checkpoint holds (default: the log of watched tool calls)
|
|
60
|
+
strict=True, # stop the tool when its pre-tool checkpoint fails (default False)
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Any Python agent
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from upoch import Guard
|
|
68
|
+
|
|
69
|
+
with Guard() as guard:
|
|
70
|
+
restored = guard.restore() # where the last run stopped, verified against the chain
|
|
71
|
+
state = restored.get("state") or {"step": 0}
|
|
72
|
+
|
|
73
|
+
guard.record_action("swap-1", "swap", "planned", input={"sell": "WETH", "qty": 0.1})
|
|
74
|
+
guard.checkpoint(state, note="before swap", kind="before_action")
|
|
75
|
+
tx = do_the_swap()
|
|
76
|
+
guard.record_action("swap-1", "swap", "confirmed", transaction_hash=tx)
|
|
77
|
+
guard.checkpoint({**state, "step": 1}, note="after swap", kind="after_action")
|
|
78
|
+
|
|
79
|
+
# Something went wrong? Go back to the checkpoint before it:
|
|
80
|
+
guard.rollback(0, reason="the swap was the wrong size")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Method | What it does | On-chain |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `restore(sequence=None)` | Latest (or a given) checkpoint after verifying it: state, note, completed and unresolved actions | read |
|
|
86
|
+
| `checkpoint(state, note, kind)` | Saves the state (`progress`, `before_action`, `after_action`, `manual`) | 1 transaction |
|
|
87
|
+
| `record_action(...)` | Records an external action (planned, submitted, confirmed, failed) with its transaction hash | no (goes into the next checkpoint) |
|
|
88
|
+
| `rollback(sequence, reason)` | Goes back to an earlier checkpoint and continues from it; lists actions since then that were not undone on-chain | 1 transaction |
|
|
89
|
+
| `complete(state, note)` | Saves a final checkpoint and marks the run finished | 1 transaction |
|
|
90
|
+
| `history(limit)` | Recent checkpoints | read |
|
|
91
|
+
| `verify(sequence)` | Verifies one checkpoint without restoring it | read |
|
|
92
|
+
| `status()` | Workflow, executor funds and today's usage | read |
|
|
93
|
+
|
|
94
|
+
Each method returns the result as a dict. Refusals and errors raise `upoch.UpochError` (with a `code`).
|
|
95
|
+
|
|
96
|
+
A rollback restores the agent's state. It cannot reverse transactions that already happened on-chain; those are
|
|
97
|
+
listed so the agent can account for them.
|
|
98
|
+
|
|
99
|
+
## How it works
|
|
100
|
+
|
|
101
|
+
`Guard` starts the `upoch mcp` command from `@upoch/guard` and talks to it over stdio. That process holds the keys:
|
|
102
|
+
it encrypts each checkpoint on your machine, stores it on IPFS, commits its fingerprint to the Upoch contract, and
|
|
103
|
+
reads it back to verify it. Only someone with your checkpoint key can read it, and anyone can check that it has
|
|
104
|
+
not been changed. Every checkpoint opens in the [Time-Travel Debugger](https://upoch.tech/docs/debugger).
|
|
105
|
+
|
|
106
|
+
Settings live in `UPOCH_HOME` (default `~/.upoch`); `Guard(home=...)` picks another one. If `upoch` is not on your
|
|
107
|
+
`PATH`, set `UPOCH_COMMAND` (for example `node /path/to/cli.js mcp`) or pass `Guard(command=[...])`.
|
|
108
|
+
|
|
109
|
+
## Safety
|
|
110
|
+
|
|
111
|
+
- Each checkpoint is one transaction from the agent's executor key. That key can only submit checkpoints. There is a
|
|
112
|
+
daily limit (100 by default, `maxCheckpointsPerDay` in `~/.upoch/config.json`).
|
|
113
|
+
- State that looks like private keys, seed phrases or API tokens is refused, because checkpoints are permanent.
|
|
114
|
+
- Keys stay on your machine, in `~/.upoch/secrets.json`, and never pass through Python. Back that file up.
|
|
115
|
+
- The Upoch contracts have not been audited.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "upoch"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Upoch Guard for Python agents: verifiable checkpoints, recovery and rollback on Robinhood Chain, with a one-line LangChain and LangGraph callback."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = []
|
|
10
|
+
keywords = ["upoch", "ai-agents", "checkpoint", "recovery", "rollback", "robinhood-chain", "langchain", "langgraph"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Topic :: Software Development :: Libraries",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
langchain = ["langchain-core>=1.0,<2"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://upoch.tech"
|
|
22
|
+
Documentation = "https://upoch.tech/docs"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["setuptools>=77"]
|
|
26
|
+
build-backend = "setuptools.build_meta"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["upoch"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
upoch = ["py.typed"]
|
upoch-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""upoch against a real local chain: anvil with the Upoch contract and a registered setup (guard/test/local-chain-cli.ts),
|
|
2
|
+
the upoch mcp server from guard/dist, and LangChain's own callback manager for the LangChain handler.
|
|
3
|
+
|
|
4
|
+
Run: python -m unittest discover -s python/tests (needs anvil, the compiled contract, guard built, langchain-core).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
17
|
+
|
|
18
|
+
from upoch import Guard, UpochError # noqa: E402
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
21
|
+
CLI = ROOT / "guard" / "dist" / "cli.js"
|
|
22
|
+
ANVIL = Path.home() / ".foundry" / "bin" / ("anvil.exe" if os.name == "nt" else "anvil")
|
|
23
|
+
HAVE_CHAIN = ANVIL.exists() and (ROOT / "artifacts" / "contracts" / "Epoch.sol" / "Epoch.json").exists() and CLI.exists()
|
|
24
|
+
TX = "0x" + "be" * 32
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from langchain_core.tools import tool
|
|
28
|
+
|
|
29
|
+
from upoch.langchain import UpochCallbackHandler, upoch_tools
|
|
30
|
+
|
|
31
|
+
HAVE_LANGCHAIN = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
HAVE_LANGCHAIN = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Chain:
|
|
37
|
+
def __init__(self, name: str) -> None:
|
|
38
|
+
self.proc = subprocess.Popen(["node", str(ROOT / "guard" / "test" / "local-chain-cli.ts"), name], cwd=ROOT / "guard", stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
|
39
|
+
assert self.proc.stdout is not None
|
|
40
|
+
line = self.proc.stdout.readline()
|
|
41
|
+
if not line:
|
|
42
|
+
raise RuntimeError("local chain setup failed")
|
|
43
|
+
self.home = json.loads(line)["home"]
|
|
44
|
+
|
|
45
|
+
def guard(self) -> Guard:
|
|
46
|
+
return Guard(self.home, command=["node", str(CLI), "mcp"])
|
|
47
|
+
|
|
48
|
+
def stop(self) -> None:
|
|
49
|
+
assert self.proc.stdin is not None
|
|
50
|
+
self.proc.stdin.close()
|
|
51
|
+
self.proc.wait(timeout=30)
|
|
52
|
+
if self.proc.stdout:
|
|
53
|
+
self.proc.stdout.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotInstalledTests(unittest.TestCase):
|
|
57
|
+
def test_missing_command_says_how_to_install(self) -> None:
|
|
58
|
+
g = Guard(command=["upoch-command-that-does-not-exist", "mcp"])
|
|
59
|
+
with self.assertRaises(UpochError) as e:
|
|
60
|
+
g.status()
|
|
61
|
+
self.assertEqual(e.exception.code, "NOT_INSTALLED")
|
|
62
|
+
self.assertIn("npm install -g @upoch/guard", str(e.exception))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@unittest.skipUnless(HAVE_CHAIN, "needs anvil (Foundry), the compiled Epoch artifact and guard/dist")
|
|
66
|
+
class GuardTests(unittest.TestCase):
|
|
67
|
+
@classmethod
|
|
68
|
+
def setUpClass(cls) -> None:
|
|
69
|
+
cls.chain = Chain("Python agent")
|
|
70
|
+
cls.g = cls.chain.guard()
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def tearDownClass(cls) -> None:
|
|
74
|
+
cls.g.close()
|
|
75
|
+
cls.chain.stop()
|
|
76
|
+
|
|
77
|
+
def test_lifecycle(self) -> None:
|
|
78
|
+
s = self.g.status()
|
|
79
|
+
self.assertEqual(s["chainId"], 31337)
|
|
80
|
+
self.assertEqual(s["workflow"]["checkpoints"], "0")
|
|
81
|
+
self.assertRegex(self.g.server_version or "", r"^0.1.")
|
|
82
|
+
|
|
83
|
+
self.g.record_action("swap-1", "swap", "planned", input={"sell": "WETH", "qty": 0.1})
|
|
84
|
+
c0 = self.g.checkpoint({"step": 1, "plan": "sell 0.1 WETH"}, note="before swap", kind="before_action")
|
|
85
|
+
self.assertEqual(c0["status"], "VERIFIED_RECOVERY_POINT", c0)
|
|
86
|
+
self.g.record_action("swap-1", "swap", "confirmed", transaction_hash=TX, result={"got": "301.2 USDC"})
|
|
87
|
+
c1 = self.g.checkpoint({"step": 2, "usdc": 301.2}, note="after swap", kind="after_action")
|
|
88
|
+
self.assertEqual(c1["sequence"], "1")
|
|
89
|
+
|
|
90
|
+
r = self.g.restore()
|
|
91
|
+
self.assertEqual(r["status"], "RESTORED")
|
|
92
|
+
self.assertEqual(r["state"], {"step": 2, "usdc": 301.2})
|
|
93
|
+
self.assertEqual(self.g.restore(sequence=0)["state"]["step"], 1)
|
|
94
|
+
self.assertTrue(self.g.verify(1)["ok"])
|
|
95
|
+
self.assertEqual(self.g.history(limit=5)["total"], "2")
|
|
96
|
+
|
|
97
|
+
# A restart of the server (a crash, a new process) recovers the same state from the chain.
|
|
98
|
+
self.g.close()
|
|
99
|
+
self.assertEqual(self.chain.guard().restore()["state"]["step"], 2)
|
|
100
|
+
|
|
101
|
+
rb = self.g.rollback(0, "the swap was the wrong size")
|
|
102
|
+
self.assertEqual(rb["status"], "ROLLED_BACK", rb)
|
|
103
|
+
self.assertEqual(rb["state"]["step"], 1)
|
|
104
|
+
self.assertEqual([a["operationId"] for a in rb["actionsSince"]], ["swap-1"], "the swap is listed as not undone")
|
|
105
|
+
self.assertEqual(self.g.restore()["state"]["step"], 1)
|
|
106
|
+
|
|
107
|
+
with self.assertRaises(UpochError) as e:
|
|
108
|
+
self.g.checkpoint({"privateKey": "0x" + "11" * 32})
|
|
109
|
+
self.assertIn("Refused", str(e.exception))
|
|
110
|
+
|
|
111
|
+
done = self.g.complete({"step": 3}, note="finished")
|
|
112
|
+
self.assertEqual(done["status"], "VERIFIED_RECOVERY_POINT", done)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@unittest.skipUnless(HAVE_CHAIN and HAVE_LANGCHAIN, "needs the local chain and langchain-core")
|
|
116
|
+
class LangChainTests(unittest.TestCase):
|
|
117
|
+
@classmethod
|
|
118
|
+
def setUpClass(cls) -> None:
|
|
119
|
+
cls.chain = Chain("LangChain Python agent")
|
|
120
|
+
cls.errors: list[Exception] = []
|
|
121
|
+
cls.handler = UpochCallbackHandler(cls.chain.guard(), on_error=cls.errors.append)
|
|
122
|
+
cls.seen: list[int | None] = []
|
|
123
|
+
handler, seen = cls.handler, cls.seen
|
|
124
|
+
|
|
125
|
+
@tool
|
|
126
|
+
def swap_tokens(qty: float) -> str:
|
|
127
|
+
"""Swap tokens."""
|
|
128
|
+
seen.append(handler.last_checkpoint)
|
|
129
|
+
return f"swapped {qty} WETH, tx {TX}"
|
|
130
|
+
|
|
131
|
+
@tool
|
|
132
|
+
def get_price() -> str:
|
|
133
|
+
"""Read a price."""
|
|
134
|
+
return "3010.5"
|
|
135
|
+
|
|
136
|
+
@tool
|
|
137
|
+
def transfer(to: str) -> str:
|
|
138
|
+
"""Transfer."""
|
|
139
|
+
raise ValueError("insufficient balance")
|
|
140
|
+
|
|
141
|
+
cls.swap, cls.price, cls.transfer = swap_tokens, get_price, transfer
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def tearDownClass(cls) -> None:
|
|
145
|
+
cls.handler.guard.close()
|
|
146
|
+
cls.chain.stop()
|
|
147
|
+
|
|
148
|
+
def test_1_checkpoints_around_a_swap(self) -> None:
|
|
149
|
+
self.price.invoke({}, config={"callbacks": [self.handler]})
|
|
150
|
+
self.assertIsNone(self.handler.last_checkpoint, "read-only tools are not checkpointed")
|
|
151
|
+
out = self.swap.invoke({"qty": 0.1}, config={"callbacks": [self.handler]})
|
|
152
|
+
self.assertIn("swapped 0.1", out)
|
|
153
|
+
self.assertEqual(self.seen, [0], "checkpoint #0 was committed before the swap body ran")
|
|
154
|
+
self.assertEqual(self.handler.last_checkpoint, 1)
|
|
155
|
+
e = self.handler.log[-1]
|
|
156
|
+
self.assertEqual((e["status"], e["transactionHash"], e["checkpointBefore"]), ("completed", TX, 0))
|
|
157
|
+
self.assertEqual(self.errors, [])
|
|
158
|
+
|
|
159
|
+
def test_2_async_waits_for_the_pre_tool_checkpoint(self) -> None:
|
|
160
|
+
import asyncio
|
|
161
|
+
|
|
162
|
+
asyncio.run(self.swap.ainvoke({"qty": 0.2}, config={"callbacks": [self.handler]}))
|
|
163
|
+
self.assertEqual(self.seen[-1], 2)
|
|
164
|
+
self.assertEqual(self.handler.last_checkpoint, 3)
|
|
165
|
+
|
|
166
|
+
def test_3_failing_tool_is_recorded_as_failed(self) -> None:
|
|
167
|
+
with self.assertRaises(ValueError):
|
|
168
|
+
self.transfer.invoke({"to": "0x1"}, config={"callbacks": [self.handler]})
|
|
169
|
+
self.assertEqual(self.handler.log[-1]["status"], "failed")
|
|
170
|
+
self.assertEqual(self.handler.last_checkpoint, 5)
|
|
171
|
+
|
|
172
|
+
def test_4_model_tools_restore_and_roll_back(self) -> None:
|
|
173
|
+
tools = {t.name: t for t in upoch_tools(handler=self.handler)}
|
|
174
|
+
self.assertEqual(sorted(tools), ["upoch_checkpoint", "upoch_complete", "upoch_history", "upoch_restore", "upoch_rollback"])
|
|
175
|
+
restored = json.loads(tools["upoch_restore"].invoke({}))
|
|
176
|
+
self.assertEqual(len(restored["state"]["tools"]), 3)
|
|
177
|
+
self.assertEqual(json.loads(tools["upoch_history"].invoke({"limit": 10}))["total"], "6")
|
|
178
|
+
rb = json.loads(tools["upoch_rollback"].invoke({"reason": "the transfer failed; go back"}))
|
|
179
|
+
self.assertEqual((rb["status"], rb["rolledBackTo"]), ("ROLLED_BACK", "4"), rb)
|
|
180
|
+
saved = json.loads(tools["upoch_checkpoint"].invoke({"state": {"plan": "retry with less"}, "note": "after rollback"}))
|
|
181
|
+
self.assertEqual(saved["status"], "VERIFIED_RECOVERY_POINT")
|
|
182
|
+
self.assertTrue(tools["upoch_checkpoint"].invoke({"state": {"privateKey": "0x" + "11" * 32}}).startswith("Error:"))
|
|
183
|
+
|
|
184
|
+
def test_5_strict_stops_the_tool_when_its_checkpoint_fails(self) -> None:
|
|
185
|
+
errors: list[Exception] = []
|
|
186
|
+
strict = UpochCallbackHandler(self.handler.guard, state=lambda: {"privateKey": "0x" + "11" * 32}, strict=True, on_error=errors.append)
|
|
187
|
+
before = len(self.seen)
|
|
188
|
+
with self.assertRaises(UpochError):
|
|
189
|
+
self.swap.invoke({"qty": 9}, config={"callbacks": [strict]})
|
|
190
|
+
self.assertEqual(len(self.seen), before, "the swap did not run")
|
|
191
|
+
self.assertEqual(len(errors), 1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
unittest.main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Upoch Guard for Python agents: verifiable checkpoints, recovery and rollback on Robinhood Chain.
|
|
2
|
+
|
|
3
|
+
from upoch import Guard
|
|
4
|
+
|
|
5
|
+
guard = Guard()
|
|
6
|
+
restored = guard.restore()
|
|
7
|
+
guard.checkpoint({"step": 3, "positions": [...]}, note="rebalanced")
|
|
8
|
+
|
|
9
|
+
LangChain and LangGraph: ``from upoch.langchain import UpochCallbackHandler`` (``pip install "upoch[langchain]"``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from .guard import ActionStatus, CheckpointKind, Guard, UpochError
|
|
15
|
+
|
|
16
|
+
__all__ = ["Guard", "UpochError", "CheckpointKind", "ActionStatus", "__version__"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Guard: Upoch Guard from Python, through the ``upoch mcp`` server of @upoch/guard.
|
|
2
|
+
|
|
3
|
+
The server holds the keys and does the signing, encryption and verification; this module only starts it and
|
|
4
|
+
calls its tools over stdio (newline-delimited JSON-RPC, the MCP stdio transport). Standard library only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
from collections import deque
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
__all__ = ["Guard", "UpochError", "CheckpointKind", "ActionStatus"]
|
|
19
|
+
|
|
20
|
+
CheckpointKind = Literal["progress", "before_action", "after_action", "manual"]
|
|
21
|
+
ActionStatus = Literal["planned", "submitted", "confirmed", "failed"]
|
|
22
|
+
|
|
23
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
24
|
+
INSTALL_HINT = "Install it with: npm install -g @upoch/guard, then run: upoch setup (in your own terminal)."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UpochError(Exception):
|
|
28
|
+
"""A tool or server error. ``code`` is the Guard error code (e.g. NOT_SET_UP, REFUSED) when there is one."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, code: str, message: str) -> None:
|
|
31
|
+
super().__init__(f"{code}: {message}")
|
|
32
|
+
self.code = code
|
|
33
|
+
self.message = message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_command() -> list[str]:
|
|
37
|
+
env = os.environ.get("UPOCH_COMMAND")
|
|
38
|
+
if env:
|
|
39
|
+
return shlex.split(env, posix=os.name != "nt")
|
|
40
|
+
found = shutil.which("upoch")
|
|
41
|
+
if not found:
|
|
42
|
+
raise UpochError("NOT_INSTALLED", f"The upoch command was not found. {INSTALL_HINT}")
|
|
43
|
+
return [found, "mcp"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _StdioClient:
|
|
47
|
+
"""A minimal MCP client over a child process's stdin/stdout. Thread-safe."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, command: list[str], env: dict[str, str], timeout: float) -> None:
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
try:
|
|
52
|
+
self.proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
|
53
|
+
except OSError as e:
|
|
54
|
+
raise UpochError("NOT_INSTALLED", f"Could not start {command[0]}: {e}. {INSTALL_HINT}") from e
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._next_id = 0
|
|
57
|
+
self._pending: dict[int, dict[str, Any]] = {}
|
|
58
|
+
self._cond = threading.Condition()
|
|
59
|
+
self._closed: str | None = None
|
|
60
|
+
self._stderr: deque[str] = deque(maxlen=20)
|
|
61
|
+
threading.Thread(target=self._read_stdout, daemon=True).start()
|
|
62
|
+
threading.Thread(target=self._read_stderr, daemon=True).start()
|
|
63
|
+
init = self.request("initialize", {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "upoch-python", "version": _version()}})
|
|
64
|
+
self.server_info: dict[str, Any] = init.get("serverInfo", {})
|
|
65
|
+
self.instructions: str = init.get("instructions", "")
|
|
66
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
67
|
+
|
|
68
|
+
def _send(self, msg: dict[str, Any]) -> None:
|
|
69
|
+
data = (json.dumps(msg, separators=(",", ":")) + "\n").encode("utf-8")
|
|
70
|
+
with self._lock:
|
|
71
|
+
if self._closed:
|
|
72
|
+
raise UpochError("SERVER_EXITED", self._closed)
|
|
73
|
+
assert self.proc.stdin is not None
|
|
74
|
+
self.proc.stdin.write(data)
|
|
75
|
+
self.proc.stdin.flush()
|
|
76
|
+
|
|
77
|
+
def _read_stdout(self) -> None:
|
|
78
|
+
assert self.proc.stdout is not None
|
|
79
|
+
for raw in self.proc.stdout:
|
|
80
|
+
try:
|
|
81
|
+
msg = json.loads(raw)
|
|
82
|
+
except ValueError:
|
|
83
|
+
continue
|
|
84
|
+
if "id" in msg and "method" in msg:
|
|
85
|
+
# A request from the server (e.g. ping): answer ping, decline anything else.
|
|
86
|
+
reply = {"jsonrpc": "2.0", "id": msg["id"], "result": {}} if msg["method"] == "ping" else {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32601, "message": "Method not found"}}
|
|
87
|
+
try:
|
|
88
|
+
self._send(reply)
|
|
89
|
+
except UpochError:
|
|
90
|
+
pass
|
|
91
|
+
elif "id" in msg:
|
|
92
|
+
with self._cond:
|
|
93
|
+
self._pending[msg["id"]] = msg
|
|
94
|
+
self._cond.notify_all()
|
|
95
|
+
with self._cond:
|
|
96
|
+
tail = " | ".join(self._stderr) or f"exit code {self.proc.poll()}"
|
|
97
|
+
self._closed = f"The upoch server stopped: {tail}"
|
|
98
|
+
self._cond.notify_all()
|
|
99
|
+
|
|
100
|
+
def _read_stderr(self) -> None:
|
|
101
|
+
assert self.proc.stderr is not None
|
|
102
|
+
for raw in self.proc.stderr:
|
|
103
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
104
|
+
if line:
|
|
105
|
+
self._stderr.append(line)
|
|
106
|
+
|
|
107
|
+
def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
with self._cond:
|
|
109
|
+
self._next_id += 1
|
|
110
|
+
rid = self._next_id
|
|
111
|
+
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
|
112
|
+
with self._cond:
|
|
113
|
+
if not self._cond.wait_for(lambda: rid in self._pending or self._closed is not None, timeout=self.timeout):
|
|
114
|
+
raise UpochError("TIMEOUT", f"No answer to {method} after {self.timeout:.0f}s.")
|
|
115
|
+
if rid not in self._pending:
|
|
116
|
+
raise UpochError("SERVER_EXITED", self._closed or "The upoch server stopped.")
|
|
117
|
+
msg = self._pending.pop(rid)
|
|
118
|
+
if "error" in msg:
|
|
119
|
+
raise UpochError("PROTOCOL", str(msg["error"].get("message", msg["error"])))
|
|
120
|
+
return msg.get("result", {})
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
with self._lock:
|
|
124
|
+
self._closed = self._closed or "closed"
|
|
125
|
+
try:
|
|
126
|
+
if self.proc.stdin:
|
|
127
|
+
self.proc.stdin.close()
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
try:
|
|
131
|
+
self.proc.wait(timeout=5)
|
|
132
|
+
except subprocess.TimeoutExpired:
|
|
133
|
+
self.proc.kill()
|
|
134
|
+
self.proc.wait()
|
|
135
|
+
for pipe in (self.proc.stdout, self.proc.stderr):
|
|
136
|
+
if pipe:
|
|
137
|
+
pipe.close()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _version() -> str:
|
|
141
|
+
from . import __version__
|
|
142
|
+
|
|
143
|
+
return __version__
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Guard:
|
|
147
|
+
"""Verifiable checkpoints, recovery and rollback for a Python agent, on Robinhood Chain.
|
|
148
|
+
|
|
149
|
+
Keys and settings come from ``upoch setup`` (in ``home``, default ``UPOCH_HOME`` or ``~/.upoch``). The
|
|
150
|
+
``upoch`` command (``npm install -g @upoch/guard``) is started on first use and stopped by :meth:`close`.
|
|
151
|
+
|
|
152
|
+
Each method returns the server's JSON result as a dict. A refused or failed call raises :class:`UpochError`;
|
|
153
|
+
:meth:`checkpoint` instead returns a result whose ``status`` says what happened (``VERIFIED_RECOVERY_POINT``
|
|
154
|
+
when the checkpoint was committed and verified).
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, home: str | os.PathLike[str] | None = None, *, command: list[str] | None = None, env: dict[str, str] | None = None, timeout: float = 300.0) -> None:
|
|
158
|
+
self._command = command
|
|
159
|
+
self._env = {**os.environ, **(env or {})}
|
|
160
|
+
if home is not None:
|
|
161
|
+
self._env["UPOCH_HOME"] = os.fspath(home)
|
|
162
|
+
self._timeout = timeout
|
|
163
|
+
self._client: _StdioClient | None = None
|
|
164
|
+
self._start_lock = threading.Lock()
|
|
165
|
+
|
|
166
|
+
def _call(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
with self._start_lock:
|
|
168
|
+
if self._client is None:
|
|
169
|
+
self._client = _StdioClient(self._command or _default_command(), self._env, self._timeout)
|
|
170
|
+
r = self._client.request("tools/call", {"name": tool, "arguments": {k: v for k, v in args.items() if v is not None}})
|
|
171
|
+
text = "".join(c.get("text", "") for c in r.get("content", []) if c.get("type") == "text")
|
|
172
|
+
if r.get("isError"):
|
|
173
|
+
code, sep, message = text.partition(": ")
|
|
174
|
+
if sep and code.replace("_", "").isupper():
|
|
175
|
+
raise UpochError(code, message)
|
|
176
|
+
raise UpochError("ERROR", text)
|
|
177
|
+
return json.loads(text) if text else {}
|
|
178
|
+
|
|
179
|
+
def status(self) -> dict[str, Any]:
|
|
180
|
+
"""The workflow (checkpoints, head), executor funds, the open run and today's usage."""
|
|
181
|
+
return self._call("upoch_status", {})
|
|
182
|
+
|
|
183
|
+
def checkpoint(self, state: Any, note: str | None = None, kind: CheckpointKind = "progress") -> dict[str, Any]:
|
|
184
|
+
"""Saves ``state`` (JSON) as an encrypted checkpoint, committed on-chain and verified. One transaction."""
|
|
185
|
+
return self._call("upoch_checkpoint", {"state": state, "note": note, "kind": kind})
|
|
186
|
+
|
|
187
|
+
def record_action(self, operation_id: str, type: str, status: ActionStatus, *, transaction_hash: str | None = None, input: Any = None, result: Any = None) -> dict[str, Any]:
|
|
188
|
+
"""Records an external action (planned, then confirmed or failed); it goes into the next checkpoint."""
|
|
189
|
+
return self._call("upoch_record_action", {"operationId": operation_id, "type": type, "status": status, "transactionHash": transaction_hash, "input": input, "result": result})
|
|
190
|
+
|
|
191
|
+
def restore(self, sequence: int | None = None) -> dict[str, Any]:
|
|
192
|
+
"""The latest checkpoint (or ``sequence``), verified: its state, note and which actions completed."""
|
|
193
|
+
return self._call("upoch_restore", {"sequence": sequence})
|
|
194
|
+
|
|
195
|
+
def rollback(self, sequence: int, reason: str) -> dict[str, Any]:
|
|
196
|
+
"""Goes back to checkpoint ``sequence`` and continues from it. One transaction.
|
|
197
|
+
|
|
198
|
+
Restores state only: on-chain transactions since then are not undone; they are listed in ``actionsSince``.
|
|
199
|
+
"""
|
|
200
|
+
return self._call("upoch_rollback", {"sequence": sequence, "reason": reason})
|
|
201
|
+
|
|
202
|
+
def history(self, limit: int | None = None) -> dict[str, Any]:
|
|
203
|
+
"""The most recent checkpoints, newest first."""
|
|
204
|
+
return self._call("upoch_history", {"limit": limit})
|
|
205
|
+
|
|
206
|
+
def verify(self, sequence: int) -> dict[str, Any]:
|
|
207
|
+
"""Verifies one checkpoint without restoring it."""
|
|
208
|
+
return self._call("upoch_verify", {"sequence": sequence})
|
|
209
|
+
|
|
210
|
+
def complete(self, state: Any, note: str | None = None) -> dict[str, Any]:
|
|
211
|
+
"""Saves a final checkpoint marking the task finished. One transaction."""
|
|
212
|
+
return self._call("upoch_complete", {"state": state, "note": note})
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def server_version(self) -> str | None:
|
|
216
|
+
return self._client.server_info.get("version") if self._client else None
|
|
217
|
+
|
|
218
|
+
def close(self) -> None:
|
|
219
|
+
"""Stops the upoch server (it starts again on the next call)."""
|
|
220
|
+
with self._start_lock:
|
|
221
|
+
if self._client is not None:
|
|
222
|
+
self._client.close()
|
|
223
|
+
self._client = None
|
|
224
|
+
|
|
225
|
+
def __enter__(self) -> Guard:
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def __exit__(self, *exc: object) -> None:
|
|
229
|
+
self.close()
|
|
230
|
+
|
|
231
|
+
def __del__(self) -> None:
|
|
232
|
+
try:
|
|
233
|
+
self.close()
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Upoch Guard for LangChain and LangGraph (Python). Two ways in, usable together:
|
|
2
|
+
|
|
3
|
+
UpochCallbackHandler() pass it in ``callbacks``: every consequential tool call (swap, transfer...) is
|
|
4
|
+
checkpointed on Robinhood Chain before and after it runs, with no code changes
|
|
5
|
+
upoch_tools() tools the model can call itself: restore, checkpoint, rollback, history, complete
|
|
6
|
+
|
|
7
|
+
Needs ``pip install "upoch[langchain]"`` and the one-time ``upoch setup`` in your terminal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Callable, Iterable
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any
|
|
18
|
+
from uuid import UUID
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
22
|
+
from langchain_core.tools import BaseTool, StructuredTool
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
except ImportError as e: # pragma: no cover
|
|
25
|
+
raise ImportError('upoch.langchain needs LangChain: pip install "upoch[langchain]"') from e
|
|
26
|
+
|
|
27
|
+
from .guard import Guard, UpochError
|
|
28
|
+
|
|
29
|
+
__all__ = ["DEFAULT_WATCH", "UpochCallbackHandler", "upoch_tools"]
|
|
30
|
+
|
|
31
|
+
#: Tool names that move value or change something outside the agent.
|
|
32
|
+
DEFAULT_WATCH = re.compile(r"(swap|transfer|send|bridge|deploy|stake|unstake|lend|borrow|repay|withdraw|deposit|buy|sell|trade|mint|launch|approve)", re.I)
|
|
33
|
+
_TX = re.compile(r"0x[0-9a-fA-F]{64}")
|
|
34
|
+
_OK = ("VERIFIED_RECOVERY_POINT", "COMMITTED")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _text(v: Any) -> str:
|
|
38
|
+
v = getattr(v, "content", v) # ToolMessage
|
|
39
|
+
if not isinstance(v, str):
|
|
40
|
+
try:
|
|
41
|
+
v = json.dumps(v, default=str)
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
v = str(v)
|
|
44
|
+
return v[:500]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UpochCallbackHandler(BaseCallbackHandler):
|
|
48
|
+
"""Checkpoints around consequential tool calls.
|
|
49
|
+
|
|
50
|
+
The pre-tool checkpoint is committed before the tool runs (LangChain waits for callback handlers, also
|
|
51
|
+
under ``ainvoke``). Failures are logged and the tool runs anyway, unless ``strict=True``.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
guard: a :class:`~upoch.Guard` to use; default: one for ``UPOCH_HOME`` (``~/.upoch``).
|
|
55
|
+
watch: which tool names to checkpoint around: a regex or a list of exact names.
|
|
56
|
+
state: what each automatic checkpoint holds (default: the log of watched tool calls).
|
|
57
|
+
strict: raise, and so stop the tool, when its pre-tool checkpoint fails.
|
|
58
|
+
on_error: called with each error (default: a warning on stderr).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, guard: Guard | None = None, *, watch: re.Pattern[str] | Iterable[str] | None = None, state: Callable[[], Any] | None = None, strict: bool = False, on_error: Callable[[Exception], None] | None = None) -> None:
|
|
62
|
+
super().__init__()
|
|
63
|
+
self.guard = guard or Guard()
|
|
64
|
+
if watch is None or isinstance(watch, re.Pattern):
|
|
65
|
+
pattern = watch or DEFAULT_WATCH
|
|
66
|
+
self._watch: Callable[[str], bool] = lambda n: bool(pattern.search(n))
|
|
67
|
+
else:
|
|
68
|
+
names = set(watch)
|
|
69
|
+
self._watch = lambda n: n in names
|
|
70
|
+
self._state = state
|
|
71
|
+
self.raise_error = strict
|
|
72
|
+
self._on_error = on_error or (lambda e: print(f"[upoch] {e}", file=__import__("sys").stderr))
|
|
73
|
+
self.log: list[dict[str, Any]] = []
|
|
74
|
+
self.last_checkpoint: int | None = None
|
|
75
|
+
self._runs: dict[UUID, dict[str, Any]] = {}
|
|
76
|
+
self._serial = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def _save(self, note: str, kind: str) -> int | None:
|
|
79
|
+
state = self._state() if self._state else {"tools": self.log[-25:]}
|
|
80
|
+
r = self.guard.checkpoint(state, note=note, kind=kind) # type: ignore[arg-type]
|
|
81
|
+
if r.get("status") not in _OK:
|
|
82
|
+
raise UpochError(str(r.get("status")), (r.get("error") or {}).get("message", "checkpoint failed"))
|
|
83
|
+
if r.get("sequence") is not None:
|
|
84
|
+
self.last_checkpoint = int(r["sequence"])
|
|
85
|
+
return self.last_checkpoint
|
|
86
|
+
|
|
87
|
+
def _guarded(self, f: Callable[[], None], rethrow: bool) -> None:
|
|
88
|
+
with self._serial:
|
|
89
|
+
try:
|
|
90
|
+
f()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
self._on_error(e)
|
|
93
|
+
if rethrow:
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
def on_tool_start(self, serialized: dict[str, Any] | None, input_str: str, *, run_id: UUID, **kwargs: Any) -> None:
|
|
97
|
+
name = (serialized or {}).get("name") or kwargs.get("name") or "tool"
|
|
98
|
+
if not self._watch(name):
|
|
99
|
+
return
|
|
100
|
+
op = re.sub(r"[^A-Za-z0-9._:-]", "_", f"{name}-{run_id}")[:128]
|
|
101
|
+
entry: dict[str, Any] = {"operationId": op, "tool": name, "status": "started", "input": _text(input_str), "at": datetime.now(timezone.utc).isoformat()}
|
|
102
|
+
self._runs[run_id] = entry
|
|
103
|
+
|
|
104
|
+
def before() -> None:
|
|
105
|
+
self.guard.record_action(op, name, "planned")
|
|
106
|
+
self.log.append(entry)
|
|
107
|
+
entry["checkpointBefore"] = self._save(f"Before {name}", "before_action")
|
|
108
|
+
|
|
109
|
+
self._guarded(before, self.raise_error)
|
|
110
|
+
|
|
111
|
+
def _finish(self, run_id: UUID, ok: bool, output: Any) -> None:
|
|
112
|
+
entry = self._runs.pop(run_id, None)
|
|
113
|
+
if entry is None:
|
|
114
|
+
return
|
|
115
|
+
out = _text(output)
|
|
116
|
+
m = _TX.search(out)
|
|
117
|
+
tx = m.group(0) if m else None
|
|
118
|
+
|
|
119
|
+
def after() -> None:
|
|
120
|
+
self.guard.record_action(entry["operationId"], entry["tool"], "confirmed" if ok else "failed", transaction_hash=tx, result={"success": ok, "output": out[:300]})
|
|
121
|
+
entry.update({"status": "completed" if ok else "failed", "output": out[:300]})
|
|
122
|
+
if tx:
|
|
123
|
+
entry["transactionHash"] = tx
|
|
124
|
+
if entry not in self.log:
|
|
125
|
+
self.log.append(entry)
|
|
126
|
+
self._save(f"{'After' if ok else 'Failed'}: {entry['tool']}" + (f" ({tx[:10]}...)" if tx else ""), "after_action")
|
|
127
|
+
|
|
128
|
+
# After the tool ran, a failed checkpoint must never turn its result into an error.
|
|
129
|
+
try:
|
|
130
|
+
self._guarded(after, False)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
135
|
+
self._finish(run_id, True, output)
|
|
136
|
+
|
|
137
|
+
def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
138
|
+
self._finish(run_id, False, str(error))
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def last_pre_tool_checkpoint(self) -> int | None:
|
|
142
|
+
"""The checkpoint taken right before the most recent watched tool call."""
|
|
143
|
+
for e in reversed(self.log):
|
|
144
|
+
if e.get("checkpointBefore") is not None:
|
|
145
|
+
return int(e["checkpointBefore"])
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _safe(f: Callable[[], Any]) -> str:
|
|
150
|
+
try:
|
|
151
|
+
return json.dumps(f(), indent=2)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return f"Error: {e}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Empty(BaseModel):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _Checkpoint(BaseModel):
|
|
161
|
+
state: dict[str, Any] = Field(description="Everything needed to continue from here, as JSON.")
|
|
162
|
+
note: str | None = Field(default=None, max_length=500)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _Rollback(BaseModel):
|
|
166
|
+
sequence: int | None = Field(default=None, ge=0, description="The checkpoint to go back to (default: the one right before the last swap/transfer).")
|
|
167
|
+
reason: str = Field(min_length=1, max_length=300)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _History(BaseModel):
|
|
171
|
+
limit: int | None = Field(default=None, ge=1, le=50)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def upoch_tools(guard: Guard | None = None, handler: UpochCallbackHandler | None = None) -> list[BaseTool]:
|
|
175
|
+
"""Upoch tools for a LangChain or LangGraph agent. Pass the handler too, so rollback can default to "before
|
|
176
|
+
the last tool call"."""
|
|
177
|
+
g = guard or (handler.guard if handler else Guard())
|
|
178
|
+
|
|
179
|
+
def rollback(reason: str, sequence: int | None = None) -> str:
|
|
180
|
+
target = sequence if sequence is not None else (handler.last_pre_tool_checkpoint if handler else None)
|
|
181
|
+
if target is None:
|
|
182
|
+
return "Error: give a checkpoint number (see upoch_history)."
|
|
183
|
+
return _safe(lambda: g.rollback(target, reason))
|
|
184
|
+
|
|
185
|
+
return [
|
|
186
|
+
StructuredTool.from_function(
|
|
187
|
+
func=lambda: _safe(g.restore),
|
|
188
|
+
name="upoch_restore",
|
|
189
|
+
description="Restore the agent's latest Upoch checkpoint, verified against Robinhood Chain: its state, note, and which recorded actions completed. Call at the start of a task; do not repeat completed actions.",
|
|
190
|
+
args_schema=_Empty,
|
|
191
|
+
),
|
|
192
|
+
StructuredTool.from_function(
|
|
193
|
+
func=lambda state, note=None: _safe(lambda: g.checkpoint(state, note=note)),
|
|
194
|
+
name="upoch_checkpoint",
|
|
195
|
+
description="Save the current state as a verifiable checkpoint on Robinhood Chain (one transaction). Use after meaningful progress. Never include keys, seed phrases or tokens.",
|
|
196
|
+
args_schema=_Checkpoint,
|
|
197
|
+
),
|
|
198
|
+
StructuredTool.from_function(
|
|
199
|
+
func=rollback,
|
|
200
|
+
name="upoch_rollback",
|
|
201
|
+
description="Roll back to an earlier checkpoint and continue from it, e.g. after a bad trade. Default: the checkpoint right before the last swap/transfer tool call. Restores state only; on-chain transactions since then are not undone and are listed.",
|
|
202
|
+
args_schema=_Rollback,
|
|
203
|
+
),
|
|
204
|
+
StructuredTool.from_function(
|
|
205
|
+
func=lambda limit=None: _safe(lambda: g.history(limit)),
|
|
206
|
+
name="upoch_history",
|
|
207
|
+
description="List the most recent Upoch checkpoints, newest first.",
|
|
208
|
+
args_schema=_History,
|
|
209
|
+
),
|
|
210
|
+
StructuredTool.from_function(
|
|
211
|
+
func=lambda state, note=None: _safe(lambda: g.complete(state, note=note)),
|
|
212
|
+
name="upoch_complete",
|
|
213
|
+
description="Save a final checkpoint marking the task finished (one transaction).",
|
|
214
|
+
args_schema=_Checkpoint,
|
|
215
|
+
),
|
|
216
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: upoch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Upoch Guard for Python agents: verifiable checkpoints, recovery and rollback on Robinhood Chain, with a one-line LangChain and LangGraph callback.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://upoch.tech
|
|
7
|
+
Project-URL: Documentation, https://upoch.tech/docs
|
|
8
|
+
Keywords: upoch,ai-agents,checkpoint,recovery,rollback,robinhood-chain,langchain,langgraph
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core<2,>=1.0; extra == "langchain"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# upoch: Upoch Guard for Python agents
|
|
20
|
+
|
|
21
|
+
Give your Python agent a memory and an undo button.
|
|
22
|
+
|
|
23
|
+
Upoch Guard saves verifiable checkpoints of an agent's work on Robinhood Chain. If the agent crashes, gets a bad
|
|
24
|
+
prompt or makes a bad trade, it restores the last good state, checked against the chain, or rolls back to the
|
|
25
|
+
checkpoint before the mistake and keeps going. It also knows which of its actions already happened.
|
|
26
|
+
|
|
27
|
+
Website and debugger: https://upoch.tech. Contract (Robinhood Chain, 4663): `0x6179d9123c3c1f9e82f948101a4350cb55aa8c27`
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
Once, in your own terminal. You need Node.js 24 or later, because the `upoch` command holds the keys and does the
|
|
32
|
+
signing:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install -g @upoch/guard
|
|
36
|
+
upoch setup
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`upoch setup` creates your keys in `~/.upoch`, waits while you send the owner address a little ETH on Robinhood
|
|
40
|
+
Chain (0.001 ETH is plenty), and registers your agent. Then:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install upoch # or: pip install "upoch[langchain]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## LangChain and LangGraph
|
|
47
|
+
|
|
48
|
+
One line: every consequential tool call (swaps, transfers, bridges, staking, lending, trades, mints, launches and
|
|
49
|
+
approvals) is checkpointed before and after it runs. The pre-tool checkpoint is committed before the tool runs,
|
|
50
|
+
with `invoke` and `ainvoke`.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from upoch.langchain import UpochCallbackHandler, upoch_tools
|
|
54
|
+
|
|
55
|
+
upoch = UpochCallbackHandler()
|
|
56
|
+
|
|
57
|
+
# Automatic checkpoints around consequential tool calls:
|
|
58
|
+
agent.invoke(inputs, config={"callbacks": [upoch]})
|
|
59
|
+
|
|
60
|
+
# Optional: let the model restore, checkpoint and roll back itself.
|
|
61
|
+
tools = [*your_tools, *upoch_tools(handler=upoch)]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Tool | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `upoch_restore` | Latest checkpoint, verified against the chain, and which actions completed |
|
|
67
|
+
| `upoch_checkpoint` | Save the current state |
|
|
68
|
+
| `upoch_rollback` | Go back to an earlier checkpoint and continue. By default, the one right before the last swap or transfer |
|
|
69
|
+
| `upoch_history` | Recent checkpoints |
|
|
70
|
+
| `upoch_complete` | Mark the task finished |
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
UpochCallbackHandler(
|
|
76
|
+
watch=["swap_tokens", "send_eth"], # exact tool names, or a compiled regex (default: value-moving names)
|
|
77
|
+
state=lambda: my_agent_state, # what each checkpoint holds (default: the log of watched tool calls)
|
|
78
|
+
strict=True, # stop the tool when its pre-tool checkpoint fails (default False)
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Any Python agent
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from upoch import Guard
|
|
86
|
+
|
|
87
|
+
with Guard() as guard:
|
|
88
|
+
restored = guard.restore() # where the last run stopped, verified against the chain
|
|
89
|
+
state = restored.get("state") or {"step": 0}
|
|
90
|
+
|
|
91
|
+
guard.record_action("swap-1", "swap", "planned", input={"sell": "WETH", "qty": 0.1})
|
|
92
|
+
guard.checkpoint(state, note="before swap", kind="before_action")
|
|
93
|
+
tx = do_the_swap()
|
|
94
|
+
guard.record_action("swap-1", "swap", "confirmed", transaction_hash=tx)
|
|
95
|
+
guard.checkpoint({**state, "step": 1}, note="after swap", kind="after_action")
|
|
96
|
+
|
|
97
|
+
# Something went wrong? Go back to the checkpoint before it:
|
|
98
|
+
guard.rollback(0, reason="the swap was the wrong size")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Method | What it does | On-chain |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `restore(sequence=None)` | Latest (or a given) checkpoint after verifying it: state, note, completed and unresolved actions | read |
|
|
104
|
+
| `checkpoint(state, note, kind)` | Saves the state (`progress`, `before_action`, `after_action`, `manual`) | 1 transaction |
|
|
105
|
+
| `record_action(...)` | Records an external action (planned, submitted, confirmed, failed) with its transaction hash | no (goes into the next checkpoint) |
|
|
106
|
+
| `rollback(sequence, reason)` | Goes back to an earlier checkpoint and continues from it; lists actions since then that were not undone on-chain | 1 transaction |
|
|
107
|
+
| `complete(state, note)` | Saves a final checkpoint and marks the run finished | 1 transaction |
|
|
108
|
+
| `history(limit)` | Recent checkpoints | read |
|
|
109
|
+
| `verify(sequence)` | Verifies one checkpoint without restoring it | read |
|
|
110
|
+
| `status()` | Workflow, executor funds and today's usage | read |
|
|
111
|
+
|
|
112
|
+
Each method returns the result as a dict. Refusals and errors raise `upoch.UpochError` (with a `code`).
|
|
113
|
+
|
|
114
|
+
A rollback restores the agent's state. It cannot reverse transactions that already happened on-chain; those are
|
|
115
|
+
listed so the agent can account for them.
|
|
116
|
+
|
|
117
|
+
## How it works
|
|
118
|
+
|
|
119
|
+
`Guard` starts the `upoch mcp` command from `@upoch/guard` and talks to it over stdio. That process holds the keys:
|
|
120
|
+
it encrypts each checkpoint on your machine, stores it on IPFS, commits its fingerprint to the Upoch contract, and
|
|
121
|
+
reads it back to verify it. Only someone with your checkpoint key can read it, and anyone can check that it has
|
|
122
|
+
not been changed. Every checkpoint opens in the [Time-Travel Debugger](https://upoch.tech/docs/debugger).
|
|
123
|
+
|
|
124
|
+
Settings live in `UPOCH_HOME` (default `~/.upoch`); `Guard(home=...)` picks another one. If `upoch` is not on your
|
|
125
|
+
`PATH`, set `UPOCH_COMMAND` (for example `node /path/to/cli.js mcp`) or pass `Guard(command=[...])`.
|
|
126
|
+
|
|
127
|
+
## Safety
|
|
128
|
+
|
|
129
|
+
- Each checkpoint is one transaction from the agent's executor key. That key can only submit checkpoints. There is a
|
|
130
|
+
daily limit (100 by default, `maxCheckpointsPerDay` in `~/.upoch/config.json`).
|
|
131
|
+
- State that looks like private keys, seed phrases or API tokens is refused, because checkpoints are permanent.
|
|
132
|
+
- Keys stay on your machine, in `~/.upoch/secrets.json`, and never pass through Python. Back that file up.
|
|
133
|
+
- The Upoch contracts have not been audited.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_upoch.py
|
|
5
|
+
upoch/__init__.py
|
|
6
|
+
upoch/guard.py
|
|
7
|
+
upoch/langchain.py
|
|
8
|
+
upoch/py.typed
|
|
9
|
+
upoch.egg-info/PKG-INFO
|
|
10
|
+
upoch.egg-info/SOURCES.txt
|
|
11
|
+
upoch.egg-info/dependency_links.txt
|
|
12
|
+
upoch.egg-info/requires.txt
|
|
13
|
+
upoch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
upoch
|