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.
@@ -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 []
@@ -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