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,110 @@
1
+ """LoopMemory — persistent state across loop iterations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional, TypeVar
7
+
8
+ from loopentx.core.models import LoopMemoryRecord, MemoryEntry
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class LoopMemory:
14
+ """Persistent memory for a loop, shared across all iterations.
15
+
16
+ Memory is loaded from the backend at the start of each run and saved
17
+ after every mutating operation. The loop accumulates knowledge over
18
+ time without any manual state management.
19
+
20
+ Example:
21
+ @loop(every="1h", memory=True)
22
+ async def research_loop(ctx, topic: str):
23
+ prior = ctx.memory.get("findings", default=[])
24
+ new = await ctx.step("search", search, topic, prior)
25
+ ctx.memory.append("findings", new)
26
+ ctx.memory.set("confidence", new.confidence)
27
+ """
28
+
29
+ def __init__(self, loop_name: str, backend: Any) -> None:
30
+ self._loop_name = loop_name
31
+ self._backend = backend
32
+ self._record: Optional[LoopMemoryRecord] = None
33
+
34
+ async def _load(self) -> LoopMemoryRecord:
35
+ if self._record is None:
36
+ self._record = await self._backend.get_loop_memory(self._loop_name)
37
+ if self._record is None:
38
+ self._record = LoopMemoryRecord(loop_name=self._loop_name)
39
+ return self._record
40
+
41
+ async def _save(self) -> None:
42
+ if self._record:
43
+ self._record.updated_at = time.time()
44
+ await self._backend.save_loop_memory(self._record)
45
+
46
+ # ── Read ──────────────────────────────────────────────────────────────
47
+
48
+ async def get(self, key: str, default: Any = None) -> Any:
49
+ """Get a value by key. Returns default if not set."""
50
+ record = await self._load()
51
+ entry = record.entries.get(key)
52
+ return entry.value if entry else default
53
+
54
+ async def last(self, n: int = 5) -> list[Any]:
55
+ """Return the last n entries from the 'history' list."""
56
+ record = await self._load()
57
+ history = record.lists.get("history", [])
58
+ return history[-n:]
59
+
60
+ async def get_list(self, key: str) -> list[Any]:
61
+ """Return the full list stored at key."""
62
+ record = await self._load()
63
+ return record.lists.get(key, [])
64
+
65
+ async def keys(self) -> list[str]:
66
+ """Return all scalar key names."""
67
+ record = await self._load()
68
+ return list(record.entries.keys())
69
+
70
+ async def all(self) -> dict[str, Any]:
71
+ """Return all scalar entries as a plain dict."""
72
+ record = await self._load()
73
+ return {k: v.value for k, v in record.entries.items()}
74
+
75
+ # ── Write ─────────────────────────────────────────────────────────────
76
+
77
+ async def set(self, key: str, value: Any) -> None:
78
+ """Set a scalar value."""
79
+ record = await self._load()
80
+ record.entries[key] = MemoryEntry(key=key, value=value)
81
+ await self._save()
82
+
83
+ async def append(self, key: str, item: Any) -> None:
84
+ """Append an item to a list stored at key."""
85
+ record = await self._load()
86
+ if key not in record.lists:
87
+ record.lists[key] = []
88
+ record.lists[key].append(item)
89
+ await self._save()
90
+
91
+ async def push_history(self, item: Any) -> None:
92
+ """Append to the canonical 'history' list for use with last(n)."""
93
+ await self.append("history", item)
94
+
95
+ async def delete(self, key: str) -> None:
96
+ """Delete a scalar key."""
97
+ record = await self._load()
98
+ record.entries.pop(key, None)
99
+ await self._save()
100
+
101
+ async def clear_list(self, key: str) -> None:
102
+ """Clear a list."""
103
+ record = await self._load()
104
+ record.lists.pop(key, None)
105
+ await self._save()
106
+
107
+ async def clear_all(self) -> None:
108
+ """Reset all memory for this loop."""
109
+ self._record = LoopMemoryRecord(loop_name=self._loop_name)
110
+ await self._save()
@@ -0,0 +1,172 @@
1
+ """Pydantic models for runs, steps, memory, trust, and skill records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from enum import Enum
7
+ from typing import Any, Optional
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class RunStatus(str, Enum):
12
+ PENDING = "pending"
13
+ RUNNING = "running"
14
+ COMPLETED = "completed"
15
+ FAILED = "failed"
16
+ CANCELLED = "cancelled"
17
+ SHADOW = "shadow"
18
+ PAUSED = "paused"
19
+
20
+
21
+ class StepStatus(str, Enum):
22
+ PENDING = "pending"
23
+ RUNNING = "running"
24
+ COMPLETED = "completed"
25
+ FAILED = "failed"
26
+ SKIPPED = "skipped"
27
+ SHADOW = "shadow"
28
+
29
+
30
+ class BlastRadius(str, Enum):
31
+ LOW = "low"
32
+ MEDIUM = "medium"
33
+ HIGH = "high"
34
+ CRITICAL = "critical"
35
+
36
+
37
+ class TrustLevel(str, Enum):
38
+ UNTRUSTED = "untrusted"
39
+ PROVISIONAL = "provisional"
40
+ TRUSTED = "trusted"
41
+ AUTONOMOUS = "autonomous"
42
+
43
+
44
+ class EscalationStatus(str, Enum):
45
+ PENDING = "pending"
46
+ RESPONDED = "responded"
47
+ TIMED_OUT = "timed_out"
48
+
49
+
50
+ # ── Step ──────────────────────────────────────────────────────────────────────
51
+
52
+ class StepRecord(BaseModel):
53
+ id: str
54
+ run_id: str
55
+ skill_name: str
56
+ step_id: str
57
+ status: StepStatus = StepStatus.PENDING
58
+ input: Optional[Any] = None
59
+ output: Optional[Any] = None
60
+ error: Optional[str] = None
61
+ duration_ms: Optional[int] = None
62
+ retry_count: int = 0
63
+ is_shadow: bool = False
64
+ started_at: float = Field(default_factory=time.time)
65
+ completed_at: Optional[float] = None
66
+
67
+
68
+ # ── Run ───────────────────────────────────────────────────────────────────────
69
+
70
+ class RunRecord(BaseModel):
71
+ id: str
72
+ skill_name: str
73
+ trigger: str # "cron" | "event" | "invoke" | "manual" | "spawn"
74
+ status: RunStatus = RunStatus.PENDING
75
+ input: Optional[dict[str, Any]] = None
76
+ output: Optional[Any] = None
77
+ error: Optional[str] = None
78
+ steps: list[StepRecord] = Field(default_factory=list)
79
+ is_shadow: bool = False
80
+ shadow_cycle: Optional[int] = None
81
+ parent_run_id:Optional[str] = None
82
+ iteration: int = 1
83
+ started_at: float = Field(default_factory=time.time)
84
+ completed_at: Optional[float] = None
85
+ duration_ms: Optional[int] = None
86
+
87
+
88
+ # ── Loop memory ───────────────────────────────────────────────────────────────
89
+
90
+ class MemoryEntry(BaseModel):
91
+ key: str
92
+ value: Any
93
+ updated_at: float = Field(default_factory=time.time)
94
+
95
+
96
+ class LoopMemoryRecord(BaseModel):
97
+ loop_name: str
98
+ entries: dict[str, MemoryEntry] = Field(default_factory=dict)
99
+ lists: dict[str, list[Any]] = Field(default_factory=dict)
100
+ updated_at: float = Field(default_factory=time.time)
101
+
102
+
103
+ # ── Skill registration ────────────────────────────────────────────────────────
104
+
105
+ class SkillRegistration(BaseModel):
106
+ name: str
107
+ kind: str # "skill" | "loop"
108
+ version: str = "1"
109
+ description: Optional[str] = None
110
+ can_read: list[str] = Field(default_factory=list)
111
+ can_write: list[str] = Field(default_factory=list)
112
+ blast_radius: BlastRadius = BlastRadius.LOW
113
+ shadow_cycles: int = 0
114
+ shadow_cycles_remaining: int = 0
115
+ require_approval: bool = False
116
+ retries: int = 3
117
+ timeout: Optional[int] = None
118
+ cron: Optional[str] = None
119
+ event_trigger: Optional[str] = None
120
+ is_active: bool = True
121
+ is_shadow: bool = False
122
+ trust_level: TrustLevel = TrustLevel.PROVISIONAL
123
+ registered_at: float = Field(default_factory=time.time)
124
+ approved_at: Optional[float] = None
125
+ approved_by: Optional[str] = None
126
+
127
+
128
+ # ── Trust ─────────────────────────────────────────────────────────────────────
129
+
130
+ class TrustRecord(BaseModel):
131
+ skill_name: str
132
+ total_runs: int = 0
133
+ successful_runs: int = 0
134
+ failed_runs: int = 0
135
+ shadow_runs: int = 0
136
+ human_approvals: int = 0
137
+ human_rejections: int = 0
138
+ false_positive_rate:float = 0.0
139
+ avg_duration_ms: float = 0.0
140
+ trust_score: float = 0.0
141
+ trust_level: TrustLevel = TrustLevel.UNTRUSTED
142
+ last_evaluated_at: Optional[float] = None
143
+ last_updated_at: float = Field(default_factory=time.time)
144
+
145
+
146
+ # ── Shadow output ─────────────────────────────────────────────────────────────
147
+
148
+ class ShadowOutput(BaseModel):
149
+ run_id: str
150
+ skill_name: str
151
+ step_id: str
152
+ output: Optional[Any] = None
153
+ error: Optional[str] = None
154
+ reviewed: bool = False
155
+ approved: Optional[bool] = None
156
+ reviewer: Optional[str] = None
157
+ captured_at: float = Field(default_factory=time.time)
158
+
159
+
160
+ # ── Escalation ────────────────────────────────────────────────────────────────
161
+
162
+ class EscalationRecord(BaseModel):
163
+ id: str
164
+ run_id: str
165
+ skill_name: str
166
+ message: str
167
+ timeout_s: int
168
+ fallback: str
169
+ status: EscalationStatus = EscalationStatus.PENDING
170
+ response: Optional[str] = None
171
+ created_at: float = Field(default_factory=time.time)
172
+ resolved_at: Optional[float] = None
@@ -0,0 +1,166 @@
1
+ """Orchestrator — runs loops, routes events, evaluates trust."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ import structlog
10
+
11
+ from loopentx.core.config import get_config
12
+ from loopentx.core.events import LoopentxEvent
13
+
14
+ log = structlog.get_logger()
15
+
16
+
17
+ class Orchestrator:
18
+ """The Loopentx execution engine.
19
+
20
+ Manages loop and skill registration, cron/interval scheduling,
21
+ event routing, concurrency, and the background trust evaluator.
22
+
23
+ Example:
24
+ from loopentx import configure, Orchestrator
25
+ from loopentx.backends import RedisBackend
26
+ from myapp.loops import health_check, research_loop
27
+
28
+ configure(backend=RedisBackend())
29
+ orch = Orchestrator()
30
+ orch.register(health_check)
31
+ orch.register(research_loop)
32
+ asyncio.run(orch.start())
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._skills: dict[str, Any] = {}
37
+ self._loops: dict[str, Any] = {}
38
+ self._cron_tasks: dict[str, asyncio.Task] = {}
39
+ self._event_listeners: dict[str, list[Any]] = {}
40
+ self._running = False
41
+
42
+ def register(self, fn: Any) -> None:
43
+ """Register a @loop or @skill with the orchestrator."""
44
+ kind = getattr(fn, "_loopentx_kind", None)
45
+
46
+ if kind == "skill":
47
+ skill_def = fn._loopentx_skill
48
+ self._skills[skill_def.name] = fn
49
+ log.info("orchestrator.skill_registered", name=skill_def.name)
50
+
51
+ elif kind == "loop":
52
+ loop_def = fn._loopentx_loop
53
+ self._loops[loop_def.name] = fn
54
+ if loop_def.event:
55
+ self._event_listeners.setdefault(loop_def.event, []).append(fn)
56
+ log.info("orchestrator.loop_registered", name=loop_def.name,
57
+ cron=loop_def.cron, every=loop_def.every, event=loop_def.event)
58
+ else:
59
+ raise ValueError(
60
+ f"{fn.__name__} is not a Loopentx loop or skill. "
61
+ "Decorate with @loop or @skill."
62
+ )
63
+
64
+ async def start(self) -> None:
65
+ """Start the orchestrator. Runs until stop() is called."""
66
+ self._running = True
67
+ log.info("orchestrator.started",
68
+ skills=list(self._skills), loops=list(self._loops))
69
+
70
+ tasks = []
71
+
72
+ for name, loop_fn in self._loops.items():
73
+ ld = loop_fn._loopentx_loop
74
+ if ld.cron:
75
+ task = asyncio.create_task(ld.start_cron(), name=f"cron:{name}")
76
+ self._cron_tasks[name] = task
77
+ tasks.append(task)
78
+ elif ld.every:
79
+ task = asyncio.create_task(ld.start_interval(), name=f"interval:{name}")
80
+ self._cron_tasks[name] = task
81
+ tasks.append(task)
82
+
83
+ tasks.append(asyncio.create_task(self._event_loop(), name="event_loop"))
84
+ tasks.append(asyncio.create_task(self._trust_loop(), name="trust_eval"))
85
+
86
+ try:
87
+ await asyncio.gather(*tasks)
88
+ except asyncio.CancelledError:
89
+ log.info("orchestrator.stopped")
90
+
91
+ async def stop(self) -> None:
92
+ """Stop all loops and the orchestrator."""
93
+ self._running = False
94
+ for loop_fn in self._loops.values():
95
+ await loop_fn._loopentx_loop.stop()
96
+ for task in self._cron_tasks.values():
97
+ if not task.done():
98
+ task.cancel()
99
+
100
+ async def trigger_event(self, evt: LoopentxEvent) -> None:
101
+ """Publish an event to trigger matching loops."""
102
+ cfg = get_config()
103
+ await cfg.backend.publish_event(evt)
104
+
105
+ async def respond_to_escalation(
106
+ self,
107
+ escalation_id: str,
108
+ response: str,
109
+ ) -> None:
110
+ """Provide a human response to a pending escalation."""
111
+ from loopentx.core.models import EscalationStatus
112
+ cfg = get_config()
113
+ esc = await cfg.backend.get_escalation(escalation_id)
114
+ if esc:
115
+ esc.status = EscalationStatus.RESPONDED
116
+ esc.response = response
117
+ esc.resolved_at = time.time()
118
+ await cfg.backend.save_escalation(esc)
119
+ log.info("orchestrator.escalation_resolved", id=escalation_id)
120
+
121
+ async def get_runs(
122
+ self,
123
+ skill_name: Optional[str] = None,
124
+ since: Optional[float] = None,
125
+ limit: int = 100,
126
+ ) -> list:
127
+ cfg = get_config()
128
+ return await cfg.backend.get_runs(skill_name=skill_name, since=since, limit=limit)
129
+
130
+ async def approve_skill(self, skill_name: str, approved_by: str = "human") -> None:
131
+ cfg = get_config()
132
+ await cfg.backend.approve_skill(skill_name, approved_by=approved_by)
133
+ log.info("orchestrator.skill_approved", skill=skill_name, by=approved_by)
134
+
135
+ async def _event_loop(self) -> None:
136
+ cfg = get_config()
137
+ while self._running:
138
+ try:
139
+ events = await cfg.backend.poll_events()
140
+ for evt in events:
141
+ for loop_fn in self._event_listeners.get(evt.name, []):
142
+ asyncio.create_task(
143
+ loop_fn._loopentx_loop.execute(
144
+ trigger="event", event_data=evt.data
145
+ )
146
+ )
147
+ except Exception as exc:
148
+ log.error("orchestrator.event_loop_error", error=str(exc))
149
+ await asyncio.sleep(get_config().worker_poll_interval)
150
+
151
+ async def _trust_loop(self) -> None:
152
+ from loopentx.trust.scorer import TrustScorer
153
+ scorer = TrustScorer()
154
+ while self._running:
155
+ await asyncio.sleep(3600) # every hour
156
+ cfg = get_config()
157
+ for name in list(self._skills) + list(self._loops):
158
+ try:
159
+ trust = await scorer.evaluate(name)
160
+ await cfg.backend.save_trust_record(trust)
161
+ log.info("orchestrator.trust_evaluated",
162
+ skill=name, score=trust.trust_score,
163
+ level=trust.trust_level)
164
+ except Exception as exc:
165
+ log.error("orchestrator.trust_eval_error",
166
+ skill=name, error=str(exc))
loopentx/core/skill.py ADDED
@@ -0,0 +1,159 @@
1
+ """The @skill decorator — durable, retryable, policy-scoped functions."""
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
+
12
+ from loopentx.core.context import LoopContext
13
+ from loopentx.core.models import RunRecord, RunStatus
14
+ from loopentx.core.exceptions import SkillError
15
+ from loopentx.core.config import get_config
16
+
17
+ log = structlog.get_logger()
18
+
19
+
20
+ class SkillDefinition:
21
+ """Internal wrapper for a @skill-decorated function."""
22
+
23
+ def __init__(
24
+ self,
25
+ fn: Callable[..., Coroutine[Any, Any, Any]],
26
+ retries: int = 3,
27
+ timeout: Optional[int] = None,
28
+ on_failure: Optional[Callable] = None,
29
+ concurrency_limit: Optional[int] = None,
30
+ description: Optional[str] = None,
31
+ ) -> None:
32
+ self.fn = fn
33
+ self.name = fn.__name__
34
+ self.retries = retries
35
+ self.timeout = timeout
36
+ self.on_failure = on_failure
37
+ self.concurrency_limit = concurrency_limit
38
+ self.description = description or fn.__doc__
39
+ self.policy_context: Optional[Any] = None
40
+
41
+ self._sem: Optional[asyncio.Semaphore] = (
42
+ asyncio.Semaphore(concurrency_limit) if concurrency_limit else None
43
+ )
44
+
45
+ async def execute(self, ctx: LoopContext, **kwargs: Any) -> Any:
46
+ config = get_config()
47
+ backend = config.backend
48
+
49
+ run = RunRecord(
50
+ id=ctx.run_id, skill_name=self.name,
51
+ trigger="invoke", status=RunStatus.RUNNING,
52
+ input=kwargs, is_shadow=ctx.is_shadow,
53
+ started_at=time.time(),
54
+ )
55
+ await backend.save_run(run)
56
+
57
+ last_exc: Optional[Exception] = None
58
+
59
+ for attempt in range(self.retries + 1):
60
+ try:
61
+ if attempt > 0:
62
+ await asyncio.sleep(min(2 ** attempt, 60))
63
+ log.info("skill.retry", skill=self.name, attempt=attempt)
64
+
65
+ if self._sem:
66
+ async with self._sem:
67
+ result = await self._run(ctx, **kwargs)
68
+ else:
69
+ result = await self._run(ctx, **kwargs)
70
+
71
+ run.status = RunStatus.COMPLETED
72
+ run.output = result
73
+ run.completed_at = time.time()
74
+ run.duration_ms = int((run.completed_at - run.started_at) * 1000)
75
+ await backend.save_run(run)
76
+ await backend.record_trust_outcome(self.name, success=True)
77
+
78
+ log.info("skill.completed", skill=self.name, run_id=ctx.run_id,
79
+ duration_ms=run.duration_ms)
80
+ return result
81
+
82
+ except Exception as exc:
83
+ last_exc = exc
84
+ log.warning("skill.attempt_failed", skill=self.name,
85
+ attempt=attempt, error=str(exc))
86
+
87
+ run.status = RunStatus.FAILED
88
+ run.error = str(last_exc)
89
+ run.completed_at = time.time()
90
+ run.duration_ms = int((run.completed_at - run.started_at) * 1000)
91
+ await backend.save_run(run)
92
+ await backend.record_trust_outcome(self.name, success=False)
93
+
94
+ if self.on_failure:
95
+ try:
96
+ await self.on_failure(error=last_exc, run=run, ctx=ctx)
97
+ except Exception as fe:
98
+ log.error("skill.on_failure_error", error=str(fe))
99
+
100
+ raise SkillError(skill_name=self.name, cause=last_exc) from last_exc
101
+
102
+ async def _run(self, ctx: LoopContext, **kwargs: Any) -> Any:
103
+ if self.timeout:
104
+ return await asyncio.wait_for(self.fn(ctx, **kwargs), timeout=self.timeout)
105
+ return await self.fn(ctx, **kwargs)
106
+
107
+
108
+ def skill(
109
+ retries: int = 3,
110
+ timeout: Optional[int] = None,
111
+ on_failure: Optional[Callable] = None,
112
+ concurrency_limit: Optional[int] = None,
113
+ description: Optional[str] = None,
114
+ ) -> Callable:
115
+ """Decorator to define a durable, retryable Loopentx skill.
116
+
117
+ Each ctx.step() call inside a skill is checkpointed. If the process
118
+ restarts mid-execution, completed steps are not re-run.
119
+
120
+ Args:
121
+ retries: Retry attempts on failure. Default 3.
122
+ timeout: Max execution time in seconds.
123
+ on_failure: Async callback when all retries exhausted.
124
+ Receives (error, run, ctx) keyword arguments.
125
+ concurrency_limit: Max concurrent executions of this skill.
126
+ description: Human-readable description.
127
+
128
+ Example:
129
+ @skill(retries=3, timeout=60)
130
+ async def analyse_report(ctx, report_id: str):
131
+ data = await ctx.step("fetch", fetch_report, report_id)
132
+ summary = await ctx.step("summarise", call_llm, data)
133
+ await ctx.step("store", save_summary, summary)
134
+ return summary
135
+ """
136
+ def decorator(fn: Callable) -> Callable:
137
+ skill_def = SkillDefinition(
138
+ fn=fn, retries=retries, timeout=timeout,
139
+ on_failure=on_failure, concurrency_limit=concurrency_limit,
140
+ description=description,
141
+ )
142
+
143
+ @functools.wraps(fn)
144
+ async def wrapper(**kwargs: Any) -> Any:
145
+ from ulid import ULID
146
+ cfg = get_config()
147
+ run_id = str(ULID())
148
+ ctx = LoopContext(
149
+ run_id=run_id, skill_name=fn.__name__,
150
+ backend=cfg.backend,
151
+ policy_context=skill_def.policy_context,
152
+ )
153
+ return await skill_def.execute(ctx, **kwargs)
154
+
155
+ wrapper._loopentx_skill = skill_def # type: ignore[attr-defined]
156
+ wrapper._loopentx_kind = "skill" # type: ignore[attr-defined]
157
+ return wrapper
158
+
159
+ return decorator
@@ -0,0 +1,3 @@
1
+ """Loopentx LLM integration."""
2
+ from loopentx.llm.caller import call_llm
3
+ __all__ = ["call_llm"]