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 ADDED
@@ -0,0 +1,31 @@
1
+ """
2
+ Loopentx — Write the loop once. Step back. Loopentx runs it forever.
3
+
4
+ Four layers:
5
+ Loop → scheduled / event-triggered autonomous execution
6
+ Skill → durable, checkpointed, retryable functions
7
+ Orchestrator→ scheduling, concurrency, history, hot-deploy
8
+ Trust → policy, shadow mode, trust scoring, escalation
9
+ """
10
+
11
+ from loopentx.core.loop import loop
12
+ from loopentx.core.skill import skill
13
+ from loopentx.core.context import LoopContext
14
+ from loopentx.core.orchestrator import Orchestrator
15
+ from loopentx.core.config import configure, get_config
16
+ from loopentx.core.events import event, LoopentxEvent
17
+ from loopentx.trust.policy import policy
18
+
19
+ __all__ = [
20
+ "loop",
21
+ "skill",
22
+ "policy",
23
+ "event",
24
+ "configure",
25
+ "get_config",
26
+ "LoopContext",
27
+ "LoopentxEvent",
28
+ "Orchestrator",
29
+ ]
30
+
31
+ __version__ = "0.1.0"
@@ -0,0 +1,11 @@
1
+ """Loopentx storage backends."""
2
+ from loopentx.backends.base import BaseBackend
3
+ from loopentx.backends.memory import MemoryBackend
4
+
5
+ __all__ = ["BaseBackend", "MemoryBackend"]
6
+
7
+ try:
8
+ from loopentx.backends.redis_backend import RedisBackend
9
+ __all__.append("RedisBackend")
10
+ except ImportError:
11
+ pass
@@ -0,0 +1,115 @@
1
+ """Abstract base class for Loopentx storage backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Optional
7
+
8
+ from loopentx.core.models import (
9
+ RunRecord, StepRecord, SkillRegistration,
10
+ TrustRecord, ShadowOutput, LoopMemoryRecord, EscalationRecord,
11
+ )
12
+ from loopentx.core.events import LoopentxEvent
13
+
14
+
15
+ class BaseBackend(ABC):
16
+ """Abstract storage interface. Implement to add a new backend.
17
+
18
+ Built-in: MemoryBackend (tests), RedisBackend (production).
19
+ """
20
+
21
+ @abstractmethod
22
+ async def connect(self) -> None: ...
23
+
24
+ @abstractmethod
25
+ async def disconnect(self) -> None: ...
26
+
27
+ # ── Runs ──────────────────────────────────────────────────────────────
28
+ @abstractmethod
29
+ async def save_run(self, run: RunRecord) -> None: ...
30
+
31
+ @abstractmethod
32
+ async def get_run(self, run_id: str) -> Optional[RunRecord]: ...
33
+
34
+ @abstractmethod
35
+ async def get_runs(
36
+ self,
37
+ skill_name: Optional[str] = None,
38
+ since: Optional[float] = None,
39
+ limit: int = 100,
40
+ ) -> list[RunRecord]: ...
41
+
42
+ # ── Steps ─────────────────────────────────────────────────────────────
43
+ @abstractmethod
44
+ async def save_step(self, step: StepRecord) -> None: ...
45
+
46
+ @abstractmethod
47
+ async def get_step_result(self, step_id: str) -> Optional[Any]: ...
48
+
49
+ # ── Skill registration ────────────────────────────────────────────────
50
+ @abstractmethod
51
+ async def save_skill_registration(self, reg: SkillRegistration) -> None: ...
52
+
53
+ @abstractmethod
54
+ async def get_skill_registration(self, skill_name: str) -> Optional[SkillRegistration]: ...
55
+
56
+ @abstractmethod
57
+ async def list_skill_registrations(self) -> list[SkillRegistration]: ...
58
+
59
+ @abstractmethod
60
+ async def set_skill_active(self, skill_name: str, active: bool) -> None: ...
61
+
62
+ async def approve_skill(self, skill_name: str, approved_by: str = "system") -> None:
63
+ import time
64
+ reg = await self.get_skill_registration(skill_name)
65
+ if reg:
66
+ reg.is_active = True
67
+ reg.is_shadow = False
68
+ reg.shadow_cycles_remaining = 0
69
+ reg.approved_at = time.time()
70
+ reg.approved_by = approved_by
71
+ await self.save_skill_registration(reg)
72
+
73
+ # ── Trust ─────────────────────────────────────────────────────────────
74
+ @abstractmethod
75
+ async def save_trust_record(self, trust: TrustRecord) -> None: ...
76
+
77
+ @abstractmethod
78
+ async def get_trust_record(self, skill_name: str) -> Optional[TrustRecord]: ...
79
+
80
+ @abstractmethod
81
+ async def record_trust_outcome(self, skill_name: str, success: bool) -> None: ...
82
+
83
+ # ── Shadow outputs ────────────────────────────────────────────────────
84
+ @abstractmethod
85
+ async def save_shadow_output(
86
+ self, run_id: str, step_id: str,
87
+ output: Optional[Any] = None, error: Optional[str] = None,
88
+ ) -> None: ...
89
+
90
+ @abstractmethod
91
+ async def get_shadow_outputs(self, skill_name: str) -> list[ShadowOutput]: ...
92
+
93
+ # ── Loop memory ───────────────────────────────────────────────────────
94
+ @abstractmethod
95
+ async def save_loop_memory(self, record: LoopMemoryRecord) -> None: ...
96
+
97
+ @abstractmethod
98
+ async def get_loop_memory(self, loop_name: str) -> Optional[LoopMemoryRecord]: ...
99
+
100
+ # ── Escalations ───────────────────────────────────────────────────────
101
+ @abstractmethod
102
+ async def save_escalation(self, esc: EscalationRecord) -> None: ...
103
+
104
+ @abstractmethod
105
+ async def get_escalation(self, esc_id: str) -> Optional[EscalationRecord]: ...
106
+
107
+ @abstractmethod
108
+ async def list_pending_escalations(self) -> list[EscalationRecord]: ...
109
+
110
+ # ── Events ────────────────────────────────────────────────────────────
111
+ @abstractmethod
112
+ async def publish_event(self, evt: LoopentxEvent) -> None: ...
113
+
114
+ @abstractmethod
115
+ async def poll_events(self) -> list[LoopentxEvent]: ...
@@ -0,0 +1,146 @@
1
+ """In-memory backend — for testing and local development."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ from loopentx.backends.base import BaseBackend
9
+ from loopentx.core.models import (
10
+ RunRecord, StepRecord, SkillRegistration, TrustRecord,
11
+ ShadowOutput, LoopMemoryRecord, EscalationRecord, StepStatus,
12
+ )
13
+ from loopentx.core.events import LoopentxEvent
14
+
15
+
16
+ class MemoryBackend(BaseBackend):
17
+ """In-memory backend. Data is NOT persisted between restarts.
18
+
19
+ Use RedisBackend for production.
20
+
21
+ Example:
22
+ from loopentx import configure
23
+ from loopentx.backends import MemoryBackend
24
+ configure(backend=MemoryBackend())
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ self._runs: dict[str, RunRecord] = {}
29
+ self._steps: dict[str, StepRecord] = {}
30
+ self._skills: dict[str, SkillRegistration] = {}
31
+ self._trust: dict[str, TrustRecord] = {}
32
+ self._shadows: dict[str, ShadowOutput] = {}
33
+ self._memory: dict[str, LoopMemoryRecord] = {}
34
+ self._escalations: dict[str, EscalationRecord] = {}
35
+ self._events: list[LoopentxEvent] = []
36
+
37
+ async def connect(self) -> None: pass
38
+ async def disconnect(self) -> None: pass
39
+
40
+ # ── Runs ──────────────────────────────────────────────────────────────
41
+ async def save_run(self, run: RunRecord) -> None:
42
+ self._runs[run.id] = run
43
+
44
+ async def get_run(self, run_id: str) -> Optional[RunRecord]:
45
+ return self._runs.get(run_id)
46
+
47
+ async def get_runs(
48
+ self,
49
+ skill_name: Optional[str] = None,
50
+ since: Optional[float] = None,
51
+ limit: int = 100,
52
+ ) -> list[RunRecord]:
53
+ runs = list(self._runs.values())
54
+ if skill_name:
55
+ runs = [r for r in runs if r.skill_name == skill_name]
56
+ if since:
57
+ runs = [r for r in runs if r.started_at >= since]
58
+ runs.sort(key=lambda r: r.started_at, reverse=True)
59
+ return runs[:limit]
60
+
61
+ # ── Steps ─────────────────────────────────────────────────────────────
62
+ async def save_step(self, step: StepRecord) -> None:
63
+ self._steps[step.id] = step
64
+
65
+ async def get_step_result(self, step_id: str) -> Optional[Any]:
66
+ step = self._steps.get(step_id)
67
+ return step.output if step and step.status == StepStatus.COMPLETED else None
68
+
69
+ # ── Skill registration ────────────────────────────────────────────────
70
+ async def save_skill_registration(self, reg: SkillRegistration) -> None:
71
+ self._skills[reg.name] = reg
72
+
73
+ async def get_skill_registration(self, name: str) -> Optional[SkillRegistration]:
74
+ return self._skills.get(name)
75
+
76
+ async def list_skill_registrations(self) -> list[SkillRegistration]:
77
+ return list(self._skills.values())
78
+
79
+ async def set_skill_active(self, name: str, active: bool) -> None:
80
+ if name in self._skills:
81
+ self._skills[name].is_active = active
82
+
83
+ # ── Trust ─────────────────────────────────────────────────────────────
84
+ async def save_trust_record(self, trust: TrustRecord) -> None:
85
+ self._trust[trust.skill_name] = trust
86
+
87
+ async def get_trust_record(self, name: str) -> Optional[TrustRecord]:
88
+ return self._trust.get(name)
89
+
90
+ async def record_trust_outcome(self, name: str, success: bool) -> None:
91
+ trust = self._trust.get(name) or TrustRecord(skill_name=name)
92
+ trust.successful_runs += int(success)
93
+ trust.failed_runs += int(not success)
94
+ trust.total_runs += 1
95
+ trust.last_updated_at = time.time()
96
+ self._trust[name] = trust
97
+
98
+ # ── Shadow outputs ────────────────────────────────────────────────────
99
+ async def save_shadow_output(
100
+ self, run_id: str, step_id: str,
101
+ output: Optional[Any] = None, error: Optional[str] = None,
102
+ ) -> None:
103
+ run = self._runs.get(run_id)
104
+ skill_name = run.skill_name if run else "unknown"
105
+ key = f"{run_id}:{step_id}"
106
+ self._shadows[key] = ShadowOutput(
107
+ run_id=run_id, skill_name=skill_name,
108
+ step_id=step_id, output=output, error=error,
109
+ )
110
+
111
+ async def get_shadow_outputs(self, skill_name: str) -> list[ShadowOutput]:
112
+ return [o for o in self._shadows.values() if o.skill_name == skill_name]
113
+
114
+ # ── Loop memory ───────────────────────────────────────────────────────
115
+ async def save_loop_memory(self, record: LoopMemoryRecord) -> None:
116
+ self._memory[record.loop_name] = record
117
+
118
+ async def get_loop_memory(self, loop_name: str) -> Optional[LoopMemoryRecord]:
119
+ return self._memory.get(loop_name)
120
+
121
+ # ── Escalations ───────────────────────────────────────────────────────
122
+ async def save_escalation(self, esc: EscalationRecord) -> None:
123
+ self._escalations[esc.id] = esc
124
+
125
+ async def get_escalation(self, esc_id: str) -> Optional[EscalationRecord]:
126
+ return self._escalations.get(esc_id)
127
+
128
+ async def list_pending_escalations(self) -> list[EscalationRecord]:
129
+ from loopentx.core.models import EscalationStatus
130
+ return [e for e in self._escalations.values()
131
+ if e.status == EscalationStatus.PENDING]
132
+
133
+ # ── Events ────────────────────────────────────────────────────────────
134
+ async def publish_event(self, evt: LoopentxEvent) -> None:
135
+ self._events.append(evt)
136
+
137
+ async def poll_events(self) -> list[LoopentxEvent]:
138
+ events, self._events = self._events[:], []
139
+ return events
140
+
141
+ # ── Test helpers ──────────────────────────────────────────────────────
142
+ def reset(self) -> None:
143
+ """Clear all stored data. Use between tests."""
144
+ self._runs.clear(); self._steps.clear(); self._skills.clear()
145
+ self._trust.clear(); self._shadows.clear(); self._memory.clear()
146
+ self._escalations.clear(); self._events.clear()
@@ -0,0 +1,196 @@
1
+ """Redis backend for production use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ import structlog
9
+
10
+ from loopentx.backends.base import BaseBackend
11
+ from loopentx.core.models import (
12
+ RunRecord, StepRecord, SkillRegistration, TrustRecord,
13
+ ShadowOutput, LoopMemoryRecord, EscalationRecord, StepStatus,
14
+ )
15
+ from loopentx.core.events import LoopentxEvent
16
+
17
+ log = structlog.get_logger()
18
+
19
+ TTL_RUN = 30 * 86400 # 30 days
20
+ TTL_STEP = 30 * 86400
21
+ TTL_EVENT = 86400
22
+
23
+
24
+ class RedisBackend(BaseBackend):
25
+ """Redis-backed storage for Loopentx.
26
+
27
+ Requires: pip install loopentx[redis]
28
+
29
+ Example:
30
+ from loopentx import configure
31
+ from loopentx.backends import RedisBackend
32
+ configure(backend=RedisBackend("redis://localhost:6379"))
33
+ """
34
+
35
+ def __init__(self, url: str = "redis://localhost:6379", prefix: str = "ltx") -> None:
36
+ self.url = url
37
+ self.prefix = prefix
38
+ self._r: Any = None
39
+
40
+ def _k(self, *parts: str) -> str:
41
+ return ":".join([self.prefix] + list(parts))
42
+
43
+ async def connect(self) -> None:
44
+ try:
45
+ import redis.asyncio as aioredis
46
+ except ImportError:
47
+ raise ImportError("pip install loopentx[redis]")
48
+ self._r = aioredis.from_url(self.url, decode_responses=True)
49
+ await self._r.ping()
50
+ log.info("redis_backend.connected", url=self.url)
51
+
52
+ async def disconnect(self) -> None:
53
+ if self._r:
54
+ await self._r.aclose()
55
+
56
+ # ── Runs ──────────────────────────────────────────────────────────────
57
+ async def save_run(self, run: RunRecord) -> None:
58
+ await self._r.setex(self._k("run", run.id), TTL_RUN, run.model_dump_json())
59
+ await self._r.zadd(self._k("runs", "all"), {run.id: run.started_at})
60
+ await self._r.zadd(self._k("runs", "by", run.skill_name), {run.id: run.started_at})
61
+
62
+ async def get_run(self, run_id: str) -> Optional[RunRecord]:
63
+ d = await self._r.get(self._k("run", run_id))
64
+ return RunRecord.model_validate_json(d) if d else None
65
+
66
+ async def get_runs(
67
+ self,
68
+ skill_name: Optional[str] = None,
69
+ since: Optional[float] = None,
70
+ limit: int = 100,
71
+ ) -> list[RunRecord]:
72
+ idx = self._k("runs", "by", skill_name) if skill_name else self._k("runs", "all")
73
+ ids = await self._r.zrangebyscore(idx, since or "-inf", "+inf", start=0, num=limit)
74
+ runs = []
75
+ for rid in ids:
76
+ r = await self.get_run(rid)
77
+ if r:
78
+ runs.append(r)
79
+ return sorted(runs, key=lambda r: r.started_at, reverse=True)
80
+
81
+ # ── Steps ─────────────────────────────────────────────────────────────
82
+ async def save_step(self, step: StepRecord) -> None:
83
+ await self._r.setex(self._k("step", step.id), TTL_STEP, step.model_dump_json())
84
+
85
+ async def get_step_result(self, step_id: str) -> Optional[Any]:
86
+ d = await self._r.get(self._k("step", step_id))
87
+ if not d:
88
+ return None
89
+ step = StepRecord.model_validate_json(d)
90
+ return step.output if step.status == StepStatus.COMPLETED else None
91
+
92
+ # ── Skill registration ────────────────────────────────────────────────
93
+ async def save_skill_registration(self, reg: SkillRegistration) -> None:
94
+ await self._r.set(self._k("skill", reg.name), reg.model_dump_json())
95
+ await self._r.sadd(self._k("skills"), reg.name)
96
+
97
+ async def get_skill_registration(self, name: str) -> Optional[SkillRegistration]:
98
+ d = await self._r.get(self._k("skill", name))
99
+ return SkillRegistration.model_validate_json(d) if d else None
100
+
101
+ async def list_skill_registrations(self) -> list[SkillRegistration]:
102
+ names = await self._r.smembers(self._k("skills"))
103
+ result = []
104
+ for n in names:
105
+ r = await self.get_skill_registration(n)
106
+ if r:
107
+ result.append(r)
108
+ return result
109
+
110
+ async def set_skill_active(self, name: str, active: bool) -> None:
111
+ reg = await self.get_skill_registration(name)
112
+ if reg:
113
+ reg.is_active = active
114
+ await self.save_skill_registration(reg)
115
+
116
+ # ── Trust ─────────────────────────────────────────────────────────────
117
+ async def save_trust_record(self, trust: TrustRecord) -> None:
118
+ await self._r.set(self._k("trust", trust.skill_name), trust.model_dump_json())
119
+
120
+ async def get_trust_record(self, name: str) -> Optional[TrustRecord]:
121
+ d = await self._r.get(self._k("trust", name))
122
+ return TrustRecord.model_validate_json(d) if d else None
123
+
124
+ async def record_trust_outcome(self, name: str, success: bool) -> None:
125
+ trust = await self.get_trust_record(name) or TrustRecord(skill_name=name)
126
+ trust.successful_runs += int(success)
127
+ trust.failed_runs += int(not success)
128
+ trust.total_runs += 1
129
+ trust.last_updated_at = time.time()
130
+ await self.save_trust_record(trust)
131
+
132
+ # ── Shadow outputs ────────────────────────────────────────────────────
133
+ async def save_shadow_output(
134
+ self, run_id: str, step_id: str,
135
+ output: Optional[Any] = None, error: Optional[str] = None,
136
+ ) -> None:
137
+ run = await self.get_run(run_id)
138
+ skill_name = run.skill_name if run else "unknown"
139
+ so = ShadowOutput(run_id=run_id, skill_name=skill_name,
140
+ step_id=step_id, output=output, error=error)
141
+ key = self._k("shadow", run_id, step_id)
142
+ await self._r.setex(key, TTL_RUN, so.model_dump_json())
143
+ await self._r.sadd(self._k("shadows", skill_name), f"{run_id}:{step_id}")
144
+
145
+ async def get_shadow_outputs(self, skill_name: str) -> list[ShadowOutput]:
146
+ members = await self._r.smembers(self._k("shadows", skill_name))
147
+ result = []
148
+ for m in members:
149
+ run_id, step_id = m.rsplit(":", 1)
150
+ d = await self._r.get(self._k("shadow", run_id, step_id))
151
+ if d:
152
+ result.append(ShadowOutput.model_validate_json(d))
153
+ return result
154
+
155
+ # ── Loop memory ───────────────────────────────────────────────────────
156
+ async def save_loop_memory(self, record: LoopMemoryRecord) -> None:
157
+ await self._r.set(self._k("memory", record.loop_name), record.model_dump_json())
158
+
159
+ async def get_loop_memory(self, loop_name: str) -> Optional[LoopMemoryRecord]:
160
+ d = await self._r.get(self._k("memory", loop_name))
161
+ return LoopMemoryRecord.model_validate_json(d) if d else None
162
+
163
+ # ── Escalations ───────────────────────────────────────────────────────
164
+ async def save_escalation(self, esc: EscalationRecord) -> None:
165
+ await self._r.setex(self._k("esc", esc.id), TTL_RUN, esc.model_dump_json())
166
+ await self._r.sadd(self._k("escalations"), esc.id)
167
+
168
+ async def get_escalation(self, esc_id: str) -> Optional[EscalationRecord]:
169
+ d = await self._r.get(self._k("esc", esc_id))
170
+ return EscalationRecord.model_validate_json(d) if d else None
171
+
172
+ async def list_pending_escalations(self) -> list[EscalationRecord]:
173
+ from loopentx.core.models import EscalationStatus
174
+ ids = await self._r.smembers(self._k("escalations"))
175
+ result = []
176
+ for eid in ids:
177
+ e = await self.get_escalation(eid)
178
+ if e and e.status == EscalationStatus.PENDING:
179
+ result.append(e)
180
+ return result
181
+
182
+ # ── Events ────────────────────────────────────────────────────────────
183
+ async def publish_event(self, evt: LoopentxEvent) -> None:
184
+ k = self._k("events")
185
+ await self._r.rpush(k, evt.model_dump_json())
186
+ await self._r.expire(k, TTL_EVENT)
187
+
188
+ async def poll_events(self) -> list[LoopentxEvent]:
189
+ k = self._k("events")
190
+ events = []
191
+ for _ in range(50):
192
+ d = await self._r.lpop(k)
193
+ if not d:
194
+ break
195
+ events.append(LoopentxEvent.model_validate_json(d))
196
+ return events
File without changes