ltcai 10.3.0 → 10.4.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 (143) hide show
  1. package/README.md +37 -36
  2. package/docs/CHANGELOG.md +77 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MYPY_BACKLOG.md +59 -92
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +1 -1
  8. package/docs/SURFACE_PARITY.md +33 -6
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +23 -3
  14. package/lattice_brain/graph/_kg_common.py +164 -9
  15. package/lattice_brain/graph/_kg_contract.py +223 -0
  16. package/lattice_brain/graph/_kg_fsutil.py +1 -1
  17. package/lattice_brain/graph/curator.py +4 -4
  18. package/lattice_brain/graph/discovery.py +12 -1
  19. package/lattice_brain/graph/discovery_index.py +12 -1
  20. package/lattice_brain/graph/documents.py +17 -2
  21. package/lattice_brain/graph/ingest.py +12 -1
  22. package/lattice_brain/graph/proactive.py +9 -5
  23. package/lattice_brain/graph/projection.py +12 -1
  24. package/lattice_brain/graph/provenance.py +12 -1
  25. package/lattice_brain/graph/retrieval.py +12 -1
  26. package/lattice_brain/graph/retrieval_docgen.py +12 -1
  27. package/lattice_brain/graph/retrieval_reads.py +12 -1
  28. package/lattice_brain/graph/retrieval_vector.py +12 -1
  29. package/lattice_brain/graph/schema.py +6 -2
  30. package/lattice_brain/graph/write_master.py +12 -1
  31. package/lattice_brain/ingestion.py +1 -1
  32. package/lattice_brain/quality.py +5 -5
  33. package/lattice_brain/runtime/contracts.py +2 -1
  34. package/lattice_brain/runtime/multi_agent.py +1 -1
  35. package/lattice_brain/workflow.py +10 -7
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/admin.py +2 -2
  38. package/latticeai/api/chat.py +19 -15
  39. package/latticeai/api/chat_agent_http.py +19 -7
  40. package/latticeai/api/chat_helpers.py +9 -8
  41. package/latticeai/api/chat_stream.py +2 -2
  42. package/latticeai/api/computer_use.py +2 -2
  43. package/latticeai/api/hooks.py +4 -2
  44. package/latticeai/api/local_files.py +8 -4
  45. package/latticeai/api/mcp.py +2 -2
  46. package/latticeai/api/models.py +5 -5
  47. package/latticeai/api/permissions.py +2 -2
  48. package/latticeai/api/search.py +5 -3
  49. package/latticeai/api/security_dashboard.py +3 -5
  50. package/latticeai/api/setup.py +3 -3
  51. package/latticeai/api/static_routes.py +7 -2
  52. package/latticeai/api/tools.py +9 -4
  53. package/latticeai/api/workspace.py +25 -22
  54. package/latticeai/app_factory.py +132 -1322
  55. package/latticeai/cli/entrypoint.py +1 -1
  56. package/latticeai/core/agent.py +28 -14
  57. package/latticeai/core/audit.py +5 -3
  58. package/latticeai/core/embedding_providers.py +19 -6
  59. package/latticeai/core/legacy_compatibility.py +1 -1
  60. package/latticeai/core/marketplace.py +2 -2
  61. package/latticeai/core/mcp_catalog.py +3 -1
  62. package/latticeai/core/model_compat.py +1 -1
  63. package/latticeai/core/plugins.py +1 -1
  64. package/latticeai/core/project_sessions.py +1 -1
  65. package/latticeai/core/run_explain.py +6 -4
  66. package/latticeai/core/workspace_os.py +0 -4
  67. package/latticeai/core/workspace_os_constants.py +1 -1
  68. package/latticeai/core/workspace_os_state.py +1 -1
  69. package/latticeai/core/workspace_snapshots.py +3 -3
  70. package/latticeai/integrations/telegram_bot.py +52 -3
  71. package/latticeai/models/router.py +61 -34
  72. package/latticeai/runtime/access_runtime.py +3 -1
  73. package/latticeai/runtime/build_phases.py +1417 -0
  74. package/latticeai/runtime/review_wiring.py +6 -1
  75. package/latticeai/runtime/runtime_context.py +276 -0
  76. package/latticeai/runtime/sso_config_runtime.py +1 -1
  77. package/latticeai/runtime/stages.py +8 -2
  78. package/latticeai/services/app_context.py +23 -0
  79. package/latticeai/services/architecture_readiness.py +1 -1
  80. package/latticeai/services/brain_intelligence.py +4 -2
  81. package/latticeai/services/cloud_streaming.py +5 -3
  82. package/latticeai/services/command_center.py +1 -1
  83. package/latticeai/services/evidence_actions.py +2 -1
  84. package/latticeai/services/hybrid_chat.py +1 -1
  85. package/latticeai/services/local_knowledge.py +25 -9
  86. package/latticeai/services/memory_service.py +8 -4
  87. package/latticeai/services/model_catalog.py +12 -12
  88. package/latticeai/services/model_engines.py +15 -17
  89. package/latticeai/services/model_loading.py +1 -1
  90. package/latticeai/services/model_runtime.py +31 -33
  91. package/latticeai/services/multimodal_streaming.py +10 -4
  92. package/latticeai/services/openai_compatible_adapter.py +4 -3
  93. package/latticeai/services/product_readiness.py +1 -1
  94. package/latticeai/services/run_executor.py +12 -3
  95. package/latticeai/services/search_service.py +1 -1
  96. package/latticeai/services/triggers.py +1 -1
  97. package/latticeai/setup/auto_setup.py +1 -1
  98. package/latticeai/setup/wizard.py +12 -3
  99. package/latticeai/tools/computer.py +3 -1
  100. package/latticeai/tools/documents.py +2 -2
  101. package/package.json +1 -1
  102. package/scripts/check_current_release_docs.mjs +1 -1
  103. package/src-tauri/Cargo.lock +1 -1
  104. package/src-tauri/Cargo.toml +1 -1
  105. package/src-tauri/tauri.conf.json +1 -1
  106. package/static/app/asset-manifest.json +36 -36
  107. package/static/app/assets/{Act-DrKIGZeJ.js → Act-BK__Y5VE.js} +1 -1
  108. package/static/app/assets/{AdminConsole-CEV1VefA.js → AdminConsole-ZrTyN8_F.js} +1 -1
  109. package/static/app/assets/{Brain-BZSXnwWU.js → Brain-CYHDXpPB.js} +1 -1
  110. package/static/app/assets/{BrainHome-CEBd86k6.js → BrainHome-bbJulb5g.js} +1 -1
  111. package/static/app/assets/{BrainSignals-O4s6R7av.js → BrainSignals-CesSPtUP.js} +1 -1
  112. package/static/app/assets/{Capture-AGbOxsg-.js → Capture-p5upXmz2.js} +1 -1
  113. package/static/app/assets/{CommandPalette-CeqBjCn2.js → CommandPalette-0zkyC0RY.js} +1 -1
  114. package/static/app/assets/{Library-CXnbNiQW.js → Library-Dt-vABI2.js} +1 -1
  115. package/static/app/assets/{LivingBrain-D9Iei2ZJ.js → LivingBrain-Bz-h6LBH.js} +1 -1
  116. package/static/app/assets/{ProductFlow-JlXiDfMM.js → ProductFlow-DAZ7DJCE.js} +1 -1
  117. package/static/app/assets/{ReviewCard-QQly2bTV.js → ReviewCard-Bv0WGbY6.js} +1 -1
  118. package/static/app/assets/{System-Bp9ZY4ER.js → System-BhrJQSD_.js} +1 -1
  119. package/static/app/assets/{activity-CxRPOQ2F.js → activity-AolpZJCH.js} +1 -1
  120. package/static/app/assets/{bot-1bcsWB2s.js → bot-CsVsT2nd.js} +1 -1
  121. package/static/app/assets/{brain-CxaeKCRM.js → brain-vuppco5y.js} +1 -1
  122. package/static/app/assets/{button-ZYFqGan7.js → button-1BP1PJZ3.js} +1 -1
  123. package/static/app/assets/{circle-pause-B5NOIqvk.js → circle-pause-5paeCOtq.js} +1 -1
  124. package/static/app/assets/{circle-play-BiJVFXf7.js → circle-play-By8mFYRV.js} +1 -1
  125. package/static/app/assets/{cpu-Vvq0OKTr.js → cpu-DvUQxdKe.js} +1 -1
  126. package/static/app/assets/{download-SEcXzkx1.js → download-CFqYG90a.js} +1 -1
  127. package/static/app/assets/{folder-open-Dw1ff_Qa.js → folder-open-CVSnp-Dp.js} +1 -1
  128. package/static/app/assets/{hard-drive-B00fR7_r.js → hard-drive-Ckr4Vd6-.js} +1 -1
  129. package/static/app/assets/{index-Dm9VXl1o.js → index--0zKF5Ow.js} +3 -3
  130. package/static/app/assets/{input-DtdWYQEa.js → input-D-JKquXU.js} +1 -1
  131. package/static/app/assets/{network-CpjBu0kI.js → network-DB88FZ2T.js} +1 -1
  132. package/static/app/assets/{primitives-CwsudBwc.js → primitives-D8U9OaIo.js} +1 -1
  133. package/static/app/assets/search-B8jsVTw_.js +1 -0
  134. package/static/app/assets/{shield-alert-CpjBc-gQ.js → shield-alert-DnsvO79P.js} +1 -1
  135. package/static/app/assets/{textarea-DUPyjHQs.js → textarea-4RchwbTa.js} +1 -1
  136. package/static/app/assets/{useFocusTrap-BWcMtCWH.js → useFocusTrap-CFJjy7cS.js} +1 -1
  137. package/static/app/assets/{useQuery-Dg5fPqTS.js → useQuery-3Reu-A4a.js} +1 -1
  138. package/static/app/assets/{users-BgcdTQGM.js → users-BTxYksxa.js} +1 -1
  139. package/static/app/assets/{utils-a3YXFtEO.js → utils-D02Vf-fw.js} +1 -1
  140. package/static/app/assets/{workspace-BMUEiXUm.js → workspace-CSJNgX-C.js} +1 -1
  141. package/static/app/index.html +3 -3
  142. package/static/sw.js +1 -1
  143. package/static/app/assets/search-CwEJWBEA.js +0 -1
@@ -1,8 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  # ruff: noqa: F403,F405
4
6
  from ._kg_common import * # noqa: F403,F401
5
7
 
8
+ # The cross-mixin surface (`_connect`, `_upsert_node`, …) is declared in
9
+ # `_kg_contract.KnowledgeGraphCore`. It is a typing-only base: at runtime this
10
+ # is `object`, so the MRO of `KnowledgeGraphStore` is unchanged.
11
+ if TYPE_CHECKING:
12
+ from ._kg_contract import KnowledgeGraphCore as _Core
13
+ else:
14
+ _Core = object
15
+
16
+
6
17
  # --- Compat seam (v9.9.5 decomposition) -------------------------------------
7
18
  # The non-search read surface (list_documents / workspaces_of /
8
19
  # filter_scoped_nodes / neighbors / get_node / relationship_search /
@@ -46,7 +57,7 @@ def context_quality_signal(
46
57
  return {"mode": mode, "nodes": nodes, "limited": limited, "reason": reason}
47
58
 
48
59
 
49
- class KnowledgeGraphRetrievalMixin:
60
+ class KnowledgeGraphRetrievalMixin(_Core):
50
61
  _GRAPH_VISIBLE_TYPES = (
51
62
  "Computer", # 내 컴퓨터
52
63
  "Drive", # 드라이브 / 볼륨
@@ -1,10 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  # ruff: noqa: F403,F405
4
6
  from ._kg_common import * # noqa: F403,F401
5
7
 
8
+ # The cross-mixin surface (`_connect`, `_upsert_node`, …) is declared in
9
+ # `_kg_contract.KnowledgeGraphCore`. It is a typing-only base: at runtime this
10
+ # is `object`, so the MRO of `KnowledgeGraphStore` is unchanged.
11
+ if TYPE_CHECKING:
12
+ from ._kg_contract import KnowledgeGraphCore as _Core
13
+ else:
14
+ _Core = object
15
+
16
+
6
17
 
7
- class KnowledgeGraphDocGenMixin:
18
+ class KnowledgeGraphDocGenMixin(_Core):
8
19
  """Multi-hop retrieval specialised for document generation, split out
9
20
  of retrieval. Composed into KnowledgeGraphStore alongside the other
10
21
  retrieval mixins; shared instance means sibling helpers resolve via MRO.
@@ -11,11 +11,22 @@ calls (e.g. ``search`` → ``filter_scoped_nodes``) behave exactly as before.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ from typing import TYPE_CHECKING
15
+
14
16
  # ruff: noqa: F403,F405
15
17
  from ._kg_common import * # noqa: F403,F401
16
18
 
19
+ # The cross-mixin surface (`_connect`, `_upsert_node`, …) is declared in
20
+ # `_kg_contract.KnowledgeGraphCore`. It is a typing-only base: at runtime this
21
+ # is `object`, so the MRO of `KnowledgeGraphStore` is unchanged.
22
+ if TYPE_CHECKING:
23
+ from ._kg_contract import KnowledgeGraphCore as _Core
24
+ else:
25
+ _Core = object
26
+
27
+
17
28
 
18
- class KnowledgeGraphReadsMixin:
29
+ class KnowledgeGraphReadsMixin(_Core):
19
30
  def list_documents(self, limit: int = 200) -> Dict[str, Any]:
20
31
  """List ingested ``Document`` nodes with their ingest + index state.
21
32
 
@@ -1,10 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  # ruff: noqa: F403,F405
4
6
  from ._kg_common import * # noqa: F403,F401
5
7
 
8
+ # The cross-mixin surface (`_connect`, `_upsert_node`, …) is declared in
9
+ # `_kg_contract.KnowledgeGraphCore`. It is a typing-only base: at runtime this
10
+ # is `object`, so the MRO of `KnowledgeGraphStore` is unchanged.
11
+ if TYPE_CHECKING:
12
+ from ._kg_contract import KnowledgeGraphCore as _Core
13
+ else:
14
+ _Core = object
15
+
16
+
6
17
 
7
- class KnowledgeGraphVectorMixin:
18
+ class KnowledgeGraphVectorMixin(_Core):
8
19
  """Vector-embedding index build/status/search, split out of retrieval.
9
20
 
10
21
  Composed into KnowledgeGraphStore alongside KnowledgeGraphRetrievalMixin;
@@ -48,6 +48,7 @@ import os
48
48
  import sqlite3
49
49
  from contextlib import contextmanager
50
50
  from enum import Enum
51
+ from pathlib import Path
51
52
  from typing import Any, Dict, Optional
52
53
 
53
54
  from ..quiet import quiet
@@ -423,8 +424,11 @@ class KGStoreV2:
423
424
  프로젝션이 담당하므로 native upsert/get/search API 는 두지 않는다.
424
425
  """
425
426
 
426
- def __init__(self, db_path: str):
427
- self.db_path = db_path
427
+ def __init__(self, db_path: "str | Path"):
428
+ # Callers pass a Path (the store keeps db_path as a Path); sqlite3
429
+ # accepts either, so the annotation now matches what is actually
430
+ # handed in instead of forcing every call site to str() it.
431
+ self.db_path = str(db_path)
428
432
 
429
433
  @contextmanager
430
434
  def _conn(self):
@@ -1,10 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  # ruff: noqa: F403,F405
4
6
  from ._kg_common import * # noqa: F403,F401
5
7
 
8
+ # The cross-mixin surface (`_connect`, `_upsert_node`, …) is declared in
9
+ # `_kg_contract.KnowledgeGraphCore`. It is a typing-only base: at runtime this
10
+ # is `object`, so the MRO of `KnowledgeGraphStore` is unchanged.
11
+ if TYPE_CHECKING:
12
+ from ._kg_contract import KnowledgeGraphCore as _Core
13
+ else:
14
+ _Core = object
15
+
16
+
6
17
 
7
- class KnowledgeGraphWriteMixin:
18
+ class KnowledgeGraphWriteMixin(_Core):
8
19
  def _upsert_node(
9
20
  self,
10
21
  conn: sqlite3.Connection,
@@ -408,7 +408,7 @@ class IngestionResult:
408
408
  quality_gate: Optional[Dict[str, Any]] = None
409
409
 
410
410
  def as_dict(self) -> Dict[str, Any]:
411
- payload = {
411
+ payload: Dict[str, Any] = {
412
412
  "status": self.status,
413
413
  "source_type": self.source_type,
414
414
  "node_id": self.node_id,
@@ -268,7 +268,7 @@ class MemoryQualityManager:
268
268
 
269
269
  def merge(self, cands: List[MemoryCandidate]) -> List[MemoryCandidate]:
270
270
  # naive merge by content prefix
271
- merged = {}
271
+ merged: Dict[str, MemoryCandidate] = {}
272
272
  for c in cands:
273
273
  key = c.content[:30]
274
274
  if key not in merged or c.score > merged[key].score:
@@ -365,19 +365,19 @@ class GraphEdgeQualityManager:
365
365
  return GraphEdgeQuality(edge.get("id", "e0"), conf, ev, quality_score=q)
366
366
 
367
367
  def detect_duplicate_edges(self, edges: List[Dict]) -> List[str]:
368
- seen = {}
369
- dups = []
368
+ seen: Dict[Any, Any] = {}
369
+ dups: List[str] = []
370
370
  for e in edges:
371
371
  key = (e.get("source"), e.get("target"), e.get("type"))
372
372
  if key in seen:
373
- dups.append(e.get("id"))
373
+ dups.append(str(e.get("id") or ""))
374
374
  else:
375
375
  seen[key] = e.get("id")
376
376
  return dups
377
377
 
378
378
  def merge_duplicate_edges(self, edges: List[Dict]) -> List[Dict]:
379
379
  # keep highest confidence
380
- best = {}
380
+ best: Dict[Any, Dict[str, Any]] = {}
381
381
  for e in edges:
382
382
  key = (e.get("source"), e.get("target"), e.get("type"))
383
383
  if key not in best or e.get("confidence", 0) > best[key].get("confidence", 0):
@@ -435,7 +435,8 @@ def realtime_event_contract(event: Dict[str, Any]) -> Dict[str, Any]:
435
435
  """
436
436
  body = dict(event or {})
437
437
  seq = body.get("seq")
438
- payload = body.get("payload") if isinstance(body.get("payload"), dict) else {}
438
+ raw_payload = body.get("payload")
439
+ payload: Dict[str, Any] = raw_payload if isinstance(raw_payload, dict) else {}
439
440
  contract_body = {
440
441
  "run_id": payload.get("run_id") or body.get("run_id"),
441
442
  "agent_id": str(payload.get("agent_id") or payload.get("workflow_id") or f"realtime:{body.get('area') or 'workspace'}"),
@@ -47,7 +47,7 @@ from typing import Any, Callable, Dict, List, Optional
47
47
  from ..utils import now_iso as _now
48
48
  from .contracts import multi_agent_contract
49
49
 
50
- MULTI_AGENT_VERSION = "10.3.0"
50
+ MULTI_AGENT_VERSION = "10.4.0"
51
51
 
52
52
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
53
53
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -239,7 +239,10 @@ class ApprovalRequired(Exception):
239
239
  permission: Optional[Dict[str, Any]] = None):
240
240
  super().__init__(message)
241
241
  self.tool = tool
242
- self.args = args or {}
242
+ # Deliberately shadows BaseException.args: callers read
243
+ # `exc.args` as the failing tool's argument mapping, not the
244
+ # exception constructor tuple.
245
+ self.args = args or {} # type: ignore[assignment]
243
246
  self.permission = permission or {}
244
247
 
245
248
 
@@ -374,7 +377,7 @@ class WorkflowEngine:
374
377
  while current is not None and steps < _MAX_STEPS:
375
378
  steps += 1
376
379
  ntype = current.get("type")
377
- nid = current.get("id")
380
+ nid = str(current.get("id") or "")
378
381
  entry: Dict[str, Any] = {
379
382
  "node": nid,
380
383
  "type": ntype,
@@ -386,7 +389,7 @@ class WorkflowEngine:
386
389
  entry["status"] = "ok"
387
390
  entry["trigger"] = (current.get("config") or {}).get("trigger", "manual")
388
391
  run.timeline.append(entry)
389
- current = nodes.get(current.get("next")) if current.get("next") else None
392
+ current = nodes.get(str(current.get("next") or "")) if current.get("next") else None
390
393
  continue
391
394
 
392
395
  if ntype == "output":
@@ -395,7 +398,7 @@ class WorkflowEngine:
395
398
  entry["output"] = payload if payload is not None else context.get("last_output")
396
399
  run.outputs[nid] = entry["output"]
397
400
  run.timeline.append(entry)
398
- current = nodes.get(current.get("next")) if current.get("next") else None
401
+ current = nodes.get(str(current.get("next") or "")) if current.get("next") else None
399
402
  continue
400
403
 
401
404
  if ntype == "condition":
@@ -409,14 +412,14 @@ class WorkflowEngine:
409
412
  continue
410
413
 
411
414
  # Executable node → dispatch to its runner family.
412
- family = _RUNNER_FOR.get(ntype)
415
+ family = _RUNNER_FOR.get(str(ntype or ""))
413
416
  runner = self.runners.get(family) if family else None
414
417
  if runner is None:
415
418
  entry["status"] = "skipped"
416
419
  entry["reason"] = f"no '{family}' runner configured"
417
420
  had_skip = True
418
421
  run.timeline.append(entry)
419
- current = nodes.get(current.get("next")) if current.get("next") else None
422
+ current = nodes.get(str(current.get("next") or "")) if current.get("next") else None
420
423
  continue
421
424
  try:
422
425
  result = runner(node=current, context=context)
@@ -451,7 +454,7 @@ class WorkflowEngine:
451
454
  entry["reason"] = str(exc)
452
455
  had_error = True
453
456
  run.timeline.append(entry)
454
- current = nodes.get(current.get("next")) if current.get("next") else None
457
+ current = nodes.get(str(current.get("next") or "")) if current.get("next") else None
455
458
 
456
459
  if steps >= _MAX_STEPS:
457
460
  run.timeline.append({"node": None, "type": "guard", "status": "error", "reason": f"exceeded {_MAX_STEPS} steps (cycle?)", "timestamp": _now()})
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "10.3.0"
3
+ __version__ = "10.4.0"
@@ -3,7 +3,7 @@
3
3
  import logging
4
4
  from collections import defaultdict
5
5
  from datetime import datetime, timedelta
6
- from typing import Callable, Dict, List, Optional
6
+ from typing import Any, Callable, Dict, List, Optional
7
7
 
8
8
  from fastapi import APIRouter, HTTPException, Query, Request
9
9
  from pydantic import BaseModel
@@ -226,7 +226,7 @@ def create_admin_router(
226
226
  for email, user in users.items(): # noqa: B007 — the key is the payload; the loop var documents the shape
227
227
  role = (get_user_role(email, users) or "user").lower()
228
228
  counts[role] += 1
229
- matrix = policy_matrix() if policy_matrix else [
229
+ matrix: List[Dict[str, Any]] = policy_matrix() if policy_matrix else [
230
230
  {"role": "admin", "caps": ["all"]},
231
231
  {"role": "user", "caps": ["chat", "search"]},
232
232
  ]
@@ -94,16 +94,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
94
94
  api_router = APIRouter()
95
95
  model_router = context.model_router
96
96
  config = context.config
97
- require_user = context.require_user
97
+ require_user = context.require("require_user")
98
98
  workspace_service = context.workspace_service
99
99
  allowed_workspaces_for = context.allowed_workspaces_for
100
+ # Bound once so every handler below calls a resolved dependency rather
101
+ # than an Optional field (see AppContext.require).
102
+ enforce_rate_limit = context.require("enforce_rate_limit")
103
+ append_audit_event = context.require("append_audit_event")
100
104
 
101
105
  chat_service = ChatService.coerce(
102
106
  context.chat_service,
103
107
  store=context.workspace_store,
104
- get_history=context.get_history,
105
- save_to_history=context.save_to_history,
106
- get_history_user=context.get_history_user,
108
+ get_history=context.require("get_history"),
109
+ save_to_history=context.require("save_to_history"),
110
+ get_history_user=context.require("get_history_user"),
107
111
  )
108
112
 
109
113
  def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
@@ -170,9 +174,9 @@ def create_chat_router(context: AppContext) -> APIRouter:
170
174
  model_router=model_router,
171
175
  execute_tool=execute_tool,
172
176
  recent_chat_context=recent_chat_context,
173
- clear_history=context.clear_history,
177
+ clear_history=context.require("clear_history"),
174
178
  knowledge_save=knowledge_save,
175
- audit=context.append_audit_event,
179
+ audit=context.require("append_audit_event"),
176
180
  hooks=context.hooks,
177
181
  brain_memory=context.brain_memory,
178
182
  )
@@ -247,7 +251,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
247
251
  @api_router.post("/chat")
248
252
  async def chat(req: ChatRequest, request: Request):
249
253
  current_user = require_user(request)
250
- context.enforce_rate_limit(current_user, "chat")
254
+ enforce_rate_limit(current_user, "chat")
251
255
  logging.debug(
252
256
  "/chat request: stream=%s image_data_len=%s message_len=%s",
253
257
  req.stream,
@@ -352,7 +356,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
352
356
  for match in file_path_pattern.finditer(req.message or "")
353
357
  ]
354
358
  if requested_paths:
355
- context.append_audit_event(
359
+ append_audit_event(
356
360
  "auto_file_context_blocked",
357
361
  user_email=effective_email,
358
362
  path_count=len(requested_paths),
@@ -393,7 +397,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
393
397
  if isinstance(trace_seed, dict):
394
398
  trace_seed["context_quality"] = context_quality
395
399
  if context.funnel_metrics is not None and int(
396
- context_quality.get("nodes") or 0
400
+ str(context_quality.get("nodes") or 0)
397
401
  ) > 0:
398
402
  context.funnel_metrics.record_recall_success()
399
403
 
@@ -542,12 +546,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
542
546
  chat_service=chat_service,
543
547
  require_user=require_user,
544
548
  scope_for_user=history_scope_for_user,
545
- group_conversations=context.group_history_conversations,
546
- get_conversation_messages=context.get_conversation_messages,
547
- conversation_title=context.conversation_title,
548
- clear_conversation=context.clear_conversation,
549
- clear_history=context.clear_history,
550
- append_audit_event=context.append_audit_event,
549
+ group_conversations=context.require("group_history_conversations"),
550
+ get_conversation_messages=context.require("get_conversation_messages"),
551
+ conversation_title=context.require("conversation_title"),
552
+ clear_conversation=context.require("clear_conversation"),
553
+ clear_history=context.require("clear_history"),
554
+ append_audit_event=context.require("append_audit_event"),
551
555
  ),
552
556
  )
553
557
  agent_controller.register_routes(api_router)
@@ -11,7 +11,7 @@ import threading
11
11
  import time
12
12
  from datetime import datetime, timedelta, timezone
13
13
  from pathlib import Path
14
- from typing import Any, Dict, Optional
14
+ from typing import Any, Callable, Dict, Optional
15
15
 
16
16
  from fastapi import APIRouter, HTTPException, Request
17
17
  from fastapi.responses import StreamingResponse
@@ -31,6 +31,20 @@ from latticeai.api.chat_stream import agent_live_stream
31
31
  from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
32
32
  from latticeai.core.quiet import quiet
33
33
  from latticeai.core.run_explain import explain_run
34
+
35
+
36
+ def _bind_case(
37
+ execute_tool: Callable[[str, Dict[str, Any]], Any],
38
+ action_name: str,
39
+ case_input: Dict[str, Any],
40
+ ) -> Callable[[], Any]:
41
+ """Freeze this iteration's action and input into a zero-arg thunk.
42
+
43
+ Previously a lambda with default-argument binding, which is correct but
44
+ invisible to a type checker (`Cannot infer type of lambda`) and one
45
+ refactor away from every case running the last input.
46
+ """
47
+ return lambda: execute_tool(action_name, case_input)
34
48
  from latticeai.core.run_store import (
35
49
  AgentRunStore,
36
50
  hash_approval_token,
@@ -200,9 +214,7 @@ class AgentHTTPController:
200
214
  # Bound as defaults: the lambda is invoked inside this
201
215
  # iteration today, so late binding is harmless — until
202
216
  # someone defers it and every case runs the last input.
203
- lambda action_name=action_name, case_input=case_input: self.execute_tool(
204
- action_name, case_input
205
- ),
217
+ _bind_case(self.execute_tool, action_name, case_input),
206
218
  source="eval",
207
219
  )
208
220
  criteria = case.get("pass_criteria", "")
@@ -509,7 +521,7 @@ class AgentHTTPController:
509
521
  }
510
522
  try:
511
523
  for summary in self.run_store.pending_summaries(current_user):
512
- run_id = summary.get("run_id")
524
+ run_id = str(summary.get("run_id") or "")
513
525
  if run_id and run_id not in pending:
514
526
  pending[run_id] = {
515
527
  "run_id": run_id,
@@ -801,7 +813,7 @@ class AgentHTTPController:
801
813
  detail="Agent run belongs to another user.",
802
814
  )
803
815
  if now_monotonic >= entry["expires_monotonic"]:
804
- self._approvals.pop(req.run_id, None)
816
+ self._approvals.pop(req.run_id or "", None)
805
817
  try:
806
818
  self.run_store.delete(req.run_id or "")
807
819
  except Exception: # noqa: BLE001
@@ -820,7 +832,7 @@ class AgentHTTPController:
820
832
  detail="Invalid approval token for this run.",
821
833
  )
822
834
  # Token validated — the pending run is consumed either way.
823
- self._approvals.pop(req.run_id, None)
835
+ self._approvals.pop(req.run_id or "", None)
824
836
  try:
825
837
  self.run_store.delete(req.run_id or "")
826
838
  except Exception as exc: # noqa: BLE001 — consumption must proceed
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import re
12
- from typing import AsyncIterator, Dict, List, Optional
12
+ from typing import Any, AsyncIterator, Dict, List, Optional
13
13
 
14
14
  from fastapi import Request
15
15
 
@@ -289,17 +289,18 @@ def assess_answer_grounding(
289
289
  them". Annotation only — the answer is never modified or blocked.
290
290
  """
291
291
  answer_text = str(answer or "").strip()
292
- trace = trace if isinstance(trace, dict) else {}
293
- sources: List[Dict[str, object]] = []
292
+ trace_map: Dict[str, Any] = trace if isinstance(trace, dict) else {}
293
+ sources: List[Dict[str, Any]] = []
294
294
  seen_ids = set()
295
- for node in list(trace.get("graph_nodes") or []):
295
+ for node in list(trace_map.get("graph_nodes") or []):
296
296
  if not isinstance(node, dict):
297
297
  continue
298
298
  node_id = str(node.get("id") or node.get("node_id") or "")
299
299
  if not node_id or node_id in seen_ids:
300
300
  continue
301
301
  seen_ids.add(node_id)
302
- meta = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
302
+ raw_meta = node.get("metadata")
303
+ meta: Dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {}
303
304
  sources.append({
304
305
  "id": node_id,
305
306
  "title": str(node.get("title") or ""),
@@ -308,7 +309,7 @@ def assess_answer_grounding(
308
309
  for part in (node.get("title"), node.get("summary"), meta.get("filename"))
309
310
  ),
310
311
  })
311
- for src in list(trace.get("source_files") or []):
312
+ for src in list(trace_map.get("source_files") or []):
312
313
  if not isinstance(src, dict):
313
314
  continue
314
315
  node_id = str(src.get("node_id") or src.get("source") or "")
@@ -349,10 +350,10 @@ def assess_answer_grounding(
349
350
 
350
351
  answer_tokens = _grounding_tokens(answer_text)
351
352
  answer_lower = answer_text.lower()
352
- cited: List[Dict[str, object]] = []
353
+ cited: List[Dict[str, Any]] = []
353
354
  best_overlap = 0.0
354
355
  for source in sources:
355
- source_tokens = _grounding_tokens(source["body"])
356
+ source_tokens = _grounding_tokens(str(source["body"]))
356
357
  if not source_tokens:
357
358
  continue
358
359
  shared = answer_tokens & source_tokens
@@ -57,13 +57,13 @@ def agent_live_stream(
57
57
 
58
58
  async def _stream() -> AsyncIterator[str]:
59
59
  response_model = model_id or router.current_model_id
60
- queue: asyncio.Queue = asyncio.Queue()
60
+ queue: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue()
61
61
 
62
62
  def observer(event: Dict[str, Any]) -> None:
63
63
  # Called synchronously from the agent coroutine on this loop.
64
64
  queue.put_nowait(event)
65
65
 
66
- task = asyncio.create_task(start(observer))
66
+ task = asyncio.ensure_future(start(observer))
67
67
  try:
68
68
  while True:
69
69
  getter = asyncio.create_task(queue.get())
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
- from typing import Optional
7
+ from typing import Any, Dict, List, Optional
8
8
 
9
9
  from fastapi import APIRouter, HTTPException, Request
10
10
  from fastapi.responses import StreamingResponse
@@ -401,7 +401,7 @@ def create_computer_use_router(
401
401
  yield _send("error", {"error": "No model loaded."})
402
402
  return
403
403
 
404
- transcript = []
404
+ transcript: List[Dict[str, Any]] = []
405
405
  last_screenshot_b64: Optional[str] = None
406
406
  max_steps = max(1, min(req.max_steps, 20))
407
407
  yield _send("start", {"task": req.task, "max_steps": max_steps})
@@ -8,7 +8,7 @@ the API.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from typing import Callable, List, Optional
11
+ from typing import Any, Callable, List, Optional
12
12
 
13
13
  from fastapi import APIRouter, HTTPException, Request
14
14
  from pydantic import BaseModel
@@ -46,7 +46,9 @@ def create_hooks_router(
46
46
  *,
47
47
  registry: HooksRegistry,
48
48
  require_user: Callable[[Request], str],
49
- require_admin: Callable[[Request], tuple],
49
+ # Production returns ``(email, users)``; small adapters return just the
50
+ # identity, which ``_require_admin_email`` below handles.
51
+ require_admin: Callable[[Request], Any],
50
52
  append_audit_event: Callable[..., None],
51
53
  ) -> APIRouter:
52
54
  router = APIRouter()
@@ -8,7 +8,7 @@ import time
8
8
  import uuid
9
9
  from datetime import datetime
10
10
  from pathlib import Path
11
- from typing import Optional
11
+ from typing import Any, Dict, List, Optional
12
12
 
13
13
  from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
14
14
  from fastapi.responses import FileResponse
@@ -132,11 +132,15 @@ def create_local_files_router(
132
132
  errors.append(f"graph: {exc}")
133
133
 
134
134
  # ── watcher + connected sources (real) ────────────────────────────────
135
- watch = local_kg_watcher.status() if local_kg_watcher else {"available": False, "active": {}}
136
- sources = []
135
+ watch: Dict[str, Any] = (
136
+ local_kg_watcher.status()
137
+ if local_kg_watcher
138
+ else {"available": False, "active": {}}
139
+ )
140
+ sources: List[Any] = []
137
141
  try:
138
142
  if knowledge_graph is not None:
139
- sources = (knowledge_graph.local_sources() or {}).get("sources", [])
143
+ sources = list((knowledge_graph.local_sources() or {}).get("sources") or [])
140
144
  except Exception as exc:
141
145
  errors.append(f"sources: {exc}")
142
146
  watched = len(watch.get("active", {}) or {})
@@ -360,7 +360,7 @@ def create_mcp_router(
360
360
  require_user(request)
361
361
  mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
362
362
  skills = await _fetch_skills_marketplace()
363
- by_author = {}
363
+ by_author: Dict[str, int] = {}
364
364
  for s in skills:
365
365
  by_author[s["author"]] = by_author.get(s["author"], 0) + 1
366
366
  return {"status": "ok", "total": len(skills), "by_author": by_author}
@@ -399,7 +399,7 @@ def create_mcp_router(
399
399
  require_user(request)
400
400
  mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
401
401
  plugins = await _fetch_plugin_directory()
402
- by_license = {}
402
+ by_license: Dict[str, int] = {}
403
403
  for p in plugins:
404
404
  lic = p.get("license", "unknown")
405
405
  by_license[lic] = by_license.get(lic, 0) + 1
@@ -155,25 +155,25 @@ def create_models_router(
155
155
 
156
156
  def _recommended_with_engine_options(
157
157
  items: List[Dict[str, object]],
158
- engines: Optional[List[Dict[str, object]]] = None,
158
+ engines: Optional[List[Dict[str, Any]]] = None,
159
159
  loaded_ids: Optional[List[str]] = None,
160
160
  current_id: Optional[str] = None,
161
161
  ) -> List[Dict[str, object]]:
162
162
  from latticeai.core.model_compat import model_runtime_compatibility
163
163
 
164
164
  engine_lookup = {str(engine.get("id") or ""): engine for engine in engines or []}
165
- model_lookup: Dict[str, Dict[str, object]] = {}
165
+ model_lookup: Dict[str, Dict[str, Any]] = {}
166
166
  for engine in engines or []:
167
167
  engine_id = str(engine.get("id") or "")
168
- for model in engine.get("models") or []:
168
+ for model in list(engine.get("models") or []):
169
169
  if isinstance(model, dict):
170
170
  model_lookup[str(model.get("id") or "")] = {**model, "_engine": engine_id}
171
171
  loaded = set(loaded_ids or [])
172
- out: List[Dict[str, object]] = []
172
+ out: List[Dict[str, Any]] = []
173
173
  for item in items:
174
174
  short_id = str(item["id"]).lower()
175
175
  aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
176
- options: List[Dict[str, object]] = []
176
+ options: List[Dict[str, Any]] = []
177
177
  for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
178
178
  real = aliases.get(engine_name)
179
179
  if not real: