ltcai 9.9.1 → 9.9.3

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 (97) hide show
  1. package/README.md +40 -39
  2. package/docs/CHANGELOG.md +80 -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/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +30 -2
  22. package/latticeai/api/chat_agent_http.py +193 -4
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +319 -8
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +15 -0
  33. package/latticeai/core/agent.py +205 -11
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +337 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +6 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +6 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/services/tool_dispatch.py +36 -0
  51. package/latticeai/setup/demo_corpus.py +98 -0
  52. package/latticeai/tools/filesystem.py +38 -1
  53. package/package.json +1 -1
  54. package/scripts/bench_models.py +234 -5
  55. package/scripts/check_current_release_docs.mjs +1 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +28 -27
  60. package/static/app/assets/Act-DmdruVKV.js +1 -0
  61. package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
  62. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  63. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  64. package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
  65. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  66. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  67. package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
  68. package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
  69. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  70. package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
  71. package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
  72. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  73. package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
  74. package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
  75. package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
  76. package/static/app/assets/index-Bge3DXW7.css +2 -0
  77. package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
  78. package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
  79. package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
  80. package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
  81. package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
  82. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  83. package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
  84. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  85. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  86. package/static/app/index.html +4 -4
  87. package/static/sw.js +1 -1
  88. package/static/app/assets/Act-CcSWTQsx.js +0 -1
  89. package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
  90. package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
  91. package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
  92. package/static/app/assets/Library-BcaiPbum.js +0 -1
  93. package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
  94. package/static/app/assets/download-C-Ei3GFa.js +0 -1
  95. package/static/app/assets/index-CFRhNBuH.css +0 -2
  96. package/static/app/assets/sunrise-DXMjueec.js +0 -1
  97. package/static/app/assets/utils-BrfFHAJl.js +0 -7
@@ -17,7 +17,15 @@ from latticeai.setup.auto_setup import (
17
17
  verify as auto_setup_verify,
18
18
  )
19
19
  from latticeai.models.router import parse_model_ref
20
+ from latticeai.setup.demo_corpus import (
21
+ DEMO_DOCUMENTS,
22
+ DEMO_METADATA_FLAG,
23
+ DEMO_URI_PREFIX,
24
+ demo_source_uri,
25
+ suggested_questions,
26
+ )
20
27
  from latticeai.setup.wizard import get_recommendations, install_stream, open_url, scan_environment
28
+ from lattice_brain.ingestion import IngestionItem
21
29
 
22
30
 
23
31
  class SetupInstallRequest(BaseModel):
@@ -25,7 +33,18 @@ class SetupInstallRequest(BaseModel):
25
33
  confirmation_token: Optional[str] = None
26
34
 
27
35
 
28
- def create_setup_router(*, model_router, require_user) -> APIRouter:
36
+ class DemoCorpusRequest(BaseModel):
37
+ workspace_id: Optional[str] = None
38
+
39
+
40
+ def create_setup_router(
41
+ *,
42
+ model_router,
43
+ require_user,
44
+ ingestion_pipeline=None,
45
+ knowledge_graph=None,
46
+ workspace_service=None,
47
+ ) -> APIRouter:
29
48
  api_router = APIRouter()
30
49
  router = model_router
31
50
 
@@ -157,6 +176,120 @@ def create_setup_router(*, model_router, require_user) -> APIRouter:
157
176
  return {"status": "ok", "opened": url, "mcp_id": mcp_id}
158
177
 
159
178
 
179
+ # ── First Value Loop demo corpus (backlog #3, review §3.3 P0) ────────────
180
+
181
+ def _require_demo_pipeline():
182
+ if ingestion_pipeline is None or not ingestion_pipeline.available():
183
+ raise HTTPException(
184
+ status_code=503, detail="Knowledge Graph ingestion is disabled.",
185
+ )
186
+ if knowledge_graph is None:
187
+ raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
188
+
189
+ def _demo_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
190
+ header = request.headers.get("X-Workspace-Id")
191
+ header = header.strip() if header and header.strip() else None
192
+ supplied = [value for value in (body_workspace, header) if value]
193
+ if len(set(supplied)) > 1:
194
+ raise HTTPException(status_code=403, detail="Workspace selectors must match.")
195
+ requested = supplied[0] if supplied else None
196
+ if workspace_service is None:
197
+ return requested
198
+ try:
199
+ return workspace_service.resolve_write_scope(requested, user or None)
200
+ except PermissionError as exc:
201
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
202
+
203
+ @api_router.get("/api/setup/demo-corpus")
204
+ async def demo_corpus_status(request: Request):
205
+ """Whether the demo corpus is installed + the suggestion chips."""
206
+ require_user(request)
207
+ _require_demo_pipeline()
208
+ installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
209
+ return {
210
+ "installed": bool(installed),
211
+ "documents": installed,
212
+ "document_count": len(installed),
213
+ "suggested_questions": suggested_questions(),
214
+ }
215
+
216
+ @api_router.post("/api/setup/demo-corpus")
217
+ async def demo_corpus_install(request: Request, req: Optional[DemoCorpusRequest] = None):
218
+ """One-click ingest of the 3 built-in demo documents (idempotent).
219
+
220
+ Every document goes through the normal IngestionPipeline door with
221
+ ``demo://`` provenance so it is honest (real nodes, real chunks, real
222
+ provenance) and removable via DELETE. Re-POSTing reports duplicates
223
+ instead of duplicating.
224
+ """
225
+ user = require_user(request)
226
+ _require_demo_pipeline()
227
+ workspace_id = _demo_workspace(request, req.workspace_id if req else None, user)
228
+ results = []
229
+ ingested = 0
230
+ duplicates = 0
231
+ failed = 0
232
+ for doc in DEMO_DOCUMENTS:
233
+ result = ingestion_pipeline.ingest(
234
+ IngestionItem(
235
+ source_type="note",
236
+ title=doc["title"],
237
+ text=doc["text"],
238
+ source_uri=demo_source_uri(doc["id"]),
239
+ owner=user or None,
240
+ workspace_id=workspace_id,
241
+ metadata={DEMO_METADATA_FLAG: True, "demo_id": doc["id"]},
242
+ ),
243
+ user_email=user or None,
244
+ )
245
+ if result.status != "ok":
246
+ failed += 1
247
+ elif result.duplicate:
248
+ duplicates += 1
249
+ else:
250
+ ingested += 1
251
+ results.append({
252
+ "demo_id": doc["id"],
253
+ "title": doc["title"],
254
+ "source_uri": demo_source_uri(doc["id"]),
255
+ "status": result.status,
256
+ "node_id": result.node_id,
257
+ "duplicate": result.duplicate,
258
+ "chunk_count": result.chunk_count,
259
+ "detail": result.detail,
260
+ })
261
+ status = "ok" if failed == 0 else ("partial" if (ingested + duplicates) else "failed")
262
+ return {
263
+ "status": status,
264
+ "ingested": ingested,
265
+ "duplicates": duplicates,
266
+ "failed": failed,
267
+ "documents": results,
268
+ "suggested_questions": suggested_questions(),
269
+ }
270
+
271
+ @api_router.delete("/api/setup/demo-corpus")
272
+ async def demo_corpus_remove(request: Request):
273
+ """Remove every demo document (node + chunks + edges + orphan source)."""
274
+ require_user(request)
275
+ _require_demo_pipeline()
276
+ installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
277
+ removed = []
278
+ for doc in installed:
279
+ outcome = knowledge_graph.delete_document_tree(doc["id"])
280
+ removed.append({
281
+ "node_id": doc["id"],
282
+ "title": doc.get("title"),
283
+ "source_uri": doc.get("source_uri"),
284
+ "status": outcome.get("status"),
285
+ "removed_nodes": outcome.get("removed_nodes", 0),
286
+ })
287
+ return {
288
+ "status": "ok",
289
+ "removed_count": len(removed),
290
+ "removed": removed,
291
+ }
292
+
160
293
  @api_router.post("/permissions/open/{permission_id}")
161
294
  async def open_permission_settings(permission_id: str, request: Request):
162
295
  require_user(request)
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
  from fastapi import APIRouter, File, HTTPException, Request, UploadFile
14
- from fastapi.responses import FileResponse
14
+ from fastapi.responses import FileResponse, Response
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from latticeai.api.computer_use import create_computer_use_router
@@ -543,8 +543,38 @@ def create_tools_router(
543
543
  filename=target.name,
544
544
  media_type="application/octet-stream",
545
545
  )
546
-
547
-
546
+
547
+
548
+ @api_router.get("/tools/download_zip")
549
+ async def tools_download_zip(path: str, request: Request):
550
+ """Serve a generated project directory from the agent workspace as a zip.
551
+
552
+ The multi-file artifact loop writes bundles like ``todo-app/`` —
553
+ this is their one-click download. Path confinement mirrors
554
+ ``/tools/download``: anything outside the workspace is refused.
555
+ """
556
+ require_user(request)
557
+ from urllib.parse import unquote
558
+
559
+ from latticeai.tools.filesystem import zip_workspace_dir
560
+
561
+ rel = unquote(path).lstrip("/")
562
+ target = (AGENT_ROOT / rel).resolve()
563
+ if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
564
+ raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
565
+ if not target.exists() or not target.is_dir():
566
+ raise HTTPException(status_code=404, detail="디렉터리가 없습니다.")
567
+ try:
568
+ payload, filename = zip_workspace_dir(rel)
569
+ except ToolError as exc:
570
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
571
+ return Response(
572
+ content=payload,
573
+ media_type="application/zip",
574
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
575
+ )
576
+
577
+
548
578
  @api_router.post("/upload/document")
549
579
  async def upload_document(request: Request, file: UploadFile = File(...)):
550
580
  current_user = require_user(request)
@@ -395,6 +395,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
395
395
  INGESTION_PIPELINE = _persistence_runtime["INGESTION_PIPELINE"]
396
396
  DEVICE_IDENTITY = _persistence_runtime["DEVICE_IDENTITY"]
397
397
  KG_PORTABILITY = _persistence_runtime["KG_PORTABILITY"]
398
+ FUNNEL_METRICS = _persistence_runtime["FUNNEL_METRICS"]
398
399
 
399
400
  def _require_graph():
400
401
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -911,6 +912,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
911
912
  chat_service=CHAT_SERVICE,
912
913
  context_assembler=CONTEXT_ASSEMBLER,
913
914
  brain_memory=BRAIN_MEMORY,
915
+ ingestion_pipeline=INGESTION_PIPELINE if ENABLE_GRAPH else None,
914
916
  chat_agent_runtime=CHAT_AGENT_RUNTIME,
915
917
  gardener=gardener,
916
918
  hooks=HOOKS_REGISTRY,
@@ -950,6 +952,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
950
952
  public_model=PUBLIC_MODEL,
951
953
  local_model=LOCAL_MODEL or "",
952
954
  on_chat_message=on_chat_message,
955
+ funnel_metrics=FUNNEL_METRICS,
953
956
  )
954
957
  app.state.context = context
955
958
 
@@ -1035,6 +1038,17 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1035
1038
  gate_write=PLATFORM.gate_write,
1036
1039
  append_audit_event=append_audit_event,
1037
1040
  workspace_graph=_workspace_graph,
1041
+ run_executor=RUN_EXECUTOR,
1042
+ review_queue=REVIEW_QUEUE,
1043
+ )
1044
+ )
1045
+ # UX funnel metrics (backlog #16): admin-only runtime counters.
1046
+ from latticeai.api.funnel_metrics import create_funnel_metrics_router
1047
+
1048
+ app.include_router(
1049
+ create_funnel_metrics_router(
1050
+ service=FUNNEL_METRICS,
1051
+ require_admin=require_admin,
1038
1052
  )
1039
1053
  )
1040
1054
  COMMAND_CENTER = CommandCenterService(
@@ -1211,6 +1225,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1211
1225
  gardener=gardener,
1212
1226
  create_setup_router=create_setup_router,
1213
1227
  model_router=router,
1228
+ knowledge_graph=KNOWLEDGE_GRAPH,
1214
1229
  )
1215
1230
 
1216
1231
  # ── Entry Point ────────────────────────────────────────────────────────────────
@@ -27,11 +27,12 @@ import re
27
27
  from dataclasses import dataclass
28
28
  from enum import Enum
29
29
  from pathlib import Path
30
- from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
30
+ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Optional, Tuple
31
31
 
32
32
  from lattice_brain.runtime.hooks import dispatch_tool
33
33
  from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
34
34
  from latticeai.core.agent_trace import LoopTrace
35
+ from latticeai.core.file_generation import infer_file_target, sanitize_write_content
35
36
  from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
36
37
  from latticeai.tools import ToolError
37
38
 
@@ -141,6 +142,129 @@ def extract_action(raw: str) -> Dict:
141
142
  return action
142
143
 
143
144
 
145
+ def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[str]]:
146
+ """Enforce the minimal plan schema so execution never starts adrift.
147
+
148
+ A weak planner that returns junk steps / a missing goal previously flowed
149
+ straight into the executor, which then had to reconstruct intent from the
150
+ raw request. Normalization keeps the loop honest: ``goal`` is always a
151
+ non-empty string, ``steps`` only contains dicts with an ``action``, and an
152
+ empty plan for an obvious file-creation request gets a deterministic
153
+ single ``write_file`` step instead of leaving the executor to improvise.
154
+
155
+ Returns ``(plan, fixes)`` where ``fixes`` names every applied repair —
156
+ the loop trace records them so plan quality is observable per model.
157
+ """
158
+ fixes: List[str] = []
159
+ if not isinstance(plan, dict):
160
+ plan = {}
161
+ fixes.append("plan_not_object")
162
+ plan = dict(plan)
163
+
164
+ goal = str(plan.get("goal") or "").strip()
165
+ if not goal:
166
+ plan["goal"] = user_message
167
+ fixes.append("goal_defaulted")
168
+
169
+ raw_steps = plan.get("steps")
170
+ steps = [
171
+ s for s in (raw_steps if isinstance(raw_steps, list) else [])
172
+ if isinstance(s, dict) and s.get("action")
173
+ ]
174
+ if raw_steps and steps != raw_steps:
175
+ fixes.append("steps_filtered")
176
+ if not steps:
177
+ inferred = infer_file_target(user_message)
178
+ if inferred:
179
+ steps = [{
180
+ "action": "write_file",
181
+ "args": {"path": inferred},
182
+ "description": f"Create {inferred} for: {user_message[:120]}",
183
+ }]
184
+ fixes.append("heuristic_file_step")
185
+ plan["steps"] = steps
186
+
187
+ try:
188
+ estimated = int(plan.get("estimated_steps") or 0)
189
+ except (TypeError, ValueError):
190
+ estimated = 0
191
+ fixes.append("estimated_steps_invalid")
192
+ plan["estimated_steps"] = max(1, estimated, len(steps))
193
+ plan["requires_approval"] = bool(plan.get("requires_approval", False))
194
+ if not isinstance(plan.get("rollback_strategy"), str):
195
+ plan["rollback_strategy"] = "none"
196
+ return plan, fixes
197
+
198
+
199
+ _TRIVIAL_LEARNING_RE = re.compile(
200
+ r"^(파일(을|이)?\s*(만들|생성|작성|저장)|작업(을|이)?\s*(완료|성공)|성공적으로"
201
+ r"|task\s+(was\s+)?complet|file\s+(was\s+)?(creat|written|saved)"
202
+ r"|(successfully\s+)?(created|completed|finished|done)\b)",
203
+ re.IGNORECASE,
204
+ )
205
+
206
+
207
+ def filter_learnings(learnings: List[Any]) -> List[str]:
208
+ """Drop trivial/duplicate learnings before they enter the brain.
209
+
210
+ "파일을 만들었다"-class statements restate what the transcript already
211
+ records and pollute recall. A learning survives when it is long enough to
212
+ carry information and is not a bare completion announcement.
213
+ """
214
+ kept: List[str] = []
215
+ seen: set = set()
216
+ for raw in learnings or []:
217
+ text = str(raw or "").strip()
218
+ if len(text) < 12:
219
+ continue
220
+ if _TRIVIAL_LEARNING_RE.match(text) and len(text) < 48:
221
+ continue
222
+ key = text.lower()
223
+ if key in seen:
224
+ continue
225
+ seen.add(key)
226
+ kept.append(text)
227
+ return kept
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class PhaseBudgets:
232
+ """Per-phase token budgets for the agent loop.
233
+
234
+ One shared budget let a weak model burn everything on planning prose and
235
+ reach EXECUTE with nothing left. Each role phase now has its own cap, so
236
+ a verbose planner can never starve execution or verification. Defaults
237
+ match the historical hardcoded values; every cap is overridable through
238
+ the ``Config.from_env`` environment pattern.
239
+ """
240
+
241
+ plan_tokens: int = 1024
242
+ execute_tokens: int = 4096
243
+ verify_tokens: int = 512
244
+ memory_tokens: int = 256
245
+
246
+ @classmethod
247
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "PhaseBudgets":
248
+ from latticeai.core.config import _int
249
+
250
+ if env is None:
251
+ import os
252
+
253
+ env = os.environ
254
+
255
+ def cap(key: str, default: int) -> int:
256
+ # A misconfigured/absurd value must not brick the loop: floor at a
257
+ # budget that still fits one JSON action object.
258
+ return max(128, _int(env, key, default))
259
+
260
+ return cls(
261
+ plan_tokens=cap("LATTICEAI_AGENT_PLAN_TOKENS", cls.plan_tokens),
262
+ execute_tokens=cap("LATTICEAI_AGENT_EXECUTE_TOKENS", cls.execute_tokens),
263
+ verify_tokens=cap("LATTICEAI_AGENT_VERIFY_TOKENS", cls.verify_tokens),
264
+ memory_tokens=cap("LATTICEAI_AGENT_MEMORY_TOKENS", cls.memory_tokens),
265
+ )
266
+
267
+
144
268
  @dataclass
145
269
  class AgentDeps:
146
270
  """The ports a :class:`SingleAgentRuntime` needs from the outside world.
@@ -200,12 +324,29 @@ class AgentDeps:
200
324
  # returning None (fall through to the classic gates) or a verdict dict.
201
325
  change_governor: Any = None
202
326
 
327
+ # ── phase budgets (optional) ─────────────────────────────────────
328
+ # Per-phase token caps (plan/execute/verify/memory). None reads the
329
+ # environment once at first use; tests inject a fixed PhaseBudgets.
330
+ phase_budgets: Optional[PhaseBudgets] = None
331
+
203
332
 
204
333
  class SingleAgentRuntime:
205
334
  """Drives the agent state machine over injected :class:`AgentDeps`."""
206
335
 
207
336
  def __init__(self, deps: AgentDeps) -> None:
208
337
  self.deps = deps
338
+ self._env_phase_budgets: Optional[PhaseBudgets] = None
339
+
340
+ @property
341
+ def phase_budgets(self) -> PhaseBudgets:
342
+ # getattr twice: partially-constructed runtimes/deps (tests build them
343
+ # via __new__ or minimal fakes) still get working default budgets.
344
+ injected = getattr(self.deps, "phase_budgets", None)
345
+ if injected is not None:
346
+ return injected
347
+ if getattr(self, "_env_phase_budgets", None) is None:
348
+ self._env_phase_budgets = PhaseBudgets.from_env()
349
+ return self._env_phase_budgets
209
350
 
210
351
  def boundary(self) -> Dict[str, Any]:
211
352
  return runtime_boundary_contract(
@@ -245,7 +386,7 @@ class SingleAgentRuntime:
245
386
  raw = await d.generate_as(
246
387
  model_id,
247
388
  message="Produce a JSON execution plan for this request.",
248
- context=context, max_tokens=1024, temperature=0.1,
389
+ context=context, max_tokens=self.phase_budgets.plan_tokens, temperature=0.1,
249
390
  )
250
391
  ctx.trace.llm_call("plan", model=model_id)
251
392
  try:
@@ -258,6 +399,9 @@ class SingleAgentRuntime:
258
399
  "goal": req.message, "steps": [],
259
400
  "requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
260
401
  }
402
+ plan, plan_fixes = normalize_plan(plan, req.message)
403
+ if plan_fixes:
404
+ ctx.trace.repair("plan", repairs=plan_fixes)
261
405
  ctx.plan = plan
262
406
  ctx.transcript.append({
263
407
  "state": AgentState.PLANNING.value,
@@ -266,12 +410,19 @@ class SingleAgentRuntime:
266
410
  "requires_approval": plan.get("requires_approval", False),
267
411
  "rollback_strategy": plan.get("rollback_strategy", "none"),
268
412
  "estimated_steps": plan.get("estimated_steps", 1),
413
+ **({"plan_fixes": plan_fixes} if plan_fixes else {}),
269
414
  })
270
415
  ctx.state = AgentState.WAITING_APPROVAL
271
416
 
272
417
  # ── APPROVAL ─────────────────────────────────────────────────────
273
- def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
274
- """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
418
+ def approval_requirements(self, ctx: AgentRunContext) -> Dict[str, Any]:
419
+ """Read-only preview of the approval gate for a planned run.
420
+
421
+ Shares the exact predicate :meth:`approve` enforces, so the HTTP
422
+ layer can pause a run as ``awaiting_approval`` (with a plan summary
423
+ for the user) instead of letting it fail closed — without ever
424
+ weakening the gate itself.
425
+ """
275
426
  d = self.deps
276
427
  auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
277
428
  # Governor-managed tools never hard-block the plan: each call is
@@ -287,7 +438,26 @@ class SingleAgentRuntime:
287
438
  if s.get("action") not in auto_approve_tools
288
439
  and s.get("action") not in governed_tools
289
440
  ]
290
- requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
441
+ requires = bool(ctx.plan.get("requires_approval", False)) or bool(non_auto)
442
+ lines = [
443
+ f"{index}. {step.get('description') or step.get('action') or '?'}"
444
+ for index, step in enumerate(steps, start=1)
445
+ ]
446
+ summary = str(ctx.plan.get("goal") or "").strip()
447
+ if lines:
448
+ summary = (summary + "\n" if summary else "") + "\n".join(lines)
449
+ return {
450
+ "requires_approval": requires,
451
+ "non_auto_steps": non_auto,
452
+ "plan_summary": summary,
453
+ }
454
+
455
+ def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
456
+ """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
457
+ d = self.deps
458
+ requirements = self.approval_requirements(ctx)
459
+ non_auto = requirements["non_auto_steps"]
460
+ requires = requirements["requires_approval"]
291
461
 
292
462
  ctx.transcript.append({
293
463
  "state": AgentState.WAITING_APPROVAL.value,
@@ -330,7 +500,8 @@ class SingleAgentRuntime:
330
500
  raw = await d.generate_as(
331
501
  model_id,
332
502
  message="Execute the next step.",
333
- context=context, max_tokens=4096, temperature=req.temperature,
503
+ context=context, max_tokens=self.phase_budgets.execute_tokens,
504
+ temperature=req.temperature,
334
505
  )
335
506
  ctx.trace.llm_call("execute", model=model_id)
336
507
  try:
@@ -557,6 +728,26 @@ class SingleAgentRuntime:
557
728
  ) -> None:
558
729
  """Role check + shared tool lifecycle, recorded on the transcript either way."""
559
730
  d = self.deps
731
+ sanitize_meta: Optional[Dict[str, Any]] = None
732
+ if name == "write_file" and isinstance(args.get("content"), str):
733
+ # ArtifactWritePipeline: the executor's args.content is untrusted
734
+ # model output. The same extract→validate→repair guarantee as the
735
+ # direct chat path applies here, so a weak model driving the JSON
736
+ # loop can never persist fenced/chatty/truncated payloads.
737
+ cleaned, meta = sanitize_write_content(
738
+ str(args.get("path") or ""), args["content"],
739
+ user_request=str(ctx.plan.get("goal") or thoughts or name),
740
+ )
741
+ if meta.get("sanitized"):
742
+ args = dict(args)
743
+ args["content"] = cleaned
744
+ sanitize_meta = meta
745
+ ctx.trace.repair(
746
+ "execute",
747
+ repairs=[
748
+ "artifact_repair" if meta.get("repaired") else "artifact_sanitize"
749
+ ],
750
+ )
560
751
  try:
561
752
  d.check_role(name, current_user)
562
753
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -570,6 +761,7 @@ class SingleAgentRuntime:
570
761
  "state": AgentState.EXECUTING.value, "action": name,
571
762
  "thoughts": thoughts, "args": args,
572
763
  "risk": risk, "governance": dict(policy), "result": result,
764
+ **({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
573
765
  })
574
766
  except (ToolError, KeyError, TypeError, PermissionError) as exc:
575
767
  ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
@@ -616,7 +808,7 @@ class SingleAgentRuntime:
616
808
  raw = await d.generate_as(
617
809
  model_id,
618
810
  message="Review the execution transcript and return your verdict JSON.",
619
- context=context, max_tokens=512, temperature=0.1,
811
+ context=context, max_tokens=self.phase_budgets.verify_tokens, temperature=0.1,
620
812
  )
621
813
  ctx.trace.llm_call("verify", model=model_id)
622
814
  verdict: Optional[Dict[str, Any]] = None
@@ -638,7 +830,8 @@ class SingleAgentRuntime:
638
830
  raw = await d.generate_as(
639
831
  model_id,
640
832
  message="Return your verdict as one strict JSON object.",
641
- context=strict_context, max_tokens=512, temperature=0.0,
833
+ context=strict_context, max_tokens=self.phase_budgets.verify_tokens,
834
+ temperature=0.0,
642
835
  )
643
836
  ctx.trace.llm_call("verify", model=model_id)
644
837
  try:
@@ -788,11 +981,12 @@ class SingleAgentRuntime:
788
981
  try:
789
982
  raw = await d.generate(
790
983
  message="Extract learnings from this completed task.",
791
- context=context, max_tokens=256, temperature=0.1,
984
+ context=context, max_tokens=self.phase_budgets.memory_tokens, temperature=0.1,
792
985
  )
793
986
  mem = extract_action(str(raw))
794
- if mem.get("save_to_knowledge") and mem.get("learnings"):
795
- learnings = "\n".join(mem["learnings"])
987
+ kept_learnings = filter_learnings(mem.get("learnings") or [])
988
+ if mem.get("save_to_knowledge") and kept_learnings:
989
+ learnings = "\n".join(kept_learnings)
796
990
  if d.brain_memory is not None:
797
991
  # This runtime is LLM-driven — its learnings are real
798
992
  # experiences and enter the brain with provenance.