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.

Files changed (124) hide show
  1. wayfinder_paths/__init__.py +2 -0
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
  3. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  4. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
  5. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  6. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  7. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  8. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  9. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  10. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  11. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  12. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  13. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  14. wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
  15. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
  17. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
  19. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  20. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
  21. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  22. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  24. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  28. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  29. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  30. wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
  31. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  32. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
  33. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  34. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  35. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  36. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  37. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  38. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  39. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  40. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  41. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  42. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  43. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  46. wayfinder_paths/conftest.py +24 -17
  47. wayfinder_paths/core/__init__.py +2 -0
  48. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  49. wayfinder_paths/core/adapters/models.py +17 -7
  50. wayfinder_paths/core/clients/BRAPClient.py +1 -1
  51. wayfinder_paths/core/clients/TokenClient.py +47 -1
  52. wayfinder_paths/core/clients/WayfinderClient.py +1 -2
  53. wayfinder_paths/core/clients/protocols.py +21 -22
  54. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  55. wayfinder_paths/core/config.py +12 -0
  56. wayfinder_paths/core/constants/__init__.py +15 -0
  57. wayfinder_paths/core/constants/base.py +6 -1
  58. wayfinder_paths/core/constants/contracts.py +39 -26
  59. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  60. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  61. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  62. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  63. wayfinder_paths/core/engine/manifest.py +66 -0
  64. wayfinder_paths/core/strategies/Strategy.py +0 -61
  65. wayfinder_paths/core/strategies/__init__.py +10 -1
  66. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  67. wayfinder_paths/core/utils/test_transaction.py +289 -0
  68. wayfinder_paths/core/utils/transaction.py +44 -1
  69. wayfinder_paths/core/utils/web3.py +3 -0
  70. wayfinder_paths/mcp/__init__.py +5 -0
  71. wayfinder_paths/mcp/preview.py +185 -0
  72. wayfinder_paths/mcp/scripting.py +84 -0
  73. wayfinder_paths/mcp/server.py +52 -0
  74. wayfinder_paths/mcp/state/profile_store.py +195 -0
  75. wayfinder_paths/mcp/state/store.py +89 -0
  76. wayfinder_paths/mcp/test_scripting.py +267 -0
  77. wayfinder_paths/mcp/tools/__init__.py +0 -0
  78. wayfinder_paths/mcp/tools/balances.py +290 -0
  79. wayfinder_paths/mcp/tools/discovery.py +158 -0
  80. wayfinder_paths/mcp/tools/execute.py +770 -0
  81. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  82. wayfinder_paths/mcp/tools/quotes.py +288 -0
  83. wayfinder_paths/mcp/tools/run_script.py +286 -0
  84. wayfinder_paths/mcp/tools/strategies.py +188 -0
  85. wayfinder_paths/mcp/tools/tokens.py +46 -0
  86. wayfinder_paths/mcp/tools/wallets.py +354 -0
  87. wayfinder_paths/mcp/utils.py +129 -0
  88. wayfinder_paths/policies/hyperliquid.py +1 -1
  89. wayfinder_paths/policies/lifi.py +18 -0
  90. wayfinder_paths/policies/util.py +8 -2
  91. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
  92. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  93. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  94. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  95. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  96. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  97. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  98. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  99. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  100. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  101. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  102. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  103. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  104. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  105. wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
  106. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  107. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  108. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
  109. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
  110. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
  111. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  112. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
  113. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
  114. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  115. wayfinder_paths/tests/test_test_coverage.py +1 -4
  116. wayfinder_paths-0.1.25.dist-info/METADATA +377 -0
  117. wayfinder_paths-0.1.25.dist-info/RECORD +185 -0
  118. wayfinder_paths/scripts/create_strategy.py +0 -139
  119. wayfinder_paths/scripts/make_wallets.py +0 -142
  120. wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
  121. wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
  122. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  123. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/LICENSE +0 -0
  124. {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__ = ["Strategy", "StatusDict", "StatusTuple"]
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)