ltcai 8.8.0 → 8.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -27
- package/auto_setup.py +73 -8
- package/docs/CHANGELOG.md +37 -0
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/graph/_kg_common.py +12 -34
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -27
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +31 -14
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +69 -30
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +104 -62
- package/latticeai/app_factory.py +93 -10
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +185 -19
- 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 +10 -10
- package/static/app/assets/{Act-C7K9wsO9.js → Act-fZokUnC0.js} +1 -1
- package/static/app/assets/{Brain-I1OSzxJu.js → Brain-DtyuWubr.js} +1 -1
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-D5KV3Cu7.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-C9kyFkSt.js} +1 -1
- package/static/app/assets/{System-D1Lkei3I.js → System-VbChmX7r.js} +1 -1
- package/static/app/assets/{index--P0ksosz.js → index-DPdcPoF0.js} +5 -5
- package/static/app/assets/{primitives-BLqaKk5g.js → primitives-DFeanEV6.js} +1 -1
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-CD8UNKIy.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
package/latticeai/api/tools.py
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import inspect
|
|
6
7
|
import io
|
|
7
8
|
import logging
|
|
8
9
|
import shutil
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import Dict, List, Optional
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
11
12
|
|
|
12
13
|
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
|
13
14
|
from fastapi.responses import FileResponse
|
|
@@ -24,6 +25,7 @@ from latticeai.services.tool_dispatch import (
|
|
|
24
25
|
TOOL_GOVERNANCE_DEFAULT as _TOOL_GOVERNANCE_DEFAULT,
|
|
25
26
|
check_tool_role as _check_tool_role,
|
|
26
27
|
get_tool_permission,
|
|
28
|
+
enforce_tool_policy,
|
|
27
29
|
list_tool_permissions,
|
|
28
30
|
tool_registry_diagnostics,
|
|
29
31
|
tool_registry_manifest,
|
|
@@ -253,7 +255,21 @@ def create_tools_router(
|
|
|
253
255
|
|
|
254
256
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
255
257
|
|
|
256
|
-
def
|
|
258
|
+
def _policy_args(fn, *args, **kwargs) -> Dict:
|
|
259
|
+
try:
|
|
260
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
261
|
+
return dict(bound.arguments)
|
|
262
|
+
except Exception:
|
|
263
|
+
return dict(kwargs)
|
|
264
|
+
|
|
265
|
+
def _tool_response(
|
|
266
|
+
fn,
|
|
267
|
+
*args,
|
|
268
|
+
current_user: Optional[str] = None,
|
|
269
|
+
source: str = "http",
|
|
270
|
+
trusted_admin: bool = False,
|
|
271
|
+
**kwargs,
|
|
272
|
+
):
|
|
257
273
|
# Shared tool lifecycle (same path as the agent + workflow tool calls):
|
|
258
274
|
# pre_tool (may block) → execute → post_tool. Keyword args are forwarded
|
|
259
275
|
# to the tool and surfaced in the hook payload so read_file / edit_file /
|
|
@@ -261,7 +277,16 @@ def create_tools_router(
|
|
|
261
277
|
# tool instead of bypassing it.
|
|
262
278
|
tool_name = getattr(fn, "__name__", "tool")
|
|
263
279
|
try:
|
|
264
|
-
|
|
280
|
+
policy_args = _policy_args(fn, *args, **kwargs)
|
|
281
|
+
if current_user is not None:
|
|
282
|
+
enforce_tool_policy(
|
|
283
|
+
tool_name,
|
|
284
|
+
policy_args,
|
|
285
|
+
current_user=current_user,
|
|
286
|
+
source=source,
|
|
287
|
+
trusted_admin=trusted_admin,
|
|
288
|
+
)
|
|
289
|
+
result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source=source)
|
|
265
290
|
except PermissionError as exc:
|
|
266
291
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
267
292
|
except ToolError as exc:
|
|
@@ -277,47 +302,58 @@ def create_tools_router(
|
|
|
277
302
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
278
303
|
except ToolError as exc:
|
|
279
304
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
305
|
+
|
|
306
|
+
def _history_scope(user_email: str) -> Dict[str, Any]:
|
|
307
|
+
require_auth = bool(getattr(CONFIG, "require_auth", False))
|
|
308
|
+
scope: Dict[str, Any] = {
|
|
309
|
+
"user_email": user_email if require_auth else None,
|
|
310
|
+
"allowed_workspaces": None,
|
|
311
|
+
"include_legacy_global": not require_auth,
|
|
312
|
+
}
|
|
313
|
+
if require_auth and user_email and allowed_workspaces_for is not None:
|
|
314
|
+
scope["allowed_workspaces"] = allowed_workspaces_for(user_email)
|
|
315
|
+
return scope
|
|
280
316
|
|
|
281
317
|
|
|
282
318
|
@api_router.post("/tools/list_dir")
|
|
283
319
|
async def tools_list_dir(req: ToolPathRequest, request: Request):
|
|
284
|
-
require_user(request)
|
|
285
|
-
return _tool_response(list_dir, req.path)
|
|
320
|
+
current_user = require_user(request)
|
|
321
|
+
return _tool_response(list_dir, req.path, current_user=current_user)
|
|
286
322
|
|
|
287
323
|
|
|
288
324
|
@api_router.post("/tools/workspace_tree")
|
|
289
325
|
async def tools_workspace_tree(req: ToolWorkspaceTreeRequest, request: Request):
|
|
290
|
-
require_user(request)
|
|
291
|
-
return _tool_response(workspace_tree, req.path, req.max_depth)
|
|
326
|
+
current_user = require_user(request)
|
|
327
|
+
return _tool_response(workspace_tree, req.path, req.max_depth, current_user=current_user)
|
|
292
328
|
|
|
293
329
|
|
|
294
330
|
@api_router.post("/tools/read_file")
|
|
295
331
|
async def tools_read_file(req: ToolReadFileRequest, request: Request):
|
|
296
|
-
require_user(request)
|
|
297
|
-
return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)
|
|
332
|
+
current_user = require_user(request)
|
|
333
|
+
return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers, current_user=current_user)
|
|
298
334
|
|
|
299
335
|
|
|
300
336
|
@api_router.post("/tools/write_file")
|
|
301
337
|
async def tools_write_file(req: ToolWriteFileRequest, request: Request):
|
|
302
|
-
require_user(request)
|
|
303
|
-
return _tool_response(write_file, req.path, req.content)
|
|
338
|
+
current_user = require_user(request)
|
|
339
|
+
return _tool_response(write_file, req.path, req.content, current_user=current_user)
|
|
304
340
|
|
|
305
341
|
|
|
306
342
|
@api_router.post("/tools/edit_file")
|
|
307
343
|
async def tools_edit_file(req: ToolEditFileRequest, request: Request):
|
|
308
|
-
require_user(request)
|
|
309
|
-
return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all)
|
|
344
|
+
current_user = require_user(request)
|
|
345
|
+
return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all, current_user=current_user)
|
|
310
346
|
|
|
311
347
|
|
|
312
348
|
@api_router.post("/tools/search_files")
|
|
313
349
|
async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
|
|
314
|
-
require_user(request)
|
|
315
|
-
return _tool_response(search_files, req.query, req.path, req.max_results)
|
|
350
|
+
current_user = require_user(request)
|
|
351
|
+
return _tool_response(search_files, req.query, req.path, req.max_results, current_user=current_user)
|
|
316
352
|
|
|
317
353
|
|
|
318
354
|
@api_router.post("/tools/grep")
|
|
319
355
|
async def tools_grep(req: ToolGrepRequest, request: Request):
|
|
320
|
-
require_user(request)
|
|
356
|
+
current_user = require_user(request)
|
|
321
357
|
return _tool_response(
|
|
322
358
|
grep,
|
|
323
359
|
req.pattern,
|
|
@@ -326,25 +362,31 @@ def create_tools_router(
|
|
|
326
362
|
max_results=req.max_results,
|
|
327
363
|
case_insensitive=req.case_insensitive,
|
|
328
364
|
context_lines=req.context_lines,
|
|
365
|
+
current_user=current_user,
|
|
329
366
|
)
|
|
330
367
|
|
|
331
368
|
|
|
332
369
|
@api_router.post("/tools/todo_read")
|
|
333
370
|
async def tools_todo_read(request: Request):
|
|
334
|
-
require_user(request)
|
|
335
|
-
return _tool_response(todo_read)
|
|
371
|
+
current_user = require_user(request)
|
|
372
|
+
return _tool_response(todo_read, current_user=current_user)
|
|
336
373
|
|
|
337
374
|
|
|
338
375
|
@api_router.post("/tools/todo_write")
|
|
339
376
|
async def tools_todo_write(req: ToolTodoWriteRequest, request: Request):
|
|
340
|
-
require_user(request)
|
|
341
|
-
return _tool_response(todo_write, req.todos)
|
|
377
|
+
current_user = require_user(request)
|
|
378
|
+
return _tool_response(todo_write, req.todos, current_user=current_user)
|
|
342
379
|
|
|
343
380
|
|
|
344
381
|
@api_router.post("/tools/clear_history")
|
|
345
382
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
346
383
|
current_user = require_user(request)
|
|
347
|
-
|
|
384
|
+
scope = _history_scope(current_user)
|
|
385
|
+
result = _dispatch(
|
|
386
|
+
"clear_history",
|
|
387
|
+
{"keep_last": req.keep_last, **scope},
|
|
388
|
+
lambda: clear_history(req.keep_last, **scope),
|
|
389
|
+
)
|
|
348
390
|
append_audit_event(
|
|
349
391
|
"history_delete",
|
|
350
392
|
user_email=current_user,
|
|
@@ -358,38 +400,38 @@ def create_tools_router(
|
|
|
358
400
|
|
|
359
401
|
@api_router.post("/tools/inspect_html")
|
|
360
402
|
async def tools_inspect_html(req: ToolPathRequest, request: Request):
|
|
361
|
-
require_user(request)
|
|
362
|
-
return _tool_response(inspect_html, req.path)
|
|
403
|
+
current_user = require_user(request)
|
|
404
|
+
return _tool_response(inspect_html, req.path, current_user=current_user)
|
|
363
405
|
|
|
364
406
|
|
|
365
407
|
@api_router.post("/tools/preview_url")
|
|
366
408
|
async def tools_preview_url(req: ToolPathRequest, request: Request):
|
|
367
|
-
require_user(request)
|
|
368
|
-
return _tool_response(preview_url, req.path)
|
|
409
|
+
current_user = require_user(request)
|
|
410
|
+
return _tool_response(preview_url, req.path, current_user=current_user)
|
|
369
411
|
|
|
370
412
|
|
|
371
413
|
@api_router.post("/tools/create_docx")
|
|
372
414
|
async def tools_create_docx(req: ToolDocxRequest, request: Request):
|
|
373
|
-
require_user(request)
|
|
374
|
-
return _tool_response(create_docx, req.title, req.body, req.filename)
|
|
415
|
+
current_user = require_user(request)
|
|
416
|
+
return _tool_response(create_docx, req.title, req.body, req.filename, current_user=current_user)
|
|
375
417
|
|
|
376
418
|
|
|
377
419
|
@api_router.post("/tools/create_xlsx")
|
|
378
420
|
async def tools_create_xlsx(req: ToolXlsxRequest, request: Request):
|
|
379
|
-
require_user(request)
|
|
380
|
-
return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name)
|
|
421
|
+
current_user = require_user(request)
|
|
422
|
+
return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name, current_user=current_user)
|
|
381
423
|
|
|
382
424
|
|
|
383
425
|
@api_router.post("/tools/create_pptx")
|
|
384
426
|
async def tools_create_pptx(req: ToolPptxRequest, request: Request):
|
|
385
|
-
require_user(request)
|
|
386
|
-
return _tool_response(create_pptx, req.title, req.slides, req.filename)
|
|
427
|
+
current_user = require_user(request)
|
|
428
|
+
return _tool_response(create_pptx, req.title, req.slides, req.filename, current_user=current_user)
|
|
387
429
|
|
|
388
430
|
|
|
389
431
|
@api_router.post("/tools/create_pdf")
|
|
390
432
|
async def tools_create_pdf(req: ToolPdfRequest, request: Request):
|
|
391
|
-
require_user(request)
|
|
392
|
-
return _tool_response(create_pdf, req.title, req.body, req.filename)
|
|
433
|
+
current_user = require_user(request)
|
|
434
|
+
return _tool_response(create_pdf, req.title, req.body, req.filename, current_user=current_user)
|
|
393
435
|
|
|
394
436
|
|
|
395
437
|
@api_router.post("/tools/read_document")
|
|
@@ -402,7 +444,7 @@ def create_tools_router(
|
|
|
402
444
|
action="read",
|
|
403
445
|
user_email=current_user,
|
|
404
446
|
)
|
|
405
|
-
return _tool_response(read_document, req.path)
|
|
447
|
+
return _tool_response(read_document, req.path, current_user=current_user)
|
|
406
448
|
|
|
407
449
|
|
|
408
450
|
@api_router.get("/tools/pdf_pages")
|
|
@@ -503,38 +545,38 @@ def create_tools_router(
|
|
|
503
545
|
|
|
504
546
|
@api_router.post("/tools/knowledge_save")
|
|
505
547
|
async def tools_knowledge_save(req: ToolKnowledgeSaveRequest, request: Request):
|
|
506
|
-
require_user(request)
|
|
507
|
-
return _tool_response(knowledge_save, req.content, req.folder, req.title)
|
|
548
|
+
current_user = require_user(request)
|
|
549
|
+
return _tool_response(knowledge_save, req.content, req.folder, req.title, current_user=current_user)
|
|
508
550
|
|
|
509
551
|
|
|
510
552
|
@api_router.post("/tools/knowledge_search")
|
|
511
553
|
async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
|
|
512
|
-
require_user(request)
|
|
513
|
-
return _tool_response(knowledge_search, req.query, req.max_results)
|
|
554
|
+
current_user = require_user(request)
|
|
555
|
+
return _tool_response(knowledge_search, req.query, req.max_results, current_user=current_user)
|
|
514
556
|
|
|
515
557
|
|
|
516
558
|
@api_router.get("/tools/knowledge_tree")
|
|
517
559
|
async def tools_knowledge_tree(request: Request):
|
|
518
|
-
require_user(request)
|
|
519
|
-
return _tool_response(knowledge_tree)
|
|
560
|
+
current_user = require_user(request)
|
|
561
|
+
return _tool_response(knowledge_tree, current_user=current_user)
|
|
520
562
|
|
|
521
563
|
|
|
522
564
|
@api_router.post("/tools/obsidian_save")
|
|
523
565
|
async def tools_obsidian_save(req: ToolKnowledgeSaveRequest, request: Request):
|
|
524
|
-
require_user(request)
|
|
525
|
-
return _tool_response(obsidian_save, req.content, req.folder, req.title)
|
|
566
|
+
current_user = require_user(request)
|
|
567
|
+
return _tool_response(obsidian_save, req.content, req.folder, req.title, current_user=current_user)
|
|
526
568
|
|
|
527
569
|
|
|
528
570
|
@api_router.post("/tools/obsidian_search")
|
|
529
571
|
async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
|
|
530
|
-
require_user(request)
|
|
531
|
-
return _tool_response(obsidian_search, req.query, req.max_results)
|
|
572
|
+
current_user = require_user(request)
|
|
573
|
+
return _tool_response(obsidian_search, req.query, req.max_results, current_user=current_user)
|
|
532
574
|
|
|
533
575
|
|
|
534
576
|
@api_router.get("/tools/obsidian_tree")
|
|
535
577
|
async def tools_obsidian_tree(request: Request):
|
|
536
|
-
require_user(request)
|
|
537
|
-
return _tool_response(obsidian_tree)
|
|
578
|
+
current_user = require_user(request)
|
|
579
|
+
return _tool_response(obsidian_tree, current_user=current_user)
|
|
538
580
|
|
|
539
581
|
|
|
540
582
|
@api_router.get("/obsidian/status")
|
|
@@ -550,50 +592,50 @@ def create_tools_router(
|
|
|
550
592
|
|
|
551
593
|
@api_router.get("/tools/git_status")
|
|
552
594
|
async def tools_git_status(request: Request):
|
|
553
|
-
require_user(request)
|
|
554
|
-
return _tool_response(git_status)
|
|
595
|
+
current_user = require_user(request)
|
|
596
|
+
return _tool_response(git_status, current_user=current_user)
|
|
555
597
|
|
|
556
598
|
|
|
557
599
|
@api_router.post("/tools/git_diff")
|
|
558
600
|
async def tools_git_diff(req: ToolGitDiffRequest, request: Request):
|
|
559
|
-
require_user(request)
|
|
560
|
-
return _tool_response(git_diff, req.path, req.cwd)
|
|
601
|
+
current_user = require_user(request)
|
|
602
|
+
return _tool_response(git_diff, req.path, req.cwd, current_user=current_user)
|
|
561
603
|
|
|
562
604
|
|
|
563
605
|
@api_router.post("/tools/git_log")
|
|
564
606
|
async def tools_git_log(req: ToolGitLogRequest, request: Request):
|
|
565
|
-
require_user(request)
|
|
566
|
-
return _tool_response(git_log, req.max_count, req.cwd)
|
|
607
|
+
current_user = require_user(request)
|
|
608
|
+
return _tool_response(git_log, req.max_count, req.cwd, current_user=current_user)
|
|
567
609
|
|
|
568
610
|
|
|
569
611
|
@api_router.post("/tools/git_show")
|
|
570
612
|
async def tools_git_show(req: ToolGitShowRequest, request: Request):
|
|
571
|
-
require_user(request)
|
|
572
|
-
return _tool_response(git_show, req.revision, req.cwd)
|
|
613
|
+
current_user = require_user(request)
|
|
614
|
+
return _tool_response(git_show, req.revision, req.cwd, current_user=current_user)
|
|
573
615
|
|
|
574
616
|
|
|
575
617
|
@api_router.post("/tools/run_command")
|
|
576
618
|
async def tools_run_command(req: ToolRunCommandRequest, request: Request):
|
|
577
|
-
require_admin(request)
|
|
578
|
-
return _tool_response(run_command, req.command, req.cwd)
|
|
619
|
+
current_user, _users = require_admin(request)
|
|
620
|
+
return _tool_response(run_command, req.command, req.cwd, current_user=current_user, trusted_admin=True)
|
|
579
621
|
|
|
580
622
|
|
|
581
623
|
@api_router.get("/tools/network_status")
|
|
582
624
|
async def tools_network_status(request: Request):
|
|
583
|
-
require_user(request)
|
|
584
|
-
return _tool_response(network_status)
|
|
625
|
+
current_user = require_user(request)
|
|
626
|
+
return _tool_response(network_status, current_user=current_user)
|
|
585
627
|
|
|
586
628
|
|
|
587
629
|
@api_router.post("/tools/build_project")
|
|
588
630
|
async def tools_build_project(req: ToolScriptRequest, request: Request):
|
|
589
|
-
require_admin(request)
|
|
590
|
-
return _tool_response(build_project, req.cwd, req.script)
|
|
631
|
+
current_user, _users = require_admin(request)
|
|
632
|
+
return _tool_response(build_project, req.cwd, req.script, current_user=current_user, trusted_admin=True)
|
|
591
633
|
|
|
592
634
|
|
|
593
635
|
@api_router.post("/tools/deploy_project")
|
|
594
636
|
async def tools_deploy_project(req: ToolScriptRequest, request: Request):
|
|
595
|
-
require_admin(request)
|
|
596
|
-
return _tool_response(deploy_project, req.cwd, req.script)
|
|
637
|
+
current_user, _users = require_admin(request)
|
|
638
|
+
return _tool_response(deploy_project, req.cwd, req.script, current_user=current_user, trusted_admin=True)
|
|
597
639
|
|
|
598
640
|
|
|
599
641
|
@api_router.get("/tools/permissions")
|
package/latticeai/app_factory.py
CHANGED
|
@@ -15,6 +15,7 @@ lazily via module ``__getattr__`` for backwards compatibility.
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
18
19
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
19
20
|
|
|
20
21
|
from latticeai.runtime.app_context_runtime import build_app_context
|
|
@@ -94,7 +95,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
94
95
|
from pydantic import BaseModel
|
|
95
96
|
|
|
96
97
|
from latticeai.models.router import LLMRouter, normalize_branding
|
|
97
|
-
from lattice_brain.graph.
|
|
98
|
+
from lattice_brain.graph.runtime import set_llm_router
|
|
98
99
|
from lattice_brain.graph.schema import set_embed_dim
|
|
99
100
|
from latticeai.core.security import (
|
|
100
101
|
hash_password,
|
|
@@ -234,6 +235,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
234
235
|
ENABLE_GRAPH = _config_runtime["ENABLE_GRAPH"]
|
|
235
236
|
AUTOLOAD_MODELS = _config_runtime["AUTOLOAD_MODELS"]
|
|
236
237
|
MODEL_IDLE_UNLOAD_SECONDS = _config_runtime["MODEL_IDLE_UNLOAD_SECONDS"]
|
|
238
|
+
ALLOW_MODEL_DOWNLOADS = _config_runtime["ALLOW_MODEL_DOWNLOADS"]
|
|
239
|
+
MODEL_DOWNLOAD_TIMEOUT = _config_runtime["MODEL_DOWNLOAD_TIMEOUT"]
|
|
237
240
|
ALLOW_LOCAL_MODELS = _config_runtime["ALLOW_LOCAL_MODELS"]
|
|
238
241
|
REQUIRE_AUTH = _config_runtime["REQUIRE_AUTH"]
|
|
239
242
|
ALLOW_PLAINTEXT_API_KEYS = _config_runtime["ALLOW_PLAINTEXT_API_KEYS"]
|
|
@@ -636,9 +639,33 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
636
639
|
get_audit_log = _audit_rt["get_audit_log"]
|
|
637
640
|
append_audit_event = _audit_rt["append_audit_event"]
|
|
638
641
|
|
|
639
|
-
def
|
|
642
|
+
def _history_allowed_workspaces_for(user_email: Optional[str]):
|
|
643
|
+
if not REQUIRE_AUTH or not user_email:
|
|
644
|
+
return None
|
|
645
|
+
try:
|
|
646
|
+
return set(WORKSPACE_SERVICE.readable_workspaces(user_email))
|
|
647
|
+
except Exception as exc:
|
|
648
|
+
logging.warning("history workspace scope resolution failed for %s: %s", user_email, exc)
|
|
649
|
+
return set()
|
|
650
|
+
|
|
651
|
+
def _history_include_legacy_global(user_email: Optional[str]) -> bool:
|
|
652
|
+
return not REQUIRE_AUTH or not user_email
|
|
653
|
+
|
|
654
|
+
def get_history(
|
|
655
|
+
user_email: Optional[str] = None,
|
|
656
|
+
allowed_workspaces=None,
|
|
657
|
+
include_legacy_global: Optional[bool] = None,
|
|
658
|
+
):
|
|
640
659
|
try:
|
|
641
|
-
|
|
660
|
+
if allowed_workspaces is None and user_email:
|
|
661
|
+
allowed_workspaces = _history_allowed_workspaces_for(user_email)
|
|
662
|
+
if include_legacy_global is None:
|
|
663
|
+
include_legacy_global = _history_include_legacy_global(user_email)
|
|
664
|
+
return CONVERSATIONS.history(
|
|
665
|
+
user_email=user_email,
|
|
666
|
+
allowed_workspaces=allowed_workspaces,
|
|
667
|
+
include_legacy_global=include_legacy_global,
|
|
668
|
+
)
|
|
642
669
|
except Exception as e:
|
|
643
670
|
logging.warning("get_history failed: %s", e)
|
|
644
671
|
return []
|
|
@@ -683,17 +710,59 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
683
710
|
|
|
684
711
|
return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
|
|
685
712
|
|
|
686
|
-
def get_conversation_messages(
|
|
687
|
-
|
|
713
|
+
def get_conversation_messages(
|
|
714
|
+
conversation_id: str,
|
|
715
|
+
*,
|
|
716
|
+
user_email: Optional[str] = None,
|
|
717
|
+
allowed_workspaces=None,
|
|
718
|
+
include_legacy_global: Optional[bool] = None,
|
|
719
|
+
) -> List[Dict]:
|
|
720
|
+
history = get_history(
|
|
721
|
+
user_email=user_email,
|
|
722
|
+
allowed_workspaces=allowed_workspaces,
|
|
723
|
+
include_legacy_global=include_legacy_global,
|
|
724
|
+
)
|
|
688
725
|
if conversation_id == "legacy-previous-history":
|
|
689
726
|
return [item for item in history if not item.get("conversation_id")]
|
|
690
727
|
return [item for item in history if item.get("conversation_id") == conversation_id]
|
|
691
728
|
|
|
692
|
-
def clear_history(
|
|
693
|
-
|
|
729
|
+
def clear_history(
|
|
730
|
+
keep_last: int = 0,
|
|
731
|
+
*,
|
|
732
|
+
user_email: Optional[str] = None,
|
|
733
|
+
allowed_workspaces=None,
|
|
734
|
+
include_legacy_global: Optional[bool] = None,
|
|
735
|
+
) -> Dict:
|
|
736
|
+
if allowed_workspaces is None and user_email:
|
|
737
|
+
allowed_workspaces = _history_allowed_workspaces_for(user_email)
|
|
738
|
+
if include_legacy_global is None:
|
|
739
|
+
include_legacy_global = _history_include_legacy_global(user_email)
|
|
740
|
+
return CONVERSATIONS.clear_all(
|
|
741
|
+
keep_last=keep_last,
|
|
742
|
+
user_email=user_email,
|
|
743
|
+
allowed_workspaces=allowed_workspaces,
|
|
744
|
+
include_legacy_global=include_legacy_global,
|
|
745
|
+
)
|
|
694
746
|
|
|
695
|
-
def clear_conversation(
|
|
696
|
-
|
|
747
|
+
def clear_conversation(
|
|
748
|
+
conversation_id: str,
|
|
749
|
+
started_at: Optional[str] = None,
|
|
750
|
+
*,
|
|
751
|
+
user_email: Optional[str] = None,
|
|
752
|
+
allowed_workspaces=None,
|
|
753
|
+
include_legacy_global: Optional[bool] = None,
|
|
754
|
+
) -> Dict:
|
|
755
|
+
if allowed_workspaces is None and user_email:
|
|
756
|
+
allowed_workspaces = _history_allowed_workspaces_for(user_email)
|
|
757
|
+
if include_legacy_global is None:
|
|
758
|
+
include_legacy_global = _history_include_legacy_global(user_email)
|
|
759
|
+
return CONVERSATIONS.clear_conversation(
|
|
760
|
+
conversation_id,
|
|
761
|
+
started_at=started_at,
|
|
762
|
+
user_email=user_email,
|
|
763
|
+
allowed_workspaces=allowed_workspaces,
|
|
764
|
+
include_legacy_global=include_legacy_global,
|
|
765
|
+
)
|
|
697
766
|
|
|
698
767
|
_access_runtime = build_access_runtime(
|
|
699
768
|
config=CONFIG,
|
|
@@ -942,6 +1011,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
942
1011
|
ENABLE_GRAPH=ENABLE_GRAPH,
|
|
943
1012
|
AUTOLOAD_MODELS=AUTOLOAD_MODELS,
|
|
944
1013
|
MODEL_IDLE_UNLOAD_SECONDS=MODEL_IDLE_UNLOAD_SECONDS,
|
|
1014
|
+
ALLOW_MODEL_DOWNLOADS=ALLOW_MODEL_DOWNLOADS,
|
|
1015
|
+
MODEL_DOWNLOAD_TIMEOUT=MODEL_DOWNLOAD_TIMEOUT,
|
|
945
1016
|
ALLOW_LOCAL_MODELS=ALLOW_LOCAL_MODELS,
|
|
946
1017
|
REQUIRE_AUTH=REQUIRE_AUTH,
|
|
947
1018
|
INVITE_GATE_ENABLED=INVITE_GATE_ENABLED,
|
|
@@ -1136,6 +1207,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1136
1207
|
load_users=load_users,
|
|
1137
1208
|
get_user_role=get_user_role,
|
|
1138
1209
|
enforce_rate_limit=enforce_rate_limit,
|
|
1210
|
+
allowed_workspaces_for=_history_allowed_workspaces_for,
|
|
1139
1211
|
append_audit_event=append_audit_event,
|
|
1140
1212
|
get_audit_log=get_audit_log,
|
|
1141
1213
|
get_history=get_history,
|
|
@@ -1383,6 +1455,16 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1383
1455
|
return dict(locals())
|
|
1384
1456
|
|
|
1385
1457
|
|
|
1458
|
+
@dataclass(frozen=True)
|
|
1459
|
+
class LegacyRuntimeNamespace:
|
|
1460
|
+
"""Compatibility adapter for the historical module-level runtime surface."""
|
|
1461
|
+
|
|
1462
|
+
namespace: Dict[str, Any]
|
|
1463
|
+
|
|
1464
|
+
def bind(self, runtime: "AppRuntime") -> None:
|
|
1465
|
+
runtime.__dict__.update(self.namespace)
|
|
1466
|
+
|
|
1467
|
+
|
|
1386
1468
|
class AppRuntime:
|
|
1387
1469
|
"""The constructed application namespace.
|
|
1388
1470
|
|
|
@@ -1391,7 +1473,8 @@ class AppRuntime:
|
|
|
1391
1473
|
"""
|
|
1392
1474
|
|
|
1393
1475
|
def __init__(self, namespace: Dict[str, Any]) -> None:
|
|
1394
|
-
self.
|
|
1476
|
+
self._legacy_namespace = LegacyRuntimeNamespace(namespace)
|
|
1477
|
+
self._legacy_namespace.bind(self)
|
|
1395
1478
|
|
|
1396
1479
|
|
|
1397
1480
|
_runtime_lock = threading.RLock()
|
package/latticeai/core/agent.py
CHANGED
|
@@ -52,7 +52,7 @@ class AgentRunContext:
|
|
|
52
52
|
"""Mutable state carrier passed through all agent phases."""
|
|
53
53
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
54
54
|
"state_history", "corrections", "final_message", "rollback_log",
|
|
55
|
-
"executing_model", "reviewing_model")
|
|
55
|
+
"executing_model", "reviewing_model", "approved_by_human")
|
|
56
56
|
|
|
57
57
|
def __init__(self) -> None:
|
|
58
58
|
self.state: AgentState = AgentState.IDLE
|
|
@@ -65,6 +65,7 @@ class AgentRunContext:
|
|
|
65
65
|
self.rollback_log: list = []
|
|
66
66
|
self.executing_model: Optional[str] = None
|
|
67
67
|
self.reviewing_model: Optional[str] = None
|
|
68
|
+
self.approved_by_human: bool = False
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
def extract_action(raw: str) -> Dict:
|
|
@@ -207,7 +208,7 @@ class SingleAgentRuntime:
|
|
|
207
208
|
ctx.state = AgentState.WAITING_APPROVAL
|
|
208
209
|
|
|
209
210
|
# ── APPROVAL ─────────────────────────────────────────────────────
|
|
210
|
-
def approve(self, ctx: AgentRunContext, current_user: str) -> None:
|
|
211
|
+
def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
|
|
211
212
|
"""APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
|
|
212
213
|
d = self.deps
|
|
213
214
|
auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
|
|
@@ -219,12 +220,22 @@ class SingleAgentRuntime:
|
|
|
219
220
|
"state": AgentState.WAITING_APPROVAL.value,
|
|
220
221
|
"requires_approval": requires,
|
|
221
222
|
"non_auto_approve_steps": non_auto,
|
|
222
|
-
"decision": "auto_approved",
|
|
223
|
+
"decision": "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
|
|
223
224
|
})
|
|
224
225
|
d.audit(
|
|
225
226
|
"agent_approval", user_email=current_user,
|
|
226
|
-
requires_approval=requires,
|
|
227
|
+
requires_approval=requires,
|
|
228
|
+
non_auto_steps=non_auto,
|
|
229
|
+
decision="human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
|
|
227
230
|
)
|
|
231
|
+
if requires and not approved_by_human:
|
|
232
|
+
ctx.final_message = (
|
|
233
|
+
"이 작업에는 명시 승인이 필요한 도구가 포함되어 있어 자동 실행을 중단했습니다. "
|
|
234
|
+
"human_in_loop 승인 흐름으로 다시 실행해 주세요."
|
|
235
|
+
)
|
|
236
|
+
ctx.state = AgentState.FAILED
|
|
237
|
+
return
|
|
238
|
+
ctx.approved_by_human = bool(approved_by_human)
|
|
228
239
|
ctx.state = AgentState.EXECUTING
|
|
229
240
|
|
|
230
241
|
# ── EXECUTE ──────────────────────────────────────────────────────
|
|
@@ -317,7 +328,7 @@ class SingleAgentRuntime:
|
|
|
317
328
|
)
|
|
318
329
|
continue
|
|
319
330
|
|
|
320
|
-
if not policy["auto_approve"]:
|
|
331
|
+
if not policy["auto_approve"] and not ctx.approved_by_human:
|
|
321
332
|
d.audit(
|
|
322
333
|
"agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
323
334
|
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
@@ -326,6 +337,13 @@ class SingleAgentRuntime:
|
|
|
326
337
|
rollback=policy["rollback"],
|
|
327
338
|
args={k: v for k, v in args.items() if k != "content"},
|
|
328
339
|
)
|
|
340
|
+
ctx.transcript.append({
|
|
341
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
342
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
343
|
+
"governance": dict(policy),
|
|
344
|
+
"error": f"BLOCKED: action '{name}' requires explicit approval.",
|
|
345
|
+
})
|
|
346
|
+
continue
|
|
329
347
|
|
|
330
348
|
try:
|
|
331
349
|
d.check_role(name, current_user)
|
|
@@ -422,8 +440,8 @@ class SingleAgentRuntime:
|
|
|
422
440
|
if gov.get("rollback") != "git":
|
|
423
441
|
continue
|
|
424
442
|
result = step.get("result", {})
|
|
425
|
-
if not
|
|
426
|
-
|
|
443
|
+
if not isinstance(result, dict):
|
|
444
|
+
result = {}
|
|
427
445
|
path = result.get("path") or (step.get("args") or {}).get("path", "")
|
|
428
446
|
if not path:
|
|
429
447
|
continue
|
|
@@ -90,8 +90,16 @@ def _entry_created_at(entry: tuple) -> float:
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
class SessionStore:
|
|
93
|
-
def __init__(
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
data_dir: Optional[Path] = None,
|
|
96
|
+
*,
|
|
97
|
+
ttl_seconds: int = SESSION_TTL,
|
|
98
|
+
refresh_threshold_seconds: int = SESSION_REFRESH_THRESHOLD,
|
|
99
|
+
):
|
|
94
100
|
self._data_dir = data_dir
|
|
101
|
+
self._ttl_seconds = int(ttl_seconds or SESSION_TTL)
|
|
102
|
+
self._refresh_threshold_seconds = int(refresh_threshold_seconds or SESSION_REFRESH_THRESHOLD)
|
|
95
103
|
self._sessions: Dict[str, tuple] = load_sessions(data_dir)
|
|
96
104
|
|
|
97
105
|
def create(self, subject: str, *, email: Optional[str] = None) -> str:
|
|
@@ -117,11 +125,11 @@ class SessionStore:
|
|
|
117
125
|
if entry is None:
|
|
118
126
|
return None
|
|
119
127
|
created_at = _entry_created_at(entry)
|
|
120
|
-
if now - created_at >
|
|
128
|
+
if now - created_at > self._ttl_seconds:
|
|
121
129
|
self._sessions.pop(key, None)
|
|
122
130
|
persist_sessions(self._sessions, self._data_dir)
|
|
123
131
|
return None
|
|
124
|
-
if now - created_at >
|
|
132
|
+
if now - created_at > self._refresh_threshold_seconds:
|
|
125
133
|
refreshed = (_entry_subject(entry), now, _entry_email(entry))
|
|
126
134
|
self._sessions[key] = refreshed
|
|
127
135
|
persist_sessions(self._sessions, self._data_dir)
|