Files
Enterprise-Knowledge-Base/internal/agent/observe.go
T
wizardchen b86efe3f92 fix(agent): harden @mention scope and simplify must_use hints
Tighten per-turn @MCP/@Skill/@tag handling after review: enforce agent
MCP/Skill whitelist, ignore mentions when selection mode is none, tag-scope
grep_chunks, and simplified English must_use lines without tool dumps.
Skip persisting scope envelopes to history rendered_content.
2026-06-29 18:09:44 +08:00

520 lines
19 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
agenttoken "github.com/Tencent/WeKnora/internal/agent/token"
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
)
// manageContextWindow consolidates or compresses messages if approaching the token limit.
// currentTokens is the caller's best estimate of the current context size (using
// API-reported Usage when available, falling back to BPE estimation).
func (e *AgentEngine) manageContextWindow(ctx context.Context, messages []chat.Message, round, currentTokens int) []chat.Message {
if e.config.MaxContextTokens <= 0 {
return messages
}
beforeLen := len(messages)
if e.memoryConsolidator != nil && e.memoryConsolidator.ShouldConsolidate(currentTokens) {
logger.Infof(ctx, "[Agent][Round-%d] Token threshold exceeded (est=%d), consolidating memory",
round, currentTokens)
consolidated, consolidateErr := e.memoryConsolidator.Consolidate(ctx, messages)
if consolidateErr != nil {
logger.Warnf(ctx, "[Agent][Round-%d] Memory consolidation failed: %v, "+
"falling back to simple compression", round, consolidateErr)
} else {
messages = consolidated
currentTokens = e.tokenEstimator.EstimateMessages(messages)
}
}
messages = agenttoken.CompressContext(messages, e.tokenEstimator, e.config.MaxContextTokens, currentTokens)
if len(messages) < beforeLen {
logger.Infof(ctx, "[Agent][Round-%d] Context managed: %d → %d messages (max_tokens=%d)",
round, beforeLen, len(messages), e.config.MaxContextTokens)
}
return messages
}
// responseVerdict captures the result of analyzing an LLM response to determine
// whether the agent loop should stop and what the final answer is (if any).
type responseVerdict struct {
isDone bool
finalAnswer string
emptyContent bool // LLM returned stop with no tool calls and empty content
step types.AgentStep
}
// analyzeResponse inspects the LLM response for stop conditions:
// - finish_reason == "stop" with no tool calls → agent is done (natural stop)
// - finish_reason == "content_filter" with no tool calls → agent is done (content filtered)
//
// The agent ends a turn by stopping naturally with its answer as plain
// assistant text (there is no dedicated final_answer tool). Any round that
// still requests tool calls is non-terminal and the caller continues the loop.
// It returns a responseVerdict. If isDone is true the caller should break out of the loop.
func (e *AgentEngine) analyzeResponse(
ctx context.Context, response *types.ChatResponse,
step types.AgentStep, iteration int, sessionID string, roundStart time.Time,
) responseVerdict {
// Case 0: Content was blocked by the model's content filter.
// Treat this as a terminal condition to avoid an infinite loop where
// the same filtered response accumulates in the context.
if response.FinishReason == "content_filter" && len(response.ToolCalls) == 0 {
logger.Warnf(ctx, "[Agent][Round-%d] Content filter triggered, stopping agent loop (content=%d chars)",
iteration+1, len(response.Content))
common.PipelineWarn(ctx, "Agent", "content_filter_stop", map[string]interface{}{
"iteration": iteration,
"round": iteration + 1,
"content_len": len(response.Content),
})
answer := response.Content
if answer == "" {
answer = "Sorry, this request was blocked by the content safety policy. Please try rephrasing your question."
}
answerID := generateEventID("answer")
e.eventBus.Emit(ctx, event.Event{
ID: answerID,
Type: event.EventAgentFinalAnswer,
SessionID: sessionID,
Data: event.AgentFinalAnswerData{
Content: answer,
Done: false,
},
})
e.eventBus.Emit(ctx, event.Event{
ID: answerID,
Type: event.EventAgentFinalAnswer,
SessionID: sessionID,
Data: event.AgentFinalAnswerData{
Content: "",
Done: true,
},
})
return responseVerdict{
isDone: true,
finalAnswer: answer,
step: step,
}
}
// Case 1: LLM stopped naturally without requesting any tool calls
if response.FinishReason == "stop" && len(response.ToolCalls) == 0 {
// Strip <think>…</think> blocks that some models embed in content
// (DeepSeek, Qwen, etc.) before processing or displaying.
response.Content = agenttools.StripThinkBlocks(response.Content)
logger.Infof(ctx, "[Agent][Round-%d] Agent finished naturally: answer=%d chars, duration=%dms",
iteration+1, len(response.Content), time.Since(roundStart).Milliseconds())
common.PipelineInfo(ctx, "Agent", "round_final_answer", map[string]interface{}{
"iteration": iteration,
"round": iteration + 1,
"answer_len": len(response.Content),
})
// Emit the final answer. The answer text reaches the UI by one of two
// paths:
// (a) Already streamed live during the think phase — the common case
// now that plain assistant content is routed straight to
// EventAgentFinalAnswer (response.AnswerStreamed). Re-emitting the
// full content here would render it twice and produce the
// end-of-stream "jump from Thinking to Answer" the user reported,
// so we only close the existing stream with a Done marker on the
// same event ID.
// (b) Not streamed live (e.g. the content only surfaced in the
// accumulated result) — emit the full content, then Done.
var answerID string
if response.AnswerStreamed && response.AnswerEventID != "" {
answerID = response.AnswerEventID
} else {
answerID = generateEventID("answer")
if response.Content != "" {
e.eventBus.Emit(ctx, event.Event{
ID: answerID,
Type: event.EventAgentFinalAnswer,
SessionID: sessionID,
Data: event.AgentFinalAnswerData{
Content: response.Content,
Done: false,
},
})
}
}
e.eventBus.Emit(ctx, event.Event{
ID: answerID,
Type: event.EventAgentFinalAnswer,
SessionID: sessionID,
Data: event.AgentFinalAnswerData{
Content: "",
Done: true,
},
})
return responseVerdict{
isDone: true,
finalAnswer: response.Content,
emptyContent: response.Content == "",
step: step,
}
}
// Any round that still requests tool calls is non-terminal: the caller
// executes the tools and loops again. The agent only ends by stopping
// naturally (Case 1) with its answer as plain assistant text.
return responseVerdict{isDone: false, step: step}
}
// indentLines prefixes every line of s with indent. Used to nest pre-rendered
// XML blocks inside the `<runtime_context>` envelope without losing readability.
func indentLines(s, indent string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
for i, line := range lines {
if line == "" {
continue
}
lines[i] = indent + line
}
return strings.Join(lines, "\n")
}
// escapeXMLAttr escapes a string for safe inclusion in an XML attribute value.
// Titles and names may contain user-supplied characters like <, >, &, ".
func escapeXMLAttr(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
// buildRuntimeContextBlock builds a metadata block with current time, session
// info, and the *active retrieval scope for this turn only*. It is injected
// into the current user message for the LLM call and is not persisted into
// conversation history — replayed user turns keep bare Content so stale scope
// snapshots do not steer follow-up questions.
//
// Per-turn communication_instruction and answer_instruction remind the model
// not to leak internal tool names or IDs in user-visible text, and to end the
// turn by writing its complete answer as plain assistant text.
//
// Emitted as an XML-ish block (not free prose) so it is a visually distinct,
// non-instruction envelope that is hard to conflate with user text and
// prompt-injection-safe.
func buildRuntimeContextBlock(
sessionID string,
kbs []*KnowledgeBaseInfo,
docs []*SelectedDocumentInfo,
) string {
var sb strings.Builder
sb.WriteString("<runtime_context scope=\"this_turn\">\n")
fmt.Fprintf(&sb, " <current_time>%s</current_time>\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(&sb, " <session>%s</session>\n", escapeXMLAttr(sessionID))
if len(kbs) > 0 {
// Render the full bound-KB detail (capabilities + recent docs) so the
// model has everything it needs to route its retrieval in one place.
// `formatKnowledgeBaseList` already emits a `<knowledge_bases>…</knowledge_bases>`
// envelope; we wrap it in `<bound_knowledge_bases>` to make the scope
// semantics explicit and to match the naming the prompt templates use
// when referring back to this block.
sb.WriteString(" <bound_knowledge_bases>\n")
sb.WriteString(indentLines(formatKnowledgeBaseList(kbs), " "))
sb.WriteString("\n </bound_knowledge_bases>\n")
}
if len(docs) > 0 {
sb.WriteString(" <pinned_documents scope=\"authoritative_for_this_turn\">\n")
for _, d := range docs {
if d == nil {
continue
}
title := d.Title
if title == "" {
title = d.FileName
}
if title == "" {
title = d.KnowledgeID
}
if d.FileType != "" {
fmt.Fprintf(&sb, " <document knowledge_id=\"%s\" title=\"%s\" file_type=\"%s\" />\n",
escapeXMLAttr(d.KnowledgeID), escapeXMLAttr(title), escapeXMLAttr(d.FileType))
} else {
fmt.Fprintf(&sb, " <document knowledge_id=\"%s\" title=\"%s\" />\n",
escapeXMLAttr(d.KnowledgeID), escapeXMLAttr(title))
}
}
sb.WriteString(" </pinned_documents>\n")
sb.WriteString(" <note>The pinned-document set above is authoritative for THIS turn. ")
sb.WriteString("Prioritize retrieving content from these documents (e.g. list_knowledge_chunks with the knowledge_id). ")
sb.WriteString("If an earlier turn analysed a different document, do NOT reuse that analysis — re-query against the current scope.</note>\n")
}
sb.WriteString(" <communication_instruction>Do not use internal tool names or identifiers in your answers or in Thought. Say \"keyword retrieval\" instead of grep_chunks, \"semantic retrieval\" instead of knowledge_search, \"browse full document\" instead of list_knowledge_chunks; likewise never expose chunk_id, knowledge_id, or other internal IDs—refer to documents by title or name.</communication_instruction>\n")
sb.WriteString(" <answer_instruction>When you have gathered enough information, write your complete user-facing answer as your reply and stop—do not request any more tools in that final message. Until then, keep using tools; do not give a partial answer mid-investigation.</answer_instruction>\n")
sb.WriteString("</runtime_context>")
return sb.String()
}
// buildMustUseBlock emits a short per-turn hint when the user @mentioned MCP/Skill.
// Tool names are not listed here — they are already in the function-calling schema.
func buildMustUseBlock(mcpServices []*PinnedMCPServiceInfo, skills []*PinnedSkillInfo) string {
var lines []string
for _, svc := range mcpServices {
if svc == nil {
continue
}
prefix := mcpToolNamePrefix(svc)
if prefix == "" {
continue
}
display := svc.Name
if display == "" {
display = svc.ID
}
lines = append(lines, fmt.Sprintf("Must use MCP tools whose names start with %s (@%s) to answer the question below.", prefix, display))
}
for _, skill := range skills {
if skill == nil || skill.Name == "" {
continue
}
lines = append(lines, fmt.Sprintf("Must call read_skill(skill_name=\"%s\") for @Skill \"%s\" before answering.", skill.Name, skill.Name))
}
if len(lines) == 0 {
return ""
}
return "<must_use>\n" + strings.Join(lines, "\n") + "\n</must_use>"
}
// mcpToolNamePrefix returns the shared prefix for an MCP service's registered tools
// (e.g. mcp_iwiki_ from mcp_iwiki_getdocument).
func mcpToolNamePrefix(svc *PinnedMCPServiceInfo) string {
if svc == nil || len(svc.ToolNames) == 0 {
return ""
}
const head = "mcp_"
for _, toolName := range svc.ToolNames {
if !strings.HasPrefix(toolName, head) {
continue
}
rest := toolName[len(head):]
idx := strings.Index(rest, "_")
if idx <= 0 {
continue
}
return head + rest[:idx+1]
}
return ""
}
// RenderUserTurnContent builds the user-turn payload for the current LLM call
// (runtime_context + must_use + query). Used by Execute and finalize paths only;
// not written to rendered_content / history.
func (e *AgentEngine) RenderUserTurnContent(sessionID, query string) string {
runtimeCtx := buildRuntimeContextBlock(sessionID, e.knowledgeBasesInfo, e.selectedDocs)
mustUse := buildMustUseBlock(e.pinnedMCPServices, e.pinnedSkills)
return composeUserTurnContent(runtimeCtx, mustUse, query)
}
func composeUserTurnContent(parts ...string) string {
nonEmpty := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) != "" {
nonEmpty = append(nonEmpty, part)
}
}
return strings.Join(nonEmpty, "\n\n")
}
// listToolNames returns tool.function names for logging
func listToolNames(ts []chat.Tool) []string {
names := make([]string, 0, len(ts))
for _, t := range ts {
names = append(names, t.Function.Name)
}
return names
}
// buildToolsForLLM builds the tools list for LLM function calling
func (e *AgentEngine) buildToolsForLLM() []chat.Tool {
functionDefs := e.toolRegistry.GetFunctionDefinitions()
tools := make([]chat.Tool, 0, len(functionDefs))
for _, def := range functionDefs {
tools = append(tools, chat.Tool{
Type: "function",
Function: chat.FunctionDef{
Name: def.Name,
Description: def.Description,
Parameters: def.Parameters,
},
})
}
return tools
}
// appendToolResults adds tool results to the in-turn message history following
// OpenAI's tool-calling format. Cross-turn persistence is handled separately:
// the final AgentSteps are written to the assistant message by the SSE handler,
// and rebuilt from DB on the next turn by service.LoadAgentHistory.
func (e *AgentEngine) appendToolResults(
messages []chat.Message,
step types.AgentStep,
) []chat.Message {
// Add assistant message with tool calls (if any)
if step.Thought != "" || len(step.ToolCalls) > 0 || step.ReasoningContent != "" {
assistantMsg := chat.Message{
Role: "assistant",
Content: step.Thought,
ReasoningContent: step.ReasoningContent,
}
// Add tool calls to assistant message (following OpenAI format)
if len(step.ToolCalls) > 0 {
assistantMsg.ToolCalls = make([]chat.ToolCall, 0, len(step.ToolCalls))
for _, tc := range step.ToolCalls {
// Convert arguments back to JSON string
argsJSON, _ := json.Marshal(tc.Args)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, chat.ToolCall{
ID: tc.ID,
Type: "function",
ProviderMetadata: tc.ProviderMetadata,
Function: chat.FunctionCall{
Name: tc.Name,
Arguments: string(argsJSON),
},
})
}
}
messages = append(messages, assistantMsg)
}
// Add tool result messages (role: "tool", following OpenAI format)
for _, toolCall := range step.ToolCalls {
resultContent := toolCall.Result.Output
if !toolCall.Result.Success {
resultContent = fmt.Sprintf("Error: %s", toolCall.Result.Error)
}
toolMsg := chat.Message{
Role: "tool",
Content: resultContent,
ToolCallID: toolCall.ID,
Name: toolCall.Name,
}
messages = append(messages, toolMsg)
}
return messages
}
// countTotalToolCalls counts total tool calls across all steps
func countTotalToolCalls(steps []types.AgentStep) int {
total := 0
for _, step := range steps {
total += len(step.ToolCalls)
}
return total
}
// kbToolNames lists tools whose results contain knowledge base content that
// may become stale across turns (KB can be switched, updated, or deleted).
// Historical results from these tools are redacted to force fresh retrieval.
var kbToolNames = map[string]bool{
agenttools.ToolKnowledgeSearch: true,
agenttools.ToolGrepChunks: true,
agenttools.ToolListKnowledgeChunks: true,
agenttools.ToolQueryKnowledgeGraph: true,
agenttools.ToolGetDocumentInfo: true,
agenttools.ToolWikiSearch: true,
agenttools.ToolWikiReadPage: true,
agenttools.ToolWikiReadSourceDoc: true,
}
// redactHistoryKBResults replaces full KB tool results in historical context
// with brief markers. This prevents the LLM from reusing stale retrieval data
// when the knowledge base has been modified or switched between turns.
func redactHistoryKBResults(llmContext []chat.Message) []chat.Message {
redacted := make([]chat.Message, 0, len(llmContext))
for _, msg := range llmContext {
if msg.Role == "tool" && kbToolNames[msg.Name] {
redacted = append(redacted, chat.Message{
Role: msg.Role,
Content: "[Previous retrieval result omitted — knowledge base may have changed. Please perform a fresh search.]",
ToolCallID: msg.ToolCallID,
Name: msg.Name,
})
} else {
redacted = append(redacted, msg)
}
}
return redacted
}
// buildMessagesWithLLMContext builds the message array with LLM context
func (e *AgentEngine) buildMessagesWithLLMContext(
systemPrompt, currentQuery, sessionID string,
llmContext []chat.Message,
imageURLs []string,
) []chat.Message {
messages := []chat.Message{
{Role: "system", Content: systemPrompt},
}
if len(llmContext) > 0 {
var sanitized []chat.Message
if e.config.RetainRetrievalHistory {
sanitized = llmContext
logger.Infof(context.Background(), "Retaining full retrieval history in context (RetainRetrievalHistory=true)")
} else {
// Redact KB tool results from previous turns to prevent the LLM
// from reusing stale retrieval data when the KB has been modified.
sanitized = redactHistoryKBResults(llmContext)
logger.Infof(context.Background(), "Added %d history messages to context (KB tool results redacted)", len(llmContext))
}
for _, msg := range sanitized {
if msg.Role == "system" {
continue
}
if msg.Role == "user" || msg.Role == "assistant" || msg.Role == "tool" {
messages = append(messages, msg)
}
}
}
// Build user message with per-turn scope envelopes (current turn only).
// Historical user messages in llmContext stay as bare Content from the DB.
runtimeCtx := buildRuntimeContextBlock(sessionID, e.knowledgeBasesInfo, e.selectedDocs)
mustUse := buildMustUseBlock(e.pinnedMCPServices, e.pinnedSkills)
userMsg := chat.Message{
Role: "user",
Content: composeUserTurnContent(runtimeCtx, mustUse, currentQuery),
Images: imageURLs,
}
messages = append(messages, userMsg)
return messages
}