loopotel 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.
- loopotel/__init__.py +6 -0
- loopotel/cli.py +40 -0
- loopotel/exporter/__init__.py +6 -0
- loopotel/exporter/jsonl.py +22 -0
- loopotel/exporter/loopnet.py +35 -0
- loopotel/exporter/otlp.py +57 -0
- loopotel/integrations/__init__.py +5 -0
- loopotel/integrations/loopgym.py +104 -0
- loopotel/les.py +18 -0
- loopotel/models.py +71 -0
- loopotel/schemas/ltf-0.1.schema.json +105 -0
- loopotel/tracer.py +235 -0
- loopotel/validate.py +32 -0
- loopotel-0.1.0.dist-info/METADATA +111 -0
- loopotel-0.1.0.dist-info/RECORD +18 -0
- loopotel-0.1.0.dist-info/WHEEL +4 -0
- loopotel-0.1.0.dist-info/entry_points.txt +2 -0
- loopotel-0.1.0.dist-info/licenses/LICENSE +21 -0
loopotel/__init__.py
ADDED
loopotel/cli.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""CLI for LTF validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from loopotel.validate import validate_trace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Validate LTF trace JSON or JSONL")
|
|
15
|
+
parser.add_argument("path", type=Path)
|
|
16
|
+
args = parser.parse_args(argv)
|
|
17
|
+
|
|
18
|
+
if not args.path.exists():
|
|
19
|
+
print(f"Missing: {args.path}", file=sys.stderr)
|
|
20
|
+
return 1
|
|
21
|
+
|
|
22
|
+
lines = args.path.read_text(encoding="utf-8").strip().splitlines()
|
|
23
|
+
docs = [json.loads(line) for line in lines if line.strip()] if args.path.suffix == ".jsonl" else [json.loads(args.path.read_text(encoding="utf-8"))]
|
|
24
|
+
|
|
25
|
+
failed = False
|
|
26
|
+
for index, doc in enumerate(docs, start=1):
|
|
27
|
+
valid, errors = validate_trace(doc)
|
|
28
|
+
label = str(args.path) if len(docs) == 1 else f"{args.path}#{index}"
|
|
29
|
+
if valid:
|
|
30
|
+
print(f"VALID: {label}")
|
|
31
|
+
else:
|
|
32
|
+
failed = True
|
|
33
|
+
print(f"INVALID: {label}", file=sys.stderr)
|
|
34
|
+
for err in errors:
|
|
35
|
+
print(f" - {err}", file=sys.stderr)
|
|
36
|
+
return 1 if failed else 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Write LTF traces to JSONL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JsonlExporter:
|
|
11
|
+
"""Append LTF trace documents to a JSONL file."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, path: str | Path) -> None:
|
|
14
|
+
self.path = Path(path)
|
|
15
|
+
|
|
16
|
+
def export(self, trace: dict[str, Any]) -> None:
|
|
17
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
19
|
+
handle.write(json.dumps(trace, separators=(",", ":")) + "\n")
|
|
20
|
+
|
|
21
|
+
def export_tracer(self, tracer: Any) -> None:
|
|
22
|
+
self.export(tracer.build_trace())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Map LTF iteration spans to LoopNet trajectory steps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def trajectory_from_trace(trace: dict[str, Any]) -> list[dict[str, Any]]:
|
|
9
|
+
"""Extract ln/record-v1-compatible trajectory steps from iteration spans."""
|
|
10
|
+
steps: list[dict[str, Any]] = []
|
|
11
|
+
for span in trace.get("spans") or []:
|
|
12
|
+
if span.get("kind") != "iteration":
|
|
13
|
+
continue
|
|
14
|
+
attrs = span.get("attributes") or {}
|
|
15
|
+
iteration = int(attrs.get("loop.iteration", len(steps) + 1))
|
|
16
|
+
goal_score = float(attrs.get("loop.goal_score", 0.0))
|
|
17
|
+
steps.append(
|
|
18
|
+
{
|
|
19
|
+
"iteration": iteration,
|
|
20
|
+
"goal_score": goal_score,
|
|
21
|
+
"primary_quality": goal_score,
|
|
22
|
+
"cost_usd": attrs.get("loop.cost_usd"),
|
|
23
|
+
"latency_seconds": (
|
|
24
|
+
float(attrs["loop.latency_ms"]) / 1000.0
|
|
25
|
+
if attrs.get("loop.latency_ms") is not None
|
|
26
|
+
else None
|
|
27
|
+
),
|
|
28
|
+
"tokens": attrs.get("loop.tokens_delta"),
|
|
29
|
+
"failure_codes": [],
|
|
30
|
+
"safety_events": 0,
|
|
31
|
+
"human_intervention": False,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
steps.sort(key=lambda s: s["iteration"])
|
|
35
|
+
return steps
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Optional OTLP export (requires opentelemetry-sdk)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OtlpExporter:
|
|
9
|
+
"""Export LTF spans to an OTLP endpoint via OpenTelemetry SDK."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, endpoint: str | None = None, service_name: str = "loop-runtime") -> None:
|
|
12
|
+
self.endpoint = endpoint
|
|
13
|
+
self.service_name = service_name
|
|
14
|
+
self._provider = None
|
|
15
|
+
|
|
16
|
+
def _ensure_provider(self) -> None:
|
|
17
|
+
if self._provider is not None:
|
|
18
|
+
return
|
|
19
|
+
try:
|
|
20
|
+
from opentelemetry import trace
|
|
21
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
22
|
+
from opentelemetry.sdk.resources import Resource
|
|
23
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
24
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise SystemExit(
|
|
27
|
+
"OTLP export requires: pip install loopotel[otlp]"
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
resource = Resource.create({"service.name": self.service_name})
|
|
31
|
+
provider = TracerProvider(resource=resource)
|
|
32
|
+
kwargs: dict[str, Any] = {}
|
|
33
|
+
if self.endpoint:
|
|
34
|
+
kwargs["endpoint"] = self.endpoint
|
|
35
|
+
exporter = OTLPSpanExporter(**kwargs)
|
|
36
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
37
|
+
trace.set_tracer_provider(provider)
|
|
38
|
+
self._provider = provider
|
|
39
|
+
|
|
40
|
+
def export(self, trace_doc: dict[str, Any]) -> None:
|
|
41
|
+
"""Best-effort map of LTF document to OTel spans."""
|
|
42
|
+
self._ensure_provider()
|
|
43
|
+
from opentelemetry import trace
|
|
44
|
+
|
|
45
|
+
otel = trace.get_tracer("loopotel")
|
|
46
|
+
root = next((s for s in trace_doc.get("spans", []) if s.get("kind") == "loop"), None)
|
|
47
|
+
if root is None:
|
|
48
|
+
return
|
|
49
|
+
with otel.start_as_current_span(root["name"]) as span:
|
|
50
|
+
for key, value in (root.get("attributes") or {}).items():
|
|
51
|
+
span.set_attribute(key, value)
|
|
52
|
+
for child in trace_doc.get("spans") or []:
|
|
53
|
+
if child.get("parent_span_id") != root.get("span_id"):
|
|
54
|
+
continue
|
|
55
|
+
with otel.start_as_current_span(child["name"]) as child_span:
|
|
56
|
+
for key, value in (child.get("attributes") or {}).items():
|
|
57
|
+
child_span.set_attribute(key, value)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""LoopGym integration — trace SimEnv / LiveEnv episodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from loopotel.tracer import LoopTracer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _worker_evaluator_ids(spec: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
12
|
+
workers = spec.get("workers") or []
|
|
13
|
+
evaluators = spec.get("evaluators") or []
|
|
14
|
+
worker_id = str(workers[0]["id"]) if workers and workers[0].get("id") else None
|
|
15
|
+
evaluator_id = str(evaluators[0]["id"]) if evaluators and evaluators[0].get("id") else None
|
|
16
|
+
return worker_id, evaluator_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _goal_target_from_spec(spec: dict[str, Any]) -> float:
|
|
20
|
+
term = spec.get("termination_conditions")
|
|
21
|
+
if isinstance(term, dict):
|
|
22
|
+
for success in term.get("success") or []:
|
|
23
|
+
if success.get("value") is not None:
|
|
24
|
+
return float(success["value"])
|
|
25
|
+
for ev in spec.get("evaluators") or []:
|
|
26
|
+
rubric = ev.get("rubric") or {}
|
|
27
|
+
if rubric.get("pass_threshold") is not None:
|
|
28
|
+
return float(rubric["pass_threshold"])
|
|
29
|
+
return 0.8
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_traced_episode(
|
|
33
|
+
env: Any,
|
|
34
|
+
*,
|
|
35
|
+
task_id: str = "",
|
|
36
|
+
seed: int | None = None,
|
|
37
|
+
enabled: bool = True,
|
|
38
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
39
|
+
"""Run a LoopGym episode and return (episode_result, ltf_trace).
|
|
40
|
+
|
|
41
|
+
Tracing is opt-in (`enabled=True`). Default off matches SimEnv policy;
|
|
42
|
+
pass `enabled=True` explicitly or use `trace_live=True` wrappers.
|
|
43
|
+
"""
|
|
44
|
+
spec = getattr(env, "spec", {}) or {}
|
|
45
|
+
loop_name = str(spec.get("loop_name", "unknown-loop"))
|
|
46
|
+
goal_target = _goal_target_from_spec(spec)
|
|
47
|
+
worker_id, evaluator_id = _worker_evaluator_ids(spec)
|
|
48
|
+
|
|
49
|
+
tracer = LoopTracer(
|
|
50
|
+
loop_name=loop_name,
|
|
51
|
+
env_id=getattr(env, "env_id", None),
|
|
52
|
+
task_id=task_id or None,
|
|
53
|
+
goal_target=goal_target,
|
|
54
|
+
enabled=enabled,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with tracer:
|
|
58
|
+
obs = env.reset(task_id=task_id, seed=seed)
|
|
59
|
+
prev_tokens = 0
|
|
60
|
+
if getattr(env, "_runtime", None) and getattr(env._runtime, "llm", None):
|
|
61
|
+
prev_tokens = getattr(env._runtime.llm, "tokens_used", 0)
|
|
62
|
+
|
|
63
|
+
last_latency_ms = 0.0
|
|
64
|
+
while not env.done:
|
|
65
|
+
t0 = time.perf_counter()
|
|
66
|
+
obs, _reward, _done, info = env.step()
|
|
67
|
+
last_latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
68
|
+
tokens_now = 0
|
|
69
|
+
if getattr(env, "_runtime", None) and getattr(env._runtime, "llm", None):
|
|
70
|
+
tokens_now = getattr(env._runtime.llm, "tokens_used", 0)
|
|
71
|
+
tokens_delta = max(0, tokens_now - prev_tokens)
|
|
72
|
+
prev_tokens = tokens_now
|
|
73
|
+
|
|
74
|
+
if obs.iteration > 0:
|
|
75
|
+
tracer.emit_iteration(
|
|
76
|
+
iteration=obs.iteration,
|
|
77
|
+
goal_score=obs.quality_score,
|
|
78
|
+
goal_target=goal_target,
|
|
79
|
+
tokens_delta=tokens_delta,
|
|
80
|
+
latency_ms=round(last_latency_ms, 2),
|
|
81
|
+
worker_id=worker_id,
|
|
82
|
+
evaluator_id=evaluator_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
outcome = "success" if info.get("success") else "failure"
|
|
86
|
+
tracer.finish(
|
|
87
|
+
outcome=outcome,
|
|
88
|
+
termination_reason=str(info.get("termination_reason", "")),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
result = {
|
|
92
|
+
"task_id": task_id,
|
|
93
|
+
"seed": seed,
|
|
94
|
+
"env_id": getattr(env, "env_id", None),
|
|
95
|
+
"success": info.get("success", False),
|
|
96
|
+
"quality_score": obs.quality_score,
|
|
97
|
+
"termination_reason": info.get("termination_reason", ""),
|
|
98
|
+
}
|
|
99
|
+
return result, tracer.build_trace()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def trace_live_episode(env: Any, **kwargs: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
103
|
+
"""LiveEnv helper — tracing on by default."""
|
|
104
|
+
return run_traced_episode(env, enabled=True, **kwargs)
|
loopotel/les.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Point-in-loop LES proxies (v0.1 — effectiveness-focused)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DEFAULT_GOAL_TARGET = 0.8
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def effectiveness(goal_score: float, goal_target: float = DEFAULT_GOAL_TARGET) -> float:
|
|
9
|
+
if goal_target <= 0:
|
|
10
|
+
return 0.0
|
|
11
|
+
return max(0.0, min(1.0, goal_score / goal_target))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cumulative_les(scores: list[float], goal_target: float = DEFAULT_GOAL_TARGET) -> float:
|
|
15
|
+
if not scores:
|
|
16
|
+
return 0.0
|
|
17
|
+
values = [effectiveness(s, goal_target) for s in scores]
|
|
18
|
+
return round(sum(values) / len(values), 4)
|
loopotel/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""LTF document builders and identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def utc_now() -> datetime:
|
|
12
|
+
return datetime.now(timezone.utc)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def isoformat(dt: datetime) -> str:
|
|
16
|
+
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_trace_id() -> str:
|
|
20
|
+
return f"ltf-{uuid4()}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_span_id() -> str:
|
|
24
|
+
return secrets.token_hex(8)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def spec_pins() -> dict[str, str]:
|
|
28
|
+
return {"lss": "lss@1.0.0", "les": "les@1.0.0", "ltf": "ltf@0.1.0"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def otel_attributes(
|
|
32
|
+
*,
|
|
33
|
+
loop_name: str,
|
|
34
|
+
env_id: str | None = None,
|
|
35
|
+
task_id: str | None = None,
|
|
36
|
+
iteration: int | None = None,
|
|
37
|
+
goal_score: float | None = None,
|
|
38
|
+
goal_target: float | None = None,
|
|
39
|
+
tokens_delta: int | None = None,
|
|
40
|
+
cost_usd: float | None = None,
|
|
41
|
+
latency_ms: float | None = None,
|
|
42
|
+
les_cumulative: float | None = None,
|
|
43
|
+
les_effectiveness: float | None = None,
|
|
44
|
+
worker_id: str | None = None,
|
|
45
|
+
evaluator_id: str | None = None,
|
|
46
|
+
outcome: str | None = None,
|
|
47
|
+
termination_reason: str | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
attrs: dict[str, Any] = {"loop.name": loop_name}
|
|
50
|
+
mapping = {
|
|
51
|
+
"loop.env_id": env_id,
|
|
52
|
+
"loop.task_id": task_id,
|
|
53
|
+
"loop.iteration": iteration,
|
|
54
|
+
"loop.goal_score": goal_score,
|
|
55
|
+
"loop.goal_target": goal_target,
|
|
56
|
+
"loop.tokens_delta": tokens_delta,
|
|
57
|
+
"loop.cost_usd": cost_usd,
|
|
58
|
+
"loop.latency_ms": latency_ms,
|
|
59
|
+
"loop.les.cumulative_normalized": les_cumulative,
|
|
60
|
+
"loop.les.effectiveness": les_effectiveness,
|
|
61
|
+
"loop.worker.id": worker_id,
|
|
62
|
+
"loop.evaluator.id": evaluator_id,
|
|
63
|
+
"loop.outcome": outcome,
|
|
64
|
+
"loop.termination_reason": termination_reason,
|
|
65
|
+
"loop.lss_version": "1.0.0",
|
|
66
|
+
"loop.les_version": "1.0.0",
|
|
67
|
+
}
|
|
68
|
+
for key, value in mapping.items():
|
|
69
|
+
if value is not None:
|
|
70
|
+
attrs[key] = value
|
|
71
|
+
return attrs
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://loop-engineering.org/schema/ltf-0.1.schema.json",
|
|
4
|
+
"title": "Loop Trace Format 0.1",
|
|
5
|
+
"description": "Canonical schema for loop execution traces: one root span per run, iteration spans with worker/evaluator events, and point-in-loop LES metrics.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"schema_version",
|
|
10
|
+
"trace_id",
|
|
11
|
+
"loop_name",
|
|
12
|
+
"started_at",
|
|
13
|
+
"ended_at",
|
|
14
|
+
"spec_pins",
|
|
15
|
+
"spans"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"schema_version": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"const": "ltf/0.1"
|
|
21
|
+
},
|
|
22
|
+
"trace_id": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"pattern": "^ltf-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
25
|
+
},
|
|
26
|
+
"loop_name": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 3,
|
|
29
|
+
"maxLength": 64
|
|
30
|
+
},
|
|
31
|
+
"env_id": { "type": "string" },
|
|
32
|
+
"task_id": { "type": "string" },
|
|
33
|
+
"started_at": { "type": "string", "format": "date-time" },
|
|
34
|
+
"ended_at": { "type": "string", "format": "date-time" },
|
|
35
|
+
"outcome": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"enum": ["success", "failure", "partial", "unknown"]
|
|
38
|
+
},
|
|
39
|
+
"termination_reason": { "type": "string" },
|
|
40
|
+
"spec_pins": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"required": ["lss", "les", "ltf"],
|
|
44
|
+
"properties": {
|
|
45
|
+
"lss": { "type": "string", "const": "lss@1.0.0" },
|
|
46
|
+
"les": { "type": "string", "const": "les@1.0.0" },
|
|
47
|
+
"ltf": { "type": "string", "const": "ltf@0.1.0" }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"spans": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"minItems": 1,
|
|
53
|
+
"items": { "$ref": "#/$defs/Span" }
|
|
54
|
+
},
|
|
55
|
+
"metadata": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"$defs": {
|
|
61
|
+
"SpanKind": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": ["loop", "iteration", "worker", "evaluator"]
|
|
64
|
+
},
|
|
65
|
+
"Span": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"additionalProperties": false,
|
|
68
|
+
"required": ["span_id", "name", "kind", "start_time", "attributes"],
|
|
69
|
+
"properties": {
|
|
70
|
+
"span_id": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"pattern": "^[0-9a-f]{16}$"
|
|
73
|
+
},
|
|
74
|
+
"parent_span_id": {
|
|
75
|
+
"type": ["string", "null"]
|
|
76
|
+
},
|
|
77
|
+
"name": { "type": "string", "minLength": 1 },
|
|
78
|
+
"kind": { "$ref": "#/$defs/SpanKind" },
|
|
79
|
+
"start_time": { "type": "string", "format": "date-time" },
|
|
80
|
+
"end_time": { "type": "string", "format": "date-time" },
|
|
81
|
+
"attributes": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"additionalProperties": true
|
|
84
|
+
},
|
|
85
|
+
"events": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": { "$ref": "#/$defs/SpanEvent" }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"SpanEvent": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"additionalProperties": false,
|
|
94
|
+
"required": ["name", "timestamp"],
|
|
95
|
+
"properties": {
|
|
96
|
+
"name": { "type": "string" },
|
|
97
|
+
"timestamp": { "type": "string", "format": "date-time" },
|
|
98
|
+
"attributes": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"additionalProperties": true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
loopotel/tracer.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""LoopTracer — emit LTF spans for loop runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import time
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from loopotel import les as les_metrics
|
|
12
|
+
from loopotel.models import isoformat, new_span_id, new_trace_id, otel_attributes, spec_pins, utc_now
|
|
13
|
+
|
|
14
|
+
_current_tracer: ContextVar[LoopTracer | None] = ContextVar("loopotel_current_tracer", default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def current_tracer() -> LoopTracer | None:
|
|
18
|
+
return _current_tracer.get()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emit_iteration(
|
|
22
|
+
*,
|
|
23
|
+
iteration: int,
|
|
24
|
+
goal_score: float,
|
|
25
|
+
goal_target: float | None = None,
|
|
26
|
+
tokens_delta: int = 0,
|
|
27
|
+
cost_usd: float | None = None,
|
|
28
|
+
latency_ms: float | None = None,
|
|
29
|
+
worker_id: str | None = None,
|
|
30
|
+
evaluator_id: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Emit an iteration span on the active tracer (no-op if tracing disabled)."""
|
|
33
|
+
tracer = current_tracer()
|
|
34
|
+
if tracer is None or not tracer.enabled:
|
|
35
|
+
return
|
|
36
|
+
tracer.emit_iteration(
|
|
37
|
+
iteration=iteration,
|
|
38
|
+
goal_score=goal_score,
|
|
39
|
+
goal_target=goal_target,
|
|
40
|
+
tokens_delta=tokens_delta,
|
|
41
|
+
cost_usd=cost_usd,
|
|
42
|
+
latency_ms=latency_ms,
|
|
43
|
+
worker_id=worker_id,
|
|
44
|
+
evaluator_id=evaluator_id,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class LoopTracer:
|
|
50
|
+
"""Collect LTF spans for one loop run."""
|
|
51
|
+
|
|
52
|
+
loop_name: str
|
|
53
|
+
env_id: str | None = None
|
|
54
|
+
task_id: str | None = None
|
|
55
|
+
goal_target: float = les_metrics.DEFAULT_GOAL_TARGET
|
|
56
|
+
enabled: bool = True
|
|
57
|
+
trace_id: str = field(default_factory=new_trace_id)
|
|
58
|
+
started_at: Any = field(default_factory=utc_now)
|
|
59
|
+
ended_at: Any | None = None
|
|
60
|
+
outcome: str = "unknown"
|
|
61
|
+
termination_reason: str = ""
|
|
62
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
_root_span_id: str = field(default_factory=new_span_id)
|
|
64
|
+
_goal_scores: list[float] = field(default_factory=list)
|
|
65
|
+
_spans: list[dict[str, Any]] = field(default_factory=list)
|
|
66
|
+
_token_total: int = 0
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> LoopTracer:
|
|
69
|
+
if self.enabled:
|
|
70
|
+
_current_tracer.set(self)
|
|
71
|
+
self._spans.append(
|
|
72
|
+
{
|
|
73
|
+
"span_id": self._root_span_id,
|
|
74
|
+
"parent_span_id": None,
|
|
75
|
+
"name": "loop.run",
|
|
76
|
+
"kind": "loop",
|
|
77
|
+
"start_time": isoformat(self.started_at),
|
|
78
|
+
"attributes": otel_attributes(
|
|
79
|
+
loop_name=self.loop_name,
|
|
80
|
+
env_id=self.env_id,
|
|
81
|
+
task_id=self.task_id,
|
|
82
|
+
goal_target=self.goal_target,
|
|
83
|
+
),
|
|
84
|
+
"events": [],
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
90
|
+
if self.enabled:
|
|
91
|
+
self.finish(
|
|
92
|
+
outcome="failure" if exc else self.outcome,
|
|
93
|
+
termination_reason=self.termination_reason or ("error" if exc else ""),
|
|
94
|
+
)
|
|
95
|
+
_current_tracer.set(None)
|
|
96
|
+
|
|
97
|
+
def emit_iteration(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
iteration: int,
|
|
101
|
+
goal_score: float,
|
|
102
|
+
goal_target: float | None = None,
|
|
103
|
+
tokens_delta: int = 0,
|
|
104
|
+
cost_usd: float | None = None,
|
|
105
|
+
latency_ms: float | None = None,
|
|
106
|
+
worker_id: str | None = None,
|
|
107
|
+
evaluator_id: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
if not self.enabled:
|
|
110
|
+
return
|
|
111
|
+
target = goal_target if goal_target is not None else self.goal_target
|
|
112
|
+
self._goal_scores.append(goal_score)
|
|
113
|
+
self._token_total += tokens_delta
|
|
114
|
+
eff = les_metrics.effectiveness(goal_score, target)
|
|
115
|
+
cumulative = les_metrics.cumulative_les(self._goal_scores, target)
|
|
116
|
+
now = utc_now()
|
|
117
|
+
span_id = new_span_id()
|
|
118
|
+
events: list[dict[str, Any]] = [
|
|
119
|
+
{
|
|
120
|
+
"name": "loop.iteration.start",
|
|
121
|
+
"timestamp": isoformat(now),
|
|
122
|
+
"attributes": {"loop.iteration": iteration},
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
if worker_id:
|
|
126
|
+
events.append(
|
|
127
|
+
{
|
|
128
|
+
"name": "loop.worker.complete",
|
|
129
|
+
"timestamp": isoformat(now),
|
|
130
|
+
"attributes": {"loop.worker.id": worker_id},
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
if evaluator_id:
|
|
134
|
+
events.append(
|
|
135
|
+
{
|
|
136
|
+
"name": "loop.evaluator.complete",
|
|
137
|
+
"timestamp": isoformat(now),
|
|
138
|
+
"attributes": {"loop.evaluator.id": evaluator_id, "loop.goal_score": goal_score},
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
events.append(
|
|
142
|
+
{
|
|
143
|
+
"name": "loop.iteration.end",
|
|
144
|
+
"timestamp": isoformat(now),
|
|
145
|
+
"attributes": {"loop.iteration": iteration, "loop.goal_score": goal_score},
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
self._spans.append(
|
|
149
|
+
{
|
|
150
|
+
"span_id": span_id,
|
|
151
|
+
"parent_span_id": self._root_span_id,
|
|
152
|
+
"name": "loop.iteration",
|
|
153
|
+
"kind": "iteration",
|
|
154
|
+
"start_time": isoformat(now),
|
|
155
|
+
"end_time": isoformat(now),
|
|
156
|
+
"attributes": otel_attributes(
|
|
157
|
+
loop_name=self.loop_name,
|
|
158
|
+
env_id=self.env_id,
|
|
159
|
+
task_id=self.task_id,
|
|
160
|
+
iteration=iteration,
|
|
161
|
+
goal_score=round(goal_score, 4),
|
|
162
|
+
goal_target=target,
|
|
163
|
+
tokens_delta=tokens_delta,
|
|
164
|
+
cost_usd=cost_usd,
|
|
165
|
+
latency_ms=latency_ms,
|
|
166
|
+
les_cumulative=cumulative,
|
|
167
|
+
les_effectiveness=round(eff, 4),
|
|
168
|
+
worker_id=worker_id,
|
|
169
|
+
evaluator_id=evaluator_id,
|
|
170
|
+
),
|
|
171
|
+
"events": events,
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def finish(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
outcome: str = "unknown",
|
|
179
|
+
termination_reason: str = "",
|
|
180
|
+
) -> None:
|
|
181
|
+
if not self.enabled:
|
|
182
|
+
return
|
|
183
|
+
self.outcome = outcome
|
|
184
|
+
self.termination_reason = termination_reason
|
|
185
|
+
self.ended_at = utc_now()
|
|
186
|
+
if self._spans:
|
|
187
|
+
root = self._spans[0]
|
|
188
|
+
root["end_time"] = isoformat(self.ended_at)
|
|
189
|
+
root["attributes"].update(
|
|
190
|
+
otel_attributes(
|
|
191
|
+
loop_name=self.loop_name,
|
|
192
|
+
env_id=self.env_id,
|
|
193
|
+
task_id=self.task_id,
|
|
194
|
+
outcome=outcome,
|
|
195
|
+
termination_reason=termination_reason,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
root["attributes"]["loop.tokens_total"] = self._token_total
|
|
199
|
+
|
|
200
|
+
def build_trace(self) -> dict[str, Any]:
|
|
201
|
+
if self.ended_at is None:
|
|
202
|
+
self.finish()
|
|
203
|
+
return {
|
|
204
|
+
"schema_version": "ltf/0.1",
|
|
205
|
+
"trace_id": self.trace_id,
|
|
206
|
+
"loop_name": self.loop_name,
|
|
207
|
+
"env_id": self.env_id,
|
|
208
|
+
"task_id": self.task_id,
|
|
209
|
+
"started_at": isoformat(self.started_at),
|
|
210
|
+
"ended_at": isoformat(self.ended_at or utc_now()),
|
|
211
|
+
"outcome": self.outcome,
|
|
212
|
+
"termination_reason": self.termination_reason,
|
|
213
|
+
"spec_pins": spec_pins(),
|
|
214
|
+
"spans": self._spans,
|
|
215
|
+
"metadata": dict(self.metadata),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def trace_loop(
|
|
220
|
+
*,
|
|
221
|
+
loop_name: str,
|
|
222
|
+
env_id: str | None = None,
|
|
223
|
+
enabled: bool = True,
|
|
224
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
225
|
+
"""Decorator: wrap a function in a LoopTracer context."""
|
|
226
|
+
|
|
227
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
228
|
+
@functools.wraps(fn)
|
|
229
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
230
|
+
with LoopTracer(loop_name=loop_name, env_id=env_id, enabled=enabled):
|
|
231
|
+
return fn(*args, **kwargs)
|
|
232
|
+
|
|
233
|
+
return wrapper
|
|
234
|
+
|
|
235
|
+
return decorator
|
loopotel/validate.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Validate LTF documents against ltf-0.1.schema.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
def load_schema() -> dict[str, Any]:
|
|
10
|
+
bundled = Path(__file__).resolve().parent / "schemas" / "ltf-0.1.schema.json"
|
|
11
|
+
repo = Path(__file__).resolve().parents[2] / "specs" / "ltf-0.1.schema.json"
|
|
12
|
+
path = bundled if bundled.exists() else repo
|
|
13
|
+
with path.open(encoding="utf-8") as handle:
|
|
14
|
+
return json.load(handle)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_trace(trace: dict[str, Any]) -> tuple[bool, list[str]]:
|
|
18
|
+
try:
|
|
19
|
+
import jsonschema
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise SystemExit("jsonschema required: pip install loopotel[dev]") from exc
|
|
22
|
+
|
|
23
|
+
schema = load_schema()
|
|
24
|
+
validator = jsonschema.Draft202012Validator(schema)
|
|
25
|
+
errors = sorted({f"{e.json_path}: {e.message}" for e in validator.iter_errors(trace)})
|
|
26
|
+
return len(errors) == 0, errors
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_file(path: Path) -> tuple[bool, list[str]]:
|
|
30
|
+
with path.open(encoding="utf-8") as handle:
|
|
31
|
+
trace = json.load(handle)
|
|
32
|
+
return validate_trace(trace)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loopotel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Loop Trace Format (LTF) instrumentation and exporters for loop observability
|
|
5
|
+
Project-URL: Homepage, https://github.com/KanakMalpani/loop-observability
|
|
6
|
+
Project-URL: Repository, https://github.com/KanakMalpani/loop-observability
|
|
7
|
+
Project-URL: Loop Core Engineering, https://github.com/KanakMalpani/Loop-Core-Engineering
|
|
8
|
+
Project-URL: LoopGym, https://github.com/KanakMalpani/LoopGym
|
|
9
|
+
Author: Kanak Malpani
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: les,loop-engineering,ltf,observability,opentelemetry
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: jsonschema>=4.21
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: loopgym>=0.1.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
22
|
+
Provides-Extra: loopgym
|
|
23
|
+
Requires-Dist: loopgym>=0.1.0; extra == 'loopgym'
|
|
24
|
+
Provides-Extra: otlp
|
|
25
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27; extra == 'otlp'
|
|
26
|
+
Requires-Dist: opentelemetry-sdk>=1.27; extra == 'otlp'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Loop Observability
|
|
30
|
+
|
|
31
|
+
**Loop Trace Format (LTF)** and OpenTelemetry conventions for production loop monitoring.
|
|
32
|
+
|
|
33
|
+
SREs need spans for iterations, evaluators, token burn, and LES deltas — not raw chat logs. This repo defines the format and ships `loopotel`, a minimal Python instrumentation library.
|
|
34
|
+
|
|
35
|
+
[](https://github.com/KanakMalpani/loop-observability/actions/workflows/test.yml)
|
|
36
|
+
[](https://pypi.org/project/loopotel/)
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install loopotel
|
|
42
|
+
pip install "loopotel[loopgym]" # LoopGym episode tracing
|
|
43
|
+
pip install "loopotel[otlp]" # OTLP export
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start — trace a LoopGym run
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import loopgym as lg
|
|
50
|
+
from loopotel.integrations.loopgym import run_traced_episode
|
|
51
|
+
from loopotel.exporter.jsonl import JsonlExporter
|
|
52
|
+
|
|
53
|
+
env = lg.make("loopbench/code-repair-v1")
|
|
54
|
+
result, trace = run_traced_episode(env, task_id="cr-001", seed=42, enabled=True)
|
|
55
|
+
|
|
56
|
+
JsonlExporter("traces.jsonl").export(trace)
|
|
57
|
+
print(result["success"], trace["trace_id"])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or run the example:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install loopgym loopotel
|
|
64
|
+
python examples/export_loopgym_ltf.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from loopotel import LoopTracer, emit_iteration, trace_loop
|
|
71
|
+
|
|
72
|
+
with LoopTracer(loop_name="my-loop", env_id="prod/agent") as tracer:
|
|
73
|
+
emit_iteration(iteration=1, goal_score=0.55, tokens_delta=120,
|
|
74
|
+
worker_id="implementer", evaluator_id="rubric")
|
|
75
|
+
tracer.finish(outcome="success", termination_reason="goal_met")
|
|
76
|
+
|
|
77
|
+
trace = tracer.build_trace() # ltf/0.1 document
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Specs
|
|
81
|
+
|
|
82
|
+
| Document | Purpose |
|
|
83
|
+
|----------|---------|
|
|
84
|
+
| [`specs/ltf-0.1.schema.json`](specs/ltf-0.1.schema.json) | LTF JSON schema |
|
|
85
|
+
| [`specs/otel-semconv-loop.md`](specs/otel-semconv-loop.md) | `loop.*` OTel attributes |
|
|
86
|
+
| [`specs/les-timeseries.md`](specs/les-timeseries.md) | Point-in-loop LES metrics |
|
|
87
|
+
|
|
88
|
+
## Grafana
|
|
89
|
+
|
|
90
|
+
Import [`examples/grafana-dashboard.json`](examples/grafana-dashboard.json) for iteration vs goal score, cumulative LES, and token burn panels (sample data included).
|
|
91
|
+
|
|
92
|
+
## Validate
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
loopotel-validate examples/sample-trace.jsonl
|
|
96
|
+
python scripts/validate_ltf.py path/to/trace.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Design
|
|
100
|
+
|
|
101
|
+
- **Minimal overhead** — tracing off by default; pass `enabled=True` for SimEnv, use `trace_live_episode()` for LiveEnv
|
|
102
|
+
- **Exporters** — JSONL (built-in), OTLP (optional), LoopNet trajectory mapping
|
|
103
|
+
- **Pins** — `lss@1.0.0`, `les@1.0.0`, `ltf@0.1.0`
|
|
104
|
+
|
|
105
|
+
## Links
|
|
106
|
+
|
|
107
|
+
- [LoopNet end-to-end tutorial](https://github.com/KanakMalpani/loopnet/blob/main/guides/END-TO-END-TUTORIAL.md) — HF → replay → LoopBench
|
|
108
|
+
- [Loop Core Engineering](https://github.com/KanakMalpani/Loop-Core-Engineering) — LES / LSS
|
|
109
|
+
- [LoopGym](https://github.com/KanakMalpani/LoopGym) — instrumentation target
|
|
110
|
+
- [LoopNet](https://github.com/KanakMalpani/loopnet) — trajectory corpus export
|
|
111
|
+
- [Publishing](PUBLISHING.md) · [PyPI](https://pypi.org/project/loopotel/)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
loopotel/__init__.py,sha256=9gYQAUK1IZPf8lOTivqWkC6Dy_ORi3I_EjJrpZm0cJE,258
|
|
2
|
+
loopotel/cli.py,sha256=U7EFR43OZC6CR5BLx-DPlBkxZDe5WXFh5ql6ulJwPpE,1232
|
|
3
|
+
loopotel/les.py,sha256=dszwqZRczUehACvmXUNFK-cx9_E6bnK0TeTSREmkI6A,560
|
|
4
|
+
loopotel/models.py,sha256=b2v2KlgpK5SUV5hBOZmK0Fkrk2PpZYdnTFyBHMLnLTA,2014
|
|
5
|
+
loopotel/tracer.py,sha256=JKxVV9odJ1NOoV44B5ypBJdXMds8ORG7TBmVSnz8N90,7862
|
|
6
|
+
loopotel/validate.py,sha256=og2kofmzMcgFABxIp7kNaidFKewd6F1XOV_Uz-7KQOI,1092
|
|
7
|
+
loopotel/exporter/__init__.py,sha256=O-WCrPSQmxX4WgoHcGW7brJUm_dIS47s8Oj0IUyWrJ4,188
|
|
8
|
+
loopotel/exporter/jsonl.py,sha256=7DczOf7LG9y7RICUyenLfO7HnbKQUV1-ennTYliCnQo,632
|
|
9
|
+
loopotel/exporter/loopnet.py,sha256=DFQ85wCLXF62vEzEbeusyByzfov4MJV3xggLmv7dMzA,1290
|
|
10
|
+
loopotel/exporter/otlp.py,sha256=bN00TdKgHOvR3jlis_B_1wVwn7FwZ8rWP7xbVfyqPM0,2367
|
|
11
|
+
loopotel/integrations/__init__.py,sha256=fZk5P9F8WBVVHVglKzVHk6XY2i9HN7L7hdlVulsk4FU,170
|
|
12
|
+
loopotel/integrations/loopgym.py,sha256=3sv5VZgpG0vwQwVxItZqZfWvAjm1s6ha7Odb7mrwbzQ,3730
|
|
13
|
+
loopotel/schemas/ltf-0.1.schema.json,sha256=_z1Lz2IrVTRO-i-sEDGM3LZTvAd_Hgm9Uq0JH91ZiwM,3026
|
|
14
|
+
loopotel-0.1.0.dist-info/METADATA,sha256=VONwjqQAYqkRvJ2hWliAz-Y7ArBk-DuYHsKGviTrKyU,4178
|
|
15
|
+
loopotel-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
loopotel-0.1.0.dist-info/entry_points.txt,sha256=5ulPBAoYxBQi-fZ_4aeVTOvI4g1P3VWroct2OK5K21k,56
|
|
17
|
+
loopotel-0.1.0.dist-info/licenses/LICENSE,sha256=evRYU4i8S6LPZ42e9jNkROkb-chKgbu-HyltWnYncvk,1069
|
|
18
|
+
loopotel-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KanakMalpani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|