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/llm/caller.py ADDED
@@ -0,0 +1,103 @@
1
+ """LLM caller — routes ctx.think() to the configured provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Optional
7
+
8
+ import structlog
9
+
10
+ from loopentx.core.config import get_config
11
+
12
+ log = structlog.get_logger()
13
+
14
+
15
+ async def call_llm(
16
+ prompt: str,
17
+ system: Optional[str] = None,
18
+ choose_from: Optional[list[str]] = None,
19
+ ) -> str:
20
+ """Call the configured LLM provider and return the response string.
21
+
22
+ Automatically routes to OpenAI or Anthropic based on config.
23
+ If choose_from is set, validates the response against the allowed values.
24
+ """
25
+ cfg = get_config()
26
+
27
+ if cfg.llm_provider == "anthropic":
28
+ response = await _call_anthropic(prompt, system, cfg)
29
+ elif cfg.llm_provider == "openai":
30
+ response = await _call_openai(prompt, system, cfg)
31
+ else:
32
+ raise ValueError(f"Unsupported LLM provider: {cfg.llm_provider!r}")
33
+
34
+ response = response.strip()
35
+
36
+ if choose_from:
37
+ response = _extract_choice(response, choose_from)
38
+
39
+ log.info("llm.called", provider=cfg.llm_provider,
40
+ prompt_len=len(prompt), response_len=len(response))
41
+ return response
42
+
43
+
44
+ async def _call_anthropic(prompt: str, system: Optional[str], cfg: Any) -> str:
45
+ try:
46
+ import anthropic
47
+ except ImportError:
48
+ raise ImportError("pip install loopentx[anthropic]")
49
+
50
+ api_key = cfg.llm_api_key or None
51
+ client = anthropic.AsyncAnthropic(api_key=api_key)
52
+
53
+ kwargs: dict = dict(
54
+ model=cfg.llm_model,
55
+ max_tokens=1024,
56
+ messages=[{"role": "user", "content": prompt}],
57
+ )
58
+ if system:
59
+ kwargs["system"] = system
60
+
61
+ msg = await client.messages.create(**kwargs)
62
+ return msg.content[0].text
63
+
64
+
65
+ async def _call_openai(prompt: str, system: Optional[str], cfg: Any) -> str:
66
+ try:
67
+ from openai import AsyncOpenAI
68
+ except ImportError:
69
+ raise ImportError("pip install loopentx[openai]")
70
+
71
+ api_key = cfg.llm_api_key or None
72
+ client = AsyncOpenAI(api_key=api_key)
73
+
74
+ messages = []
75
+ if system:
76
+ messages.append({"role": "system", "content": system})
77
+ messages.append({"role": "user", "content": prompt})
78
+
79
+ resp = await client.chat.completions.create(
80
+ model=cfg.llm_model,
81
+ messages=messages,
82
+ max_tokens=1024,
83
+ )
84
+ return resp.choices[0].message.content or ""
85
+
86
+
87
+ def _extract_choice(response: str, choices: list[str]) -> str:
88
+ """Extract the matching choice from the LLM response."""
89
+ lower = response.lower().strip()
90
+ for choice in choices:
91
+ if choice.lower() in lower:
92
+ return choice
93
+ # Fuzzy: first word
94
+ first_word = re.split(r"\W+", lower)[0] if lower else ""
95
+ for choice in choices:
96
+ if choice.lower().startswith(first_word):
97
+ return choice
98
+ log.warning("llm.choice_not_found", response=response, choices=choices)
99
+ return choices[0]
100
+
101
+
102
+ # Allow Any import for type hints in private functions
103
+ from typing import Any
@@ -0,0 +1,4 @@
1
+ """Loopentx trust layer."""
2
+ from loopentx.trust.policy import policy, PolicyContext
3
+ from loopentx.trust.scorer import TrustScorer, TrustScore
4
+ __all__ = ["policy", "PolicyContext", "TrustScorer", "TrustScore"]
@@ -0,0 +1,185 @@
1
+ """The @policy decorator — capability scoping, shadow mode, blast radius."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import time
7
+ from typing import Any, Callable, Optional
8
+
9
+ import structlog
10
+
11
+ from loopentx.core.models import BlastRadius, SkillRegistration, TrustLevel
12
+ from loopentx.core.exceptions import PolicyViolationError, SkillNotApprovedError
13
+ from loopentx.core.config import get_config
14
+
15
+ log = structlog.get_logger()
16
+
17
+ _WRITE_PATTERNS = [
18
+ "post_", "send_", "write_", "create_", "update_", "delete_",
19
+ "publish_", "notify_", "alert_", "push_", "emit_", "dispatch_",
20
+ "insert_", "patch_", "put_", "remove_", "drop_",
21
+ ]
22
+
23
+
24
+ class PolicyContext:
25
+ """Runtime policy enforcement attached to a skill."""
26
+
27
+ def __init__(
28
+ self,
29
+ skill_name: str,
30
+ can_read: list[str],
31
+ can_write: list[str],
32
+ blast_radius: BlastRadius,
33
+ shadow_cycles: int,
34
+ require_approval: bool,
35
+ ) -> None:
36
+ self.skill_name = skill_name
37
+ self.can_read = set(can_read)
38
+ self.can_write = set(can_write)
39
+ self.blast_radius = blast_radius
40
+ self.shadow_cycles = shadow_cycles
41
+ self.require_approval = require_approval
42
+
43
+ def assert_can_read(self, capability: str) -> None:
44
+ if capability not in self.can_read and capability not in self.can_write:
45
+ raise PolicyViolationError(self.skill_name, capability, "read")
46
+
47
+ def assert_can_write(self, capability: str) -> None:
48
+ if capability not in self.can_write:
49
+ raise PolicyViolationError(self.skill_name, capability, "write")
50
+
51
+ def is_write_action(self, fn: Callable) -> bool:
52
+ name = fn.__name__.lower()
53
+ return any(name.startswith(p) for p in _WRITE_PATTERNS)
54
+
55
+ def to_registration(self, fn_name: str) -> SkillRegistration:
56
+ needs_gate = self.shadow_cycles > 0 or self.require_approval
57
+ return SkillRegistration(
58
+ name=fn_name,
59
+ kind="skill",
60
+ can_read=list(self.can_read),
61
+ can_write=list(self.can_write),
62
+ blast_radius=self.blast_radius,
63
+ shadow_cycles=self.shadow_cycles,
64
+ shadow_cycles_remaining=self.shadow_cycles,
65
+ require_approval=self.require_approval,
66
+ is_active=not needs_gate,
67
+ is_shadow=self.shadow_cycles > 0,
68
+ trust_level=TrustLevel.UNTRUSTED if needs_gate else TrustLevel.PROVISIONAL,
69
+ )
70
+
71
+
72
+ def policy(
73
+ can_read: Optional[list[str]] = None,
74
+ can_write: Optional[list[str]] = None,
75
+ blast_radius: str = "low",
76
+ shadow_cycles: int = 0,
77
+ require_approval: bool = False,
78
+ ) -> Callable:
79
+ """Declare the trust policy for a skill.
80
+
81
+ Must be applied ABOVE @skill in the decorator stack.
82
+
83
+ Args:
84
+ can_read: Systems the skill may read from.
85
+ can_write: Systems the skill may write to (implies read).
86
+ blast_radius: Impact scope: "low" | "medium" | "high" | "critical".
87
+ high/critical automatically require human approval.
88
+ shadow_cycles: Dry-run cycles before going live. Write actions
89
+ are captured but not applied during shadow mode.
90
+ require_approval: Require explicit `loopentx trust approve` before live.
91
+
92
+ Example:
93
+ @policy(
94
+ can_read=["metrics_api"],
95
+ can_write=["slack"],
96
+ blast_radius="medium",
97
+ shadow_cycles=3,
98
+ )
99
+ @skill(retries=3)
100
+ async def triage(ctx, services: list[str]):
101
+ ...
102
+ """
103
+ try:
104
+ blast = BlastRadius(blast_radius)
105
+ except ValueError:
106
+ raise ValueError(
107
+ f"Invalid blast_radius: {blast_radius!r}. "
108
+ f"Must be one of: low, medium, high, critical"
109
+ )
110
+
111
+ _require = require_approval or blast in (BlastRadius.HIGH, BlastRadius.CRITICAL)
112
+
113
+ def decorator(fn: Callable) -> Callable:
114
+ policy_ctx = PolicyContext(
115
+ skill_name=fn.__name__,
116
+ can_read=can_read or [],
117
+ can_write=can_write or [],
118
+ blast_radius=blast,
119
+ shadow_cycles=shadow_cycles,
120
+ require_approval=_require,
121
+ )
122
+
123
+ if hasattr(fn, "_loopentx_skill"):
124
+ fn._loopentx_skill.policy_context = policy_ctx
125
+
126
+ @functools.wraps(fn)
127
+ async def wrapper(**kwargs: Any) -> Any:
128
+ if shadow_cycles > 0 or _require:
129
+ cfg = get_config()
130
+ reg = await cfg.backend.get_skill_registration(fn.__name__)
131
+ if reg and not reg.is_active:
132
+ if reg.is_shadow and reg.shadow_cycles_remaining > 0:
133
+ return await _run_shadow(fn, policy_ctx, reg, **kwargs)
134
+ raise SkillNotApprovedError(fn.__name__)
135
+ return await fn(**kwargs)
136
+
137
+ wrapper._loopentx_policy = policy_ctx # type: ignore[attr-defined]
138
+ if hasattr(fn, "_loopentx_skill"):
139
+ wrapper._loopentx_skill = fn._loopentx_skill # type: ignore[attr-defined]
140
+ wrapper._loopentx_skill.policy_context = policy_ctx
141
+ wrapper._loopentx_kind = "skill" # type: ignore[attr-defined]
142
+
143
+ return wrapper
144
+
145
+ return decorator
146
+
147
+
148
+ async def _run_shadow(
149
+ fn: Callable,
150
+ policy_ctx: PolicyContext,
151
+ reg: Any,
152
+ **kwargs: Any,
153
+ ) -> Any:
154
+ from loopentx.core.context import LoopContext
155
+ from loopentx.core.config import get_config
156
+ from ulid import ULID
157
+
158
+ cfg = get_config()
159
+ run_id = str(ULID())
160
+ ctx = LoopContext(
161
+ run_id=run_id,
162
+ skill_name=fn.__name__,
163
+ backend=cfg.backend,
164
+ policy_context=policy_ctx,
165
+ shadow_mode=True,
166
+ )
167
+
168
+ if hasattr(fn, "_loopentx_skill"):
169
+ result = await fn._loopentx_skill.execute(ctx, **kwargs)
170
+ else:
171
+ result = await fn(ctx, **kwargs)
172
+
173
+ remaining = reg.shadow_cycles_remaining - 1
174
+ if remaining <= 0:
175
+ if policy_ctx.blast_radius == BlastRadius.LOW and not policy_ctx.require_approval:
176
+ await cfg.backend.approve_skill(fn.__name__, approved_by="auto")
177
+ log.info("policy.auto_approved", skill=fn.__name__)
178
+ else:
179
+ log.info("policy.shadow_complete_awaiting_approval",
180
+ skill=fn.__name__, blast=policy_ctx.blast_radius.value)
181
+ else:
182
+ reg.shadow_cycles_remaining = remaining
183
+ await cfg.backend.save_skill_registration(reg)
184
+
185
+ return result
@@ -0,0 +1,141 @@
1
+ """Trust scorer — evaluates skill reliability and promotes trust levels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Optional
7
+
8
+ import structlog
9
+
10
+ from loopentx.core.models import TrustRecord, TrustLevel, RunStatus
11
+ from loopentx.core.config import get_config
12
+
13
+ log = structlog.get_logger()
14
+
15
+ THRESHOLDS = {
16
+ TrustLevel.AUTONOMOUS: 0.90,
17
+ TrustLevel.TRUSTED: 0.65,
18
+ TrustLevel.PROVISIONAL: 0.30,
19
+ TrustLevel.UNTRUSTED: 0.0,
20
+ }
21
+
22
+ WEIGHTS = {
23
+ "success_rate": 0.45,
24
+ "human_approval_rate": 0.30,
25
+ "volume_bonus": 0.10,
26
+ "recency_factor": 0.15,
27
+ }
28
+
29
+
30
+ class TrustScorer:
31
+ """Calculates and updates trust scores for loops and skills.
32
+
33
+ Score components:
34
+ success_rate (45%) — ratio of completed to total live runs
35
+ human_approval_rate (30%)— ratio of approvals to human reviews
36
+ volume_bonus (10%) — logarithmic reward for proven track record
37
+ recency_factor (15%) — penalises dormant skills
38
+
39
+ Score → Level:
40
+ 0.00–0.29 UNTRUSTED shadow + human review required
41
+ 0.30–0.64 PROVISIONAL runs live, changes monitored
42
+ 0.65–0.89 TRUSTED autonomous, changes need review
43
+ 0.90–1.00 AUTONOMOUS fully autonomous, changes auto-approved
44
+ """
45
+
46
+ async def evaluate(self, skill_name: str) -> TrustRecord:
47
+ cfg = get_config()
48
+ since = time.time() - (30 * 86400)
49
+ runs = await cfg.backend.get_runs(skill_name=skill_name, since=since)
50
+
51
+ existing = await cfg.backend.get_trust_record(skill_name)
52
+ trust = existing or TrustRecord(skill_name=skill_name)
53
+
54
+ if not runs:
55
+ trust.last_evaluated_at = time.time()
56
+ return trust
57
+
58
+ live = [r for r in runs if not r.is_shadow]
59
+ total = len(live)
60
+ ok = sum(1 for r in live if r.status == RunStatus.COMPLETED)
61
+ failed = sum(1 for r in live if r.status == RunStatus.FAILED)
62
+ shadow = sum(1 for r in runs if r.is_shadow)
63
+
64
+ trust.total_runs = total
65
+ trust.successful_runs= ok
66
+ trust.failed_runs = failed
67
+ trust.shadow_runs = shadow
68
+
69
+ durations = [r.duration_ms for r in runs if r.duration_ms]
70
+ trust.avg_duration_ms = sum(durations) / len(durations) if durations else 0.0
71
+
72
+ success_rate = ok / total if total > 0 else 0.0
73
+
74
+ approvals = trust.human_approvals
75
+ rejections = trust.human_rejections
76
+ reviews = approvals + rejections
77
+ approval_rate = approvals / reviews if reviews > 0 else 0.5
78
+
79
+ volume_bonus = min(total / 50.0, 1.0)
80
+
81
+ most_recent = max((r.completed_at or 0.0) for r in runs)
82
+ days_inactive = (time.time() - most_recent) / 86400 if most_recent else 30
83
+ recency = max(0.0, 1.0 - days_inactive / 14.0)
84
+
85
+ score = (
86
+ success_rate * WEIGHTS["success_rate"]
87
+ + approval_rate * WEIGHTS["human_approval_rate"]
88
+ + volume_bonus * WEIGHTS["volume_bonus"]
89
+ + recency * WEIGHTS["recency_factor"]
90
+ )
91
+
92
+ trust.trust_score = round(min(score, 1.0), 4)
93
+ trust.trust_level = self._to_level(trust.trust_score)
94
+ trust.last_evaluated_at = time.time()
95
+ trust.last_updated_at = time.time()
96
+
97
+ log.info("trust.evaluated", skill=skill_name, score=trust.trust_score,
98
+ level=trust.trust_level, success_rate=round(success_rate, 3))
99
+ return trust
100
+
101
+ def _to_level(self, score: float) -> TrustLevel:
102
+ for level, threshold in THRESHOLDS.items():
103
+ if score >= threshold:
104
+ return level
105
+ return TrustLevel.UNTRUSTED
106
+
107
+ def explain(self, trust: TrustRecord) -> str:
108
+ return "\n".join([
109
+ f"Trust: '{trust.skill_name}' → {trust.trust_score:.2f} ({trust.trust_level.value})",
110
+ f" Runs (30d): {trust.total_runs}",
111
+ f" Successful: {trust.successful_runs}",
112
+ f" Failed: {trust.failed_runs}",
113
+ f" Shadow runs: {trust.shadow_runs}",
114
+ f" Human approvals: {trust.human_approvals}",
115
+ f" Human rejections: {trust.human_rejections}",
116
+ f" Avg duration: {trust.avg_duration_ms:.0f}ms",
117
+ ])
118
+
119
+
120
+ class TrustScore:
121
+ """Convenience helpers for reading and updating trust records."""
122
+
123
+ @staticmethod
124
+ async def get(skill_name: str) -> Optional[TrustRecord]:
125
+ return await get_config().backend.get_trust_record(skill_name)
126
+
127
+ @staticmethod
128
+ async def approve(skill_name: str, approved_by: str = "human") -> None:
129
+ backend = get_config().backend
130
+ trust = await backend.get_trust_record(skill_name) or TrustRecord(skill_name=skill_name)
131
+ trust.human_approvals += 1
132
+ trust.last_updated_at = time.time()
133
+ await backend.save_trust_record(trust)
134
+
135
+ @staticmethod
136
+ async def reject(skill_name: str, rejected_by: str = "human") -> None:
137
+ backend = get_config().backend
138
+ trust = await backend.get_trust_record(skill_name) or TrustRecord(skill_name=skill_name)
139
+ trust.human_rejections += 1
140
+ trust.last_updated_at = time.time()
141
+ await backend.save_trust_record(trust)