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.
Files changed (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. 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 = _BUNDLE_HTML_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 web-project manifest from a creation request.
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, an HTML-page intent,
619
- at least one *additional* named technology (css/js), and no explicit
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))
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.9.3"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.9.5"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.9.3"
14
+ MARKETPLACE_VERSION = "9.9.5"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -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.3"
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: context_id (chat_id, executing_model, reviewing_model)
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
- context_id = data.get("context_id", "")
717
- plan = data.get("plan", {})
718
- goal = plan.get("goal", "")
719
- steps = plan.get("steps", [])
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
- desc = step.get("description") or step.get("action") or str(step)
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[context_id] = {
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:{context_id}"},
742
- {"text": "❌ 취소", "callback_data": f"plan:cancel:{context_id}"},
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, context_id = parts
753
- pending = _bot_pending_plans.get(context_id)
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(context_id, None)
792
+ pending = _bot_pending_plans.pop(pause_id, None)
762
793
 
763
- if action == "cancel" or not pending:
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(AGENT_RESUME_URL, json={
773
- "context_id": context_id,
774
- "approved": True,
775
- "executing_model": pending.get("executing_model"),
776
- "reviewing_model": pending.get("reviewing_model"),
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
- # Human-in-the-loop: show plan and wait for approval
798
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
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.get("status") == "waiting_approval":
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)