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/__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
|
loopentx/cli/__init__.py
ADDED
|
File without changes
|