loopentx 0.1.0__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.
- loopentx/__init__.py +31 -0
- loopentx/backends/__init__.py +11 -0
- loopentx/backends/base.py +115 -0
- loopentx/backends/memory.py +146 -0
- loopentx/backends/redis_backend.py +196 -0
- loopentx/cli/__init__.py +0 -0
- loopentx/cli/main.py +398 -0
- loopentx/core/__init__.py +28 -0
- loopentx/core/config.py +67 -0
- loopentx/core/context.py +362 -0
- loopentx/core/events.py +35 -0
- loopentx/core/exceptions.py +67 -0
- loopentx/core/loop.py +240 -0
- loopentx/core/memory.py +110 -0
- loopentx/core/models.py +172 -0
- loopentx/core/orchestrator.py +166 -0
- loopentx/core/skill.py +159 -0
- loopentx/llm/__init__.py +3 -0
- loopentx/llm/caller.py +103 -0
- loopentx/trust/__init__.py +4 -0
- loopentx/trust/policy.py +185 -0
- loopentx/trust/scorer.py +141 -0
- loopentx-0.1.0.dist-info/METADATA +555 -0
- loopentx-0.1.0.dist-info/RECORD +28 -0
- loopentx-0.1.0.dist-info/WHEEL +5 -0
- loopentx-0.1.0.dist-info/entry_points.txt +2 -0
- loopentx-0.1.0.dist-info/licenses/LICENSE +21 -0
- loopentx-0.1.0.dist-info/top_level.txt +1 -0
loopentx/core/context.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""LoopContext — execution context passed to every loop and skill."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable, Coroutine, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
from loopentx.core.models import StepRecord, StepStatus, RunStatus
|
|
10
|
+
from loopentx.core.exceptions import StepError, EscalationTimeoutError
|
|
11
|
+
from loopentx.core.memory import LoopMemory
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
log = structlog.get_logger()
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
# Patterns that signal a write / side-effect action
|
|
20
|
+
_WRITE_PATTERNS = [
|
|
21
|
+
"post_", "send_", "write_", "create_", "update_", "delete_",
|
|
22
|
+
"publish_", "notify_", "alert_", "push_", "emit_", "dispatch_",
|
|
23
|
+
"insert_", "patch_", "put_", "remove_", "drop_",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LoopContext:
|
|
28
|
+
"""Execution context for a loop or skill run.
|
|
29
|
+
|
|
30
|
+
Provides step checkpointing, LLM decisions, loop memory,
|
|
31
|
+
child loop spawning, and optional human escalation.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
@loop(every="1h", memory=True)
|
|
35
|
+
async def my_loop(ctx):
|
|
36
|
+
state = await ctx.step("fetch", fetch_state)
|
|
37
|
+
decision = await ctx.think("What next?", context=state,
|
|
38
|
+
choose_from=["act", "skip"])
|
|
39
|
+
if decision == "act":
|
|
40
|
+
await ctx.invoke(my_skill, data=state)
|
|
41
|
+
ctx.memory.push_history({"state": state, "decision": decision})
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
run_id: str,
|
|
47
|
+
skill_name: str,
|
|
48
|
+
backend: Any,
|
|
49
|
+
policy_context: Optional[Any] = None,
|
|
50
|
+
shadow_mode: bool = False,
|
|
51
|
+
iteration: int = 1,
|
|
52
|
+
enable_memory: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.run_id = run_id
|
|
55
|
+
self.skill_name = skill_name
|
|
56
|
+
self._backend = backend
|
|
57
|
+
self._policy_ctx = policy_context
|
|
58
|
+
self._shadow_mode = shadow_mode
|
|
59
|
+
self._iteration = iteration
|
|
60
|
+
self._completed: dict[str, Any] = {}
|
|
61
|
+
|
|
62
|
+
self.memory = LoopMemory(skill_name, backend) if enable_memory else _NoopMemory()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_shadow(self) -> bool:
|
|
66
|
+
return self._shadow_mode
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def iteration(self) -> int:
|
|
70
|
+
return self._iteration
|
|
71
|
+
|
|
72
|
+
# ── Step checkpointing ────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async def step(
|
|
75
|
+
self,
|
|
76
|
+
step_id: str,
|
|
77
|
+
fn: Callable[..., Coroutine[Any, Any, T]],
|
|
78
|
+
*args: Any,
|
|
79
|
+
**kwargs:Any,
|
|
80
|
+
) -> T:
|
|
81
|
+
"""Execute a durable, checkpointed step.
|
|
82
|
+
|
|
83
|
+
If this step already completed in a previous run attempt, the
|
|
84
|
+
cached result is returned immediately without re-executing fn.
|
|
85
|
+
|
|
86
|
+
In shadow mode, write actions are intercepted and logged but
|
|
87
|
+
not applied.
|
|
88
|
+
"""
|
|
89
|
+
full_id = f"{self.run_id}:{step_id}"
|
|
90
|
+
|
|
91
|
+
# In-process cache
|
|
92
|
+
if step_id in self._completed:
|
|
93
|
+
return self._completed[step_id]
|
|
94
|
+
|
|
95
|
+
# Persistent checkpoint
|
|
96
|
+
cached = await self._backend.get_step_result(full_id)
|
|
97
|
+
if cached is not None:
|
|
98
|
+
self._completed[step_id] = cached
|
|
99
|
+
return cached
|
|
100
|
+
|
|
101
|
+
# Shadow mode: intercept writes
|
|
102
|
+
if self._shadow_mode and self._is_write(fn):
|
|
103
|
+
return await self._shadow_step(step_id, fn, *args, **kwargs)
|
|
104
|
+
|
|
105
|
+
# Execute
|
|
106
|
+
rec = StepRecord(
|
|
107
|
+
id=full_id, run_id=self.run_id, skill_name=self.skill_name,
|
|
108
|
+
step_id=step_id, status=StepStatus.RUNNING, started_at=time.time(),
|
|
109
|
+
is_shadow=self._shadow_mode,
|
|
110
|
+
)
|
|
111
|
+
await self._backend.save_step(rec)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
t0 = time.monotonic()
|
|
115
|
+
result = await fn(*args, **kwargs)
|
|
116
|
+
ms = int((time.monotonic() - t0) * 1000)
|
|
117
|
+
|
|
118
|
+
rec.status = StepStatus.COMPLETED
|
|
119
|
+
rec.output = result
|
|
120
|
+
rec.duration_ms = ms
|
|
121
|
+
rec.completed_at = time.time()
|
|
122
|
+
await self._backend.save_step(rec)
|
|
123
|
+
|
|
124
|
+
self._completed[step_id] = result
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
rec.status = StepStatus.FAILED
|
|
129
|
+
rec.error = str(exc)
|
|
130
|
+
rec.completed_at = time.time()
|
|
131
|
+
await self._backend.save_step(rec)
|
|
132
|
+
raise StepError(step_id=step_id, cause=exc) from exc
|
|
133
|
+
|
|
134
|
+
# ── LLM decision point ────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async def think(
|
|
137
|
+
self,
|
|
138
|
+
prompt: str,
|
|
139
|
+
context: Any = None,
|
|
140
|
+
choose_from: Optional[list[str]] = None,
|
|
141
|
+
system: Optional[str] = None,
|
|
142
|
+
) -> str:
|
|
143
|
+
"""Call the configured LLM and return its decision.
|
|
144
|
+
|
|
145
|
+
This is the explicit, named decision point in every loop.
|
|
146
|
+
Results are checkpointed like any other step.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
prompt: The question or instruction for the LLM.
|
|
150
|
+
context: Additional context to inject (serialised to str).
|
|
151
|
+
choose_from: Constrain the LLM to one of these choices.
|
|
152
|
+
system: Optional system prompt override.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The LLM's response as a string (or one of choose_from values).
|
|
156
|
+
"""
|
|
157
|
+
from loopentx.llm.caller import call_llm
|
|
158
|
+
|
|
159
|
+
step_id = f"think:{hash(prompt) & 0xFFFF:04x}"
|
|
160
|
+
|
|
161
|
+
ctx_str = ""
|
|
162
|
+
if context is not None:
|
|
163
|
+
ctx_str = f"\n\nContext:\n{context}"
|
|
164
|
+
|
|
165
|
+
choice_str = ""
|
|
166
|
+
if choose_from:
|
|
167
|
+
choice_str = (
|
|
168
|
+
f"\n\nYou MUST respond with exactly one of these options "
|
|
169
|
+
f"(no other text): {', '.join(choose_from)}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
full_prompt = f"{prompt}{ctx_str}{choice_str}"
|
|
173
|
+
|
|
174
|
+
return await self.step(
|
|
175
|
+
step_id,
|
|
176
|
+
call_llm,
|
|
177
|
+
full_prompt,
|
|
178
|
+
system=system,
|
|
179
|
+
choose_from=choose_from,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ── Child loop / skill invocation ─────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async def invoke(self, skill_fn: Any, **kwargs: Any) -> Any:
|
|
185
|
+
"""Invoke another skill as a synchronous child task.
|
|
186
|
+
|
|
187
|
+
The child runs with its own run ID and full checkpointing.
|
|
188
|
+
The parent waits for the child to complete.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
skill_fn: A function decorated with @skill.
|
|
192
|
+
**kwargs: Arguments forwarded to the skill.
|
|
193
|
+
"""
|
|
194
|
+
if not hasattr(skill_fn, "_loopentx_skill"):
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"{skill_fn.__name__} is not a Loopentx skill. "
|
|
197
|
+
"Decorate it with @skill first."
|
|
198
|
+
)
|
|
199
|
+
from ulid import ULID
|
|
200
|
+
child_run_id = str(ULID())
|
|
201
|
+
child_ctx = LoopContext(
|
|
202
|
+
run_id=child_run_id,
|
|
203
|
+
skill_name=skill_fn.__name__,
|
|
204
|
+
backend=self._backend,
|
|
205
|
+
policy_context=skill_fn._loopentx_skill.policy_context,
|
|
206
|
+
shadow_mode=self._shadow_mode,
|
|
207
|
+
)
|
|
208
|
+
return await skill_fn._loopentx_skill.execute(child_ctx, **kwargs)
|
|
209
|
+
|
|
210
|
+
async def spawn(
|
|
211
|
+
self,
|
|
212
|
+
loop_fn: Any,
|
|
213
|
+
wait: bool = True,
|
|
214
|
+
**kwargs: Any,
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""Spawn a child loop.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
loop_fn: A function decorated with @loop.
|
|
220
|
+
wait: If True, block until the child completes.
|
|
221
|
+
If False, fire-and-forget.
|
|
222
|
+
**kwargs: Arguments forwarded to the child loop.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The child loop's return value if wait=True, else None.
|
|
226
|
+
"""
|
|
227
|
+
if not hasattr(loop_fn, "_loopentx_loop"):
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"{loop_fn.__name__} is not a Loopentx loop. "
|
|
230
|
+
"Decorate it with @loop first."
|
|
231
|
+
)
|
|
232
|
+
loop_def = loop_fn._loopentx_loop
|
|
233
|
+
if wait:
|
|
234
|
+
return await loop_def.execute(trigger="spawn", event_data=kwargs)
|
|
235
|
+
else:
|
|
236
|
+
asyncio.create_task(
|
|
237
|
+
loop_def.execute(trigger="spawn", event_data=kwargs)
|
|
238
|
+
)
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
async def gather(self, coroutines: list[Any]) -> list[Any]:
|
|
242
|
+
"""Run multiple spawn() or step() calls concurrently.
|
|
243
|
+
|
|
244
|
+
Example:
|
|
245
|
+
results = await ctx.gather([
|
|
246
|
+
ctx.spawn(worker, task=t, wait=True) for t in tasks
|
|
247
|
+
])
|
|
248
|
+
"""
|
|
249
|
+
return list(await asyncio.gather(*coroutines))
|
|
250
|
+
|
|
251
|
+
# ── Human escalation (opt-in) ─────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
async def escalate(
|
|
254
|
+
self,
|
|
255
|
+
message: str,
|
|
256
|
+
timeout: str = "2h",
|
|
257
|
+
fallback: str = "pause",
|
|
258
|
+
) -> str:
|
|
259
|
+
"""Pause and request human input.
|
|
260
|
+
|
|
261
|
+
The loop parks here until a human responds via CLI or API,
|
|
262
|
+
or until timeout elapses (in which case fallback is applied).
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
message: The question or situation to present to the human.
|
|
266
|
+
timeout: How long to wait: "30m", "2h", "1d".
|
|
267
|
+
fallback: What to do on timeout: "pause" | "continue" | "abort".
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The human's response string, or the fallback action.
|
|
271
|
+
"""
|
|
272
|
+
from loopentx.core.models import EscalationRecord, EscalationStatus
|
|
273
|
+
from ulid import ULID
|
|
274
|
+
import time
|
|
275
|
+
|
|
276
|
+
timeout_s = _parse_duration(timeout)
|
|
277
|
+
|
|
278
|
+
esc = EscalationRecord(
|
|
279
|
+
id=str(ULID()),
|
|
280
|
+
run_id=self.run_id,
|
|
281
|
+
skill_name=self.skill_name,
|
|
282
|
+
message=message,
|
|
283
|
+
timeout_s=timeout_s,
|
|
284
|
+
fallback=fallback,
|
|
285
|
+
)
|
|
286
|
+
await self._backend.save_escalation(esc)
|
|
287
|
+
|
|
288
|
+
log.info(
|
|
289
|
+
"loop.escalation_created",
|
|
290
|
+
run_id=self.run_id,
|
|
291
|
+
message=message,
|
|
292
|
+
timeout=timeout,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
deadline = time.time() + timeout_s
|
|
296
|
+
while time.time() < deadline:
|
|
297
|
+
await asyncio.sleep(5)
|
|
298
|
+
updated = await self._backend.get_escalation(esc.id)
|
|
299
|
+
if updated and updated.status == EscalationStatus.RESPONDED:
|
|
300
|
+
log.info("loop.escalation_resolved", response=updated.response)
|
|
301
|
+
return updated.response or fallback
|
|
302
|
+
|
|
303
|
+
log.warning("loop.escalation_timed_out", fallback=fallback)
|
|
304
|
+
esc.status = EscalationStatus.TIMED_OUT
|
|
305
|
+
await self._backend.save_escalation(esc)
|
|
306
|
+
return fallback
|
|
307
|
+
|
|
308
|
+
# ── Internals ─────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def _is_write(self, fn: Callable) -> bool:
|
|
311
|
+
name = fn.__name__.lower()
|
|
312
|
+
return any(name.startswith(p) for p in _WRITE_PATTERNS)
|
|
313
|
+
|
|
314
|
+
async def _shadow_step(
|
|
315
|
+
self,
|
|
316
|
+
step_id: str,
|
|
317
|
+
fn: Callable[..., Coroutine[Any, Any, T]],
|
|
318
|
+
*args: Any,
|
|
319
|
+
**kwargs: Any,
|
|
320
|
+
) -> T:
|
|
321
|
+
"""Run a write-side-effect step in shadow — capture but don't apply."""
|
|
322
|
+
try:
|
|
323
|
+
result = await fn(*args, **kwargs)
|
|
324
|
+
await self._backend.save_shadow_output(
|
|
325
|
+
run_id=self.run_id, step_id=step_id, output=result
|
|
326
|
+
)
|
|
327
|
+
log.info(
|
|
328
|
+
"shadow.write_intercepted",
|
|
329
|
+
skill=self.skill_name,
|
|
330
|
+
step=step_id,
|
|
331
|
+
)
|
|
332
|
+
return result
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
await self._backend.save_shadow_output(
|
|
335
|
+
run_id=self.run_id, step_id=step_id, error=str(exc)
|
|
336
|
+
)
|
|
337
|
+
raise StepError(step_id=step_id, cause=exc) from exc
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── Duration parser ────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def _parse_duration(s: str) -> int:
|
|
343
|
+
"""Parse a duration string like '30m', '2h', '1d' into seconds."""
|
|
344
|
+
unit = s[-1].lower()
|
|
345
|
+
val = int(s[:-1])
|
|
346
|
+
return {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(unit, 3600) * val
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ── Noop memory (when memory=False) ───────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
class _NoopMemory:
|
|
352
|
+
"""Returned as ctx.memory when memory=False. All ops are no-ops."""
|
|
353
|
+
async def get(self, key: str, default: Any = None) -> Any: return default
|
|
354
|
+
async def set(self, key: str, value: Any) -> None: pass
|
|
355
|
+
async def append(self, key: str, item: Any) -> None: pass
|
|
356
|
+
async def last(self, n: int = 5) -> list: return []
|
|
357
|
+
async def get_list(self, key: str) -> list: return []
|
|
358
|
+
async def push_history(self, item: Any) -> None: pass
|
|
359
|
+
async def delete(self, key: str) -> None: pass
|
|
360
|
+
async def clear_all(self) -> None: pass
|
|
361
|
+
async def all(self) -> dict: return {}
|
|
362
|
+
async def keys(self) -> list: return []
|
loopentx/core/events.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Event system for event-triggered loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from ulid import ULID
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LoopentxEvent(BaseModel):
|
|
12
|
+
id: str = Field(default_factory=lambda: str(ULID()))
|
|
13
|
+
name: str
|
|
14
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
15
|
+
source: Optional[str] = None
|
|
16
|
+
timestamp: float = Field(default_factory=time.time)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def event(
|
|
20
|
+
name: str,
|
|
21
|
+
data: Optional[dict[str, Any]] = None,
|
|
22
|
+
source: Optional[str] = None,
|
|
23
|
+
) -> LoopentxEvent:
|
|
24
|
+
"""Create and return a LoopentxEvent.
|
|
25
|
+
|
|
26
|
+
Publish it via the backend to trigger event-driven loops.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
from loopentx import event, get_config
|
|
30
|
+
|
|
31
|
+
cfg = get_config()
|
|
32
|
+
evt = event("deploy.completed", data={"env": "prod", "version": "2.4.1"})
|
|
33
|
+
await cfg.backend.publish_event(evt)
|
|
34
|
+
"""
|
|
35
|
+
return LoopentxEvent(name=name, data=data or {}, source=source)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Loopentx custom exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LoopentxError(Exception):
|
|
8
|
+
"""Base exception for all Loopentx errors."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StepError(LoopentxError):
|
|
12
|
+
def __init__(self, step_id: str, cause: Optional[Exception] = None) -> None:
|
|
13
|
+
self.step_id = step_id
|
|
14
|
+
self.cause = cause
|
|
15
|
+
super().__init__(f"Step '{step_id}' failed: {cause}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillError(LoopentxError):
|
|
19
|
+
def __init__(self, skill_name: str, cause: Optional[Exception] = None) -> None:
|
|
20
|
+
self.skill_name = skill_name
|
|
21
|
+
self.cause = cause
|
|
22
|
+
super().__init__(f"Skill '{skill_name}' failed after all retries: {cause}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SkillTimeoutError(LoopentxError):
|
|
26
|
+
def __init__(self, skill_name: str, timeout: int) -> None:
|
|
27
|
+
self.skill_name = skill_name
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
super().__init__(f"Skill '{skill_name}' timed out after {timeout}s")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PolicyViolationError(LoopentxError):
|
|
33
|
+
def __init__(self, skill_name: str, capability: str, action: str) -> None:
|
|
34
|
+
self.skill_name = skill_name
|
|
35
|
+
self.capability = capability
|
|
36
|
+
self.action = action
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"Policy violation in '{skill_name}': "
|
|
39
|
+
f"attempted to {action} '{capability}' which is not declared in @policy"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkillNotApprovedError(LoopentxError):
|
|
44
|
+
def __init__(self, skill_name: str) -> None:
|
|
45
|
+
self.skill_name = skill_name
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"Skill '{skill_name}' is not approved. "
|
|
48
|
+
f"Run `loopentx trust approve {skill_name}` or wait for shadow cycles to complete."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LoopExitConditionMet(Exception):
|
|
53
|
+
"""Raised internally when a loop's exit condition is satisfied."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EscalationTimeoutError(LoopentxError):
|
|
57
|
+
def __init__(self, run_id: str) -> None:
|
|
58
|
+
self.run_id = run_id
|
|
59
|
+
super().__init__(f"Escalation for run '{run_id}' timed out with no response.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BackendError(LoopentxError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigurationError(LoopentxError):
|
|
67
|
+
pass
|
loopentx/core/loop.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The @loop decorator — the core Loopentx primitive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Coroutine, Optional
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from croniter import croniter
|
|
12
|
+
|
|
13
|
+
from loopentx.core.context import LoopContext, _parse_duration
|
|
14
|
+
from loopentx.core.models import RunRecord, RunStatus
|
|
15
|
+
from loopentx.core.exceptions import LoopExitConditionMet
|
|
16
|
+
from loopentx.core.config import get_config
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoopDefinition:
|
|
22
|
+
"""Internal wrapper for a @loop-decorated function."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
fn: Callable[..., Coroutine[Any, Any, Any]],
|
|
27
|
+
cron: Optional[str] = None,
|
|
28
|
+
every: Optional[str] = None,
|
|
29
|
+
event: Optional[str] = None,
|
|
30
|
+
until: Optional[Callable] = None,
|
|
31
|
+
max_iterations: Optional[int] = None,
|
|
32
|
+
memory: bool = False,
|
|
33
|
+
max_concurrency:int = 1,
|
|
34
|
+
description: Optional[str] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.fn = fn
|
|
37
|
+
self.name = fn.__name__
|
|
38
|
+
self.cron = cron
|
|
39
|
+
self.every = every
|
|
40
|
+
self.event = event
|
|
41
|
+
self.until = until
|
|
42
|
+
self.max_iterations = max_iterations
|
|
43
|
+
self.memory = memory
|
|
44
|
+
self.max_concurrency= max_concurrency
|
|
45
|
+
self.description = description or fn.__doc__
|
|
46
|
+
|
|
47
|
+
if cron and not croniter.is_valid(cron):
|
|
48
|
+
raise ValueError(f"Invalid cron expression: {cron!r}")
|
|
49
|
+
|
|
50
|
+
self._every_s: Optional[int] = _parse_duration(every) if every else None
|
|
51
|
+
self._running = False
|
|
52
|
+
self._task: Optional[asyncio.Task] = None
|
|
53
|
+
self._iteration = 0
|
|
54
|
+
|
|
55
|
+
def next_cron_time(self) -> Optional[float]:
|
|
56
|
+
if not self.cron:
|
|
57
|
+
return None
|
|
58
|
+
return croniter(self.cron).get_next(float)
|
|
59
|
+
|
|
60
|
+
async def execute(
|
|
61
|
+
self,
|
|
62
|
+
trigger: str = "manual",
|
|
63
|
+
event_data: Optional[dict] = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Execute one iteration of the loop."""
|
|
66
|
+
cfg = get_config()
|
|
67
|
+
backend = cfg.backend
|
|
68
|
+
from ulid import ULID
|
|
69
|
+
|
|
70
|
+
self._iteration += 1
|
|
71
|
+
run_id = str(ULID())
|
|
72
|
+
|
|
73
|
+
ctx = LoopContext(
|
|
74
|
+
run_id=run_id,
|
|
75
|
+
skill_name=self.name,
|
|
76
|
+
backend=backend,
|
|
77
|
+
iteration=self._iteration,
|
|
78
|
+
enable_memory=self.memory,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Check exit condition before running
|
|
82
|
+
if self.until and await self._check_exit(ctx):
|
|
83
|
+
log.info("loop.exit_condition_met", loop=self.name, iteration=self._iteration)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if self.max_iterations and self._iteration > self.max_iterations:
|
|
87
|
+
log.info("loop.max_iterations_reached", loop=self.name)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
run = RunRecord(
|
|
91
|
+
id=run_id, skill_name=self.name, trigger=trigger,
|
|
92
|
+
status=RunStatus.RUNNING, input=event_data,
|
|
93
|
+
iteration=self._iteration, started_at=time.time(),
|
|
94
|
+
)
|
|
95
|
+
await backend.save_run(run)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = await self.fn(ctx, **(event_data or {}))
|
|
99
|
+
|
|
100
|
+
run.status = RunStatus.COMPLETED
|
|
101
|
+
run.output = result
|
|
102
|
+
run.completed_at = time.time()
|
|
103
|
+
run.duration_ms = int((run.completed_at - run.started_at) * 1000)
|
|
104
|
+
await backend.save_run(run)
|
|
105
|
+
|
|
106
|
+
log.info("loop.completed", loop=self.name, run_id=run_id,
|
|
107
|
+
iteration=self._iteration, duration_ms=run.duration_ms)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
except LoopExitConditionMet:
|
|
111
|
+
run.status = RunStatus.COMPLETED
|
|
112
|
+
run.completed_at = time.time()
|
|
113
|
+
await backend.save_run(run)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
run.status = RunStatus.FAILED
|
|
118
|
+
run.error = str(exc)
|
|
119
|
+
run.completed_at = time.time()
|
|
120
|
+
run.duration_ms = int((run.completed_at - run.started_at) * 1000)
|
|
121
|
+
await backend.save_run(run)
|
|
122
|
+
log.error("loop.failed", loop=self.name, run_id=run_id, error=str(exc))
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
async def _check_exit(self, ctx: LoopContext) -> bool:
|
|
126
|
+
if not self.until:
|
|
127
|
+
return False
|
|
128
|
+
try:
|
|
129
|
+
result = self.until(ctx)
|
|
130
|
+
if asyncio.iscoroutine(result):
|
|
131
|
+
return bool(await result)
|
|
132
|
+
return bool(result)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
log.warning("loop.exit_condition_error", loop=self.name, error=str(exc))
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
async def start_cron(self) -> None:
|
|
138
|
+
"""Run the cron scheduler for this loop indefinitely."""
|
|
139
|
+
self._running = True
|
|
140
|
+
log.info("loop.cron_started", loop=self.name, cron=self.cron)
|
|
141
|
+
while self._running:
|
|
142
|
+
now = time.time()
|
|
143
|
+
next_run = self.next_cron_time()
|
|
144
|
+
if next_run is None:
|
|
145
|
+
break
|
|
146
|
+
wait = max(0.0, next_run - now)
|
|
147
|
+
await asyncio.sleep(wait)
|
|
148
|
+
if not self._running:
|
|
149
|
+
break
|
|
150
|
+
try:
|
|
151
|
+
await self.execute(trigger="cron")
|
|
152
|
+
except Exception:
|
|
153
|
+
pass # logged inside execute()
|
|
154
|
+
|
|
155
|
+
async def start_interval(self) -> None:
|
|
156
|
+
"""Run on a fixed interval (every=) indefinitely."""
|
|
157
|
+
assert self._every_s is not None
|
|
158
|
+
self._running = True
|
|
159
|
+
log.info("loop.interval_started", loop=self.name, every_s=self._every_s)
|
|
160
|
+
while self._running:
|
|
161
|
+
try:
|
|
162
|
+
await self.execute(trigger="interval")
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
await asyncio.sleep(self._every_s)
|
|
166
|
+
|
|
167
|
+
async def stop(self) -> None:
|
|
168
|
+
self._running = False
|
|
169
|
+
if self._task and not self._task.done():
|
|
170
|
+
self._task.cancel()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def loop(
|
|
174
|
+
cron: Optional[str] = None,
|
|
175
|
+
every: Optional[str] = None,
|
|
176
|
+
event: Optional[str] = None,
|
|
177
|
+
until: Optional[Callable] = None,
|
|
178
|
+
max_iterations: Optional[int] = None,
|
|
179
|
+
memory: bool = False,
|
|
180
|
+
max_concurrency:int = 1,
|
|
181
|
+
description: Optional[str] = None,
|
|
182
|
+
) -> Callable:
|
|
183
|
+
"""Decorator to define a Loopentx loop.
|
|
184
|
+
|
|
185
|
+
A loop is the core primitive: it runs on a schedule or event,
|
|
186
|
+
uses ctx.think() to decide what to do next, and invokes skills.
|
|
187
|
+
You write it once — Loopentx runs it forever.
|
|
188
|
+
|
|
189
|
+
At least one of cron=, every=, or event= is required.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
cron: Cron expression (e.g. "*/30 * * * *").
|
|
193
|
+
every: Interval string (e.g. "30m", "2h", "1d").
|
|
194
|
+
event: Event name that triggers this loop.
|
|
195
|
+
until: Callable(ctx) → bool. Loop stops when True.
|
|
196
|
+
max_iterations: Hard ceiling on iteration count.
|
|
197
|
+
memory: If True, enable loop-native persistent memory.
|
|
198
|
+
max_concurrency:Max concurrent executions. Default 1.
|
|
199
|
+
description: Human-readable description.
|
|
200
|
+
|
|
201
|
+
Examples:
|
|
202
|
+
# Heartbeat — Boris's pattern
|
|
203
|
+
@loop(every="1h", memory=True)
|
|
204
|
+
async def monitor(ctx):
|
|
205
|
+
state = await ctx.step("check", check_state)
|
|
206
|
+
decision = await ctx.think("Action needed?", context=state,
|
|
207
|
+
choose_from=["act", "skip"])
|
|
208
|
+
if decision == "act":
|
|
209
|
+
await ctx.invoke(triage, data=state)
|
|
210
|
+
|
|
211
|
+
# Research — Andrej's pattern
|
|
212
|
+
@loop(until=lambda ctx: False, max_iterations=50, memory=True)
|
|
213
|
+
async def research(ctx, topic: str):
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
# Supervisor — Steipete's pattern
|
|
217
|
+
@loop(cron="0 9 * * 1")
|
|
218
|
+
async def weekly_supervisor(ctx):
|
|
219
|
+
tasks = await ctx.step("plan", decompose, goal)
|
|
220
|
+
results = await ctx.gather([ctx.spawn(worker, task=t) for t in tasks])
|
|
221
|
+
"""
|
|
222
|
+
if cron is None and every is None and event is None:
|
|
223
|
+
raise ValueError("@loop requires at least one of: cron=, every=, event=")
|
|
224
|
+
|
|
225
|
+
def decorator(fn: Callable) -> Callable:
|
|
226
|
+
loop_def = LoopDefinition(
|
|
227
|
+
fn=fn, cron=cron, every=every, event=event,
|
|
228
|
+
until=until, max_iterations=max_iterations, memory=memory,
|
|
229
|
+
max_concurrency=max_concurrency, description=description,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@functools.wraps(fn)
|
|
233
|
+
async def wrapper(**kwargs: Any) -> Any:
|
|
234
|
+
return await loop_def.execute(trigger="manual", event_data=kwargs)
|
|
235
|
+
|
|
236
|
+
wrapper._loopentx_loop = loop_def # type: ignore[attr-defined]
|
|
237
|
+
wrapper._loopentx_kind = "loop" # type: ignore[attr-defined]
|
|
238
|
+
return wrapper
|
|
239
|
+
|
|
240
|
+
return decorator
|