fix: 修复工具调用接口的bug
This commit is contained in:
parent
c88b5686f3
commit
85619c0d97
|
|
@ -216,6 +216,82 @@ classDiagram
|
||||||
| `get_weather` | 天气查询 | 支持城市名查询 |
|
| `get_weather` | 天气查询 | 支持城市名查询 |
|
||||||
| `process_data` | 数据处理 | JSON 转换、格式化等 |
|
| `process_data` | 数据处理 | JSON 转换、格式化等 |
|
||||||
|
|
||||||
|
#### 工具开发规范
|
||||||
|
|
||||||
|
所有工具必须遵循统一的开发规范,确保错误处理和返回格式一致。
|
||||||
|
|
||||||
|
**核心原则:装饰器自动处理一切,工具函数只写业务逻辑**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from luxx.tools.factory import tool
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="my_tool",
|
||||||
|
description="工具描述",
|
||||||
|
parameters={...},
|
||||||
|
required_params=["arg1"], # 自动验证
|
||||||
|
category="my_category"
|
||||||
|
)
|
||||||
|
def my_tool(arguments: dict):
|
||||||
|
# 业务逻辑 - 只管返回数据
|
||||||
|
data = fetch_data(arguments["arg1"])
|
||||||
|
return {"items": data, "count": len(data)}
|
||||||
|
|
||||||
|
# 或者直接抛出异常(装饰器自动捕获并转换)
|
||||||
|
if invalid:
|
||||||
|
raise ValueError("Invalid input")
|
||||||
|
```
|
||||||
|
|
||||||
|
**装饰器自动处理:**
|
||||||
|
1. 必需参数验证(`required_params`)
|
||||||
|
2. 所有异常捕获和转换
|
||||||
|
3. 结果格式统一包装
|
||||||
|
|
||||||
|
**返回格式转换**
|
||||||
|
|
||||||
|
| 工具函数返回/抛出 | 装饰器转换为 |
|
||||||
|
|-------------------|-------------|
|
||||||
|
| `return {"result": "ok"}` | `{"success": true, "data": {...}, "error": null}` |
|
||||||
|
| `raise ValueError("msg")` | `{"success": false, "data": null, "error": "ValueError: msg"}` |
|
||||||
|
| `raise Exception()` | `{"success": false, "data": null, "error": "..."}` |
|
||||||
|
|
||||||
|
**工具调用流程**
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM 请求
|
||||||
|
↓
|
||||||
|
ToolRegistry.execute(name, args)
|
||||||
|
↓
|
||||||
|
@tool 装饰器
|
||||||
|
├─ 验证 required_params
|
||||||
|
├─ 执行工具函数 (try-except 包裹)
|
||||||
|
├─ 捕获异常 → 转换为 error
|
||||||
|
├─ 包装返回格式
|
||||||
|
└─ 返回 ToolResult
|
||||||
|
↓
|
||||||
|
前端 ProcessBlock 显示
|
||||||
|
```
|
||||||
|
|
||||||
|
| 工具函数返回 | 装饰器转换为 |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `{"result": "ok"}` | `{"success": true, "data": {...}, "error": null}` |
|
||||||
|
| `{"error": "msg"}` | `{"success": false, "data": null, "error": "msg"}` |
|
||||||
|
| `raise Exception()` | `{"success": false, "data": null, "error": "..."}` |
|
||||||
|
|
||||||
|
**工具调用流程**
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM 请求 → ToolRegistry.execute() → @tool 装饰器包装
|
||||||
|
↓
|
||||||
|
工具函数执行
|
||||||
|
↓
|
||||||
|
返回 ToolResult 或 dict
|
||||||
|
↓
|
||||||
|
自动转换为标准格式 {"success": bool, "data": any, "error": str}
|
||||||
|
↓
|
||||||
|
前端 ProcessBlock 显示结果
|
||||||
|
```
|
||||||
|
|
||||||
### 6. 服务层
|
### 6. 服务层
|
||||||
|
|
||||||
#### ChatService (`services/chat.py`)
|
#### ChatService (`services/chat.py`)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# 配置文件
|
# 配置文件
|
||||||
app:
|
app:
|
||||||
secret_key: ${APP_SECRET_KEY}
|
secret_key: ${APP_SECRET_KEY}
|
||||||
debug: false
|
debug: true
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,13 +113,20 @@ const allItems = computed(() => {
|
||||||
if (match) {
|
if (match) {
|
||||||
let resultContent = step.content || ''
|
let resultContent = step.content || ''
|
||||||
let displayContent = resultContent
|
let displayContent = resultContent
|
||||||
|
let hasError = false
|
||||||
|
|
||||||
// 尝试解析 JSON 并格式化显示
|
// 尝试解析 JSON 并格式化显示
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(resultContent)
|
const parsed = JSON.parse(resultContent)
|
||||||
|
// 检查标准 ToolResult 格式
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
displayContent = `错误: ${parsed.error}`
|
displayContent = `错误: ${parsed.error}`
|
||||||
|
hasError = true
|
||||||
|
} else if (parsed.success !== undefined && parsed.data !== undefined) {
|
||||||
|
// 标准 ToolResult 格式: 只显示 data 部分
|
||||||
|
displayContent = JSON.stringify(parsed.data, null, 2)
|
||||||
} else {
|
} else {
|
||||||
|
// 旧格式或其他 JSON
|
||||||
displayContent = JSON.stringify(parsed, null, 2)
|
displayContent = JSON.stringify(parsed, null, 2)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -129,7 +136,7 @@ const allItems = computed(() => {
|
||||||
match.resultSummary = displayContent.slice(0, 200)
|
match.resultSummary = displayContent.slice(0, 200)
|
||||||
match.fullResult = displayContent
|
match.fullResult = displayContent
|
||||||
match.displayResult = displayContent.length > 2048 ? displayContent.slice(0, 2048) + '...' : displayContent
|
match.displayResult = displayContent.length > 2048 ? displayContent.slice(0, 2048) + '...' : displayContent
|
||||||
match.isSuccess = step.success !== false
|
match.isSuccess = !hasError && step.success !== false
|
||||||
match.loading = false
|
match.loading = false
|
||||||
} else {
|
} else {
|
||||||
// 如果没有找到对应的 tool_call,创建一个占位符
|
// 如果没有找到对应的 tool_call,创建一个占位符
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,41 @@ export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
// 处理数据
|
||||||
|
if (value) {
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流结束时,先处理 buffer 中的剩余数据,再 break
|
||||||
|
if (done) {
|
||||||
|
// 处理 buffer 中剩余的数据
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = ''
|
||||||
|
|
||||||
|
let currentEvent = ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
currentEvent = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6))
|
||||||
|
if (currentEvent === 'process_step' && onProcessStep) {
|
||||||
|
onProcessStep(data.step)
|
||||||
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
|
completed = true
|
||||||
|
onDone(data)
|
||||||
|
} else if (currentEvent === 'error' && onError) {
|
||||||
|
onError(data.content)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const lines = buffer.split('\n')
|
const lines = buffer.split('\n')
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
|
@ -82,19 +114,24 @@ export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
|
||||||
if (line.startsWith('event: ')) {
|
if (line.startsWith('event: ')) {
|
||||||
currentEvent = line.slice(7).trim()
|
currentEvent = line.slice(7).trim()
|
||||||
} else if (line.startsWith('data: ')) {
|
} else if (line.startsWith('data: ')) {
|
||||||
const data = JSON.parse(line.slice(6))
|
try {
|
||||||
if (currentEvent === 'process_step' && onProcessStep) {
|
const data = JSON.parse(line.slice(6))
|
||||||
onProcessStep(data.step)
|
if (currentEvent === 'process_step' && onProcessStep) {
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
onProcessStep(data.step)
|
||||||
completed = true
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
onDone(data)
|
completed = true
|
||||||
} else if (currentEvent === 'error' && onError) {
|
onDone(data)
|
||||||
onError(data.content)
|
} else if (currentEvent === 'error' && onError) {
|
||||||
|
onError(data.content)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流结束但没有收到 done 事件,才报错
|
||||||
if (!completed && onError) {
|
if (!completed && onError) {
|
||||||
onError('stream ended unexpectedly')
|
onError('stream ended unexpectedly')
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +176,8 @@ export const messagesAPI = {
|
||||||
return createSSEStream('/messages/stream', {
|
return createSSEStream('/messages/stream', {
|
||||||
conversation_id: data.conversation_id,
|
conversation_id: data.conversation_id,
|
||||||
content: data.content,
|
content: data.content,
|
||||||
thinking_enabled: data.thinking_enabled || false
|
thinking_enabled: data.thinking_enabled || false,
|
||||||
|
enabled_tools: data.enabled_tools || []
|
||||||
}, callbacks)
|
}, callbacks)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,18 +165,8 @@ const loadEnabledTools = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await toolsAPI.list()
|
const res = await toolsAPI.list()
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const data = res.data?.categorized || {}
|
const tools = res.data?.tools || []
|
||||||
const enabled = []
|
enabledTools.value = tools.map(t => t.function?.name || t.name)
|
||||||
Object.values(data).forEach(arr => {
|
|
||||||
if (Array.isArray(arr)) {
|
|
||||||
arr.forEach(t => {
|
|
||||||
if (t.enabled !== false) {
|
|
||||||
enabled.push(t.function?.name || t.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
enabledTools.value = enabled
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load tools:', e)
|
console.error('Failed to load tools:', e)
|
||||||
|
|
@ -317,6 +307,15 @@ const formatTime = (time) => {
|
||||||
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听对话 ID 变化,重新加载消息
|
||||||
|
watch(() => route.params.id, (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
conversationId.value = newId
|
||||||
|
loadMessages()
|
||||||
|
loadEnabledTools()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMessages()
|
loadMessages()
|
||||||
loadEnabledTools()
|
loadEnabledTools()
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { conversationsAPI, providersAPI, messagesAPI } from '../utils/api.js'
|
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||||
|
|
||||||
|
|
@ -158,9 +158,23 @@ const selectedId = ref(null)
|
||||||
const selectedConv = ref(null)
|
const selectedConv = ref(null)
|
||||||
const convMessages = ref([])
|
const convMessages = ref([])
|
||||||
const loadingMessages = ref(false)
|
const loadingMessages = ref(false)
|
||||||
|
const enabledTools = ref([]) // 启用的工具名称列表
|
||||||
|
|
||||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||||
|
|
||||||
|
// 加载启用的工具列表
|
||||||
|
const loadEnabledTools = async () => {
|
||||||
|
try {
|
||||||
|
const res = await toolsAPI.list()
|
||||||
|
if (res.success) {
|
||||||
|
const tools = res.data?.tools || []
|
||||||
|
enabledTools.value = tools.map(t => t.function?.name || t.name)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load tools:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onProviderChange = () => {
|
const onProviderChange = () => {
|
||||||
const p = providers.value.find(p => p.id === form.value.provider_id)
|
const p = providers.value.find(p => p.id === form.value.provider_id)
|
||||||
if (p) form.value.model = p.default_model || ''
|
if (p) form.value.model = p.default_model || ''
|
||||||
|
|
@ -267,7 +281,8 @@ const sendMessage = async () => {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
messagesAPI.sendStream({
|
messagesAPI.sendStream({
|
||||||
conversation_id: selectedConv.value.id,
|
conversation_id: selectedConv.value.id,
|
||||||
content: content
|
content: content,
|
||||||
|
enabled_tools: enabledTools.value // 传递启用的工具名称列表
|
||||||
}, {
|
}, {
|
||||||
onProcessStep: (step) => {
|
onProcessStep: (step) => {
|
||||||
if (!streamingMessage.value) return
|
if (!streamingMessage.value) return
|
||||||
|
|
@ -356,7 +371,10 @@ const formatDate = (dateStr) => {
|
||||||
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchData)
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
loadEnabledTools()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ async def lifespan(app: FastAPI):
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Import and register tools
|
||||||
from luxx.tools.builtin import crawler, code, data
|
from luxx.tools.builtin import crawler, code, data
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class MessageCreate(BaseModel):
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
content: str
|
content: str
|
||||||
thinking_enabled: bool = False
|
thinking_enabled: bool = False
|
||||||
|
enabled_tools: List[str] = [] # 启用的工具名称列表
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
class MessageResponse(BaseModel):
|
||||||
|
|
@ -142,7 +143,8 @@ async def stream_message(
|
||||||
async for sse_str in chat_service.stream_response(
|
async for sse_str in chat_service.stream_response(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
user_message=data.content,
|
user_message=data.content,
|
||||||
thinking_enabled=data.thinking_enabled
|
thinking_enabled=data.thinking_enabled,
|
||||||
|
enabled_tools=data.enabled_tools
|
||||||
):
|
):
|
||||||
# Chat service returns raw SSE strings (including done event)
|
# Chat service returns raw SSE strings (including done event)
|
||||||
yield sse_str
|
yield sse_str
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,24 @@ def list_tools(
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get available tools list"""
|
"""Get available tools list"""
|
||||||
|
# Get tool definitions directly from registry to access category
|
||||||
|
from luxx.tools.core import ToolDefinition
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
tools = registry.list_by_category(category)
|
all_tools = [t for t in registry._tools.values() if t.category == category]
|
||||||
|
tools = [t.to_openai_format() for t in all_tools]
|
||||||
|
categorized_tools = [t for t in registry._tools.values() if t.category == category]
|
||||||
else:
|
else:
|
||||||
|
all_tools = list(registry._tools.values())
|
||||||
tools = registry.list_all()
|
tools = registry.list_all()
|
||||||
|
categorized_tools = all_tools
|
||||||
|
|
||||||
categorized = {}
|
categorized = {}
|
||||||
for tool in tools:
|
for tool in categorized_tools:
|
||||||
cat = tool.get("category", "other")
|
cat = tool.category
|
||||||
if cat not in categorized:
|
if cat not in categorized:
|
||||||
categorized[cat] = []
|
categorized[cat] = []
|
||||||
categorized[cat].append(tool)
|
categorized[cat].append(tool.to_openai_format())
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,8 @@ class ChatService:
|
||||||
self,
|
self,
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
thinking_enabled: bool = False
|
thinking_enabled: bool = False,
|
||||||
|
enabled_tools: list = None
|
||||||
) -> AsyncGenerator[Dict[str, str], None]:
|
) -> AsyncGenerator[Dict[str, str], None]:
|
||||||
"""
|
"""
|
||||||
Streaming response generator
|
Streaming response generator
|
||||||
|
|
@ -112,8 +113,11 @@ class ChatService:
|
||||||
"content": json.dumps({"text": user_message, "attachments": []})
|
"content": json.dumps({"text": user_message, "attachments": []})
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get all available tools
|
# Get tools based on enabled_tools filter
|
||||||
tools = registry.list_all()
|
if enabled_tools:
|
||||||
|
tools = [t for t in registry.list_all() if t.get("function", {}).get("name") in enabled_tools]
|
||||||
|
else:
|
||||||
|
tools = []
|
||||||
|
|
||||||
llm, provider_max_tokens = get_llm_client(conversation)
|
llm, provider_max_tokens = get_llm_client(conversation)
|
||||||
model = conversation.model or llm.default_model or "gpt-4"
|
model = conversation.model or llm.default_model or "gpt-4"
|
||||||
|
|
@ -133,8 +137,6 @@ class ChatService:
|
||||||
text_step_idx = None
|
text_step_idx = None
|
||||||
|
|
||||||
for iteration in range(MAX_ITERATIONS):
|
for iteration in range(MAX_ITERATIONS):
|
||||||
print(f"[CHAT] Starting iteration {iteration + 1}, messages: {len(messages)}")
|
|
||||||
|
|
||||||
# Stream from LLM
|
# Stream from LLM
|
||||||
full_content = ""
|
full_content = ""
|
||||||
full_thinking = ""
|
full_thinking = ""
|
||||||
|
|
@ -193,19 +195,25 @@ class ChatService:
|
||||||
# Get delta
|
# Get delta
|
||||||
choices = chunk.get("choices", [])
|
choices = chunk.get("choices", [])
|
||||||
if not choices:
|
if not choices:
|
||||||
# Check if there's any content in the response
|
# Check if there's any content in the response (for non-standard LLM responses)
|
||||||
if chunk.get("content") or chunk.get("message"):
|
if chunk.get("content") or chunk.get("message"):
|
||||||
content = chunk.get("content") or chunk.get("message", {}).get("content", "")
|
content = chunk.get("content") or chunk.get("message", {}).get("content", "")
|
||||||
if content:
|
if content:
|
||||||
|
# BUG FIX: Update full_content so it gets saved to database
|
||||||
|
prev_content_len = len(full_content)
|
||||||
|
full_content += content
|
||||||
|
if prev_content_len == 0: # New text stream started
|
||||||
|
text_step_idx = step_index
|
||||||
|
text_step_id = f"step-{step_index}"
|
||||||
|
step_index += 1
|
||||||
yield _sse_event("process_step", {
|
yield _sse_event("process_step", {
|
||||||
"step": {
|
"step": {
|
||||||
"id": f"step-{step_index}",
|
"id": text_step_id if prev_content_len == 0 else f"step-{step_index - 1}",
|
||||||
"index": step_index,
|
"index": text_step_idx if prev_content_len == 0 else step_index - 1,
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": content
|
"content": full_content # Always send accumulated content
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
step_index += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
delta = choices[0].get("delta", {})
|
delta = choices[0].get("delta", {})
|
||||||
|
|
@ -313,13 +321,25 @@ class ChatService:
|
||||||
result_step_idx = step_index
|
result_step_idx = step_index
|
||||||
result_step_id = f"step-{step_index}"
|
result_step_id = f"step-{step_index}"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
|
# 解析 content 中的 success 状态
|
||||||
|
content = tr.get("content", "")
|
||||||
|
success = True
|
||||||
|
try:
|
||||||
|
content_obj = json.loads(content)
|
||||||
|
if isinstance(content_obj, dict):
|
||||||
|
success = content_obj.get("success", True)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
result_step = {
|
result_step = {
|
||||||
"id": result_step_id,
|
"id": result_step_id,
|
||||||
"index": result_step_idx,
|
"index": result_step_idx,
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"id_ref": tool_call_step_id, # Reference to the tool_call step
|
"id_ref": tool_call_step_id, # Reference to the tool_call step
|
||||||
"name": tr.get("name", ""),
|
"name": tr.get("name", ""),
|
||||||
"content": tr.get("content", "")
|
"content": content,
|
||||||
|
"success": success
|
||||||
}
|
}
|
||||||
all_steps.append(result_step)
|
all_steps.append(result_step)
|
||||||
yield _sse_event("process_step", {"step": result_step})
|
yield _sse_event("process_step", {"step": result_step})
|
||||||
|
|
@ -357,11 +377,20 @@ class ChatService:
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Max iterations exceeded
|
# Max iterations exceeded - save message before error
|
||||||
|
if full_content or all_tool_calls:
|
||||||
|
msg_id = str(uuid.uuid4())
|
||||||
|
self._save_message(
|
||||||
|
conversation.id,
|
||||||
|
msg_id,
|
||||||
|
full_content,
|
||||||
|
all_tool_calls,
|
||||||
|
all_tool_results,
|
||||||
|
all_steps
|
||||||
|
)
|
||||||
yield _sse_event("error", {"content": "Exceeded maximum tool call iterations"})
|
yield _sse_event("error", {"content": "Exceeded maximum tool call iterations"})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[CHAT] Exception: {type(e).__name__}: {str(e)}")
|
|
||||||
yield _sse_event("error", {"content": str(e)})
|
yield _sse_event("error", {"content": str(e)})
|
||||||
|
|
||||||
def _save_message(
|
def _save_message(
|
||||||
|
|
@ -396,8 +425,8 @@ class ChatService:
|
||||||
db.add(msg)
|
db.add(msg)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[CHAT] Failed to save message: {e}")
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Code execution tools"""
|
"""Code execution tools - exception handling in decorator"""
|
||||||
import json
|
import io
|
||||||
import traceback
|
from contextlib import redirect_stdout
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from luxx.tools.factory import tool
|
from luxx.tools.factory import tool
|
||||||
|
|
@ -24,53 +24,45 @@ from luxx.tools.factory import tool
|
||||||
},
|
},
|
||||||
"required": ["code"]
|
"required": ["code"]
|
||||||
},
|
},
|
||||||
|
required_params=["code"],
|
||||||
category="code"
|
category="code"
|
||||||
)
|
)
|
||||||
def python_execute(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def python_execute(arguments: Dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
Execute Python code
|
Execute Python code
|
||||||
|
|
||||||
Note: This is a simplified executor, production environments should use safer isolated environments
|
Note: This is a simplified executor, production environments should use safer isolated environments
|
||||||
such as: Docker containers, Pyodide, etc.
|
such as: Docker containers, Pyodide, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"output": str, "variables": dict}
|
||||||
"""
|
"""
|
||||||
code = arguments.get("code", "")
|
code = arguments.get("code", "")
|
||||||
timeout = arguments.get("timeout", 30)
|
|
||||||
|
|
||||||
if not code:
|
|
||||||
return {"error": "Code is required"}
|
|
||||||
|
|
||||||
# Create execution environment
|
# Create execution environment
|
||||||
namespace = {
|
namespace = {
|
||||||
"__builtins__": __builtins__
|
"__builtins__": __builtins__
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
# Compile and execute code
|
||||||
# Compile and execute code
|
compiled = compile(code, "<string>", "exec")
|
||||||
compiled = compile(code, "<string>", "exec")
|
|
||||||
|
|
||||||
# Capture output
|
# Capture output
|
||||||
import io
|
output = io.StringIO()
|
||||||
from contextlib import redirect_stdout
|
|
||||||
|
|
||||||
output = io.StringIO()
|
with redirect_stdout(output):
|
||||||
|
exec(compiled, namespace)
|
||||||
|
|
||||||
with redirect_stdout(output):
|
result = output.getvalue()
|
||||||
exec(compiled, namespace)
|
|
||||||
|
|
||||||
result = output.getvalue()
|
# Extract variables
|
||||||
|
result_vars = {k: v for k, v in namespace.items()
|
||||||
|
if not k.startswith("_") and k != "__builtins__"}
|
||||||
|
|
||||||
# Try to extract variables
|
return {
|
||||||
result_vars = {k: v for k, v in namespace.items()
|
"output": result,
|
||||||
if not k.startswith("_") and k != "__builtins__"}
|
"variables": {k: repr(v) for k, v in result_vars.items()}
|
||||||
|
}
|
||||||
return {
|
|
||||||
"output": result,
|
|
||||||
"variables": {k: repr(v) for k, v in result_vars.items()}
|
|
||||||
}
|
|
||||||
except SyntaxError as e:
|
|
||||||
return {"error": f"Syntax error: {e}"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Runtime error: {type(e).__name__}: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -86,20 +78,20 @@ def python_execute(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
},
|
},
|
||||||
"required": ["expression"]
|
"required": ["expression"]
|
||||||
},
|
},
|
||||||
|
required_params=["expression"],
|
||||||
category="code"
|
category="code"
|
||||||
)
|
)
|
||||||
def python_eval(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def python_eval(arguments: Dict[str, Any]):
|
||||||
"""Evaluate Python expression"""
|
"""
|
||||||
|
Evaluate Python expression
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"result": str, "type": str}
|
||||||
|
"""
|
||||||
expression = arguments.get("expression", "")
|
expression = arguments.get("expression", "")
|
||||||
|
|
||||||
if not expression:
|
result = eval(expression)
|
||||||
return {"error": "Expression is required"}
|
return {
|
||||||
|
"result": repr(result),
|
||||||
try:
|
"type": type(result).__name__
|
||||||
result = eval(expression)
|
}
|
||||||
return {
|
|
||||||
"result": repr(result),
|
|
||||||
"type": type(result).__name__
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Evaluation error: {str(e)}"}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
"""Crawler tools"""
|
"""Crawler tools - all exception handling in decorator"""
|
||||||
from luxx.tools.factory import tool
|
from luxx.tools.factory import tool
|
||||||
from luxx.tools.services import SearchService, FetchService
|
from luxx.tools.services import SearchService, FetchService
|
||||||
|
|
||||||
|
# 服务实例(SearchService.search() 是静态方法风格,不需要实例化)
|
||||||
|
_fetch_service = FetchService()
|
||||||
|
|
||||||
|
|
||||||
@tool(name="web_search", description="Search the internet. Use when you need to find latest news or answer questions.", parameters={
|
@tool(name="web_search", description="Search the internet. Use when you need to find latest news or answer questions.", parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -10,42 +13,63 @@ from luxx.tools.services import SearchService, FetchService
|
||||||
"max_results": {"type": "integer", "description": "Number of results, default 5", "default": 5}
|
"max_results": {"type": "integer", "description": "Number of results, default 5", "default": 5}
|
||||||
},
|
},
|
||||||
"required": ["query"]
|
"required": ["query"]
|
||||||
}, category="crawler")
|
}, required_params=["query"], category="crawler")
|
||||||
def web_search(arguments: dict) -> dict:
|
def web_search(arguments: dict):
|
||||||
results = SearchService().search(arguments["query"], arguments.get("max_results", 5))
|
"""
|
||||||
return {"results": results or []}
|
Search the web using DuckDuckGo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"query": str, "count": int, "results": list}
|
||||||
|
"""
|
||||||
|
query = arguments["query"]
|
||||||
|
max_results = arguments.get("max_results", 5)
|
||||||
|
|
||||||
|
results = SearchService().search(query, max_results)
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"count": len(results),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@tool(name="web_fetch", description="Fetch content from a webpage.", parameters={
|
@tool(name="web_fetch", description="Fetch content from a webpage.", parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"url": {"type": "string", "description": "URL to fetch"},
|
"url": {"type": "string", "description": "URL to fetch"}
|
||||||
"extract_type": {"type": "string", "enum": ["text", "links", "structured"], "default": "text"}
|
|
||||||
},
|
},
|
||||||
"required": ["url"]
|
"required": ["url"]
|
||||||
}, category="crawler")
|
}, required_params=["url"], category="crawler")
|
||||||
def web_fetch(arguments: dict) -> dict:
|
def web_fetch(arguments: dict):
|
||||||
if not arguments.get("url"):
|
"""
|
||||||
return {"error": "URL is required"}
|
Fetch webpage content
|
||||||
result = FetchService().fetch(arguments["url"], arguments.get("extract_type", "text"))
|
|
||||||
if "error" in result:
|
Returns:
|
||||||
return {"error": result["error"]}
|
{"url": str, "title": str, "text": str}
|
||||||
return result
|
"""
|
||||||
|
url = arguments["url"]
|
||||||
|
|
||||||
|
return _fetch_service.fetch(url)
|
||||||
|
|
||||||
|
|
||||||
@tool(name="batch_fetch", description="Batch fetch multiple webpages.", parameters={
|
@tool(name="batch_fetch", description="Batch fetch multiple webpages.", parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"urls": {"type": "array", "items": {"type": "string"}, "description": "URLs to fetch"},
|
"urls": {"type": "array", "items": {"type": "string"}, "description": "URLs to fetch"}
|
||||||
"extract_type": {"type": "string", "enum": ["text", "links", "structured"], "default": "text"}
|
|
||||||
},
|
},
|
||||||
"required": ["urls"]
|
"required": ["urls"]
|
||||||
}, category="crawler")
|
}, required_params=["urls"], category="crawler")
|
||||||
def batch_fetch(arguments: dict) -> dict:
|
def batch_fetch(arguments: dict):
|
||||||
|
"""
|
||||||
|
Batch fetch multiple webpages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"count": int, "results": list}
|
||||||
|
"""
|
||||||
urls = arguments.get("urls", [])
|
urls = arguments.get("urls", [])
|
||||||
if not urls:
|
|
||||||
return {"error": "URLs list is required"}
|
results = _fetch_service.fetch_batch(urls)
|
||||||
if len(urls) > 10:
|
|
||||||
return {"error": "Maximum 10 pages allowed"}
|
return {
|
||||||
results = FetchService().fetch_batch(urls, arguments.get("extract_type", "text"))
|
"count": len(results),
|
||||||
return {"results": results, "total": len(results)}
|
"results": results
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Data processing tools"""
|
"""Data processing tools - exception handling in decorator"""
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
|
@ -21,40 +22,39 @@ from luxx.tools.factory import tool
|
||||||
},
|
},
|
||||||
"required": ["expression"]
|
"required": ["expression"]
|
||||||
},
|
},
|
||||||
|
required_params=["expression"],
|
||||||
category="data"
|
category="data"
|
||||||
)
|
)
|
||||||
def calculate(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def calculate(arguments: Dict[str, Any]):
|
||||||
"""Execute mathematical calculation"""
|
"""
|
||||||
|
Execute mathematical calculation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"result": float, "expression": str}
|
||||||
|
"""
|
||||||
expression = arguments.get("expression", "")
|
expression = arguments.get("expression", "")
|
||||||
|
|
||||||
if not expression:
|
# Safe replacement for math functions
|
||||||
return {"error": "Expression is required"}
|
safe_dict = {
|
||||||
|
"abs": abs,
|
||||||
|
"round": round,
|
||||||
|
"min": min,
|
||||||
|
"max": max,
|
||||||
|
"pow": pow,
|
||||||
|
"sqrt": lambda x: x ** 0.5,
|
||||||
|
"sin": lambda x: __import__('math').sin(x),
|
||||||
|
"cos": lambda x: __import__('math').cos(x),
|
||||||
|
"tan": lambda x: __import__('math').tan(x),
|
||||||
|
"log": lambda x: __import__('math').log(x),
|
||||||
|
"pi": __import__('math').pi,
|
||||||
|
"e": __import__('math').e
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
# Remove dangerous characters, only keep numbers and operators
|
||||||
# Safe replacement for math functions
|
safe_expr = re.sub(r"[^0-9+\-*/().%sqrtinsclogmaxminpowabsroundte, ]", "", expression)
|
||||||
safe_dict = {
|
result = eval(safe_expr, {"__builtins__": {}, **safe_dict})
|
||||||
"abs": abs,
|
|
||||||
"round": round,
|
|
||||||
"min": min,
|
|
||||||
"max": max,
|
|
||||||
"pow": pow,
|
|
||||||
"sqrt": lambda x: x ** 0.5,
|
|
||||||
"sin": lambda x: __import__('math').sin(x),
|
|
||||||
"cos": lambda x: __import__('math').cos(x),
|
|
||||||
"tan": lambda x: __import__('math').tan(x),
|
|
||||||
"log": lambda x: __import__('math').log(x),
|
|
||||||
"pi": __import__('math').pi,
|
|
||||||
"e": __import__('math').e
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove dangerous characters, only keep numbers and operators
|
return {"result": result, "expression": expression}
|
||||||
safe_expr = re.sub(r"[^0-9+\-*/().%sqrtinsclogmaxminpowabsroundte, ]", "", expression)
|
|
||||||
|
|
||||||
result = eval(safe_expr, {"__builtins__": {}, **safe_dict})
|
|
||||||
|
|
||||||
return {"result": result}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Calculation error: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -69,22 +69,25 @@ def calculate(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
},
|
},
|
||||||
"operation": {
|
"operation": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Operation to perform: uppercase, lowercase, title, strip, reverse, word_count, char_count",
|
"description": "Operation to perform",
|
||||||
"enum": ["uppercase", "lowercase", "title", "strip", "reverse", "word_count", "char_count"]
|
"enum": ["uppercase", "lowercase", "title", "strip", "reverse", "word_count", "char_count"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["text", "operation"]
|
"required": ["text", "operation"]
|
||||||
},
|
},
|
||||||
|
required_params=["text", "operation"],
|
||||||
category="data"
|
category="data"
|
||||||
)
|
)
|
||||||
def text_process(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def text_process(arguments: Dict[str, Any]):
|
||||||
"""Text processing"""
|
"""
|
||||||
|
Text processing operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"operation": str, "result": str|int, "original_length": int}
|
||||||
|
"""
|
||||||
text = arguments.get("text", "")
|
text = arguments.get("text", "")
|
||||||
operation = arguments.get("operation", "")
|
operation = arguments.get("operation", "")
|
||||||
|
|
||||||
if not text:
|
|
||||||
return {"error": "Text is required"}
|
|
||||||
|
|
||||||
operations = {
|
operations = {
|
||||||
"uppercase": text.upper(),
|
"uppercase": text.upper(),
|
||||||
"lowercase": text.lower(),
|
"lowercase": text.lower(),
|
||||||
|
|
@ -96,11 +99,14 @@ def text_process(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
result = operations.get(operation)
|
result = operations.get(operation)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
return {"error": f"Unknown operation: {operation}"}
|
raise ValueError(f"Unknown operation: {operation}")
|
||||||
|
|
||||||
return {"result": result}
|
return {
|
||||||
|
"operation": operation,
|
||||||
|
"result": result,
|
||||||
|
"original_length": len(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -121,31 +127,31 @@ def text_process(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
},
|
},
|
||||||
"required": ["data", "operation"]
|
"required": ["data", "operation"]
|
||||||
},
|
},
|
||||||
|
required_params=["data", "operation"],
|
||||||
category="data"
|
category="data"
|
||||||
)
|
)
|
||||||
def json_process(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def json_process(arguments: Dict[str, Any]):
|
||||||
"""JSON data processing"""
|
"""
|
||||||
|
JSON data processing
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"operation": str, "result": str}
|
||||||
|
"""
|
||||||
data = arguments.get("data", "")
|
data = arguments.get("data", "")
|
||||||
operation = arguments.get("operation", "")
|
operation = arguments.get("operation", "")
|
||||||
|
|
||||||
if not data:
|
parsed = json.loads(data)
|
||||||
return {"error": "Data is required"}
|
|
||||||
|
|
||||||
try:
|
if operation == "format":
|
||||||
parsed = json.loads(data)
|
result = json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||||
|
elif operation == "minify":
|
||||||
|
result = json.dumps(parsed, ensure_ascii=False)
|
||||||
|
elif operation == "validate":
|
||||||
|
result = "Valid JSON"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operation: {operation}")
|
||||||
|
|
||||||
if operation == "format":
|
return {"operation": operation, "result": result}
|
||||||
result = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
||||||
elif operation == "minify":
|
|
||||||
result = json.dumps(parsed, ensure_ascii=False)
|
|
||||||
elif operation == "validate":
|
|
||||||
result = "Valid JSON"
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown operation: {operation}"}
|
|
||||||
|
|
||||||
return {"result": result}
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
return {"error": f"Invalid JSON: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -160,32 +166,35 @@ def json_process(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
},
|
},
|
||||||
"algorithm": {
|
"algorithm": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Hash algorithm: md5, sha1, sha256, sha512",
|
"description": "Hash algorithm",
|
||||||
|
"enum": ["md5", "sha1", "sha256", "sha512"],
|
||||||
"default": "sha256"
|
"default": "sha256"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["text"]
|
"required": ["text"]
|
||||||
},
|
},
|
||||||
|
required_params=["text"],
|
||||||
category="data"
|
category="data"
|
||||||
)
|
)
|
||||||
def hash_text(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def hash_text(arguments: Dict[str, Any]):
|
||||||
"""Generate text hash"""
|
"""
|
||||||
import hashlib
|
Generate text hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"algorithm": str, "hash": str, "length": int}
|
||||||
|
"""
|
||||||
text = arguments.get("text", "")
|
text = arguments.get("text", "")
|
||||||
algorithm = arguments.get("algorithm", "sha256")
|
algorithm = arguments.get("algorithm", "sha256")
|
||||||
|
|
||||||
if not text:
|
hash_obj = hashlib.new(algorithm)
|
||||||
return {"error": "Text is required"}
|
hash_obj.update(text.encode('utf-8'))
|
||||||
|
hash_value = hash_obj.hexdigest()
|
||||||
|
|
||||||
try:
|
return {
|
||||||
hash_obj = hashlib.new(algorithm)
|
"algorithm": algorithm,
|
||||||
hash_obj.update(text.encode('utf-8'))
|
"hash": hash_value,
|
||||||
hash_value = hash_obj.hexdigest()
|
"length": len(hash_value)
|
||||||
|
}
|
||||||
return {"hash": hash_value}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Hash error: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -200,33 +209,33 @@ def hash_text(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
},
|
},
|
||||||
"operation": {
|
"operation": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Operation: encode, decode",
|
"description": "Operation",
|
||||||
"enum": ["encode", "decode"]
|
"enum": ["encode", "decode"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["text", "operation"]
|
"required": ["text", "operation"]
|
||||||
},
|
},
|
||||||
|
required_params=["text", "operation"],
|
||||||
category="data"
|
category="data"
|
||||||
)
|
)
|
||||||
def url_encode_decode(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def url_encode_decode(arguments: Dict[str, Any]):
|
||||||
"""URL encoding/decoding"""
|
"""
|
||||||
|
URL encoding/decoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"operation": str, "result": str}
|
||||||
|
"""
|
||||||
text = arguments.get("text", "")
|
text = arguments.get("text", "")
|
||||||
operation = arguments.get("operation", "")
|
operation = arguments.get("operation", "")
|
||||||
|
|
||||||
if not text:
|
if operation == "encode":
|
||||||
return {"error": "Text is required"}
|
result = quote(text)
|
||||||
|
elif operation == "decode":
|
||||||
|
result = unquote(text)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operation: {operation}")
|
||||||
|
|
||||||
try:
|
return {"operation": operation, "result": result}
|
||||||
if operation == "encode":
|
|
||||||
result = quote(text)
|
|
||||||
elif operation == "decode":
|
|
||||||
result = unquote(text)
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown operation: {operation}"}
|
|
||||||
|
|
||||||
return {"result": result}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"URL error: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|
@ -241,30 +250,30 @@ def url_encode_decode(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
},
|
},
|
||||||
"operation": {
|
"operation": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Operation: encode, decode",
|
"description": "Operation",
|
||||||
"enum": ["encode", "decode"]
|
"enum": ["encode", "decode"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["text", "operation"]
|
"required": ["text", "operation"]
|
||||||
},
|
},
|
||||||
|
required_params=["text", "operation"],
|
||||||
category="data"
|
category="data"
|
||||||
)
|
)
|
||||||
def base64_encode_decode(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def base64_encode_decode(arguments: Dict[str, Any]):
|
||||||
"""Base64 encoding/decoding"""
|
"""
|
||||||
|
Base64 encoding/decoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"operation": str, "result": str}
|
||||||
|
"""
|
||||||
text = arguments.get("text", "")
|
text = arguments.get("text", "")
|
||||||
operation = arguments.get("operation", "")
|
operation = arguments.get("operation", "")
|
||||||
|
|
||||||
if not text:
|
if operation == "encode":
|
||||||
return {"error": "Text is required"}
|
result = base64.b64encode(text.encode()).decode()
|
||||||
|
elif operation == "decode":
|
||||||
|
result = base64.b64decode(text.encode()).decode()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operation: {operation}")
|
||||||
|
|
||||||
try:
|
return {"operation": operation, "result": result}
|
||||||
if operation == "encode":
|
|
||||||
result = base64.b64encode(text.encode()).decode()
|
|
||||||
elif operation == "decode":
|
|
||||||
result = base64.b64decode(text.encode()).decode()
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown operation: {operation}"}
|
|
||||||
|
|
||||||
return {"result": result}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Base64 error: {str(e)}"}
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
"""Tool decorator factory"""
|
"""Tool decorator factory with unified result wrapping"""
|
||||||
from typing import Callable, Any, Dict
|
from typing import Callable, Any, Dict, Optional, List
|
||||||
from luxx.tools.core import ToolDefinition, registry
|
from luxx.tools.core import ToolDefinition, ToolResult, registry
|
||||||
|
import traceback
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def tool(
|
def tool(
|
||||||
name: str,
|
name: str,
|
||||||
description: str,
|
description: str,
|
||||||
parameters: Dict[str, Any],
|
parameters: Dict[str, Any],
|
||||||
category: str = "general"
|
category: str = "general",
|
||||||
|
required_params: Optional[List[str]] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Tool registration decorator
|
Tool registration decorator with UNIFED result wrapping
|
||||||
|
|
||||||
|
The decorator automatically wraps all return values into ToolResult format.
|
||||||
|
Tool functions only need to return plain data - no need to use ToolResult manually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Tool name
|
||||||
|
description: Tool description
|
||||||
|
parameters: OpenAI format parameters schema
|
||||||
|
category: Tool category
|
||||||
|
required_params: List of required parameter names (auto-validated)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
```python
|
```python
|
||||||
|
|
@ -23,21 +38,52 @@ def tool(
|
||||||
"arg1": {"type": "string"}
|
"arg1": {"type": "string"}
|
||||||
},
|
},
|
||||||
"required": ["arg1"]
|
"required": ["arg1"]
|
||||||
}
|
},
|
||||||
|
required_params=["arg1"] # Auto-validated
|
||||||
)
|
)
|
||||||
def my_tool(arguments: dict) -> dict:
|
def my_tool(arguments: dict):
|
||||||
# Implementation...
|
# Just return plain data - decorator handles wrapping!
|
||||||
return {"result": "success"}
|
return {"result": "success", "count": 5}
|
||||||
|
|
||||||
# The tool will be automatically registered
|
# For errors, return a dict with "error" key:
|
||||||
|
def my_tool2(arguments: dict):
|
||||||
|
return {"error": "Something went wrong"}
|
||||||
|
|
||||||
|
# Or raise an exception:
|
||||||
|
def my_tool3(arguments: dict):
|
||||||
|
raise ValueError("Invalid input")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The decorator will convert:
|
||||||
|
- Plain dict → {"success": true, "data": dict, "error": null}
|
||||||
|
- {"error": "msg"} → {"success": false, "data": null, "error": "msg"}
|
||||||
|
- Exception → {"success": false, "data": null, "error": "..."}
|
||||||
"""
|
"""
|
||||||
def decorator(func: Callable) -> Callable:
|
def decorator(func: Callable) -> Callable:
|
||||||
|
def wrapped_handler(arguments: Dict[str, Any]) -> ToolResult:
|
||||||
|
try:
|
||||||
|
# 1. Validate required params
|
||||||
|
if required_params:
|
||||||
|
for param in required_params:
|
||||||
|
if param not in arguments or arguments[param] is None:
|
||||||
|
return ToolResult.fail(f"Missing required parameter: {param}")
|
||||||
|
|
||||||
|
# 2. Execute handler
|
||||||
|
result = func(arguments)
|
||||||
|
|
||||||
|
# 3. Auto-wrap result
|
||||||
|
return _auto_wrap(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[{name}] Unexpected error: {type(e).__name__}: {str(e)}")
|
||||||
|
logger.debug(traceback.format_exc())
|
||||||
|
return ToolResult.fail(f"Execution failed: {type(e).__name__}: {str(e)}")
|
||||||
|
|
||||||
tool_def = ToolDefinition(
|
tool_def = ToolDefinition(
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
parameters=parameters,
|
parameters=parameters,
|
||||||
handler=func,
|
handler=wrapped_handler,
|
||||||
category=category
|
category=category
|
||||||
)
|
)
|
||||||
registry.register(tool_def)
|
registry.register(tool_def)
|
||||||
|
|
@ -45,13 +91,44 @@ def tool(
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_wrap(result: Any) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Auto-wrap any return value into ToolResult format
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- ToolResult → use directly
|
||||||
|
- dict with "error" key → ToolResult.fail()
|
||||||
|
- dict without "error" → ToolResult.ok()
|
||||||
|
- other values → ToolResult.ok()
|
||||||
|
"""
|
||||||
|
# Already a ToolResult
|
||||||
|
if isinstance(result, ToolResult):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Dict with error
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return ToolResult.fail(str(result["error"]))
|
||||||
|
|
||||||
|
# Plain dict or other value
|
||||||
|
return ToolResult.ok(result)
|
||||||
|
|
||||||
|
|
||||||
def tool_function(
|
def tool_function(
|
||||||
name: str = None,
|
name: str = None,
|
||||||
description: str = None,
|
description: str = None,
|
||||||
parameters: Dict[str, Any] = None,
|
parameters: Dict[str, Any] = None,
|
||||||
category: str = "general"
|
category: str = "general",
|
||||||
|
required_params: Optional[List[str]] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Alias for tool decorator, providing a more semantic naming
|
Alias for tool decorator, providing a more semantic naming
|
||||||
|
|
||||||
|
All parameters are the same as tool()
|
||||||
"""
|
"""
|
||||||
return tool(name=name, description=description, parameters=parameters, category=category)
|
return tool(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
parameters=parameters,
|
||||||
|
category=category,
|
||||||
|
required_params=required_params
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,13 @@ class SearchService:
|
||||||
|
|
||||||
def search(self, query: str, max_results: int = 5) -> List[dict]:
|
def search(self, query: str, max_results: int = 5) -> List[dict]:
|
||||||
results = []
|
results = []
|
||||||
try:
|
with DDGS() as client:
|
||||||
for result in DDGS().text(query, max_results=max_results):
|
for result in client.text(query, max_results=max_results):
|
||||||
results.append({
|
results.append({
|
||||||
"title": result.get("title", ""),
|
"title": result.get("title", ""),
|
||||||
"url": result.get("href", ""),
|
"url": result.get("href", ""),
|
||||||
"snippet": result.get("body", "")
|
"snippet": result.get("body", "")
|
||||||
})
|
})
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,32 +33,27 @@ class FetchService:
|
||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
url = "https://" + url
|
url = "https://" + url
|
||||||
|
|
||||||
try:
|
headers = {
|
||||||
headers = {
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
}
|
||||||
|
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
|
||||||
|
response = client.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|
||||||
|
# Remove script and style elements
|
||||||
|
for script in soup(["script", "style"]):
|
||||||
|
script.decompose()
|
||||||
|
|
||||||
|
title = soup.title.string if soup.title else ""
|
||||||
|
text = soup.get_text(separator="\n", strip=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"title": title[:500] if title else "",
|
||||||
|
"text": text[:15000]
|
||||||
}
|
}
|
||||||
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
|
|
||||||
response = client.get(url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
|
|
||||||
# Remove script and style elements
|
|
||||||
for script in soup(["script", "style"]):
|
|
||||||
script.decompose()
|
|
||||||
|
|
||||||
title = soup.title.string if soup.title else ""
|
|
||||||
text = soup.get_text(separator="\n", strip=True)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"url": url,
|
|
||||||
"title": title[:500] if title else "",
|
|
||||||
"text": text[:15000]
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
return {"url": url, "title": "", "text": ""}
|
|
||||||
|
|
||||||
def fetch_batch(self, urls: List[str], extract_type: str = "text", max_concurrent: int = 5) -> List[dict]:
|
def fetch_batch(self, urls: List[str], extract_type: str = "text", max_concurrent: int = 5) -> List[dict]:
|
||||||
if len(urls) <= 1:
|
if len(urls) <= 1:
|
||||||
|
|
@ -72,9 +65,6 @@ class FetchService:
|
||||||
with ThreadPoolExecutor(max_workers=max_concurrent) as pool:
|
with ThreadPoolExecutor(max_workers=max_concurrent) as pool:
|
||||||
futures = {pool.submit(self.fetch, url, extract_type): i for i, url in enumerate(urls)}
|
futures = {pool.submit(self.fetch, url, extract_type): i for i, url in enumerate(urls)}
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
try:
|
results[futures[future]] = future.result()
|
||||||
results[futures[future]] = future.result()
|
|
||||||
except Exception as e:
|
|
||||||
results[futures[future]] = {"error": str(e)}
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue