ltcai 11.2.0 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -0,0 +1,145 @@
1
+ """PLAN and APPROVAL — deciding what to do, and whether it may be done.
2
+
3
+ The planner turns the request into a structured plan; the approval gate then
4
+ answers one question about that plan under the run's autonomy dial: does a step
5
+ in it need a human to say yes? :meth:`approval_requirements` is the read-only
6
+ half of exactly the predicate :meth:`approve` enforces, so the HTTP layer can
7
+ pause a run instead of failing it closed without ever weakening the gate.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Optional
13
+
14
+ from latticeai.core.agent_helpers import extract_action_details, normalize_plan
15
+ from latticeai.core.agent_permission import non_auto_plan_steps
16
+ from latticeai.core.agent_state import AgentState
17
+ from latticeai.core.permission_mode import plan_requires_approval
18
+
19
+ from ._contract import AgentCore as _Core
20
+ from .context import AgentRunContext
21
+
22
+
23
+ class _PlanningMixin(_Core):
24
+ """The PLAN and APPROVAL phases of :class:`SingleAgentRuntime`."""
25
+
26
+ # ── PLAN ─────────────────────────────────────────────────────────
27
+ async def plan(
28
+ self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
29
+ model_id: Optional[str] = None,
30
+ ) -> None:
31
+ """PLAN: Planner role produces a structured plan JSON."""
32
+ d = self.deps
33
+ project_block = self._project_block(ctx)
34
+ context = (
35
+ f"{d.planner_prompt}\n\n"
36
+ f"[LANGUAGE HINT: {lang_hint}]\n"
37
+ f"Workspace root: {d.agent_root}{project_block}\n\n"
38
+ f"User request: {req.message}"
39
+ )
40
+ raw = await d.generate_as(
41
+ model_id,
42
+ message="Produce a JSON execution plan for this request.",
43
+ context=context, max_tokens=self.phase_budgets.plan_tokens, temperature=0.1,
44
+ )
45
+ ctx.trace.llm_call("plan", model=model_id)
46
+ try:
47
+ plan, plan_repairs = extract_action_details(str(raw))
48
+ ctx.trace.repair("plan", repairs=plan_repairs)
49
+ except ValueError as exc:
50
+ ctx.trace.parse_error("plan", error=str(exc), recovered=True)
51
+ plan = {
52
+ "action": "plan", "state": "PLAN",
53
+ "goal": req.message, "steps": [],
54
+ "requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
55
+ }
56
+ plan, plan_fixes = normalize_plan(plan, req.message)
57
+ if plan_fixes:
58
+ ctx.trace.repair("plan", repairs=plan_fixes)
59
+ ctx.plan = plan
60
+ ctx.transcript.append({
61
+ "state": AgentState.PLANNING.value,
62
+ "goal": plan.get("goal", req.message),
63
+ "steps": plan.get("steps", []),
64
+ "requires_approval": plan.get("requires_approval", False),
65
+ "rollback_strategy": plan.get("rollback_strategy", "none"),
66
+ "estimated_steps": plan.get("estimated_steps", 1),
67
+ **({"plan_fixes": plan_fixes} if plan_fixes else {}),
68
+ })
69
+ self._emit_step(
70
+ ctx, "plan", "planned",
71
+ goal=str(plan.get("goal") or "")[:200],
72
+ steps=len(plan.get("steps") or []),
73
+ requires_approval=bool(plan.get("requires_approval", False)),
74
+ )
75
+ ctx.state = AgentState.WAITING_APPROVAL
76
+
77
+ # ── APPROVAL ─────────────────────────────────────────────────────
78
+ def approval_requirements(self, ctx: AgentRunContext) -> Dict[str, Any]:
79
+ """Read-only preview of the approval gate for a planned run.
80
+
81
+ Shares the exact predicate :meth:`approve` enforces, so the HTTP
82
+ layer can pause a run as ``awaiting_approval`` (with a plan summary
83
+ for the user) instead of letting it fail closed — without ever
84
+ weakening the gate itself.
85
+ """
86
+ d = self.deps
87
+ mode = self.resolve_permission_mode(ctx)
88
+ # Governor-managed tools never hard-block the plan: each call is
89
+ # classified at execution time — additive creates run, mutations and
90
+ # deletions of existing content become review proposals.
91
+ governed_tools = self._governed_tools()
92
+ steps = ctx.plan.get("steps", [])
93
+ non_auto = non_auto_plan_steps(
94
+ mode, steps, d.tool_governance or {}, governed_tools=governed_tools,
95
+ )
96
+ requires = plan_requires_approval(
97
+ mode,
98
+ non_auto_steps=non_auto,
99
+ plan_flag=bool(ctx.plan.get("requires_approval", False)),
100
+ )
101
+ lines = [
102
+ f"{index}. {step.get('description') or step.get('action') or '?'}"
103
+ for index, step in enumerate(steps, start=1)
104
+ ]
105
+ summary = str(ctx.plan.get("goal") or "").strip()
106
+ if lines:
107
+ summary = (summary + "\n" if summary else "") + "\n".join(lines)
108
+ return {
109
+ "requires_approval": requires,
110
+ "non_auto_steps": non_auto,
111
+ "permission_mode": mode.value,
112
+ "plan_summary": summary,
113
+ }
114
+
115
+ def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
116
+ """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
117
+ d = self.deps
118
+ requirements = self.approval_requirements(ctx)
119
+ non_auto = requirements["non_auto_steps"]
120
+ requires = requirements["requires_approval"]
121
+
122
+ ctx.transcript.append({
123
+ "state": AgentState.WAITING_APPROVAL.value,
124
+ "requires_approval": requires,
125
+ "non_auto_approve_steps": non_auto,
126
+ "decision": "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
127
+ })
128
+ decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
129
+ ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
130
+ self._emit_step(ctx, "approval", "decision", decision=decision)
131
+ d.audit(
132
+ "agent_approval", user_email=current_user,
133
+ requires_approval=requires,
134
+ non_auto_steps=non_auto,
135
+ decision=decision,
136
+ )
137
+ if requires and not approved_by_human:
138
+ ctx.final_message = (
139
+ "이 작업에는 명시 승인이 필요한 도구가 포함되어 있어 자동 실행을 중단했습니다. "
140
+ "human_in_loop 승인 흐름으로 다시 실행해 주세요."
141
+ )
142
+ ctx.state = AgentState.FAILED
143
+ return
144
+ ctx.approved_by_human = bool(approved_by_human)
145
+ ctx.state = AgentState.EXECUTING
@@ -0,0 +1,157 @@
1
+ """ROLLBACK and MEMORY — putting the workspace back, and keeping the lesson.
2
+
3
+ Recovery tries git first where the policy says the tool is git-rollbackable,
4
+ then the pre-write snapshot the executor captured, and otherwise reports
5
+ ``mode="none"`` rather than claiming a recovery it did not perform. The memory
6
+ phase records what a *terminal* run taught: DONE runs record what worked,
7
+ FAILED and NEEDS_REVIEW runs record what went wrong, stored under the run's
8
+ actual terminal state and never a blanket "ok".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from latticeai.core.agent_helpers import extract_action, filter_learnings
18
+ from latticeai.core.agent_state import AGENT_TERMINAL_STATES, AgentState
19
+
20
+ from ._contract import AgentCore as _Core
21
+ from .context import AgentRunContext
22
+
23
+
24
+ class _RecoveryMixin(_Core):
25
+ """The ROLLBACK and MEMORY phases of :class:`SingleAgentRuntime`."""
26
+
27
+ # ── ROLLBACK ─────────────────────────────────────────────────────
28
+ def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
29
+ for entry in ctx.rollback_log:
30
+ if entry.get("path") == path:
31
+ return entry
32
+ return None
33
+
34
+ def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
35
+ """Recover one path: git when governed and available, else the
36
+ pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
37
+ d = self.deps
38
+ if gov.get("rollback") == "git" and d.rollback_file is not None:
39
+ try:
40
+ result = dict(d.rollback_file(str(path)))
41
+ except Exception as exc: # noqa: BLE001
42
+ result = {"path": path, "ok": False, "error": str(exc)}
43
+ if result.get("ok"):
44
+ result["mode"] = "git"
45
+ return result
46
+ snapshot = self._snapshot_for(ctx, str(path))
47
+ if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
48
+ content = snapshot.get("content") if snapshot.get("existed") else None
49
+ try:
50
+ restored = dict(d.restore_snapshot(str(path), content))
51
+ except Exception as exc: # noqa: BLE001
52
+ restored = {"path": path, "ok": False, "error": str(exc)}
53
+ restored.setdefault("path", path)
54
+ restored["mode"] = "snapshot"
55
+ return restored
56
+ return {
57
+ "path": path, "ok": False, "mode": "none",
58
+ "error": "no rollback available (git not applicable, no usable snapshot)",
59
+ }
60
+
61
+ def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
62
+ """ROLLBACK: recover written files (git → snapshot → none), then FAILED."""
63
+ d = self.deps
64
+ rolled: List[dict] = []
65
+ seen_paths: set = set()
66
+ for step in ctx.transcript:
67
+ if step.get("state") != AgentState.EXECUTING.value:
68
+ continue
69
+ if not isinstance(step.get("result"), dict):
70
+ continue
71
+ gov = step.get("governance", {}) or {}
72
+ path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
73
+ if not path or str(path) in seen_paths:
74
+ continue
75
+ if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
76
+ continue
77
+ seen_paths.add(str(path))
78
+ rolled.append(self._rollback_one(ctx, str(path), gov))
79
+
80
+ ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
81
+ ctx.trace.decision(
82
+ "rollback", decision="rolled_back",
83
+ attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
84
+ )
85
+ recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
86
+ ctx.final_message = (
87
+ f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
88
+ if recovered
89
+ else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
90
+ )
91
+ d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
92
+ self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
93
+ # Rollback is a recovery from a failed verification — terminal state is FAILED
94
+ ctx.state = AgentState.FAILED
95
+
96
+ # ── MEMORY ───────────────────────────────────────────────────────
97
+ async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
98
+ """Background: Memory Updater role extracts learnings from a terminal run.
99
+
100
+ Terminal-state learning policy (review §4.2 L6): DONE runs record what
101
+ worked; FAILED / NEEDS_REVIEW runs record what went wrong — failure is
102
+ exactly the experience worth remembering. The run status stored with
103
+ the experience is the *actual* terminal state, never a blanket "ok".
104
+ """
105
+ d = self.deps
106
+ terminal = ctx.state.value if ctx.state in AGENT_TERMINAL_STATES else "UNKNOWN"
107
+ outcome_hint = (
108
+ "The task completed successfully."
109
+ if ctx.state == AgentState.DONE
110
+ else (
111
+ f"The task ended as {terminal} — extract what went wrong and "
112
+ "what to do differently next time, not a success story."
113
+ )
114
+ )
115
+ context = (
116
+ f"{d.memory_updater_prompt}\n\n"
117
+ f"Task: {req.message}\n"
118
+ f"Terminal status: {terminal}. {outcome_hint}\n\n"
119
+ f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
120
+ )
121
+ try:
122
+ raw = await d.generate(
123
+ message="Extract learnings from this completed task.",
124
+ context=context, max_tokens=self.phase_budgets.memory_tokens, temperature=0.1,
125
+ )
126
+ mem = extract_action(str(raw))
127
+ kept_learnings = filter_learnings(mem.get("learnings") or [])
128
+ if mem.get("save_to_knowledge") and kept_learnings:
129
+ learnings = "\n".join(kept_learnings)
130
+ status_label = {
131
+ AgentState.DONE: "ok",
132
+ AgentState.NEEDS_REVIEW: "needs_review",
133
+ AgentState.FAILED: "failed",
134
+ }.get(ctx.state, "unknown")
135
+ if d.brain_memory is not None:
136
+ # This runtime is LLM-driven — its learnings are real
137
+ # experiences and enter the brain with provenance.
138
+ d.brain_memory.record_experience(
139
+ f"Agent: {req.message[:60]}",
140
+ learnings,
141
+ run={
142
+ "mode": "llm",
143
+ "status": status_label,
144
+ "agent_id": "agent:executor",
145
+ "steps": len(ctx.transcript),
146
+ },
147
+ user_email=current_user or None,
148
+ )
149
+ else:
150
+ d.knowledge_save(
151
+ learnings,
152
+ folder="30_Projects",
153
+ title=f"Agent: {req.message[:60]}",
154
+ )
155
+ except Exception as exc:
156
+ # Never crash a completed run, but never swallow silently either.
157
+ logging.warning("agent memory update failed: %s", exc)
@@ -0,0 +1,210 @@
1
+ """The runtime itself: shared state, the budgets, and the drive loop.
2
+
3
+ :class:`SingleAgentRuntime` is assembled from the four phase mixins. What lives
4
+ *here* is what all four need — the injected ports, the per-phase token and
5
+ transcript budgets, the autonomy dial, the step observer, the boundary/config
6
+ contracts — plus :meth:`run_to_completion`, which drives EXECUTING → VERIFYING
7
+ → ROLLBACK until a terminal state.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any, Dict, FrozenSet, Optional
15
+
16
+ from lattice_brain.runtime.contracts import (
17
+ runtime_boundary_contract,
18
+ single_agent_contract,
19
+ )
20
+ from latticeai.core.agent_helpers import PhaseBudgets, TranscriptBudget
21
+ from latticeai.core.agent_permission import resolve_deps_mode
22
+ from latticeai.core.agent_profiles import AgentProfile, profile_for_model
23
+ from latticeai.core.agent_state import AGENT_TERMINAL_STATES, AgentState
24
+ from latticeai.core.permission_mode import PermissionMode
25
+ from latticeai.tools import document_output_target
26
+
27
+ from .context import AgentRunContext
28
+ from .deps import AgentDeps
29
+ from .execution import _ExecutionMixin
30
+ from .planning import _PlanningMixin
31
+ from .recovery import _RecoveryMixin
32
+ from .verification import _VerificationMixin
33
+
34
+
35
+ class SingleAgentRuntime(
36
+ _PlanningMixin, _ExecutionMixin, _VerificationMixin, _RecoveryMixin
37
+ ):
38
+ """Drives the agent state machine over injected :class:`AgentDeps`.
39
+
40
+ The four mixins define disjoint method sets, so resolution order changes
41
+ nothing at runtime: this class exposes exactly the methods it exposed when
42
+ all four phases lived in one 1,465-line module.
43
+ """
44
+
45
+ def __init__(self, deps: AgentDeps) -> None:
46
+ self.deps = deps
47
+ self._env_phase_budgets: Optional[PhaseBudgets] = None
48
+ self._env_transcript_budget: Optional[TranscriptBudget] = None
49
+
50
+ @property
51
+ def phase_budgets(self) -> PhaseBudgets:
52
+ # getattr twice: partially-constructed runtimes/deps (tests build them
53
+ # via __new__ or minimal fakes) still get working default budgets.
54
+ injected = getattr(self.deps, "phase_budgets", None)
55
+ if injected is not None:
56
+ return injected
57
+ cached = getattr(self, "_env_phase_budgets", None)
58
+ if cached is None:
59
+ cached = PhaseBudgets.from_env()
60
+ self._env_phase_budgets = cached
61
+ return cached
62
+
63
+ @property
64
+ def transcript_budget(self) -> TranscriptBudget:
65
+ injected = getattr(self.deps, "transcript_budget", None)
66
+ if injected is not None:
67
+ return injected
68
+ cached = getattr(self, "_env_transcript_budget", None)
69
+ if cached is None:
70
+ cached = TranscriptBudget.from_env()
71
+ self._env_transcript_budget = cached
72
+ return cached
73
+
74
+ # ── permission mode (v9.9.8) ─────────────────────────────────────
75
+ def resolve_permission_mode(
76
+ self,
77
+ ctx: Optional[AgentRunContext] = None,
78
+ *,
79
+ user_email: Optional[str] = None,
80
+ workspace_id: Optional[str] = None,
81
+ ) -> PermissionMode:
82
+ """Autonomy dial for this run.
83
+
84
+ A mode stamped on ``ctx`` wins, so the plan a user approved and every
85
+ tool step in the same run are judged by one dial even if the stored
86
+ preference changes mid-run. Otherwise the resolver on ``deps`` is
87
+ consulted with the caller's scope — resolving unscoped would collapse
88
+ every caller onto the process-wide default.
89
+ """
90
+ return resolve_deps_mode(
91
+ self.deps, ctx, user_email=user_email, workspace_id=workspace_id,
92
+ )
93
+
94
+ def _governed_path_exists(self, name: str, path: str) -> bool:
95
+ """Does this tool call's *real* target already exist?
96
+
97
+ The document creators sanitize ``filename`` into their own output
98
+ directory, so the raw argument is resolved through
99
+ :func:`document_output_target` first — checking it verbatim would
100
+ inspect a path nothing ever writes and the fail-closed overwrite guard
101
+ would never fire. Workspace-relative paths resolve under
102
+ ``deps.agent_root``; absolute paths (home-sandbox writes) are honored
103
+ as-is. Never raises: governance must not be able to crash the loop, and
104
+ an unresolvable path degrades to "new file", which the remaining gates
105
+ still cover.
106
+ """
107
+ try:
108
+ candidate = Path(document_output_target(name, path) or path)
109
+ if not candidate.is_absolute():
110
+ candidate = Path(self.deps.agent_root) / candidate
111
+ return candidate.exists()
112
+ except Exception: # noqa: BLE001 — classification is best-effort
113
+ return False
114
+
115
+ def _governed_tools(self) -> FrozenSet[str]:
116
+ governor = getattr(self.deps, "change_governor", None)
117
+ if governor is None:
118
+ return frozenset()
119
+ return frozenset(getattr(governor, "governed_tools", frozenset()))
120
+
121
+ def profile_for(self, model_id: Optional[str]) -> AgentProfile:
122
+ """Loop profile for the model actually executing this run (v9.9.7).
123
+
124
+ An injected ``AgentDeps.agent_profile`` wins (tests, explicit config);
125
+ otherwise the profile is derived from the model id, so a small local
126
+ model gets the compact loop without any extra configuration.
127
+ """
128
+ injected = getattr(self.deps, "agent_profile", None)
129
+ if injected is not None:
130
+ return injected
131
+ return profile_for_model(model_id)
132
+
133
+ def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
134
+ """Fire the per-run / deps step observers (review Wave 1.1).
135
+
136
+ Observers power the live step timeline in the UI. They are pure
137
+ telemetry: any observer failure is logged and swallowed — the loop
138
+ itself must never notice.
139
+ """
140
+ payload: Dict[str, Any] = {"phase": phase, "event": event}
141
+ for key, value in details.items():
142
+ if value is not None:
143
+ payload[key] = value
144
+ for observer in (getattr(ctx, "on_step", None), getattr(self.deps, "on_step", None)):
145
+ if observer is None:
146
+ continue
147
+ try:
148
+ observer(dict(payload))
149
+ except Exception as exc: # noqa: BLE001 — observers are advisory
150
+ logging.warning("agent step observer failed: %s", exc)
151
+
152
+ @staticmethod
153
+ def _project_block(ctx: AgentRunContext) -> str:
154
+ """Project-session context for prompts, or "" for a standalone run.
155
+
156
+ Multi-turn project loop (v9.9.6): a later run must see the files the
157
+ project already produced and what is still open, instead of planning
158
+ from a blank workspace every time.
159
+ """
160
+ summary = str(getattr(ctx, "project_context", "") or "").strip()
161
+ return f"\n\n[PROJECT SESSION]\n{summary}" if summary else ""
162
+
163
+ def boundary(self) -> Dict[str, Any]:
164
+ return runtime_boundary_contract(
165
+ name="SingleAgentRuntime",
166
+ runtime="single_agent",
167
+ entrypoint="latticeai.core.agent.SingleAgentRuntime",
168
+ surface="/agent",
169
+ owns="single-agent PLAN / EXECUTE / VERIFY state machine over injected ports",
170
+ compatibility_aliases=[],
171
+ )
172
+
173
+ def config(self) -> Dict[str, Any]:
174
+ return {
175
+ "boundary": self.boundary(),
176
+ "states": [state.value for state in AgentState],
177
+ "terminal_states": sorted(state.value for state in AGENT_TERMINAL_STATES),
178
+ "execution_mode": "injected_ports",
179
+ }
180
+
181
+ def contract(self, ctx: AgentRunContext, req: Any, *, run_id: Optional[str] = None) -> Dict[str, Any]:
182
+ """Expose the shared agent-run contract for the single-agent loop."""
183
+ return single_agent_contract(ctx=ctx, goal=getattr(req, "message", ""), run_id=run_id)
184
+
185
+ # ── DRIVE LOOP ───────────────────────────────────────────────────
186
+ async def run_to_completion(
187
+ self, ctx: AgentRunContext, req: Any, lang_hint: str,
188
+ current_user: str, max_steps: int, max_retry: int,
189
+ ) -> None:
190
+ """Run EXECUTING → VERIFYING → ROLLBACK loop until a terminal state."""
191
+ while ctx.state not in AGENT_TERMINAL_STATES:
192
+ ctx.state_history.append(ctx.state.value)
193
+ if len(ctx.state_history) > 200:
194
+ ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
195
+ ctx.state = AgentState.FAILED
196
+ break
197
+
198
+ if ctx.state == AgentState.EXECUTING:
199
+ await self.execute(ctx, req, lang_hint, current_user, max_steps,
200
+ model_id=ctx.executing_model)
201
+ elif ctx.state == AgentState.VERIFYING:
202
+ await self.verify(ctx, req, lang_hint, current_user, max_retry,
203
+ model_id=ctx.reviewing_model)
204
+ elif ctx.state == AgentState.ROLLBACK:
205
+ self.rollback(ctx, current_user)
206
+ else:
207
+ ctx.state = AgentState.FAILED
208
+
209
+ ctx.state_history.append(ctx.state.value)
210
+ self._emit_step(ctx, "terminal", "state", state=ctx.state.value)