feat: 增加文件调用工具

This commit is contained in:
ViperEkura 2026-04-15 20:42:23 +08:00
parent 7a6ab26379
commit 99e7de9efd
16 changed files with 684 additions and 39 deletions

View File

@ -9,6 +9,10 @@ database:
type: sqlite
url: sqlite:///./chat.db
workspace:
root: ./workspaces
auto_create: true
llm:
provider: deepseek
api_key: ${DEEPSEEK_API_KEY}

View File

@ -336,14 +336,14 @@ body {
.message-bubble.user .message-container {
align-items: flex-end;
width: fit-content;
max-width: 70%;
max-width: 80%;
}
.message-bubble.assistant .message-container {
align-items: flex-start;
flex: 1 1 auto;
width: 90%;
max-width: 90%;
width: 80%;
max-width: 80%;
min-width: 0;
}
@ -354,7 +354,7 @@ body {
.avatar {
width: 32px;
height: 32px;
border-radius: 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;

View File

@ -157,7 +157,9 @@ export const authAPI = {
login: (data) => api.post('/auth/login', data),
register: (data) => api.post('/auth/register', data),
logout: () => api.post('/auth/logout'),
getMe: () => api.get('/auth/me')
getMe: () => api.get('/auth/me'),
listUsers: () => api.get('/auth/users'),
updateUserPermission: (userId, data) => api.put(`/auth/users/${userId}`, data)
}
// ============ 会话接口 ============

View File

@ -80,7 +80,7 @@
/>
<!-- 流式消息 - 使用 store 中的状态 -->
<div v-if="currentStreamState" class="chat-message assistant streaming">
<div class="message-avatar">Luxx</div>
<div class="avatar">Luxx</div>
<div class="message-content">
<ProcessBlock
:process-steps="currentStreamState.process_steps"
@ -392,9 +392,8 @@ onUnmounted(() => {
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
.chat-message.user { flex-direction: row-reverse; }
.chat-message.streaming { opacity: 0.9; }
.chat-message.streaming .message-avatar { animation: pulse 1.5s ease-in-out infinite; }
.chat-message.streaming .avatar { animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.message-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; }
.message-content { max-width: 80%; width: 80%; }
.chat-message.user .message-content { max-width: 80%; width: auto; }
.message-text { padding: 0.65rem 0.9rem; border-radius: 12px; font-size: 0.9rem; line-height: 1.5; background: var(--bg-secondary); color: var(--text-primary); word-break: break-word; }

View File

@ -185,6 +185,49 @@
</div>
</div>
<!-- 用户管理 (仅管理员可见) -->
<div v-if="isAdmin" class="settings-section">
<div class="section-title">
<span class="section-icon">👥</span>
<span class="section-text">用户管理</span>
</div>
<div v-if="loadingUsers" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="usersError" class="error">{{ usersError }}</div>
<div v-else-if="users.length" class="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>权限级别</th>
<th class="ops-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="name-col">{{ u.username }}</td>
<td class="info-col">{{ u.email || '-' }}</td>
<td>{{ u.role }}</td>
<td>
<select v-model="u.permission_level" class="inline-select permission-select" @change="updateUserPermission(u)">
<option :value="1">只读 (READ_ONLY)</option>
<option :value="2">写入 (WRITE)</option>
<option :value="3">执行 (EXECUTE)</option>
<option :value="4">管理员 (ADMIN)</option>
</select>
</td>
<td class="ops-col">
<span class="permission-label">{{ getPermissionName(u.permission_level) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-card">暂无用户</div>
</div>
<!-- 测试结果弹窗 -->
<div v-if="testResult !== null || testing" class="modal-overlay" @click.self="testResult = null; testing = null">
<div class="modal result-modal" :class="{ success: testResult?.success === true, error: testResult?.success === false, loading: testing }">
@ -258,7 +301,7 @@ import { authAPI } from '../utils/api.js'
import { useRouter } from 'vue-router'
const router = useRouter()
const { logout } = useAuth()
const { logout, user: currentUser } = useAuth()
//
const isDark = ref(localStorage.getItem('theme') === 'dark')
@ -293,6 +336,10 @@ const showUserModal = ref(false)
const savingUser = ref(false)
const userFormError = ref('')
const loadingUser = ref(false)
const isAdmin = ref(false)
const users = ref([])
const loadingUsers = ref(false)
const usersError = ref('')
const modelSettings = ref({
default_provider: null,
@ -493,9 +540,54 @@ const saveDefaultProvider = async () => {
} catch (e) { alert('设置默认 Provider 失败: ' + e.message) }
}
const fetchUsers = async () => {
loadingUsers.value = true
usersError.value = ''
try {
const res = await authAPI.listUsers()
if (res.success) {
users.value = res.data.users || []
} else if (res.message === 'Admin permission required') {
isAdmin.value = false
} else {
usersError.value = res.message || '获取用户列表失败'
}
} catch (e) {
isAdmin.value = false
} finally {
loadingUsers.value = false
}
}
const updateUserPermission = async (user) => {
try {
const res = await authAPI.updateUserPermission(user.id, { permission_level: user.permission_level })
if (!res.success) {
alert(res.message || '更新权限失败')
}
} catch (e) {
alert('更新权限失败: ' + e.message)
}
}
const getPermissionName = (level) => {
const names = { 1: '只读', 2: '写入', 3: '执行', 4: '管理员' }
return names[level] || '未知'
}
onMounted(() => {
fetchUserInfo()
fetchProviders()
fetchUsers()
//
const userData = localStorage.getItem('user')
if (userData) {
try {
const user = JSON.parse(userData)
isAdmin.value = user.permission_level === 4
} catch (e) {}
}
const savedSettings = localStorage.getItem('modelSettings')
if (savedSettings) {
@ -626,4 +718,8 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
/* 权限选择器 */
.permission-select { min-width: 140px; }
.permission-label { font-size: 0.8rem; color: var(--text-secondary); }
</style>

View File

@ -29,12 +29,12 @@ async def lifespan(app: FastAPI):
if not default_user:
default_user = User(
username="admin",
password_hash=hash_password("admin123"),
password_hash=hash_password("admin"),
role="admin"
)
db.add(default_user)
db.commit()
logger.info("Default admin user created: admin / admin123")
logger.info("Default admin user created: admin / admin")
finally:
db.close()

View File

@ -124,6 +124,15 @@ class Config:
@property
def log_date_format(self) -> str:
return self.get("logging.date_format", "%Y-%m-%d %H:%M:%S")
# Workspace configuration
@property
def workspace_root(self) -> str:
return self.get("workspace.root", "./workspaces")
@property
def workspace_auto_create(self) -> bool:
return self.get("workspace.auto_create", True)
# Global configuration instance

View File

@ -75,6 +75,8 @@ class User(Base):
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(20), default="user")
permission_level: Mapped[int] = mapped_column(Integer, default=1) # 1=READ_ONLY, 2=WRITE, 3=EXECUTE, 4=ADMIN
workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # 用户工作空间路径
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
@ -89,6 +91,8 @@ class User(Base):
"username": self.username,
"email": self.email,
"role": self.role,
"permission_level": self.permission_level,
"workspace_path": self.workspace_path,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None
}

View File

@ -41,6 +41,12 @@ class UserResponse(BaseModel):
username: str
email: str | None
role: str
permission_level: int
class UserPermissionUpdate(BaseModel):
"""User permission update model"""
permission_level: int
class TokenResponse(BaseModel):
@ -66,6 +72,13 @@ def get_current_user(
return user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Require admin role"""
if current_user.permission_level < 4:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
@router.post("/register", response_model=dict)
def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""User registration"""
@ -130,3 +143,27 @@ def logout(current_user: User = Depends(get_current_user)):
def get_me(current_user: User = Depends(get_current_user)):
"""Get current user info"""
return success_response(data=current_user.to_dict())
@router.get("/users", response_model=dict)
def get_users(admin_user: User = Depends(require_admin), db: Session = Depends(get_db)):
"""Get all users (admin only)"""
users = db.query(User).all()
return success_response(data={"users": [u.to_dict() for u in users]})
@router.put("/users/{user_id}", response_model=dict)
def update_user(user_id: int, data: UserPermissionUpdate, admin_user: User = Depends(require_admin), db: Session = Depends(get_db)):
"""Update user permission level (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return error_response("User not found", 404)
# Validate permission level
if data.permission_level < 1 or data.permission_level > 4:
return error_response("Invalid permission level (1-4)", 400)
user.permission_level = data.permission_level
db.commit()
return success_response(data=user.to_dict(), message="User permission updated")

View File

@ -138,12 +138,18 @@ async def stream_message(
conversation.updated_at = datetime.now()
db.commit()
workspace = current_user.workspace_path if current_user.workspace_path else None
async def event_generator():
async for sse_str in chat_service.stream_response(
conversation=conversation,
user_message=data.content,
thinking_enabled=data.thinking_enabled,
enabled_tools=data.enabled_tools
enabled_tools=data.enabled_tools,
user_id=current_user.id,
username=current_user.username,
workspace=workspace,
user_permission_level=current_user.permission_level
):
# Chat service returns raw SSE strings (including done event)
yield sse_str

View File

@ -99,7 +99,11 @@ class ChatService:
conversation: Conversation,
user_message: str,
thinking_enabled: bool = False,
enabled_tools: list = None
enabled_tools: list = None,
user_id: int = None,
username: str = None,
workspace: str = None,
user_permission_level: int = 1
) -> AsyncGenerator[Dict[str, str], None]:
"""
Streaming response generator
@ -326,8 +330,14 @@ class ChatService:
yield _sse_event("process_step", {"step": call_step})
# Execute tools
tool_context = {
"workspace": workspace,
"user_id": user_id,
"username": username,
"user_permission_level": user_permission_level
}
tool_results = self.tool_executor.process_tool_calls_parallel(
tool_calls_list, {}
tool_calls_list, tool_context
)
# Yield tool_result steps - use unified step-{index} format

View File

@ -3,5 +3,6 @@
from luxx.tools.builtin import crawler
from luxx.tools.builtin import code
from luxx.tools.builtin import data
from luxx.tools.builtin import file
__all__ = ["crawler", "code", "data"]
__all__ = ["crawler", "code", "data", "file"]

413
luxx/tools/builtin/file.py Normal file
View File

@ -0,0 +1,413 @@
"""File operation tools with workspace path injection"""
import os
import hashlib
from pathlib import Path
from typing import Dict, Any, Optional
from luxx.tools.factory import tool
from luxx.tools.core import ToolContext, CommandPermission
from luxx.config import config
def get_user_workspace(user_id: int) -> str:
"""Get user's workspace path, create if not exists
Directory name is based on user_id hash for privacy:
{workspace_root}/{hash_of_user_id}
"""
workspace_root = Path(config.workspace_root).resolve()
# Generate hash from user_id for privacy
user_hash = hashlib.sha256(str(user_id).encode()).hexdigest()[:16]
# User-specific workspace: {root}/{hash}
user_workspace = workspace_root / user_hash
if config.workspace_auto_create and not user_workspace.exists():
user_workspace.mkdir(parents=True, exist_ok=True)
return str(user_workspace)
def safe_path(file_path: str, workspace: str) -> Optional[Path]:
"""
Validate and resolve file path within workspace.
Returns resolved Path if valid, None if path escapes workspace (security check).
"""
try:
workspace_path = Path(workspace).resolve()
requested_path = (workspace_path / file_path).resolve()
# Check if resolved path is within workspace
if requested_path.is_relative_to(workspace_path):
return requested_path
else:
return None # Path escapes workspace
except (ValueError, OSError):
return None
def get_workspace_from_context(context: ToolContext) -> Optional[str]:
"""Get workspace from context, auto-create if needed"""
if context.workspace:
return context.workspace
if context.user_id is not None:
return get_user_workspace(context.user_id)
return None
@tool(
name="file_read",
description="Read content from a file within the user's workspace",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path of the file to read (relative to workspace root)"
},
"max_lines": {
"type": "integer",
"description": "Maximum number of lines to read",
"default": 100
}
},
"required": ["path"]
},
required_params=["path"],
category="file",
required_permission=CommandPermission.READ_ONLY
)
def file_read(arguments: Dict[str, Any], context: ToolContext = None):
"""
Read file content from user's workspace.
Args:
path: Relative path within workspace
max_lines: Maximum lines to read (default 100)
Returns:
{"content": str, "lines": int, "path": str}
"""
workspace = get_workspace_from_context(context) if context else None
file_path = arguments.get("path", "")
max_lines = arguments.get("max_lines", 100)
if not workspace:
return {"error": "Workspace not configured"}
# Security check
safe_file_path = safe_path(file_path, workspace)
if not safe_file_path:
return {"error": f"Access denied: path '{file_path}' is outside workspace"}
# Check if file exists
if not safe_file_path.exists():
return {"error": f"File not found: {file_path}"}
if not safe_file_path.is_file():
return {"error": f"Path is not a file: {file_path}"}
try:
with open(safe_file_path, 'r', encoding='utf-8') as f:
lines = []
for i, line in enumerate(f):
if i >= max_lines:
break
lines.append(line.rstrip('\n'))
content = '\n'.join(lines)
total_lines = sum(1 for _ in open(safe_file_path, 'r', encoding='utf-8'))
return {
"content": content,
"lines": len(lines),
"total_lines": total_lines,
"path": file_path,
"truncated": total_lines > len(lines)
}
except UnicodeDecodeError:
return {"error": "File is not text-readable (binary file)"}
except Exception as e:
return {"error": f"Read error: {str(e)}"}
@tool(
name="file_write",
description="Write content to a file within the user's workspace",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path of the file to write (relative to workspace root)"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"append": {
"type": "boolean",
"description": "Append to existing file instead of overwriting",
"default": False
}
},
"required": ["path", "content"]
},
required_params=["path", "content"],
category="file",
required_permission=CommandPermission.WRITE
)
def file_write(arguments: Dict[str, Any], context: ToolContext = None):
"""
Write content to file in user's workspace.
Args:
path: Relative path within workspace
content: Content to write
append: Whether to append (default False = overwrite)
Returns:
{"bytes_written": int, "path": str}
"""
workspace = get_workspace_from_context(context) if context else None
file_path = arguments.get("path", "")
content = arguments.get("content", "")
append = arguments.get("append", False)
if not workspace:
return {"error": "Workspace not configured"}
# Security check
safe_file_path = safe_path(file_path, workspace)
if not safe_file_path:
return {"error": f"Access denied: path '{file_path}' is outside workspace"}
try:
# Create parent directories if needed
safe_file_path.parent.mkdir(parents=True, exist_ok=True)
mode = 'a' if append else 'w'
with open(safe_file_path, mode, encoding='utf-8') as f:
bytes_written = f.write(content)
return {
"bytes_written": bytes_written,
"path": file_path,
"action": "appended" if append else "written"
}
except Exception as e:
return {"error": f"Write error: {str(e)}"}
@tool(
name="file_list",
description="List files and directories within the user's workspace",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path of directory to list (relative to workspace root)",
"default": "."
},
"recursive": {
"type": "boolean",
"description": "List recursively",
"default": False
}
},
"required": []
},
required_params=[],
category="file",
required_permission=CommandPermission.READ_ONLY
)
def file_list(arguments: Dict[str, Any], context: ToolContext = None):
"""
List directory contents in user's workspace.
Args:
path: Relative directory path (default ".")
recursive: Whether to list recursively
Returns:
{"files": list, "dirs": list}
"""
workspace = get_workspace_from_context(context) if context else None
dir_path = arguments.get("path", ".")
recursive = arguments.get("recursive", False)
if not workspace:
return {"error": "Workspace not configured"}
# Security check
safe_dir_path = safe_path(dir_path, workspace)
if not safe_dir_path:
return {"error": f"Access denied: path '{dir_path}' is outside workspace"}
if not safe_dir_path.exists():
return {"error": f"Directory not found: {dir_path}"}
if not safe_dir_path.is_dir():
return {"error": f"Path is not a directory: {dir_path}"}
try:
files = []
dirs = []
if recursive:
for root, dirnames, filenames in os.walk(safe_dir_path):
root_rel = os.path.relpath(root, safe_dir_path)
if root_rel == '.':
root_rel = ''
for d in dirnames:
dirs.append(os.path.join(root_rel, d) if root_rel else d)
for f in filenames:
files.append(os.path.join(root_rel, f) if root_rel else f)
else:
for item in safe_dir_path.iterdir():
if item.is_dir():
dirs.append(item.name)
else:
files.append(item.name)
return {
"files": sorted(files),
"dirs": sorted(dirs),
"path": dir_path
}
except Exception as e:
return {"error": f"List error: {str(e)}"}
@tool(
name="file_exists",
description="Check if a file or directory exists within the user's workspace",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to check (relative to workspace root)"
}
},
"required": ["path"]
},
required_params=["path"],
category="file",
required_permission=CommandPermission.READ_ONLY
)
def file_exists(arguments: Dict[str, Any], context: ToolContext = None):
"""
Check if path exists in user's workspace.
Returns:
{"exists": bool, "type": "file"|"dir"|"none"}
"""
workspace = get_workspace_from_context(context) if context else None
file_path = arguments.get("path", "")
if not workspace:
return {"error": "Workspace not configured"}
# Security check
safe_file_path = safe_path(file_path, workspace)
if not safe_file_path:
return {"exists": False, "type": "none", "path": file_path}
if not safe_file_path.exists():
return {"exists": False, "type": "none", "path": file_path}
item_type = "dir" if safe_file_path.is_dir() else "file"
return {"exists": True, "type": item_type, "path": file_path}
@tool(
name="file_grep",
description="Search for lines matching a pattern in a file (read-only grep)",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path of the file to search (relative to workspace root)"
},
"pattern": {
"type": "string",
"description": "Regex pattern to search for"
},
"max_results": {
"type": "integer",
"description": "Maximum number of matching lines to return",
"default": 100
},
"case_sensitive": {
"type": "boolean",
"description": "Case sensitive search",
"default": False
}
},
"required": ["path", "pattern"]
},
required_params=["path", "pattern"],
category="file",
required_permission=CommandPermission.READ_ONLY
)
def file_grep(arguments: Dict[str, Any], context: ToolContext = None):
"""
Search for lines matching a pattern in a file.
Returns:
{"matches": [{"line": int, "content": str}], "path": str, "count": int}
"""
import re
workspace = get_workspace_from_context(context) if context else None
file_path = arguments.get("path", "")
pattern = arguments.get("pattern", "")
max_results = arguments.get("max_results", 100)
case_sensitive = arguments.get("case_sensitive", False)
if not workspace:
return {"error": "Workspace not configured"}
# Security check
safe_file_path = safe_path(file_path, workspace)
if not safe_file_path:
return {"error": f"Access denied: path '{file_path}' is outside workspace"}
if not safe_file_path.exists():
return {"error": f"File not found: {file_path}"}
if not safe_file_path.is_file():
return {"error": f"Path is not a file: {file_path}"}
try:
flags = 0 if case_sensitive else re.IGNORECASE
regex = re.compile(pattern, flags)
matches = []
with open(safe_file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
if regex.search(line):
matches.append({
"line": line_num,
"content": line.rstrip('\n')
})
if len(matches) >= max_results:
break
return {
"matches": matches,
"path": file_path,
"count": len(matches),
"pattern": pattern
}
except re.error as e:
return {"error": f"Invalid regex pattern: {str(e)}"}
except UnicodeDecodeError:
return {"error": "File is not text-readable (binary file)"}
except Exception as e:
return {"error": f"Search error: {str(e)}"}

View File

@ -1,6 +1,24 @@
"""Tool system core module"""
from dataclasses import dataclass, field
from typing import Callable, Any, Dict, List, Optional
from enum import IntEnum
class CommandPermission(IntEnum):
"""Command permission level - higher value means more permissions"""
READ_ONLY = 1
WRITE = 2
EXECUTE = 3
ADMIN = 4
@dataclass
class ToolContext:
"""Tool execution context - explicitly passed to tool execution"""
workspace: Optional[str] = None
user_id: Optional[int] = None
username: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
@ -11,6 +29,7 @@ class ToolDefinition:
parameters: Dict[str, Any]
handler: Callable
category: str = "general"
required_permission: CommandPermission = CommandPermission.READ_ONLY
def to_openai_format(self) -> Dict[str, Any]:
"""Convert to OpenAI format"""
@ -77,14 +96,27 @@ class ToolRegistry:
if t.category == category
]
def execute(self, name: str, arguments: dict) -> Dict[str, Any]:
"""Execute tool"""
def execute(self, name: str, arguments: dict, context: ToolContext = None) -> Dict[str, Any]:
"""Execute tool with optional context and automatic permission check"""
tool = self.get(name)
if not tool:
return {"success": False, "error": f"Tool '{name}' not found"}
# Automatic permission check (transparent to tool function)
if context is not None and context.user_id is not None:
user_level = context.extra.get("user_permission_level", CommandPermission.READ_ONLY)
if user_level < tool.required_permission:
return {
"success": False,
"error": f"Permission denied: requires {tool.required_permission.name}, you have {user_level.name}"
}
try:
result = tool.handler(arguments)
# Pass context to handler if it accepts it
if context is not None:
result = tool.handler(arguments, context=context)
else:
result = tool.handler(arguments)
if isinstance(result, ToolResult):
return result.to_dict()
return result

View File

@ -3,7 +3,7 @@ import json
import time
from typing import List, Dict, Any, Optional
from luxx.tools.core import registry, ToolResult
from luxx.tools.core import registry, ToolResult, ToolContext
class ToolExecutor:
@ -63,6 +63,17 @@ class ToolExecutor:
context: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Process tool calls sequentially"""
# Build ToolContext from context dict (includes user_permission_level)
tool_ctx = ToolContext(
workspace=context.get("workspace"),
user_id=context.get("user_id"),
username=context.get("username"),
extra={
"user_permission_level": context.get("user_permission_level", 1),
**(context.get("extra", {}))
}
)
results = []
for call in tool_calls:
@ -75,15 +86,17 @@ class ToolExecutor:
except json.JSONDecodeError:
args = {}
# Check cache
# Check cache (include context in cache key for file operations)
cache_key = self._make_cache_key(name, args)
if tool_ctx.workspace:
cache_key = f"{cache_key}:{tool_ctx.workspace}"
cached = self._get_cached(cache_key)
if cached is not None:
result = cached
else:
# Execute tool
result = registry.execute(name, args)
# Execute tool with context
result = registry.execute(name, args, context=tool_ctx)
self._set_cached(cache_key, result)
# Record call
@ -103,6 +116,17 @@ class ToolExecutor:
if len(tool_calls) <= 1:
return self.process_tool_calls(tool_calls, context)
# Build ToolContext from context dict (includes user_permission_level)
tool_ctx = ToolContext(
workspace=context.get("workspace"),
user_id=context.get("user_id"),
username=context.get("username"),
extra={
"user_permission_level": context.get("user_permission_level", 1),
**(context.get("extra", {}))
}
)
try:
from concurrent.futures import ThreadPoolExecutor, as_completed
@ -121,28 +145,29 @@ class ToolExecutor:
# Check cache
cache_key = self._make_cache_key(name, args)
if tool_ctx.workspace:
cache_key = f"{cache_key}:{tool_ctx.workspace}"
cached = self._get_cached(cache_key)
if cached is not None:
futures[call_id] = (name, args, cached)
else:
# Submit task
future = executor.submit(registry.execute, name, args)
futures[future] = (call_id, name, args)
# Submit task with context
future = executor.submit(registry.execute, name, args, context=tool_ctx)
futures[future] = (call_id, name, args, cache_key)
results = []
for future in as_completed(futures.keys()):
if future in futures:
call_id, name, args = futures[future]
item = futures[future]
if len(item) == 3:
call_id, name, args = item
cache_key = self._make_cache_key(name, args)
else:
call_id, name, args, cache_key = item
result = future.result()
self._set_cached(self._make_cache_key(name, args), result)
self._record_call(name, args, result)
results.append(self._create_tool_result(call_id, name, result))
else:
call_id, name, args = futures[future]
result = future.result()
self._set_cached(self._make_cache_key(name, args), result)
self._set_cached(cache_key, result)
self._record_call(name, args, result)
results.append(self._create_tool_result(call_id, name, result))

View File

@ -1,6 +1,6 @@
"""Tool decorator factory with unified result wrapping"""
from typing import Callable, Any, Dict, Optional, List
from luxx.tools.core import ToolDefinition, ToolResult, registry
from luxx.tools.core import ToolDefinition, ToolResult, registry, CommandPermission
import traceback
import logging
@ -12,7 +12,8 @@ def tool(
description: str,
parameters: Dict[str, Any],
category: str = "general",
required_params: Optional[List[str]] = None
required_params: Optional[List[str]] = None,
required_permission: CommandPermission = CommandPermission.READ_ONLY
):
"""
Tool registration decorator with UNIFED result wrapping
@ -60,7 +61,7 @@ def tool(
- Exception {"success": false, "data": null, "error": "..."}
"""
def decorator(func: Callable) -> Callable:
def wrapped_handler(arguments: Dict[str, Any]) -> ToolResult:
def wrapped_handler(arguments: Dict[str, Any], context=None) -> ToolResult:
try:
# 1. Validate required params
if required_params:
@ -68,8 +69,13 @@ def tool(
if param not in arguments or arguments[param] is None:
return ToolResult.fail(f"Missing required parameter: {param}")
# 2. Execute handler
result = func(arguments)
# 2. Execute handler - pass context only if function accepts it
import inspect
sig = inspect.signature(func)
if 'context' in sig.parameters:
result = func(arguments, context=context)
else:
result = func(arguments)
# 3. Auto-wrap result
return _auto_wrap(result)
@ -84,7 +90,8 @@ def tool(
description=description,
parameters=parameters,
handler=wrapped_handler,
category=category
category=category,
required_permission=required_permission
)
registry.register(tool_def)
return func