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/memory.py
ADDED
|
@@ -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()
|
loopentx/core/models.py
ADDED
|
@@ -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
|
loopentx/llm/__init__.py
ADDED