ltcai 8.9.0 → 9.0.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 (89) hide show
  1. package/README.md +28 -39
  2. package/auto_setup.py +11 -62
  3. package/docs/CHANGELOG.md +76 -237
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +2 -2
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +4 -9
  14. package/lattice_brain/embeddings.py +38 -2
  15. package/lattice_brain/graph/_kg_common.py +27 -462
  16. package/lattice_brain/graph/_kg_constants.py +243 -0
  17. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  18. package/lattice_brain/graph/discovery.py +0 -948
  19. package/lattice_brain/graph/discovery_index.py +972 -0
  20. package/lattice_brain/graph/retrieval.py +0 -570
  21. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  22. package/lattice_brain/graph/retrieval_vector.py +460 -0
  23. package/lattice_brain/graph/store.py +6 -0
  24. package/lattice_brain/ingestion.py +68 -0
  25. package/lattice_brain/portability.py +1 -9
  26. package/lattice_brain/quality.py +98 -4
  27. package/lattice_brain/runtime/agent_runtime.py +166 -0
  28. package/lattice_brain/runtime/multi_agent.py +1 -1
  29. package/lattice_brain/utils.py +28 -0
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/chat.py +340 -407
  32. package/latticeai/api/chat_helpers.py +227 -0
  33. package/latticeai/api/computer_use.py +149 -31
  34. package/latticeai/api/marketplace.py +11 -0
  35. package/latticeai/api/permissions.py +3 -3
  36. package/latticeai/api/tools.py +1 -0
  37. package/latticeai/app_factory.py +82 -360
  38. package/latticeai/core/io_utils.py +37 -0
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/local_embeddings.py +2 -4
  41. package/latticeai/core/marketplace.py +33 -2
  42. package/latticeai/core/mcp_catalog.py +450 -0
  43. package/latticeai/core/mcp_registry.py +2 -441
  44. package/latticeai/core/users.py +4 -9
  45. package/latticeai/core/workspace_os.py +1 -1
  46. package/latticeai/core/workspace_os_utils.py +3 -17
  47. package/latticeai/integrations/telegram_bot.py +7 -2
  48. package/latticeai/models/model_providers.py +111 -0
  49. package/latticeai/models/router.py +58 -136
  50. package/latticeai/runtime/audit_runtime.py +27 -16
  51. package/latticeai/runtime/automation_runtime.py +9 -0
  52. package/latticeai/runtime/history_runtime.py +163 -0
  53. package/latticeai/runtime/namespace_runtime.py +173 -0
  54. package/latticeai/runtime/network_config_runtime.py +56 -0
  55. package/latticeai/runtime/sso_config_runtime.py +128 -0
  56. package/latticeai/runtime/user_key_runtime.py +106 -0
  57. package/latticeai/services/architecture_readiness.py +2 -2
  58. package/latticeai/services/memory_service.py +213 -0
  59. package/latticeai/services/platform_runtime.py +9 -1
  60. package/latticeai/services/product_readiness.py +11 -11
  61. package/latticeai/services/review_queue.py +64 -11
  62. package/latticeai/services/run_executor.py +21 -0
  63. package/latticeai/services/setup_detection.py +80 -0
  64. package/latticeai/services/tool_dispatch.py +1 -1
  65. package/package.json +1 -1
  66. package/setup_wizard.py +11 -55
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +11 -11
  71. package/static/app/assets/{Act-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
  72. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  73. package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
  74. package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
  75. package/static/app/assets/System-K6krGCqn.js +1 -0
  76. package/static/app/assets/index-C4R3ws30.js +17 -0
  77. package/static/app/assets/index-ChSeOB02.css +2 -0
  78. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  79. package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
  80. package/static/app/index.html +2 -2
  81. package/static/css/tokens.css +4 -2
  82. package/static/sw.js +1 -1
  83. package/tools/local_files.py +6 -0
  84. package/latticeai/runtime/sso_runtime.py +0 -52
  85. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  86. package/static/app/assets/System-VbChmX7r.js +0 -1
  87. package/static/app/assets/index-DCh5AoXt.css +0 -2
  88. package/static/app/assets/index-DPdcPoF0.js +0 -17
  89. package/static/app/assets/primitives-DFeanEV6.js +0 -1
@@ -0,0 +1,227 @@
1
+ """Pure chat helpers: language/intent detection, file-action parsing,
2
+ network-status formatting, workspace-scope extraction, and recent-context
3
+ assembly. Split out of the chat router module so create_chat_router keeps
4
+ only request wiring. chat.py re-imports every name, so external import
5
+ sites (tests, app_factory) are unaffected.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from typing import AsyncIterator, Dict, List, Optional
13
+
14
+ from fastapi import Request
15
+
16
+
17
+ def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
18
+ """Restrict history to one user's exchange.
19
+
20
+ Keeps the user's own messages plus assistant replies that directly follow
21
+ them. A bare role=="assistant" pass would leak every other user's replies
22
+ into this user's prompt context.
23
+ """
24
+ paired: List[Dict] = []
25
+ include_next_assistant = False
26
+ for item in history:
27
+ if item.get("role") == "assistant":
28
+ if include_next_assistant:
29
+ paired.append(item)
30
+ include_next_assistant = False
31
+ elif item.get("user_email") == user_email:
32
+ paired.append(item)
33
+ include_next_assistant = True
34
+ else:
35
+ include_next_assistant = False
36
+ return paired
37
+
38
+
39
+ def detect_language(text: str) -> str:
40
+ """Detect language: 'ko' (Korean) or 'en' (English)."""
41
+ total = max(len(text), 1)
42
+ ko = sum(1 for c in text if '가' <= c <= '힣')
43
+ if ko / total > 0.05:
44
+ return "ko"
45
+ return "en"
46
+
47
+ _LANG_HINT = {
48
+ "ko": "Respond in Korean (한국어로 답변하세요).",
49
+ "en": "Respond in English.",
50
+ }
51
+
52
+ def is_network_status_request(text: str) -> bool:
53
+ """사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
54
+ t = (text or "").lower()
55
+ explicit_network = any(
56
+ phrase in t
57
+ for phrase in (
58
+ "ipconfig",
59
+ "ifconfig",
60
+ "network status",
61
+ "네트워크 상태",
62
+ "네트워크 확인",
63
+ "현재 네트워크",
64
+ )
65
+ )
66
+ current_ip = bool(
67
+ re.search(
68
+ r"(내|현재|지금|로컬|local|public|공인|외부|내부)\s*(ip|아이피)\s*(주소)?",
69
+ t,
70
+ )
71
+ or re.search(
72
+ r"(ip|아이피)\s*(주소)?\s*(확인|상태|알려줘|보여줘)",
73
+ t,
74
+ )
75
+ )
76
+ return explicit_network or current_ip
77
+
78
+ def is_current_url_request(text: str) -> bool:
79
+ t = (text or "").lower()
80
+ explicit_url = any(phrase in t for phrase in ("현재 url", "current url", "page url", "페이지 url"))
81
+ current_page_link = bool(
82
+ re.search(r"(현재|지금|여기|이\s*페이지|브라우저|접속)\s*(페이지\s*)?(url|링크|주소)", t)
83
+ or re.search(r"(url|링크)\s*(알려줘|보여줘|확인)", t)
84
+ )
85
+ return explicit_url or current_page_link
86
+
87
+ def is_clear_command(text: str) -> bool:
88
+ return (text or "").strip().lower() in {"/clear", "/clear_all"}
89
+
90
+ # Path segments intentionally exclude spaces: allowing spaces let the target
91
+ # match swallow preceding words (e.g. "create a text file report.txt" resolved
92
+ # to the whole phrase as the path). Chat file targets are single tokens.
93
+ _FILE_TARGET_RE = re.compile(
94
+ r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
95
+ r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
96
+ re.IGNORECASE,
97
+ )
98
+
99
+
100
+ def is_file_action_request(text: str) -> bool:
101
+ """Return True for chat requests that should execute file tools, not prose.
102
+
103
+ The Brain chat composer posts to ``/chat``. Without this gate, explicit
104
+ side-effect requests such as "create hello.md" are handled as plain model
105
+ generation, which commonly produces a code block instead of a real file.
106
+ Keep the gate narrow so normal Q&A and "how do I create a file?" stay in
107
+ ordinary chat.
108
+ """
109
+ raw = (text or "").strip()
110
+ if not raw:
111
+ return False
112
+ lower = raw.lower()
113
+
114
+ if any(phrase in lower for phrase in (
115
+ "how to ", "how do i ", "방법", "어떻게", "예시", "sample", "example",
116
+ )) and not any(phrase in lower for phrase in (
117
+ "actually create", "real file", "실제로", "파일로", "저장해", "만들어",
118
+ )):
119
+ return False
120
+
121
+ has_target = bool(_FILE_TARGET_RE.search(raw))
122
+ has_file_word = any(word in lower for word in (
123
+ "file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
124
+ ))
125
+ has_action = any(word in lower for word in (
126
+ "create", "make", "write", "save", "generate", "edit", "update",
127
+ "만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
128
+ ))
129
+
130
+ if not has_action:
131
+ return False
132
+ if has_target and has_action:
133
+ return True
134
+ return has_file_word and has_action
135
+
136
+
137
+ def file_action_target(text: str) -> Optional[str]:
138
+ """Extract the first explicit workspace file target from a request."""
139
+ match = _FILE_TARGET_RE.search(text or "")
140
+ if not match:
141
+ return None
142
+ return match.group(0).strip().strip("`'\".,:;)]}")
143
+
144
+
145
+ def inline_file_action_content(text: str) -> Optional[str]:
146
+ """Extract short user-provided content for deterministic file writes."""
147
+ raw = (text or "").strip()
148
+ # Each pattern requires an explicit binder so ambiguous words like "text"
149
+ # ("create a text file report.txt") or a bare "with" ("report.md with a
150
+ # summary of X") do not get captured as literal file content.
151
+ patterns = [
152
+ r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
153
+ r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
154
+ r"(?:content|body)\s+(?:is|as)\s+(.+)$",
155
+ r"(?:with the content|with content|containing)\s+(.+)$",
156
+ ]
157
+ for pattern in patterns:
158
+ match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
159
+ if match:
160
+ content = match.group(1).strip()
161
+ return content.strip("`'\"")
162
+ return None
163
+
164
+
165
+ def strip_generated_file_content(text: str) -> str:
166
+ """Remove common chat wrappers when a model is asked for file content only."""
167
+ content = (text or "").strip()
168
+ fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
169
+ if fenced:
170
+ content = fenced.group(1).strip()
171
+ return content
172
+
173
+
174
+ def format_network_status(info: Dict) -> str:
175
+ lines = [
176
+ f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
177
+ f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
178
+ f"호스트명: {info.get('hostname') or '확인 안 됨'}",
179
+ ]
180
+ local_ips = info.get("local_ips") or {}
181
+ if local_ips:
182
+ lines.extend(["", "인터페이스:"])
183
+ lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
184
+ note = info.get("note")
185
+ if note:
186
+ lines.extend(["", note])
187
+ return "\n".join(lines)
188
+
189
+ def workspace_scope_from_request(request: Request) -> Optional[str]:
190
+ header = request.headers.get("X-Workspace-Id")
191
+ if header and header.strip():
192
+ return header.strip()
193
+ query = request.query_params.get("workspace_id")
194
+ return query.strip() if query and query.strip() else None
195
+
196
+ async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
197
+ yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
198
+ yield "data: [DONE]\n\n"
199
+
200
+
201
+ def build_recent_chat_context(
202
+ *,
203
+ get_history,
204
+ limit: int = 10,
205
+ include_image_missing_replies: bool = True,
206
+ user_email: Optional[str] = None,
207
+ conversation_id: Optional[str] = None,
208
+ ) -> str:
209
+ history = get_history()
210
+ if conversation_id:
211
+ history = [item for item in history if item.get("conversation_id") == conversation_id]
212
+ if user_email:
213
+ history = pair_user_history(history, user_email)
214
+ history = history[-limit:]
215
+ lines = []
216
+ for item in history:
217
+ role = item.get("role", "user")
218
+ content = item.get("content", "")
219
+ if not include_image_missing_replies and role == "assistant":
220
+ if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
221
+ continue
222
+ source = item.get("source")
223
+ label = role
224
+ if source:
225
+ label = f"{role} ({source})"
226
+ lines.append(f"{label}: {content}")
227
+ return "\n".join(lines)
@@ -12,7 +12,9 @@ from pydantic import BaseModel
12
12
 
13
13
  from latticeai.core.agent import extract_action as _extract_agent_action
14
14
  from lattice_brain.runtime.hooks import dispatch_tool
15
+ from latticeai.services.tool_dispatch import enforce_tool_policy
15
16
  from tools import (
17
+ AGENT_ROOT,
16
18
  ToolError,
17
19
  computer_click,
18
20
  computer_drag,
@@ -108,14 +110,64 @@ class CuDragRequest(BaseModel):
108
110
  y2: int
109
111
 
110
112
 
111
- def create_computer_use_router(*, model_router, require_user, tool_response, save_to_history, hooks=None) -> APIRouter:
113
+ def create_computer_use_router(
114
+ *,
115
+ model_router,
116
+ require_user,
117
+ tool_response,
118
+ save_to_history,
119
+ hooks=None,
120
+ append_audit_event=None,
121
+ ) -> APIRouter:
112
122
  router = APIRouter()
113
123
 
114
- def _dispatch(name, args, fn):
124
+ def _audit_args(name: str, args: dict) -> dict:
125
+ """Return audit-safe argument metadata without storing typed text."""
126
+ args = dict(args or {})
127
+ if name == "computer_type":
128
+ return {"text_length": len(str(args.get("text") or "")), "interval": args.get("interval")}
129
+ if name == "vision_analyze":
130
+ return {
131
+ "image_b64_length": len(str(args.get("image_b64") or "")),
132
+ "prompt_length": len(str(args.get("prompt") or "")),
133
+ }
134
+ return args
135
+
136
+ def _audit(name: str, args: dict, *, current_user: str, status: str, error: Optional[str] = None) -> None:
137
+ if append_audit_event is None:
138
+ return
139
+ payload = {
140
+ "user_email": current_user,
141
+ "source": "computer_use",
142
+ "tool": name,
143
+ "status": status,
144
+ "args": _audit_args(name, args),
145
+ }
146
+ if error:
147
+ payload["error"] = error[:500]
148
+ append_audit_event("computer_use_tool", **payload)
149
+
150
+ def _dispatch(name, args, fn, *, current_user: str):
115
151
  # Run a computer-use action through the unified pre_tool/post_tool
116
- # lifecycle. With hooks=None this is a transparent pass-through, so the
117
- # behaviour is unchanged when hooks are absent.
118
- return dispatch_tool(hooks, name, dict(args or {}), fn, source="computer_use")
152
+ # lifecycle after the same ToolRegistry policy gate used by direct
153
+ # /tools execution. This keeps desktop control behind one governance
154
+ # boundary instead of relying on route-specific checks.
155
+ args = dict(args or {})
156
+ try:
157
+ enforce_tool_policy(name, args, current_user=current_user, source="computer_use")
158
+ except HTTPException as exc:
159
+ _audit(name, args, current_user=current_user, status="blocked", error=str(exc.detail))
160
+ raise
161
+ try:
162
+ result = dispatch_tool(hooks, name, args, fn, source="computer_use")
163
+ except (PermissionError, ToolError, KeyError, TypeError) as exc:
164
+ _audit(name, args, current_user=current_user, status="error", error=str(exc))
165
+ raise
166
+ _audit(name, args, current_user=current_user, status="ok")
167
+ return result
168
+
169
+ def _response(result):
170
+ return {"status": "ok", "workspace": str(AGENT_ROOT), "result": result}
119
171
 
120
172
  @router.get("/tools/chrome_status")
121
173
  async def tools_chrome_status(request: Request):
@@ -129,9 +181,11 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
129
181
 
130
182
  @router.get("/cu/status")
131
183
  async def cu_status(request: Request):
132
- require_user(request)
184
+ current_user = require_user(request)
133
185
  try:
134
- return _dispatch("computer_status", {}, computer_status)
186
+ return _dispatch("computer_status", {}, computer_status, current_user=current_user)
187
+ except HTTPException:
188
+ raise
135
189
  except PermissionError as exc:
136
190
  raise HTTPException(status_code=403, detail=str(exc))
137
191
  except ToolError as exc:
@@ -139,9 +193,11 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
139
193
 
140
194
  @router.get("/cu/screenshot")
141
195
  async def cu_screenshot(request: Request):
142
- require_user(request)
196
+ current_user = require_user(request)
143
197
  try:
144
- return _dispatch("computer_screenshot", {}, computer_screenshot)
198
+ return _dispatch("computer_screenshot", {}, computer_screenshot, current_user=current_user)
199
+ except HTTPException:
200
+ raise
145
201
  except PermissionError as exc:
146
202
  raise HTTPException(status_code=403, detail=str(exc))
147
203
  except ToolError as exc:
@@ -149,47 +205,95 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
149
205
 
150
206
  @router.post("/cu/open_app")
151
207
  async def cu_open_app(req: CuOpenAppRequest, request: Request):
152
- require_user(request)
153
- return tool_response(computer_open_app, req.app)
208
+ current_user = require_user(request)
209
+ result = _dispatch(
210
+ "computer_open_app",
211
+ {"app": req.app},
212
+ lambda: computer_open_app(req.app),
213
+ current_user=current_user,
214
+ )
215
+ return _response(result)
154
216
 
155
217
  @router.post("/cu/open_url")
156
218
  async def cu_open_url(req: CuOpenUrlRequest, request: Request):
157
- require_user(request)
158
- return tool_response(computer_open_url, req.url, req.app)
219
+ current_user = require_user(request)
220
+ result = _dispatch(
221
+ "computer_open_url",
222
+ {"url": req.url, "app": req.app},
223
+ lambda: computer_open_url(req.url, req.app),
224
+ current_user=current_user,
225
+ )
226
+ return _response(result)
159
227
 
160
228
  @router.post("/cu/click")
161
229
  async def cu_click(req: CuClickRequest, request: Request):
162
- require_user(request)
163
- return tool_response(computer_click, req.x, req.y, req.button, req.double)
230
+ current_user = require_user(request)
231
+ result = _dispatch(
232
+ "computer_click",
233
+ {"x": req.x, "y": req.y, "button": req.button, "double": req.double},
234
+ lambda: computer_click(req.x, req.y, req.button, req.double),
235
+ current_user=current_user,
236
+ )
237
+ return _response(result)
164
238
 
165
239
  @router.post("/cu/type")
166
240
  async def cu_type(req: CuTypeRequest, request: Request):
167
- require_user(request)
168
- return tool_response(computer_type, req.text, req.interval)
241
+ current_user = require_user(request)
242
+ result = _dispatch(
243
+ "computer_type",
244
+ {"text": req.text, "interval": req.interval},
245
+ lambda: computer_type(req.text, req.interval),
246
+ current_user=current_user,
247
+ )
248
+ return _response(result)
169
249
 
170
250
  @router.post("/cu/key")
171
251
  async def cu_key(req: CuKeyRequest, request: Request):
172
- require_user(request)
173
- return tool_response(computer_key, req.key)
252
+ current_user = require_user(request)
253
+ result = _dispatch(
254
+ "computer_key",
255
+ {"key": req.key},
256
+ lambda: computer_key(req.key),
257
+ current_user=current_user,
258
+ )
259
+ return _response(result)
174
260
 
175
261
  @router.post("/cu/scroll")
176
262
  async def cu_scroll(req: CuScrollRequest, request: Request):
177
- require_user(request)
178
- return tool_response(computer_scroll, req.x, req.y, req.direction, req.clicks)
263
+ current_user = require_user(request)
264
+ result = _dispatch(
265
+ "computer_scroll",
266
+ {"x": req.x, "y": req.y, "direction": req.direction, "clicks": req.clicks},
267
+ lambda: computer_scroll(req.x, req.y, req.direction, req.clicks),
268
+ current_user=current_user,
269
+ )
270
+ return _response(result)
179
271
 
180
272
  @router.post("/cu/move")
181
273
  async def cu_move(req: CuMoveRequest, request: Request):
182
- require_user(request)
183
- return tool_response(computer_move, req.x, req.y)
274
+ current_user = require_user(request)
275
+ result = _dispatch(
276
+ "computer_move",
277
+ {"x": req.x, "y": req.y},
278
+ lambda: computer_move(req.x, req.y),
279
+ current_user=current_user,
280
+ )
281
+ return _response(result)
184
282
 
185
283
  @router.post("/cu/drag")
186
284
  async def cu_drag(req: CuDragRequest, request: Request):
187
- require_user(request)
188
- return tool_response(computer_drag, req.x1, req.y1, req.x2, req.y2)
285
+ current_user = require_user(request)
286
+ result = _dispatch(
287
+ "computer_drag",
288
+ {"x1": req.x1, "y1": req.y1, "x2": req.x2, "y2": req.y2},
289
+ lambda: computer_drag(req.x1, req.y1, req.x2, req.y2),
290
+ current_user=current_user,
291
+ )
292
+ return _response(result)
189
293
 
190
294
  @router.post("/cu/agent")
191
295
  async def cu_agent(req: CuAgentRequest, request: Request):
192
- require_user(request)
296
+ current_user = require_user(request)
193
297
 
194
298
  async def _stream():
195
299
  task_lower = (req.task or "").lower()
@@ -209,8 +313,12 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
209
313
  "action",
210
314
  {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
211
315
  )
212
- result = _dispatch("computer_open_url", {"url": url, "app": "Google Chrome"},
213
- lambda: computer_open_url(url, "Google Chrome"))
316
+ result = _dispatch(
317
+ "computer_open_url",
318
+ {"url": url, "app": "Google Chrome"},
319
+ lambda: computer_open_url(url, "Google Chrome"),
320
+ current_user=current_user,
321
+ )
214
322
  yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
215
323
  message = f"Google Chrome에서 {url}을 열었습니다."
216
324
  action_name = "computer_open_url"
@@ -219,14 +327,20 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
219
327
  "action",
220
328
  {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
221
329
  )
222
- result = _dispatch("computer_open_app", {"app": "Google Chrome"},
223
- lambda: computer_open_app("Google Chrome"))
330
+ result = _dispatch(
331
+ "computer_open_app",
332
+ {"app": "Google Chrome"},
333
+ lambda: computer_open_app("Google Chrome"),
334
+ current_user=current_user,
335
+ )
224
336
  yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
225
337
  message = "Google Chrome을 열었습니다."
226
338
  action_name = "computer_open_app"
227
339
  save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
228
340
  save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
229
341
  yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
342
+ except HTTPException as exc:
343
+ yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
230
344
  except (ToolError, PermissionError) as exc:
231
345
  yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
232
346
  return
@@ -271,7 +385,7 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
271
385
 
272
386
  yield _send("action", {"step": step + 1, "action": name, "args": args})
273
387
  try:
274
- result = _dispatch(name, args, lambda: execute_tool(name, args))
388
+ result = _dispatch(name, args, lambda: execute_tool(name, args), current_user=current_user)
275
389
  if name == "computer_screenshot" and "screenshot_b64" in result:
276
390
  last_screenshot_b64 = result["screenshot_b64"]
277
391
  result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
@@ -290,6 +404,10 @@ def create_computer_use_router(*, model_router, require_user, tool_response, sav
290
404
  last_screenshot_b64 = None
291
405
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
292
406
  yield _send("result", {"step": step + 1, "action": name, "result": result})
407
+ except HTTPException as exc:
408
+ error_str = str(exc.detail)
409
+ transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
410
+ yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
293
411
  except (ToolError, PermissionError, KeyError, TypeError) as exc:
294
412
  error_str = str(exc)
295
413
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
@@ -91,4 +91,15 @@ def create_marketplace_router(
91
91
  scope = gate_read(request)
92
92
  return {"registry": store.list_template_registry(workspace_id=scope)}
93
93
 
94
+ @router.get("/marketplace/interop/bridges")
95
+ async def list_interop_bridges(request: Request):
96
+ require_user(request)
97
+ gate_read(request)
98
+ bridges = catalog.list_templates(kind="ingestion_bridge")["templates"]
99
+ return {
100
+ "bridges": bridges,
101
+ "total": len(bridges),
102
+ "pipeline": "unified-ingestion",
103
+ }
104
+
94
105
  return router
@@ -216,9 +216,9 @@ class PermissionGateway:
216
216
  now = time.time()
217
217
  key = self.token_hash(token)
218
218
  with self.local_approval_lock:
219
- expired = [key for key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
220
- for key in expired:
221
- self.local_approvals.pop(key, None)
219
+ expired = [approval_key for approval_key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
220
+ for expired_key in expired:
221
+ self.local_approvals.pop(expired_key, None)
222
222
  record = self.local_approvals.get(key)
223
223
  if not record:
224
224
  raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
@@ -541,6 +541,7 @@ def create_tools_router(
541
541
  tool_response=_tool_response,
542
542
  save_to_history=save_to_history,
543
543
  hooks=HOOKS,
544
+ append_audit_event=append_audit_event,
544
545
  ))
545
546
 
546
547
  @api_router.post("/tools/knowledge_save")