ltcai 9.9.3 → 9.9.4
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 +43 -38
- package/docs/CHANGELOG.md +65 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +47 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/retrieval.py +61 -12
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +175 -6
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +264 -9
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- 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 +29 -28
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- package/static/app/assets/utils-uQYKXNeq.js +0 -7
|
@@ -13,6 +13,78 @@ class KnowledgeGraphVectorMixin:
|
|
|
13
13
|
retrieval/write helpers (e.g. self._vector_text_for_node) through the MRO.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
# ── embedder fingerprint (review Wave 2.2 — stale_embedder) ──────────────
|
|
17
|
+
# vector_search filters on the CURRENT model/dim, so swapping the embedder
|
|
18
|
+
# silently yields zero vector rows. The fingerprint persisted in graph_meta
|
|
19
|
+
# records which embedder actually built the index; a mismatch is surfaced
|
|
20
|
+
# as the honest ``stale_embedder`` signal instead of a silent degradation.
|
|
21
|
+
|
|
22
|
+
_EMBEDDER_FINGERPRINT_KEY = "embedder_fingerprint"
|
|
23
|
+
|
|
24
|
+
def _embedder_fingerprint_record(
|
|
25
|
+
self, conn: sqlite3.Connection
|
|
26
|
+
) -> Optional[Dict[str, Any]]:
|
|
27
|
+
"""Read the recorded embedder fingerprint from graph_meta (or None)."""
|
|
28
|
+
row = conn.execute(
|
|
29
|
+
"SELECT value FROM graph_meta WHERE key=?",
|
|
30
|
+
(self._EMBEDDER_FINGERPRINT_KEY,),
|
|
31
|
+
).fetchone()
|
|
32
|
+
if not row:
|
|
33
|
+
return None
|
|
34
|
+
payload = _safe_loads(row["value"])
|
|
35
|
+
if not isinstance(payload, dict) or not payload.get("model_id"):
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
dim = int(payload.get("dim") or 0)
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
dim = 0
|
|
41
|
+
return {"model_id": str(payload["model_id"]), "dim": dim}
|
|
42
|
+
|
|
43
|
+
def _write_embedder_fingerprint(self, conn: sqlite3.Connection) -> Dict[str, Any]:
|
|
44
|
+
"""Persist the CURRENT embedder identity (same transaction as caller)."""
|
|
45
|
+
fingerprint = {
|
|
46
|
+
"model_id": self._embedding_model.model_id,
|
|
47
|
+
"dim": int(self._embedding_model.dim),
|
|
48
|
+
}
|
|
49
|
+
conn.execute(
|
|
50
|
+
"INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
|
|
51
|
+
(self._EMBEDDER_FINGERPRINT_KEY, _json(fingerprint)),
|
|
52
|
+
)
|
|
53
|
+
return fingerprint
|
|
54
|
+
|
|
55
|
+
def record_embedder_fingerprint(self) -> Dict[str, Any]:
|
|
56
|
+
"""Record the current embedder (model_id + dim) as the index builder."""
|
|
57
|
+
with self._connect() as conn:
|
|
58
|
+
return self._write_embedder_fingerprint(conn)
|
|
59
|
+
|
|
60
|
+
def embedder_fingerprint_status(self) -> Dict[str, Any]:
|
|
61
|
+
"""Compare the current embedder against the recorded index fingerprint.
|
|
62
|
+
|
|
63
|
+
Returns ``{"current": {model_id, dim}, "recorded": {...} | None,
|
|
64
|
+
"stale_embedder": bool}``. ``stale_embedder`` is True only when a
|
|
65
|
+
fingerprint was recorded AND it differs from the current embedder —
|
|
66
|
+
an unrecorded index (legacy DBs, nothing indexed yet) is honestly
|
|
67
|
+
"unknown", never reported stale. Never raises.
|
|
68
|
+
"""
|
|
69
|
+
current = {
|
|
70
|
+
"model_id": self._embedding_model.model_id,
|
|
71
|
+
"dim": int(self._embedding_model.dim),
|
|
72
|
+
}
|
|
73
|
+
recorded: Optional[Dict[str, Any]] = None
|
|
74
|
+
try:
|
|
75
|
+
with self._connect() as conn:
|
|
76
|
+
recorded = self._embedder_fingerprint_record(conn)
|
|
77
|
+
except Exception: # noqa: BLE001 — status must degrade, never raise
|
|
78
|
+
recorded = None
|
|
79
|
+
stale = bool(
|
|
80
|
+
recorded is not None
|
|
81
|
+
and (
|
|
82
|
+
recorded.get("model_id") != current["model_id"]
|
|
83
|
+
or recorded.get("dim") != current["dim"]
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return {"current": current, "recorded": recorded, "stale_embedder": stale}
|
|
87
|
+
|
|
16
88
|
def _iter_vector_source_items(
|
|
17
89
|
self,
|
|
18
90
|
conn: sqlite3.Connection,
|
|
@@ -153,6 +225,11 @@ class KnowledgeGraphVectorMixin:
|
|
|
153
225
|
indexed += 1
|
|
154
226
|
else:
|
|
155
227
|
skipped += 1
|
|
228
|
+
if indexed and self._embedder_fingerprint_record(conn) is None:
|
|
229
|
+
# First successful vector write establishes the fingerprint;
|
|
230
|
+
# later incremental writes never overwrite it (only a full
|
|
231
|
+
# rebuild may flip it after an embedder swap).
|
|
232
|
+
self._write_embedder_fingerprint(conn)
|
|
156
233
|
summary.update(
|
|
157
234
|
{
|
|
158
235
|
"items_total": len(items),
|
|
@@ -254,6 +331,10 @@ class KnowledgeGraphVectorMixin:
|
|
|
254
331
|
op_id,
|
|
255
332
|
),
|
|
256
333
|
)
|
|
334
|
+
# A successful rebuild (re)establishes which embedder built
|
|
335
|
+
# the index — this is the only path that may flip a recorded
|
|
336
|
+
# fingerprint after an embedder swap.
|
|
337
|
+
self._write_embedder_fingerprint(conn)
|
|
257
338
|
return {
|
|
258
339
|
"status": "completed",
|
|
259
340
|
"operation_id": op_id,
|
|
@@ -401,8 +482,10 @@ class KnowledgeGraphVectorMixin:
|
|
|
401
482
|
"within_target": float(duration_ms) <= 10_000,
|
|
402
483
|
}
|
|
403
484
|
)
|
|
485
|
+
embedder_status = self.embedder_fingerprint_status()
|
|
404
486
|
return {
|
|
405
487
|
"status": "ready" if pending == 0 else "needs_reindex",
|
|
488
|
+
"embedder": embedder_status,
|
|
406
489
|
"storage": {
|
|
407
490
|
"db_path": str(self.db_path),
|
|
408
491
|
"backend": "sqlite",
|
|
@@ -449,7 +532,11 @@ class KnowledgeGraphVectorMixin:
|
|
|
449
532
|
"backlog_reasons": backlog_reasons,
|
|
450
533
|
"backlog_samples": backlog_samples,
|
|
451
534
|
"incremental_reindex_recommended": pending > 0,
|
|
452
|
-
|
|
535
|
+
# A stale embedder means every old-model row must be re-embedded;
|
|
536
|
+
# only a full rebuild (which re-records the fingerprint) heals it.
|
|
537
|
+
"full_rebuild_recommended": bool(
|
|
538
|
+
orphaned_items > 0 or embedder_status["stale_embedder"]
|
|
539
|
+
),
|
|
453
540
|
"latency_budget": latency_budget,
|
|
454
541
|
},
|
|
455
542
|
"operations": [
|
|
@@ -475,7 +562,11 @@ class KnowledgeGraphVectorMixin:
|
|
|
475
562
|
|
|
476
563
|
Reduces :meth:`index_status` (``pending = missing + stale``) to the
|
|
477
564
|
fixed contract ``{"status", "pending_items", "total_items", "detail"}``
|
|
478
|
-
with ``status`` in ``ready`` / ``pending`` / ``
|
|
565
|
+
with ``status`` in ``ready`` / ``pending`` / ``stale_embedder`` /
|
|
566
|
+
``unavailable``. ``stale_embedder`` (review Wave 2.2) is reported only
|
|
567
|
+
when the recorded embedder fingerprint differs from the current
|
|
568
|
+
embedder AND rows indexed under the old model still exist — the index
|
|
569
|
+
needs a full rebuild, not an incremental sync.
|
|
479
570
|
|
|
480
571
|
Never raises: environments where the embedding provider or index
|
|
481
572
|
storage cannot be used report ``"unavailable"`` with the cause in
|
|
@@ -492,6 +583,35 @@ class KnowledgeGraphVectorMixin:
|
|
|
492
583
|
}
|
|
493
584
|
pending = int(status.get("pending_items") or 0)
|
|
494
585
|
total = int(status.get("source_items") or 0)
|
|
586
|
+
embedder = status.get("embedder") or {}
|
|
587
|
+
if embedder.get("stale_embedder"):
|
|
588
|
+
old_model_rows = 0
|
|
589
|
+
try:
|
|
590
|
+
with self._connect() as conn:
|
|
591
|
+
old_model_rows = int(
|
|
592
|
+
conn.execute(
|
|
593
|
+
"SELECT COUNT(*) AS c FROM vector_embeddings "
|
|
594
|
+
"WHERE embedding_model<>? OR embedding_dim<>?",
|
|
595
|
+
(
|
|
596
|
+
self._embedding_model.model_id,
|
|
597
|
+
int(self._embedding_model.dim),
|
|
598
|
+
),
|
|
599
|
+
).fetchone()["c"]
|
|
600
|
+
)
|
|
601
|
+
except Exception: # noqa: BLE001 — keep the existing statuses on failure
|
|
602
|
+
old_model_rows = 0
|
|
603
|
+
if old_model_rows > 0:
|
|
604
|
+
recorded = embedder.get("recorded") or {}
|
|
605
|
+
return {
|
|
606
|
+
"status": "stale_embedder",
|
|
607
|
+
"pending_items": pending,
|
|
608
|
+
"total_items": total,
|
|
609
|
+
"detail": (
|
|
610
|
+
f"embedding model changed ({recorded.get('model_id')} → "
|
|
611
|
+
f"{self._embedding_model.model_id}); {old_model_rows} indexed "
|
|
612
|
+
"rows still use the previous model — run a full vector index rebuild"
|
|
613
|
+
),
|
|
614
|
+
}
|
|
495
615
|
if pending > 0:
|
|
496
616
|
return {
|
|
497
617
|
"status": "pending",
|
|
@@ -14,6 +14,7 @@ any cloud service. Two complementary mechanisms, both fully local:
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import logging
|
|
17
18
|
import os
|
|
18
19
|
import shutil
|
|
19
20
|
import sqlite3
|
|
@@ -74,9 +75,12 @@ def _checkpoint_sqlite(db_path: Path) -> None:
|
|
|
74
75
|
|
|
75
76
|
|
|
76
77
|
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
77
|
-
|
|
78
|
+
try:
|
|
78
79
|
shutil.copy2(backup, path)
|
|
79
|
-
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
# The backup vanished (or never existed — transient -wal/-shm): the
|
|
82
|
+
# honest reconstruction is "no such sibling", never a crash that
|
|
83
|
+
# masks the error that triggered the rollback.
|
|
80
84
|
path.unlink(missing_ok=True)
|
|
81
85
|
|
|
82
86
|
|
|
@@ -101,10 +105,22 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
|
101
105
|
sibling.unlink(missing_ok=True)
|
|
102
106
|
os.replace(tmp, dest)
|
|
103
107
|
except Exception:
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
# Recovery I/O must never replace the swap error being reported —
|
|
109
|
+
# an exception raised here would mask it (the CI-observed
|
|
110
|
+
# "[Errno 2]" over the real failure). Best-effort restore, then
|
|
111
|
+
# always re-raise the original.
|
|
112
|
+
try:
|
|
113
|
+
tmp.unlink(missing_ok=True)
|
|
114
|
+
except OSError as cleanup_exc:
|
|
115
|
+
logging.warning("restore tmp cleanup failed: %s", cleanup_exc)
|
|
106
116
|
for sibling in _sqlite_siblings(dest):
|
|
107
|
-
|
|
117
|
+
try:
|
|
118
|
+
_restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
|
|
119
|
+
except OSError as rollback_exc:
|
|
120
|
+
logging.warning(
|
|
121
|
+
"restore sibling rollback incomplete for %s: %s",
|
|
122
|
+
sibling, rollback_exc,
|
|
123
|
+
)
|
|
108
124
|
raise
|
|
109
125
|
|
|
110
126
|
|
|
@@ -127,12 +143,17 @@ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path)
|
|
|
127
143
|
shutil.rmtree(dest)
|
|
128
144
|
os.replace(staged, dest)
|
|
129
145
|
except Exception:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
# Same masking guard as the sqlite swap: rollback I/O is
|
|
147
|
+
# best-effort and the original failure always propagates.
|
|
148
|
+
try:
|
|
149
|
+
if staged.exists():
|
|
150
|
+
shutil.rmtree(staged)
|
|
151
|
+
if dest.exists():
|
|
152
|
+
shutil.rmtree(dest)
|
|
153
|
+
if backup.exists():
|
|
154
|
+
shutil.copytree(backup, dest)
|
|
155
|
+
except OSError as rollback_exc:
|
|
156
|
+
logging.warning("blob tree rollback incomplete: %s", rollback_exc)
|
|
136
157
|
raise
|
|
137
158
|
|
|
138
159
|
|
|
@@ -307,7 +328,15 @@ class KGPortabilityService:
|
|
|
307
328
|
blob_src = tmp / "blobs"
|
|
308
329
|
_replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
|
|
309
330
|
except Exception:
|
|
310
|
-
|
|
331
|
+
# The rollback is best-effort recovery; an I/O slip inside
|
|
332
|
+
# it must never mask the restore failure being reported.
|
|
333
|
+
try:
|
|
334
|
+
_rollback_sqlite_from_backup(db_dest, backup_dir)
|
|
335
|
+
except OSError as rollback_exc:
|
|
336
|
+
logging.warning(
|
|
337
|
+
"pre-restore rollback incomplete (backup kept at %s): %s",
|
|
338
|
+
backup_dir, rollback_exc,
|
|
339
|
+
)
|
|
311
340
|
raise
|
|
312
341
|
stats = self._kg.stats()
|
|
313
342
|
return {
|
|
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
|
|
|
48
48
|
from ..utils import now_iso as _now
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
MULTI_AGENT_VERSION = "9.9.
|
|
51
|
+
MULTI_AGENT_VERSION = "9.9.4"
|
|
52
52
|
|
|
53
53
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
54
54
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/chat.py
CHANGED
|
@@ -45,6 +45,7 @@ from latticeai.api.chat_helpers import (
|
|
|
45
45
|
workspace_scope_from_request,
|
|
46
46
|
)
|
|
47
47
|
from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
|
|
48
|
+
from latticeai.core.run_store import AgentRunStore
|
|
48
49
|
from latticeai.api.chat_intents import ChatIntentController
|
|
49
50
|
from latticeai.api.chat_stream import stream_chat
|
|
50
51
|
from latticeai.services.app_context import AppContext
|
|
@@ -191,6 +192,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
191
192
|
agent_root=AGENT_ROOT,
|
|
192
193
|
ensure_agent_root=ensure_agent_root,
|
|
193
194
|
funnel_metrics=context.funnel_metrics,
|
|
195
|
+
run_store=(
|
|
196
|
+
AgentRunStore(Path(context.data_dir) / "agent_runs")
|
|
197
|
+
if context.data_dir
|
|
198
|
+
else None
|
|
199
|
+
),
|
|
194
200
|
)
|
|
195
201
|
intent_controller = ChatIntentController(
|
|
196
202
|
model_router=model_router,
|
|
@@ -11,7 +11,7 @@ import threading
|
|
|
11
11
|
import time
|
|
12
12
|
from datetime import datetime, timedelta, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
15
|
|
|
16
16
|
from fastapi import APIRouter, HTTPException, Request
|
|
17
17
|
|
|
@@ -19,6 +19,11 @@ from lattice_brain.runtime.hooks import dispatch_tool
|
|
|
19
19
|
from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
|
|
20
20
|
from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
|
|
21
21
|
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
22
|
+
from latticeai.core.run_store import (
|
|
23
|
+
AgentRunStore,
|
|
24
|
+
hash_approval_token,
|
|
25
|
+
restore_run_context,
|
|
26
|
+
)
|
|
22
27
|
from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
|
|
23
28
|
|
|
24
29
|
|
|
@@ -42,6 +47,7 @@ class AgentHTTPController:
|
|
|
42
47
|
agent_root: Path,
|
|
43
48
|
ensure_agent_root: Any,
|
|
44
49
|
funnel_metrics: Any = None,
|
|
50
|
+
run_store: Any = None,
|
|
45
51
|
) -> None:
|
|
46
52
|
self.runtime = runtime
|
|
47
53
|
self.model_router = model_router
|
|
@@ -68,6 +74,16 @@ class AgentHTTPController:
|
|
|
68
74
|
# explicit human-in-loop context flow above).
|
|
69
75
|
self._approvals: Dict[str, Dict[str, Any]] = {}
|
|
70
76
|
self._approval_ttl_seconds = 10 * 60
|
|
77
|
+
# Durable side of the approval loop (review Wave 0.1): paused runs are
|
|
78
|
+
# mirrored to disk so a valid token still resumes after a restart or
|
|
79
|
+
# from another worker process. In-memory stays the fast path.
|
|
80
|
+
self.run_store = run_store if run_store is not None else AgentRunStore(
|
|
81
|
+
Path(base_dir) / "data" / "agent_runs"
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
self.run_store.sweep_expired()
|
|
85
|
+
except Exception as exc: # noqa: BLE001 — hygiene must not break startup
|
|
86
|
+
logging.warning("agent run store sweep failed: %s", exc)
|
|
71
87
|
self._background_tasks: set[asyncio.Task] = set()
|
|
72
88
|
|
|
73
89
|
def register_routes(self, router: APIRouter) -> None:
|
|
@@ -83,6 +99,10 @@ class AgentHTTPController:
|
|
|
83
99
|
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
84
100
|
return await self.resume(req, request)
|
|
85
101
|
|
|
102
|
+
@router.get("/agent/approvals")
|
|
103
|
+
async def agent_approvals(request: Request):
|
|
104
|
+
return self.pending_approvals(request)
|
|
105
|
+
|
|
86
106
|
def _schedule_background_task(self, coro: Any) -> None:
|
|
87
107
|
task = asyncio.create_task(coro)
|
|
88
108
|
self._background_tasks.add(task)
|
|
@@ -180,8 +200,17 @@ class AgentHTTPController:
|
|
|
180
200
|
"results": results,
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
async def agent(
|
|
184
|
-
|
|
203
|
+
async def agent(
|
|
204
|
+
self,
|
|
205
|
+
req: AgentRequest,
|
|
206
|
+
request: Request,
|
|
207
|
+
on_step: Any = None,
|
|
208
|
+
) -> Dict[str, Any]:
|
|
209
|
+
"""Plan and execute a natural-language local agent run.
|
|
210
|
+
|
|
211
|
+
``on_step`` (optional) is a per-run step observer — the live SSE
|
|
212
|
+
route attaches one so the client sees progress while EXECUTING.
|
|
213
|
+
"""
|
|
185
214
|
current_user = self.require_user(request)
|
|
186
215
|
self.enforce_rate_limit(current_user, "agent")
|
|
187
216
|
effective_email = self.authenticated_identity(current_user, req.user_email)
|
|
@@ -207,6 +236,8 @@ class AgentHTTPController:
|
|
|
207
236
|
max_steps = max(1, min(req.max_steps, 50))
|
|
208
237
|
max_retry = 3
|
|
209
238
|
ctx = AgentRunContext()
|
|
239
|
+
if on_step is not None:
|
|
240
|
+
ctx.on_step = on_step
|
|
210
241
|
ctx.executing_model = req.executing_model
|
|
211
242
|
ctx.reviewing_model = req.reviewing_model
|
|
212
243
|
ctx.state = AgentState.PLANNING
|
|
@@ -289,6 +320,26 @@ class AgentHTTPController:
|
|
|
289
320
|
"expires_monotonic": now_monotonic + self._approval_ttl_seconds,
|
|
290
321
|
"expires_at": expires_at,
|
|
291
322
|
}
|
|
323
|
+
# Durable mirror: a restart between pause and resume must not orphan
|
|
324
|
+
# the run. Best-effort — the in-memory pause still answers on failure.
|
|
325
|
+
try:
|
|
326
|
+
self.run_store.save(
|
|
327
|
+
run_id,
|
|
328
|
+
ctx=ctx,
|
|
329
|
+
req_payload=req.model_dump(),
|
|
330
|
+
language_hint=language_hint,
|
|
331
|
+
user=current_user,
|
|
332
|
+
token=approval_token,
|
|
333
|
+
expires_epoch=time.time() + self._approval_ttl_seconds,
|
|
334
|
+
expires_at=expires_at,
|
|
335
|
+
)
|
|
336
|
+
except Exception as exc: # noqa: BLE001
|
|
337
|
+
logging.warning("agent run store persist failed: %s", exc)
|
|
338
|
+
if self.funnel_metrics is not None:
|
|
339
|
+
try:
|
|
340
|
+
self.funnel_metrics.increment("approval_pauses")
|
|
341
|
+
except Exception as exc: # noqa: BLE001 — advisory only
|
|
342
|
+
logging.warning("funnel metrics increment failed: %s", exc)
|
|
292
343
|
ctx.state_history.append(AgentState.WAITING_APPROVAL.value)
|
|
293
344
|
message = (
|
|
294
345
|
"이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
|
|
@@ -318,6 +369,101 @@ class AgentHTTPController:
|
|
|
318
369
|
for run_id, entry in list(self._approvals.items()):
|
|
319
370
|
if now_monotonic >= entry["expires_monotonic"]:
|
|
320
371
|
self._approvals.pop(run_id, None)
|
|
372
|
+
try:
|
|
373
|
+
self.run_store.delete(run_id)
|
|
374
|
+
except Exception: # noqa: BLE001 — hygiene only
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
def pending_approvals(self, request: Request) -> Dict[str, Any]:
|
|
378
|
+
"""Unexpired paused runs for the current user (memory ∪ disk).
|
|
379
|
+
|
|
380
|
+
Lets the UI re-surface an approval card after a reload/restart instead
|
|
381
|
+
of the run silently vanishing.
|
|
382
|
+
"""
|
|
383
|
+
current_user = self.require_user(request)
|
|
384
|
+
now_monotonic = time.monotonic()
|
|
385
|
+
pending: Dict[str, Dict[str, Any]] = {}
|
|
386
|
+
with self._pending_lock:
|
|
387
|
+
for run_id, entry in self._approvals.items():
|
|
388
|
+
if entry["user"] != current_user:
|
|
389
|
+
continue
|
|
390
|
+
if now_monotonic >= entry["expires_monotonic"]:
|
|
391
|
+
continue
|
|
392
|
+
plan = getattr(entry["ctx"], "plan", {}) or {}
|
|
393
|
+
pending[run_id] = {
|
|
394
|
+
"run_id": run_id,
|
|
395
|
+
"goal": str(plan.get("goal") or "")[:200],
|
|
396
|
+
"expires_at": entry["expires_at"],
|
|
397
|
+
}
|
|
398
|
+
try:
|
|
399
|
+
for summary in self.run_store.pending_summaries(current_user):
|
|
400
|
+
run_id = summary.get("run_id")
|
|
401
|
+
if run_id and run_id not in pending:
|
|
402
|
+
pending[run_id] = {
|
|
403
|
+
"run_id": run_id,
|
|
404
|
+
"goal": summary.get("goal") or "",
|
|
405
|
+
"expires_at": summary.get("expires_at"),
|
|
406
|
+
}
|
|
407
|
+
except Exception as exc: # noqa: BLE001 — disk listing is best-effort
|
|
408
|
+
logging.warning("agent run store listing failed: %s", exc)
|
|
409
|
+
return {"pending": sorted(pending.values(), key=lambda p: p["run_id"])}
|
|
410
|
+
|
|
411
|
+
def _restore_persisted_approval(self, run_id: str) -> Optional[Dict[str, Any]]:
|
|
412
|
+
"""Rebuild an approval entry from disk after a restart.
|
|
413
|
+
|
|
414
|
+
Returns an entry shaped like the in-memory ones but carrying
|
|
415
|
+
``token_hash`` instead of the plaintext token. Expired records are
|
|
416
|
+
deleted and reported via the same 410 contract as the memory path.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
record = self.run_store.load(run_id)
|
|
420
|
+
except Exception as exc: # noqa: BLE001 — load error == not found
|
|
421
|
+
logging.warning("agent run store load failed: %s", exc)
|
|
422
|
+
return None
|
|
423
|
+
if record is None:
|
|
424
|
+
return None
|
|
425
|
+
req_payload = record.get("req") or {}
|
|
426
|
+
if time.time() >= float(record.get("expires_epoch") or 0):
|
|
427
|
+
try:
|
|
428
|
+
self.run_store.delete(run_id)
|
|
429
|
+
except Exception: # noqa: BLE001
|
|
430
|
+
pass
|
|
431
|
+
raise HTTPException(
|
|
432
|
+
status_code=410,
|
|
433
|
+
detail={
|
|
434
|
+
"error": "approval_expired",
|
|
435
|
+
"message": "Approval token expired. Start a new request.",
|
|
436
|
+
"replan": {"message": str(req_payload.get("message") or "")},
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
try:
|
|
440
|
+
restored_req = AgentRequest(**req_payload)
|
|
441
|
+
except Exception as exc: # noqa: BLE001 — unreconstructable == not found
|
|
442
|
+
logging.warning("agent run store request restore failed: %s", exc)
|
|
443
|
+
return None
|
|
444
|
+
remaining = max(1.0, float(record["expires_epoch"]) - time.time())
|
|
445
|
+
return {
|
|
446
|
+
"ctx": restore_run_context(record.get("ctx") or {}),
|
|
447
|
+
"req": restored_req,
|
|
448
|
+
"language_hint": str(record.get("language_hint") or "English"),
|
|
449
|
+
"user": record.get("user"),
|
|
450
|
+
"token_hash": str(record.get("token_hash") or ""),
|
|
451
|
+
"expires_monotonic": time.monotonic() + remaining,
|
|
452
|
+
"expires_at": record.get("expires_at"),
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def _token_matches(entry: Dict[str, Any], supplied: str) -> bool:
|
|
457
|
+
"""Constant-time token check for both plaintext and hashed entries."""
|
|
458
|
+
if not supplied:
|
|
459
|
+
return False
|
|
460
|
+
stored = entry.get("token")
|
|
461
|
+
if stored is not None:
|
|
462
|
+
return secrets.compare_digest(str(stored), supplied)
|
|
463
|
+
stored_hash = str(entry.get("token_hash") or "")
|
|
464
|
+
return bool(stored_hash) and secrets.compare_digest(
|
|
465
|
+
stored_hash, hash_approval_token(supplied)
|
|
466
|
+
)
|
|
321
467
|
|
|
322
468
|
async def _finish(
|
|
323
469
|
self,
|
|
@@ -468,6 +614,10 @@ class AgentHTTPController:
|
|
|
468
614
|
now_monotonic = time.monotonic()
|
|
469
615
|
with self._pending_lock:
|
|
470
616
|
entry = self._approvals.get(req.run_id or "")
|
|
617
|
+
if entry is None:
|
|
618
|
+
# Restart survival (review Wave 0.1): the in-memory dict is
|
|
619
|
+
# gone but the durable record may still be valid.
|
|
620
|
+
entry = self._restore_persisted_approval(req.run_id or "")
|
|
471
621
|
if entry is None:
|
|
472
622
|
raise HTTPException(
|
|
473
623
|
status_code=404,
|
|
@@ -480,24 +630,43 @@ class AgentHTTPController:
|
|
|
480
630
|
)
|
|
481
631
|
if now_monotonic >= entry["expires_monotonic"]:
|
|
482
632
|
self._approvals.pop(req.run_id, None)
|
|
633
|
+
try:
|
|
634
|
+
self.run_store.delete(req.run_id or "")
|
|
635
|
+
except Exception: # noqa: BLE001
|
|
636
|
+
pass
|
|
483
637
|
raise HTTPException(
|
|
484
638
|
status_code=410,
|
|
485
|
-
detail=
|
|
639
|
+
detail={
|
|
640
|
+
"error": "approval_expired",
|
|
641
|
+
"message": "Approval token expired. Start a new request.",
|
|
642
|
+
"replan": {"message": getattr(entry.get("req"), "message", "")},
|
|
643
|
+
},
|
|
486
644
|
)
|
|
487
|
-
|
|
488
|
-
if not supplied or not secrets.compare_digest(entry["token"], supplied):
|
|
645
|
+
if not self._token_matches(entry, req.approval_token or ""):
|
|
489
646
|
raise HTTPException(
|
|
490
647
|
status_code=403,
|
|
491
648
|
detail="Invalid approval token for this run.",
|
|
492
649
|
)
|
|
493
650
|
# Token validated — the pending run is consumed either way.
|
|
494
651
|
self._approvals.pop(req.run_id, None)
|
|
652
|
+
try:
|
|
653
|
+
self.run_store.delete(req.run_id or "")
|
|
654
|
+
except Exception as exc: # noqa: BLE001 — consumption must proceed
|
|
655
|
+
logging.warning("agent run store delete failed: %s", exc)
|
|
495
656
|
self._purge_expired_approvals_locked(now_monotonic)
|
|
496
657
|
|
|
497
658
|
ctx: AgentRunContext = entry["ctx"]
|
|
498
659
|
original_request: AgentRequest = entry["req"]
|
|
499
660
|
language_hint: str = entry["language_hint"]
|
|
500
661
|
|
|
662
|
+
if self.funnel_metrics is not None:
|
|
663
|
+
# approval_resume_rate = resumes / pauses (review §4.3): counted on
|
|
664
|
+
# every token-valid resume decision, approve and deny alike.
|
|
665
|
+
try:
|
|
666
|
+
self.funnel_metrics.increment("approval_resumes")
|
|
667
|
+
except Exception as exc: # noqa: BLE001 — advisory only
|
|
668
|
+
logging.warning("funnel metrics increment failed: %s", exc)
|
|
669
|
+
|
|
501
670
|
approved = req.approve if req.approve is not None else req.approved
|
|
502
671
|
if not approved:
|
|
503
672
|
message = "사용자가 계획을 취소했습니다."
|
|
@@ -17,7 +17,11 @@ from latticeai.api.chat_helpers import (
|
|
|
17
17
|
format_network_status,
|
|
18
18
|
inline_file_action_content,
|
|
19
19
|
)
|
|
20
|
-
from latticeai.api.chat_stream import
|
|
20
|
+
from latticeai.api.chat_stream import (
|
|
21
|
+
agent_live_stream,
|
|
22
|
+
agent_payload_stream,
|
|
23
|
+
single_answer_response,
|
|
24
|
+
)
|
|
21
25
|
from latticeai.core.agent import AgentState
|
|
22
26
|
from latticeai.core.file_generation import (
|
|
23
27
|
PREVIEWABLE_EXTENSIONS,
|
|
@@ -596,31 +600,46 @@ class ChatIntentController:
|
|
|
596
600
|
user_nickname=req.user_nickname,
|
|
597
601
|
workspace_id=workspace_id,
|
|
598
602
|
)
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
603
|
+
|
|
604
|
+
def finalize(result: Dict) -> str:
|
|
605
|
+
"""History/funnel side effects once the run has a terminal payload."""
|
|
606
|
+
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
607
|
+
# UX funnel (backlog #16): a file-intent request that the agent loop
|
|
608
|
+
# finished without producing a single artifact is the "code-only"
|
|
609
|
+
# failure mode the >95% real-file goal watches. Paused/failed runs
|
|
610
|
+
# count neither way — they did not answer with code instead of files.
|
|
611
|
+
delivered = bool(result.get("created_files") or result.get("artifacts"))
|
|
612
|
+
if delivered:
|
|
613
|
+
self._funnel_increment("real_file_delivered")
|
|
614
|
+
elif str(result.get("status") or "") == "ok":
|
|
615
|
+
self._funnel_increment("code_only_responses")
|
|
616
|
+
self.notify("user", req.message, req.source)
|
|
617
|
+
self.notify("assistant", answer, req.source)
|
|
618
|
+
result["routed_to_agent"] = True
|
|
619
|
+
return answer
|
|
620
|
+
|
|
613
621
|
if req.stream:
|
|
622
|
+
# Live loop visibility (review Wave 1.1): run the agent inside the
|
|
623
|
+
# SSE generator so step events stream while EXECUTING; the final
|
|
624
|
+
# frames keep the exact historical payload shape.
|
|
625
|
+
async def start(observer):
|
|
626
|
+
return await self.agent_controller.agent(
|
|
627
|
+
agent_request, request, on_step=observer
|
|
628
|
+
)
|
|
629
|
+
|
|
614
630
|
return StreamingResponse(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
result,
|
|
631
|
+
agent_live_stream(
|
|
632
|
+
start,
|
|
618
633
|
router=self.router,
|
|
619
634
|
model_id=model_id,
|
|
635
|
+
finalize=finalize,
|
|
620
636
|
),
|
|
621
637
|
media_type="text/event-stream",
|
|
622
638
|
headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
|
|
623
639
|
)
|
|
640
|
+
|
|
641
|
+
result = await self.agent_controller.agent(agent_request, request)
|
|
642
|
+
finalize(result)
|
|
624
643
|
return JSONResponse(content=result)
|
|
625
644
|
|
|
626
645
|
|