ltcai 9.9.3 → 9.9.5
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 +39 -38
- package/docs/CHANGELOG.md +100 -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 +53 -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/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +90 -455
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/quality.py +26 -6
- 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 +262 -52
- package/latticeai/api/chat_contracts.py +1 -1
- 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 +420 -27
- 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 +245 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- 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/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -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/scripts/run_sidecar_e2e.mjs +159 -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-DmxfbqBp.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
- package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
- package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/utils-BqoznzGP.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
|
@@ -52,6 +52,11 @@ class EnterpriseCapability(str, Enum):
|
|
|
52
52
|
DLP_POLICY = "dlp_policy"
|
|
53
53
|
EDISCOVERY = "ediscovery"
|
|
54
54
|
ADMIN_POLICY_PACKS = "admin_policy_packs"
|
|
55
|
+
# Human review-before-promote governance for knowledge-graph concept
|
|
56
|
+
# promotions (review 2026-07-25 Wave 4). Community users can opt in per
|
|
57
|
+
# deployment via LATTICEAI_GRAPH_PROMOTION_REVIEW; this capability is the
|
|
58
|
+
# seam for Enterprise policy packs that mandate reviewed promotions.
|
|
59
|
+
GRAPH_PROMOTION_REVIEW = "graph_promotion_review"
|
|
55
60
|
|
|
56
61
|
|
|
57
62
|
@runtime_checkable
|
|
@@ -358,6 +358,23 @@ _BUNDLE_HTML_RULE = (
|
|
|
358
358
|
"blocks, no inline behavior scripts."
|
|
359
359
|
)
|
|
360
360
|
|
|
361
|
+
# Vite/React bundles need a module entry point, not classic script tags.
|
|
362
|
+
_BUNDLE_HTML_MODULE_RULE = (
|
|
363
|
+
"Produce ONE complete HTML5 document: <!DOCTYPE html>, <html>, <head> with "
|
|
364
|
+
"<meta charset=\"utf-8\"> and a <title>, and a closed </html> tag. "
|
|
365
|
+
"This page is the Vite entry of a React project: the <body> must contain "
|
|
366
|
+
"<div id=\"root\"></div> and load the app with "
|
|
367
|
+
"<script type=\"module\" src=\"/src/main.jsx\"></script> just before "
|
|
368
|
+
"</body>. No inline <style> blocks, no other scripts, no external files."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _bundle_html_rule(bundle_files: List[str]) -> str:
|
|
373
|
+
"""Pick the HTML bundle rule that matches the bundle's technology."""
|
|
374
|
+
if any(str(path).lower().endswith((".jsx", ".tsx")) for path in bundle_files):
|
|
375
|
+
return _BUNDLE_HTML_MODULE_RULE
|
|
376
|
+
return _BUNDLE_HTML_RULE
|
|
377
|
+
|
|
361
378
|
|
|
362
379
|
def build_file_generation_context(
|
|
363
380
|
target_path: str,
|
|
@@ -381,7 +398,7 @@ def build_file_generation_context(
|
|
|
381
398
|
]
|
|
382
399
|
type_rule = _TYPE_RULES.get(ext)
|
|
383
400
|
if bundle_files and ext in (".html", ".htm"):
|
|
384
|
-
type_rule =
|
|
401
|
+
type_rule = _bundle_html_rule(bundle_files)
|
|
385
402
|
if type_rule:
|
|
386
403
|
parts.append(f"- {type_rule}")
|
|
387
404
|
if bundle_files:
|
|
@@ -608,16 +625,116 @@ _EXPLICIT_FILENAME_RE = re.compile(
|
|
|
608
625
|
re.IGNORECASE,
|
|
609
626
|
)
|
|
610
627
|
_PROJECT_NAME_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:앱|app\b)", re.IGNORECASE)
|
|
628
|
+
# React/Vite intent: the react keyword is specific enough on its own.
|
|
629
|
+
_REACT_HINT_RE = re.compile(r"(?<![a-z0-9])react(?![a-z0-9])|리액트")
|
|
630
|
+
_VITE_HINT_RE = re.compile(r"(?<![a-z0-9])vite(?![a-z0-9])")
|
|
631
|
+
# Python package intent: language + package word, both required.
|
|
632
|
+
_PYTHON_HINT_RE = re.compile(r"(?<![a-z0-9])python(?![a-z0-9])|파이썬")
|
|
633
|
+
_PACKAGE_HINT_RE = re.compile(r"패키지|(?<![a-z0-9])package(?![a-z0-9])")
|
|
634
|
+
_PKG_NAME_RE = re.compile(
|
|
635
|
+
r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:패키지|package\b)", re.IGNORECASE
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _react_manifest(text: str) -> Dict[str, Any]:
|
|
640
|
+
"""Vite + React starter manifest (review Wave 4: manifest 확장)."""
|
|
641
|
+
name_match = _PROJECT_NAME_RE.search(text)
|
|
642
|
+
name = f"{name_match.group(1).lower()}-app" if name_match else "react-app"
|
|
643
|
+
return {
|
|
644
|
+
"name": name,
|
|
645
|
+
"kind": "react",
|
|
646
|
+
"files": [
|
|
647
|
+
{
|
|
648
|
+
"path": "package.json",
|
|
649
|
+
"brief": (
|
|
650
|
+
f'Vite React app manifest: strictly valid JSON with "name": "{name}", '
|
|
651
|
+
'"private": true, "type": "module", "scripts" {"dev": "vite", '
|
|
652
|
+
'"build": "vite build", "preview": "vite preview"}, "dependencies" '
|
|
653
|
+
'with react and react-dom (^18), and "devDependencies" with vite '
|
|
654
|
+
"and @vitejs/plugin-react."
|
|
655
|
+
),
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
"path": "index.html",
|
|
659
|
+
"brief": (
|
|
660
|
+
"The Vite entry HTML: <div id=\"root\"></div> in <body> and "
|
|
661
|
+
"<script type=\"module\" src=\"/src/main.jsx\"></script> just "
|
|
662
|
+
"before </body>. No inline styles or scripts."
|
|
663
|
+
),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
"path": "src/main.jsx",
|
|
667
|
+
"brief": (
|
|
668
|
+
"React entry: createRoot from react-dom/client rendering <App /> "
|
|
669
|
+
"into #root; imports ./App.jsx and ./App.css."
|
|
670
|
+
),
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
"path": "src/App.jsx",
|
|
674
|
+
"brief": (
|
|
675
|
+
"The main App component implementing the user's request as one "
|
|
676
|
+
"self-contained React component (hooks allowed, no extra deps)."
|
|
677
|
+
),
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
"path": "src/App.css",
|
|
681
|
+
"brief": "All visual styles for the App component.",
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _python_package_manifest(text: str) -> Dict[str, Any]:
|
|
688
|
+
"""Multi-file Python package manifest (review Wave 4: manifest 확장)."""
|
|
689
|
+
name_match = _PKG_NAME_RE.search(text)
|
|
690
|
+
raw_name = name_match.group(1).lower() if name_match else "my_package"
|
|
691
|
+
module = re.sub(r"[^a-z0-9_]", "_", raw_name)
|
|
692
|
+
if not re.match(r"[a-z_]", module):
|
|
693
|
+
module = f"pkg_{module}"
|
|
694
|
+
return {
|
|
695
|
+
"name": module,
|
|
696
|
+
"kind": "python",
|
|
697
|
+
"files": [
|
|
698
|
+
{
|
|
699
|
+
"path": f"{module}/__init__.py",
|
|
700
|
+
"brief": (
|
|
701
|
+
f"Package init for {module}: import and re-export the public "
|
|
702
|
+
"API from .core with an explicit __all__."
|
|
703
|
+
),
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"path": f"{module}/core.py",
|
|
707
|
+
"brief": (
|
|
708
|
+
"Implement the user's request as clean, documented functions/"
|
|
709
|
+
"classes with type hints. Standard library only."
|
|
710
|
+
),
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
"path": f"{module}/cli.py",
|
|
714
|
+
"brief": (
|
|
715
|
+
"argparse CLI wrapping the core API: a main() function and an "
|
|
716
|
+
'if __name__ == "__main__": main() guard.'
|
|
717
|
+
),
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"path": "README.md",
|
|
721
|
+
"brief": (
|
|
722
|
+
f"Usage documentation for the {module} package: install, import "
|
|
723
|
+
"example, and CLI example."
|
|
724
|
+
),
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
}
|
|
611
728
|
|
|
612
729
|
|
|
613
730
|
def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
|
|
614
|
-
"""Infer a multi-file
|
|
731
|
+
"""Infer a multi-file project manifest from a creation request.
|
|
615
732
|
|
|
616
733
|
"todo 앱 html+css+js로 만들어줘" should yield real linked files, not one
|
|
617
734
|
inlined page. Deliberately narrow and deterministic (weak local models
|
|
618
|
-
never see this decision): requires a creation verb,
|
|
619
|
-
|
|
620
|
-
filename. Single-type requests return ``None`` so the existing
|
|
735
|
+
never see this decision): requires a creation verb, a recognized project
|
|
736
|
+
intent (web page + css/js, React/Vite app, or Python package), and no
|
|
737
|
+
explicit filename. Single-type requests return ``None`` so the existing
|
|
621
738
|
single-file flow is completely unchanged.
|
|
622
739
|
"""
|
|
623
740
|
text = (message or "").strip()
|
|
@@ -626,6 +743,14 @@ def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
|
|
|
626
743
|
if _EXPLICIT_FILENAME_RE.search(text):
|
|
627
744
|
return None
|
|
628
745
|
lower = text.lower()
|
|
746
|
+
|
|
747
|
+
# Most-specific first: React (its own structure), then Python package,
|
|
748
|
+
# then the classic html+css/js web bundle.
|
|
749
|
+
if _REACT_HINT_RE.search(lower) or _VITE_HINT_RE.search(lower):
|
|
750
|
+
return _react_manifest(text)
|
|
751
|
+
if _PYTHON_HINT_RE.search(lower) and _PACKAGE_HINT_RE.search(lower):
|
|
752
|
+
return _python_package_manifest(text)
|
|
753
|
+
|
|
629
754
|
wants_html = bool(_HTML_HINT_RE.search(lower))
|
|
630
755
|
wants_css = bool(_CSS_HINT_RE.search(lower))
|
|
631
756
|
wants_js = bool(_JS_HINT_RE.search(lower))
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
legacy_context: bool = False,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Persist a paused run. Best-effort: returns False on any failure."""
|
|
119
|
+
path = self._path_for(run_id)
|
|
120
|
+
if path is None:
|
|
121
|
+
return False
|
|
122
|
+
record = {
|
|
123
|
+
"version": 1,
|
|
124
|
+
"run_id": run_id,
|
|
125
|
+
"user": user,
|
|
126
|
+
"language_hint": language_hint,
|
|
127
|
+
"token_hash": hash_approval_token(token),
|
|
128
|
+
"expires_epoch": float(expires_epoch),
|
|
129
|
+
"expires_at": expires_at,
|
|
130
|
+
"legacy_context": bool(legacy_context),
|
|
131
|
+
"req": req_payload,
|
|
132
|
+
"ctx": serialize_run_context(ctx),
|
|
133
|
+
}
|
|
134
|
+
try:
|
|
135
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
fd, tmp_name = tempfile.mkstemp(prefix=path.name, dir=str(self._root))
|
|
137
|
+
try:
|
|
138
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
139
|
+
json.dump(record, handle, ensure_ascii=False)
|
|
140
|
+
os.replace(tmp_name, path)
|
|
141
|
+
finally:
|
|
142
|
+
if os.path.exists(tmp_name):
|
|
143
|
+
try:
|
|
144
|
+
os.unlink(tmp_name)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
return True
|
|
148
|
+
except Exception as exc: # noqa: BLE001 — pause must still answer without disk
|
|
149
|
+
LOGGER.warning("agent run store save failed for %s: %s", run_id, exc)
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def delete(self, run_id: str) -> None:
|
|
153
|
+
path = self._path_for(run_id)
|
|
154
|
+
if path is None:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
path.unlink(missing_ok=True)
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
LOGGER.warning("agent run store delete failed for %s: %s", run_id, exc)
|
|
160
|
+
|
|
161
|
+
# ── reads ────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def load(self, run_id: str) -> Optional[Dict[str, Any]]:
|
|
164
|
+
"""Raw persisted record, or None when missing/corrupt/invalid id."""
|
|
165
|
+
path = self._path_for(run_id)
|
|
166
|
+
if path is None:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
return None
|
|
172
|
+
except Exception as exc: # noqa: BLE001 — corrupt record == not found
|
|
173
|
+
LOGGER.warning("agent run store load failed for %s: %s", run_id, exc)
|
|
174
|
+
return None
|
|
175
|
+
if not isinstance(record, dict) or record.get("run_id") != run_id:
|
|
176
|
+
return None
|
|
177
|
+
return record
|
|
178
|
+
|
|
179
|
+
def pending_summaries(self, user: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
180
|
+
"""Unexpired pending runs (optionally per user) for surfacing in UI."""
|
|
181
|
+
summaries: List[Dict[str, Any]] = []
|
|
182
|
+
now = time.time()
|
|
183
|
+
try:
|
|
184
|
+
paths = sorted(self._root.glob("*.json"))
|
|
185
|
+
except OSError:
|
|
186
|
+
return summaries
|
|
187
|
+
for path in paths:
|
|
188
|
+
try:
|
|
189
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
190
|
+
except Exception: # noqa: BLE001
|
|
191
|
+
continue
|
|
192
|
+
if not isinstance(record, dict):
|
|
193
|
+
continue
|
|
194
|
+
if float(record.get("expires_epoch") or 0) <= now:
|
|
195
|
+
continue
|
|
196
|
+
if user is not None and record.get("user") != user:
|
|
197
|
+
continue
|
|
198
|
+
plan = (record.get("ctx") or {}).get("plan") or {}
|
|
199
|
+
summaries.append({
|
|
200
|
+
"run_id": record.get("run_id"),
|
|
201
|
+
"user": record.get("user"),
|
|
202
|
+
"goal": str(plan.get("goal") or "")[:200],
|
|
203
|
+
"expires_at": record.get("expires_at"),
|
|
204
|
+
})
|
|
205
|
+
return summaries
|
|
206
|
+
|
|
207
|
+
def sweep_expired(
|
|
208
|
+
self,
|
|
209
|
+
now_epoch: Optional[float] = None,
|
|
210
|
+
*,
|
|
211
|
+
retention_seconds: float = 86400.0,
|
|
212
|
+
) -> int:
|
|
213
|
+
"""Remove long-expired run files; returns how many were removed.
|
|
214
|
+
|
|
215
|
+
Recently expired records are *kept* for ``retention_seconds`` so that
|
|
216
|
+
a resume attempt after expiry can still answer 410 with a one-click
|
|
217
|
+
replan hint (the original request message) instead of a bare 404.
|
|
218
|
+
"""
|
|
219
|
+
removed = 0
|
|
220
|
+
now = time.time() if now_epoch is None else float(now_epoch)
|
|
221
|
+
try:
|
|
222
|
+
paths = list(self._root.glob("*.json"))
|
|
223
|
+
except OSError:
|
|
224
|
+
return removed
|
|
225
|
+
for path in paths:
|
|
226
|
+
try:
|
|
227
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
228
|
+
expires = float(record.get("expires_epoch") or 0)
|
|
229
|
+
except Exception: # noqa: BLE001 — unreadable == expired garbage
|
|
230
|
+
expires = -float(retention_seconds)
|
|
231
|
+
if expires + float(retention_seconds) <= now:
|
|
232
|
+
try:
|
|
233
|
+
path.unlink(missing_ok=True)
|
|
234
|
+
removed += 1
|
|
235
|
+
except OSError:
|
|
236
|
+
pass
|
|
237
|
+
return removed
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = [
|
|
241
|
+
"AgentRunStore",
|
|
242
|
+
"hash_approval_token",
|
|
243
|
+
"restore_run_context",
|
|
244
|
+
"serialize_run_context",
|
|
245
|
+
]
|
|
@@ -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.5"
|
|
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
|
|
@@ -40,7 +40,8 @@ UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
|
|
|
40
40
|
AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
|
|
41
41
|
AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
|
|
42
42
|
|
|
43
|
-
# Pending plan approvals
|
|
43
|
+
# Pending plan approvals keyed by run_id (or legacy context_id).
|
|
44
|
+
# Values: chat_id, models, and the resume credentials (token and/or legacy).
|
|
44
45
|
_bot_pending_plans: dict[str, dict] = {}
|
|
45
46
|
MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
|
|
46
47
|
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
|
|
@@ -711,35 +712,65 @@ async def send_generated_files(client, chat_id, generated_files):
|
|
|
711
712
|
|
|
712
713
|
# ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
|
|
713
714
|
|
|
715
|
+
def _approval_pause_id(data: dict) -> str:
|
|
716
|
+
"""Stable key for a paused plan: run_id preferred, legacy context_id fallback."""
|
|
717
|
+
return str(data.get("run_id") or data.get("context_id") or "")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _is_approval_pause(data: dict) -> bool:
|
|
721
|
+
status = str(data.get("status") or "")
|
|
722
|
+
return status in {"waiting_approval", "awaiting_approval"}
|
|
723
|
+
|
|
724
|
+
|
|
714
725
|
async def send_plan_for_approval(client, chat_id, data: dict) -> None:
|
|
715
|
-
"""Show the agent plan to the user and present Done/Cancel buttons.
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
726
|
+
"""Show the agent plan to the user and present Done/Cancel buttons.
|
|
727
|
+
|
|
728
|
+
Handles both the modern ``awaiting_approval`` (run_id + token) and the
|
|
729
|
+
legacy ``waiting_approval`` / ``context_id`` wire contracts (v9.9.5
|
|
730
|
+
SURFACE_PARITY — Telegram approval flow).
|
|
731
|
+
"""
|
|
732
|
+
pause_id = _approval_pause_id(data)
|
|
733
|
+
if not pause_id:
|
|
734
|
+
await send_message(client, chat_id, "❌ 승인할 계획을 식별할 수 없습니다.")
|
|
735
|
+
return
|
|
736
|
+
plan = data.get("plan", {}) or {}
|
|
737
|
+
goal = plan.get("goal") or (data.get("approval") or {}).get("plan_summary") or ""
|
|
738
|
+
steps = plan.get("steps", []) or data.get("non_auto_steps") or []
|
|
720
739
|
p_model = data.get("planning_model", "current")
|
|
721
740
|
e_model = data.get("executing_model", "current")
|
|
722
741
|
r_model = data.get("reviewing_model", "current")
|
|
742
|
+
approval = data.get("approval") or {}
|
|
743
|
+
expires_at = approval.get("expires_at")
|
|
723
744
|
|
|
724
745
|
lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
|
|
725
746
|
if goal:
|
|
726
747
|
lines.append(f"*목표:* {goal}\n")
|
|
727
748
|
for i, step in enumerate(steps, 1):
|
|
728
|
-
|
|
749
|
+
if isinstance(step, dict):
|
|
750
|
+
desc = step.get("description") or step.get("action") or str(step)
|
|
751
|
+
else:
|
|
752
|
+
desc = str(step)
|
|
729
753
|
lines.append(f"{i}. {desc}")
|
|
730
754
|
lines.append(f"\n🧠 플래닝: `{p_model}`")
|
|
731
755
|
lines.append(f"⚙️ 실행: `{e_model}`")
|
|
732
756
|
lines.append(f"🔍 검토: `{r_model}`")
|
|
757
|
+
if expires_at:
|
|
758
|
+
lines.append(f"⏳ 승인 만료: `{expires_at}`")
|
|
733
759
|
|
|
734
|
-
_bot_pending_plans[
|
|
760
|
+
_bot_pending_plans[pause_id] = {
|
|
735
761
|
"chat_id": chat_id,
|
|
762
|
+
"run_id": data.get("run_id") or pause_id,
|
|
763
|
+
"context_id": data.get("context_id"),
|
|
764
|
+
"approval_token": approval.get("token"),
|
|
765
|
+
"legacy": data.get("status") == "waiting_approval" or bool(data.get("context_id")),
|
|
736
766
|
"executing_model": data.get("executing_model"),
|
|
737
767
|
"reviewing_model": data.get("reviewing_model"),
|
|
738
768
|
}
|
|
739
769
|
|
|
770
|
+
# callback_data max 64 bytes — pause ids are token_urlsafe(16) (~22 chars).
|
|
740
771
|
keyboard = {"inline_keyboard": [[
|
|
741
|
-
{"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{
|
|
742
|
-
{"text": "❌ 취소", "callback_data": f"plan:cancel:{
|
|
772
|
+
{"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{pause_id}"},
|
|
773
|
+
{"text": "❌ 취소", "callback_data": f"plan:cancel:{pause_id}"},
|
|
743
774
|
]]}
|
|
744
775
|
await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
|
|
745
776
|
|
|
@@ -749,8 +780,8 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
749
780
|
parts = data.split(":", 2)
|
|
750
781
|
if len(parts) != 3:
|
|
751
782
|
return
|
|
752
|
-
_, action,
|
|
753
|
-
pending = _bot_pending_plans.get(
|
|
783
|
+
_, action, pause_id = parts
|
|
784
|
+
pending = _bot_pending_plans.get(pause_id)
|
|
754
785
|
|
|
755
786
|
# A callback is bound to the chat that received the plan. Even two allowed
|
|
756
787
|
# chats cannot approve or cancel each other's plan by replaying callback
|
|
@@ -758,9 +789,19 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
758
789
|
if pending and int(pending.get("chat_id")) != int(chat_id):
|
|
759
790
|
await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
|
|
760
791
|
return
|
|
761
|
-
pending = _bot_pending_plans.pop(
|
|
792
|
+
pending = _bot_pending_plans.pop(pause_id, None)
|
|
762
793
|
|
|
763
|
-
if action == "cancel"
|
|
794
|
+
if action == "cancel":
|
|
795
|
+
if pending:
|
|
796
|
+
try:
|
|
797
|
+
resume_body = _resume_payload(pending, approved=False)
|
|
798
|
+
async with _server_client() as sc:
|
|
799
|
+
await sc.post(AGENT_RESUME_URL, json=resume_body, timeout=30.0)
|
|
800
|
+
except Exception as exc: # noqa: BLE001 — cancel is best-effort
|
|
801
|
+
logger.warning("telegram cancel resume failed: %s", safe_log_text(exc))
|
|
802
|
+
await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
|
|
803
|
+
return
|
|
804
|
+
if not pending:
|
|
764
805
|
await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
|
|
765
806
|
return
|
|
766
807
|
|
|
@@ -769,12 +810,11 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
769
810
|
|
|
770
811
|
try:
|
|
771
812
|
async with _server_client() as sc:
|
|
772
|
-
res = await sc.post(
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}, timeout=300.0)
|
|
813
|
+
res = await sc.post(
|
|
814
|
+
AGENT_RESUME_URL,
|
|
815
|
+
json=_resume_payload(pending, approved=True),
|
|
816
|
+
timeout=300.0,
|
|
817
|
+
)
|
|
778
818
|
data_r = res.json() if res.status_code == 200 else {}
|
|
779
819
|
ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
|
|
780
820
|
await send_message(client, chat_id, str(ans))
|
|
@@ -785,6 +825,25 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
785
825
|
await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
|
|
786
826
|
|
|
787
827
|
|
|
828
|
+
def _resume_payload(pending: dict, *, approved: bool) -> dict:
|
|
829
|
+
"""Build /agent/resume body: token path preferred, else legacy context_id."""
|
|
830
|
+
body: dict = {
|
|
831
|
+
"approved": approved,
|
|
832
|
+
"executing_model": pending.get("executing_model"),
|
|
833
|
+
"reviewing_model": pending.get("reviewing_model"),
|
|
834
|
+
}
|
|
835
|
+
token = pending.get("approval_token")
|
|
836
|
+
run_id = pending.get("run_id")
|
|
837
|
+
# Unified durable store (9.9.5): token-gated resume works for both the
|
|
838
|
+
# modern awaiting_approval and the legacy human_in_loop pause.
|
|
839
|
+
if token and run_id:
|
|
840
|
+
body["run_id"] = run_id
|
|
841
|
+
body["approval_token"] = token
|
|
842
|
+
return body
|
|
843
|
+
body["context_id"] = pending.get("context_id") or run_id
|
|
844
|
+
return body
|
|
845
|
+
|
|
846
|
+
|
|
788
847
|
# ── AI request task ───────────────────────────────────────────────────────────
|
|
789
848
|
|
|
790
849
|
async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
@@ -794,8 +853,8 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
|
794
853
|
data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
|
|
795
854
|
logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
|
|
796
855
|
|
|
797
|
-
#
|
|
798
|
-
if isinstance(data, dict) and data
|
|
856
|
+
# Approval pause (legacy waiting_approval or modern awaiting_approval)
|
|
857
|
+
if isinstance(data, dict) and _is_approval_pause(data):
|
|
799
858
|
await send_plan_for_approval(client, chat_id, data)
|
|
800
859
|
return
|
|
801
860
|
|
|
@@ -887,7 +946,7 @@ async def handle_command(client, chat_id, command: str, args: str):
|
|
|
887
946
|
await send_chat_action(client, chat_id, "typing")
|
|
888
947
|
data = await ask_ai(client, task_text, agent_mode=True,
|
|
889
948
|
executing_model=exec_model, reviewing_model=reviewing_model)
|
|
890
|
-
if isinstance(data, dict) and data
|
|
949
|
+
if isinstance(data, dict) and _is_approval_pause(data):
|
|
891
950
|
await send_plan_for_approval(client, chat_id, data)
|
|
892
951
|
else:
|
|
893
952
|
ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
|