ltcai 8.8.0 → 9.0.0
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.
- package/README.md +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +368 -418
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +196 -74
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
|
@@ -5,10 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
from ._kg_common import * # noqa: F403,F401
|
|
6
6
|
from .documents import KnowledgeGraphDocumentsMixin
|
|
7
7
|
from .discovery import KnowledgeGraphDiscoveryMixin
|
|
8
|
+
from .discovery_index import KnowledgeGraphLocalIndexMixin
|
|
8
9
|
from .ingest import KnowledgeGraphIngestMixin
|
|
9
10
|
from .projection import KnowledgeGraphProjectionMixin
|
|
10
11
|
from .provenance import KnowledgeGraphProvenanceMixin
|
|
11
12
|
from .retrieval import KnowledgeGraphRetrievalMixin
|
|
13
|
+
from .retrieval_docgen import KnowledgeGraphDocGenMixin
|
|
14
|
+
from .retrieval_vector import KnowledgeGraphVectorMixin
|
|
12
15
|
from .write_master import KnowledgeGraphWriteMixin
|
|
13
16
|
|
|
14
17
|
|
|
@@ -16,10 +19,13 @@ class KnowledgeGraphStore(
|
|
|
16
19
|
KnowledgeGraphProjectionMixin,
|
|
17
20
|
KnowledgeGraphWriteMixin,
|
|
18
21
|
KnowledgeGraphDiscoveryMixin,
|
|
22
|
+
KnowledgeGraphLocalIndexMixin,
|
|
19
23
|
KnowledgeGraphIngestMixin,
|
|
20
24
|
KnowledgeGraphProvenanceMixin,
|
|
21
25
|
KnowledgeGraphDocumentsMixin,
|
|
22
26
|
KnowledgeGraphRetrievalMixin,
|
|
27
|
+
KnowledgeGraphVectorMixin,
|
|
28
|
+
KnowledgeGraphDocGenMixin,
|
|
23
29
|
):
|
|
24
30
|
def __init__(
|
|
25
31
|
self,
|
|
@@ -50,6 +50,52 @@ def _now_iso() -> str:
|
|
|
50
50
|
return datetime.now(timezone.utc).isoformat()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
# --- Large candidate 1 slice: incremental / background ingestion support ---
|
|
54
|
+
@dataclass
|
|
55
|
+
class BackgroundIngestionJob:
|
|
56
|
+
"""Job descriptor for background/incremental indexing (KG scale-up slice)."""
|
|
57
|
+
job_id: str
|
|
58
|
+
items: List[IngestionItem]
|
|
59
|
+
status: str = "pending" # pending | running | done | failed
|
|
60
|
+
created_at: str = field(default_factory=_now_iso)
|
|
61
|
+
processed: int = 0
|
|
62
|
+
total: int = 0
|
|
63
|
+
errors: List[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BackgroundIngestionQueue:
|
|
67
|
+
"""Simple in-memory queue for background incremental ingestion.
|
|
68
|
+
|
|
69
|
+
For large corpus: this is the seam where a real scheduler / worker pool
|
|
70
|
+
(celery, rq, or internal thread) can be plugged later without changing callers.
|
|
71
|
+
Supports incremental (skip duplicates) vs force reindex.
|
|
72
|
+
"""
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._jobs: Dict[str, BackgroundIngestionJob] = {}
|
|
75
|
+
self._counter = 0
|
|
76
|
+
|
|
77
|
+
def schedule(self, items: List[IngestionItem], *, incremental: bool = True) -> BackgroundIngestionJob:
|
|
78
|
+
self._counter += 1
|
|
79
|
+
job_id = f"bg_ingest_{self._counter:04d}"
|
|
80
|
+
job = BackgroundIngestionJob(
|
|
81
|
+
job_id=job_id,
|
|
82
|
+
items=items,
|
|
83
|
+
total=len(items),
|
|
84
|
+
)
|
|
85
|
+
# annotate items for downstream
|
|
86
|
+
for it in job.items:
|
|
87
|
+
# attach flag without breaking dataclass defaults (use metadata)
|
|
88
|
+
it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
|
|
89
|
+
self._jobs[job_id] = job
|
|
90
|
+
return job
|
|
91
|
+
|
|
92
|
+
def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
93
|
+
return self._jobs.get(job_id)
|
|
94
|
+
|
|
95
|
+
def list_pending(self) -> List[BackgroundIngestionJob]:
|
|
96
|
+
return [j for j in self._jobs.values() if j.status == "pending"]
|
|
97
|
+
|
|
98
|
+
|
|
53
99
|
@dataclass
|
|
54
100
|
class IngestionItem:
|
|
55
101
|
"""A single thing to ingest, normalized across every source type."""
|
|
@@ -118,6 +164,7 @@ class IngestionPipeline:
|
|
|
118
164
|
audit: Optional[Any] = None,
|
|
119
165
|
max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
|
|
120
166
|
pipeline_name: str = "unified-ingestion",
|
|
167
|
+
bg_queue: Optional[BackgroundIngestionQueue] = None,
|
|
121
168
|
) -> None:
|
|
122
169
|
self._kg = knowledge_graph
|
|
123
170
|
self._hooks = hooks
|
|
@@ -125,6 +172,7 @@ class IngestionPipeline:
|
|
|
125
172
|
self._audit = audit
|
|
126
173
|
self._max_text_bytes = int(max_text_bytes)
|
|
127
174
|
self._pipeline_name = pipeline_name
|
|
175
|
+
self._bg_queue = bg_queue or BackgroundIngestionQueue()
|
|
128
176
|
|
|
129
177
|
def available(self) -> bool:
|
|
130
178
|
return self._enable and self._kg is not None
|
|
@@ -243,6 +291,26 @@ class IngestionPipeline:
|
|
|
243
291
|
detail=provenance_detail,
|
|
244
292
|
)
|
|
245
293
|
|
|
294
|
+
# --- Large candidate #1: background / incremental scheduling (slice) ---
|
|
295
|
+
def schedule_background(
|
|
296
|
+
self,
|
|
297
|
+
items: List[IngestionItem],
|
|
298
|
+
*,
|
|
299
|
+
incremental: bool = True,
|
|
300
|
+
) -> BackgroundIngestionJob:
|
|
301
|
+
"""Schedule items for background incremental indexing.
|
|
302
|
+
|
|
303
|
+
Returns a job handle. Actual execution can be driven by caller
|
|
304
|
+
(or future worker) calling pipeline.ingest on each. This seam enables
|
|
305
|
+
large-corpus scale without blocking user requests.
|
|
306
|
+
"""
|
|
307
|
+
job = self._bg_queue.schedule(items, incremental=incremental)
|
|
308
|
+
# mark initial status on results concept (jobs track)
|
|
309
|
+
return job
|
|
310
|
+
|
|
311
|
+
def get_background_job(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
312
|
+
return self._bg_queue.get(job_id)
|
|
313
|
+
|
|
246
314
|
# ── routing helpers ──────────────────────────────────────────────────────
|
|
247
315
|
def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
248
316
|
text = item.text or ""
|
|
@@ -13,7 +13,6 @@ any cloud service. Two complementary mechanisms, both fully local:
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
-
import hashlib
|
|
17
16
|
import json
|
|
18
17
|
import os
|
|
19
18
|
import shutil
|
|
@@ -30,6 +29,7 @@ from .storage import (
|
|
|
30
29
|
PostgresEngine,
|
|
31
30
|
SQLiteToPostgresMigrator,
|
|
32
31
|
)
|
|
32
|
+
from .utils import sha256_file as _sha256_file
|
|
33
33
|
|
|
34
34
|
FORMAT = "latticeai.kg.export"
|
|
35
35
|
FORMAT_VERSION = 1
|
|
@@ -44,14 +44,6 @@ def _stamp() -> str:
|
|
|
44
44
|
return _now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _sha256_file(path: Path) -> str:
|
|
48
|
-
h = hashlib.sha256()
|
|
49
|
-
with open(path, "rb") as fh:
|
|
50
|
-
for block in iter(lambda: fh.read(65536), b""):
|
|
51
|
-
h.update(block)
|
|
52
|
-
return h.hexdigest()
|
|
53
|
-
|
|
54
|
-
|
|
55
47
|
def _safe_zip_names(names) -> None:
|
|
56
48
|
for name in names:
|
|
57
49
|
path = PurePosixPath(name)
|
package/lattice_brain/quality.py
CHANGED
|
@@ -152,6 +152,28 @@ class MemoryCandidate:
|
|
|
152
152
|
conflicts: List[str] = field(default_factory=list)
|
|
153
153
|
|
|
154
154
|
class MemoryQualityManager:
|
|
155
|
+
_NEGATION_PATTERNS = (
|
|
156
|
+
"not",
|
|
157
|
+
"does not",
|
|
158
|
+
"don't",
|
|
159
|
+
"do not",
|
|
160
|
+
"싫어",
|
|
161
|
+
"원하지 않",
|
|
162
|
+
"하지 않",
|
|
163
|
+
"반대",
|
|
164
|
+
)
|
|
165
|
+
_POSITIVE_PATTERNS = (
|
|
166
|
+
"prefers",
|
|
167
|
+
"prefer",
|
|
168
|
+
"likes",
|
|
169
|
+
"like",
|
|
170
|
+
"wants",
|
|
171
|
+
"want",
|
|
172
|
+
"좋아",
|
|
173
|
+
"선호",
|
|
174
|
+
"원해",
|
|
175
|
+
)
|
|
176
|
+
|
|
155
177
|
def extract_candidates(self, memories: List[Dict]) -> List[MemoryCandidate]:
|
|
156
178
|
return [MemoryCandidate(m["id"], m["content"], m.get("score", 0.6), m.get("source", "mem")) for m in memories]
|
|
157
179
|
|
|
@@ -163,10 +185,16 @@ class MemoryQualityManager:
|
|
|
163
185
|
return sorted(cands, key=lambda x: x.score, reverse=True)
|
|
164
186
|
|
|
165
187
|
def dedupe(self, cands: List[MemoryCandidate], threshold: float = 0.85) -> List[MemoryCandidate]:
|
|
188
|
+
"""Advanced content dedup (KG scale slice).
|
|
189
|
+
|
|
190
|
+
Uses sha256 prefix + normalized length bucket for better collision resistance
|
|
191
|
+
than plain md5. Future: plug embedding cosine or simhash here.
|
|
192
|
+
"""
|
|
166
193
|
kept = []
|
|
167
194
|
seen = set()
|
|
168
195
|
for c in cands:
|
|
169
|
-
|
|
196
|
+
norm = " ".join(c.content.lower().split())[:200]
|
|
197
|
+
h = hashlib.sha256((norm + f"|{len(c.content)//50}").encode()).hexdigest()[:16]
|
|
170
198
|
if h not in seen:
|
|
171
199
|
seen.add(h)
|
|
172
200
|
kept.append(c)
|
|
@@ -182,12 +210,78 @@ class MemoryQualityManager:
|
|
|
182
210
|
return list(merged.values())
|
|
183
211
|
|
|
184
212
|
def detect_conflicts(self, cands: List[MemoryCandidate]) -> List[MemoryCandidate]:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
213
|
+
"""Flag local contradiction signals before proactive synthesis.
|
|
214
|
+
|
|
215
|
+
The heuristic stays deterministic/offline: direct negation marks a row
|
|
216
|
+
as risky, and pairwise token overlap catches common preference conflicts
|
|
217
|
+
such as "prefers light mode" vs "does not like light mode".
|
|
218
|
+
"""
|
|
219
|
+
for c in cands:
|
|
220
|
+
lowered = c.content.lower()
|
|
221
|
+
if any(pattern in lowered for pattern in self._NEGATION_PATTERNS):
|
|
188
222
|
c.conflicts.append("conflict:possible_negation")
|
|
223
|
+
|
|
224
|
+
signatures = [(c, self._content_signature(c.content)) for c in cands]
|
|
225
|
+
for left_index, (left, left_sig) in enumerate(signatures):
|
|
226
|
+
for right, right_sig in signatures[left_index + 1:]:
|
|
227
|
+
if not left_sig or len(left_sig & right_sig) < 2:
|
|
228
|
+
continue
|
|
229
|
+
left_negative = self._is_negative(left.content)
|
|
230
|
+
right_negative = self._is_negative(right.content)
|
|
231
|
+
left_positive = self._is_positive(left.content)
|
|
232
|
+
right_positive = self._is_positive(right.content)
|
|
233
|
+
if left_negative == right_negative:
|
|
234
|
+
continue
|
|
235
|
+
if not (left_positive or right_positive):
|
|
236
|
+
continue
|
|
237
|
+
left.conflicts.append(f"conflict:contradicts:{right.id}")
|
|
238
|
+
right.conflicts.append(f"conflict:contradicts:{left.id}")
|
|
189
239
|
return cands
|
|
190
240
|
|
|
241
|
+
def _is_negative(self, content: str) -> bool:
|
|
242
|
+
lowered = content.lower()
|
|
243
|
+
return any(pattern in lowered for pattern in self._NEGATION_PATTERNS)
|
|
244
|
+
|
|
245
|
+
def _is_positive(self, content: str) -> bool:
|
|
246
|
+
lowered = content.lower()
|
|
247
|
+
return any(pattern in lowered for pattern in self._POSITIVE_PATTERNS)
|
|
248
|
+
|
|
249
|
+
def _content_signature(self, content: str) -> set[str]:
|
|
250
|
+
stopwords = {
|
|
251
|
+
"a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
|
|
252
|
+
"user", "users", "does", "do", "not", "like", "likes", "prefer",
|
|
253
|
+
"prefers", "want", "wants",
|
|
254
|
+
}
|
|
255
|
+
tokens = set(re.findall(r"\w+", content.lower()))
|
|
256
|
+
return {token for token in tokens if len(token) > 2 and token not in stopwords}
|
|
257
|
+
|
|
258
|
+
# --- Large candidate #4 slice: proactive / temporal contradiction detection ---
|
|
259
|
+
def detect_temporal_contradictions(self, memories: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
260
|
+
"""Simple temporal + contradiction detector over memory list (proactive synthesis extension).
|
|
261
|
+
|
|
262
|
+
Looks at negation keywords across similar contents + differing timestamps.
|
|
263
|
+
Real version would walk graph historical projections + LLM judge.
|
|
264
|
+
Returns flagged items with 'contradiction' tag.
|
|
265
|
+
"""
|
|
266
|
+
flagged = []
|
|
267
|
+
neg_tokens = ("not ", "반대", "거짓", "no longer", "never", "중단", "사용하지")
|
|
268
|
+
pos_group = []
|
|
269
|
+
neg_group = []
|
|
270
|
+
for m in memories:
|
|
271
|
+
c = (m.get("content") or "").lower()
|
|
272
|
+
if any(t in c for t in neg_tokens):
|
|
273
|
+
neg_group.append(m)
|
|
274
|
+
else:
|
|
275
|
+
pos_group.append(m)
|
|
276
|
+
# cross check: if both pos and neg exist with time spread
|
|
277
|
+
all_t = [m.get("timestamp") or m.get("created_at") or 0 for m in memories]
|
|
278
|
+
if neg_group and pos_group and len(set(all_t)) > 1:
|
|
279
|
+
for g in (pos_group + neg_group):
|
|
280
|
+
gg = dict(g)
|
|
281
|
+
gg["proactive_flag"] = "contradiction:temporal_negation"
|
|
282
|
+
flagged.append(gg)
|
|
283
|
+
return flagged
|
|
284
|
+
|
|
191
285
|
def apply_retention(self, cands: List[MemoryCandidate], max_age_days: int = 90) -> List[MemoryCandidate]:
|
|
192
286
|
now = time.time()
|
|
193
287
|
return [c for c in cands if (now - c.timestamp) < max_age_days * 86400]
|
|
@@ -65,6 +65,10 @@ def _now() -> str:
|
|
|
65
65
|
return datetime.now().isoformat(timespec="seconds")
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _compact_text(value: Any, *, limit: int) -> str:
|
|
69
|
+
return " ".join(str(value or "").split())[:limit]
|
|
70
|
+
|
|
71
|
+
|
|
68
72
|
class AgentRuntimeUnavailable(RuntimeError):
|
|
69
73
|
"""Raised when a product run would otherwise persist simulation output."""
|
|
70
74
|
|
|
@@ -80,6 +84,8 @@ class AgentRuntime:
|
|
|
80
84
|
max_retries_cap: int = 5,
|
|
81
85
|
hooks: Any = None,
|
|
82
86
|
allow_simulation_runs: bool = False,
|
|
87
|
+
memory_ingest: Optional[Callable[..., Dict[str, Any]]] = None,
|
|
88
|
+
review_sink: Any = None,
|
|
83
89
|
):
|
|
84
90
|
self._store = store
|
|
85
91
|
self._orchestrator_factory = orchestrator_factory
|
|
@@ -91,6 +97,11 @@ class AgentRuntime:
|
|
|
91
97
|
self._hooks = hooks
|
|
92
98
|
self._allow_simulation_runs = bool(allow_simulation_runs)
|
|
93
99
|
self._run_executor: Any = None
|
|
100
|
+
# Optional memory synthesis: successful agent runs produce durable
|
|
101
|
+
# Brain memories (long_term / workspace tier) so users *feel* the results
|
|
102
|
+
# in BrainBrief, MemoryRings, search and graph immediately.
|
|
103
|
+
self._memory_ingest = memory_ingest
|
|
104
|
+
self._review_sink = review_sink
|
|
94
105
|
|
|
95
106
|
def attach_executor(self, executor: Any) -> None:
|
|
96
107
|
self._run_executor = executor
|
|
@@ -415,6 +426,155 @@ class AgentRuntime:
|
|
|
415
426
|
"current_role": None,
|
|
416
427
|
}
|
|
417
428
|
|
|
429
|
+
def _synthesize_brain_memory(self, *, goal: str, result: Any, user_email: Optional[str], scope: Optional[str]) -> None:
|
|
430
|
+
"""Turn a successful agent run into durable Brain memory + graph nodes.
|
|
431
|
+
|
|
432
|
+
This is the key to users *strongly feeling* the scale of agent work:
|
|
433
|
+
delegated goals don't disappear into Act tab; they become part of the
|
|
434
|
+
Living Brain (searchable, visible in rings/brief, connected in graph).
|
|
435
|
+
"""
|
|
436
|
+
if not self._memory_ingest:
|
|
437
|
+
return
|
|
438
|
+
if getattr(result, "status", None) not in ("ok", "retried_ok"):
|
|
439
|
+
return
|
|
440
|
+
try:
|
|
441
|
+
output = _compact_text(getattr(result, "output", ""), limit=2200)
|
|
442
|
+
plan_steps = getattr(result, "plan", None) or []
|
|
443
|
+
sections = self._agent_synthesis_sections(goal=goal, output=output, plan_steps=plan_steps, result=result)
|
|
444
|
+
plan_summary = "; ".join(sections["plan_steps"][:4])
|
|
445
|
+
content = "\n\n".join([
|
|
446
|
+
f"[Agent synthesis] Goal: {_compact_text(goal, limit=240)}",
|
|
447
|
+
f"Outcome: {output}",
|
|
448
|
+
self._format_synthesis_section("Key facts", sections["facts"]),
|
|
449
|
+
self._format_synthesis_section("Decisions", sections["decisions"]),
|
|
450
|
+
self._format_synthesis_section("Follow-ups", sections["followups"]),
|
|
451
|
+
f"Plan: {plan_summary}" if plan_summary else "",
|
|
452
|
+
]).strip()
|
|
453
|
+
if not content or len(content) < 20:
|
|
454
|
+
return
|
|
455
|
+
tags = ["agent-synthesis", "delegated", "auto"]
|
|
456
|
+
self._memory_ingest(
|
|
457
|
+
kind="long_term",
|
|
458
|
+
content=content,
|
|
459
|
+
user_email=user_email,
|
|
460
|
+
tags=tags,
|
|
461
|
+
metadata={
|
|
462
|
+
"source": "agent_runtime",
|
|
463
|
+
"synthesis_version": 2,
|
|
464
|
+
"goal": _compact_text(goal, limit=200),
|
|
465
|
+
"roles": getattr(result, "roles_run", None),
|
|
466
|
+
"facts": sections["facts"],
|
|
467
|
+
"decisions": sections["decisions"],
|
|
468
|
+
"followups": sections["followups"],
|
|
469
|
+
},
|
|
470
|
+
graph=self._workspace_graph() if hasattr(self, "_workspace_graph") else None,
|
|
471
|
+
workspace_id=scope,
|
|
472
|
+
)
|
|
473
|
+
if sections["decisions"]:
|
|
474
|
+
self._memory_ingest(
|
|
475
|
+
kind="decisions",
|
|
476
|
+
content=f"Agent decision for {_compact_text(goal, limit=120)}: {'; '.join(sections['decisions'][:3])}",
|
|
477
|
+
user_email=user_email,
|
|
478
|
+
tags=["agent", "outcome", "decision"],
|
|
479
|
+
metadata={"source": "agent_runtime_synthesis", "synthesis_version": 2, "goal": _compact_text(goal, limit=200)},
|
|
480
|
+
workspace_id=scope,
|
|
481
|
+
)
|
|
482
|
+
if sections["followups"]:
|
|
483
|
+
self._memory_ingest(
|
|
484
|
+
kind="workspace",
|
|
485
|
+
content=f"Agent follow-ups for {_compact_text(goal, limit=120)}: {'; '.join(sections['followups'][:5])}",
|
|
486
|
+
user_email=user_email,
|
|
487
|
+
tags=["agent", "follow-up", "next-action"],
|
|
488
|
+
metadata={"source": "agent_runtime_followups", "synthesis_version": 2, "goal": _compact_text(goal, limit=200)},
|
|
489
|
+
workspace_id=scope,
|
|
490
|
+
)
|
|
491
|
+
self._enqueue_agent_followups(
|
|
492
|
+
goal=goal,
|
|
493
|
+
followups=sections["followups"],
|
|
494
|
+
result=result,
|
|
495
|
+
user_email=user_email,
|
|
496
|
+
scope=scope,
|
|
497
|
+
output=output,
|
|
498
|
+
)
|
|
499
|
+
except Exception:
|
|
500
|
+
# Synthesis must never break the run record.
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
@staticmethod
|
|
504
|
+
def _format_synthesis_section(title: str, items: List[str]) -> str:
|
|
505
|
+
if not items:
|
|
506
|
+
return ""
|
|
507
|
+
return f"{title}:\n" + "\n".join(f"- {item}" for item in items[:5])
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _agent_synthesis_sections(*, goal: str, output: str, plan_steps: List[Dict[str, Any]], result: Any) -> Dict[str, List[str]]:
|
|
511
|
+
plan_descriptions = [
|
|
512
|
+
_compact_text(step.get("description") or step.get("name") or step.get("id"), limit=120)
|
|
513
|
+
for step in plan_steps
|
|
514
|
+
if isinstance(step, dict) and (step.get("description") or step.get("name") or step.get("id"))
|
|
515
|
+
]
|
|
516
|
+
sentences = [
|
|
517
|
+
sentence.strip(" -•\t")
|
|
518
|
+
for sentence in output.replace("\n", ". ").split(".")
|
|
519
|
+
if sentence.strip(" -•\t")
|
|
520
|
+
]
|
|
521
|
+
facts = [_compact_text(sentence, limit=180) for sentence in sentences[:4]]
|
|
522
|
+
review = getattr(result, "review", None) if isinstance(getattr(result, "review", None), dict) else {}
|
|
523
|
+
plan_review = getattr(result, "plan_review", None) if isinstance(getattr(result, "plan_review", None), dict) else {}
|
|
524
|
+
decision_seed = review.get("decision") or review.get("status") or plan_review.get("decision") or getattr(result, "status", "")
|
|
525
|
+
decisions = [
|
|
526
|
+
_compact_text(f"Run finished with {decision_seed}", limit=160),
|
|
527
|
+
_compact_text(f"Goal accepted: {goal}", limit=180),
|
|
528
|
+
]
|
|
529
|
+
followups = [
|
|
530
|
+
item for item in plan_descriptions
|
|
531
|
+
if any(token in item.lower() for token in ("next", "follow", "review", "verify", "test", "ship", "publish", "document", "implement"))
|
|
532
|
+
][:5]
|
|
533
|
+
if not followups:
|
|
534
|
+
followups = plan_descriptions[:3]
|
|
535
|
+
return {
|
|
536
|
+
"facts": [item for item in facts if item],
|
|
537
|
+
"decisions": [item for item in decisions if item],
|
|
538
|
+
"followups": [item for item in followups if item],
|
|
539
|
+
"plan_steps": plan_descriptions,
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
def _enqueue_agent_followups(
|
|
543
|
+
self,
|
|
544
|
+
*,
|
|
545
|
+
goal: str,
|
|
546
|
+
followups: List[str],
|
|
547
|
+
result: Any,
|
|
548
|
+
user_email: Optional[str],
|
|
549
|
+
scope: Optional[str],
|
|
550
|
+
output: str,
|
|
551
|
+
) -> None:
|
|
552
|
+
if self._review_sink is None:
|
|
553
|
+
return
|
|
554
|
+
for index, followup in enumerate(followups[:5], start=1):
|
|
555
|
+
try:
|
|
556
|
+
self._review_sink.create(
|
|
557
|
+
title=_compact_text(followup, limit=96) or f"Agent follow-up {index}",
|
|
558
|
+
summary=_compact_text(f"From goal: {goal}", limit=420),
|
|
559
|
+
source="agent_followup",
|
|
560
|
+
kind="task_draft",
|
|
561
|
+
payload={
|
|
562
|
+
"goal": _compact_text(goal, limit=300),
|
|
563
|
+
"followup": _compact_text(followup, limit=300),
|
|
564
|
+
"output_preview": _compact_text(output, limit=800),
|
|
565
|
+
"roles": getattr(result, "roles_run", None),
|
|
566
|
+
},
|
|
567
|
+
provenance={
|
|
568
|
+
"agent_id": getattr(result, "agent_id", ""),
|
|
569
|
+
"source_detail": "agent_runtime_followup",
|
|
570
|
+
"status": getattr(result, "status", ""),
|
|
571
|
+
},
|
|
572
|
+
user_email=user_email,
|
|
573
|
+
workspace_id=scope,
|
|
574
|
+
)
|
|
575
|
+
except Exception:
|
|
576
|
+
continue
|
|
577
|
+
|
|
418
578
|
def _post_run_hooks(
|
|
419
579
|
self,
|
|
420
580
|
*,
|
|
@@ -572,6 +732,9 @@ class AgentRuntime:
|
|
|
572
732
|
status=updated.get("status") or result.status,
|
|
573
733
|
retries=result.retries,
|
|
574
734
|
)
|
|
735
|
+
# Large-scale user-visible impact: successful runs enrich the Brain permanently.
|
|
736
|
+
if (updated.get("status") or result.status) in ("ok", "retried_ok"):
|
|
737
|
+
self._synthesize_brain_memory(goal=goal, result=result, user_email=user_email, scope=scope)
|
|
575
738
|
post_dispatch = self._post_run_hooks(
|
|
576
739
|
run_id=run_id,
|
|
577
740
|
result=result,
|
|
@@ -651,6 +814,9 @@ class AgentRuntime:
|
|
|
651
814
|
status=result.status,
|
|
652
815
|
retries=result.retries,
|
|
653
816
|
)
|
|
817
|
+
# Large-scale user-visible impact: successful runs enrich the Brain permanently.
|
|
818
|
+
if result.status in ("ok", "retried_ok"):
|
|
819
|
+
self._synthesize_brain_memory(goal=goal, result=result, user_email=user_email, scope=scope)
|
|
654
820
|
|
|
655
821
|
run_id = run.get("id") or run.get("run_id") if isinstance(run, dict) else None
|
|
656
822
|
post_dispatch = self._post_run_hooks(
|
|
@@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
21
21
|
from .contracts import multi_agent_contract
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
MULTI_AGENT_VERSION = "
|
|
24
|
+
MULTI_AGENT_VERSION = "9.0.0"
|
|
25
25
|
|
|
26
26
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
27
27
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared Brain Core utility helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
12
|
+
if not value:
|
|
13
|
+
return None
|
|
14
|
+
try:
|
|
15
|
+
return datetime.fromisoformat(str(value))
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256_file(path: Path) -> str:
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
with open(path, "rb") as fh:
|
|
23
|
+
for block in iter(lambda: fh.read(65536), b""):
|
|
24
|
+
digest.update(block)
|
|
25
|
+
return digest.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["parse_iso", "sha256_file"]
|
package/latticeai/__init__.py
CHANGED