loopengt 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.
- loopengt/__init__.py +31 -0
- loopengt/adapters/__init__.py +1 -0
- loopengt/adapters/antigravity/__init__.py +1 -0
- loopengt/adapters/antigravity/adapter.py +55 -0
- loopengt/adapters/antigravity/commands.py +21 -0
- loopengt/adapters/base.py +51 -0
- loopengt/adapters/claude_code/__init__.py +1 -0
- loopengt/adapters/claude_code/adapter.py +55 -0
- loopengt/adapters/claude_code/commands.py +16 -0
- loopengt/adapters/codex/__init__.py +1 -0
- loopengt/adapters/codex/adapter.py +52 -0
- loopengt/adapters/codex/commands.py +16 -0
- loopengt/adapters/cursor/__init__.py +1 -0
- loopengt/adapters/cursor/adapter.py +56 -0
- loopengt/adapters/cursor/commands.py +29 -0
- loopengt/adapters/generic/__init__.py +1 -0
- loopengt/adapters/generic/terminal.py +82 -0
- loopengt/cli/__init__.py +1 -0
- loopengt/cli/commands/__init__.py +1 -0
- loopengt/cli/commands/design.py +171 -0
- loopengt/cli/commands/doctor.py +110 -0
- loopengt/cli/commands/eval.py +105 -0
- loopengt/cli/commands/init.py +131 -0
- loopengt/cli/commands/mcp_serve.py +57 -0
- loopengt/cli/commands/run.py +99 -0
- loopengt/cli/commands/template.py +145 -0
- loopengt/cli/commands/trace.py +114 -0
- loopengt/cli/formatters.py +125 -0
- loopengt/cli/main.py +66 -0
- loopengt/core/__init__.py +1 -0
- loopengt/core/evals/__init__.py +1 -0
- loopengt/core/evals/judges.py +216 -0
- loopengt/core/evals/metrics.py +119 -0
- loopengt/core/evals/regression.py +157 -0
- loopengt/core/memory/__init__.py +1 -0
- loopengt/core/memory/retrieval.py +124 -0
- loopengt/core/memory/store.py +184 -0
- loopengt/core/memory/summarizer.py +97 -0
- loopengt/core/models/__init__.py +43 -0
- loopengt/core/models/agent.py +126 -0
- loopengt/core/models/loop_spec.py +251 -0
- loopengt/core/models/policy.py +131 -0
- loopengt/core/models/state.py +271 -0
- loopengt/core/models/tool.py +105 -0
- loopengt/core/runtime/__init__.py +1 -0
- loopengt/core/runtime/checkpoint.py +152 -0
- loopengt/core/runtime/executor.py +463 -0
- loopengt/core/runtime/handoff.py +139 -0
- loopengt/core/runtime/scheduler.py +168 -0
- loopengt/core/tracing/__init__.py +1 -0
- loopengt/core/tracing/events.py +95 -0
- loopengt/core/tracing/exporters.py +158 -0
- loopengt/core/tracing/store.py +202 -0
- loopengt/mcp/__init__.py +1 -0
- loopengt/mcp/client/__init__.py +1 -0
- loopengt/mcp/client/manager.py +118 -0
- loopengt/mcp/client/tools.py +107 -0
- loopengt/mcp/server/__init__.py +1 -0
- loopengt/mcp/server/prompts.py +82 -0
- loopengt/mcp/server/resources.py +75 -0
- loopengt/mcp/server/server.py +50 -0
- loopengt/mcp/server/tools.py +214 -0
- loopengt/mcp/shared/__init__.py +1 -0
- loopengt/mcp/shared/schemas.py +91 -0
- loopengt/plugins/__init__.py +1 -0
- loopengt/plugins/base.py +90 -0
- loopengt/plugins/loader.py +130 -0
- loopengt/plugins/manifest.py +70 -0
- loopengt/plugins/registry.py +146 -0
- loopengt/prompts/LOOPENGT.md +60 -0
- loopengt/prompts/__init__.py +1 -0
- loopengt/storage/__init__.py +1 -0
- loopengt/storage/jsonl.py +84 -0
- loopengt/storage/sqlite.py +102 -0
- loopengt/templates/__init__.py +1 -0
- loopengt/templates/builtins/handoff_loop/LOOPENGS.md +10 -0
- loopengt/templates/builtins/planner_executor/LOOPENGS.md +29 -0
- loopengt/templates/builtins/research_architect/LOOPENGS.md +17 -0
- loopengt/templates/builtins/reviewer_retry/LOOPENGS.md +29 -0
- loopengt/templates/builtins/supervisor_workers/LOOPENGS.md +29 -0
- loopengt/templates/loader.py +38 -0
- loopengt/templates/registry.py +85 -0
- loopengt-0.1.0.dist-info/METADATA +275 -0
- loopengt-0.1.0.dist-info/RECORD +87 -0
- loopengt-0.1.0.dist-info/WHEEL +4 -0
- loopengt-0.1.0.dist-info/entry_points.txt +8 -0
- loopengt-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Step scheduling and concurrency management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from loopengt.core.models.loop_spec import LoopSpec, StepSpec
|
|
12
|
+
from loopengt.core.models.state import LoopState, StepStatus
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchedulerError(Exception):
|
|
18
|
+
"""Raised when the scheduler cannot resolve a valid execution order."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StepScheduler:
|
|
22
|
+
"""Manages step ordering, dependency resolution, and concurrency.
|
|
23
|
+
|
|
24
|
+
Given a ``LoopSpec``, the scheduler computes the execution order
|
|
25
|
+
respecting step dependencies and concurrency limits.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
scheduler = StepScheduler(spec, state)
|
|
30
|
+
for batch in scheduler.batches():
|
|
31
|
+
# execute each batch (possibly in parallel)
|
|
32
|
+
...
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, spec: LoopSpec, state: LoopState) -> None:
|
|
36
|
+
self._spec = spec
|
|
37
|
+
self._state = state
|
|
38
|
+
self._log = logger.bind(loop=spec.name)
|
|
39
|
+
self._dep_graph = self._build_dependency_graph()
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Public API
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def batches(self) -> list[list[StepSpec]]:
|
|
46
|
+
"""Compute execution batches using topological sort.
|
|
47
|
+
|
|
48
|
+
Returns a list of batches, where each batch contains steps that
|
|
49
|
+
can execute concurrently (all their dependencies are satisfied).
|
|
50
|
+
"""
|
|
51
|
+
resolved: set[str] = set()
|
|
52
|
+
remaining = {s.name for s in self._spec.steps}
|
|
53
|
+
batches: list[list[StepSpec]] = []
|
|
54
|
+
|
|
55
|
+
while remaining:
|
|
56
|
+
# Find steps whose dependencies are all resolved
|
|
57
|
+
ready = [
|
|
58
|
+
name
|
|
59
|
+
for name in remaining
|
|
60
|
+
if all(dep in resolved for dep in self._dep_graph[name])
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if not ready:
|
|
64
|
+
raise SchedulerError(
|
|
65
|
+
f"Circular dependency detected among steps: {remaining}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
batch = [
|
|
69
|
+
s for s in self._spec.steps if s.name in ready
|
|
70
|
+
]
|
|
71
|
+
batches.append(batch)
|
|
72
|
+
resolved.update(ready)
|
|
73
|
+
remaining -= set(ready)
|
|
74
|
+
|
|
75
|
+
return batches
|
|
76
|
+
|
|
77
|
+
def next_ready_steps(self) -> list[StepSpec]:
|
|
78
|
+
"""Return steps that are ready to execute now.
|
|
79
|
+
|
|
80
|
+
A step is ready when:
|
|
81
|
+
1. Its status is PENDING.
|
|
82
|
+
2. All its dependencies have status COMPLETED.
|
|
83
|
+
"""
|
|
84
|
+
ready = []
|
|
85
|
+
for step in self._spec.steps:
|
|
86
|
+
step_state = self._state.step_states.get(step.name)
|
|
87
|
+
if step_state is None or step_state.status != StepStatus.PENDING:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
deps_met = all(
|
|
91
|
+
self._state.step_states.get(dep, None) is not None
|
|
92
|
+
and self._state.step_states[dep].status == StepStatus.COMPLETED
|
|
93
|
+
for dep in self._dep_graph[step.name]
|
|
94
|
+
)
|
|
95
|
+
if deps_met:
|
|
96
|
+
ready.append(step)
|
|
97
|
+
|
|
98
|
+
return ready
|
|
99
|
+
|
|
100
|
+
def is_complete(self) -> bool:
|
|
101
|
+
"""Return True when all steps have a terminal status."""
|
|
102
|
+
terminal = {StepStatus.COMPLETED, StepStatus.FAILED, StepStatus.SKIPPED}
|
|
103
|
+
return all(
|
|
104
|
+
self._state.step_states.get(s.name, None) is not None
|
|
105
|
+
and self._state.step_states[s.name].status in terminal
|
|
106
|
+
for s in self._spec.steps
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Parallel execution helper
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async def run_parallel(
|
|
114
|
+
self,
|
|
115
|
+
step_fn: Any,
|
|
116
|
+
*,
|
|
117
|
+
max_concurrency: int | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Execute steps respecting dependencies with concurrency control.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
step_fn:
|
|
124
|
+
An ``async def step_fn(step: StepSpec) -> None`` coroutine
|
|
125
|
+
that executes a single step.
|
|
126
|
+
max_concurrency:
|
|
127
|
+
Maximum number of concurrent steps. Defaults to the
|
|
128
|
+
policy's ``max_concurrency``.
|
|
129
|
+
"""
|
|
130
|
+
concurrency = max_concurrency or self._spec.policy.max_concurrency
|
|
131
|
+
sem = anyio.Semaphore(concurrency)
|
|
132
|
+
|
|
133
|
+
for batch in self.batches():
|
|
134
|
+
self._log.info("scheduler.batch", steps=[s.name for s in batch])
|
|
135
|
+
|
|
136
|
+
async with anyio.create_task_group() as tg:
|
|
137
|
+
for step in batch:
|
|
138
|
+
async def _run(s: StepSpec = step) -> None:
|
|
139
|
+
async with sem:
|
|
140
|
+
await step_fn(s)
|
|
141
|
+
|
|
142
|
+
tg.start_soon(_run)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Internals
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _build_dependency_graph(self) -> dict[str, set[str]]:
|
|
149
|
+
"""Build a mapping of step name → set of dependency step names."""
|
|
150
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
151
|
+
valid_names = {s.name for s in self._spec.steps}
|
|
152
|
+
|
|
153
|
+
for step in self._spec.steps:
|
|
154
|
+
for dep in step.dependencies:
|
|
155
|
+
if dep.step_name not in valid_names:
|
|
156
|
+
self._log.warning(
|
|
157
|
+
"scheduler.unknown_dependency",
|
|
158
|
+
step=step.name,
|
|
159
|
+
dependency=dep.step_name,
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
graph[step.name].add(dep.step_name)
|
|
163
|
+
|
|
164
|
+
# Ensure every step has an entry
|
|
165
|
+
if step.name not in graph:
|
|
166
|
+
graph[step.name] = set()
|
|
167
|
+
|
|
168
|
+
return dict(graph)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tracing subsystem for loop execution observability."""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Event definitions for the tracing subsystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _utc_now() -> datetime:
|
|
14
|
+
return datetime.now(timezone.utc)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _event_id() -> str:
|
|
18
|
+
return uuid.uuid4().hex[:16]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventType(StrEnum):
|
|
22
|
+
"""Classification of trace events."""
|
|
23
|
+
|
|
24
|
+
LOOP_START = "loop.start"
|
|
25
|
+
LOOP_COMPLETE = "loop.complete"
|
|
26
|
+
LOOP_ERROR = "loop.error"
|
|
27
|
+
STEP_START = "step.start"
|
|
28
|
+
STEP_COMPLETE = "step.complete"
|
|
29
|
+
STEP_ERROR = "step.error"
|
|
30
|
+
STEP_RETRY = "step.retry"
|
|
31
|
+
AGENT_INVOKE = "agent.invoke"
|
|
32
|
+
AGENT_RESPONSE = "agent.response"
|
|
33
|
+
AGENT_ERROR = "agent.error"
|
|
34
|
+
TOOL_CALL = "tool.call"
|
|
35
|
+
TOOL_RESULT = "tool.result"
|
|
36
|
+
TOOL_ERROR = "tool.error"
|
|
37
|
+
CHECKPOINT_SAVE = "checkpoint.save"
|
|
38
|
+
CHECKPOINT_LOAD = "checkpoint.load"
|
|
39
|
+
HANDOFF = "handoff"
|
|
40
|
+
CUSTOM = "custom"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TraceEvent(BaseModel):
|
|
44
|
+
"""Base trace event emitted during loop execution.
|
|
45
|
+
|
|
46
|
+
Every event captures a timestamp, the run/step/agent context,
|
|
47
|
+
and an arbitrary data payload.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(frozen=True)
|
|
51
|
+
|
|
52
|
+
event_id: str = Field(default_factory=_event_id, description="Unique event ID")
|
|
53
|
+
event_type: EventType = Field(..., description="Event classification")
|
|
54
|
+
timestamp: datetime = Field(default_factory=_utc_now, description="When the event occurred")
|
|
55
|
+
run_id: str = Field(default="", description="Parent run ID")
|
|
56
|
+
step_name: str | None = Field(default=None, description="Step that emitted the event")
|
|
57
|
+
agent_name: str | None = Field(default=None, description="Agent involved")
|
|
58
|
+
tool_name: str | None = Field(default=None, description="Tool involved")
|
|
59
|
+
turn: int | None = Field(default=None, description="Turn number")
|
|
60
|
+
data: dict[str, Any] = Field(default_factory=dict, description="Event payload")
|
|
61
|
+
duration_seconds: float | None = Field(default=None, description="Duration if timed")
|
|
62
|
+
parent_event_id: str | None = Field(default=None, description="Parent event for nesting")
|
|
63
|
+
metadata: dict[str, Any] = Field(default_factory=dict, description="Arbitrary metadata")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoopEvent(TraceEvent):
|
|
67
|
+
"""Event specific to loop lifecycle (start, complete, error)."""
|
|
68
|
+
|
|
69
|
+
loop_name: str = Field(default="", description="Name of the loop")
|
|
70
|
+
pattern: str = Field(default="", description="Orchestration pattern")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StepEvent(TraceEvent):
|
|
74
|
+
"""Event specific to step execution."""
|
|
75
|
+
|
|
76
|
+
attempt: int = Field(default=1, ge=1, description="Attempt number")
|
|
77
|
+
output: Any = Field(default=None, description="Step output (on completion)")
|
|
78
|
+
error: str | None = Field(default=None, description="Error message (on failure)")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AgentEvent(TraceEvent):
|
|
82
|
+
"""Event specific to agent invocations."""
|
|
83
|
+
|
|
84
|
+
prompt_tokens: int = Field(default=0, ge=0, description="Prompt token count")
|
|
85
|
+
completion_tokens: int = Field(default=0, ge=0, description="Completion token count")
|
|
86
|
+
model: str = Field(default="", description="Model used")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ToolEvent(TraceEvent):
|
|
90
|
+
"""Event specific to tool calls and results."""
|
|
91
|
+
|
|
92
|
+
tool_type: str = Field(default="", description="Tool type (mcp/function/builtin)")
|
|
93
|
+
input_data: dict[str, Any] = Field(default_factory=dict, description="Tool input")
|
|
94
|
+
output_data: Any = Field(default=None, description="Tool output")
|
|
95
|
+
error: str | None = Field(default=None, description="Error if tool call failed")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Trace exporters — OTel spans, JSON, markdown reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from loopengt.core.tracing.events import TraceEvent
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class TraceExporter(Protocol):
|
|
18
|
+
"""Protocol for trace exporters."""
|
|
19
|
+
|
|
20
|
+
async def export(self, events: list[TraceEvent], dest: Path | None = None) -> str: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JSONExporter:
|
|
24
|
+
"""Export trace events as a single JSON file."""
|
|
25
|
+
|
|
26
|
+
async def export(
|
|
27
|
+
self, events: list[TraceEvent], dest: Path | None = None
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Export events to JSON. Returns the JSON string."""
|
|
30
|
+
data = [e.model_dump(mode="json") for e in events]
|
|
31
|
+
output = json.dumps(data, indent=2, default=str)
|
|
32
|
+
|
|
33
|
+
if dest:
|
|
34
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
dest.write_text(output, encoding="utf-8")
|
|
36
|
+
logger.info("export.json", path=str(dest), events=len(events))
|
|
37
|
+
|
|
38
|
+
return output
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MarkdownExporter:
|
|
42
|
+
"""Export trace events as a human-readable markdown report."""
|
|
43
|
+
|
|
44
|
+
async def export(
|
|
45
|
+
self, events: list[TraceEvent], dest: Path | None = None
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Export events to markdown. Returns the markdown string."""
|
|
48
|
+
if not events:
|
|
49
|
+
return "# Trace Report\n\nNo events recorded.\n"
|
|
50
|
+
|
|
51
|
+
run_id = events[0].run_id if events else "unknown"
|
|
52
|
+
lines = [
|
|
53
|
+
f"# Trace Report — Run `{run_id}`\n",
|
|
54
|
+
f"**Events**: {len(events)}\n",
|
|
55
|
+
"",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Group by step
|
|
59
|
+
by_step: dict[str | None, list[TraceEvent]] = {}
|
|
60
|
+
for e in events:
|
|
61
|
+
by_step.setdefault(e.step_name, []).append(e)
|
|
62
|
+
|
|
63
|
+
for step_name, step_events in by_step.items():
|
|
64
|
+
heading = step_name or "Loop-level"
|
|
65
|
+
lines.append(f"## {heading}\n")
|
|
66
|
+
|
|
67
|
+
for e in step_events:
|
|
68
|
+
ts = e.timestamp.strftime("%H:%M:%S.%f")[:-3]
|
|
69
|
+
dur = f" ({e.duration_seconds:.2f}s)" if e.duration_seconds else ""
|
|
70
|
+
lines.append(f"- `{ts}` **{e.event_type}**{dur}")
|
|
71
|
+
if e.data:
|
|
72
|
+
for k, v in e.data.items():
|
|
73
|
+
lines.append(f" - {k}: `{v}`")
|
|
74
|
+
lines.append("")
|
|
75
|
+
|
|
76
|
+
output = "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
if dest:
|
|
79
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
dest.write_text(output, encoding="utf-8")
|
|
81
|
+
logger.info("export.markdown", path=str(dest))
|
|
82
|
+
|
|
83
|
+
return output
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OTelExporter:
|
|
87
|
+
"""Export trace events as OpenTelemetry spans.
|
|
88
|
+
|
|
89
|
+
Requires the ``opentelemetry-sdk`` package. If not installed,
|
|
90
|
+
the exporter logs a warning and returns empty output.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
async def export(
|
|
94
|
+
self, events: list[TraceEvent], dest: Path | None = None
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Export events as OTel spans.
|
|
97
|
+
|
|
98
|
+
When *dest* is provided, exports spans to a JSON file in
|
|
99
|
+
OTLP-compatible format. Otherwise, sends to the configured
|
|
100
|
+
OTel collector.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
return await self._export_otel(events, dest)
|
|
104
|
+
except ImportError:
|
|
105
|
+
logger.warning(
|
|
106
|
+
"export.otel.not_installed",
|
|
107
|
+
hint="pip install opentelemetry-sdk opentelemetry-exporter-otlp",
|
|
108
|
+
)
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
async def _export_otel(
|
|
112
|
+
self, events: list[TraceEvent], dest: Path | None
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Internal OTel export — separated for import isolation."""
|
|
115
|
+
from opentelemetry import trace as otel_trace
|
|
116
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
117
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
118
|
+
from opentelemetry.sdk.trace.export.in_memory import InMemorySpanExporter
|
|
119
|
+
|
|
120
|
+
provider = TracerProvider()
|
|
121
|
+
exporter = InMemorySpanExporter()
|
|
122
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
123
|
+
tracer = provider.get_tracer("loopengt")
|
|
124
|
+
|
|
125
|
+
for event in events:
|
|
126
|
+
with tracer.start_as_current_span(
|
|
127
|
+
name=event.event_type,
|
|
128
|
+
attributes={
|
|
129
|
+
"run_id": event.run_id,
|
|
130
|
+
"step_name": event.step_name or "",
|
|
131
|
+
"agent_name": event.agent_name or "",
|
|
132
|
+
"tool_name": event.tool_name or "",
|
|
133
|
+
},
|
|
134
|
+
):
|
|
135
|
+
pass # Span auto-completes
|
|
136
|
+
|
|
137
|
+
spans = exporter.get_finished_spans()
|
|
138
|
+
span_data = [
|
|
139
|
+
{
|
|
140
|
+
"name": s.name,
|
|
141
|
+
"trace_id": format(s.context.trace_id, "032x"),
|
|
142
|
+
"span_id": format(s.context.span_id, "016x"),
|
|
143
|
+
"start_time": s.start_time,
|
|
144
|
+
"end_time": s.end_time,
|
|
145
|
+
"attributes": dict(s.attributes) if s.attributes else {},
|
|
146
|
+
}
|
|
147
|
+
for s in spans
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
output = json.dumps(span_data, indent=2, default=str)
|
|
151
|
+
|
|
152
|
+
if dest:
|
|
153
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
dest.write_text(output, encoding="utf-8")
|
|
155
|
+
logger.info("export.otel", path=str(dest), spans=len(spans))
|
|
156
|
+
|
|
157
|
+
provider.shutdown()
|
|
158
|
+
return output
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Trace storage backends — SQLite and JSONL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from loopengt.core.tracing.events import TraceEvent
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class TraceBackend(Protocol):
|
|
18
|
+
"""Protocol for trace storage backends."""
|
|
19
|
+
|
|
20
|
+
async def write(self, event: TraceEvent) -> None: ...
|
|
21
|
+
async def read_run(self, run_id: str) -> list[TraceEvent]: ...
|
|
22
|
+
async def list_runs(self) -> list[str]: ...
|
|
23
|
+
async def close(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JSONLTraceStore:
|
|
27
|
+
"""JSONL-file-based trace storage.
|
|
28
|
+
|
|
29
|
+
Each run is stored in a separate ``.jsonl`` file where every line is
|
|
30
|
+
a JSON-serialised ``TraceEvent``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, directory: Path | None = None) -> None:
|
|
34
|
+
self._dir = directory or Path(".loopengt/runs")
|
|
35
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self._handles: dict[str, Any] = {}
|
|
37
|
+
self._log = logger.bind(backend="jsonl")
|
|
38
|
+
|
|
39
|
+
async def write(self, event: TraceEvent) -> None:
|
|
40
|
+
"""Append an event to the run's JSONL file."""
|
|
41
|
+
path = self._dir / f"{event.run_id}.trace.jsonl"
|
|
42
|
+
line = event.model_dump_json() + "\n"
|
|
43
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
44
|
+
f.write(line)
|
|
45
|
+
|
|
46
|
+
async def read_run(self, run_id: str) -> list[TraceEvent]:
|
|
47
|
+
"""Read all events for a run."""
|
|
48
|
+
path = self._dir / f"{run_id}.trace.jsonl"
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
events: list[TraceEvent] = []
|
|
53
|
+
with open(path, encoding="utf-8") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if line:
|
|
57
|
+
data = json.loads(line)
|
|
58
|
+
events.append(TraceEvent.model_validate(data))
|
|
59
|
+
return events
|
|
60
|
+
|
|
61
|
+
async def list_runs(self) -> list[str]:
|
|
62
|
+
"""List all run IDs with traces."""
|
|
63
|
+
return sorted(
|
|
64
|
+
p.stem.replace(".trace", "")
|
|
65
|
+
for p in self._dir.glob("*.trace.jsonl")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def close(self) -> None:
|
|
69
|
+
"""Close all open file handles."""
|
|
70
|
+
self._handles.clear()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SQLiteTraceStore:
|
|
74
|
+
"""SQLite-backed trace storage.
|
|
75
|
+
|
|
76
|
+
Uses aiosqlite for async access. Events are stored in a single
|
|
77
|
+
``events`` table with JSON-serialised data columns.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
81
|
+
self._db_path = db_path or Path(".loopengt/traces.db")
|
|
82
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self._db: Any = None
|
|
84
|
+
self._log = logger.bind(backend="sqlite")
|
|
85
|
+
|
|
86
|
+
async def _ensure_db(self) -> Any:
|
|
87
|
+
"""Lazily open and initialise the database."""
|
|
88
|
+
if self._db is None:
|
|
89
|
+
import aiosqlite
|
|
90
|
+
|
|
91
|
+
self._db = await aiosqlite.connect(str(self._db_path))
|
|
92
|
+
await self._db.execute(
|
|
93
|
+
"""
|
|
94
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
95
|
+
event_id TEXT PRIMARY KEY,
|
|
96
|
+
run_id TEXT NOT NULL,
|
|
97
|
+
event_type TEXT NOT NULL,
|
|
98
|
+
timestamp TEXT NOT NULL,
|
|
99
|
+
step_name TEXT,
|
|
100
|
+
agent_name TEXT,
|
|
101
|
+
tool_name TEXT,
|
|
102
|
+
turn INTEGER,
|
|
103
|
+
data TEXT NOT NULL,
|
|
104
|
+
duration_seconds REAL,
|
|
105
|
+
parent_event_id TEXT,
|
|
106
|
+
metadata TEXT
|
|
107
|
+
)
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
110
|
+
await self._db.execute(
|
|
111
|
+
"CREATE INDEX IF NOT EXISTS idx_events_run_id ON events(run_id)"
|
|
112
|
+
)
|
|
113
|
+
await self._db.commit()
|
|
114
|
+
return self._db
|
|
115
|
+
|
|
116
|
+
async def write(self, event: TraceEvent) -> None:
|
|
117
|
+
"""Insert an event into the database."""
|
|
118
|
+
db = await self._ensure_db()
|
|
119
|
+
await db.execute(
|
|
120
|
+
"""
|
|
121
|
+
INSERT OR REPLACE INTO events
|
|
122
|
+
(event_id, run_id, event_type, timestamp, step_name, agent_name,
|
|
123
|
+
tool_name, turn, data, duration_seconds, parent_event_id, metadata)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
125
|
+
""",
|
|
126
|
+
(
|
|
127
|
+
event.event_id,
|
|
128
|
+
event.run_id,
|
|
129
|
+
event.event_type,
|
|
130
|
+
event.timestamp.isoformat(),
|
|
131
|
+
event.step_name,
|
|
132
|
+
event.agent_name,
|
|
133
|
+
event.tool_name,
|
|
134
|
+
event.turn,
|
|
135
|
+
json.dumps(event.data, default=str),
|
|
136
|
+
event.duration_seconds,
|
|
137
|
+
event.parent_event_id,
|
|
138
|
+
json.dumps(event.metadata, default=str),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
await db.commit()
|
|
142
|
+
|
|
143
|
+
async def read_run(self, run_id: str) -> list[TraceEvent]:
|
|
144
|
+
"""Read all events for a run, ordered by timestamp."""
|
|
145
|
+
db = await self._ensure_db()
|
|
146
|
+
cursor = await db.execute(
|
|
147
|
+
"SELECT * FROM events WHERE run_id = ? ORDER BY timestamp",
|
|
148
|
+
(run_id,),
|
|
149
|
+
)
|
|
150
|
+
rows = await cursor.fetchall()
|
|
151
|
+
columns = [desc[0] for desc in cursor.description]
|
|
152
|
+
|
|
153
|
+
events = []
|
|
154
|
+
for row in rows:
|
|
155
|
+
row_dict = dict(zip(columns, row))
|
|
156
|
+
row_dict["data"] = json.loads(row_dict.get("data", "{}"))
|
|
157
|
+
row_dict["metadata"] = json.loads(row_dict.get("metadata", "{}"))
|
|
158
|
+
events.append(TraceEvent.model_validate(row_dict))
|
|
159
|
+
return events
|
|
160
|
+
|
|
161
|
+
async def list_runs(self) -> list[str]:
|
|
162
|
+
"""List all distinct run IDs."""
|
|
163
|
+
db = await self._ensure_db()
|
|
164
|
+
cursor = await db.execute("SELECT DISTINCT run_id FROM events ORDER BY run_id")
|
|
165
|
+
rows = await cursor.fetchall()
|
|
166
|
+
return [row[0] for row in rows]
|
|
167
|
+
|
|
168
|
+
async def close(self) -> None:
|
|
169
|
+
"""Close the database connection."""
|
|
170
|
+
if self._db:
|
|
171
|
+
await self._db.close()
|
|
172
|
+
self._db = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TraceStore:
|
|
176
|
+
"""Unified trace store that delegates to a configured backend.
|
|
177
|
+
|
|
178
|
+
Defaults to JSONL. Switch to SQLite by passing ``backend="sqlite"``.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
backend: str = "jsonl",
|
|
184
|
+
directory: Path | None = None,
|
|
185
|
+
db_path: Path | None = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
if backend == "sqlite":
|
|
188
|
+
self._backend: TraceBackend = SQLiteTraceStore(db_path=db_path)
|
|
189
|
+
else:
|
|
190
|
+
self._backend = JSONLTraceStore(directory=directory)
|
|
191
|
+
|
|
192
|
+
async def write(self, event: TraceEvent) -> None:
|
|
193
|
+
await self._backend.write(event)
|
|
194
|
+
|
|
195
|
+
async def read_run(self, run_id: str) -> list[TraceEvent]:
|
|
196
|
+
return await self._backend.read_run(run_id)
|
|
197
|
+
|
|
198
|
+
async def list_runs(self) -> list[str]:
|
|
199
|
+
return await self._backend.list_runs()
|
|
200
|
+
|
|
201
|
+
async def close(self) -> None:
|
|
202
|
+
await self._backend.close()
|
loopengt/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) integration layer."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP client subpackage."""
|