ltcai 3.4.1 → 3.6.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 +206 -247
- package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
- package/docs/CHANGELOG.md +32 -0
- package/docs/HANDOVER_v3.6.0.md +46 -0
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
- package/docs/architecture.md +13 -12
- package/docs/kg-schema.md +55 -0
- package/docs/privacy.md +18 -2
- package/docs/security-model.md +17 -0
- package/kg_schema.py +46 -0
- package/knowledge_graph.py +520 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/browser.py +217 -0
- package/latticeai/api/chat.py +4 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/portability.py +93 -0
- package/latticeai/api/tools.py +29 -26
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/oidc.py +205 -0
- package/latticeai/core/security.py +59 -5
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +39 -0
- package/latticeai/services/ingestion.py +271 -0
- package/latticeai/services/kg_portability.py +177 -0
- package/package.json +5 -4
- package/requirements.txt +1 -0
- package/scripts/build_vsix.mjs +72 -0
- package/scripts/check_python.py +87 -0
- package/static/css/reference/account.css +1 -1
- package/static/css/reference/admin.css +1 -1
- package/static/css/reference/base.css +8 -5
- package/static/css/reference/chat.css +8 -8
- package/static/css/reference/graph.css +2 -2
- package/static/css/responsive.css +2 -2
- package/static/v3/asset-manifest.json +9 -9
- package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
- package/static/v3/css/lattice.shell.css +2 -1
- package/static/v3/js/{app.d086489d.js → app.c541f955.js} +1 -1
- package/static/v3/js/core/{api.12b568ad.js → api.33d6320e.js} +38 -0
- package/static/v3/js/core/api.js +38 -0
- package/static/v3/js/core/{routes.d214b399.js → routes.2ce3815a.js} +1 -1
- package/static/v3/js/core/routes.js +1 -1
- package/static/v3/js/core/{shell.d05266f5.js → shell.8c163e0e.js} +2 -2
- package/static/v3/js/views/knowledge-graph.a96040a5.js +513 -0
- package/static/v3/js/views/knowledge-graph.js +293 -17
- package/static/workspace.css +1 -1
- package/tools/__init__.py +276 -0
- package/tools/commands.py +188 -0
- package/tools/computer.py +185 -0
- package/tools/documents.py +243 -0
- package/tools/filesystem.py +560 -0
- package/tools/knowledge.py +97 -0
- package/tools/local_files.py +69 -0
- package/tools/network.py +66 -0
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
- package/tools.py +0 -1525
package/latticeai/api/auth.py
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
"""Authentication API router: register, login, logout, SSO, profile."""
|
|
2
2
|
|
|
3
|
-
import base64
|
|
4
|
-
import json
|
|
5
3
|
import logging
|
|
6
4
|
import secrets
|
|
7
5
|
import time
|
|
8
|
-
from typing import Any, Callable, Dict, Optional
|
|
6
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
|
9
7
|
from urllib.parse import urlencode
|
|
10
8
|
|
|
11
9
|
from fastapi import APIRouter, HTTPException, Request
|
|
12
10
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
13
11
|
from pydantic import BaseModel
|
|
14
12
|
|
|
13
|
+
from latticeai.core.oidc import (
|
|
14
|
+
OIDCValidationError,
|
|
15
|
+
fetch_jwks as _default_fetch_jwks,
|
|
16
|
+
verify_id_token as _default_verify_id_token,
|
|
17
|
+
)
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class UserRegister(BaseModel):
|
|
17
21
|
email: str
|
|
@@ -35,7 +39,9 @@ class UpdateProfileRequest(BaseModel):
|
|
|
35
39
|
nickname: Optional[str] = None
|
|
36
40
|
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
# state → (issued_at, nonce). The nonce binds the eventual ID token to *this*
|
|
43
|
+
# login attempt (replay / token-injection defence); the timestamp expires it.
|
|
44
|
+
_sso_states: Dict[str, Tuple[float, str]] = {}
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def create_auth_router(
|
|
@@ -58,6 +64,8 @@ def create_auth_router(
|
|
|
58
64
|
open_registration: bool,
|
|
59
65
|
session_ttl: int,
|
|
60
66
|
require_auth: bool = True,
|
|
67
|
+
verify_id_token: Callable[..., Dict] = _default_verify_id_token,
|
|
68
|
+
fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
|
|
61
69
|
) -> APIRouter:
|
|
62
70
|
router = APIRouter()
|
|
63
71
|
|
|
@@ -114,13 +122,15 @@ def create_auth_router(
|
|
|
114
122
|
if not settings.get("enabled") or not discovery:
|
|
115
123
|
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
116
124
|
state = secrets.token_urlsafe(16)
|
|
117
|
-
|
|
125
|
+
nonce = secrets.token_urlsafe(16)
|
|
126
|
+
_sso_states[state] = (time.time(), nonce)
|
|
118
127
|
params = urlencode({
|
|
119
128
|
"client_id": settings["client_id"],
|
|
120
129
|
"response_type": "code",
|
|
121
130
|
"redirect_uri": settings["redirect_uri"],
|
|
122
131
|
"scope": settings.get("scopes") or "openid email profile",
|
|
123
132
|
"state": state,
|
|
133
|
+
"nonce": nonce,
|
|
124
134
|
})
|
|
125
135
|
return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
|
|
126
136
|
|
|
@@ -128,9 +138,10 @@ def create_auth_router(
|
|
|
128
138
|
async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
129
139
|
if error:
|
|
130
140
|
return RedirectResponse(f"/?sso_error={error}")
|
|
131
|
-
|
|
132
|
-
if
|
|
141
|
+
entry = _sso_states.pop(state, None)
|
|
142
|
+
if entry is None or time.time() - entry[0] > 300:
|
|
133
143
|
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
144
|
+
_, nonce = entry
|
|
134
145
|
settings = get_sso_settings()
|
|
135
146
|
discovery = await get_sso_discovery()
|
|
136
147
|
if not settings.get("enabled") or not discovery:
|
|
@@ -148,8 +159,25 @@ def create_auth_router(
|
|
|
148
159
|
id_token = tokens.get("id_token")
|
|
149
160
|
if not id_token:
|
|
150
161
|
raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
# Never trust a decoded JWT payload: verify signature (against the
|
|
163
|
+
# provider JWKS), issuer, audience, expiry and the login nonce before
|
|
164
|
+
# using any claim. Any failure is fail-closed (401).
|
|
165
|
+
issuer = discovery.get("issuer") or ""
|
|
166
|
+
try:
|
|
167
|
+
jwks = await fetch_jwks(discovery.get("jwks_uri", ""))
|
|
168
|
+
payload = verify_id_token(
|
|
169
|
+
id_token,
|
|
170
|
+
jwks=jwks,
|
|
171
|
+
issuer=issuer,
|
|
172
|
+
audience=settings["client_id"],
|
|
173
|
+
nonce=nonce,
|
|
174
|
+
)
|
|
175
|
+
except OIDCValidationError as exc:
|
|
176
|
+
logging.warning("SSO ID token rejected: %s", exc)
|
|
177
|
+
raise HTTPException(status_code=401, detail="SSO 토큰 검증에 실패했습니다.")
|
|
178
|
+
except Exception as exc: # discovery/JWKS fetch failure → fail closed
|
|
179
|
+
logging.warning("SSO token validation error: %s", exc)
|
|
180
|
+
raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
|
|
153
181
|
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
|
|
154
182
|
if not email:
|
|
155
183
|
raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Browser & web ingestion — Knowledge Graph inputs, not standalone features.
|
|
2
|
+
|
|
3
|
+
v3.6.0 Knowledge Graph First: a public URL or an open browser tab is just another
|
|
4
|
+
source that converges into the Knowledge Graph through the unified ingestion
|
|
5
|
+
pipeline. Everything runs on the **local runtime** — the server fetches/reads
|
|
6
|
+
locally, stores into local SQLite, and never uploads to a cloud service.
|
|
7
|
+
|
|
8
|
+
Two layers, both feeding ``IngestionPipeline.ingest``:
|
|
9
|
+
|
|
10
|
+
* ``POST /api/browser/read-url`` — the runtime fetches a public URL locally,
|
|
11
|
+
extracts readable text, stores it as ``source_type=web_url``. Fails gracefully
|
|
12
|
+
on blocked / login-required pages.
|
|
13
|
+
* ``POST /api/browser/ingest-current-tab`` — accepts a payload from the local
|
|
14
|
+
browser extension (url/title/text/selected_text/html), sanitizes + size-limits
|
|
15
|
+
it, stores it as ``source_type=browser_tab``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from html.parser import HTMLParser
|
|
21
|
+
from typing import Any, Callable, Optional, Tuple
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from latticeai.services.ingestion import IngestionItem
|
|
28
|
+
|
|
29
|
+
MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
|
|
30
|
+
MAX_URL_FETCH_BYTES = 4 * 1024 * 1024 # 4 MB cap on a fetched page
|
|
31
|
+
URL_FETCH_TIMEOUT = 12.0 # seconds
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BrowserFetchError(Exception):
|
|
35
|
+
"""A URL could not be fetched (blocked, login-required, timeout, too big)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── readable-text extraction ─────────────────────────────────────────────────
|
|
39
|
+
_SKIP_TAGS = {"script", "style", "noscript", "template", "svg", "head"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _TextExtractor(HTMLParser):
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
super().__init__(convert_charrefs=True)
|
|
45
|
+
self._skip_depth = 0
|
|
46
|
+
self._chunks: list[str] = []
|
|
47
|
+
self.title: str = ""
|
|
48
|
+
self._in_title = False
|
|
49
|
+
|
|
50
|
+
def handle_starttag(self, tag, attrs):
|
|
51
|
+
if tag in _SKIP_TAGS:
|
|
52
|
+
self._skip_depth += 1
|
|
53
|
+
if tag == "title":
|
|
54
|
+
self._in_title = True
|
|
55
|
+
|
|
56
|
+
def handle_endtag(self, tag):
|
|
57
|
+
if tag in _SKIP_TAGS and self._skip_depth > 0:
|
|
58
|
+
self._skip_depth -= 1
|
|
59
|
+
if tag == "title":
|
|
60
|
+
self._in_title = False
|
|
61
|
+
if tag in {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "section", "article"}:
|
|
62
|
+
self._chunks.append("\n")
|
|
63
|
+
|
|
64
|
+
def handle_data(self, data):
|
|
65
|
+
if self._in_title:
|
|
66
|
+
self.title += data
|
|
67
|
+
if self._skip_depth == 0:
|
|
68
|
+
text = data.strip()
|
|
69
|
+
if text:
|
|
70
|
+
self._chunks.append(text)
|
|
71
|
+
|
|
72
|
+
def text(self) -> str:
|
|
73
|
+
raw = " ".join(self._chunks)
|
|
74
|
+
# collapse runs of whitespace while keeping paragraph breaks
|
|
75
|
+
lines = [ln.strip() for ln in raw.replace("\r", "").split("\n")]
|
|
76
|
+
return "\n".join([ln for ln in lines if ln]).strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_readable_text(html: str) -> Tuple[str, str]:
|
|
80
|
+
"""Return (title, readable_text) from an HTML string. Never raises."""
|
|
81
|
+
parser = _TextExtractor()
|
|
82
|
+
try:
|
|
83
|
+
parser.feed(html or "")
|
|
84
|
+
except Exception: # noqa: BLE001 — malformed HTML must still yield best-effort text
|
|
85
|
+
pass
|
|
86
|
+
return parser.title.strip(), parser.text()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default_fetch_url(url: str) -> Tuple[str, str]:
|
|
90
|
+
"""Fetch a public URL on the local runtime and extract readable text.
|
|
91
|
+
|
|
92
|
+
Raises :class:`BrowserFetchError` on any non-success (blocked, login wall,
|
|
93
|
+
timeout, oversized, non-HTML) so the route can fail gracefully.
|
|
94
|
+
"""
|
|
95
|
+
import httpx
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with httpx.Client(
|
|
99
|
+
follow_redirects=True, timeout=URL_FETCH_TIMEOUT,
|
|
100
|
+
headers={"User-Agent": "LatticeAI-local/3.6 (+local-first knowledge graph)"},
|
|
101
|
+
) as client:
|
|
102
|
+
resp = client.get(url)
|
|
103
|
+
except httpx.HTTPError as exc:
|
|
104
|
+
raise BrowserFetchError(f"Could not reach the page: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
if resp.status_code in (401, 403):
|
|
107
|
+
raise BrowserFetchError("The page is login-required or blocked (HTTP %s)." % resp.status_code)
|
|
108
|
+
if resp.status_code >= 400:
|
|
109
|
+
raise BrowserFetchError(f"The page returned HTTP {resp.status_code}.")
|
|
110
|
+
content_type = resp.headers.get("content-type", "")
|
|
111
|
+
if content_type and "html" not in content_type and "text" not in content_type:
|
|
112
|
+
raise BrowserFetchError(f"Unsupported content type: {content_type or 'unknown'}.")
|
|
113
|
+
body = resp.text or ""
|
|
114
|
+
if len(body.encode("utf-8", "ignore")) > MAX_URL_FETCH_BYTES:
|
|
115
|
+
body = body.encode("utf-8", "ignore")[:MAX_URL_FETCH_BYTES].decode("utf-8", "ignore")
|
|
116
|
+
title, text = extract_readable_text(body)
|
|
117
|
+
return (title or url, text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate_http_url(url: str) -> str:
|
|
121
|
+
url = (url or "").strip()
|
|
122
|
+
if not url:
|
|
123
|
+
raise HTTPException(status_code=400, detail="url is required.")
|
|
124
|
+
parsed = urlparse(url)
|
|
125
|
+
if parsed.scheme not in ("http", "https"):
|
|
126
|
+
raise HTTPException(status_code=400, detail="Only http(s) URLs are supported.")
|
|
127
|
+
if not parsed.netloc:
|
|
128
|
+
raise HTTPException(status_code=400, detail="Malformed URL.")
|
|
129
|
+
return url
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── request models ───────────────────────────────────────────────────────────
|
|
133
|
+
class ReadUrlRequest(BaseModel):
|
|
134
|
+
url: str
|
|
135
|
+
workspace_id: Optional[str] = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class IngestTabRequest(BaseModel):
|
|
139
|
+
url: str
|
|
140
|
+
title: Optional[str] = None
|
|
141
|
+
text: Optional[str] = None
|
|
142
|
+
selected_text: Optional[str] = None
|
|
143
|
+
html: Optional[str] = None
|
|
144
|
+
captured_at: Optional[str] = None
|
|
145
|
+
workspace_id: Optional[str] = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_browser_router(
|
|
149
|
+
*,
|
|
150
|
+
pipeline: Any,
|
|
151
|
+
require_user: Callable[[Request], str],
|
|
152
|
+
fetch_url: Optional[Callable[[str], Tuple[str, str]]] = None,
|
|
153
|
+
max_tab_bytes: int = MAX_TAB_BYTES,
|
|
154
|
+
) -> APIRouter:
|
|
155
|
+
router = APIRouter()
|
|
156
|
+
_fetch = fetch_url or _default_fetch_url
|
|
157
|
+
|
|
158
|
+
def _require_pipeline():
|
|
159
|
+
if pipeline is None or not pipeline.available():
|
|
160
|
+
raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
|
|
161
|
+
|
|
162
|
+
@router.post("/api/browser/read-url")
|
|
163
|
+
async def read_url(req: ReadUrlRequest, request: Request):
|
|
164
|
+
"""Fetch a public URL locally and ingest it as a web_url source."""
|
|
165
|
+
user = require_user(request)
|
|
166
|
+
_require_pipeline()
|
|
167
|
+
url = _validate_http_url(req.url)
|
|
168
|
+
try:
|
|
169
|
+
title, text = _fetch(url)
|
|
170
|
+
except BrowserFetchError as exc:
|
|
171
|
+
# Graceful failure — not a 5xx; the page was simply unreadable.
|
|
172
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
173
|
+
if not (text or "").strip():
|
|
174
|
+
return {"status": "empty", "source_type": "web_url", "url": url,
|
|
175
|
+
"detail": "No readable text was extracted from the page."}
|
|
176
|
+
res = pipeline.ingest(
|
|
177
|
+
IngestionItem(
|
|
178
|
+
source_type="web_url", title=title, text=text, source_uri=url,
|
|
179
|
+
owner=user, workspace_id=req.workspace_id,
|
|
180
|
+
),
|
|
181
|
+
user_email=user,
|
|
182
|
+
)
|
|
183
|
+
return res.as_dict()
|
|
184
|
+
|
|
185
|
+
@router.post("/api/browser/ingest-current-tab")
|
|
186
|
+
async def ingest_current_tab(req: IngestTabRequest, request: Request):
|
|
187
|
+
"""Ingest a payload captured from the local browser extension."""
|
|
188
|
+
user = require_user(request)
|
|
189
|
+
_require_pipeline()
|
|
190
|
+
url = _validate_http_url(req.url)
|
|
191
|
+
# Sanitize: reject an oversized payload before doing any work.
|
|
192
|
+
for value in (req.text, req.html, req.selected_text):
|
|
193
|
+
if value and len(value.encode("utf-8", "ignore")) > max_tab_bytes:
|
|
194
|
+
raise HTTPException(status_code=413, detail="Captured payload is too large.")
|
|
195
|
+
text = (req.text or "").strip()
|
|
196
|
+
if not text and req.html:
|
|
197
|
+
_title, text = extract_readable_text(req.html)
|
|
198
|
+
if not text:
|
|
199
|
+
text = (req.selected_text or "").strip()
|
|
200
|
+
if not text:
|
|
201
|
+
raise HTTPException(status_code=400, detail="No text, html, or selected_text provided.")
|
|
202
|
+
res = pipeline.ingest(
|
|
203
|
+
IngestionItem(
|
|
204
|
+
source_type="browser_tab",
|
|
205
|
+
title=req.title or url,
|
|
206
|
+
text=text,
|
|
207
|
+
source_uri=url,
|
|
208
|
+
captured_at=req.captured_at,
|
|
209
|
+
owner=user,
|
|
210
|
+
workspace_id=req.workspace_id,
|
|
211
|
+
metadata={"has_selection": bool(req.selected_text)},
|
|
212
|
+
),
|
|
213
|
+
user_email=user,
|
|
214
|
+
)
|
|
215
|
+
return res.as_dict()
|
|
216
|
+
|
|
217
|
+
return router
|
package/latticeai/api/chat.py
CHANGED
|
@@ -24,6 +24,7 @@ from PIL import Image
|
|
|
24
24
|
from latticeai.core.agent import AgentRunContext, AgentState
|
|
25
25
|
from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
|
|
26
26
|
from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
|
|
27
|
+
from latticeai.core.hooks import dispatch_tool
|
|
27
28
|
from latticeai.services.chat_service import ChatService
|
|
28
29
|
from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
|
|
29
30
|
from telegram_bot import broadcast_web_chat
|
|
@@ -653,7 +654,9 @@ def create_chat_router(
|
|
|
653
654
|
for case in eval_cases:
|
|
654
655
|
case_id = case.get("id", "?")
|
|
655
656
|
try:
|
|
656
|
-
|
|
657
|
+
case_input = case.get("input", {})
|
|
658
|
+
result = dispatch_tool(hooks, action_name, case_input,
|
|
659
|
+
lambda: execute_tool(action_name, case_input), source="eval")
|
|
657
660
|
criteria = case.get("pass_criteria", "")
|
|
658
661
|
if "success == true" in criteria:
|
|
659
662
|
passed = result.get("success") is True
|
|
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
|
|
|
11
11
|
from pydantic import BaseModel
|
|
12
12
|
|
|
13
13
|
from latticeai.core.agent import extract_action as _extract_agent_action
|
|
14
|
+
from latticeai.core.hooks import dispatch_tool
|
|
14
15
|
from tools import (
|
|
15
16
|
ToolError,
|
|
16
17
|
computer_click,
|
|
@@ -105,9 +106,15 @@ class CuDragRequest(BaseModel):
|
|
|
105
106
|
y2: int
|
|
106
107
|
|
|
107
108
|
|
|
108
|
-
def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history) -> APIRouter:
|
|
109
|
+
def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history, hooks=None) -> APIRouter:
|
|
109
110
|
router = APIRouter()
|
|
110
111
|
|
|
112
|
+
def _dispatch(name, args, fn):
|
|
113
|
+
# Run a computer-use action through the unified pre_tool/post_tool
|
|
114
|
+
# lifecycle. With hooks=None this is a transparent pass-through, so the
|
|
115
|
+
# behaviour is unchanged when hooks are absent.
|
|
116
|
+
return dispatch_tool(hooks, name, dict(args or {}), fn, source="computer_use")
|
|
117
|
+
|
|
111
118
|
@router.get("/tools/chrome_status")
|
|
112
119
|
async def tools_chrome_status(request: Request):
|
|
113
120
|
require_user(request)
|
|
@@ -122,7 +129,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
122
129
|
async def cu_status(request: Request):
|
|
123
130
|
require_user(request)
|
|
124
131
|
try:
|
|
125
|
-
return computer_status
|
|
132
|
+
return _dispatch("computer_status", {}, computer_status)
|
|
133
|
+
except PermissionError as exc:
|
|
134
|
+
raise HTTPException(status_code=403, detail=str(exc))
|
|
126
135
|
except ToolError as exc:
|
|
127
136
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
128
137
|
|
|
@@ -130,7 +139,9 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
130
139
|
async def cu_screenshot(request: Request):
|
|
131
140
|
require_user(request)
|
|
132
141
|
try:
|
|
133
|
-
return computer_screenshot
|
|
142
|
+
return _dispatch("computer_screenshot", {}, computer_screenshot)
|
|
143
|
+
except PermissionError as exc:
|
|
144
|
+
raise HTTPException(status_code=403, detail=str(exc))
|
|
134
145
|
except ToolError as exc:
|
|
135
146
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
136
147
|
|
|
@@ -196,7 +207,8 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
196
207
|
"action",
|
|
197
208
|
{"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
|
|
198
209
|
)
|
|
199
|
-
result = computer_open_url
|
|
210
|
+
result = _dispatch("computer_open_url", {"url": url, "app": "Google Chrome"},
|
|
211
|
+
lambda: computer_open_url(url, "Google Chrome"))
|
|
200
212
|
yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
|
|
201
213
|
message = f"Google Chrome에서 {url}을 열었습니다."
|
|
202
214
|
action_name = "computer_open_url"
|
|
@@ -205,14 +217,15 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
205
217
|
"action",
|
|
206
218
|
{"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
|
|
207
219
|
)
|
|
208
|
-
result =
|
|
220
|
+
result = _dispatch("computer_open_app", {"app": "Google Chrome"},
|
|
221
|
+
lambda: computer_open_app("Google Chrome"))
|
|
209
222
|
yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
|
|
210
223
|
message = "Google Chrome을 열었습니다."
|
|
211
224
|
action_name = "computer_open_app"
|
|
212
225
|
save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
|
|
213
226
|
save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
|
|
214
227
|
yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
|
|
215
|
-
except ToolError as exc:
|
|
228
|
+
except (ToolError, PermissionError) as exc:
|
|
216
229
|
yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
|
|
217
230
|
return
|
|
218
231
|
|
|
@@ -256,7 +269,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
256
269
|
|
|
257
270
|
yield _send("action", {"step": step + 1, "action": name, "args": args})
|
|
258
271
|
try:
|
|
259
|
-
result = execute_tool(name, args)
|
|
272
|
+
result = _dispatch(name, args, lambda: execute_tool(name, args))
|
|
260
273
|
if name == "computer_screenshot" and "screenshot_b64" in result:
|
|
261
274
|
last_screenshot_b64 = result["screenshot_b64"]
|
|
262
275
|
result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
|
|
@@ -275,7 +288,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
|
|
|
275
288
|
last_screenshot_b64 = None
|
|
276
289
|
transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
|
|
277
290
|
yield _send("result", {"step": step + 1, "action": name, "result": result})
|
|
278
|
-
except (ToolError, KeyError, TypeError) as exc:
|
|
291
|
+
except (ToolError, PermissionError, KeyError, TypeError) as exc:
|
|
279
292
|
error_str = str(exc)
|
|
280
293
|
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
281
294
|
yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Knowledge Graph portability routes — local export / import / backup / restore.
|
|
2
|
+
|
|
3
|
+
Reads (export, status) require a signed-in user. Mutating operations (import,
|
|
4
|
+
backup, restore, file export) require admin because the graph is machine-global,
|
|
5
|
+
not workspace-scoped. Nothing here touches a cloud service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImportRequest(BaseModel):
|
|
17
|
+
artifact: dict
|
|
18
|
+
mode: str = "merge"
|
|
19
|
+
dry_run: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BackupRequest(BaseModel):
|
|
23
|
+
path: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RestoreRequest(BaseModel):
|
|
27
|
+
path: str
|
|
28
|
+
verify: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_portability_router(
|
|
32
|
+
*,
|
|
33
|
+
service: Any,
|
|
34
|
+
require_user: Callable[[Request], str],
|
|
35
|
+
require_admin: Callable[[Request], Any],
|
|
36
|
+
) -> APIRouter:
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
def _require_service():
|
|
40
|
+
if service is None or not service.available():
|
|
41
|
+
raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
|
|
42
|
+
|
|
43
|
+
@router.get("/api/knowledge-graph/portability")
|
|
44
|
+
async def portability_status(request: Request):
|
|
45
|
+
require_user(request)
|
|
46
|
+
_require_service()
|
|
47
|
+
return service.snapshot_metadata()
|
|
48
|
+
|
|
49
|
+
@router.get("/api/knowledge-graph/provenance")
|
|
50
|
+
async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
|
|
51
|
+
"""Recent ingestions (provenance trail) for the ingestion-sources UI."""
|
|
52
|
+
require_user(request)
|
|
53
|
+
_require_service()
|
|
54
|
+
return service.recent_ingestions(limit=limit, source_type=source_type)
|
|
55
|
+
|
|
56
|
+
@router.post("/api/knowledge-graph/export")
|
|
57
|
+
async def export_graph(request: Request):
|
|
58
|
+
"""Logical JSON export of the whole graph (read-only)."""
|
|
59
|
+
require_user(request)
|
|
60
|
+
_require_service()
|
|
61
|
+
return service.export()
|
|
62
|
+
|
|
63
|
+
@router.post("/api/knowledge-graph/export-file")
|
|
64
|
+
async def export_graph_file(request: Request):
|
|
65
|
+
require_admin(request)
|
|
66
|
+
_require_service()
|
|
67
|
+
return service.export_to_file()
|
|
68
|
+
|
|
69
|
+
@router.post("/api/knowledge-graph/import")
|
|
70
|
+
async def import_graph(req: ImportRequest, request: Request):
|
|
71
|
+
require_admin(request)
|
|
72
|
+
_require_service()
|
|
73
|
+
try:
|
|
74
|
+
return service.import_data(req.artifact, mode=req.mode, dry_run=req.dry_run)
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
77
|
+
|
|
78
|
+
@router.post("/api/knowledge-graph/backup")
|
|
79
|
+
async def backup_graph(req: BackupRequest, request: Request):
|
|
80
|
+
require_admin(request)
|
|
81
|
+
_require_service()
|
|
82
|
+
return service.backup(req.path)
|
|
83
|
+
|
|
84
|
+
@router.post("/api/knowledge-graph/restore")
|
|
85
|
+
async def restore_graph(req: RestoreRequest, request: Request):
|
|
86
|
+
require_admin(request)
|
|
87
|
+
_require_service()
|
|
88
|
+
try:
|
|
89
|
+
return service.restore(req.path, verify=req.verify)
|
|
90
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
91
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
92
|
+
|
|
93
|
+
return router
|
package/latticeai/api/tools.py
CHANGED
|
@@ -221,17 +221,30 @@ def create_tools_router(
|
|
|
221
221
|
|
|
222
222
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
223
223
|
|
|
224
|
-
def _tool_response(fn, *args):
|
|
224
|
+
def _tool_response(fn, *args, **kwargs):
|
|
225
225
|
# Shared tool lifecycle (same path as the agent + workflow tool calls):
|
|
226
|
-
# pre_tool (may block) → execute → post_tool.
|
|
226
|
+
# pre_tool (may block) → execute → post_tool. Keyword args are forwarded
|
|
227
|
+
# to the tool and surfaced in the hook payload so read_file / edit_file /
|
|
228
|
+
# grep (which need kwargs) run through the SAME lifecycle as every other
|
|
229
|
+
# tool instead of bypassing it.
|
|
227
230
|
tool_name = getattr(fn, "__name__", "tool")
|
|
228
231
|
try:
|
|
229
|
-
result = dispatch_tool(HOOKS, tool_name,
|
|
232
|
+
result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source="http")
|
|
230
233
|
except PermissionError as exc:
|
|
231
234
|
raise HTTPException(status_code=403, detail=str(exc))
|
|
232
235
|
except ToolError as exc:
|
|
233
236
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
234
237
|
return {"status": "ok", "workspace": str(AGENT_ROOT), "result": result}
|
|
238
|
+
|
|
239
|
+
def _dispatch(tool_name, args, fn):
|
|
240
|
+
# Lifecycle wrapper for callables that aren't a plain tools.* function
|
|
241
|
+
# (e.g. the server's clear_history). Same pre_tool/post_tool path.
|
|
242
|
+
try:
|
|
243
|
+
return dispatch_tool(HOOKS, tool_name, dict(args or {}), fn, source="http")
|
|
244
|
+
except PermissionError as exc:
|
|
245
|
+
raise HTTPException(status_code=403, detail=str(exc))
|
|
246
|
+
except ToolError as exc:
|
|
247
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
235
248
|
|
|
236
249
|
|
|
237
250
|
@api_router.post("/tools/list_dir")
|
|
@@ -249,11 +262,7 @@ def create_tools_router(
|
|
|
249
262
|
@api_router.post("/tools/read_file")
|
|
250
263
|
async def tools_read_file(req: ToolReadFileRequest, request: Request):
|
|
251
264
|
require_user(request)
|
|
252
|
-
|
|
253
|
-
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
254
|
-
"result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
|
|
255
|
-
except ToolError as exc:
|
|
256
|
-
raise HTTPException(status_code=400, detail=str(exc))
|
|
265
|
+
return _tool_response(read_file, req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)
|
|
257
266
|
|
|
258
267
|
|
|
259
268
|
@api_router.post("/tools/write_file")
|
|
@@ -265,11 +274,7 @@ def create_tools_router(
|
|
|
265
274
|
@api_router.post("/tools/edit_file")
|
|
266
275
|
async def tools_edit_file(req: ToolEditFileRequest, request: Request):
|
|
267
276
|
require_user(request)
|
|
268
|
-
|
|
269
|
-
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
270
|
-
"result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
|
|
271
|
-
except ToolError as exc:
|
|
272
|
-
raise HTTPException(status_code=400, detail=str(exc))
|
|
277
|
+
return _tool_response(edit_file, req.path, req.old_string, req.new_string, replace_all=req.replace_all)
|
|
273
278
|
|
|
274
279
|
|
|
275
280
|
@api_router.post("/tools/search_files")
|
|
@@ -281,18 +286,15 @@ def create_tools_router(
|
|
|
281
286
|
@api_router.post("/tools/grep")
|
|
282
287
|
async def tools_grep(req: ToolGrepRequest, request: Request):
|
|
283
288
|
require_user(request)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
)}
|
|
294
|
-
except ToolError as exc:
|
|
295
|
-
raise HTTPException(status_code=400, detail=str(exc))
|
|
289
|
+
return _tool_response(
|
|
290
|
+
grep,
|
|
291
|
+
req.pattern,
|
|
292
|
+
path=req.path,
|
|
293
|
+
glob=req.glob,
|
|
294
|
+
max_results=req.max_results,
|
|
295
|
+
case_insensitive=req.case_insensitive,
|
|
296
|
+
context_lines=req.context_lines,
|
|
297
|
+
)
|
|
296
298
|
|
|
297
299
|
|
|
298
300
|
@api_router.post("/tools/todo_read")
|
|
@@ -310,7 +312,7 @@ def create_tools_router(
|
|
|
310
312
|
@api_router.post("/tools/clear_history")
|
|
311
313
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
312
314
|
current_user = require_user(request)
|
|
313
|
-
result = clear_history(req.keep_last)
|
|
315
|
+
result = _dispatch("clear_history", {"keep_last": req.keep_last}, lambda: clear_history(req.keep_last))
|
|
314
316
|
append_audit_event(
|
|
315
317
|
"history_delete",
|
|
316
318
|
user_email=current_user,
|
|
@@ -461,6 +463,7 @@ def create_tools_router(
|
|
|
461
463
|
require_user=require_user,
|
|
462
464
|
tool_response=_tool_response,
|
|
463
465
|
save_to_history=save_to_history,
|
|
466
|
+
hooks=HOOKS,
|
|
464
467
|
))
|
|
465
468
|
|
|
466
469
|
@api_router.post("/tools/knowledge_save")
|
package/latticeai/core/config.py
CHANGED
|
@@ -86,6 +86,7 @@ class Config:
|
|
|
86
86
|
invite_code: str
|
|
87
87
|
invite_gate_enabled: bool
|
|
88
88
|
admin_emails: List[str]
|
|
89
|
+
trusted_proxies: List[str]
|
|
89
90
|
|
|
90
91
|
# ── models ──────────────────────────────────────────────────────
|
|
91
92
|
public_model: str
|
|
@@ -139,6 +140,7 @@ class Config:
|
|
|
139
140
|
|
|
140
141
|
cors_extra = [item.strip() for item in _value(env, "LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",") if item.strip()]
|
|
141
142
|
admin_emails = [item.strip().lower() for item in _value(env, "LATTICEAI_ADMIN_EMAILS", "").split(",") if item.strip()]
|
|
143
|
+
trusted_proxies = [item.strip() for item in _value(env, "LATTICEAI_TRUSTED_PROXIES", "").split(",") if item.strip()]
|
|
142
144
|
|
|
143
145
|
public_model = _value(env, "LATTICEAI_PUBLIC_MODEL", _value(env, "LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
|
|
144
146
|
local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12b-it-4bit")
|
|
@@ -170,6 +172,7 @@ class Config:
|
|
|
170
172
|
invite_code=_value(env, "LATTICEAI_INVITE_CODE", "gemma-lattice-ai"),
|
|
171
173
|
invite_gate_enabled=_bool(env, "LATTICEAI_INVITE_GATE_ENABLED", default=False),
|
|
172
174
|
admin_emails=admin_emails,
|
|
175
|
+
trusted_proxies=trusted_proxies,
|
|
173
176
|
public_model=public_model,
|
|
174
177
|
local_model=local_model,
|
|
175
178
|
local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
|
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "3.
|
|
17
|
+
MULTI_AGENT_VERSION = "3.6.0"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|