fix: 修复api 适配问题

This commit is contained in:
ViperEkura 2026-05-04 21:20:36 +08:00
parent e070dca10e
commit c332380080
16 changed files with 464 additions and 73 deletions

View File

@ -0,0 +1,48 @@
<template>
<div class="pagination">
<button class="page-btn" :disabled="page <= 1" @click="$emit('page-change', page - 1)"></button>
<template v-for="p in visiblePages">
<span v-if="p === '...'" :key="p + '-ellipsis'" class="page-ellipsis"></span>
<button v-else :key="p" class="page-btn" :class="{ active: p === page }" @click="$emit('page-change', p)">{{ p }}</button>
</template>
<button class="page-btn" :disabled="page >= totalPages" @click="$emit('page-change', page + 1)"></button>
<span class="page-info"> {{ total }} </span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
total: { type: Number, default: 0 }
})
defineEmits(['page-change'])
const visiblePages = computed(() => {
const tp = props.totalPages
const cur = props.page
if (tp <= 7) return Array.from({ length: tp }, (_, i) => i + 1)
const pages = []
if (cur <= 4) {
pages.push(1, 2, 3, 4, 5, '...', tp)
} else if (cur >= tp - 3) {
pages.push(1, '...', tp - 4, tp - 3, tp - 2, tp - 1, tp)
} else {
pages.push(1, '...', cur - 1, cur, cur + 1, '...', tp)
}
return pages
})
</script>
<style scoped>
.pagination { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
.page-btn { padding: 0.25rem 0.5rem; border: 1px solid #d1d5db; background: white; border-radius: 0.25rem; cursor: pointer; font-size: 0.875rem; }
.page-btn:hover:not(:disabled) { background: #f3f4f6; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
.page-ellipsis { padding: 0.25rem 0.25rem; color: #6b7280; }
.page-info { font-size: 0.875rem; color: #6b7280; margin-left: 0.5rem; }
</style>

View File

@ -142,7 +142,8 @@ const allItems = computed(() => {
displayContent = JSON.stringify(parsed, null, 2) displayContent = JSON.stringify(parsed, null, 2)
} }
} catch (e) { } catch (e) {
// JSON // JSON
console.warn('Failed to parse tool result JSON:', e)
} }
match.resultSummary = displayContent.slice(0, 200) match.resultSummary = displayContent.slice(0, 200)

View File

@ -149,7 +149,8 @@ class StreamManager {
const data = JSON.parse(line.slice(6)) const data = JSON.parse(line.slice(6))
this._handleEvent(conversationId, currentEvent, data, streamStore, null) this._handleEvent(conversationId, currentEvent, data, streamStore, null)
} catch (e) { } catch (e) {
// 忽略解析错误 console.error('SSE parse error in _processLines:', e, 'line:', line)
streamStore.errorStream(conversationId, `Parse error: ${e.message}`)
} }
} }
} }

View File

@ -48,7 +48,16 @@ export const useStreamStore = defineStore('stream', () => {
const idx = state.process_steps.findIndex(s => s.id === step.id) const idx = state.process_steps.findIndex(s => s.id === step.id)
if (idx >= 0) { if (idx >= 0) {
// 对于 thinking/text 步骤,后端发送的是增量内容(基于 offset
// 需要追加到已有内容上,而不是替换
if (step.type === 'thinking' || step.type === 'text') {
const existing = state.process_steps[idx]
existing.content = (existing.content || '') + (step.content || '')
// 触发响应式更新
state.process_steps[idx] = { ...existing }
} else {
state.process_steps[idx] = step state.process_steps[idx] = step
}
} else { } else {
state.process_steps.push(step) state.process_steps.push(step)
} }

View File

@ -48,11 +48,12 @@
</div> </div>
</div> </div>
<div v-if="totalPages > 1" class="sidebar-pagination"> <Pagination
<button @click="page--; fetchData()" :disabled="page === 1" class="btn-page"></button> v-if="totalPages > 1"
<span>{{ page }} / {{ totalPages }}</span> :page="page"
<button @click="page++; fetchData()" :disabled="page >= totalPages" class="btn-page"></button> :total-pages="totalPages"
</div> @page-change="handlePageChange"
/>
</aside> </aside>
<!-- 右侧内容区 - 对话界面 --> <!-- 右侧内容区 - 对话界面 -->
@ -170,6 +171,7 @@ import { formatDate } from '../utils/useFormatters.js'
import ProcessBlock from '../components/ProcessBlock.vue' import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue' import MessageNav from '../components/MessageNav.vue'
import MessageBubble from '../components/MessageBubble.vue' import MessageBubble from '../components/MessageBubble.vue'
import Pagination from '../components/Pagination.vue'
const { const {
list, list,
@ -277,6 +279,12 @@ const handleRegenerateMessage = async (msgId) => {
} }
//
const handlePageChange = (newPage) => {
page.value = newPage
fetchData()
}
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 || ''

View File

@ -155,6 +155,7 @@
<div class="section-title"> <div class="section-title">
<span class="section-icon">🔌</span> <span class="section-icon">🔌</span>
<span class="section-text">LLM Provider</span> <span class="section-text">LLM Provider</span>
<span class="section-count">{{ providers.length }}</span>
</div> </div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div> <div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
@ -163,16 +164,17 @@
<table class="settings-table"> <table class="settings-table">
<thead> <thead>
<tr> <tr>
<th>名称</th> <th>名称 / 类型</th>
<th>API / 模型</th> <th>API / 模型</th>
<th class="switch-col">启用</th> <th class="switch-col">启用</th>
<th class="ops-col">操作</th> <th class="ops-col">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="p in providers" :key="p.id"> <tr v-for="p in paginatedProviders" :key="p.id">
<td class="name-col"> <td class="name-col">
<div class="provider-name">{{ p.name }}</div> <div class="provider-name">{{ p.name }}</div>
<div class="provider-type">{{ p.provider_type }}</div>
<div class="provider-badges"> <div class="provider-badges">
<span v-if="p.is_default" class="badge badge-default">默认</span> <span v-if="p.is_default" class="badge badge-default">默认</span>
<span v-if="p.enabled" class="badge badge-enabled">启用</span> <span v-if="p.enabled" class="badge badge-enabled">启用</span>
@ -180,9 +182,9 @@
</div> </div>
</td> </td>
<td class="info-col"> <td class="info-col">
<div class="info-item">{{ p.base_url }}</div> <div class="info-item" :title="p.base_url">{{ p.base_url }}</div>
<div class="info-item sub">模型: {{ p.default_model }}</div> <div class="info-item sub">模型: <code>{{ p.default_model }}</code></div>
<div class="info-item sub">最大Tokens: {{ p.max_tokens || 8192 }}</div> <div class="info-item sub">最大 Tokens: {{ p.max_tokens || 8192 }}</div>
</td> </td>
<td class="switch-col"> <td class="switch-col">
<label class="switch" @click.prevent="toggleEnabled(p)"> <label class="switch" @click.prevent="toggleEnabled(p)">
@ -208,7 +210,15 @@
<tfoot> <tfoot>
<tr> <tr>
<td colspan="4" class="table-footer"> <td colspan="4" class="table-footer">
<button @click="showModal = true" class="btn-primary"> <Pagination
v-if="providerTotalPages > 1"
:page="providerPage"
:total-pages="providerTotalPages"
:total="providers.length"
@page-change="handleProviderPageChange"
class="pagination-inline"
/>
<button @click="showModal = true; editing = null" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg> <svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg>
添加 Provider 添加 Provider
</button> </button>
@ -240,7 +250,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="u in users" :key="u.id"> <tr v-for="u in paginatedUsers" :key="u.id">
<td class="name-col">{{ u.username }}</td> <td class="name-col">{{ u.username }}</td>
<td class="info-col">{{ u.email || '-' }}</td> <td class="info-col">{{ u.email || '-' }}</td>
<td>{{ u.role }}</td> <td>{{ u.role }}</td>
@ -257,6 +267,19 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tfoot v-if="userTotalPages > 1">
<tr>
<td colspan="5" class="table-footer">
<Pagination
:page="userPage"
:total-pages="userTotalPages"
:total="users.length"
@page-change="handleUserPageChange"
class="pagination-inline"
/>
</td>
</tr>
</tfoot>
</table> </table>
</div> </div>
<div v-else class="empty-card">暂无用户</div> <div v-else class="empty-card">暂无用户</div>
@ -308,23 +331,55 @@
<!-- 添加/编辑 Provider 模态框 --> <!-- 添加/编辑 Provider 模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal"> <div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal"> <div class="modal provider-modal">
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2> <h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
<div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div> <div class="form-group">
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/chat/completions" /></div> <label>名称 <span class="required">*</span></label>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div> <input v-model="form.name" placeholder="如: 我的 DeepSeek" />
</div>
<div class="form-group"> <div class="form-group">
<label>模型名称</label> <label>Provider 类型 <span class="required">*</span></label>
<select v-model="form.provider_type" class="form-select">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="deepseek">DeepSeek</option>
<option value="glm">GLM (智谱)</option>
<option value="openai-compatible">OpenAI 兼容</option>
</select>
<span class="hint">不同类型的 API 格式和认证方式不同</span>
</div>
<div class="form-group">
<label>Base URL <span class="required">*</span></label>
<input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" />
</div>
<div class="form-group">
<label>API Key</label>
<input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" />
<span v-if="editing" class="hint">留空则不修改已有 Key</span>
</div>
<div class="form-group">
<label>模型名称 <span class="required">*</span></label>
<input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required /> <input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>最大 Tokens</label> <label>最大 Tokens</label>
<input v-model.number="form.max_tokens" type="number" placeholder="8192" min="1" /> <input v-model.number="form.max_tokens" type="number" placeholder="8192" min="1" />
<span class="hint">单次回复最大 token 默认 8192</span> <span class="hint">单次回复最大 token 默认 8192</span>
</div> </div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input v-model="form.is_default" type="checkbox" />
<span>设为默认 Provider</span>
</label>
</div>
<div v-if="formError" class="error">{{ formError }}</div> <div v-if="formError" class="error">{{ formError }}</div>
<div class="modal-actions"> <div class="modal-actions">
@ -343,11 +398,12 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { providersAPI } from '../utils/api.js' import { providersAPI } from '../utils/api.js'
import { useAuth } from '../utils/useAuth.js' import { useAuth } from '../utils/useAuth.js'
import { authAPI } from '../utils/api.js' import { authAPI } from '../utils/api.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Pagination from '../components/Pagination.vue'
const router = useRouter() const router = useRouter()
const { logout, user: currentUser } = useAuth() const { logout, user: currentUser } = useAuth()
@ -465,8 +521,32 @@ const testing = ref(null)
const testResult = ref(null) const testResult = ref(null)
const formError = ref('') const formError = ref('')
// Provider
const providerPage = ref(1)
const providerPageSize = 10
const providerTotalPages = computed(() => Math.max(1, Math.ceil(providers.value.length / providerPageSize)))
const paginatedProviders = computed(() => {
const start = (providerPage.value - 1) * providerPageSize
return providers.value.slice(start, start + providerPageSize)
})
const handleProviderPageChange = (newPage) => {
providerPage.value = newPage
}
//
const userPage = ref(1)
const userPageSize = 10
const userTotalPages = computed(() => Math.max(1, Math.ceil(users.value.length / userPageSize)))
const paginatedUsers = computed(() => {
const start = (userPage.value - 1) * userPageSize
return users.value.slice(start, start + userPageSize)
})
const handleUserPageChange = (newPage) => {
userPage.value = newPage
}
const form = ref({ const form = ref({
name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192 name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false
}) })
const fetchProviders = async () => { const fetchProviders = async () => {
@ -504,7 +584,7 @@ const fetchSettings = async () => {
const closeModal = () => { const closeModal = () => {
showModal.value = false showModal.value = false
editing.value = null editing.value = null
form.value = { name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192 } form.value = { name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false }
formError.value = '' formError.value = ''
} }
@ -515,10 +595,12 @@ const editProvider = async (p) => {
if (res.success && res.data) { if (res.success && res.data) {
form.value = { form.value = {
name: res.data.name, name: res.data.name,
provider_type: res.data.provider_type || 'openai',
base_url: res.data.base_url, base_url: res.data.base_url,
api_key: '', // api_key: '', //
default_model: res.data.default_model, default_model: res.data.default_model,
max_tokens: res.data.max_tokens || 8192 max_tokens: res.data.max_tokens || 8192,
is_default: res.data.is_default || false
} }
} }
} catch (e) { } catch (e) {
@ -663,6 +745,19 @@ onMounted(() => {
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; } .section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
.section-icon { font-size: 1rem; } .section-icon { font-size: 1rem; }
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); } .section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
.section-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-primary);
color: white;
font-size: 0.7rem;
font-weight: 600;
margin-left: 0.35rem;
}
/* 内联输入框 */ /* 内联输入框 */
.inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; } .inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; }
@ -678,6 +773,12 @@ onMounted(() => {
/* 列宽 */ /* 列宽 */
.name-col { width: 15%; min-width: 120px; } .name-col { width: 15%; min-width: 120px; }
.provider-type {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 0.2rem;
font-family: monospace;
}
.info-col { width: 60%; min-width: 200px; } .info-col { width: 60%; min-width: 200px; }
.switch-col { text-align: center; width: 80px; } .switch-col { text-align: center; width: 80px; }
.ops-col { width: 15%; min-width: 180px; text-align: center; } .ops-col { width: 15%; min-width: 180px; text-align: center; }
@ -692,6 +793,7 @@ onMounted(() => {
/* 表格底部 */ /* 表格底部 */
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); } .table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
.pagination-inline { display: inline-flex; margin-right: 1rem; border-top: none; padding: 0; background: transparent; }
/* Provider 单元格 */ /* Provider 单元格 */
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); } .provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
@ -731,6 +833,7 @@ onMounted(() => {
/* 模态框 */ /* 模态框 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; } .modal { background: var(--bg-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; }
.provider-modal { max-width: 480px; }
.modal h2 { margin: 0 0 1.25rem; color: var(--text-primary); font-size: 1.1rem; } .modal h2 { margin: 0 0 1.25rem; color: var(--text-primary); font-size: 1.1rem; }
.result-modal { text-align: center; } .result-modal { text-align: center; }
@ -745,9 +848,37 @@ onMounted(() => {
.result-modal .result-json { background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; } .result-modal .result-json { background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; } .modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group .required { color: var(--danger-color); }
.form-select {
width: 100%;
padding: 0.65rem;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--bg-input);
box-sizing: border-box;
color: var(--text-primary);
font-size: 0.9rem;
}
.checkbox-group { margin-top: 0.5rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
cursor: pointer;
}
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; } .form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; } .form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
.info-item code {
background: var(--bg-code);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.8rem;
}
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; } .optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; } .error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }

View File

@ -150,7 +150,8 @@ async def stream_message(
user_id=current_user.id, user_id=current_user.id,
username=current_user.username, username=current_user.username,
workspace=workspace, workspace=workspace,
user_permission_level=current_user.permission_level user_permission_level=current_user.permission_level,
skip_user_message=True # User message already saved in DB above
): ):
# Chat service returns raw SSE strings (including done event) # Chat service returns raw SSE strings (including done event)
yield sse_str yield sse_str

View File

@ -6,13 +6,29 @@ from pydantic import BaseModel
from luxx.database import get_db from luxx.database import get_db
from luxx.models import User from luxx.models import User
from luxx.routes.auth import get_current_user from luxx.routes.auth import get_current_user
from luxx.tools.core import registry from luxx.tools.core import registry, CommandPermission
from luxx.utils.helpers import success_response from luxx.utils.helpers import success_response
router = APIRouter(prefix="/tools", tags=["Tools"]) router = APIRouter(prefix="/tools", tags=["Tools"])
def _tool_to_detail(tool) -> Dict[str, Any]:
"""Convert tool definition to a detailed dict with permission and category info"""
# to_openai_format returns {"type": "function", "function": {name, description, parameters}}
# Flatten the function properties to top level for easier frontend access
base = tool.to_openai_format()
func = base.get("function", {})
return {
"name": func.get("name", tool.name),
"description": func.get("description", tool.description),
"parameters": func.get("parameters", tool.parameters),
"category": tool.category,
"required_permission": tool.required_permission.name,
"required_permission_level": int(tool.required_permission)
}
@router.get("/", response_model=dict) @router.get("/", response_model=dict)
def list_tools( def list_tools(
category: Optional[str] = None, category: Optional[str] = None,
@ -23,17 +39,17 @@ def list_tools(
if category: if category:
categorized_tools = [t for t in registry._tools.values() if t.category == category] categorized_tools = [t for t in registry._tools.values() if t.category == category]
tools = [t.to_openai_format() for t in categorized_tools] tools = [_tool_to_detail(t) for t in categorized_tools]
else: else:
categorized_tools = list(registry._tools.values()) categorized_tools = list(registry._tools.values())
tools = registry.list_all() tools = [_tool_to_detail(t) for t in categorized_tools]
categorized = {} categorized = {}
for tool in categorized_tools: for tool in categorized_tools:
cat = tool.category cat = tool.category
if cat not in categorized: if cat not in categorized:
categorized[cat] = [] categorized[cat] = []
categorized[cat].append(tool.to_openai_format()) categorized[cat].append(_tool_to_detail(tool))
return success_response(data={ return success_response(data={
"tools": tools, "tools": tools,
@ -53,7 +69,7 @@ def get_tool(
if not tool: if not tool:
return {"success": False, "message": "Tool not found", "code": 404} return {"success": False, "message": "Tool not found", "code": 404}
return success_response(data=tool.to_openai_format()) return success_response(data=_tool_to_detail(tool))
@router.post("/{name}/execute", response_model=dict) @router.post("/{name}/execute", response_model=dict)

View File

@ -2,6 +2,7 @@
This module follows the Single Responsibility Principle. This module follows the Single Responsibility Principle.
""" """
import json
import uuid import uuid
import logging import logging
from typing import List, Dict, AsyncGenerator from typing import List, Dict, AsyncGenerator
@ -22,6 +23,7 @@ class AgenticLoop:
def __init__(self, tool_executor: ToolExecutor): def __init__(self, tool_executor: ToolExecutor):
self.tool_executor = tool_executor self.tool_executor = tool_executor
self._reasoning_content = "" # DeepSeek thinking mode - persists across iterations
async def execute( async def execute(
self, self,
@ -37,18 +39,42 @@ class AgenticLoop:
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
# Reset reasoning_content for each execution (DeepSeek thinking mode)
self._reasoning_content = ""
for iteration in range(MAX_ITERATIONS): for iteration in range(MAX_ITERATIONS):
# Per-iteration reset, keep previous steps and tool results # Per-iteration reset, keep previous steps and tool results
context.reset(full_reset=False) context.reset(full_reset=False)
# BUG FIX: DeepSeek thinking mode requires reasoning_content to be passed back.
# Without this, DeepSeek returns:
# "The `reasoning_content` in the thinking mode must be passed back to the API."
# We find the last assistant message and add reasoning_content from the previous
# iteration's accumulated thinking content.
if iteration > 0 and self._reasoning_content:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant":
messages[i]["reasoning_content"] = self._reasoning_content
break
async for delta in llm.stream_call( async for delta in llm.stream_call(
model=model, model=model,
messages=messages, messages=messages,
tools=tools, tools=tools if iteration == 0 else None, # Only send tools on first iteration
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
thinking_enabled=thinking_enabled thinking_enabled=thinking_enabled
): ):
# Handle error delta - propagate as SSE error event
if delta.has_error():
logger.error(f"[AGENTIC_LOOP] Stream error: {delta.error_msg}")
yield sse_event("error", {"content": delta.error_msg})
return
# Accumulate reasoning_content for DeepSeek thinking mode
if delta.has_thinking():
self._reasoning_content += delta.thinking
events = self._process_delta(delta, context, total_usage) events = self._process_delta(delta, context, total_usage)
for event in events: for event in events:
yield event yield event
@ -88,6 +114,19 @@ class AgenticLoop:
"total_tokens": delta.usage.get("total_tokens", 0) "total_tokens": delta.usage.get("total_tokens", 0)
}) })
# BUG FIX: Handle DeepSeek reasoning_content (delta.thinking).
# DeepSeek sends reasoning_content in separate deltas from normal content.
# These must be rendered as thinking steps AND accumulated into
# self._reasoning_content for subsequent API calls (line 71-72).
# Without this, DeepSeek returns 400:
# "The `reasoning_content` in the thinking mode must be passed back to the API."
if delta.thinking:
if ctx.current_step_type != StepType.THINKING:
ctx.start_step(StepType.THINKING)
ctx.full_thinking += delta.thinking
events.append(StreamRenderer.render_thinking(ctx))
ctx._thinking_offset = len(ctx.full_thinking)
if delta.content: if delta.content:
result = ctx.process_content(delta.content) result = ctx.process_content(delta.content)
if result["should_emit"]: if result["should_emit"]:
@ -120,12 +159,30 @@ class AgenticLoop:
"""Execute tools and add results to messages""" """Execute tools and add results to messages"""
events = [] events = []
# Check if there's an active streaming step that needs to be finalized
# before tool execution (e.g. text step started but not finalized)
if ctx.current_step_id is not None:
ctx.finalize_step()
for event in StreamRenderer.render_tool_calls(ctx): for event in StreamRenderer.render_tool_calls(ctx):
events.append(event) events.append(event)
# Execute tools and handle any exceptions
try:
tool_results = self.tool_executor.process_tool_calls_parallel( tool_results = self.tool_executor.process_tool_calls_parallel(
ctx.tool_calls_list, tool_context or {} ctx.tool_calls_list, tool_context or {}
) )
except Exception as e:
logger.error(f"[EXECUTE_TOOLS] Tool execution failed: {type(e).__name__}: {e}")
# Generate error results for each tool call
tool_results = []
for tc in ctx.tool_calls_list:
tool_results.append({
"tool_call_id": tc.get("id", ""),
"role": "tool",
"name": tc.get("function", {}).get("name", "unknown"),
"content": json.dumps({"success": False, "error": f"Tool execution failed: {type(e).__name__}: {str(e)}"})
})
# Build mapping from LLM tool_call_id to step id # Build mapping from LLM tool_call_id to step id
# Use index-based matching as fallback when id is empty # Use index-based matching as fallback when id is empty
@ -146,7 +203,7 @@ class AgenticLoop:
logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}") logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}")
break break
# Fallback: use index-based matching # Fallback: use index-based matching (most reliable for parallel execution)
if ref_id is None and i < len(tool_call_steps): if ref_id is None and i < len(tool_call_steps):
ref_id = tool_call_steps[i].id ref_id = tool_call_steps[i].id
logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}") logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}")
@ -154,16 +211,31 @@ class AgenticLoop:
# Last resort: generate a step id # Last resort: generate a step id
if ref_id is None: if ref_id is None:
ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}" ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}"
logger.debug(f"[EXECUTE_TOOLS] Generated ref_id: {ref_id}") logger.warning(f"[EXECUTE_TOOLS] Could not match tool result, generated ref_id: {ref_id}")
_, event = StreamRenderer.render_tool_result(ctx, tr, ref_id) _, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
events.append(event) events.append(event)
messages.append({ # When tool_calls exist, content should be null (OpenAI API spec)
assistant_msg = {
"role": "assistant", "role": "assistant",
"content": ctx.full_content or "",
"tool_calls": ctx.tool_calls_list "tool_calls": ctx.tool_calls_list
}) }
# When tool_calls exist, content must be null (don't include text before tool calls)
if not ctx.tool_calls_list and ctx.full_content and not ctx.full_content.isspace():
assistant_msg["content"] = ctx.full_content
# If tool_calls exist, omit content field entirely (or set to null)
# BUG FIX: Include reasoning_content in assistant message for DeepSeek.
# DeepSeek's thinking mode REQUIRES reasoning_content to be echoed back
# to the API in every subsequent request. Without this, the API returns:
# HTTP 400: "The `reasoning_content` in the thinking mode must be
# passed back to the API."
# This field is accumulated from delta.thinking in the stream loop above.
if self._reasoning_content:
assistant_msg["reasoning_content"] = self._reasoning_content
messages.append(assistant_msg)
messages.extend(ctx.all_tool_results[-len(tool_results):]) messages.extend(ctx.all_tool_results[-len(tool_results):])
return events return events

View File

@ -187,12 +187,15 @@ class ChatService:
user_id: int = None, user_id: int = None,
username: str = None, username: str = None,
workspace: str = None, workspace: str = None,
user_permission_level: int = 1 user_permission_level: int = 1,
skip_user_message: bool = False # Skip adding user message if already in DB
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Streaming response with Agentic Loop.""" """Streaming response with Agentic Loop."""
try: try:
# Build initial messages # Build initial messages
messages = self.build_messages(conversation) messages = self.build_messages(conversation)
# Only add user message if not already in database
if not skip_user_message:
messages.append({ messages.append({
"role": "user", "role": "user",
"content": json.dumps({"text": user_message, "attachments": []}) "content": json.dumps({"text": user_message, "attachments": []})

View File

@ -35,6 +35,15 @@ class OpenAIAdapter(ProviderAdapter):
if tools: if tools:
body["tools"] = tools body["tools"] = tools
body["tool_choice"] = "auto" body["tool_choice"] = "auto"
# DeepSeek-specific parameters
if "reasoning_effort" in kwargs:
body["reasoning_effort"] = kwargs["reasoning_effort"]
if "thinking_enabled" in kwargs and kwargs["thinking_enabled"]:
body["thinking"] = {"type": "enabled"}
if "response_format" in kwargs:
body["response_format"] = kwargs["response_format"]
return body, headers return body, headers
def reset(self): def reset(self):
@ -79,9 +88,23 @@ class OpenAIAdapter(ProviderAdapter):
finish_reason = choice.get("finish_reason") finish_reason = choice.get("finish_reason")
content = delta.get("content", "") content = delta.get("content", "")
# BUG FIX: Extract DeepSeek reasoning_content (thinking mode).
# DeepSeek sends reasoning_content alongside normal content in stream deltas.
# This must be yielded as a separate ParsedDelta with only the "thinking" field
# set, so the agentic loop can:
# 1. Render it as a thinking step in the UI
# 2. Accumulate it to echo back in subsequent API calls
# Without this, DeepSeek returns 400:
# "The `reasoning_content` in the thinking mode must be passed back to the API."
reasoning_content = delta.get("reasoning_content", "")
# MiniMax may send tool_calls as array in delta # MiniMax may send tool_calls as array in delta
tool_calls = delta.get("tool_calls", []) tool_calls = delta.get("tool_calls", [])
# Yield thinking (includes DeepSeek reasoning_content)
if reasoning_content:
yield ParsedDelta(thinking=reasoning_content)
# Yield content if present # Yield content if present
if content: if content:
yield ParsedDelta(content=content) yield ParsedDelta(content=content)
@ -90,8 +113,10 @@ class OpenAIAdapter(ProviderAdapter):
for tc in tool_calls: for tc in tool_calls:
yield ParsedDelta(tool_call=tc) yield ParsedDelta(tool_call=tc)
# Set is_complete for final chunks (DeepSeek may return null, "length", "content_filter") # Only set is_complete for actual completion ("stop").
if finish_reason and finish_reason not in (None, ""): # DeepSeek sends "tool_calls" when it wants to call tools (stream continues with tool_call deltas).
# "length" / "content_filter" mean max_tokens hit - treat as incomplete, let loop handle it.
if finish_reason == "stop":
yield ParsedDelta(is_complete=True) yield ParsedDelta(is_complete=True)
def parse_response(self, data: Dict) -> Dict: def parse_response(self, data: Dict) -> Dict:

View File

@ -22,6 +22,7 @@ Usage:
Extending Providers: Extending Providers:
LLMClient.register_adapter("my_provider", MyAdapter) LLMClient.register_adapter("my_provider", MyAdapter)
""" """
import json
import logging import logging
import traceback import traceback
from typing import Dict, List, Any, Optional, AsyncGenerator, Type from typing import Dict, List, Any, Optional, AsyncGenerator, Type
@ -284,6 +285,36 @@ class LLMClient:
logger.error(f"Sync call error: {e}\n{traceback.format_exc()}") logger.error(f"Sync call error: {e}\n{traceback.format_exc()}")
raise raise
@staticmethod
def _parse_error(response: httpx.Response) -> str:
"""Extract error message from an API error response.
Handles various formats: JSON with "error.message", plain text, empty body.
This is a static method so it works both with an open stream (aread)
and a closed response (text fallback).
"""
error_body = ""
try:
error_body_bytes = response.read()
if error_body_bytes:
error_body = error_body_bytes.decode('utf-8', errors='replace')
except Exception:
try:
error_body = response.text
except Exception:
pass
if error_body:
try:
error_json = json.loads(error_body)
detail = error_json.get("error", {}).get("message", "") or str(error_json)
except json.JSONDecodeError:
detail = error_body
else:
detail = f"HTTP {response.status_code} (no body)"
return detail[:500]
async def stream_call( async def stream_call(
self, self,
model: str, model: str,
@ -316,7 +347,6 @@ class LLMClient:
endpoint = self.build_endpoint() endpoint = self.build_endpoint()
logger.info(f"Stream call to {endpoint} with model {model}") logger.info(f"Stream call to {endpoint} with model {model}")
try: try:
async with httpx.AsyncClient(timeout=120.0) as client: async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream( async with client.stream(
@ -326,7 +356,16 @@ class LLMClient:
json=body json=body
) as response: ) as response:
logger.info(f"Response status: {response.status_code}") logger.info(f"Response status: {response.status_code}")
response.raise_for_status()
# BUG FIX: Read error body BEFORE raising or closing the stream.
# httpx's client.stream() __aexit__ closes the response stream
# when an exception occurs, making e.response.aread() return 0 bytes
# in the except block below. Reading here (stream still open) fixes this.
if response.status_code >= 400:
error_msg = self._parse_error(response)
logger.error(f"HTTP {response.status_code}: {error_msg}")
yield ParsedDelta(error_msg=error_msg)
return
async for line in response.aiter_lines(): async for line in response.aiter_lines():
line = line.strip() line = line.strip()
@ -351,23 +390,22 @@ class LLMClient:
# Pass clean data to adapter (OpenAIAdapter also handles stripping, # Pass clean data to adapter (OpenAIAdapter also handles stripping,
# but AnthropicAdapter and others need clean JSON input) # but AnthropicAdapter and others need clean JSON input)
async for delta in self.adapter.parse_stream_chunk(event_data): async for delta in self.adapter.parse_stream_chunk(event_data):
if delta.content or delta.has_tool_call() or delta.is_complete or delta.usage: # BUG FIX: Include has_thinking() in filter condition.
# DeepSeek sends reasoning_content as separate deltas with only
# the "thinking" field populated. Without has_thinking() check,
# these deltas were silently dropped, preventing reasoning_content
# accumulation and leading to "must be passed back to the API" error.
if delta.content or delta.has_thinking() or delta.has_tool_call() or delta.is_complete or delta.usage:
yield delta yield delta
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
status_code = e.response.status_code if e.response else "?" # Fallback: httpx.HTTPStatusError with closed stream
error_body = "" error_msg = self._parse_error(e.response) if e.response else f"HTTP error"
if e.response: logger.error(f"HTTP error (fallback): {error_msg}")
try: yield ParsedDelta(error_msg=error_msg)
await e.response.aread()
error_body = e.response.text
except Exception:
pass
logger.error(f"HTTP error: {status_code} - {error_body}")
yield ParsedDelta()
except Exception as e: except Exception as e:
logger.error(f"Stream error: {type(e).__name__}: {e}\n{traceback.format_exc()}") logger.error(f"Stream call failed: {type(e).__name__}: {e}")
yield ParsedDelta() yield ParsedDelta(error_msg=f"{type(e).__name__}: {str(e)}")
# Convenience function # Convenience function

View File

@ -41,11 +41,12 @@ class Step:
class ParsedDelta: class ParsedDelta:
"""LLM streaming response delta""" """LLM streaming response delta"""
content: str = "" content: str = ""
thinking: str = "" thinking: str = "" # Includes DeepSeek reasoning_content
text: str = "" text: str = ""
tool_call: Optional[Dict] = None tool_call: Optional[Dict] = None
usage: Dict[str, int] = field(default_factory=dict) usage: Dict[str, int] = field(default_factory=dict)
is_complete: bool = False is_complete: bool = False
error_msg: Optional[str] = None
def has_thinking(self) -> bool: def has_thinking(self) -> bool:
return bool(self.thinking) return bool(self.thinking)
@ -58,3 +59,6 @@ class ParsedDelta:
def has_content(self) -> bool: def has_content(self) -> bool:
return bool(self.content) or self.has_thinking() or self.has_text() or self.has_tool_call() return bool(self.content) or self.has_thinking() or self.has_text() or self.has_tool_call()
def has_error(self) -> bool:
return self.error_msg is not None

View File

@ -91,6 +91,9 @@ class StreamState:
# Track content offset per step to avoid duplication across steps # Track content offset per step to avoid duplication across steps
self._text_offset = 0 self._text_offset = 0
self._thinking_offset = 0 self._thinking_offset = 0
# Track step start offset for finalize_step to compute complete step content
self._step_start_text_offset = 0
self._step_start_thinking_offset = 0
def process_content(self, content: str) -> Dict: def process_content(self, content: str) -> Dict:
"""Process raw content, handling thinking tags.""" """Process raw content, handling thinking tags."""
@ -172,8 +175,10 @@ class StreamState:
# Record content offset so this step only includes content added during it # Record content offset so this step only includes content added during it
if step_type == StepType.TEXT: if step_type == StepType.TEXT:
self._text_offset = len(self.full_content) self._text_offset = len(self.full_content)
self._step_start_text_offset = len(self.full_content)
elif step_type == StepType.THINKING: elif step_type == StepType.THINKING:
self._thinking_offset = len(self.full_thinking) self._thinking_offset = len(self.full_thinking)
self._step_start_thinking_offset = len(self.full_thinking)
self.step_index += 1 self.step_index += 1
return self.current_step_id return self.current_step_id
@ -190,9 +195,9 @@ class StreamState:
self.current_step_type = None self.current_step_type = None
return return
if self.current_step_type == StepType.TEXT: if self.current_step_type == StepType.TEXT:
content = self.full_content[self._text_offset:] content = self.full_content[self._step_start_text_offset:]
elif self.current_step_type == StepType.THINKING: elif self.current_step_type == StepType.THINKING:
content = self.full_thinking[self._thinking_offset:] content = self.full_thinking[self._step_start_thinking_offset:]
else: else:
content = "" content = ""
step = Step( step = Step(
@ -211,15 +216,22 @@ class StreamState:
"""Accumulate tool call delta""" """Accumulate tool call delta"""
idx = tc_delta.get("index", 0) idx = tc_delta.get("index", 0)
if idx >= len(self.tool_calls_list): if idx >= len(self.tool_calls_list):
# BUG FIX: DeepSeek may omit "type" in tool_call deltas.
# Without defaulting to "function", the tool call would have
# type=None, causing JSON serialization issues or API rejections.
tc_type = tc_delta.get("type", "function")
self.tool_calls_list.append({ self.tool_calls_list.append({
"id": tc_delta.get("id", ""), "id": tc_delta.get("id", ""),
"type": "function", "type": tc_type,
"function": {"name": "", "arguments": ""} "function": {"name": "", "arguments": ""}
}) })
else: else:
# Update id if provided (LLM may send id in a later delta) # Update id if provided (LLM may send id in a later delta)
if tc_delta.get("id"): if tc_delta.get("id"):
self.tool_calls_list[idx]["id"] = tc_delta["id"] self.tool_calls_list[idx]["id"] = tc_delta["id"]
# Ensure type field is always present
if "type" in tc_delta:
self.tool_calls_list[idx]["type"] = tc_delta["type"]
func = tc_delta.get("function", {}) func = tc_delta.get("function", {})
if func.get("name"): if func.get("name"):
self.tool_calls_list[idx]["function"]["name"] += func["name"] self.tool_calls_list[idx]["function"]["name"] += func["name"]

View File

@ -2,7 +2,9 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable, Any, Dict, List, Optional from typing import Callable, Any, Dict, List, Optional
from enum import IntEnum from enum import IntEnum
import logging
logger = logging.getLogger(__name__)
class CommandPermission(IntEnum): class CommandPermission(IntEnum):
"""Command permission level - higher value means more permissions""" """Command permission level - higher value means more permissions"""
@ -98,8 +100,10 @@ class ToolRegistry:
def execute(self, name: str, arguments: dict, context: ToolContext = None) -> Dict[str, Any]: def execute(self, name: str, arguments: dict, context: ToolContext = None) -> Dict[str, Any]:
"""Execute tool with optional context and automatic permission check""" """Execute tool with optional context and automatic permission check"""
tool = self.get(name) tool = self.get(name)
if not tool: if not tool:
logger.error(f"[REGISTRY] Tool '{name}' not found")
return {"success": False, "error": f"Tool '{name}' not found"} return {"success": False, "error": f"Tool '{name}' not found"}
# Automatic permission check (transparent to tool function) # Automatic permission check (transparent to tool function)
@ -109,22 +113,27 @@ class ToolRegistry:
if isinstance(user_level, int): if isinstance(user_level, int):
user_level = CommandPermission(user_level) user_level = CommandPermission(user_level)
if user_level < tool.required_permission: if user_level < tool.required_permission:
logger.warning(f"[REGISTRY] Permission denied for tool '{name}': requires {tool.required_permission.name}, user has {user_level.name}")
return { return {
"success": False, "success": False,
"error": f"Permission denied: requires {tool.required_permission.name}, you have {user_level.name}" "error": f"Permission denied: requires {tool.required_permission.name}, you have {user_level.name}"
} }
logger.info(f"[REGISTRY] Executing tool '{name}' with args: {arguments}")
try: try:
# Pass context to handler if it accepts it # Pass context to handler if it accepts it
if context is not None: if context is not None:
result = tool.handler(arguments, context=context) result = tool.handler(arguments, context=context)
else: else:
result = tool.handler(arguments) result = tool.handler(arguments)
logger.info(f"[REGISTRY] Tool '{name}' executed successfully")
if isinstance(result, ToolResult): if isinstance(result, ToolResult):
return result.to_dict() return result.to_dict()
return result return result
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} import traceback
logger.error(f"[REGISTRY] Tool '{name}' execution failed: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
return {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
def clear(self) -> None: def clear(self) -> None:
"""Clear all tools""" """Clear all tools"""

View File

@ -7,11 +7,12 @@ This module follows the Single Responsibility Principle:
""" """
import json import json
import time import time
import logging
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from threading import Lock from threading import Lock
from luxx.tools.core import registry, ToolContext from luxx.tools.core import registry, ToolContext
logger = logging.getLogger(__name__)
class CacheManager: class CacheManager:
"""Manages tool result caching""" """Manages tool result caching"""
@ -165,6 +166,8 @@ class ToolExecutor:
not in completion order. This ensures proper matching between tool_call not in completion order. This ensures proper matching between tool_call
and tool_result steps in the frontend. and tool_result steps in the frontend.
""" """
if len(tool_calls) <= 1: if len(tool_calls) <= 1:
return self.process_tool_calls(tool_calls, context) return self.process_tool_calls(tool_calls, context)
@ -201,16 +204,27 @@ class ToolExecutor:
# Wait for all futures and store results at correct indices # Wait for all futures and store results at correct indices
for future in futures_with_index: for future in futures_with_index:
idx, call_id, name, args, cache_key = futures_with_index[future] idx, call_id, name, args, cache_key = futures_with_index[future]
try:
result = future.result() result = future.result()
self.cache.set(cache_key, result) self.cache.set(cache_key, result)
self.history.record(name, args, result) self.history.record(name, args, result)
results[idx] = self._create_tool_result(call_id, name, result) results[idx] = self._create_tool_result(call_id, name, result)
except Exception as e:
logger.error(f"[EXECUTOR] Tool '{name}' execution failed: {type(e).__name__}: {e}")
# Create error result
error_result = {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
self.history.record(name, args, error_result)
results[idx] = self._create_tool_result(call_id, name, error_result)
# Filter out None values (shouldn't happen, but safety check) # Filter out None values (shouldn't happen, but safety check)
return [r for r in results if r is not None] return [r for r in results if r is not None]
except ImportError: except ImportError:
return self.process_tool_calls(tool_calls, context) return self.process_tool_calls(tool_calls, context)
except Exception as e:
logger.error(f"[EXECUTOR] Parallel execution failed: {type(e).__name__}: {e}")
# Fallback to sequential execution
return self.process_tool_calls(tool_calls, context)
def _build_tool_context(self, context: Dict[str, Any]) -> ToolContext: def _build_tool_context(self, context: Dict[str, Any]) -> ToolContext:
"""Build ToolContext from context dict""" """Build ToolContext from context dict"""
@ -232,11 +246,10 @@ class ToolExecutor:
return {} return {}
def _create_tool_result(self, call_id: str, name: str, result: Dict) -> Dict[str, Any]: def _create_tool_result(self, call_id: str, name: str, result: Dict) -> Dict[str, Any]:
"""Create tool result message""" """Create tool result message (OpenAI format: only tool_call_id and content)"""
return { return {
"tool_call_id": call_id, "tool_call_id": call_id,
"role": "tool", "role": "tool",
"name": name,
"content": json.dumps(result, ensure_ascii=False) "content": json.dumps(result, ensure_ascii=False)
} }