ltcai 9.9.0 → 9.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -35
- package/bin/ltcai.js +1 -1
- package/desktop/electron/main.cjs +1 -1
- package/docs/CHANGELOG.md +83 -0
- package/docs/CI_AND_RELEASE_GATES.md +1 -1
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/LEGACY_COMPATIBILITY.md +48 -49
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +6 -6
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/schema.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +3 -0
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_intents.py +130 -7
- package/latticeai/app_factory.py +1 -0
- package/latticeai/core/agent.py +114 -2
- package/latticeai/core/file_generation.py +58 -0
- package/latticeai/core/legacy_compatibility.py +71 -47
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +7 -2
- package/latticeai/services/tool_dispatch.py +36 -0
- package/latticeai/tools/__init__.py +1 -1
- package/latticeai/tools/knowledge.py +1 -1
- package/package.json +6 -16
- package/scripts/check_current_release_docs.mjs +20 -1
- package/scripts/check_legacy_debt.mjs +91 -0
- package/scripts/prune_release_evidence.mjs +38 -0
- package/scripts/wheel_smoke.py +22 -6
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +12 -12
- package/src-tauri/tauri.conf.json +1 -13
- package/static/app/asset-manifest.json +27 -27
- package/static/app/assets/Act-Cx_gZNbE.js +1 -0
- package/static/app/assets/{Brain-Cgkh0Hpn.js → Brain-BnGbytHa.js} +2 -2
- package/static/app/assets/BrainHome-CRUMJL93.js +2 -0
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +1 -0
- package/static/app/assets/Capture-CAKnLcb0.js +1 -0
- package/static/app/assets/CommandPalette-BoUTXBfm.js +1 -0
- package/static/app/assets/Library-PcfVUco6.js +1 -0
- package/static/app/assets/LivingBrain-C3VbogVo.js +1 -0
- package/static/app/assets/ProductFlow-BLau7c7N.js +1 -0
- package/static/app/assets/ReviewCard-CX5RqRqx.js +1 -0
- package/static/app/assets/System-DRHc_6Su.js +1 -0
- package/static/app/assets/bot-DZkDdh-A.js +1 -0
- package/static/app/assets/cpu-DyvsDBbj.js +1 -0
- package/static/app/assets/download-C7yxYHvz.js +1 -0
- package/static/app/assets/folder-open-CffoIr9t.js +1 -0
- package/static/app/assets/hard-drive-C1oojrHb.js +1 -0
- package/static/app/assets/{index-BjaXCeOZ.js → index-BS_3aN1n.js} +3 -3
- package/static/app/assets/index-DU78Kogz.css +2 -0
- package/static/app/assets/input-B_U7zvbR.js +1 -0
- package/static/app/assets/navigation-xG-gyjbe.js +1 -0
- package/static/app/assets/network-BF13HJJF.js +1 -0
- package/static/app/assets/primitives-DsuKh3M8.js +1 -0
- package/static/app/assets/sunrise-CIP2ELJ1.js +1 -0
- package/static/app/assets/textarea-DsDL4ET8.js +1 -0
- package/static/app/assets/utils-Dh-Dwtkb.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/auto_setup.py +0 -21
- package/kg_schema.py +0 -13
- package/knowledge_graph.py +0 -51
- package/knowledge_graph_api.py +0 -24
- package/llm_router.py +0 -20
- package/local_knowledge_api.py +0 -16
- package/ltcai_cli.py +0 -21
- package/mcp_registry.py +0 -20
- package/p_reinforce.py +0 -16
- package/setup_wizard.py +0 -16
- package/static/app/assets/Act-CfOPUKPs.js +0 -2
- package/static/app/assets/BrainHome-D8e3wQiW.js +0 -3
- package/static/app/assets/BrainSignals-BjRBA18L.js +0 -1
- package/static/app/assets/Capture-CQQYQ1Ga.js +0 -1
- package/static/app/assets/CommandPalette-pss56Mg4.js +0 -1
- package/static/app/assets/Library-CoI3xYJk.js +0 -1
- package/static/app/assets/LivingBrain-CaU_hCxQ.js +0 -1
- package/static/app/assets/ProductFlow-Db-1O71-.js +0 -1
- package/static/app/assets/System-Dfocn0zN.js +0 -1
- package/static/app/assets/bot-Bp2x1i6F.js +0 -1
- package/static/app/assets/circle-check-CyGal42W.js +0 -1
- package/static/app/assets/cpu-DQFk82hG.js +0 -1
- package/static/app/assets/download-C70gKQVi.js +0 -1
- package/static/app/assets/folder-open-DNGytsVw.js +0 -1
- package/static/app/assets/hard-drive-CYo_bEl4.js +0 -1
- package/static/app/assets/i18n-BBMJshCW.js +0 -7
- package/static/app/assets/index-BqRcLZR3.css +0 -2
- package/static/app/assets/input-ChWD-Fsh.js +0 -1
- package/static/app/assets/navigation-D9D9_FPC.js +0 -1
- package/static/app/assets/network-DzLDGmtV.js +0 -1
- package/static/app/assets/primitives-CmaSAyGG.js +0 -1
- package/static/app/assets/sparkles-DXiaM1NS.js +0 -1
- package/static/app/assets/textarea-CV43-o79.js +0 -1
- package/telegram_bot.py +0 -16
- package/tools/__init__.py +0 -29
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict, Optional
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
8
10
|
|
|
9
11
|
from fastapi import HTTPException, Request
|
|
10
12
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
@@ -17,7 +19,43 @@ from latticeai.api.chat_helpers import (
|
|
|
17
19
|
)
|
|
18
20
|
from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
|
|
19
21
|
from latticeai.core.agent import AgentState
|
|
20
|
-
from latticeai.core.file_generation import
|
|
22
|
+
from latticeai.core.file_generation import (
|
|
23
|
+
PREVIEWABLE_EXTENSIONS,
|
|
24
|
+
generate_file_content,
|
|
25
|
+
infer_file_target,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def next_available_path(root: Path, target: str) -> str:
|
|
30
|
+
"""Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
|
|
31
|
+
file already exists in the workspace.
|
|
32
|
+
|
|
33
|
+
The direct chat write path must never silently overwrite an existing file:
|
|
34
|
+
overwrites belong to the reviewable-proposal flow. Deterministic suffixing
|
|
35
|
+
keeps "make me an html page" repeatable without a 409 dead end.
|
|
36
|
+
"""
|
|
37
|
+
candidate = (root / target)
|
|
38
|
+
if not candidate.exists():
|
|
39
|
+
return target
|
|
40
|
+
stem, suffix = candidate.stem, candidate.suffix
|
|
41
|
+
# An earlier auto-suffix run may have produced name_2 already; restart the
|
|
42
|
+
# numbering from the base name so we fill the first free slot.
|
|
43
|
+
base = re.sub(r"_\d+$", "", stem) or stem
|
|
44
|
+
for index in range(2, 100):
|
|
45
|
+
variant = candidate.with_name(f"{base}_{index}{suffix}")
|
|
46
|
+
if not variant.exists():
|
|
47
|
+
return str(Path(target).with_name(variant.name))
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=409,
|
|
50
|
+
detail=f"'{target}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ingest_generated_enabled() -> bool:
|
|
55
|
+
"""Feature toggle for auto-indexing generated files into the Brain."""
|
|
56
|
+
return os.environ.get("LATTICEAI_INGEST_GENERATED", "1").strip().lower() not in {
|
|
57
|
+
"0", "false", "off", "no",
|
|
58
|
+
}
|
|
21
59
|
|
|
22
60
|
|
|
23
61
|
class ChatIntentController:
|
|
@@ -41,6 +79,7 @@ class ChatIntentController:
|
|
|
41
79
|
execute_tool: Any,
|
|
42
80
|
agent_controller: Any,
|
|
43
81
|
agent_root: Path,
|
|
82
|
+
ingestion_pipeline: Any = None,
|
|
44
83
|
) -> None:
|
|
45
84
|
self.router = model_router
|
|
46
85
|
self.config = config
|
|
@@ -59,6 +98,7 @@ class ChatIntentController:
|
|
|
59
98
|
self.execute_tool = execute_tool
|
|
60
99
|
self.agent_controller = agent_controller
|
|
61
100
|
self.agent_root = Path(agent_root)
|
|
101
|
+
self.ingestion_pipeline = ingestion_pipeline
|
|
62
102
|
|
|
63
103
|
def no_model_response(self) -> JSONResponse:
|
|
64
104
|
detail = "No model loaded. Call /models/load first."
|
|
@@ -184,13 +224,25 @@ class ChatIntentController:
|
|
|
184
224
|
)
|
|
185
225
|
return single_answer_response(req, answer, model="client_url")
|
|
186
226
|
|
|
187
|
-
async def direct_file_action(
|
|
227
|
+
async def direct_file_action(
|
|
228
|
+
self,
|
|
229
|
+
req: Any,
|
|
230
|
+
*,
|
|
231
|
+
model_id: Optional[str],
|
|
232
|
+
effective_email: Optional[str] = None,
|
|
233
|
+
workspace_id: Optional[str] = None,
|
|
234
|
+
):
|
|
188
235
|
# An explicit path ("report.txt") wins; otherwise infer one from an
|
|
189
236
|
# explicit type keyword ("html 파일 만들어줘") so weak models never
|
|
190
237
|
# have to drive the JSON tool loop just to create a file.
|
|
191
238
|
target_path = file_action_target(req.message) or infer_file_target(req.message)
|
|
192
239
|
if not target_path:
|
|
193
240
|
return None
|
|
241
|
+
# Never silently overwrite: an existing target gets a _2/_3 suffix so
|
|
242
|
+
# regeneration is safe and repeatable without the proposal flow.
|
|
243
|
+
deduped_path = next_available_path(self.agent_root, target_path)
|
|
244
|
+
renamed = deduped_path != target_path
|
|
245
|
+
target_path = deduped_path
|
|
194
246
|
content = inline_file_action_content(req.message)
|
|
195
247
|
generation_meta: Optional[Dict[str, Any]] = None
|
|
196
248
|
if content is None and not model_id:
|
|
@@ -227,19 +279,43 @@ class ChatIntentController:
|
|
|
227
279
|
)
|
|
228
280
|
except self.tool_error as exc:
|
|
229
281
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
230
|
-
|
|
231
|
-
|
|
282
|
+
final_path = result.get("path") or target_path
|
|
283
|
+
answer = f"{final_path} 파일을 만들었습니다."
|
|
284
|
+
if renamed:
|
|
285
|
+
answer += " (같은 이름의 파일이 있어 새 이름으로 저장했습니다.)"
|
|
286
|
+
repaired = bool(generation_meta and generation_meta.get("repaired"))
|
|
287
|
+
if repaired:
|
|
232
288
|
answer += (
|
|
233
289
|
" (모델 출력이 불완전해 자동 보정을 거쳐 유효한 파일로 저장했습니다.)"
|
|
234
290
|
)
|
|
235
291
|
created_files = [
|
|
236
292
|
{
|
|
237
|
-
"path":
|
|
238
|
-
"filename": Path(
|
|
293
|
+
"path": final_path,
|
|
294
|
+
"filename": Path(final_path).name,
|
|
239
295
|
"bytes": result.get("bytes", 0),
|
|
240
296
|
"action": "write_file",
|
|
241
297
|
}
|
|
242
298
|
]
|
|
299
|
+
# Artifact-first contract: every file the request produced, with the
|
|
300
|
+
# honesty flags the UI needs (valid vs repaired scaffold, previewable).
|
|
301
|
+
artifacts: List[Dict[str, Any]] = [
|
|
302
|
+
{
|
|
303
|
+
"kind": "file",
|
|
304
|
+
"path": final_path,
|
|
305
|
+
"filename": Path(final_path).name,
|
|
306
|
+
"bytes": result.get("bytes", 0),
|
|
307
|
+
"previewable": Path(final_path).suffix.lower() in PREVIEWABLE_EXTENSIONS,
|
|
308
|
+
"valid": True,
|
|
309
|
+
"repaired": repaired,
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
brain_ingest = self._ingest_generated_file(
|
|
313
|
+
final_path,
|
|
314
|
+
content,
|
|
315
|
+
effective_email=effective_email,
|
|
316
|
+
workspace_id=workspace_id,
|
|
317
|
+
conversation_id=getattr(req, "conversation_id", None),
|
|
318
|
+
)
|
|
243
319
|
self.notify("user", req.message, req.source)
|
|
244
320
|
self.notify("assistant", answer, req.source)
|
|
245
321
|
payload = {
|
|
@@ -257,11 +333,14 @@ class ChatIntentController:
|
|
|
257
333
|
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
258
334
|
"final_state": AgentState.DONE.value,
|
|
259
335
|
"created_files": created_files,
|
|
336
|
+
"artifacts": artifacts,
|
|
260
337
|
"routed_to_agent": True,
|
|
261
338
|
"action_route": "direct_write_file",
|
|
262
339
|
}
|
|
263
340
|
if generation_meta is not None:
|
|
264
341
|
payload["generation"] = generation_meta
|
|
342
|
+
if brain_ingest is not None:
|
|
343
|
+
payload["brain_ingest"] = brain_ingest
|
|
265
344
|
if req.stream:
|
|
266
345
|
return StreamingResponse(
|
|
267
346
|
agent_payload_stream(
|
|
@@ -275,6 +354,50 @@ class ChatIntentController:
|
|
|
275
354
|
)
|
|
276
355
|
return JSONResponse(content=payload)
|
|
277
356
|
|
|
357
|
+
def _ingest_generated_file(
|
|
358
|
+
self,
|
|
359
|
+
rel_path: str,
|
|
360
|
+
content: Optional[str],
|
|
361
|
+
*,
|
|
362
|
+
effective_email: Optional[str],
|
|
363
|
+
workspace_id: Optional[str],
|
|
364
|
+
conversation_id: Optional[str],
|
|
365
|
+
) -> Optional[Dict[str, Any]]:
|
|
366
|
+
"""Optionally index a just-generated file into the Brain.
|
|
367
|
+
|
|
368
|
+
The knowledge promise is "what Lattice makes, Lattice remembers":
|
|
369
|
+
without this, generated files are invisible to recall until the user
|
|
370
|
+
re-uploads them. Best-effort and additive — ingestion problems never
|
|
371
|
+
fail the file creation, and LATTICEAI_INGEST_GENERATED=0 disables it.
|
|
372
|
+
"""
|
|
373
|
+
if self.ingestion_pipeline is None or not ingest_generated_enabled():
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
from lattice_brain.ingestion import IngestionItem
|
|
377
|
+
|
|
378
|
+
item = IngestionItem(
|
|
379
|
+
source_type="file",
|
|
380
|
+
title=Path(rel_path).name,
|
|
381
|
+
text=content,
|
|
382
|
+
path=str(self.agent_root / rel_path),
|
|
383
|
+
source_uri=f"workspace://{rel_path}",
|
|
384
|
+
owner=effective_email,
|
|
385
|
+
workspace_id=workspace_id,
|
|
386
|
+
conversation_id=conversation_id,
|
|
387
|
+
metadata={"origin": "generated_file", "route": "direct_write_file"},
|
|
388
|
+
)
|
|
389
|
+
result = self.ingestion_pipeline.ingest(item, user_email=effective_email)
|
|
390
|
+
payload = result.as_dict() if hasattr(result, "as_dict") else dict(result or {})
|
|
391
|
+
return {
|
|
392
|
+
"status": payload.get("status"),
|
|
393
|
+
"node_id": payload.get("node_id"),
|
|
394
|
+
"chunk_count": payload.get("chunk_count", 0),
|
|
395
|
+
"duplicate": payload.get("duplicate", False),
|
|
396
|
+
}
|
|
397
|
+
except Exception as exc: # noqa: BLE001 — indexing must never break creation
|
|
398
|
+
logging.warning("generated-file ingest failed: %s", exc)
|
|
399
|
+
return {"status": "failed", "detail": str(exc)[:200]}
|
|
400
|
+
|
|
278
401
|
async def route_file_to_agent(
|
|
279
402
|
self,
|
|
280
403
|
req: Any,
|
package/latticeai/app_factory.py
CHANGED
|
@@ -911,6 +911,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
911
911
|
chat_service=CHAT_SERVICE,
|
|
912
912
|
context_assembler=CONTEXT_ASSEMBLER,
|
|
913
913
|
brain_memory=BRAIN_MEMORY,
|
|
914
|
+
ingestion_pipeline=INGESTION_PIPELINE if ENABLE_GRAPH else None,
|
|
914
915
|
chat_agent_runtime=CHAT_AGENT_RUNTIME,
|
|
915
916
|
gardener=gardener,
|
|
916
917
|
hooks=HOOKS_REGISTRY,
|
package/latticeai/core/agent.py
CHANGED
|
@@ -32,6 +32,7 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tu
|
|
|
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,91 @@ 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
|
+
|
|
144
230
|
@dataclass
|
|
145
231
|
class AgentDeps:
|
|
146
232
|
"""The ports a :class:`SingleAgentRuntime` needs from the outside world.
|
|
@@ -258,6 +344,9 @@ class SingleAgentRuntime:
|
|
|
258
344
|
"goal": req.message, "steps": [],
|
|
259
345
|
"requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
|
|
260
346
|
}
|
|
347
|
+
plan, plan_fixes = normalize_plan(plan, req.message)
|
|
348
|
+
if plan_fixes:
|
|
349
|
+
ctx.trace.repair("plan", repairs=plan_fixes)
|
|
261
350
|
ctx.plan = plan
|
|
262
351
|
ctx.transcript.append({
|
|
263
352
|
"state": AgentState.PLANNING.value,
|
|
@@ -266,6 +355,7 @@ class SingleAgentRuntime:
|
|
|
266
355
|
"requires_approval": plan.get("requires_approval", False),
|
|
267
356
|
"rollback_strategy": plan.get("rollback_strategy", "none"),
|
|
268
357
|
"estimated_steps": plan.get("estimated_steps", 1),
|
|
358
|
+
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
269
359
|
})
|
|
270
360
|
ctx.state = AgentState.WAITING_APPROVAL
|
|
271
361
|
|
|
@@ -557,6 +647,26 @@ class SingleAgentRuntime:
|
|
|
557
647
|
) -> None:
|
|
558
648
|
"""Role check + shared tool lifecycle, recorded on the transcript either way."""
|
|
559
649
|
d = self.deps
|
|
650
|
+
sanitize_meta: Optional[Dict[str, Any]] = None
|
|
651
|
+
if name == "write_file" and isinstance(args.get("content"), str):
|
|
652
|
+
# ArtifactWritePipeline: the executor's args.content is untrusted
|
|
653
|
+
# model output. The same extract→validate→repair guarantee as the
|
|
654
|
+
# direct chat path applies here, so a weak model driving the JSON
|
|
655
|
+
# loop can never persist fenced/chatty/truncated payloads.
|
|
656
|
+
cleaned, meta = sanitize_write_content(
|
|
657
|
+
str(args.get("path") or ""), args["content"],
|
|
658
|
+
user_request=str(ctx.plan.get("goal") or thoughts or name),
|
|
659
|
+
)
|
|
660
|
+
if meta.get("sanitized"):
|
|
661
|
+
args = dict(args)
|
|
662
|
+
args["content"] = cleaned
|
|
663
|
+
sanitize_meta = meta
|
|
664
|
+
ctx.trace.repair(
|
|
665
|
+
"execute",
|
|
666
|
+
repairs=[
|
|
667
|
+
"artifact_repair" if meta.get("repaired") else "artifact_sanitize"
|
|
668
|
+
],
|
|
669
|
+
)
|
|
560
670
|
try:
|
|
561
671
|
d.check_role(name, current_user)
|
|
562
672
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -570,6 +680,7 @@ class SingleAgentRuntime:
|
|
|
570
680
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
571
681
|
"thoughts": thoughts, "args": args,
|
|
572
682
|
"risk": risk, "governance": dict(policy), "result": result,
|
|
683
|
+
**({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
|
|
573
684
|
})
|
|
574
685
|
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
575
686
|
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
@@ -791,8 +902,9 @@ class SingleAgentRuntime:
|
|
|
791
902
|
context=context, max_tokens=256, temperature=0.1,
|
|
792
903
|
)
|
|
793
904
|
mem = extract_action(str(raw))
|
|
794
|
-
|
|
795
|
-
|
|
905
|
+
kept_learnings = filter_learnings(mem.get("learnings") or [])
|
|
906
|
+
if mem.get("save_to_knowledge") and kept_learnings:
|
|
907
|
+
learnings = "\n".join(kept_learnings)
|
|
796
908
|
if d.brain_memory is not None:
|
|
797
909
|
# This runtime is LLM-driven — its learnings are real
|
|
798
910
|
# experiences and enter the brain with provenance.
|
|
@@ -195,6 +195,14 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
|
195
195
|
return False, "not a complete HTML document (missing <!DOCTYPE html>/<html>)"
|
|
196
196
|
if "</html>" not in lower:
|
|
197
197
|
return False, "HTML document is truncated (missing </html>)"
|
|
198
|
+
# A document that merely *contains* html somewhere is not a valid
|
|
199
|
+
# file payload: fenced/chat-wrapped replies must fail here so the
|
|
200
|
+
# extraction pass gets a chance to slice out the real document.
|
|
201
|
+
stripped = content.lstrip()
|
|
202
|
+
if not (stripped.lower().startswith("<!doctype") or stripped.lower().startswith("<html")):
|
|
203
|
+
return False, "HTML document is wrapped in prose or fences"
|
|
204
|
+
if "```" in content:
|
|
205
|
+
return False, "output still contains Markdown fences"
|
|
198
206
|
return True, "ok"
|
|
199
207
|
if ext == ".json":
|
|
200
208
|
try:
|
|
@@ -359,6 +367,54 @@ def _repair_html(salvage: str, user_request: str) -> str:
|
|
|
359
367
|
)
|
|
360
368
|
|
|
361
369
|
|
|
370
|
+
# Extensions the Brain UI can render inline (preview) after creation.
|
|
371
|
+
PREVIEWABLE_EXTENSIONS = frozenset({
|
|
372
|
+
".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
|
|
373
|
+
".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ── write-side sanitize (ArtifactWritePipeline) ─────────────────────────
|
|
378
|
+
|
|
379
|
+
def sanitize_write_content(
|
|
380
|
+
target_path: str,
|
|
381
|
+
content: Any,
|
|
382
|
+
user_request: str = "",
|
|
383
|
+
) -> Tuple[str, Dict[str, Any]]:
|
|
384
|
+
"""Single write-side guarantee for model-produced file content.
|
|
385
|
+
|
|
386
|
+
The direct chat path already runs the full generate→validate→repair
|
|
387
|
+
pipeline, but the agent JSON loop historically wrote ``args.content``
|
|
388
|
+
verbatim — weak models routinely put fenced/chatty payloads there. This
|
|
389
|
+
conservative sanitizer closes that gap for *any* write entry point:
|
|
390
|
+
|
|
391
|
+
1. content that already validates is returned byte-for-byte unchanged
|
|
392
|
+
(trusted/user-authored content is never mangled);
|
|
393
|
+
2. otherwise the extraction pass strips fences/think-blocks/chat noise
|
|
394
|
+
and is used only when the extracted payload validates;
|
|
395
|
+
3. otherwise deterministic repair guarantees a structurally valid file.
|
|
396
|
+
|
|
397
|
+
Empty content is left untouched (creating an empty file is a legitimate,
|
|
398
|
+
intentional action — e.g. ``__init__.py``). Returns ``(content, meta)``
|
|
399
|
+
where meta is ``{"sanitized": bool, "repaired": bool, "reason": str}``.
|
|
400
|
+
"""
|
|
401
|
+
raw = str(content or "")
|
|
402
|
+
if not raw.strip():
|
|
403
|
+
return raw, {"sanitized": False, "repaired": False, "reason": "empty"}
|
|
404
|
+
ok, reason = validate_file_content(raw, target_path)
|
|
405
|
+
if ok:
|
|
406
|
+
return raw, {"sanitized": False, "repaired": False, "reason": "ok"}
|
|
407
|
+
extracted = extract_file_content(raw, target_path)
|
|
408
|
+
if extracted:
|
|
409
|
+
extracted_ok, _ = validate_file_content(extracted, target_path)
|
|
410
|
+
if extracted_ok:
|
|
411
|
+
return extracted, {"sanitized": True, "repaired": False, "reason": reason}
|
|
412
|
+
repaired = repair_file_content(
|
|
413
|
+
extracted or raw, target_path, user_request or f"content for {target_path}"
|
|
414
|
+
)
|
|
415
|
+
return repaired, {"sanitized": True, "repaired": True, "reason": reason}
|
|
416
|
+
|
|
417
|
+
|
|
362
418
|
# ── filename inference ──────────────────────────────────────────────────
|
|
363
419
|
|
|
364
420
|
_CREATE_VERB_RE = re.compile(
|
|
@@ -441,11 +497,13 @@ async def generate_file_content(
|
|
|
441
497
|
|
|
442
498
|
|
|
443
499
|
__all__ = [
|
|
500
|
+
"PREVIEWABLE_EXTENSIONS",
|
|
444
501
|
"build_file_generation_context",
|
|
445
502
|
"extract_file_content",
|
|
446
503
|
"generate_file_content",
|
|
447
504
|
"infer_file_target",
|
|
448
505
|
"looks_like_refusal",
|
|
449
506
|
"repair_file_content",
|
|
507
|
+
"sanitize_write_content",
|
|
450
508
|
"validate_file_content",
|
|
451
509
|
]
|
|
@@ -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.
|
|
17
|
+
LEGACY_COMPATIBILITY_VERSION = "9.9.2"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
@dataclass(frozen=True)
|
|
@@ -40,106 +40,130 @@ class LegacyShim:
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
LEGACY_SHIMS: List[LegacyShim] = [
|
|
43
|
+
LegacyShim(
|
|
44
|
+
path="server.py",
|
|
45
|
+
owner="latticeai.server_app",
|
|
46
|
+
replacement="from latticeai.server_app import app, main",
|
|
47
|
+
reason="Deployment docs, Docker CMD, start scripts, and uvicorn targets still use server:app.",
|
|
48
|
+
removal_phase="major-release-after-9.x",
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Shim layers that have completed their compatibility window and were
|
|
53
|
+
# physically deleted. Kept in the report so consumers (docs, tests, release
|
|
54
|
+
# notes) can tell "removed on purpose" apart from "missing by accident".
|
|
55
|
+
# Removing the internal-only layers is the first step of extracting
|
|
56
|
+
# ``lattice_brain`` as a standalone Brain Core package: the package now has
|
|
57
|
+
# exactly one import surface (``lattice_brain.*`` physical paths).
|
|
58
|
+
REMOVED_SHIMS: List[LegacyShim] = [
|
|
43
59
|
LegacyShim(
|
|
44
60
|
path="knowledge_graph.py",
|
|
45
|
-
owner="lattice_brain.graph",
|
|
61
|
+
owner="lattice_brain.graph.store",
|
|
46
62
|
replacement="from lattice_brain.graph.store import KnowledgeGraphStore",
|
|
47
|
-
reason="
|
|
48
|
-
removal_phase="
|
|
63
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
64
|
+
removal_phase="removed-9.9.1",
|
|
65
|
+
layer="root",
|
|
66
|
+
status="removed",
|
|
49
67
|
),
|
|
50
68
|
LegacyShim(
|
|
51
69
|
path="knowledge_graph_api.py",
|
|
52
70
|
owner="latticeai.api.knowledge_graph",
|
|
53
71
|
replacement="from latticeai.api.knowledge_graph import create_knowledge_graph_router",
|
|
54
|
-
reason="
|
|
55
|
-
removal_phase="
|
|
72
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
73
|
+
removal_phase="removed-9.9.1",
|
|
74
|
+
layer="root",
|
|
75
|
+
status="removed",
|
|
56
76
|
),
|
|
57
77
|
LegacyShim(
|
|
58
78
|
path="kg_schema.py",
|
|
59
79
|
owner="lattice_brain.graph.schema",
|
|
60
80
|
replacement="from lattice_brain.graph.schema import ...",
|
|
61
|
-
reason="
|
|
62
|
-
removal_phase="
|
|
81
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
82
|
+
removal_phase="removed-9.9.1",
|
|
83
|
+
layer="root",
|
|
84
|
+
status="removed",
|
|
63
85
|
),
|
|
64
86
|
LegacyShim(
|
|
65
87
|
path="ltcai_cli.py",
|
|
66
88
|
owner="latticeai.cli.entrypoint",
|
|
67
89
|
replacement="from latticeai.cli.entrypoint import main",
|
|
68
|
-
reason="
|
|
69
|
-
removal_phase="
|
|
90
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
91
|
+
removal_phase="removed-9.9.1",
|
|
92
|
+
layer="root",
|
|
93
|
+
status="removed",
|
|
70
94
|
),
|
|
71
95
|
LegacyShim(
|
|
72
96
|
path="telegram_bot.py",
|
|
73
97
|
owner="latticeai.integrations.telegram_bot",
|
|
74
98
|
replacement="from latticeai.integrations.telegram_bot import run_bot",
|
|
75
|
-
reason="
|
|
76
|
-
removal_phase="
|
|
99
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
100
|
+
removal_phase="removed-9.9.1",
|
|
101
|
+
layer="root",
|
|
102
|
+
status="removed",
|
|
77
103
|
),
|
|
78
104
|
LegacyShim(
|
|
79
105
|
path="p_reinforce.py",
|
|
80
106
|
owner="latticeai.services.p_reinforce",
|
|
81
107
|
replacement="from latticeai.services.p_reinforce import PReinforceGardener",
|
|
82
|
-
reason="
|
|
83
|
-
removal_phase="
|
|
108
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
109
|
+
removal_phase="removed-9.9.1",
|
|
110
|
+
layer="root",
|
|
111
|
+
status="removed",
|
|
84
112
|
),
|
|
85
113
|
LegacyShim(
|
|
86
114
|
path="mcp_registry.py",
|
|
87
115
|
owner="latticeai.core.mcp_registry",
|
|
88
116
|
replacement="import latticeai.core.mcp_registry",
|
|
89
|
-
reason="
|
|
90
|
-
removal_phase="
|
|
117
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
118
|
+
removal_phase="removed-9.9.1",
|
|
119
|
+
layer="root",
|
|
120
|
+
status="removed",
|
|
91
121
|
),
|
|
92
122
|
LegacyShim(
|
|
93
123
|
path="llm_router.py",
|
|
94
124
|
owner="latticeai.models.router",
|
|
95
125
|
replacement="import latticeai.models.router",
|
|
96
|
-
reason="
|
|
97
|
-
removal_phase="
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
path="server.py",
|
|
101
|
-
owner="latticeai.server_app",
|
|
102
|
-
replacement="from latticeai.server_app import app, main",
|
|
103
|
-
reason="Deployment docs and local launch scripts historically targeted server.py.",
|
|
104
|
-
removal_phase="major-release-after-8.x",
|
|
105
|
-
),
|
|
106
|
-
LegacyShim(
|
|
107
|
-
path="tools/",
|
|
108
|
-
owner="latticeai.tools",
|
|
109
|
-
replacement="from latticeai.tools import execute_tool",
|
|
110
|
-
reason="Existing integrations and tests import the historical root tools package.",
|
|
111
|
-
removal_phase="major-release-after-9.x",
|
|
126
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
127
|
+
removal_phase="removed-9.9.1",
|
|
128
|
+
layer="root",
|
|
129
|
+
status="removed",
|
|
112
130
|
),
|
|
113
131
|
LegacyShim(
|
|
114
132
|
path="local_knowledge_api.py",
|
|
115
133
|
owner="latticeai.services.local_knowledge",
|
|
116
134
|
replacement="from latticeai.services.local_knowledge import create_local_knowledge_router",
|
|
117
|
-
reason="
|
|
118
|
-
removal_phase="
|
|
135
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
136
|
+
removal_phase="removed-9.9.1",
|
|
137
|
+
layer="root",
|
|
138
|
+
status="removed",
|
|
119
139
|
),
|
|
120
140
|
LegacyShim(
|
|
121
141
|
path="auto_setup.py",
|
|
122
142
|
owner="latticeai.setup.auto_setup",
|
|
123
143
|
replacement="from latticeai.setup.auto_setup import probe, recommend",
|
|
124
|
-
reason="
|
|
125
|
-
removal_phase="
|
|
144
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
145
|
+
removal_phase="removed-9.9.1",
|
|
146
|
+
layer="root",
|
|
147
|
+
status="removed",
|
|
126
148
|
),
|
|
127
149
|
LegacyShim(
|
|
128
150
|
path="setup_wizard.py",
|
|
129
151
|
owner="latticeai.setup.wizard",
|
|
130
152
|
replacement="from latticeai.setup.wizard import scan_environment",
|
|
131
|
-
reason="
|
|
132
|
-
removal_phase="
|
|
153
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
154
|
+
removal_phase="removed-9.9.1",
|
|
155
|
+
layer="root",
|
|
156
|
+
status="removed",
|
|
157
|
+
),
|
|
158
|
+
LegacyShim(
|
|
159
|
+
path="tools/",
|
|
160
|
+
owner="latticeai.tools",
|
|
161
|
+
replacement="from latticeai.tools import execute_tool",
|
|
162
|
+
reason="Root compatibility shims completed their window; removed in 9.9.1.",
|
|
163
|
+
removal_phase="removed-9.9.1",
|
|
164
|
+
layer="root",
|
|
165
|
+
status="removed",
|
|
133
166
|
),
|
|
134
|
-
]
|
|
135
|
-
|
|
136
|
-
# Shim layers that have completed their compatibility window and were
|
|
137
|
-
# physically deleted. Kept in the report so consumers (docs, tests, release
|
|
138
|
-
# notes) can tell "removed on purpose" apart from "missing by accident".
|
|
139
|
-
# Removing the internal-only layers is the first step of extracting
|
|
140
|
-
# ``lattice_brain`` as a standalone Brain Core package: the package now has
|
|
141
|
-
# exactly one import surface (``lattice_brain.*`` physical paths).
|
|
142
|
-
REMOVED_SHIMS: List[LegacyShim] = [
|
|
143
167
|
LegacyShim(
|
|
144
168
|
path="lattice_brain/store.py",
|
|
145
169
|
owner="lattice_brain.graph.store",
|