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.
- package/README.md +40 -39
- package/docs/CHANGELOG.md +80 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/curator.py +141 -0
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/projection.py +161 -0
- package/lattice_brain/graph/retrieval.py +19 -1
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +30 -2
- package/latticeai/api/chat_agent_http.py +193 -4
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +319 -8
- package/latticeai/api/chat_stream.py +17 -1
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +30 -0
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +15 -0
- package/latticeai/core/agent.py +205 -11
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/file_generation.py +337 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +6 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +20 -1
- package/latticeai/services/folder_watch.py +391 -0
- package/latticeai/services/funnel_metrics.py +202 -0
- package/latticeai/services/product_readiness.py +6 -1
- package/latticeai/services/search_service.py +17 -1
- package/latticeai/services/tool_dispatch.py +36 -0
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +28 -27
- package/static/app/assets/Act-DmdruVKV.js +1 -0
- package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
- package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
- package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
- package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
- package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
- package/static/app/assets/Library-Bp0n-HlW.js +1 -0
- package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
- package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
- package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
- package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
- package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
- package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
- package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
- package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
- package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
- package/static/app/assets/index-Bge3DXW7.css +2 -0
- package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
- package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
- package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
- package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
- package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
- package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
- package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
- package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
- package/static/app/assets/utils-uQYKXNeq.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-CcSWTQsx.js +0 -1
- package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
- package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
- package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
- package/static/app/assets/Library-BcaiPbum.js +0 -1
- package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
- package/static/app/assets/download-C-Ei3GFa.js +0 -1
- package/static/app/assets/index-CFRhNBuH.css +0 -2
- package/static/app/assets/sunrise-DXMjueec.js +0 -1
- package/static/app/assets/utils-BrfFHAJl.js +0 -7
package/latticeai/api/setup.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/latticeai/api/tools.py
CHANGED
|
@@ -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)
|
package/latticeai/app_factory.py
CHANGED
|
@@ -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 ────────────────────────────────────────────────────────────────
|
package/latticeai/core/agent.py
CHANGED
|
@@ -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=
|
|
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
|
|
274
|
-
"""
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
984
|
+
context=context, max_tokens=self.phase_budgets.memory_tokens, temperature=0.1,
|
|
792
985
|
)
|
|
793
986
|
mem = extract_action(str(raw))
|
|
794
|
-
|
|
795
|
-
|
|
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.
|