wayfinder-paths 0.1.23__py3-none-any.whl → 0.1.25__py3-none-any.whl
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.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/__init__.py +2 -0
- wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
- wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
- wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
- wayfinder_paths/adapters/boros_adapter/client.py +476 -0
- wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
- wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
- wayfinder_paths/adapters/boros_adapter/types.py +70 -0
- wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
- wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
- wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
- wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
- wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
- wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
- wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
- wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
- wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/examples.json +0 -4
- wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
- wayfinder_paths/conftest.py +24 -17
- wayfinder_paths/core/__init__.py +2 -0
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +1 -1
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -2
- wayfinder_paths/core/clients/protocols.py +21 -22
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +12 -0
- wayfinder_paths/core/constants/__init__.py +15 -0
- wayfinder_paths/core/constants/base.py +6 -1
- wayfinder_paths/core/constants/contracts.py +39 -26
- wayfinder_paths/core/constants/erc20_abi.py +0 -1
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
- wayfinder_paths/core/constants/hyperliquid.py +16 -0
- wayfinder_paths/core/constants/moonwell_abi.py +0 -15
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -61
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/transaction.py +44 -1
- wayfinder_paths/core/utils/web3.py +3 -0
- wayfinder_paths/mcp/__init__.py +5 -0
- wayfinder_paths/mcp/preview.py +185 -0
- wayfinder_paths/mcp/scripting.py +84 -0
- wayfinder_paths/mcp/server.py +52 -0
- wayfinder_paths/mcp/state/profile_store.py +195 -0
- wayfinder_paths/mcp/state/store.py +89 -0
- wayfinder_paths/mcp/test_scripting.py +267 -0
- wayfinder_paths/mcp/tools/__init__.py +0 -0
- wayfinder_paths/mcp/tools/balances.py +290 -0
- wayfinder_paths/mcp/tools/discovery.py +158 -0
- wayfinder_paths/mcp/tools/execute.py +770 -0
- wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
- wayfinder_paths/mcp/tools/quotes.py +288 -0
- wayfinder_paths/mcp/tools/run_script.py +286 -0
- wayfinder_paths/mcp/tools/strategies.py +188 -0
- wayfinder_paths/mcp/tools/tokens.py +46 -0
- wayfinder_paths/mcp/tools/wallets.py +354 -0
- wayfinder_paths/mcp/utils.py +129 -0
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
- wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
- wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
- wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
- wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
- wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
- wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
- wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
- wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
- wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
- wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
- wayfinder_paths/tests/test_test_coverage.py +1 -4
- wayfinder_paths-0.1.25.dist-info/METADATA +377 -0
- wayfinder_paths-0.1.25.dist-info/RECORD +185 -0
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
- wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/WHEEL +0 -0
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
# mToken (CErc20Delegator) ABI - for lending, borrowing, and position management
|
|
2
1
|
MTOKEN_ABI = [
|
|
3
|
-
# Lend (supply) tokens by minting mTokens
|
|
4
2
|
{
|
|
5
3
|
"name": "mint",
|
|
6
4
|
"type": "function",
|
|
@@ -8,7 +6,6 @@ MTOKEN_ABI = [
|
|
|
8
6
|
"inputs": [{"name": "mintAmount", "type": "uint256"}],
|
|
9
7
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
10
8
|
},
|
|
11
|
-
# Withdraw (redeem) underlying by burning mTokens
|
|
12
9
|
{
|
|
13
10
|
"name": "redeem",
|
|
14
11
|
"type": "function",
|
|
@@ -16,7 +13,6 @@ MTOKEN_ABI = [
|
|
|
16
13
|
"inputs": [{"name": "redeemTokens", "type": "uint256"}],
|
|
17
14
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
18
15
|
},
|
|
19
|
-
# Withdraw exact underlying amount
|
|
20
16
|
{
|
|
21
17
|
"name": "redeemUnderlying",
|
|
22
18
|
"type": "function",
|
|
@@ -24,7 +20,6 @@ MTOKEN_ABI = [
|
|
|
24
20
|
"inputs": [{"name": "redeemAmount", "type": "uint256"}],
|
|
25
21
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
26
22
|
},
|
|
27
|
-
# Borrow underlying tokens
|
|
28
23
|
{
|
|
29
24
|
"name": "borrow",
|
|
30
25
|
"type": "function",
|
|
@@ -32,7 +27,6 @@ MTOKEN_ABI = [
|
|
|
32
27
|
"inputs": [{"name": "borrowAmount", "type": "uint256"}],
|
|
33
28
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
34
29
|
},
|
|
35
|
-
# Repay borrowed tokens
|
|
36
30
|
{
|
|
37
31
|
"name": "repayBorrow",
|
|
38
32
|
"type": "function",
|
|
@@ -124,7 +118,6 @@ MTOKEN_ABI = [
|
|
|
124
118
|
"inputs": [],
|
|
125
119
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
126
120
|
},
|
|
127
|
-
# Accrue interest
|
|
128
121
|
{
|
|
129
122
|
"name": "accrueInterest",
|
|
130
123
|
"type": "function",
|
|
@@ -141,9 +134,7 @@ MTOKEN_ABI = [
|
|
|
141
134
|
},
|
|
142
135
|
]
|
|
143
136
|
|
|
144
|
-
# Comptroller ABI - for collateral management and account liquidity
|
|
145
137
|
COMPTROLLER_ABI = [
|
|
146
|
-
# Enable a market as collateral
|
|
147
138
|
{
|
|
148
139
|
"name": "enterMarkets",
|
|
149
140
|
"type": "function",
|
|
@@ -151,7 +142,6 @@ COMPTROLLER_ABI = [
|
|
|
151
142
|
"inputs": [{"name": "mTokens", "type": "address[]"}],
|
|
152
143
|
"outputs": [{"name": "", "type": "uint256[]"}],
|
|
153
144
|
},
|
|
154
|
-
# Disable a market as collateral
|
|
155
145
|
{
|
|
156
146
|
"name": "exitMarket",
|
|
157
147
|
"type": "function",
|
|
@@ -234,7 +224,6 @@ COMPTROLLER_ABI = [
|
|
|
234
224
|
"inputs": [],
|
|
235
225
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
236
226
|
},
|
|
237
|
-
# Claim rewards for a user (called on comptroller in some versions)
|
|
238
227
|
{
|
|
239
228
|
"name": "claimReward",
|
|
240
229
|
"type": "function",
|
|
@@ -244,9 +233,7 @@ COMPTROLLER_ABI = [
|
|
|
244
233
|
},
|
|
245
234
|
]
|
|
246
235
|
|
|
247
|
-
# Reward Distributor ABI - for claiming WELL rewards
|
|
248
236
|
REWARD_DISTRIBUTOR_ABI = [
|
|
249
|
-
# Claim rewards for all markets
|
|
250
237
|
{
|
|
251
238
|
"name": "claimReward",
|
|
252
239
|
"type": "function",
|
|
@@ -254,7 +241,6 @@ REWARD_DISTRIBUTOR_ABI = [
|
|
|
254
241
|
"inputs": [],
|
|
255
242
|
"outputs": [],
|
|
256
243
|
},
|
|
257
|
-
# Claim rewards for specific holder and markets
|
|
258
244
|
{
|
|
259
245
|
"name": "claimReward",
|
|
260
246
|
"type": "function",
|
|
@@ -350,7 +336,6 @@ REWARD_DISTRIBUTOR_ABI = [
|
|
|
350
336
|
},
|
|
351
337
|
]
|
|
352
338
|
|
|
353
|
-
# WETH ABI for wrapping/unwrapping ETH
|
|
354
339
|
WETH_ABI = [
|
|
355
340
|
{
|
|
356
341
|
"name": "deposit",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pydantic import BaseModel, Field, validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AdapterRequirement(BaseModel):
|
|
8
|
+
name: str = Field(
|
|
9
|
+
..., description="Adapter symbolic name (e.g., BALANCE, HYPERLIQUID)"
|
|
10
|
+
)
|
|
11
|
+
capabilities: list[str] = Field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StrategyManifest(BaseModel):
|
|
15
|
+
schema_version: str = Field(default="0.1")
|
|
16
|
+
entrypoint: str = Field(
|
|
17
|
+
...,
|
|
18
|
+
description="Python path to class, e.g. strategies.funding_rate_strategy.FundingRateStrategy",
|
|
19
|
+
)
|
|
20
|
+
name: str | None = Field(
|
|
21
|
+
default=None,
|
|
22
|
+
description="Unique name identifier for this strategy instance. Used to look up dedicated wallet in wallets.json by label.",
|
|
23
|
+
)
|
|
24
|
+
permissions: dict[str, Any] = Field(default_factory=dict)
|
|
25
|
+
adapters: list[AdapterRequirement] = Field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@validator("entrypoint")
|
|
28
|
+
def validate_entrypoint(cls, v: str) -> str:
|
|
29
|
+
if "." not in v:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"entrypoint must be a full import path to a Strategy class"
|
|
32
|
+
)
|
|
33
|
+
return v
|
|
34
|
+
|
|
35
|
+
@validator("permissions")
|
|
36
|
+
def validate_permissions(cls, v: dict) -> dict:
|
|
37
|
+
if "policy" not in v:
|
|
38
|
+
raise ValueError("permissions.policy is required")
|
|
39
|
+
if not v["policy"]:
|
|
40
|
+
raise ValueError("permissions.policy cannot be empty")
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
@validator("adapters")
|
|
44
|
+
def validate_adapters(cls, v: list) -> list:
|
|
45
|
+
if not v:
|
|
46
|
+
raise ValueError("adapters cannot be empty")
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_strategy_manifest(path: str) -> StrategyManifest:
|
|
51
|
+
with open(path) as f:
|
|
52
|
+
data = yaml.safe_load(f)
|
|
53
|
+
return StrategyManifest(**data)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_manifest(path: str) -> StrategyManifest:
|
|
57
|
+
"""Legacy function for backward compatibility."""
|
|
58
|
+
return load_strategy_manifest(path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_manifest(manifest: StrategyManifest) -> None:
|
|
62
|
+
# Simple v0.1 rules: require at least one adapter and permissions.policy
|
|
63
|
+
if not manifest.adapters:
|
|
64
|
+
raise ValueError("Manifest must declare at least one adapter")
|
|
65
|
+
if "policy" not in manifest.permissions:
|
|
66
|
+
raise ValueError("Manifest.permissions must include 'policy'")
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import traceback
|
|
4
3
|
from abc import ABC, abstractmethod
|
|
5
4
|
from collections.abc import Awaitable, Callable
|
|
6
5
|
from typing import Any, TypedDict
|
|
@@ -56,7 +55,6 @@ class Strategy(ABC):
|
|
|
56
55
|
strategy_wallet_signing_callback: Callable[[dict], Awaitable[str]]
|
|
57
56
|
| None = None,
|
|
58
57
|
):
|
|
59
|
-
self.adapters = {}
|
|
60
58
|
self.ledger_adapter = None
|
|
61
59
|
self.logger = logger.bind(strategy=self.__class__.__name__)
|
|
62
60
|
self.config = config
|
|
@@ -66,12 +64,6 @@ class Strategy(ABC):
|
|
|
66
64
|
async def setup(self) -> None:
|
|
67
65
|
pass
|
|
68
66
|
|
|
69
|
-
async def log(self, msg: str) -> None:
|
|
70
|
-
self.logger.info(msg)
|
|
71
|
-
|
|
72
|
-
async def quote(self) -> None:
|
|
73
|
-
pass
|
|
74
|
-
|
|
75
67
|
def _get_strategy_wallet_address(self) -> str:
|
|
76
68
|
strategy_wallet = self.config.get("strategy_wallet")
|
|
77
69
|
if not strategy_wallet or not isinstance(strategy_wallet, dict):
|
|
@@ -122,59 +114,6 @@ class Strategy(ABC):
|
|
|
122
114
|
|
|
123
115
|
return status
|
|
124
116
|
|
|
125
|
-
def register_adapters(self, adapters: list[Any]) -> None:
|
|
126
|
-
self.adapters = {}
|
|
127
|
-
for adapter in adapters:
|
|
128
|
-
if hasattr(adapter, "adapter_type"):
|
|
129
|
-
self.adapters[adapter.adapter_type] = adapter
|
|
130
|
-
elif hasattr(adapter, "__class__"):
|
|
131
|
-
self.adapters[adapter.__class__.__name__] = adapter
|
|
132
|
-
|
|
133
|
-
def unwind_on_error(
|
|
134
|
-
self, func: Callable[..., Awaitable[StatusTuple]]
|
|
135
|
-
) -> Callable[..., Awaitable[StatusTuple]]:
|
|
136
|
-
async def wrapper(*args: Any, **kwargs: Any) -> StatusTuple:
|
|
137
|
-
try:
|
|
138
|
-
return await func(*args, **kwargs)
|
|
139
|
-
except Exception:
|
|
140
|
-
trace = traceback.format_exc()
|
|
141
|
-
try:
|
|
142
|
-
await self.withdraw()
|
|
143
|
-
return (
|
|
144
|
-
False,
|
|
145
|
-
f"Strategy failed during operation and was unwound. Failure: {trace}",
|
|
146
|
-
)
|
|
147
|
-
except Exception:
|
|
148
|
-
trace2 = traceback.format_exc()
|
|
149
|
-
return (
|
|
150
|
-
False,
|
|
151
|
-
f"Strategy failed and unwinding also failed. Operation error: {trace}. Unwind error: {trace2}",
|
|
152
|
-
)
|
|
153
|
-
finally:
|
|
154
|
-
if hasattr(self, "ledger_adapter") and self.ledger_adapter:
|
|
155
|
-
await self.ledger_adapter.save()
|
|
156
|
-
|
|
157
|
-
return wrapper
|
|
158
|
-
|
|
159
|
-
@classmethod
|
|
160
|
-
def get_metadata(cls) -> dict[str, Any]:
|
|
161
|
-
return {
|
|
162
|
-
"name": getattr(cls, "name", None),
|
|
163
|
-
"description": getattr(cls, "description", None),
|
|
164
|
-
"summary": getattr(cls, "summary", None),
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async def health_check(self) -> dict[str, Any]:
|
|
168
|
-
health = {"status": "healthy", "strategy": self.name, "adapters": {}}
|
|
169
|
-
|
|
170
|
-
for name, adapter in self.adapters.items():
|
|
171
|
-
if hasattr(adapter, "health_check"):
|
|
172
|
-
health["adapters"][name] = await adapter.health_check()
|
|
173
|
-
else:
|
|
174
|
-
health["adapters"][name] = {"status": "unknown"}
|
|
175
|
-
|
|
176
|
-
return health
|
|
177
|
-
|
|
178
117
|
async def partial_liquidate(
|
|
179
118
|
self, usd_value: float
|
|
180
119
|
) -> tuple[bool, LiquidationResult]:
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
from .base import StatusDict, StatusTuple, Strategy
|
|
2
|
+
from .opa_loop import OPAConfig, OPALoopMixin, Plan, PlanStep
|
|
2
3
|
|
|
3
|
-
__all__ = [
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Strategy",
|
|
6
|
+
"StatusDict",
|
|
7
|
+
"StatusTuple",
|
|
8
|
+
"OPALoopMixin",
|
|
9
|
+
"OPAConfig",
|
|
10
|
+
"Plan",
|
|
11
|
+
"PlanStep",
|
|
12
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class OPAConfig:
|
|
13
|
+
max_iterations_per_tick: int = 4
|
|
14
|
+
max_steps_per_iteration: int = 5
|
|
15
|
+
max_total_steps_per_tick: int = 15
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PlanStep[TOp: Enum]:
|
|
20
|
+
op: TOp
|
|
21
|
+
priority: int
|
|
22
|
+
key: str
|
|
23
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
reason: str = ""
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"PlanStep({self.op.name}, priority={self.priority}, key={self.key!r})"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Plan[TOp: Enum]:
|
|
32
|
+
steps: list[PlanStep[TOp]] = field(default_factory=list)
|
|
33
|
+
desired_state: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def __bool__(self) -> bool:
|
|
36
|
+
return bool(self.steps)
|
|
37
|
+
|
|
38
|
+
def __len__(self) -> int:
|
|
39
|
+
return len(self.steps)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OPALoopMixin[TInventory, TOp: Enum](ABC):
|
|
43
|
+
@property
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def opa_config(self) -> OPAConfig: ...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def observe(self) -> TInventory: ...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def plan(self, inventory: TInventory) -> Plan[TOp]: ...
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def execute_step(
|
|
55
|
+
self, step: PlanStep[TOp], inventory: TInventory
|
|
56
|
+
) -> tuple[bool, str]: ...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def get_inventory_changing_ops(self) -> set[TOp]: ...
|
|
60
|
+
|
|
61
|
+
async def on_loop_start(self) -> tuple[bool, str] | None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
async def on_step_executed(
|
|
65
|
+
self, step: PlanStep[TOp], success: bool, message: str
|
|
66
|
+
) -> None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def should_stop_early(
|
|
70
|
+
self, inventory: TInventory, iteration: int
|
|
71
|
+
) -> tuple[bool, str] | None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
async def on_loop_end(
|
|
75
|
+
self, success: bool, messages: list[str], total_steps: int
|
|
76
|
+
) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
async def run_opa_loop(self) -> tuple[bool, str, bool]:
|
|
80
|
+
loop_logger = logger.bind(loop="opa")
|
|
81
|
+
|
|
82
|
+
setup_result = await self.on_loop_start()
|
|
83
|
+
if setup_result is not None:
|
|
84
|
+
return (*setup_result, False)
|
|
85
|
+
|
|
86
|
+
total_steps = 0
|
|
87
|
+
messages: list[str] = []
|
|
88
|
+
rotated = False
|
|
89
|
+
config = self.opa_config
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
for iteration in range(config.max_iterations_per_tick):
|
|
93
|
+
loop_logger.debug(
|
|
94
|
+
f"OPA iteration {iteration + 1}/{config.max_iterations_per_tick}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# OBSERVE
|
|
98
|
+
try:
|
|
99
|
+
inventory = await self.observe()
|
|
100
|
+
except Exception as e:
|
|
101
|
+
loop_logger.error(f"Observe failed: {e}")
|
|
102
|
+
return (False, f"Failed to observe: {e}", rotated)
|
|
103
|
+
|
|
104
|
+
stop_result = self.should_stop_early(inventory, iteration)
|
|
105
|
+
if stop_result is not None:
|
|
106
|
+
await self.on_loop_end(stop_result[0], messages, total_steps)
|
|
107
|
+
return (*stop_result, rotated)
|
|
108
|
+
|
|
109
|
+
# PLAN
|
|
110
|
+
try:
|
|
111
|
+
plan = self.plan(inventory)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
loop_logger.error(f"Plan failed: {e}")
|
|
114
|
+
return (False, f"Failed to plan: {e}", rotated)
|
|
115
|
+
|
|
116
|
+
if not plan.steps:
|
|
117
|
+
loop_logger.debug("Plan is empty, nothing to do")
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
loop_logger.debug(f"Plan has {len(plan.steps)} steps")
|
|
121
|
+
|
|
122
|
+
# ACT - execute steps up to limit
|
|
123
|
+
steps_this_iteration = 0
|
|
124
|
+
for step in plan.steps[: config.max_steps_per_iteration]:
|
|
125
|
+
if total_steps >= config.max_total_steps_per_tick:
|
|
126
|
+
loop_logger.warning(
|
|
127
|
+
f"Hit max total steps ({config.max_total_steps_per_tick})"
|
|
128
|
+
)
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
loop_logger.info(f"Executing step: {step.op.name} ({step.reason})")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
success, msg = await self.execute_step(step, inventory)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
success = False
|
|
137
|
+
msg = f"Step {step.op.name} raised exception: {e}"
|
|
138
|
+
loop_logger.error(msg)
|
|
139
|
+
|
|
140
|
+
await self.on_step_executed(step, success, msg)
|
|
141
|
+
messages.append(f"{step.op.name}: {msg}")
|
|
142
|
+
total_steps += 1
|
|
143
|
+
steps_this_iteration += 1
|
|
144
|
+
|
|
145
|
+
if step.params.get("is_rotation"):
|
|
146
|
+
rotated = True
|
|
147
|
+
|
|
148
|
+
# Re-observe after inventory-changing ops (failed steps likely didn't change anything)
|
|
149
|
+
if success and step.op in self.get_inventory_changing_ops():
|
|
150
|
+
loop_logger.debug(
|
|
151
|
+
f"Step {step.op.name} changes inventory, re-observing"
|
|
152
|
+
)
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
if steps_this_iteration == 0:
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
except Exception as e:
|
|
159
|
+
loop_logger.error(f"OPA loop failed: {e}")
|
|
160
|
+
await self.on_loop_end(False, messages, total_steps)
|
|
161
|
+
return (False, f"OPA loop error: {e}", rotated)
|
|
162
|
+
|
|
163
|
+
final_message = "; ".join(messages) if messages else "No action needed"
|
|
164
|
+
loop_logger.info(f"OPA loop complete: {total_steps} steps executed")
|
|
165
|
+
|
|
166
|
+
await self.on_loop_end(True, messages, total_steps)
|
|
167
|
+
return (True, final_message, rotated)
|