feat: 插件-工具合并 — 创建 pkg/plugins 共享模块并移除 tool-engine
- 新增 backend/pkg/plugins/ 共享模块:SDK 接口、PluginManager、ToolRegistry(含环形缓冲区调用日志) - 13 个通用插件从 plugin-manager 迁移至共享模块(import 路径统一) - ai-core 切换至共享 ToolRegistry,进程内执行(零网络开销),包装 6 个专属工具 - plugin-manager 迁移至共享模块,保留管理 REST API - 新增 DevTools 插件管理面板(侧边栏 → 🔌 插件管理) - 移除 tool-engine 服务(从 go.work、DevTools 配置、编译系统) - 工具调用记录 API 从 Tool-Engine 迁至 AI-Core(/api/v1/tools/calls) - ai-core ContextStore 启动时从 PostgreSQL 恢复会话历史 - 清理所有过时引用和备份文件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,9 @@ import (
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
|
||||
|
||||
plgManager "github.com/yourname/cyrene-ai/pkg/plugins/manager"
|
||||
plgSDK "github.com/yourname/cyrene-ai/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// PendingThought 待推送的后台思考
|
||||
@@ -50,7 +53,7 @@ type Thinker struct {
|
||||
memoryStore *memory.Store
|
||||
|
||||
// 工具调用
|
||||
toolRegistry *tools.Registry
|
||||
toolRegistry *plgManager.ToolRegistry
|
||||
|
||||
// 会话上下文
|
||||
convStore *ctxbuild.ConversationStore
|
||||
@@ -115,6 +118,9 @@ type Thinker struct {
|
||||
// Phase 2: 主动消息决策守卫
|
||||
proactiveGuard *ProactiveGuard
|
||||
|
||||
// 动态调度: 按时间段自动调整思考间隔
|
||||
scheduleLoader *ScheduleLoader
|
||||
|
||||
// Phase 2: 在线状态追踪
|
||||
userOnline bool
|
||||
lastOnlineChange time.Time
|
||||
@@ -149,6 +155,13 @@ func DefaultAutonomousToolPolicy() *AutonomousToolPolicy {
|
||||
}
|
||||
|
||||
// SetMessagePusher 设置主动消息推送回调
|
||||
// SetScheduleLoader sets the dynamic schedule loader for interval calculation.
|
||||
func (t *Thinker) SetScheduleLoader(loader *ScheduleLoader) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.scheduleLoader = loader
|
||||
}
|
||||
|
||||
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
@@ -228,7 +241,7 @@ func NewThinker(
|
||||
toolAdapter *llm.Adapter,
|
||||
iotClient *tools.IoTClient,
|
||||
memoryStore *memory.Store,
|
||||
toolRegistry *tools.Registry,
|
||||
toolRegistry *plgManager.ToolRegistry,
|
||||
convStore *ctxbuild.ConversationStore,
|
||||
adminUserID string,
|
||||
adminSessionID string,
|
||||
@@ -434,8 +447,8 @@ func (t *Thinker) resetSilenceTimer() {
|
||||
|
||||
// periodicThinkLoop 周期性自主思考循环
|
||||
//
|
||||
// 每隔 thinkInterval 触发一次思考,保证昔涟在无用户活动时也能持续进行后台反思。
|
||||
// 每次触发前检查 minThinkGap,避免与事件驱动思考冲突。
|
||||
// 使用动态间隔:若配置了 ScheduleLoader,每次循环根据当前时段计算间隔;
|
||||
// 否则回退到固定的 thinkInterval。
|
||||
func (t *Thinker) periodicThinkLoop() {
|
||||
defer t.wg.Done()
|
||||
defer func() {
|
||||
@@ -444,17 +457,22 @@ func (t *Thinker) periodicThinkLoop() {
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(t.thinkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Printf("[后台思考] 周期性思考已启动 (间隔=%v)", t.thinkInterval)
|
||||
|
||||
for {
|
||||
// 计算本次等待间隔
|
||||
interval := t.thinkInterval
|
||||
if t.scheduleLoader != nil {
|
||||
if mins := t.scheduleLoader.GetInterval(time.Now()); mins > 0 {
|
||||
interval = time.Duration(mins) * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
log.Println("[后台思考] 周期性思考已停止")
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-time.After(interval):
|
||||
t.mu.Lock()
|
||||
sinceLastThink := time.Since(t.lastThinkTime)
|
||||
sinceLastUser := time.Since(t.lastUserMessage)
|
||||
@@ -482,7 +500,7 @@ func (t *Thinker) periodicThinkLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[后台思考] 周期性触发 (上次思考=%v前, 上次用户消息=%v前)", sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
|
||||
log.Printf("[后台思考] 周期性触发 (间隔=%v, 上次思考=%v前, 上次用户消息=%v前)", interval, sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
|
||||
t.performThink("periodic")
|
||||
}
|
||||
}
|
||||
@@ -653,6 +671,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
if execErr != nil {
|
||||
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
|
||||
}
|
||||
if result == nil {
|
||||
result = &plgSDK.ToolResult{ToolName: tc.Name, Success: false, Error: execErr.Error()}
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
messages = append(messages, model.LLMMessage{
|
||||
@@ -741,7 +762,7 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
- 开拓者说要离开一会儿、去忙、去吃饭
|
||||
- 开拓者明确表示不想被打扰
|
||||
- 对话刚刚自然结束且开拓者没有未完成的事
|
||||
如果对话历史显示以上任何情况,反思中不要写【主动消息】标记。你可以在心里想想他,但不要去打扰。`
|
||||
如果对话历史显示以上任何情况,你只需要在心里默默陪伴,不要输出任何【主动消息】指令行。`
|
||||
|
||||
switch triggerReason {
|
||||
case "post_chat":
|
||||
@@ -768,9 +789,10 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
其他规则:
|
||||
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
|
||||
2. 只有开拓者状态正常且真的有必要时才写【主动消息】,不要硬找话题。
|
||||
3. 【主动消息】的内容必须直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。反思是给自己看的,主动消息是发给他的——语气要区分开。
|
||||
4. 2-4句话即可。`
|
||||
2. 只有开拓者状态正常且真的有必要时,才在独立一行写【主动消息】标记,后面跟你要发给他的话。不要硬找话题。
|
||||
3. 【主动消息】标记必须独占一行开头,内容直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。
|
||||
4. 如果你在反思中提到"主动消息"这个词但不打算发消息,不要使用【主动消息】这个带括号的标记——系统会误解析。
|
||||
5. 2-4句话即可。`
|
||||
|
||||
case "silence":
|
||||
thinkingInstructions = `
|
||||
@@ -840,6 +862,14 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 注入当前现实时间,让模型对时间有感知
|
||||
now := time.Now()
|
||||
weekdayNames := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||
sb.WriteString(fmt.Sprintf("🕐 现在是 %s %s %02d:%02d。\n",
|
||||
now.Format("2006年1月2日"),
|
||||
weekdayNames[now.Weekday()],
|
||||
now.Hour(), now.Minute()))
|
||||
|
||||
// 根据触发原因使用不同的开场白
|
||||
switch triggerReason {
|
||||
case "post_chat":
|
||||
@@ -886,7 +916,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
|
||||
// 关键:强调根据对话历史判断用户当前状态
|
||||
if lastUserMsg != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,反思中不要写【主动消息】。\n", lastUserMsg))
|
||||
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,不要输出【主动消息】指令行。\n", lastUserMsg))
|
||||
}
|
||||
|
||||
// 现有记忆
|
||||
@@ -930,9 +960,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
// 结尾引导
|
||||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
||||
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
||||
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要写【主动消息】。你可以在心里想他,但不要去打扰。")
|
||||
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才写【主动消息】。")
|
||||
sb.WriteString("\n❗【主动消息】的内容必须直接对开拓者说话(用\"你\"来称呼他),就像你主动找他聊天一样自然。不要用第三人称或自言自语的方式写主动消息。")
|
||||
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要输出【主动消息】指令行。你可以在心里想他,但不要去打扰。")
|
||||
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才输出一行【主动消息】+ 你要发给他的话。")
|
||||
sb.WriteString("\n❗【主动消息】标记必须独占一行开头,后面紧跟你要对开拓者说的话(用\"你\"称呼),语气自然像主动找他聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -963,7 +993,7 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
||||
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
defs := t.toolRegistry.GetDefinitions()
|
||||
defs := t.toolRegistry.Definitions()
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -1066,29 +1096,52 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
}
|
||||
}
|
||||
|
||||
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容
|
||||
// 返回空字符串表示没有主动消息
|
||||
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容。
|
||||
// 返回空字符串表示没有主动消息。
|
||||
//
|
||||
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
|
||||
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
|
||||
func extractProactiveMessage(content string) string {
|
||||
marker := "【主动消息】"
|
||||
idx := strings.Index(content, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
|
||||
// 扫描每一行,只接受 marker 在行首(忽略前导空白)的行作为指令
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, marker) {
|
||||
continue
|
||||
}
|
||||
// 检查否定语境:标记前面的文字包含否定词
|
||||
markerIdx := strings.Index(line, marker)
|
||||
prefix := strings.TrimSpace(line[:markerIdx])
|
||||
if containsNegation(prefix) {
|
||||
continue
|
||||
}
|
||||
// 提取标记后的内容
|
||||
msg := strings.TrimSpace(trimmed[len(marker):])
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
// 限制主动消息长度(最多 200 字符,保持简短)
|
||||
runes := []rune(msg)
|
||||
if len(runes) > 200 {
|
||||
msg = string(runes[:200])
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 提取标记后的内容(到下一个标记或结尾)
|
||||
msg := strings.TrimSpace(content[idx+len(marker):])
|
||||
// 截断到下一个【或换行之前的合理长度
|
||||
if endIdx := strings.Index(msg, "【"); endIdx > 0 {
|
||||
msg = strings.TrimSpace(msg[:endIdx])
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// containsNegation checks if a short prefix string contains negation words
|
||||
// that would nullify the 【主动消息】directive.
|
||||
func containsNegation(prefix string) bool {
|
||||
negations := []string{"不", "别", "不要", "不需要", "不用", "别写", "没", "没有"}
|
||||
for _, n := range negations {
|
||||
if strings.Contains(prefix, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 限制主动消息长度(最多 200 字符,保持简短)
|
||||
runes := []rune(msg)
|
||||
if len(runes) > 200 {
|
||||
msg = string(runes[:200])
|
||||
}
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
return msg
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@ package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
)
|
||||
|
||||
// IoTDeviceSummary IoT设备摘要接口(避免循环依赖)
|
||||
@@ -76,6 +79,48 @@ func (cs *ConversationStore) GetHistory(sessionID string, limit int) []model.LLM
|
||||
return result
|
||||
}
|
||||
|
||||
// LoadFromDB 从数据库的 messages 表恢复会话历史到内存
|
||||
func (cs *ConversationStore) LoadFromDB(databaseURL, sessionID string, limit int) error {
|
||||
db, err := sql.Open("postgres", databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT role, content FROM messages
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2`,
|
||||
sessionID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询消息失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
var loaded int
|
||||
for rows.Next() {
|
||||
var roleStr, content string
|
||||
if err := rows.Scan(&roleStr, &content); err != nil {
|
||||
return fmt.Errorf("扫描消息行失败: %w", err)
|
||||
}
|
||||
cs.messages[sessionID] = append(cs.messages[sessionID], model.LLMMessage{
|
||||
Role: model.Role(roleStr),
|
||||
Content: content,
|
||||
})
|
||||
loaded++
|
||||
}
|
||||
|
||||
if loaded > 0 {
|
||||
logger.Printf("[context] 从数据库恢复会话 %s 历史 %d 条", sessionID, loaded)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// Builder 对话上下文构建器
|
||||
type Builder struct {
|
||||
convStore *ConversationStore
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
)
|
||||
|
||||
// ToolDefinition 工具定义(用于 LLM function calling)
|
||||
@@ -31,11 +33,93 @@ type ToolExecutor interface {
|
||||
Definition() ToolDefinition
|
||||
}
|
||||
|
||||
// CallLogRecord 工具调用记录
|
||||
type CallLogRecord struct {
|
||||
CallID string `json:"call_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Arguments string `json:"arguments"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
Success bool `json:"success"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// callLogRing 线程安全的环形缓冲区
|
||||
type callLogRing struct {
|
||||
mu sync.Mutex
|
||||
records []CallLogRecord
|
||||
capacity int
|
||||
head int
|
||||
size int
|
||||
}
|
||||
|
||||
func newCallLogRing(capacity int) *callLogRing {
|
||||
return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)}
|
||||
}
|
||||
|
||||
func (r *callLogRing) push(rec CallLogRecord) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
rec.Timestamp = time.Now().UnixMilli()
|
||||
r.records[r.head] = rec
|
||||
r.head = (r.head + 1) % r.capacity
|
||||
if r.size < r.capacity {
|
||||
r.size++
|
||||
}
|
||||
}
|
||||
|
||||
func (r *callLogRing) get(limit int) []CallLogRecord {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if limit <= 0 || limit > r.size {
|
||||
limit = r.size
|
||||
}
|
||||
result := make([]CallLogRecord, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
idx := (r.head - 1 - i) % r.capacity
|
||||
if idx < 0 {
|
||||
idx += r.capacity
|
||||
}
|
||||
result[i] = r.records[idx]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *callLogRing) statsByTool() map[string]map[string]interface{} {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
byTool := make(map[string]map[string]interface{})
|
||||
for i := 0; i < r.size; i++ {
|
||||
idx := (r.head - 1 - i) % r.capacity
|
||||
if idx < 0 {
|
||||
idx += r.capacity
|
||||
}
|
||||
rec := r.records[idx]
|
||||
if _, ok := byTool[rec.ToolName]; !ok {
|
||||
byTool[rec.ToolName] = map[string]interface{}{
|
||||
"tool_name": rec.ToolName, "count": 0, "success_count": 0, "fail_count": 0, "total_duration_ms": 0,
|
||||
}
|
||||
}
|
||||
s := byTool[rec.ToolName]
|
||||
s["count"] = s["count"].(int) + 1
|
||||
if rec.Success {
|
||||
s["success_count"] = s["success_count"].(int) + 1
|
||||
} else {
|
||||
s["fail_count"] = s["fail_count"].(int) + 1
|
||||
}
|
||||
s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs
|
||||
}
|
||||
return byTool
|
||||
}
|
||||
|
||||
// Registry 工具注册中心
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]ToolExecutor
|
||||
enabled bool
|
||||
mu sync.RWMutex
|
||||
tools map[string]ToolExecutor
|
||||
enabled bool
|
||||
callLog *callLogRing
|
||||
}
|
||||
|
||||
// NewRegistry 创建工具注册中心
|
||||
@@ -43,6 +127,7 @@ func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
tools: make(map[string]ToolExecutor),
|
||||
enabled: true,
|
||||
callLog: newCallLogRing(500),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,30 +158,38 @@ func (r *Registry) Execute(ctx context.Context, toolName string, arguments map[s
|
||||
executor, ok := r.tools[toolName]
|
||||
r.mu.RUnlock()
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
if !ok {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("未知工具: %s", toolName),
|
||||
}, nil
|
||||
errMsg := fmt.Sprintf("未知工具: %s", toolName)
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolName, Error: errMsg, Success: false, DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
})
|
||||
return &ToolResult{ToolName: toolName, Success: false, Error: errMsg}, nil
|
||||
}
|
||||
|
||||
logger.Printf("[工具执行] 调用工具 %s,参数: %v", toolName, arguments)
|
||||
result, err := executor.Execute(ctx, arguments)
|
||||
durationMs := int(time.Since(startTime).Milliseconds())
|
||||
|
||||
if err != nil {
|
||||
logger.Printf("[工具执行] 工具 %s 执行失败: %v", toolName, err)
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolName, Error: err.Error(), Success: false, DurationMs: durationMs,
|
||||
})
|
||||
return &ToolResult{ToolName: toolName, Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
argsJSON, _ := json.Marshal(arguments)
|
||||
if result.Success {
|
||||
logger.Printf("[工具执行] 工具 %s 执行成功 (数据长度: %d)", toolName, len(result.Data))
|
||||
} else {
|
||||
logger.Printf("[工具执行] 工具 %s 返回错误: %s", toolName, result.Error)
|
||||
}
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolName, Arguments: string(argsJSON), Output: result.Data,
|
||||
Error: result.Error, Success: result.Success, DurationMs: durationMs,
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -135,6 +228,63 @@ func (r *Registry) ListTools() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// GetCallLogs 获取工具调用记录(最新在前)
|
||||
func (r *Registry) GetCallLogs(toolName string, limit int) []CallLogRecord {
|
||||
all := r.callLog.get(r.callLog.size)
|
||||
if toolName == "" {
|
||||
if limit > 0 && limit < len(all) {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all
|
||||
}
|
||||
filtered := make([]CallLogRecord, 0)
|
||||
for _, rec := range all {
|
||||
if rec.ToolName == toolName {
|
||||
filtered = append(filtered, rec)
|
||||
if limit > 0 && len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GetCallStats 获取工具调用统计
|
||||
func (r *Registry) GetCallStats() map[string]interface{} {
|
||||
byTool := r.callLog.statsByTool()
|
||||
totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0
|
||||
toolStats := make([]map[string]interface{}, 0, len(byTool))
|
||||
for _, s := range byTool {
|
||||
count := s["count"].(int)
|
||||
success := s["success_count"].(int)
|
||||
fail := s["fail_count"].(int)
|
||||
totalDur := s["total_duration_ms"].(int)
|
||||
avgDur := 0.0
|
||||
if count > 0 {
|
||||
avgDur = float64(totalDur) / float64(count)
|
||||
}
|
||||
s["avg_duration_ms"] = avgDur
|
||||
delete(s, "total_duration_ms")
|
||||
toolStats = append(toolStats, s)
|
||||
totalCalls += count
|
||||
successCount += success
|
||||
failCount += fail
|
||||
totalDurationMs += totalDur
|
||||
}
|
||||
avgDuration := 0.0
|
||||
if totalCalls > 0 {
|
||||
avgDuration = float64(totalDurationMs) / float64(totalCalls)
|
||||
}
|
||||
successRate := 0.0
|
||||
if totalCalls > 0 {
|
||||
successRate = float64(successCount) / float64(totalCalls) * 100
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"total_calls": totalCalls, "success_count": successCount, "fail_count": failCount,
|
||||
"success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats,
|
||||
}
|
||||
}
|
||||
|
||||
// ToJSON 将工具定义序列化为 JSON(用于 LLM 请求)
|
||||
func (r *Registry) ToJSON() ([]byte, error) {
|
||||
defs := r.GetDefinitions()
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"github.com/yourname/cyrene-ai/pkg/logger"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ToolEngineClient 工具引擎 HTTP 客户端
|
||||
// 将工具执行请求转发到独立的 tool-engine 微服务
|
||||
type ToolEngineClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// toolEngineToolDef 来自 tool-engine 的工具定义响应
|
||||
type toolEngineToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
// toolEngineResult 来自 tool-engine 的工具执行结果
|
||||
type toolEngineResult struct {
|
||||
ID string `json:"id"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewToolEngineClient 创建工具引擎客户端
|
||||
func NewToolEngineClient(baseURL string) *ToolEngineClient {
|
||||
return &ToolEngineClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefinitions 从 tool-engine 获取所有工具定义
|
||||
func (c *ToolEngineClient) GetDefinitions(ctx context.Context) ([]ToolDefinition, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/tools", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求工具列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("获取工具列表返回状态码 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Tools []toolEngineToolDef `json:"tools"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析工具列表失败: %w", err)
|
||||
}
|
||||
|
||||
defs := make([]ToolDefinition, 0, len(result.Tools))
|
||||
for _, t := range result.Tools {
|
||||
defs = append(defs, ToolDefinition{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
logger.Printf("[tool-engine-client] 从 tool-engine 获取了 %d 个工具定义", len(defs))
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// Execute 通过 tool-engine 执行工具调用
|
||||
// 包含重试逻辑:最多重试 2 次(共 3 次尝试),间隔 100ms
|
||||
func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
const maxRetries = 2
|
||||
const retryDelay = 100 * time.Millisecond
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
logger.Printf("[tool-engine-client] 工具 %s 第 %d 次重试 (上次错误: %v)", toolName, attempt, lastErr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("请求被取消: %v", ctx.Err()),
|
||||
}, nil
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
}
|
||||
|
||||
result, err := c.executeOnce(ctx, toolName, arguments)
|
||||
if err == nil && result.Success {
|
||||
return result, nil
|
||||
}
|
||||
if result != nil {
|
||||
lastErr = fmt.Errorf("%s", result.Error)
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// 不可重试的错误:工具不存在、参数序列化失败、创建请求失败
|
||||
if result != nil && (strings.Contains(result.Error, "不存在") ||
|
||||
strings.Contains(result.Error, "序列化") ||
|
||||
strings.Contains(result.Error, "创建请求")) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
logger.Printf("[tool-engine-client] 工具 %s 所有重试均失败 (最后错误: %v)", toolName, lastErr)
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("请求 tool-engine 失败 (已重试 %d 次): %v", maxRetries, lastErr),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executeOnce 执行单次工具调用(不含重试逻辑)
|
||||
func (c *ToolEngineClient) executeOnce(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"arguments": arguments,
|
||||
})
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("序列化参数失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/tools/%s/execute", c.baseURL, toolName)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("请求 tool-engine 失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("工具 %s 不存在", toolName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("tool-engine 返回状态码 %d: %s", resp.StatusCode, string(respBody)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var result toolEngineResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析 tool-engine 响应失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: result.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: true,
|
||||
Data: result.Output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HealthCheck 检查 tool-engine 服务是否可用
|
||||
func (c *ToolEngineClient) HealthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建健康检查请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tool-engine 不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tool-engine 健康检查返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user