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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+
2
+ [langchain]
3
+ langchain-core<2,>=1.0
@@ -0,0 +1 @@
1
+ upoch