feat: enhance AI agent functionality with tool management and logging improvements
- Added AllowedToolCodes field to Context for managing tool access. - Introduced ResumeSource in aiReplyTraceData to track resume points. - Enhanced logging with additional fields: PlannedSkillName, SkillRouteTrace, InterruptType, ResumeSource, and FinalStatus. - Implemented functions to parse and resolve allowed tool codes for agents and skills. - Refactored skill definition creation and update logic to streamline request handling. - Updated MCP tool catalog to include source type and integrated built-in tools. - Improved UI components to display additional tool information and enhance user experience.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
einoadapter "cs-agent/internal/ai/runtime/internal/impl/adapter"
|
||||
@@ -17,14 +18,16 @@ import (
|
||||
)
|
||||
|
||||
type AgentFactory struct {
|
||||
chatModelFactory *ChatModelFactory
|
||||
toolFactory *ToolFactory
|
||||
chatModelFactory *ChatModelFactory
|
||||
toolFactory *ToolFactory
|
||||
instructionAssembler *InstructionAssembler
|
||||
}
|
||||
|
||||
func NewAgentFactory() *AgentFactory {
|
||||
return &AgentFactory{
|
||||
chatModelFactory: NewChatModelFactory(),
|
||||
toolFactory: NewToolFactory(),
|
||||
chatModelFactory: NewChatModelFactory(),
|
||||
toolFactory: NewToolFactory(),
|
||||
instructionAssembler: NewInstructionAssembler(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +97,35 @@ func buildAgentInstruction(aiAgent *models.AIAgent, selectedSkill *models.SkillD
|
||||
5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。
|
||||
`))
|
||||
}
|
||||
if len(appendixParts) == 0 {
|
||||
return baseInstruction
|
||||
projectRoot, _ := os.Getwd()
|
||||
return NewInstructionAssembler().Build(InstructionAssemblerInput{
|
||||
ProjectRoot: projectRoot,
|
||||
AgentInstruction: baseInstruction,
|
||||
SkillInstruction: firstAppendixPart(appendixParts),
|
||||
ToolAppendices: remainingAppendixParts(appendixParts),
|
||||
})
|
||||
}
|
||||
|
||||
func firstAppendixPart(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
if baseInstruction == "" {
|
||||
return strings.Join(appendixParts, "\n\n")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
func remainingAppendixParts(parts []string) []string {
|
||||
if len(parts) <= 1 {
|
||||
return nil
|
||||
}
|
||||
return baseInstruction + "\n\n" + strings.Join(appendixParts, "\n\n")
|
||||
ret := make([]string, 0, len(parts)-1)
|
||||
for _, item := range parts[1:] {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildSelectedSkillInstruction(skill *models.SkillDefinition, toolDefinitions []einoadapter.MCPToolDefinition) string {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type InstructionAssembler struct {
|
||||
governanceInstruction string
|
||||
}
|
||||
|
||||
type InstructionAssemblerInput struct {
|
||||
AgentInstruction string
|
||||
SkillInstruction string
|
||||
ToolAppendices []string
|
||||
ProjectRoot string
|
||||
ProjectInstruction string
|
||||
}
|
||||
|
||||
var (
|
||||
projectInstructionOnce sync.Once
|
||||
projectInstructionText string
|
||||
)
|
||||
|
||||
func NewInstructionAssembler() *InstructionAssembler {
|
||||
return &InstructionAssembler{
|
||||
governanceInstruction: strings.TrimSpace(`
|
||||
你正在一个有明确工程约束的客服系统中工作。
|
||||
执行时必须严格遵守当前注入的项目规则、Agent 规则和技能规则。
|
||||
如果存在工具白名单限制,只能调用当前允许的工具;信息不足时优先追问,不要伪造事实或跳过必要确认。
|
||||
`),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *InstructionAssembler) Build(input InstructionAssemblerInput) string {
|
||||
parts := make([]string, 0, 5)
|
||||
projectInstruction := strings.TrimSpace(input.ProjectInstruction)
|
||||
if projectInstruction == "" {
|
||||
projectInstruction = loadProjectInstruction(input.ProjectRoot)
|
||||
}
|
||||
if projectInstruction != "" {
|
||||
parts = append(parts, buildInstructionSection("项目级规则", projectInstruction))
|
||||
}
|
||||
if a != nil && strings.TrimSpace(a.governanceInstruction) != "" {
|
||||
parts = append(parts, buildInstructionSection("系统治理规则", a.governanceInstruction))
|
||||
}
|
||||
if agentInstruction := strings.TrimSpace(input.AgentInstruction); agentInstruction != "" {
|
||||
parts = append(parts, buildInstructionSection("Agent 规则", agentInstruction))
|
||||
}
|
||||
if skillInstruction := strings.TrimSpace(input.SkillInstruction); skillInstruction != "" {
|
||||
parts = append(parts, buildInstructionSection("当前技能上下文", skillInstruction))
|
||||
}
|
||||
if appendix := buildToolAppendix(input.ToolAppendices); appendix != "" {
|
||||
parts = append(parts, buildInstructionSection("工具补充规则", appendix))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
|
||||
func buildInstructionSection(title, body string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if title == "" {
|
||||
return body
|
||||
}
|
||||
return title + ":\n" + body
|
||||
}
|
||||
|
||||
func buildToolAppendix(input []string) string {
|
||||
if len(input) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(input))
|
||||
for _, item := range input {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, item)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
|
||||
func loadProjectInstruction(projectRoot string) string {
|
||||
// TODO 这里还要读取工程目录中的AGENTS.md吗?
|
||||
projectInstructionOnce.Do(func() {
|
||||
candidates := []string{
|
||||
"AGENTS.md",
|
||||
}
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
if projectRoot != "" {
|
||||
candidates = append([]string{filepath.Join(projectRoot, "AGENTS.md")}, candidates...)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
projectInstructionText = strings.TrimSpace(string(data))
|
||||
if projectInstructionText != "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return projectInstructionText
|
||||
}
|
||||
@@ -32,10 +32,14 @@ func (f *ToolFactory) BuildMCPTools(aiAgent *models.AIAgent) ([]impladapter.MCPT
|
||||
if toolCode == "" {
|
||||
toolCode = toolx.BuildMCPToolCode(item.ServerCode, item.ToolName)
|
||||
}
|
||||
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
|
||||
if serverCode == "" || toolName == "" {
|
||||
continue
|
||||
}
|
||||
definition := impladapter.MCPToolDefinition{
|
||||
ToolCode: toolCode,
|
||||
ServerCode: strings.TrimSpace(item.ServerCode),
|
||||
ToolName: strings.TrimSpace(item.ToolName),
|
||||
ServerCode: serverCode,
|
||||
ToolName: toolName,
|
||||
Title: strings.TrimSpace(item.Title),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
FixedArgs: cloneStringMap(item.Arguments),
|
||||
|
||||
@@ -21,10 +21,17 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
|
||||
Tools: make([]einotool.BaseTool, 0, len(r.tools)),
|
||||
ToolCodes: make(map[string]string),
|
||||
}
|
||||
allowedToolCodes := makeAllowedToolCodeSet(ctx.AllowedToolCodes)
|
||||
for _, toolDef := range r.tools {
|
||||
if toolDef == nil || !toolDef.Enabled(ctx) {
|
||||
continue
|
||||
}
|
||||
toolCode := strings.TrimSpace(toolDef.Code())
|
||||
if len(allowedToolCodes) > 0 {
|
||||
if _, ok := allowedToolCodes[toolCode]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
tool, err := toolDef.Build(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -33,7 +40,6 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(toolDef.Name())
|
||||
toolCode := strings.TrimSpace(toolDef.Code())
|
||||
if toolName == "" || toolCode == "" {
|
||||
continue
|
||||
}
|
||||
@@ -42,3 +48,18 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func makeAllowedToolCodeSet(input []string) map[string]struct{} {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[string]struct{}, len(input))
|
||||
for _, item := range input {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
ret[item] = struct{}{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
Conversation *models.Conversation
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
UserMessage *models.Message
|
||||
Conversation *models.Conversation
|
||||
AIAgent *models.AIAgent
|
||||
AIConfig *models.AIConfig
|
||||
UserMessage *models.Message
|
||||
AllowedToolCodes []string
|
||||
}
|
||||
|
||||
type ToolSet struct {
|
||||
|
||||
@@ -36,6 +36,7 @@ type aiReplyTraceData struct {
|
||||
RecheckMs int64 `json:"recheckMs,omitempty"`
|
||||
CommitMs int64 `json:"commitMs,omitempty"`
|
||||
FinalAction string `json:"finalAction,omitempty"`
|
||||
ResumeSource string `json:"resumeSource,omitempty"`
|
||||
ReplySent bool `json:"replySent,omitempty"`
|
||||
ReplyMessageID int64 `json:"replyMessageId,omitempty"`
|
||||
Runtime json.RawMessage `json:"runtime,omitempty"`
|
||||
@@ -162,6 +163,7 @@ func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, conversatio
|
||||
return fmt.Errorf("ai config is nil")
|
||||
}
|
||||
runtimeStartedAt := time.Now()
|
||||
trace.ResumeSource = "pending_interrupt"
|
||||
summary, err := Service.Resume(ctx, ResumeRequest{
|
||||
Conversation: &conversation,
|
||||
AIAgent: &aiAgent,
|
||||
@@ -327,9 +329,14 @@ func (s *aiReplyService) writeRunLog(startedAt time.Time, message models.Message
|
||||
UserMessage: strings.TrimSpace(question),
|
||||
PlannedAction: plannedAction,
|
||||
PlannedSkillCode: strings.TrimSpace(summaryPlannedSkillCode(summary)),
|
||||
PlannedSkillName: strings.TrimSpace(summaryPlannedSkillName(summary)),
|
||||
SkillRouteTrace: strings.TrimSpace(summarySkillRouteTrace(summary)),
|
||||
PlannedToolCode: plannedToolCode,
|
||||
PlanReason: planReason,
|
||||
InterruptType: firstInterruptType(summary),
|
||||
ResumeSource: runLogResumeSource(trace),
|
||||
FinalAction: toRunLogFinalAction(summary),
|
||||
FinalStatus: runLogFinalStatus(summary),
|
||||
ReplyText: buildRunLogReplyText(summary),
|
||||
ErrorMessage: errorMessage,
|
||||
LatencyMs: time.Since(startedAt).Milliseconds(),
|
||||
@@ -422,6 +429,34 @@ func summaryPlannedSkillCode(summary *Summary) string {
|
||||
return strings.TrimSpace(summary.PlannedSkillCode)
|
||||
}
|
||||
|
||||
func summaryPlannedSkillName(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.PlannedSkillName)
|
||||
}
|
||||
|
||||
func summarySkillRouteTrace(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.SkillRouteTrace)
|
||||
}
|
||||
|
||||
func runLogResumeSource(trace *aiReplyTraceData) string {
|
||||
if trace == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(trace.ResumeSource)
|
||||
}
|
||||
|
||||
func runLogFinalStatus(summary *Summary) string {
|
||||
if summary == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary.Status)
|
||||
}
|
||||
|
||||
func (s *aiReplyService) incrementAIReplyRounds(conversationID int64, nextRounds int, aiAgentName string) error {
|
||||
return repositories.ConversationRepository.Updates(sqls.DB(), conversationID, map[string]any{
|
||||
"ai_reply_rounds": nextRounds,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"cs-agent/internal/ai/runtime/tools"
|
||||
"cs-agent/internal/ai/skills"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
var Service = newService()
|
||||
@@ -89,10 +90,11 @@ func (s *service) prepareToolsForRun(req *Request) error {
|
||||
return nil
|
||||
}
|
||||
toolSet, err := s.registry.Resolve(registry.Context{
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
UserMessage: req.UserMessage,
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
UserMessage: req.UserMessage,
|
||||
AllowedToolCodes: resolveAllowedToolCodes(req.AIAgent, req.SelectedSkill),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -200,3 +202,76 @@ func cloneSkillDefinition(item *models.SkillDefinition) *models.SkillDefinition
|
||||
clone := *item
|
||||
return &clone
|
||||
}
|
||||
|
||||
func parseSkillAllowedToolCodes(skill *models.SkillDefinition) []string {
|
||||
if skill == nil {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(skill.AllowedToolCodes)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseAgentAllowedToolCodes(aiAgent *models.AIAgent) []string {
|
||||
if aiAgent == nil || strings.TrimSpace(aiAgent.AllowedMCPTools) == "" {
|
||||
return nil
|
||||
}
|
||||
items, err := toolx.ParseAgentMCPToolsJSON(aiAgent.AllowedMCPTools)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
toolCode := strings.TrimSpace(item.ToolCode)
|
||||
if toolCode == "" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, toolCode)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func resolveAllowedToolCodes(aiAgent *models.AIAgent, skill *models.SkillDefinition) []string {
|
||||
agentAllowed := parseAgentAllowedToolCodes(aiAgent)
|
||||
skillAllowed := parseSkillAllowedToolCodes(skill)
|
||||
switch {
|
||||
case len(agentAllowed) == 0:
|
||||
return skillAllowed
|
||||
case len(skillAllowed) == 0:
|
||||
return agentAllowed
|
||||
default:
|
||||
skillSet := make(map[string]struct{}, len(skillAllowed))
|
||||
for _, item := range skillAllowed {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
skillSet[item] = struct{}{}
|
||||
}
|
||||
ret := make([]string, 0, len(agentAllowed))
|
||||
for _, item := range agentAllowed {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := skillSet[item]; ok {
|
||||
ret = append(ret, item)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/toolx"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
componenttool "github.com/cloudwego/eino/components/tool"
|
||||
@@ -20,8 +21,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation"
|
||||
CreateTicketConfirmToolName = "create_ticket_with_confirmation"
|
||||
CreateTicketConfirmToolCode = toolx.BuiltinCreateTicketConfirmToolCode
|
||||
CreateTicketConfirmToolName = toolx.BuiltinCreateTicketConfirmToolName
|
||||
)
|
||||
|
||||
type CreateTicketConfirmState struct {
|
||||
|
||||
Reference in New Issue
Block a user