基本完成
This commit is contained in:
@@ -0,0 +1,250 @@
|
|||||||
|
// Package sign 提供签到工具。当用户想签到时,把签到信息写入 signs 表,
|
||||||
|
// 每个节点每天只能签到一次。
|
||||||
|
//
|
||||||
|
// 节点身份(node_id / long_name / short_name)由 autoreply 在处理队列消息时
|
||||||
|
// 通过 ctx 注入(见 agenttool.NodeContext);text_message 包本身不含名字,
|
||||||
|
// 队列记录里的 long_name/short_name 经常为空,因此签到工具会在名字缺失时
|
||||||
|
// 用 node_id 查 nodeinfo 表补全。签到正文里的地区、名字、设备等字段则由
|
||||||
|
// LLM 从用户消息中提取后作为工具参数传入。
|
||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/agenttool"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
|
||||||
|
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignStore 定义签到工具所需的持久化能力,通常由 *store.Store 实现。
|
||||||
|
type SignStore interface {
|
||||||
|
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
||||||
|
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
||||||
|
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 是签到工具。
|
||||||
|
type Tool struct {
|
||||||
|
enabled bool
|
||||||
|
store SignStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the tool name
|
||||||
|
func (t *Tool) Name() string { return "sign" }
|
||||||
|
|
||||||
|
// Enabled returns whether the tool is enabled
|
||||||
|
func (t *Tool) Enabled() bool { return t.enabled && t.store != nil }
|
||||||
|
|
||||||
|
// ToolDefinition returns the OpenAI tool definition
|
||||||
|
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||||||
|
desc := "签到工具。当用户想要签到/打卡/上台时调用。会记录该节点今日的签到信息,每个节点每天只能签到一次。" +
|
||||||
|
"必填参数:地区、名字、设备;可选参数:发射功率、天线长度、身处高度。"
|
||||||
|
if description != "" {
|
||||||
|
desc = description
|
||||||
|
}
|
||||||
|
return &model.Tool{
|
||||||
|
Type: model.ToolTypeFunction,
|
||||||
|
Function: &model.FunctionDefinition{
|
||||||
|
Name: "sign",
|
||||||
|
Description: desc,
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"region": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "地区,例如 \"上海闵行\"、\"安徽\"、\"广东深圳\"",
|
||||||
|
},
|
||||||
|
"name": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "签到用户的名字/呼号,例如 \"Kevin\"、\"TaoEngine\"",
|
||||||
|
},
|
||||||
|
"device": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "使用的设备型号,例如 \"GAT562\"、\"EBYTE_EoRa_S3\"",
|
||||||
|
},
|
||||||
|
"tx_power": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选:发射功率,例如 \"25mW\"、\"100mW\"",
|
||||||
|
},
|
||||||
|
"antenna_length": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选:天线长度,例如 \"5dBi\"、\"1.2m\"",
|
||||||
|
},
|
||||||
|
"altitude": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选:身处高度,例如 \"30m\"、\"海拔500m\"",
|
||||||
|
},
|
||||||
|
"raw_text": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选:用户原始签到文本。当无法准确拆分地区/名字/设备时,传入用户原文作为签到正文",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"region", "name", "device"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute executes the sign tool
|
||||||
|
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||||
|
if t.store == nil {
|
||||||
|
return "", fmt.Errorf("sign store is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var params signParams
|
||||||
|
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Region = strings.TrimSpace(params.Region)
|
||||||
|
params.Name = strings.TrimSpace(params.Name)
|
||||||
|
params.Device = strings.TrimSpace(params.Device)
|
||||||
|
params.RawText = strings.TrimSpace(params.RawText)
|
||||||
|
if params.Region == "" || params.Name == "" || params.Device == "" {
|
||||||
|
if params.RawText == "" {
|
||||||
|
return "", fmt.Errorf("region, name, device 都是必填参数")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点身份来自消息上下文,而非 LLM 回填,保证「每节点每天一次」判定可靠。
|
||||||
|
node, ok := agenttool.NodeContextFromContext(ctx)
|
||||||
|
if !ok || strings.TrimSpace(node.NodeID) == "" {
|
||||||
|
return "", fmt.Errorf("缺少发送节点上下文,无法签到")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := runtime.Now
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每个节点每天只能签到一次
|
||||||
|
signed, err := t.store.HasSignedOnDay(node.NodeID, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("签到失败:检查今日签到状态时出错:%v", err), nil
|
||||||
|
}
|
||||||
|
if signed {
|
||||||
|
return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
signText := buildSignText(params)
|
||||||
|
if signText == "" {
|
||||||
|
// 结构化字段缺失时回退到用户原始文本
|
||||||
|
signText = params.RawText
|
||||||
|
}
|
||||||
|
longName, shortName := resolveNodeNames(t, node)
|
||||||
|
|
||||||
|
record, err := t.store.CreateSign(node.NodeID, longName, shortName, signText, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("签到失败:%v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("签到成功!%s\n签到内容:%s", displayName(node), record.SignText), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNodeNames 取出节点的 long_name / short_name。
|
||||||
|
// text_message 包本身不含名字,队列记录里的 long_name/short_name 经常为空;
|
||||||
|
// 此时用 node_id 查 nodeinfo 表补全,保证签到记录里能看到节点名。
|
||||||
|
// 仍查不到则返回两个 nil,签到照常进行(仅名字字段为空)。
|
||||||
|
func resolveNodeNames(t *Tool, node agenttool.NodeContext) (*string, *string) {
|
||||||
|
var longName, shortName *string
|
||||||
|
if ln := strings.TrimSpace(node.LongName); ln != "" {
|
||||||
|
longName = &ln
|
||||||
|
}
|
||||||
|
if sn := strings.TrimSpace(node.ShortName); sn != "" {
|
||||||
|
shortName = &sn
|
||||||
|
}
|
||||||
|
// 队列上下文已有名字就直接用,无需查库
|
||||||
|
if longName != nil && shortName != nil {
|
||||||
|
return longName, shortName
|
||||||
|
}
|
||||||
|
if t.store == nil {
|
||||||
|
return longName, shortName
|
||||||
|
}
|
||||||
|
info, err := t.store.GetNodeInfo(node.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
return longName, shortName
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
return longName, shortName
|
||||||
|
}
|
||||||
|
if longName == nil && info.LongName != nil {
|
||||||
|
if v := strings.TrimSpace(*info.LongName); v != "" {
|
||||||
|
longName = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shortName == nil && info.ShortName != nil {
|
||||||
|
if v := strings.TrimSpace(*info.ShortName); v != "" {
|
||||||
|
shortName = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return longName, shortName
|
||||||
|
}
|
||||||
|
|
||||||
|
// signParams 是签到工具的入参。
|
||||||
|
type signParams struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
TxPower string `json:"tx_power"`
|
||||||
|
AntennaLength string `json:"antenna_length"`
|
||||||
|
Altitude string `json:"altitude"`
|
||||||
|
// RawText 是用户原始签到文本,结构化字段缺失时作为签到正文回退使用。
|
||||||
|
RawText string `json:"raw_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignText 按参考格式拼装签到正文:地区-名字-设备签到,可选信息附在括号内。
|
||||||
|
// 当 region/name/device 任一缺失时返回空串,由调用方回退到 RawText。
|
||||||
|
func buildSignText(p signParams) string {
|
||||||
|
if strings.TrimSpace(p.Region) == "" || strings.TrimSpace(p.Name) == "" || strings.TrimSpace(p.Device) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%s-%s-%s签到", p.Region, p.Name, p.Device)
|
||||||
|
|
||||||
|
var extras []string
|
||||||
|
if v := strings.TrimSpace(p.TxPower); v != "" {
|
||||||
|
extras = append(extras, "发射功率 "+v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(p.AntennaLength); v != "" {
|
||||||
|
extras = append(extras, "天线 "+v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(p.Altitude); v != "" {
|
||||||
|
extras = append(extras, "高度 "+v)
|
||||||
|
}
|
||||||
|
if len(extras) > 0 {
|
||||||
|
text += "(" + strings.Join(extras, ",") + ")"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayName(node agenttool.NodeContext) string {
|
||||||
|
if node.LongName != "" {
|
||||||
|
return node.LongName
|
||||||
|
}
|
||||||
|
if node.ShortName != "" {
|
||||||
|
return node.ShortName
|
||||||
|
}
|
||||||
|
return node.NodeID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawState returns the tool state
|
||||||
|
func (t *Tool) RawState() any {
|
||||||
|
return map[string]any{"enabled": t.enabled, "has_store": t.store != nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
agenttool.Register(agenttool.Descriptor{
|
||||||
|
Name: "sign",
|
||||||
|
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||||
|
tool := &Tool{enabled: true}
|
||||||
|
if store, ok := options.Value("store").(SignStore); ok && store != nil {
|
||||||
|
tool.store = store
|
||||||
|
}
|
||||||
|
return tool, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -47,6 +47,28 @@ type Runtime struct {
|
|||||||
Now time.Time
|
Now time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NodeContext carries the originating node identity for a tool execution.
|
||||||
|
// 它由 autoreply 在处理一条队列消息时注入到 ctx 中,供需要识别发送节点的
|
||||||
|
// 工具(如签到工具)使用,避免依赖 LLM 从文本里回填节点 ID。
|
||||||
|
type NodeContext struct {
|
||||||
|
NodeID string
|
||||||
|
LongName string
|
||||||
|
ShortName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeContextKey struct{}
|
||||||
|
|
||||||
|
// WithNodeContext 把节点身份信息挂到 ctx 上。
|
||||||
|
func WithNodeContext(ctx context.Context, nc NodeContext) context.Context {
|
||||||
|
return context.WithValue(ctx, nodeContextKey{}, nc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeContextFromContext 从 ctx 中取出节点身份信息;不存在时第二个返回值为 false。
|
||||||
|
func NodeContextFromContext(ctx context.Context) (NodeContext, bool) {
|
||||||
|
nc, ok := ctx.Value(nodeContextKey{}).(NodeContext)
|
||||||
|
return nc, ok
|
||||||
|
}
|
||||||
|
|
||||||
// LoadedTool is the interface that all tools must implement
|
// LoadedTool is the interface that all tools must implement
|
||||||
type LoadedTool interface {
|
type LoadedTool interface {
|
||||||
Name() string
|
Name() string
|
||||||
|
|||||||
+10
-3
@@ -7,16 +7,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"meshtastic_mqtt_server/internal/agenttool"
|
|
||||||
_ "meshtastic_mqtt_server/internal/agents/calculator"
|
_ "meshtastic_mqtt_server/internal/agents/calculator"
|
||||||
|
_ "meshtastic_mqtt_server/internal/agents/sign"
|
||||||
_ "meshtastic_mqtt_server/internal/agents/time"
|
_ "meshtastic_mqtt_server/internal/agents/time"
|
||||||
|
"meshtastic_mqtt_server/internal/agenttool"
|
||||||
"meshtastic_mqtt_server/internal/autoreply"
|
"meshtastic_mqtt_server/internal/autoreply"
|
||||||
"meshtastic_mqtt_server/internal/conversation"
|
"meshtastic_mqtt_server/internal/conversation"
|
||||||
"meshtastic_mqtt_server/internal/llm"
|
"meshtastic_mqtt_server/internal/llm"
|
||||||
storepkg "meshtastic_mqtt_server/internal/store"
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
"meshtastic_mqtt_server/internal/toolmanager"
|
"meshtastic_mqtt_server/internal/toolmanager"
|
||||||
"meshtastic_mqtt_server/internal/topicrouter"
|
|
||||||
"meshtastic_mqtt_server/internal/toolrouter"
|
"meshtastic_mqtt_server/internal/toolrouter"
|
||||||
|
"meshtastic_mqtt_server/internal/topicrouter"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,8 @@ type Config struct {
|
|||||||
ToolConfigStore ToolConfigStore
|
ToolConfigStore ToolConfigStore
|
||||||
ToolRouterStore ToolRouterStore
|
ToolRouterStore ToolRouterStore
|
||||||
TopicRouterStore TopicRouterStore
|
TopicRouterStore TopicRouterStore
|
||||||
|
// Store 注入持久化层,供需要 DB 访问的 agent 工具(如签到工具)使用。
|
||||||
|
Store *storepkg.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service manages all AI-related components
|
// Service manages all AI-related components
|
||||||
@@ -194,7 +197,11 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load tools
|
// Load tools
|
||||||
toolMgr, err := toolmanager.Load(agentsDir, agenttool.LoadOptions{})
|
loadOptions := agenttool.LoadOptions{Values: map[string]any{}}
|
||||||
|
if cfg.Store != nil {
|
||||||
|
loadOptions.Values["store"] = cfg.Store
|
||||||
|
}
|
||||||
|
toolMgr, err := toolmanager.Load(agentsDir, loadOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load tools: %w", err)
|
return nil, fmt.Errorf("failed to load tools: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/agenttool"
|
||||||
"meshtastic_mqtt_server/internal/completion"
|
"meshtastic_mqtt_server/internal/completion"
|
||||||
"meshtastic_mqtt_server/internal/conversation"
|
"meshtastic_mqtt_server/internal/conversation"
|
||||||
"meshtastic_mqtt_server/internal/llm"
|
"meshtastic_mqtt_server/internal/llm"
|
||||||
"meshtastic_mqtt_server/internal/message"
|
"meshtastic_mqtt_server/internal/message"
|
||||||
"meshtastic_mqtt_server/internal/stream"
|
"meshtastic_mqtt_server/internal/stream"
|
||||||
"meshtastic_mqtt_server/internal/toolmanager"
|
"meshtastic_mqtt_server/internal/toolmanager"
|
||||||
"meshtastic_mqtt_server/internal/topicrouter"
|
|
||||||
"meshtastic_mqtt_server/internal/toolrouter"
|
"meshtastic_mqtt_server/internal/toolrouter"
|
||||||
|
"meshtastic_mqtt_server/internal/topicrouter"
|
||||||
|
|
||||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||||
)
|
)
|
||||||
@@ -247,6 +248,14 @@ func truncate(s string, n int) string {
|
|||||||
return s[:n] + "..."
|
return s[:n] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ptrStringValue 安全取出 *string 的值,nil 返回空串。
|
||||||
|
func ptrStringValue(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
// processMessage processes a single queued message
|
// processMessage processes a single queued message
|
||||||
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||||
// Mark message as processing
|
// Mark message as processing
|
||||||
@@ -339,7 +348,13 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
|||||||
routerModel = routerProfile.Config.Model
|
routerModel = routerProfile.Config.Model
|
||||||
}
|
}
|
||||||
s.logf("msg=%d router_model=%s tool_loop start", msg.ID, routerModel)
|
s.logf("msg=%d router_model=%s tool_loop start", msg.ID, routerModel)
|
||||||
augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel))
|
// 把发送节点身份注入 ctx,供需要识别节点的工具(如签到)使用
|
||||||
|
nodeCtx := agenttool.WithNodeContext(procCtx, agenttool.NodeContext{
|
||||||
|
NodeID: msg.FromNodeID,
|
||||||
|
LongName: ptrStringValue(msg.LongName),
|
||||||
|
ShortName: ptrStringValue(msg.ShortName),
|
||||||
|
})
|
||||||
|
augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(nodeCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logf("msg=%d WARN tool_loop err=%v", msg.ID, err)
|
s.logf("msg=%d WARN tool_loop err=%v", msg.ID, err)
|
||||||
// Continue with original messages if tool loop fails
|
// Continue with original messages if tool loop fails
|
||||||
|
|||||||
@@ -45,6 +45,28 @@ func (s *Store) CountSignsByDay(opts ListOptions) ([]SignDayCount, error) {
|
|||||||
return rows, q.Scan(&rows).Error
|
return rows, q.Scan(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasSignedOnDay 判断指定节点在 day 所属的自然日(按 day 的时区)是否已有签到记录。
|
||||||
|
// 用 Go 端计算当日起止时间再查询,避免依赖 SQLite/MySQL 各自的日期函数。
|
||||||
|
func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) {
|
||||||
|
nodeID = strings.TrimSpace(nodeID)
|
||||||
|
if nodeID == "" {
|
||||||
|
return false, fmt.Errorf("node id is required")
|
||||||
|
}
|
||||||
|
loc := day.Location()
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||||
|
end := start.AddDate(0, 0, 1)
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&SignRecord{}).
|
||||||
|
Where("node_id = ? AND sign_time >= ? AND sign_time < ?", nodeID, start, end).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return false, fmt.Errorf("check sign on day: %w", err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
|
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
|
||||||
var row SignRecord
|
var row SignRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package toolrouter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -107,6 +108,13 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
|||||||
}
|
}
|
||||||
// toolUsed 记录本轮是否真的执行了至少一次工具调用,供调用方决定是否跳过话题选择等后续门控。
|
// toolUsed 记录本轮是否真的执行了至少一次工具调用,供调用方决定是否跳过话题选择等后续门控。
|
||||||
toolUsed := false
|
toolUsed := false
|
||||||
|
// 签到意图强制调用:用户明确想签到(「签到/打卡/上台」等)时,模型却不主动调
|
||||||
|
// sign 工具的话,会被下游话题判定当成噪音丢弃。这里在循环外预判意图,待模型
|
||||||
|
// 该轮未请求任何工具时强制注入一次 sign 调用(用用户原文作为 raw_text),
|
||||||
|
// 保证签到一定落库、且不会被话题判定丢弃。
|
||||||
|
forceSignText := detectSignIntent(chatMessages)
|
||||||
|
_, signAvailable := toolByName["sign"]
|
||||||
|
signInvoked := false // 本轮循环中是否已经调用过 sign(含模型主动调与强制调)
|
||||||
for i := 0; i < maxAgentToolIterations; i++ {
|
for i := 0; i < maxAgentToolIterations; i++ {
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "running", Message: fmt.Sprintf("正在进行第 %d 轮工具判断", i+1), Data: map[string]any{"iteration": i + 1, "max_iterations": maxAgentToolIterations, "tools": availableNames}})
|
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "running", Message: fmt.Sprintf("正在进行第 %d 轮工具判断", i+1), Data: map[string]any{"iteration": i + 1, "max_iterations": maxAgentToolIterations, "tools": availableNames}})
|
||||||
@@ -137,11 +145,20 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
|||||||
calls = []*model.ToolCall{{ID: "legacy_function_call", Type: model.ToolTypeFunction, Function: *choice.Message.FunctionCall}}
|
calls = []*model.ToolCall{{ID: "legacy_function_call", Type: model.ToolTypeFunction, Function: *choice.Message.FunctionCall}}
|
||||||
}
|
}
|
||||||
if len(calls) == 0 {
|
if len(calls) == 0 {
|
||||||
|
// 模型本轮未请求任何工具。若检测到签到意图且 sign 工具可用、本次循环尚未调过 sign,
|
||||||
|
// 则强制注入一次 sign 调用(以用户原文作为 raw_text),保证签到一定落库。
|
||||||
|
if forced := buildForcedSignCall(forceSignText, signAvailable, signInvoked); forced != nil {
|
||||||
|
if emit != nil {
|
||||||
|
emit(stream.Frame{Type: "trace", Tool: "sign", Stage: "tool_calls", Status: "running", Message: "检测到签到意图,模型未调用签到工具,强制调用 sign", Data: map[string]any{"tools": []string{"sign"}, "forced": true, "iteration": i + 1}})
|
||||||
|
}
|
||||||
|
calls = []*model.ToolCall{forced}
|
||||||
|
} else {
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"})
|
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"})
|
||||||
}
|
}
|
||||||
return finalMessages, toolUsed, nil
|
return finalMessages, toolUsed, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
callNames := make([]string, 0, len(calls))
|
callNames := make([]string, 0, len(calls))
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
if call != nil {
|
if call != nil {
|
||||||
@@ -157,6 +174,9 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
|||||||
finalMessages = append(finalMessages, assistantMessage)
|
finalMessages = append(finalMessages, assistantMessage)
|
||||||
decisionMessages = append(decisionMessages, assistantMessage)
|
decisionMessages = append(decisionMessages, assistantMessage)
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
|
if call != nil && call.Function.Name == "sign" {
|
||||||
|
signInvoked = true
|
||||||
|
}
|
||||||
result := ExecuteAgentToolCall(ctx, call, toolByName, emit)
|
result := ExecuteAgentToolCall(ctx, call, toolByName, emit)
|
||||||
resultContent := &model.ChatCompletionMessageContent{StringValue: &result}
|
resultContent := &model.ChatCompletionMessageContent{StringValue: &result}
|
||||||
toolMessage := &model.ChatCompletionMessage{Role: "tool", ToolCallID: call.ID, Content: resultContent}
|
toolMessage := &model.ChatCompletionMessage{Role: "tool", ToolCallID: call.ID, Content: resultContent}
|
||||||
@@ -195,3 +215,67 @@ func BoolPtr(b bool) *bool {
|
|||||||
func IntPtr(i int) *int {
|
func IntPtr(i int) *int {
|
||||||
return &i
|
return &i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signIntentKeywords 是判定签到意图的关键词。命中任一即认为用户想签到。
|
||||||
|
var signIntentKeywords = []string{"签到", "打卡", "上台"}
|
||||||
|
|
||||||
|
// detectSignIntent 取最后一条用户消息,若包含签到意图关键词则返回该消息原文
|
||||||
|
//(去除前缀的「[来自 ...]」等格式化包装),否则返回空串。
|
||||||
|
func detectSignIntent(chatMessages []message.ChatMessage) string {
|
||||||
|
userText := lastUserMessageText(chatMessages)
|
||||||
|
if strings.TrimSpace(userText) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, kw := range signIntentKeywords {
|
||||||
|
if strings.Contains(userText, kw) {
|
||||||
|
return stripFromPrefix(userText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripFromPrefix 去掉 autoreply.formatUserMessage 加上的「[来自 ...] 」前缀,
|
||||||
|
// 让签到正文只保留用户实际发送的内容。
|
||||||
|
func stripFromPrefix(s string) string {
|
||||||
|
if idx := strings.Index(s, "]"); idx >= 0 && strings.HasPrefix(strings.TrimSpace(s), "[") {
|
||||||
|
return strings.TrimSpace(s[idx+1:])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildForcedSignCall 在满足条件时构造一次强制 sign 调用。条件:
|
||||||
|
// - 有签到意图原文(signText 非空)
|
||||||
|
// - sign 工具可用
|
||||||
|
// - 本次循环尚未调过 sign(避免重复签到)
|
||||||
|
//
|
||||||
|
// 调用参数仅含 raw_text(用户原文),由 sign 工具回退为签到正文。
|
||||||
|
func buildForcedSignCall(signText string, signAvailable, signInvoked bool) *model.ToolCall {
|
||||||
|
if strings.TrimSpace(signText) == "" || !signAvailable || signInvoked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args, _ := json.Marshal(map[string]string{"raw_text": signText})
|
||||||
|
argsStr := string(args)
|
||||||
|
return &model.ToolCall{
|
||||||
|
ID: "forced_sign",
|
||||||
|
Type: model.ToolTypeFunction,
|
||||||
|
Function: model.FunctionCall{
|
||||||
|
Name: "sign",
|
||||||
|
Arguments: argsStr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastUserMessageText 返回消息列表中最后一条 role 为 user 的消息内容。
|
||||||
|
func lastUserMessageText(messages []message.ChatMessage) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
msg := messages[i]
|
||||||
|
role := msg.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "user"
|
||||||
|
}
|
||||||
|
if role == "user" {
|
||||||
|
return msg.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ func run(cfg *configpkg.Config) error {
|
|||||||
ToolConfigStore: store,
|
ToolConfigStore: store,
|
||||||
ToolRouterStore: store,
|
ToolRouterStore: store,
|
||||||
TopicRouterStore: store,
|
TopicRouterStore: store,
|
||||||
|
Store: store,
|
||||||
}, store.DB(), botSenderAdapter)
|
}, store.DB(), botSenderAdapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user