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:
mlogclub
2026-04-10 14:43:57 +08:00
parent 7411bac57d
commit 1370e4b675
23 changed files with 642 additions and 222 deletions
@@ -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),
+22 -1
View File
@@ -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
}
+5 -4
View File
@@ -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 {
+35
View File
@@ -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,
+79 -4
View File
@@ -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 {