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,157 @@
|
|
|
1
|
+
"""Regression test harness for loop specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
from loopengt.core.evals.metrics import MetricResult, evaluate_all
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RegressionResult:
|
|
18
|
+
"""Result of a regression test run."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("spec_name", "baseline_metrics", "current_metrics", "regressions")
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
spec_name: str,
|
|
25
|
+
baseline_metrics: list[MetricResult],
|
|
26
|
+
current_metrics: list[MetricResult],
|
|
27
|
+
regressions: list[str],
|
|
28
|
+
) -> None:
|
|
29
|
+
self.spec_name = spec_name
|
|
30
|
+
self.baseline_metrics = baseline_metrics
|
|
31
|
+
self.current_metrics = current_metrics
|
|
32
|
+
self.regressions = regressions
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def passed(self) -> bool:
|
|
36
|
+
return len(self.regressions) == 0
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"spec_name": self.spec_name,
|
|
41
|
+
"passed": self.passed,
|
|
42
|
+
"regressions": self.regressions,
|
|
43
|
+
"baseline": [m.to_dict() for m in self.baseline_metrics],
|
|
44
|
+
"current": [m.to_dict() for m in self.current_metrics],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RegressionHarness:
|
|
49
|
+
"""Runs loop specs and compares against baseline metrics.
|
|
50
|
+
|
|
51
|
+
Usage::
|
|
52
|
+
|
|
53
|
+
harness = RegressionHarness(baselines_dir=Path(".loopengt/baselines"))
|
|
54
|
+
result = await harness.run(spec_path, baseline_id="v1.0")
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, baselines_dir: Path | None = None) -> None:
|
|
58
|
+
self._baselines_dir = baselines_dir or Path(".loopengt/baselines")
|
|
59
|
+
self._baselines_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._log = logger.bind(component="regression")
|
|
61
|
+
|
|
62
|
+
async def run(
|
|
63
|
+
self,
|
|
64
|
+
spec_path: Path,
|
|
65
|
+
baseline_id: str | None = None,
|
|
66
|
+
) -> RegressionResult:
|
|
67
|
+
"""Execute a spec and compare with baseline metrics."""
|
|
68
|
+
import yaml
|
|
69
|
+
|
|
70
|
+
from loopengt.core.models.loop_spec import LoopSpec
|
|
71
|
+
from loopengt.core.runtime.executor import LoopExecutor
|
|
72
|
+
|
|
73
|
+
# Load and run the spec
|
|
74
|
+
raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
|
75
|
+
spec = LoopSpec.model_validate(raw)
|
|
76
|
+
executor = LoopExecutor(spec)
|
|
77
|
+
state = await executor.execute()
|
|
78
|
+
|
|
79
|
+
# Evaluate current run
|
|
80
|
+
trace = state.model_dump(mode="json")
|
|
81
|
+
current_metrics = evaluate_all(trace)
|
|
82
|
+
|
|
83
|
+
# Load baseline
|
|
84
|
+
baseline_metrics = self._load_baseline(spec.name, baseline_id)
|
|
85
|
+
|
|
86
|
+
# Compare
|
|
87
|
+
regressions = self._compare(baseline_metrics, current_metrics)
|
|
88
|
+
|
|
89
|
+
result = RegressionResult(
|
|
90
|
+
spec_name=spec.name,
|
|
91
|
+
baseline_metrics=baseline_metrics,
|
|
92
|
+
current_metrics=current_metrics,
|
|
93
|
+
regressions=regressions,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self._log.info(
|
|
97
|
+
"regression.complete",
|
|
98
|
+
spec=spec.name,
|
|
99
|
+
passed=result.passed,
|
|
100
|
+
regressions=len(regressions),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
async def save_baseline(
|
|
106
|
+
self, spec_name: str, metrics: list[MetricResult], baseline_id: str = "latest"
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Save metrics as a baseline for future comparison."""
|
|
109
|
+
path = self._baselines_dir / f"{spec_name}_{baseline_id}.json"
|
|
110
|
+
data = [m.to_dict() for m in metrics]
|
|
111
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
112
|
+
self._log.info("regression.baseline_saved", spec=spec_name, id=baseline_id)
|
|
113
|
+
|
|
114
|
+
def _load_baseline(
|
|
115
|
+
self, spec_name: str, baseline_id: str | None
|
|
116
|
+
) -> list[MetricResult]:
|
|
117
|
+
"""Load baseline metrics. Returns empty list if none found."""
|
|
118
|
+
bid = baseline_id or "latest"
|
|
119
|
+
path = self._baselines_dir / f"{spec_name}_{bid}.json"
|
|
120
|
+
|
|
121
|
+
if not path.exists():
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
125
|
+
return [
|
|
126
|
+
MetricResult(
|
|
127
|
+
name=m["name"],
|
|
128
|
+
value=m["value"],
|
|
129
|
+
passed=m["passed"],
|
|
130
|
+
detail=m.get("detail", ""),
|
|
131
|
+
)
|
|
132
|
+
for m in data
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
def _compare(
|
|
136
|
+
self, baseline: list[MetricResult], current: list[MetricResult]
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
"""Compare current metrics against baseline. Returns regression descriptions."""
|
|
139
|
+
if not baseline:
|
|
140
|
+
return [] # No baseline to compare against
|
|
141
|
+
|
|
142
|
+
baseline_map = {m.name: m for m in baseline}
|
|
143
|
+
regressions = []
|
|
144
|
+
|
|
145
|
+
for metric in current:
|
|
146
|
+
base = baseline_map.get(metric.name)
|
|
147
|
+
if base is None:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Check for regressions
|
|
151
|
+
if base.passed and not metric.passed:
|
|
152
|
+
regressions.append(
|
|
153
|
+
f"{metric.name}: was passing, now failing "
|
|
154
|
+
f"(baseline={base.value}, current={metric.value})"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return regressions
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Memory subsystem for agent context management."""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Retrieval logic for agent memory — keyword + semantic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from loopengt.core.memory.store import MemoryEntry, MemoryStore
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MemoryRetriever:
|
|
15
|
+
"""Retrieves relevant memories using keyword and/or vector search.
|
|
16
|
+
|
|
17
|
+
Combines results from both search strategies with configurable
|
|
18
|
+
weighting.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
retriever = MemoryRetriever(store)
|
|
23
|
+
results = retriever.retrieve("API design patterns", top_k=5)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
store: MemoryStore,
|
|
29
|
+
*,
|
|
30
|
+
keyword_weight: float = 0.5,
|
|
31
|
+
vector_weight: float = 0.5,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._store = store
|
|
34
|
+
self._keyword_weight = keyword_weight
|
|
35
|
+
self._vector_weight = vector_weight
|
|
36
|
+
self._log = logger.bind(component="retriever")
|
|
37
|
+
|
|
38
|
+
def retrieve(
|
|
39
|
+
self,
|
|
40
|
+
query: str,
|
|
41
|
+
*,
|
|
42
|
+
top_k: int = 5,
|
|
43
|
+
metadata_filter: dict[str, Any] | None = None,
|
|
44
|
+
query_embedding: list[float] | None = None,
|
|
45
|
+
) -> list[MemoryEntry]:
|
|
46
|
+
"""Retrieve top-k relevant memories.
|
|
47
|
+
|
|
48
|
+
If *query_embedding* is provided, combines keyword and vector
|
|
49
|
+
search scores. Otherwise, uses keyword search only.
|
|
50
|
+
"""
|
|
51
|
+
keyword_results = self._store.search(
|
|
52
|
+
query, top_k=top_k * 2, metadata_filter=metadata_filter
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if query_embedding is None:
|
|
56
|
+
return keyword_results[:top_k]
|
|
57
|
+
|
|
58
|
+
vector_results = self._store.vector_search(
|
|
59
|
+
query_embedding, top_k=top_k * 2, metadata_filter=metadata_filter
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Merge and re-rank
|
|
63
|
+
merged = self._merge_results(keyword_results, vector_results)
|
|
64
|
+
return merged[:top_k]
|
|
65
|
+
|
|
66
|
+
def retrieve_by_metadata(
|
|
67
|
+
self,
|
|
68
|
+
metadata_filter: dict[str, Any],
|
|
69
|
+
*,
|
|
70
|
+
top_k: int = 10,
|
|
71
|
+
) -> list[MemoryEntry]:
|
|
72
|
+
"""Retrieve entries matching specific metadata criteria."""
|
|
73
|
+
results = [
|
|
74
|
+
entry
|
|
75
|
+
for entry in self._store.all_entries()
|
|
76
|
+
if all(entry.metadata.get(k) == v for k, v in metadata_filter.items())
|
|
77
|
+
]
|
|
78
|
+
return results[:top_k]
|
|
79
|
+
|
|
80
|
+
def retrieve_recent(self, *, top_k: int = 5) -> list[MemoryEntry]:
|
|
81
|
+
"""Retrieve the most recently added entries."""
|
|
82
|
+
entries = self._store.all_entries()
|
|
83
|
+
# Entries are stored in insertion order (dict in Python 3.7+)
|
|
84
|
+
return entries[-top_k:][::-1]
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Internals
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _merge_results(
|
|
91
|
+
self,
|
|
92
|
+
keyword: list[MemoryEntry],
|
|
93
|
+
vector: list[MemoryEntry],
|
|
94
|
+
) -> list[MemoryEntry]:
|
|
95
|
+
"""Merge keyword and vector results with weighted scoring."""
|
|
96
|
+
combined: dict[str, MemoryEntry] = {}
|
|
97
|
+
|
|
98
|
+
for entry in keyword:
|
|
99
|
+
eid = entry.entry_id
|
|
100
|
+
if eid not in combined:
|
|
101
|
+
combined[eid] = MemoryEntry(
|
|
102
|
+
entry_id=eid,
|
|
103
|
+
content=entry.content,
|
|
104
|
+
metadata=entry.metadata,
|
|
105
|
+
embedding=entry.embedding,
|
|
106
|
+
score=0.0,
|
|
107
|
+
)
|
|
108
|
+
combined[eid].score += entry.score * self._keyword_weight
|
|
109
|
+
|
|
110
|
+
for entry in vector:
|
|
111
|
+
eid = entry.entry_id
|
|
112
|
+
if eid not in combined:
|
|
113
|
+
combined[eid] = MemoryEntry(
|
|
114
|
+
entry_id=eid,
|
|
115
|
+
content=entry.content,
|
|
116
|
+
metadata=entry.metadata,
|
|
117
|
+
embedding=entry.embedding,
|
|
118
|
+
score=0.0,
|
|
119
|
+
)
|
|
120
|
+
combined[eid].score += entry.score * self._vector_weight
|
|
121
|
+
|
|
122
|
+
results = list(combined.values())
|
|
123
|
+
results.sort(key=lambda e: e.score, reverse=True)
|
|
124
|
+
return results
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""In-memory vector + keyword store for agent memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class MemoryEntry:
|
|
17
|
+
"""A single memory entry with text content and optional embedding."""
|
|
18
|
+
|
|
19
|
+
entry_id: str
|
|
20
|
+
content: str
|
|
21
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
embedding: list[float] | None = None
|
|
23
|
+
score: float = 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoryStore:
|
|
27
|
+
"""Lightweight in-memory store for agent memories.
|
|
28
|
+
|
|
29
|
+
Supports keyword search and (when embeddings are provided)
|
|
30
|
+
cosine-similarity based vector search.
|
|
31
|
+
|
|
32
|
+
Usage::
|
|
33
|
+
|
|
34
|
+
store = MemoryStore()
|
|
35
|
+
store.add("plan-1", "Build the API endpoints first", metadata={"step": "plan"})
|
|
36
|
+
results = store.search("API endpoints", top_k=3)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._entries: dict[str, MemoryEntry] = {}
|
|
41
|
+
self._log = logger.bind(component="memory_store")
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Write operations
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def add(
|
|
48
|
+
self,
|
|
49
|
+
entry_id: str | None = None,
|
|
50
|
+
content: str = "",
|
|
51
|
+
metadata: dict[str, Any] | None = None,
|
|
52
|
+
embedding: list[float] | None = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Add a memory entry. Returns the entry ID."""
|
|
55
|
+
if entry_id is None:
|
|
56
|
+
entry_id = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
57
|
+
|
|
58
|
+
self._entries[entry_id] = MemoryEntry(
|
|
59
|
+
entry_id=entry_id,
|
|
60
|
+
content=content,
|
|
61
|
+
metadata=metadata or {},
|
|
62
|
+
embedding=embedding,
|
|
63
|
+
)
|
|
64
|
+
self._log.debug("memory.add", entry_id=entry_id, length=len(content))
|
|
65
|
+
return entry_id
|
|
66
|
+
|
|
67
|
+
def remove(self, entry_id: str) -> bool:
|
|
68
|
+
"""Remove an entry by ID. Returns True if found."""
|
|
69
|
+
if entry_id in self._entries:
|
|
70
|
+
del self._entries[entry_id]
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def clear(self) -> None:
|
|
75
|
+
"""Remove all entries."""
|
|
76
|
+
self._entries.clear()
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Read / search operations
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def get(self, entry_id: str) -> MemoryEntry | None:
|
|
83
|
+
"""Get an entry by ID."""
|
|
84
|
+
return self._entries.get(entry_id)
|
|
85
|
+
|
|
86
|
+
def search(
|
|
87
|
+
self,
|
|
88
|
+
query: str,
|
|
89
|
+
*,
|
|
90
|
+
top_k: int = 5,
|
|
91
|
+
metadata_filter: dict[str, Any] | None = None,
|
|
92
|
+
) -> list[MemoryEntry]:
|
|
93
|
+
"""Search entries by keyword matching.
|
|
94
|
+
|
|
95
|
+
Scores entries by the fraction of query words found in the
|
|
96
|
+
content. Optionally filters by metadata key-value matches.
|
|
97
|
+
"""
|
|
98
|
+
query_words = set(query.lower().split())
|
|
99
|
+
if not query_words:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
scored: list[MemoryEntry] = []
|
|
103
|
+
for entry in self._entries.values():
|
|
104
|
+
if metadata_filter and not self._matches_filter(entry, metadata_filter):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
content_words = set(entry.content.lower().split())
|
|
108
|
+
overlap = query_words & content_words
|
|
109
|
+
score = len(overlap) / len(query_words) if query_words else 0.0
|
|
110
|
+
|
|
111
|
+
if score > 0:
|
|
112
|
+
# Return a copy with the score
|
|
113
|
+
scored.append(
|
|
114
|
+
MemoryEntry(
|
|
115
|
+
entry_id=entry.entry_id,
|
|
116
|
+
content=entry.content,
|
|
117
|
+
metadata=entry.metadata,
|
|
118
|
+
embedding=entry.embedding,
|
|
119
|
+
score=score,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
scored.sort(key=lambda e: e.score, reverse=True)
|
|
124
|
+
return scored[:top_k]
|
|
125
|
+
|
|
126
|
+
def vector_search(
|
|
127
|
+
self,
|
|
128
|
+
query_embedding: list[float],
|
|
129
|
+
*,
|
|
130
|
+
top_k: int = 5,
|
|
131
|
+
metadata_filter: dict[str, Any] | None = None,
|
|
132
|
+
) -> list[MemoryEntry]:
|
|
133
|
+
"""Search entries by cosine similarity of embeddings."""
|
|
134
|
+
scored: list[MemoryEntry] = []
|
|
135
|
+
|
|
136
|
+
for entry in self._entries.values():
|
|
137
|
+
if entry.embedding is None:
|
|
138
|
+
continue
|
|
139
|
+
if metadata_filter and not self._matches_filter(entry, metadata_filter):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
sim = self._cosine_similarity(query_embedding, entry.embedding)
|
|
143
|
+
scored.append(
|
|
144
|
+
MemoryEntry(
|
|
145
|
+
entry_id=entry.entry_id,
|
|
146
|
+
content=entry.content,
|
|
147
|
+
metadata=entry.metadata,
|
|
148
|
+
embedding=entry.embedding,
|
|
149
|
+
score=sim,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
scored.sort(key=lambda e: e.score, reverse=True)
|
|
154
|
+
return scored[:top_k]
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def size(self) -> int:
|
|
158
|
+
"""Number of entries in the store."""
|
|
159
|
+
return len(self._entries)
|
|
160
|
+
|
|
161
|
+
def all_entries(self) -> list[MemoryEntry]:
|
|
162
|
+
"""Return all entries."""
|
|
163
|
+
return list(self._entries.values())
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Internals
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
171
|
+
"""Compute cosine similarity between two vectors."""
|
|
172
|
+
if len(a) != len(b):
|
|
173
|
+
return 0.0
|
|
174
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
175
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
176
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
177
|
+
if norm_a == 0 or norm_b == 0:
|
|
178
|
+
return 0.0
|
|
179
|
+
return dot / (norm_a * norm_b)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _matches_filter(entry: MemoryEntry, filt: dict[str, Any]) -> bool:
|
|
183
|
+
"""Check if an entry's metadata matches all filter conditions."""
|
|
184
|
+
return all(entry.metadata.get(k) == v for k, v in filt.items())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Context window summarization for long-running loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
|
|
7
|
+
logger = structlog.get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Summarizer:
|
|
11
|
+
"""Summarises conversation / context history to fit within token budgets.
|
|
12
|
+
|
|
13
|
+
Uses a simple extractive strategy by default. When an LLM provider
|
|
14
|
+
is available, switches to abstractive summarization.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
summarizer = Summarizer(max_tokens=4096)
|
|
19
|
+
short = summarizer.summarize(long_history)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
max_tokens: int = 4096,
|
|
26
|
+
strategy: str = "extractive",
|
|
27
|
+
) -> None:
|
|
28
|
+
self._max_tokens = max_tokens
|
|
29
|
+
self._strategy = strategy
|
|
30
|
+
self._log = logger.bind(component="summarizer")
|
|
31
|
+
|
|
32
|
+
def summarize(self, entries: list[str]) -> str:
|
|
33
|
+
"""Produce a condensed summary of the given text entries.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
entries:
|
|
38
|
+
List of text chunks (e.g., step outputs, conversation turns)
|
|
39
|
+
to summarize.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
str
|
|
44
|
+
The summarized text, estimated to be within ``max_tokens``.
|
|
45
|
+
"""
|
|
46
|
+
if not entries:
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
full_text = "\n\n".join(entries)
|
|
50
|
+
estimated_tokens = len(full_text.split()) # rough word-based estimate
|
|
51
|
+
|
|
52
|
+
if estimated_tokens <= self._max_tokens:
|
|
53
|
+
return full_text
|
|
54
|
+
|
|
55
|
+
self._log.info(
|
|
56
|
+
"summarizer.truncating",
|
|
57
|
+
original_tokens=estimated_tokens,
|
|
58
|
+
target_tokens=self._max_tokens,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if self._strategy == "extractive":
|
|
62
|
+
return self._extractive_summarize(entries)
|
|
63
|
+
else:
|
|
64
|
+
return self._truncate(full_text)
|
|
65
|
+
|
|
66
|
+
def _extractive_summarize(self, entries: list[str]) -> str:
|
|
67
|
+
"""Keep the first and last entries, plus sampled middle entries."""
|
|
68
|
+
if len(entries) <= 3:
|
|
69
|
+
return "\n\n".join(entries)
|
|
70
|
+
|
|
71
|
+
# Always keep first and last
|
|
72
|
+
selected = [entries[0]]
|
|
73
|
+
|
|
74
|
+
# Sample from the middle, keeping budget
|
|
75
|
+
middle = entries[1:-1]
|
|
76
|
+
budget = self._max_tokens - len(entries[0].split()) - len(entries[-1].split())
|
|
77
|
+
|
|
78
|
+
used = 0
|
|
79
|
+
step = max(1, len(middle) // 5) # keep ~5 middle entries
|
|
80
|
+
for i in range(0, len(middle), step):
|
|
81
|
+
entry_tokens = len(middle[i].split())
|
|
82
|
+
if used + entry_tokens > budget:
|
|
83
|
+
selected.append("... [context truncated] ...")
|
|
84
|
+
break
|
|
85
|
+
selected.append(middle[i])
|
|
86
|
+
used += entry_tokens
|
|
87
|
+
|
|
88
|
+
selected.append(entries[-1])
|
|
89
|
+
return "\n\n".join(selected)
|
|
90
|
+
|
|
91
|
+
def _truncate(self, text: str) -> str:
|
|
92
|
+
"""Simple truncation to fit token budget."""
|
|
93
|
+
words = text.split()
|
|
94
|
+
if len(words) <= self._max_tokens:
|
|
95
|
+
return text
|
|
96
|
+
truncated = " ".join(words[: self._max_tokens])
|
|
97
|
+
return truncated + "\n\n... [truncated]"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Core data models for loop engineering."""
|
|
2
|
+
|
|
3
|
+
from loopengt.core.models.agent import AgentCapability, AgentConfig, AgentRole
|
|
4
|
+
from loopengt.core.models.loop_spec import (
|
|
5
|
+
LoopSpec,
|
|
6
|
+
OutputSchema,
|
|
7
|
+
StepSpec,
|
|
8
|
+
StopCondition,
|
|
9
|
+
)
|
|
10
|
+
from loopengt.core.models.policy import (
|
|
11
|
+
BackoffStrategy,
|
|
12
|
+
Policy,
|
|
13
|
+
RetryPolicy,
|
|
14
|
+
VerificationGate,
|
|
15
|
+
)
|
|
16
|
+
from loopengt.core.models.state import (
|
|
17
|
+
AgentState,
|
|
18
|
+
CheckpointData,
|
|
19
|
+
LoopState,
|
|
20
|
+
StepState,
|
|
21
|
+
)
|
|
22
|
+
from loopengt.core.models.tool import ToolAuth, ToolSpec, ToolType
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AgentCapability",
|
|
26
|
+
"AgentConfig",
|
|
27
|
+
"AgentRole",
|
|
28
|
+
"AgentState",
|
|
29
|
+
"BackoffStrategy",
|
|
30
|
+
"CheckpointData",
|
|
31
|
+
"LoopSpec",
|
|
32
|
+
"LoopState",
|
|
33
|
+
"OutputSchema",
|
|
34
|
+
"Policy",
|
|
35
|
+
"RetryPolicy",
|
|
36
|
+
"StepSpec",
|
|
37
|
+
"StepState",
|
|
38
|
+
"StopCondition",
|
|
39
|
+
"ToolAuth",
|
|
40
|
+
"ToolSpec",
|
|
41
|
+
"ToolType",
|
|
42
|
+
"VerificationGate",
|
|
43
|
+
]
|