ltcai 9.9.2 → 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 +44 -39
- package/docs/CHANGELOG.md +109 -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/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- 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/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- 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/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- 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 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.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-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- 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-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Durable persistence for paused agent runs (review 2026-07-25 Wave 0.1).
|
|
2
|
+
|
|
3
|
+
The ``awaiting_approval`` loop parked runs in an in-process dict, so a server
|
|
4
|
+
restart between pause and resume turned every outstanding approval into a 404
|
|
5
|
+
("승인하려다 사라짐"). This store writes each paused run to one JSON file under
|
|
6
|
+
the data dir, so a valid, unexpired token can resume its run across restarts
|
|
7
|
+
and across worker processes.
|
|
8
|
+
|
|
9
|
+
Design constraints (deliberate, matching :mod:`latticeai.services.funnel_metrics`):
|
|
10
|
+
|
|
11
|
+
* **One JSON file per run**, atomic replace on write — no new dependencies.
|
|
12
|
+
* **Fail-open on save, fail-closed on resume.** A persistence error never
|
|
13
|
+
breaks the pause response (the in-memory path still works); a load error on
|
|
14
|
+
resume is treated as "run not found".
|
|
15
|
+
* **Tokens are never stored in plaintext.** Only the SHA-256 hex digest is
|
|
16
|
+
persisted; resume hashes the presented token and compares digests.
|
|
17
|
+
* **Wall-clock expiry.** The in-memory dict keeps its monotonic deadline, but
|
|
18
|
+
monotonic time does not survive a restart — the durable record carries an
|
|
19
|
+
absolute UTC epoch and files past it are swept, never resumed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
from latticeai.core.agent import AgentRunContext, AgentState
|
|
35
|
+
from latticeai.core.agent_trace import LoopTrace
|
|
36
|
+
|
|
37
|
+
LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# run_id comes from ``secrets.token_urlsafe`` — validate before any path use
|
|
40
|
+
# so a crafted id can never traverse out of the store directory.
|
|
41
|
+
_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hash_approval_token(token: str) -> str:
|
|
45
|
+
"""Stable digest used both at save and at resume comparison time."""
|
|
46
|
+
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serialize_run_context(ctx: AgentRunContext) -> Dict[str, Any]:
|
|
50
|
+
"""Reduce an :class:`AgentRunContext` to plain JSON-safe data."""
|
|
51
|
+
return {
|
|
52
|
+
"state": ctx.state.value,
|
|
53
|
+
"plan": ctx.plan,
|
|
54
|
+
"transcript": ctx.transcript,
|
|
55
|
+
"retry_count": ctx.retry_count,
|
|
56
|
+
"state_history": ctx.state_history,
|
|
57
|
+
"corrections": ctx.corrections,
|
|
58
|
+
"final_message": ctx.final_message,
|
|
59
|
+
"rollback_log": ctx.rollback_log,
|
|
60
|
+
"executing_model": ctx.executing_model,
|
|
61
|
+
"reviewing_model": ctx.reviewing_model,
|
|
62
|
+
"approved_by_human": ctx.approved_by_human,
|
|
63
|
+
"trace": {"events": ctx.trace.events, "truncated": ctx.trace.truncated},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def restore_run_context(payload: Dict[str, Any]) -> AgentRunContext:
|
|
68
|
+
"""Rebuild an :class:`AgentRunContext` from :func:`serialize_run_context`."""
|
|
69
|
+
ctx = AgentRunContext()
|
|
70
|
+
try:
|
|
71
|
+
ctx.state = AgentState(str(payload.get("state") or AgentState.WAITING_APPROVAL.value))
|
|
72
|
+
except ValueError:
|
|
73
|
+
ctx.state = AgentState.WAITING_APPROVAL
|
|
74
|
+
ctx.plan = dict(payload.get("plan") or {})
|
|
75
|
+
ctx.transcript = list(payload.get("transcript") or [])
|
|
76
|
+
ctx.retry_count = int(payload.get("retry_count") or 0)
|
|
77
|
+
ctx.state_history = list(payload.get("state_history") or [])
|
|
78
|
+
ctx.corrections = list(payload.get("corrections") or [])
|
|
79
|
+
ctx.final_message = str(payload.get("final_message") or "")
|
|
80
|
+
ctx.rollback_log = list(payload.get("rollback_log") or [])
|
|
81
|
+
ctx.executing_model = payload.get("executing_model")
|
|
82
|
+
ctx.reviewing_model = payload.get("reviewing_model")
|
|
83
|
+
ctx.approved_by_human = bool(payload.get("approved_by_human"))
|
|
84
|
+
trace_payload = payload.get("trace") or {}
|
|
85
|
+
trace = LoopTrace()
|
|
86
|
+
trace.events = list(trace_payload.get("events") or [])
|
|
87
|
+
trace.truncated = int(trace_payload.get("truncated") or 0)
|
|
88
|
+
ctx.trace = trace
|
|
89
|
+
return ctx
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AgentRunStore:
|
|
93
|
+
"""One-JSON-file-per-run persistence for paused approval runs."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, root: Path) -> None:
|
|
96
|
+
self._root = Path(root)
|
|
97
|
+
|
|
98
|
+
def _path_for(self, run_id: str) -> Optional[Path]:
|
|
99
|
+
if not _RUN_ID_RE.fullmatch(str(run_id or "")):
|
|
100
|
+
return None
|
|
101
|
+
return self._root / f"{run_id}.json"
|
|
102
|
+
|
|
103
|
+
# ── writes ───────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def save(
|
|
106
|
+
self,
|
|
107
|
+
run_id: str,
|
|
108
|
+
*,
|
|
109
|
+
ctx: AgentRunContext,
|
|
110
|
+
req_payload: Dict[str, Any],
|
|
111
|
+
language_hint: str,
|
|
112
|
+
user: str,
|
|
113
|
+
token: str,
|
|
114
|
+
expires_epoch: float,
|
|
115
|
+
expires_at: str,
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Persist a paused run. Best-effort: returns False on any failure."""
|
|
118
|
+
path = self._path_for(run_id)
|
|
119
|
+
if path is None:
|
|
120
|
+
return False
|
|
121
|
+
record = {
|
|
122
|
+
"version": 1,
|
|
123
|
+
"run_id": run_id,
|
|
124
|
+
"user": user,
|
|
125
|
+
"language_hint": language_hint,
|
|
126
|
+
"token_hash": hash_approval_token(token),
|
|
127
|
+
"expires_epoch": float(expires_epoch),
|
|
128
|
+
"expires_at": expires_at,
|
|
129
|
+
"req": req_payload,
|
|
130
|
+
"ctx": serialize_run_context(ctx),
|
|
131
|
+
}
|
|
132
|
+
try:
|
|
133
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
fd, tmp_name = tempfile.mkstemp(prefix=path.name, dir=str(self._root))
|
|
135
|
+
try:
|
|
136
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
137
|
+
json.dump(record, handle, ensure_ascii=False)
|
|
138
|
+
os.replace(tmp_name, path)
|
|
139
|
+
finally:
|
|
140
|
+
if os.path.exists(tmp_name):
|
|
141
|
+
try:
|
|
142
|
+
os.unlink(tmp_name)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
return True
|
|
146
|
+
except Exception as exc: # noqa: BLE001 — pause must still answer without disk
|
|
147
|
+
LOGGER.warning("agent run store save failed for %s: %s", run_id, exc)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def delete(self, run_id: str) -> None:
|
|
151
|
+
path = self._path_for(run_id)
|
|
152
|
+
if path is None:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
path.unlink(missing_ok=True)
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
LOGGER.warning("agent run store delete failed for %s: %s", run_id, exc)
|
|
158
|
+
|
|
159
|
+
# ── reads ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def load(self, run_id: str) -> Optional[Dict[str, Any]]:
|
|
162
|
+
"""Raw persisted record, or None when missing/corrupt/invalid id."""
|
|
163
|
+
path = self._path_for(run_id)
|
|
164
|
+
if path is None:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
return None
|
|
170
|
+
except Exception as exc: # noqa: BLE001 — corrupt record == not found
|
|
171
|
+
LOGGER.warning("agent run store load failed for %s: %s", run_id, exc)
|
|
172
|
+
return None
|
|
173
|
+
if not isinstance(record, dict) or record.get("run_id") != run_id:
|
|
174
|
+
return None
|
|
175
|
+
return record
|
|
176
|
+
|
|
177
|
+
def pending_summaries(self, user: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
178
|
+
"""Unexpired pending runs (optionally per user) for surfacing in UI."""
|
|
179
|
+
summaries: List[Dict[str, Any]] = []
|
|
180
|
+
now = time.time()
|
|
181
|
+
try:
|
|
182
|
+
paths = sorted(self._root.glob("*.json"))
|
|
183
|
+
except OSError:
|
|
184
|
+
return summaries
|
|
185
|
+
for path in paths:
|
|
186
|
+
try:
|
|
187
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
188
|
+
except Exception: # noqa: BLE001
|
|
189
|
+
continue
|
|
190
|
+
if not isinstance(record, dict):
|
|
191
|
+
continue
|
|
192
|
+
if float(record.get("expires_epoch") or 0) <= now:
|
|
193
|
+
continue
|
|
194
|
+
if user is not None and record.get("user") != user:
|
|
195
|
+
continue
|
|
196
|
+
plan = (record.get("ctx") or {}).get("plan") or {}
|
|
197
|
+
summaries.append({
|
|
198
|
+
"run_id": record.get("run_id"),
|
|
199
|
+
"user": record.get("user"),
|
|
200
|
+
"goal": str(plan.get("goal") or "")[:200],
|
|
201
|
+
"expires_at": record.get("expires_at"),
|
|
202
|
+
})
|
|
203
|
+
return summaries
|
|
204
|
+
|
|
205
|
+
def sweep_expired(
|
|
206
|
+
self,
|
|
207
|
+
now_epoch: Optional[float] = None,
|
|
208
|
+
*,
|
|
209
|
+
retention_seconds: float = 86400.0,
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Remove long-expired run files; returns how many were removed.
|
|
212
|
+
|
|
213
|
+
Recently expired records are *kept* for ``retention_seconds`` so that
|
|
214
|
+
a resume attempt after expiry can still answer 410 with a one-click
|
|
215
|
+
replan hint (the original request message) instead of a bare 404.
|
|
216
|
+
"""
|
|
217
|
+
removed = 0
|
|
218
|
+
now = time.time() if now_epoch is None else float(now_epoch)
|
|
219
|
+
try:
|
|
220
|
+
paths = list(self._root.glob("*.json"))
|
|
221
|
+
except OSError:
|
|
222
|
+
return removed
|
|
223
|
+
for path in paths:
|
|
224
|
+
try:
|
|
225
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
226
|
+
expires = float(record.get("expires_epoch") or 0)
|
|
227
|
+
except Exception: # noqa: BLE001 — unreadable == expired garbage
|
|
228
|
+
expires = -float(retention_seconds)
|
|
229
|
+
if expires + float(retention_seconds) <= now:
|
|
230
|
+
try:
|
|
231
|
+
path.unlink(missing_ok=True)
|
|
232
|
+
removed += 1
|
|
233
|
+
except OSError:
|
|
234
|
+
pass
|
|
235
|
+
return removed
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
__all__ = [
|
|
239
|
+
"AgentRunStore",
|
|
240
|
+
"hash_approval_token",
|
|
241
|
+
"restore_run_context",
|
|
242
|
+
"serialize_run_context",
|
|
243
|
+
]
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.9.4"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -78,6 +78,27 @@ If context or old chat history mentions those names, treat them only as legacy a
|
|
|
78
78
|
You are a Vision-Language Model (VLM). If an image is provided, analyze it.
|
|
79
79
|
Be concise and respond in the user's language."""
|
|
80
80
|
|
|
81
|
+
# Appended ONLY when retrieved context exists (review 2026-07-25 Wave 2.3):
|
|
82
|
+
# grounded answers should cite their sources and admit gaps. Advisory prompt
|
|
83
|
+
# guidance — grounding assessment stays annotation-only and never blocks.
|
|
84
|
+
CITATION_INSTRUCTION = """The Context section above contains retrieved sources.
|
|
85
|
+
Ground your claims in those sources and cite them inline as [1], [2], ... matching the order they appear in the Context.
|
|
86
|
+
If the context does not cover the question, say so instead of inventing sources.
|
|
87
|
+
Never cite a source that is not in the Context."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _compose_system(base: str, context: str) -> str:
|
|
91
|
+
"""Compose the system prompt with optional retrieved context.
|
|
92
|
+
|
|
93
|
+
Byte-compatible with the historical prompt when ``context`` is empty:
|
|
94
|
+
the return value is exactly ``base``. When context exists, the Context
|
|
95
|
+
block plus :data:`CITATION_INSTRUCTION` are appended.
|
|
96
|
+
"""
|
|
97
|
+
if not context:
|
|
98
|
+
return base
|
|
99
|
+
return f"{base}\n\nContext:\n{context}\n\n{CITATION_INSTRUCTION}"
|
|
100
|
+
|
|
101
|
+
|
|
81
102
|
def normalize_branding(text: Optional[str]) -> str:
|
|
82
103
|
if not text:
|
|
83
104
|
return ""
|
|
@@ -497,10 +518,8 @@ class LLMRouter:
|
|
|
497
518
|
return raw
|
|
498
519
|
|
|
499
520
|
def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
|
|
500
|
-
system = SYSTEM_PROMPT
|
|
501
521
|
context = normalize_branding(context)
|
|
502
|
-
|
|
503
|
-
system += f"\n\nContext:\n{context}"
|
|
522
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
504
523
|
if hasattr(tokenizer, "apply_chat_template"):
|
|
505
524
|
try:
|
|
506
525
|
msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
|
|
@@ -510,10 +529,8 @@ class LLMRouter:
|
|
|
510
529
|
return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
511
530
|
|
|
512
531
|
def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
|
|
513
|
-
system = SYSTEM_PROMPT
|
|
514
532
|
context = normalize_branding(context)
|
|
515
|
-
|
|
516
|
-
system += f"\n\nContext:\n{context}"
|
|
533
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
517
534
|
try:
|
|
518
535
|
from mlx_vlm import apply_chat_template
|
|
519
536
|
|
|
@@ -616,10 +633,8 @@ class LLMRouter:
|
|
|
616
633
|
return normalize_branding(str(result))
|
|
617
634
|
|
|
618
635
|
async def _cloud_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> str:
|
|
619
|
-
system = SYSTEM_PROMPT
|
|
620
636
|
context = normalize_branding(context)
|
|
621
|
-
|
|
622
|
-
system += f"\n\nContext:\n{context}"
|
|
637
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
623
638
|
try:
|
|
624
639
|
response = await cloud.client.chat.completions.create(
|
|
625
640
|
model=cloud.model,
|
|
@@ -703,10 +718,8 @@ class LLMRouter:
|
|
|
703
718
|
yield chunk
|
|
704
719
|
|
|
705
720
|
async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
|
|
706
|
-
system = SYSTEM_PROMPT
|
|
707
721
|
context = normalize_branding(context)
|
|
708
|
-
|
|
709
|
-
system += f"\n\nContext:\n{context}"
|
|
722
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
710
723
|
try:
|
|
711
724
|
stream = await cloud.client.chat.completions.create(
|
|
712
725
|
model=cloud.model,
|
|
@@ -38,6 +38,7 @@ def build_persistence_runtime(
|
|
|
38
38
|
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
39
39
|
from latticeai.services.automation_intelligence import AutomationIntelligenceService
|
|
40
40
|
from latticeai.services.brain_intelligence import BrainIntelligenceService
|
|
41
|
+
from latticeai.services.funnel_metrics import FunnelMetricsService
|
|
41
42
|
from latticeai.services.memory_service import MemoryService
|
|
42
43
|
from latticeai.services.workspace_service import WorkspaceService
|
|
43
44
|
|
|
@@ -70,11 +71,27 @@ def build_persistence_runtime(
|
|
|
70
71
|
store=workspace_os,
|
|
71
72
|
enable_graph=enable_graph,
|
|
72
73
|
)
|
|
74
|
+
# UX funnel metrics (backlog #16): cheap JSON counters under the data dir.
|
|
75
|
+
# The ingestion pipeline's audit seam is wrapped so every successful
|
|
76
|
+
# ingest bumps the funnel (and starts the TTFV clock) without touching
|
|
77
|
+
# lattice_brain internals.
|
|
78
|
+
funnel_metrics = FunnelMetricsService(Path(data_dir) / "funnel_metrics.json")
|
|
79
|
+
|
|
80
|
+
def _funnel_audit(action, detail, user):
|
|
81
|
+
try:
|
|
82
|
+
if action == "kg_ingest":
|
|
83
|
+
funnel_metrics.record_ingest(
|
|
84
|
+
duplicate=bool((detail or {}).get("duplicate"))
|
|
85
|
+
)
|
|
86
|
+
except Exception: # noqa: BLE001 — metrics must never break ingestion
|
|
87
|
+
pass
|
|
88
|
+
audit(action, detail, user)
|
|
89
|
+
|
|
73
90
|
ingestion_pipeline = IngestionPipeline(
|
|
74
91
|
knowledge_graph,
|
|
75
92
|
hooks=hooks_registry,
|
|
76
93
|
enable_graph=enable_graph,
|
|
77
|
-
audit=
|
|
94
|
+
audit=_funnel_audit,
|
|
78
95
|
)
|
|
79
96
|
device_identity = DeviceIdentity(data_dir)
|
|
80
97
|
kg_portability = KGPortabilityService(
|
|
@@ -99,4 +116,5 @@ def build_persistence_runtime(
|
|
|
99
116
|
"INGESTION_PIPELINE": ingestion_pipeline,
|
|
100
117
|
"DEVICE_IDENTITY": device_identity,
|
|
101
118
|
"KG_PORTABILITY": kg_portability,
|
|
119
|
+
"FUNNEL_METRICS": funnel_metrics,
|
|
102
120
|
}
|
|
@@ -605,6 +605,7 @@ def register_review_and_brain_tail_routers(
|
|
|
605
605
|
create_setup_router: Any,
|
|
606
606
|
model_router: Any,
|
|
607
607
|
change_proposals: Any = None,
|
|
608
|
+
knowledge_graph: Any = None,
|
|
608
609
|
) -> Any:
|
|
609
610
|
"""Register the final review/browser/brain tail routes in legacy order."""
|
|
610
611
|
|
|
@@ -644,6 +645,12 @@ def register_review_and_brain_tail_routers(
|
|
|
644
645
|
require_admin=require_admin,
|
|
645
646
|
),
|
|
646
647
|
create_garden_router(gardener=gardener, require_user=require_user),
|
|
647
|
-
create_setup_router(
|
|
648
|
+
create_setup_router(
|
|
649
|
+
model_router=model_router,
|
|
650
|
+
require_user=require_user,
|
|
651
|
+
ingestion_pipeline=ingestion_pipeline,
|
|
652
|
+
knowledge_graph=knowledge_graph,
|
|
653
|
+
workspace_service=workspace_service,
|
|
654
|
+
),
|
|
648
655
|
)
|
|
649
656
|
return brain_network
|
|
@@ -42,6 +42,9 @@ class AppContext:
|
|
|
42
42
|
hooks: Any = None
|
|
43
43
|
realtime_bus: Any = None
|
|
44
44
|
capability_registry: Any = None
|
|
45
|
+
# UX funnel metrics (latticeai.services.funnel_metrics); None in tests
|
|
46
|
+
# that don't observe the funnel — every increment site is nil-safe.
|
|
47
|
+
funnel_metrics: Any = None
|
|
45
48
|
|
|
46
49
|
# ── auth / session callables ──────────────────────────────────────────
|
|
47
50
|
require_user: Optional[Callable[..., str]] = None
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Automation "run now" support: dry-run reports + execution log surfacing.
|
|
2
|
+
|
|
3
|
+
Backlog #6 (review §7.2 F, Wave 2): after installing an automation the user
|
|
4
|
+
should be able to run it once immediately — first as a **dry run** that
|
|
5
|
+
reports exactly what WOULD happen without side effects, then for real — and
|
|
6
|
+
every automation card should show its last execution result.
|
|
7
|
+
|
|
8
|
+
This module owns the deterministic policy pieces:
|
|
9
|
+
|
|
10
|
+
* :func:`dry_run_report` — walks the workflow's node graph (same normalizer
|
|
11
|
+
as the engine) and describes each step without invoking any runner. No
|
|
12
|
+
model call, no store write, no side effects.
|
|
13
|
+
* :func:`build_last_execution` / :func:`summarize_workflow_run` — the compact
|
|
14
|
+
``last_execution`` record persisted on the workflow's metadata and surfaced
|
|
15
|
+
by the automation overview + daily briefing.
|
|
16
|
+
* :func:`last_execution_view` — read-side merge: the stamped metadata record
|
|
17
|
+
vs. the newest persisted workflow run (so trigger-driven runs surface too).
|
|
18
|
+
* :func:`enqueue_failed_execution` — failed live executions become a review
|
|
19
|
+
queue item (source ``workflow_run``, kind ``automation_failure``) so
|
|
20
|
+
failures land in the same inbox as every other automation outcome.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
from latticeai.core.timeutil import now_iso as _now
|
|
29
|
+
|
|
30
|
+
LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
AUTOMATION_CREATED_FROM = frozenset({
|
|
33
|
+
"automation_suggestion",
|
|
34
|
+
"brain_automation_recipe",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
_TRIGGER_LABEL = {
|
|
38
|
+
"interval": "wait for the user-enabled schedule",
|
|
39
|
+
"brain_event": "wait for new Brain knowledge",
|
|
40
|
+
"manual": "start when the user asks",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_automation_workflow(workflow: Dict[str, Any]) -> bool:
|
|
45
|
+
metadata = (workflow or {}).get("metadata") or {}
|
|
46
|
+
return metadata.get("created_from") in AUTOMATION_CREATED_FROM
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dry_run_report(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
|
50
|
+
"""Describe what a run WOULD do — deterministically, with no side effects.
|
|
51
|
+
|
|
52
|
+
Walks the normalized node chain (``trigger`` first, then ``next``
|
|
53
|
+
pointers) and emits one human-readable step per node. Nothing is
|
|
54
|
+
executed: no runner, no model, no store write.
|
|
55
|
+
"""
|
|
56
|
+
from lattice_brain.workflow import normalize_definition, validate_definition
|
|
57
|
+
|
|
58
|
+
definition = normalize_definition(workflow or {})
|
|
59
|
+
errors = validate_definition(definition)
|
|
60
|
+
metadata = definition.get("metadata") or {}
|
|
61
|
+
steps: List[Dict[str, Any]] = []
|
|
62
|
+
|
|
63
|
+
nodes = {node.get("id"): node for node in definition.get("nodes") or []}
|
|
64
|
+
current = next(
|
|
65
|
+
(node for node in definition.get("nodes") or [] if node.get("type") == "trigger"),
|
|
66
|
+
None,
|
|
67
|
+
)
|
|
68
|
+
visited: set = set()
|
|
69
|
+
while current is not None and current.get("id") not in visited:
|
|
70
|
+
visited.add(current.get("id"))
|
|
71
|
+
ntype = str(current.get("type") or "")
|
|
72
|
+
config = current.get("config") or {}
|
|
73
|
+
if ntype == "trigger":
|
|
74
|
+
would = _TRIGGER_LABEL.get(
|
|
75
|
+
str(config.get("trigger") or "manual"),
|
|
76
|
+
"start when triggered",
|
|
77
|
+
)
|
|
78
|
+
would = f"skipped in a manual run ({would})"
|
|
79
|
+
elif ntype == "agent":
|
|
80
|
+
prompt = str(config.get("prompt") or config.get("goal") or "")[:160]
|
|
81
|
+
would = f"ask the local agent to draft: {prompt}" if prompt else (
|
|
82
|
+
"ask the local agent to draft a review item"
|
|
83
|
+
)
|
|
84
|
+
elif ntype == "tool":
|
|
85
|
+
would = f"run tool '{config.get('tool') or current.get('name') or 'tool'}'"
|
|
86
|
+
elif ntype == "skill":
|
|
87
|
+
would = f"run skill '{config.get('skill') or current.get('name') or 'skill'}'"
|
|
88
|
+
elif ntype == "plugin":
|
|
89
|
+
would = f"run plugin '{config.get('plugin') or current.get('name') or 'plugin'}'"
|
|
90
|
+
elif ntype == "condition":
|
|
91
|
+
would = "evaluate a branch condition"
|
|
92
|
+
elif ntype == "output":
|
|
93
|
+
would = "deliver the draft to the review inbox"
|
|
94
|
+
else:
|
|
95
|
+
would = f"run node '{current.get('id')}'"
|
|
96
|
+
steps.append({
|
|
97
|
+
"node": current.get("id"),
|
|
98
|
+
"type": ntype,
|
|
99
|
+
"name": current.get("name") or current.get("id"),
|
|
100
|
+
"would": would,
|
|
101
|
+
})
|
|
102
|
+
next_id = current.get("next")
|
|
103
|
+
current = nodes.get(next_id) if next_id else None
|
|
104
|
+
|
|
105
|
+
executable = [s for s in steps if s["type"] not in {"trigger", "output", "condition"}]
|
|
106
|
+
creates = list(metadata.get("creates") or [])
|
|
107
|
+
if errors:
|
|
108
|
+
summary = "invalid workflow: " + "; ".join(errors[:3])
|
|
109
|
+
status = "invalid"
|
|
110
|
+
else:
|
|
111
|
+
summary = (
|
|
112
|
+
f"{len(executable)} step(s) would run locally and produce a reviewable draft"
|
|
113
|
+
)
|
|
114
|
+
if creates:
|
|
115
|
+
summary += " (" + ", ".join(str(c) for c in creates[:3]) + ")"
|
|
116
|
+
summary += "; no external actions, nothing is written until you approve."
|
|
117
|
+
status = "ok"
|
|
118
|
+
return {
|
|
119
|
+
"mode": "dry_run",
|
|
120
|
+
"status": status,
|
|
121
|
+
"steps": steps,
|
|
122
|
+
"summary": summary,
|
|
123
|
+
"side_effects": False,
|
|
124
|
+
"validation_errors": errors,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_last_execution(
|
|
129
|
+
*,
|
|
130
|
+
mode: str,
|
|
131
|
+
status: str,
|
|
132
|
+
summary: str,
|
|
133
|
+
run_id: Optional[str] = None,
|
|
134
|
+
) -> Dict[str, Any]:
|
|
135
|
+
"""The compact per-automation execution record (persisted on metadata)."""
|
|
136
|
+
return {
|
|
137
|
+
"mode": mode, # dry_run | live
|
|
138
|
+
"status": status, # ok | failed | running | partial | ...
|
|
139
|
+
"summary": str(summary or "")[:300],
|
|
140
|
+
"run_id": run_id,
|
|
141
|
+
"finished_at": _now(),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def summarize_workflow_run(run: Dict[str, Any]) -> str:
|
|
146
|
+
"""One honest line for a persisted workflow run."""
|
|
147
|
+
status = str(run.get("status") or "unknown")
|
|
148
|
+
timeline = run.get("timeline") or []
|
|
149
|
+
if status == "failed":
|
|
150
|
+
detail = ""
|
|
151
|
+
for entry in reversed(timeline):
|
|
152
|
+
if entry.get("status") in {"failed", "error"}:
|
|
153
|
+
detail = str(
|
|
154
|
+
entry.get("reason") or entry.get("detail") or entry.get("errors") or ""
|
|
155
|
+
)[:160]
|
|
156
|
+
break
|
|
157
|
+
if not detail:
|
|
158
|
+
detail = str((run.get("outputs") or {}).get("error") or "")[:160]
|
|
159
|
+
return f"failed after {len(timeline)} step(s)" + (f": {detail}" if detail else "")
|
|
160
|
+
if status == "awaiting_approval":
|
|
161
|
+
return "paused — a step needs your approval before it can run"
|
|
162
|
+
if status in {"queued", "running", "cancelling"}:
|
|
163
|
+
return "still running"
|
|
164
|
+
return f"{status} — {len(timeline)} step(s) recorded"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def last_execution_view(
|
|
168
|
+
workflow: Dict[str, Any],
|
|
169
|
+
*,
|
|
170
|
+
store: Any = None,
|
|
171
|
+
workspace_id: Optional[str] = None,
|
|
172
|
+
) -> Optional[Dict[str, Any]]:
|
|
173
|
+
"""Read-side ``last_execution``: stamped metadata vs. newest persisted run.
|
|
174
|
+
|
|
175
|
+
Run-now stamps ``metadata.last_execution`` directly; trigger/background
|
|
176
|
+
runs only leave workflow-run rows. Whichever is newer wins so the card is
|
|
177
|
+
honest about the latest execution regardless of how it was started.
|
|
178
|
+
"""
|
|
179
|
+
metadata = (workflow or {}).get("metadata") or {}
|
|
180
|
+
stamped = metadata.get("last_execution")
|
|
181
|
+
stamped = dict(stamped) if isinstance(stamped, dict) else None
|
|
182
|
+
|
|
183
|
+
latest_run: Optional[Dict[str, Any]] = None
|
|
184
|
+
if store is not None and hasattr(store, "list_workflow_runs"):
|
|
185
|
+
try:
|
|
186
|
+
runs = (
|
|
187
|
+
store.list_workflow_runs(
|
|
188
|
+
workflow_id=workflow.get("id"), limit=1, workspace_id=workspace_id
|
|
189
|
+
)
|
|
190
|
+
or {}
|
|
191
|
+
).get("runs") or []
|
|
192
|
+
latest_run = runs[0] if runs else None
|
|
193
|
+
except Exception: # noqa: BLE001 — surfacing must never break the overview
|
|
194
|
+
LOGGER.exception("automation last-execution run read failed")
|
|
195
|
+
latest_run = None
|
|
196
|
+
|
|
197
|
+
derived: Optional[Dict[str, Any]] = None
|
|
198
|
+
if latest_run is not None:
|
|
199
|
+
derived = {
|
|
200
|
+
"mode": "live",
|
|
201
|
+
"status": str(latest_run.get("status") or "unknown"),
|
|
202
|
+
"summary": summarize_workflow_run(latest_run),
|
|
203
|
+
"run_id": latest_run.get("id"),
|
|
204
|
+
"finished_at": str(
|
|
205
|
+
latest_run.get("completed_at")
|
|
206
|
+
or latest_run.get("updated_at")
|
|
207
|
+
or latest_run.get("created_at")
|
|
208
|
+
or ""
|
|
209
|
+
),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if stamped is None:
|
|
213
|
+
return derived
|
|
214
|
+
if derived is None:
|
|
215
|
+
return stamped
|
|
216
|
+
return derived if str(derived.get("finished_at") or "") > str(
|
|
217
|
+
stamped.get("finished_at") or ""
|
|
218
|
+
) else stamped
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def enqueue_failed_execution(
|
|
222
|
+
review_queue: Any,
|
|
223
|
+
*,
|
|
224
|
+
workflow: Dict[str, Any],
|
|
225
|
+
run_id: Optional[str],
|
|
226
|
+
error: str,
|
|
227
|
+
user_email: Optional[str] = None,
|
|
228
|
+
workspace_id: Optional[str] = None,
|
|
229
|
+
) -> Optional[Dict[str, Any]]:
|
|
230
|
+
"""Failed live execution → review queue item (never raises)."""
|
|
231
|
+
if review_queue is None:
|
|
232
|
+
return None
|
|
233
|
+
name = str(workflow.get("name") or "Automation")
|
|
234
|
+
try:
|
|
235
|
+
return review_queue.create(
|
|
236
|
+
title=f"Automation failed: {name}"[:160],
|
|
237
|
+
summary=str(error or "")[:500],
|
|
238
|
+
source="workflow_run",
|
|
239
|
+
kind="automation_failure",
|
|
240
|
+
payload={
|
|
241
|
+
"workflow_id": workflow.get("id"),
|
|
242
|
+
"run_id": run_id,
|
|
243
|
+
"error": str(error or "")[:500],
|
|
244
|
+
},
|
|
245
|
+
provenance={
|
|
246
|
+
"workflow_id": workflow.get("id"),
|
|
247
|
+
"run_id": run_id,
|
|
248
|
+
"origin": "automation_run_now",
|
|
249
|
+
},
|
|
250
|
+
user_email=user_email,
|
|
251
|
+
workspace_id=workspace_id,
|
|
252
|
+
)
|
|
253
|
+
except Exception: # noqa: BLE001 — review enqueue is best-effort surfacing
|
|
254
|
+
LOGGER.exception("automation failure review enqueue failed")
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = [
|
|
259
|
+
"AUTOMATION_CREATED_FROM",
|
|
260
|
+
"is_automation_workflow",
|
|
261
|
+
"dry_run_report",
|
|
262
|
+
"build_last_execution",
|
|
263
|
+
"summarize_workflow_run",
|
|
264
|
+
"last_execution_view",
|
|
265
|
+
"enqueue_failed_execution",
|
|
266
|
+
]
|