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.
Files changed (63) hide show
  1. package/README.md +34 -27
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +37 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/conversations.py +156 -21
  16. package/lattice_brain/graph/_kg_common.py +12 -34
  17. package/lattice_brain/graph/json_utils.py +25 -0
  18. package/lattice_brain/graph/retrieval.py +66 -27
  19. package/lattice_brain/graph/runtime.py +16 -0
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +31 -14
  23. package/latticeai/api/mcp.py +3 -2
  24. package/latticeai/api/models.py +4 -1
  25. package/latticeai/api/permissions.py +69 -30
  26. package/latticeai/api/setup.py +17 -2
  27. package/latticeai/api/tools.py +104 -62
  28. package/latticeai/app_factory.py +93 -10
  29. package/latticeai/core/agent.py +25 -7
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/sessions.py +11 -3
  33. package/latticeai/core/tool_registry.py +15 -4
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/runtime/bootstrap.py +1 -1
  36. package/latticeai/services/app_context.py +1 -0
  37. package/latticeai/services/architecture_readiness.py +2 -2
  38. package/latticeai/services/model_engines.py +79 -12
  39. package/latticeai/services/model_runtime.py +24 -4
  40. package/latticeai/services/process_audit.py +208 -0
  41. package/latticeai/services/product_readiness.py +11 -11
  42. package/latticeai/services/search_service.py +106 -30
  43. package/latticeai/services/tool_dispatch.py +66 -0
  44. package/latticeai/services/workspace_service.py +15 -0
  45. package/package.json +1 -1
  46. package/scripts/check_i18n_literals.mjs +20 -8
  47. package/scripts/i18n_literal_allowlist.json +34 -0
  48. package/scripts/lint_frontend.mjs +6 -2
  49. package/setup_wizard.py +185 -19
  50. package/src-tauri/Cargo.lock +1 -1
  51. package/src-tauri/Cargo.toml +1 -1
  52. package/src-tauri/tauri.conf.json +1 -1
  53. package/static/app/asset-manifest.json +10 -10
  54. package/static/app/assets/{Act-C7K9wsO9.js → Act-fZokUnC0.js} +1 -1
  55. package/static/app/assets/{Brain-I1OSzxJu.js → Brain-DtyuWubr.js} +1 -1
  56. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-D5KV3Cu7.js} +1 -1
  57. package/static/app/assets/{Library-Cgj-EF50.js → Library-C9kyFkSt.js} +1 -1
  58. package/static/app/assets/{System-D1Lkei3I.js → System-VbChmX7r.js} +1 -1
  59. package/static/app/assets/{index--P0ksosz.js → index-DPdcPoF0.js} +5 -5
  60. package/static/app/assets/{primitives-BLqaKk5g.js → primitives-DFeanEV6.js} +1 -1
  61. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-CD8UNKIy.js} +1 -1
  62. package/static/app/index.html +1 -1
  63. package/static/sw.js +1 -1
@@ -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 _tool_response(fn, *args, **kwargs):
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
- result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source="http")
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
- result = _dispatch("clear_history", {"keep_last": req.keep_last}, lambda: clear_history(req.keep_last))
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")
@@ -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._kg_common import set_llm_router
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 get_history():
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
- return CONVERSATIONS.history()
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(conversation_id: str) -> List[Dict]:
687
- history = get_history()
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(keep_last: int = 0) -> Dict:
693
- return CONVERSATIONS.clear_all(keep_last=keep_last)
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(conversation_id: str, started_at: Optional[str] = None) -> Dict:
696
- return CONVERSATIONS.clear_conversation(conversation_id, started_at=started_at)
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.__dict__.update(namespace)
1476
+ self._legacy_namespace = LegacyRuntimeNamespace(namespace)
1477
+ self._legacy_namespace.bind(self)
1395
1478
 
1396
1479
 
1397
1480
  _runtime_lock = threading.RLock()
@@ -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, non_auto_steps=non_auto, decision="auto_approved",
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 (isinstance(result, dict) and result.get("success")):
426
- continue
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
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "8.8.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "8.9.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "8.8.0"
14
+ MARKETPLACE_VERSION = "8.9.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -90,8 +90,16 @@ def _entry_created_at(entry: tuple) -> float:
90
90
 
91
91
 
92
92
  class SessionStore:
93
- def __init__(self, data_dir: Optional[Path] = None):
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 > SESSION_TTL:
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 > SESSION_REFRESH_THRESHOLD:
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)