ltcai 3.4.0 → 3.5.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 +175 -225
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/chat.py +6 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/local_files.py +76 -10
- package/latticeai/api/tools.py +35 -35
- package/latticeai/core/agent.py +13 -2
- package/latticeai/core/builtin_hooks.py +106 -0
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/hooks.py +76 -2
- 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/workflow_engine.py +3 -3
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +22 -34
- package/latticeai/services/platform_runtime.py +18 -6
- package/latticeai/services/tool_dispatch.py +2 -0
- package/latticeai/services/upload_service.py +24 -4
- package/local_knowledge_api.py +27 -1
- package/package.json +3 -3
- package/requirements.txt +1 -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.c4acfdd8.js → app.d086489d.js} +1 -1
- package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
- package/static/v3/js/core/components.js +1 -1
- package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
- package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
- package/static/v3/js/views/hooks.js +12 -7
- package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
- package/static/v3/js/views/my-computer.js +7 -4
- 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/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="이메일을 확인할 수 없습니다.")
|
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
|
|
@@ -144,6 +145,7 @@ def create_chat_router(
|
|
|
144
145
|
knowledge_graph,
|
|
145
146
|
public_model: str,
|
|
146
147
|
base_dir: Path,
|
|
148
|
+
hooks=None,
|
|
147
149
|
) -> APIRouter:
|
|
148
150
|
api_router = APIRouter()
|
|
149
151
|
router = model_router
|
|
@@ -247,6 +249,7 @@ def create_chat_router(
|
|
|
247
249
|
clear_history=clear_history,
|
|
248
250
|
knowledge_save=knowledge_save,
|
|
249
251
|
audit=append_audit_event,
|
|
252
|
+
hooks=hooks,
|
|
250
253
|
)
|
|
251
254
|
|
|
252
255
|
@api_router.post("/chat")
|
|
@@ -651,7 +654,9 @@ def create_chat_router(
|
|
|
651
654
|
for case in eval_cases:
|
|
652
655
|
case_id = case.get("id", "?")
|
|
653
656
|
try:
|
|
654
|
-
|
|
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")
|
|
655
660
|
criteria = case.get("pass_criteria", "")
|
|
656
661
|
if "success == true" in criteria:
|
|
657
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})
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import platform
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
6
10
|
from pathlib import Path
|
|
7
11
|
from typing import Optional
|
|
8
12
|
|
|
@@ -14,6 +18,11 @@ from knowledge_graph_api import create_knowledge_graph_router
|
|
|
14
18
|
from local_knowledge_api import create_local_knowledge_router
|
|
15
19
|
from tools import local_list, local_read, local_write
|
|
16
20
|
|
|
21
|
+
try:
|
|
22
|
+
from latticeai import __version__ as _LATTICE_VERSION
|
|
23
|
+
except Exception: # pragma: no cover - defensive
|
|
24
|
+
_LATTICE_VERSION = "unknown"
|
|
25
|
+
|
|
17
26
|
|
|
18
27
|
class LocalAccessRequest(BaseModel):
|
|
19
28
|
path: str
|
|
@@ -37,47 +46,103 @@ def create_local_files_router(
|
|
|
37
46
|
require_graph,
|
|
38
47
|
static_dir: Path,
|
|
39
48
|
local_kg_watcher,
|
|
49
|
+
hooks=None,
|
|
50
|
+
data_dir: Optional[Path] = None,
|
|
40
51
|
) -> APIRouter:
|
|
41
52
|
router = APIRouter()
|
|
42
53
|
|
|
43
54
|
@router.get("/api/local-agent/status")
|
|
44
55
|
async def local_agent_status(request: Request):
|
|
45
56
|
"""Real on-device runtime status — the 'Local Agent' is the Lattice
|
|
46
|
-
server running on this machine
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
server running on this machine. Every field below is *probed*, not
|
|
58
|
+
hardcoded: filesystem access is a real write/read/delete; the graph and
|
|
59
|
+
watcher are reached live; the mode/handshake are derived from those
|
|
60
|
+
probes. A failing subsystem yields ``degraded``/``error`` honestly.
|
|
61
|
+
|
|
62
|
+
Allowed modes: offline · starting · online · degraded · error.
|
|
63
|
+
"""
|
|
49
64
|
require_user(request)
|
|
65
|
+
started = time.perf_counter()
|
|
66
|
+
errors = []
|
|
67
|
+
|
|
68
|
+
# ── filesystem capability: a real write → read → delete probe ─────────
|
|
69
|
+
fs_ok = False
|
|
70
|
+
probe_dir = Path(data_dir) if data_dir else Path(static_dir).parent
|
|
71
|
+
try:
|
|
72
|
+
probe_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
probe = probe_dir / f".local_agent_probe_{uuid.uuid4().hex}"
|
|
74
|
+
token = uuid.uuid4().hex
|
|
75
|
+
probe.write_text(token, encoding="utf-8")
|
|
76
|
+
fs_ok = probe.read_text(encoding="utf-8") == token
|
|
77
|
+
probe.unlink()
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
errors.append(f"filesystem: {exc}")
|
|
80
|
+
|
|
81
|
+
# ── graph subsystem reachability (real call) ──────────────────────────
|
|
82
|
+
graph_reachable = None
|
|
83
|
+
if knowledge_graph is not None:
|
|
84
|
+
try:
|
|
85
|
+
knowledge_graph.stats()
|
|
86
|
+
graph_reachable = True
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
graph_reachable = False
|
|
89
|
+
errors.append(f"graph: {exc}")
|
|
90
|
+
|
|
91
|
+
# ── watcher + connected sources (real) ────────────────────────────────
|
|
50
92
|
watch = local_kg_watcher.status() if local_kg_watcher else {"available": False, "active": {}}
|
|
51
93
|
sources = []
|
|
52
94
|
try:
|
|
53
95
|
if knowledge_graph is not None:
|
|
54
96
|
sources = (knowledge_graph.local_sources() or {}).get("sources", [])
|
|
55
|
-
except Exception:
|
|
56
|
-
sources
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
errors.append(f"sources: {exc}")
|
|
57
99
|
watched = len(watch.get("active", {}) or {})
|
|
100
|
+
|
|
101
|
+
# ── derive mode + handshake from the probes (no constants) ────────────
|
|
102
|
+
if not fs_ok:
|
|
103
|
+
mode = "error"
|
|
104
|
+
elif graph_reachable is False:
|
|
105
|
+
mode = "degraded"
|
|
106
|
+
else:
|
|
107
|
+
mode = "online"
|
|
108
|
+
handshake_ok = fs_ok and graph_reachable is not False
|
|
109
|
+
latency_ms = round((time.perf_counter() - started) * 1000, 2)
|
|
110
|
+
|
|
58
111
|
return {
|
|
59
112
|
"agent": {
|
|
60
113
|
"id": "lattice-local-runtime",
|
|
61
114
|
"name": "Lattice Local Agent",
|
|
62
115
|
"kind": "on-device-runtime",
|
|
63
|
-
"online":
|
|
116
|
+
"online": mode == "online",
|
|
64
117
|
"platform": platform.platform(),
|
|
65
118
|
"machine": platform.machine(),
|
|
66
119
|
"python": platform.python_version(),
|
|
67
120
|
},
|
|
121
|
+
"online": mode == "online",
|
|
122
|
+
"mode": mode,
|
|
123
|
+
"version": _LATTICE_VERSION,
|
|
124
|
+
"pid": os.getpid(),
|
|
68
125
|
"handshake": {
|
|
69
|
-
"ok":
|
|
126
|
+
"ok": handshake_ok,
|
|
70
127
|
"transport": "in-process",
|
|
71
|
-
"
|
|
128
|
+
"latency_ms": latency_ms,
|
|
129
|
+
"detail": "Probed the in-process runtime (filesystem + graph); the local Lattice server is the on-device agent — no separate desktop process.",
|
|
72
130
|
},
|
|
73
131
|
"health": {
|
|
74
|
-
"status":
|
|
75
|
-
"filesystem_access":
|
|
132
|
+
"status": mode,
|
|
133
|
+
"filesystem_access": fs_ok,
|
|
134
|
+
"graph_reachable": graph_reachable,
|
|
76
135
|
"watcher_available": bool(watch.get("available")),
|
|
77
136
|
},
|
|
137
|
+
"filesystem_access": fs_ok,
|
|
138
|
+
"watcher_available": bool(watch.get("available")),
|
|
139
|
+
"connected_folders": len(sources),
|
|
140
|
+
"watched_folders": watched,
|
|
78
141
|
"folders": {"connected": len(sources), "watching": watched},
|
|
79
142
|
"watch": watch,
|
|
80
143
|
"sources": sources,
|
|
144
|
+
"last_seen": datetime.now().isoformat(timespec="seconds"),
|
|
145
|
+
"error": "; ".join(errors) if errors else None,
|
|
81
146
|
}
|
|
82
147
|
|
|
83
148
|
@router.post("/local/list")
|
|
@@ -157,6 +222,7 @@ def create_local_files_router(
|
|
|
157
222
|
local_permission_response=permission_gateway.local_permission_response,
|
|
158
223
|
require_local_approval=permission_gateway.require_local_approval,
|
|
159
224
|
watcher=local_kg_watcher,
|
|
225
|
+
hooks=hooks,
|
|
160
226
|
)
|
|
161
227
|
)
|
|
162
228
|
|
package/latticeai/api/tools.py
CHANGED
|
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
|
|
15
15
|
|
|
16
16
|
from latticeai.api.computer_use import create_computer_use_router
|
|
17
17
|
from latticeai.api.local_files import create_local_files_router
|
|
18
|
+
from latticeai.core.hooks import dispatch_tool
|
|
18
19
|
from latticeai.api.mcp import create_mcp_router
|
|
19
20
|
from latticeai.api.permissions import create_permissions_router
|
|
20
21
|
from latticeai.services.upload_service import process_uploaded_document
|
|
@@ -220,23 +221,30 @@ def create_tools_router(
|
|
|
220
221
|
|
|
221
222
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
222
223
|
|
|
223
|
-
def _tool_response(fn, *args):
|
|
224
|
+
def _tool_response(fn, *args, **kwargs):
|
|
225
|
+
# Shared tool lifecycle (same path as the agent + workflow tool calls):
|
|
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.
|
|
224
230
|
tool_name = getattr(fn, "__name__", "tool")
|
|
225
|
-
# ── pre_tool hooks ── a blocking pre_tool hook (e.g. a permission gate)
|
|
226
|
-
# stops the tool call before it runs.
|
|
227
|
-
if HOOKS is not None:
|
|
228
|
-
pre = HOOKS.fire_hook("pre_tool", f"tool.{tool_name}", payload={"tool": tool_name, "argc": len(args)})
|
|
229
|
-
if pre.get("blocked"):
|
|
230
|
-
raise HTTPException(status_code=403, detail=pre.get("block_reason") or f"Tool '{tool_name}' blocked by a pre_tool hook.")
|
|
231
231
|
try:
|
|
232
|
-
result = fn(*args)
|
|
232
|
+
result = dispatch_tool(HOOKS, tool_name, dict(kwargs), lambda: fn(*args, **kwargs), source="http")
|
|
233
|
+
except PermissionError as exc:
|
|
234
|
+
raise HTTPException(status_code=403, detail=str(exc))
|
|
233
235
|
except ToolError as exc:
|
|
234
|
-
if HOOKS is not None:
|
|
235
|
-
HOOKS.fire_hook("post_tool", f"tool.{tool_name}", payload={"tool": tool_name, "status": "error", "detail": str(exc)})
|
|
236
236
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
237
|
-
if HOOKS is not None:
|
|
238
|
-
HOOKS.fire_hook("post_tool", f"tool.{tool_name}", payload={"tool": tool_name, "status": "ok"})
|
|
239
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))
|
|
240
248
|
|
|
241
249
|
|
|
242
250
|
@api_router.post("/tools/list_dir")
|
|
@@ -254,11 +262,7 @@ def create_tools_router(
|
|
|
254
262
|
@api_router.post("/tools/read_file")
|
|
255
263
|
async def tools_read_file(req: ToolReadFileRequest, request: Request):
|
|
256
264
|
require_user(request)
|
|
257
|
-
|
|
258
|
-
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
259
|
-
"result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
|
|
260
|
-
except ToolError as exc:
|
|
261
|
-
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)
|
|
262
266
|
|
|
263
267
|
|
|
264
268
|
@api_router.post("/tools/write_file")
|
|
@@ -270,11 +274,7 @@ def create_tools_router(
|
|
|
270
274
|
@api_router.post("/tools/edit_file")
|
|
271
275
|
async def tools_edit_file(req: ToolEditFileRequest, request: Request):
|
|
272
276
|
require_user(request)
|
|
273
|
-
|
|
274
|
-
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
275
|
-
"result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
|
|
276
|
-
except ToolError as exc:
|
|
277
|
-
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)
|
|
278
278
|
|
|
279
279
|
|
|
280
280
|
@api_router.post("/tools/search_files")
|
|
@@ -286,18 +286,15 @@ def create_tools_router(
|
|
|
286
286
|
@api_router.post("/tools/grep")
|
|
287
287
|
async def tools_grep(req: ToolGrepRequest, request: Request):
|
|
288
288
|
require_user(request)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)}
|
|
299
|
-
except ToolError as exc:
|
|
300
|
-
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
|
+
)
|
|
301
298
|
|
|
302
299
|
|
|
303
300
|
@api_router.post("/tools/todo_read")
|
|
@@ -315,7 +312,7 @@ def create_tools_router(
|
|
|
315
312
|
@api_router.post("/tools/clear_history")
|
|
316
313
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
317
314
|
current_user = require_user(request)
|
|
318
|
-
result = clear_history(req.keep_last)
|
|
315
|
+
result = _dispatch("clear_history", {"keep_last": req.keep_last}, lambda: clear_history(req.keep_last))
|
|
319
316
|
append_audit_event(
|
|
320
317
|
"history_delete",
|
|
321
318
|
user_email=current_user,
|
|
@@ -458,12 +455,15 @@ def create_tools_router(
|
|
|
458
455
|
require_graph=_require_graph,
|
|
459
456
|
static_dir=STATIC_DIR,
|
|
460
457
|
local_kg_watcher=LOCAL_KG_WATCHER,
|
|
458
|
+
hooks=HOOKS,
|
|
459
|
+
data_dir=DATA_DIR,
|
|
461
460
|
))
|
|
462
461
|
api_router.include_router(create_computer_use_router(
|
|
463
462
|
model_router=router,
|
|
464
463
|
require_user=require_user,
|
|
465
464
|
tool_response=_tool_response,
|
|
466
465
|
save_to_history=save_to_history,
|
|
466
|
+
hooks=HOOKS,
|
|
467
467
|
))
|
|
468
468
|
|
|
469
469
|
@api_router.post("/tools/knowledge_save")
|
package/latticeai/core/agent.py
CHANGED
|
@@ -28,6 +28,7 @@ from enum import Enum
|
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional
|
|
30
30
|
|
|
31
|
+
from latticeai.core.hooks import dispatch_tool
|
|
31
32
|
from tools import ToolError
|
|
32
33
|
|
|
33
34
|
|
|
@@ -122,6 +123,11 @@ class AgentDeps:
|
|
|
122
123
|
memory_updater_prompt: str
|
|
123
124
|
agent_root: Path
|
|
124
125
|
|
|
126
|
+
# ── lifecycle hooks port (optional) ──────────────────────────────
|
|
127
|
+
# When present, every tool execution fires the shared pre_tool/post_tool
|
|
128
|
+
# lifecycle, so the agent tool path no longer bypasses hooks.
|
|
129
|
+
hooks: Any = None
|
|
130
|
+
|
|
125
131
|
|
|
126
132
|
class AgentRuntime:
|
|
127
133
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -289,13 +295,18 @@ class AgentRuntime:
|
|
|
289
295
|
|
|
290
296
|
try:
|
|
291
297
|
d.check_role(name, current_user)
|
|
292
|
-
|
|
298
|
+
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
299
|
+
result = dispatch_tool(
|
|
300
|
+
d.hooks, name, args,
|
|
301
|
+
lambda: d.execute_tool(name, args),
|
|
302
|
+
user_email=current_user, source="agent",
|
|
303
|
+
)
|
|
293
304
|
ctx.transcript.append({
|
|
294
305
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
295
306
|
"thoughts": thoughts, "args": args,
|
|
296
307
|
"risk": risk, "governance": dict(policy), "result": result,
|
|
297
308
|
})
|
|
298
|
-
except (ToolError, KeyError, TypeError) as exc:
|
|
309
|
+
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
299
310
|
ctx.transcript.append({
|
|
300
311
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
301
312
|
"thoughts": thoughts, "args": args,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Real runners for the built-in lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
Each built-in hook in :data:`latticeai.core.hooks.BUILTIN_HOOKS` is bound here to
|
|
4
|
+
an actual callable so dispatch performs real platform work rather than a silent
|
|
5
|
+
no-op. Kept out of ``server_app`` to keep the assembly file lean; ``server_app``
|
|
6
|
+
calls :func:`register_builtin_hook_runners` once with the platform dependencies.
|
|
7
|
+
|
|
8
|
+
A runner receives a :class:`~latticeai.core.hooks.HookContext` and returns a
|
|
9
|
+
status dict (``{status, output, block?, detail?}``). It may mutate the context
|
|
10
|
+
payload (e.g. redaction) or call ``context.block()`` to gate a ``pre_*`` action.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
_SECRET_KEY_HINTS = (
|
|
18
|
+
"token", "password", "passwd", "secret", "api_key", "apikey",
|
|
19
|
+
"authorization", "auth", "cookie", "session", "private_key",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register_builtin_hook_runners(
|
|
24
|
+
registry: Any,
|
|
25
|
+
*,
|
|
26
|
+
append_audit_event: Callable[..., None],
|
|
27
|
+
get_tool_permission: Callable[..., dict],
|
|
28
|
+
classify_sensitive_message: Callable[..., dict],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Bind a real runner to every built-in hook on ``registry``."""
|
|
31
|
+
|
|
32
|
+
def redact_secrets(context):
|
|
33
|
+
"""pre_run — strip secret-like keys from the agent context packet."""
|
|
34
|
+
payload = context.payload if isinstance(context.payload, dict) else {}
|
|
35
|
+
redacted = []
|
|
36
|
+
for key in list(payload.keys()):
|
|
37
|
+
if any(s in str(key).lower() for s in _SECRET_KEY_HINTS):
|
|
38
|
+
payload[key] = "***redacted***"
|
|
39
|
+
redacted.append(key)
|
|
40
|
+
return {"status": "ok", "output": f"redacted {len(redacted)} field(s)" if redacted else "no secrets present"}
|
|
41
|
+
|
|
42
|
+
def audit_agent_run(context):
|
|
43
|
+
"""post_run — append the completed agent run to the workspace audit log."""
|
|
44
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
45
|
+
append_audit_event(
|
|
46
|
+
"hook_post_run", user_email=context.user_email,
|
|
47
|
+
run_id=p.get("run_id"), agent_id=p.get("agent_id"), status=p.get("status"),
|
|
48
|
+
)
|
|
49
|
+
return {"status": "ok", "output": f"audited run {p.get('run_id') or ''}".strip()}
|
|
50
|
+
|
|
51
|
+
def pipeline_index_status(context):
|
|
52
|
+
"""post_index — record ingest/embed/graph-build pipeline state."""
|
|
53
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
54
|
+
return {"status": "ok", "output": f"pipeline {context.event}: indexed={p.get('indexed')}"}
|
|
55
|
+
|
|
56
|
+
def research_memory_snapshot(context):
|
|
57
|
+
"""agent — record that a short-term memory snapshot was captured."""
|
|
58
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
59
|
+
n = p.get("context_items")
|
|
60
|
+
return {"status": "ok", "output": f"memory snapshot recorded ({n if n is not None else '0'} context items)"}
|
|
61
|
+
|
|
62
|
+
def tool_permission_gate(context):
|
|
63
|
+
"""pre_tool — evaluate the real governance policy for the tool and record it.
|
|
64
|
+
|
|
65
|
+
Enforcement (admin-only gating, approval tokens) stays in the tool
|
|
66
|
+
dispatcher; this surfaces the policy into the run log and only blocks when
|
|
67
|
+
the governance policy itself marks the tool denied.
|
|
68
|
+
"""
|
|
69
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
70
|
+
tool = p.get("tool") or ""
|
|
71
|
+
try:
|
|
72
|
+
perm = dict(get_tool_permission(tool))
|
|
73
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
74
|
+
return {"status": "ok", "output": f"policy unavailable for '{tool}': {exc}"}
|
|
75
|
+
if perm.get("policy") == "deny" or perm.get("risk") == "deny":
|
|
76
|
+
return {"status": "blocked", "block": True, "detail": f"governance policy denies '{tool}'"}
|
|
77
|
+
return {"status": "ok", "output": f"policy[{tool}]: risk={perm.get('risk')} approval={perm.get('requires_approval')}"}
|
|
78
|
+
|
|
79
|
+
def sensitive_data_guard(context):
|
|
80
|
+
"""pre_tool — classify the outgoing tool payload for sensitive data."""
|
|
81
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
82
|
+
content = " ".join(str(v) for v in p.values() if isinstance(v, (str, int, float)))
|
|
83
|
+
try:
|
|
84
|
+
verdict = classify_sensitive_message(
|
|
85
|
+
{"role": "tool", "content": content, "user_email": context.user_email}, -1
|
|
86
|
+
)
|
|
87
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
88
|
+
return {"status": "ok", "output": f"classifier unavailable: {exc}"}
|
|
89
|
+
labels = verdict.get("labels") or []
|
|
90
|
+
return {"status": "ok", "output": f"sensitivity={verdict.get('sensitivity')} labels={','.join(labels) if labels else 'none'}"}
|
|
91
|
+
|
|
92
|
+
def workflow_replay_log(context):
|
|
93
|
+
"""post_workflow — record the workflow run so it can be replayed."""
|
|
94
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
95
|
+
return {"status": "ok", "output": f"workflow {p.get('workflow_id') or '?'} -> {p.get('status') or 'recorded'} ({p.get('steps', '?')} steps)"}
|
|
96
|
+
|
|
97
|
+
registry.register_hook("builtin:redact-secrets", redact_secrets)
|
|
98
|
+
registry.register_hook("builtin:audit-agent-run", audit_agent_run)
|
|
99
|
+
registry.register_hook("builtin:pipeline-index-status", pipeline_index_status)
|
|
100
|
+
registry.register_hook("builtin:research-memory-snapshot", research_memory_snapshot)
|
|
101
|
+
registry.register_hook("builtin:tool-permission-gate", tool_permission_gate)
|
|
102
|
+
registry.register_hook("builtin:sensitive-data-guard", sensitive_data_guard)
|
|
103
|
+
registry.register_hook("builtin:workflow-replay-log", workflow_replay_log)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = ["register_builtin_hook_runners"]
|