package agent import ( "fmt" "strings" "time" "github.com/Tencent/WeKnora/internal/agent/skills" "github.com/Tencent/WeKnora/internal/config" "github.com/Tencent/WeKnora/internal/types" ) // formatFileSize formats file size in human-readable format func formatFileSize(size int64) string { const ( KB = 1024 MB = 1024 * KB GB = 1024 * MB ) if size < KB { return fmt.Sprintf("%d B", size) } else if size < MB { return fmt.Sprintf("%.2f KB", float64(size)/KB) } else if size < GB { return fmt.Sprintf("%.2f MB", float64(size)/MB) } return fmt.Sprintf("%.2f GB", float64(size)/GB) } // formatDocSummary cleans and truncates document summaries for table display func formatDocSummary(summary string, maxLen int) string { cleaned := strings.TrimSpace(summary) if cleaned == "" { return "-" } cleaned = strings.ReplaceAll(cleaned, "\n", " ") cleaned = strings.ReplaceAll(cleaned, "\r", " ") cleaned = strings.Join(strings.Fields(cleaned), " ") runes := []rune(cleaned) if len(runes) <= maxLen { return cleaned } return strings.TrimSpace(string(runes[:maxLen])) + "..." } // RecentDocInfo contains brief information about a recently added document type RecentDocInfo struct { ChunkID string KnowledgeBaseID string KnowledgeID string Title string Description string FileName string FileSize int64 Type string CreatedAt string // Formatted time string FAQStandardQuestion string FAQSimilarQuestions []string FAQAnswers []string } // SelectedDocumentInfo contains summary information about a user-selected document (via @ mention). // Injected into the user message runtime_context (pinned_documents); content is fetched via tools. type SelectedDocumentInfo struct { KnowledgeID string // Knowledge ID KnowledgeBaseID string // Knowledge base ID Title string // Document title FileName string // Original file name FileType string // File type (pdf, docx, etc.) } // PinnedMCPServiceInfo describes an MCP service explicitly @mentioned for this turn. type PinnedMCPServiceInfo struct { ID string Name string Description string ToolNames []string // Registered tool.function names for this service (mcp_{service}_{tool}) } // PinnedSkillInfo describes a preloaded skill explicitly @mentioned for this turn. type PinnedSkillInfo struct { Name string Description string } // KnowledgeBaseInfo contains essential information about a knowledge base for agent prompt type KnowledgeBaseInfo struct { ID string Name string Type string // Knowledge base type: "document" or "faq" Description string DocCount int // Capabilities lists the retrieval surfaces this KB exposes. Any subset of // {"wiki", "chunks"}. "chunks" is present when the KB has vector and/or // keyword (BM25) indexing enabled. This is the *deterministic* source of // truth the agent should consult before picking a retrieval strategy — // significantly more reliable than running probing searches. Capabilities []string RecentDocs []RecentDocInfo // Recently added documents (up to 10) } // PlaceholderDefinition defines a placeholder exposed to UI/configuration // Deprecated: Use types.PromptPlaceholder instead type PlaceholderDefinition struct { Name string `json:"name"` Label string `json:"label"` Description string `json:"description"` } // AvailablePlaceholders lists all supported prompt placeholders for UI hints // This returns agent mode specific placeholders func AvailablePlaceholders() []PlaceholderDefinition { // Use centralized placeholder definitions from types package placeholders := types.PlaceholdersByField(types.PromptFieldAgentSystemPrompt) result := make([]PlaceholderDefinition, len(placeholders)) for i, p := range placeholders { result[i] = PlaceholderDefinition{ Name: p.Name, Label: p.Label, Description: p.Description, } } return result } // formatKnowledgeBaseList formats knowledge base information as XML for the prompt func formatKnowledgeBaseList(kbInfos []*KnowledgeBaseInfo) string { if len(kbInfos) == 0 { return "" } var b strings.Builder b.WriteString("\n") for _, kb := range kbInfos { kbType := kb.Type if kbType == "" { kbType = "document" } capsAttr := "" if len(kb.Capabilities) > 0 { capsAttr = fmt.Sprintf(" capabilities=\"%s\"", strings.Join(kb.Capabilities, ",")) } b.WriteString(fmt.Sprintf("\n", kb.ID, kb.Name, kbType, kb.DocCount, capsAttr)) if kb.Description != "" { b.WriteString(fmt.Sprintf("%s\n", kb.Description)) } if len(kb.RecentDocs) > 0 { if kbType == "faq" { b.WriteString("\n") for j, doc := range kb.RecentDocs { if j >= 10 { break } question := doc.FAQStandardQuestion if question == "" { question = doc.FileName } b.WriteString(fmt.Sprintf("\n", doc.ChunkID, doc.KnowledgeID, doc.CreatedAt)) b.WriteString(fmt.Sprintf("%s\n", question)) if len(doc.FAQAnswers) > 0 { for _, ans := range doc.FAQAnswers { b.WriteString(fmt.Sprintf("%s\n", ans)) } } b.WriteString("\n") } b.WriteString("\n") } else { b.WriteString("\n") for j, doc := range kb.RecentDocs { if j >= 2 { break } docName := doc.Title if docName == "" { docName = doc.FileName } fileSize := formatFileSize(doc.FileSize) b.WriteString(fmt.Sprintf("\n", doc.KnowledgeID, doc.Type, fileSize, doc.CreatedAt)) b.WriteString(fmt.Sprintf("%s\n", docName)) if doc.Description != "" { summary := formatDocSummary(doc.Description, 120) b.WriteString(fmt.Sprintf("%s\n", summary)) } b.WriteString("\n") } b.WriteString("\n") } } b.WriteString("\n") } b.WriteString("") return b.String() } // renderPromptPlaceholders renders placeholders in the prompt template. // // Supported placeholders: // - {{knowledge_bases}} - Historically expanded to the full bound-KB XML // block. Since that block now lives in the user message's // `` (see observe.buildRuntimeContextBlock), the // placeholder is expanded to a short pointer so legacy / custom // templates that still reference `{{knowledge_bases}}` degrade // gracefully instead of dumping the detail twice. // - `` is NOT a placeholder — when the user @mentions MCP/Skill, // observe.buildMustUseBlock injects it as a sibling block in the user // message; system prompts document it by convention (see agent_system_prompt.yaml). func renderPromptPlaceholders(template string, knowledgeBases []*KnowledgeBaseInfo) string { result := template if strings.Contains(result, "{{knowledge_bases}}") { var replacement string if len(knowledgeBases) == 0 { replacement = "(no knowledge bases bound to this session)" } else { replacement = "(see `` inside the user message's `` for the current bound KB list and their capabilities)" } result = strings.ReplaceAll(result, "{{knowledge_bases}}", replacement) } return result } // formatSkillsMetadata formats skills metadata for the system prompt (Level 1 - Progressive Disclosure) // This is a lightweight representation that only includes skill name and description func formatSkillsMetadata(skillsMetadata []*skills.SkillMetadata, shellExecEnabled bool) string { if len(skillsMetadata) == 0 { return "" } var builder strings.Builder builder.WriteString("\n### Available Skills (IMPORTANT - READ CAREFULLY)\n\n") builder.WriteString("**You MUST actively consider using these skills for EVERY user request.**\n\n") builder.WriteString("#### Skill Matching Protocol (MANDATORY)\n\n") builder.WriteString("Before responding to ANY user query, follow this checklist:\n\n") builder.WriteString("1. **SCAN**: Read each skill's description and trigger conditions below\n") builder.WriteString("2. **MATCH**: Check if the user's intent matches ANY skill's triggers (keywords, scenarios, or task types)\n") builder.WriteString("3. **LOAD**: If a match is found, call `read_skill(skill_name=\"...\")` BEFORE generating your response\n") builder.WriteString("4. **APPLY**: Follow the skill's instructions to provide a higher-quality, structured response\n\n") builder.WriteString("**⚠️ CRITICAL**: Skill usage is MANDATORY when applicable. Do NOT skip skills to save time or tokens.\n\n") builder.WriteString("#### Available Skills\n\n") for i, skill := range skillsMetadata { builder.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, skill.Name)) builder.WriteString(fmt.Sprintf(" %s\n\n", skill.Description)) } builder.WriteString("#### Workspace\n\n") builder.WriteString("Everything you run happens in this session's sandbox, ") builder.WriteString("whose working directory is `/workspace`:\n") builder.WriteString("- `/workspace/input`: the user's uploaded files, listed in ") builder.WriteString("``. Read-only — pass their absolute paths as arguments\n") builder.WriteString("- `/workspace/output`: what you generate for the user. ") builder.WriteString("Files here are collected for download\n") builder.WriteString("- `/workspace/.skill-packages/`: where on-demand Python extras go, ") builder.WriteString("since the skill tree is frozen after install\n") builder.WriteString("- The skills themselves live under `/opt/weknora/tenant/skills` and are ") builder.WriteString("reached through `read_skill` / `execute_skill_script`, never by `ls` or `cat`\n\n") builder.WriteString("#### Tool Reference\n\n") builder.WriteString("- `read_skill(skill_name)`: Load SKILL.md **and** list the skill's files. This is how you discover scripts — do not `list_sandbox_files` or `ls` `/opt/weknora/tenant/skills/...`\n") builder.WriteString("- `read_skill(skill_name, file_path)`: Read one file inside the skill (`file_path` is relative, e.g. `scripts/generate_ppt.py`)\n") builder.WriteString("- `execute_skill_script(skill_name, script_path, args, input)`: Run a skill script with that skill's interpreter and packages\n") builder.WriteString(" - `script_path`: relative inside the skill (`scripts/foo.py`), or an absolute `/workspace/...` file from `write_sandbox_file` / `edit_sandbox_file` (not `/workspace/input`)\n") builder.WriteString(" - `input`: Pass data directly via stdin (use this when you have data in memory, e.g. JSON string)\n") builder.WriteString(" - `args`: Command-line arguments; pass absolute `/workspace/input/...` paths from `` for user-uploaded files\n") builder.WriteString(" - Treat `/workspace/input` as read-only and write generated files only to `$WEKNORA_SKILL_OUTPUT_DIR`\n") builder.WriteString(" - Scripts run with `/workspace` as their working directory, so a relative ") builder.WriteString("path inside a script resolves there, not inside the skill; ") builder.WriteString("`$WEKNORA_SKILL_DIR` is how a script reaches its own files\n") builder.WriteString(sandboxArtifactReferenceGuidance()) builder.WriteString(" - Every script needing a skill's packages runs through this tool, including ") builder.WriteString("one you wrote yourself to `/workspace`. Do not rebuild the environment by hand ") builder.WriteString("with `PYTHONPATH=... python3` or a skill's `.venv/bin/python`: system `python3` ") builder.WriteString("cannot see what the skill baked in at install time, so that route only makes you ") builder.WriteString("reinstall what was already there. A failed import under `python3 -c` / `node -e` ") builder.WriteString("says nothing about whether the skill runs\n") builder.WriteString(" - The skill tree is frozen after install — do not run install_deps.py, chown, ") builder.WriteString("ensurepip, or pip into `/opt/weknora/tenant/skills`. Only once a run reports a ") builder.WriteString("package missing: `python3 -m pip install --target ") builder.WriteString("/workspace/.skill-packages/ `, then execute_skill_script, which ") builder.WriteString("puts that directory on the path for you\n") if shellExecEnabled { // Only the inventory line. How to drive the shell — working directory, // output limits, exit-code semantics, which scripts do not belong here — // is all in shell_exec's own description, which ships with the tools on // every request and so is never further away than this paragraph. builder.WriteString("- `shell_exec(command, work_dir, timeout_sec, max_output_bytes, max_stderr_bytes, env)`: ") builder.WriteString("Freely execute shell commands and explore the current session's isolated Cube ") builder.WriteString("sandbox. Read its tool description before the first call — it says which work ") builder.WriteString("belongs here and which belongs to `execute_skill_script`\n") } return builder.String() } // sandboxArtifactReferenceGuidance tells the model how to point at a file it // generated in the sandbox from its final answer. // // Without this, models improvise a Markdown image with the bare file name // (`![评分](市场画像评分.html)`), which the browser cannot resolve — the answer // renders a broken image icon. The `sandbox:` prefix makes the intent explicit // so the server can bind the name to the artifact index it hands the client. func sandboxArtifactReferenceGuidance() string { var builder strings.Builder builder.WriteString(" - To show a generated file inside your answer, reference it as ") builder.WriteString("`![description](sandbox:)` using the exact file name and no directory path\n") builder.WriteString(" - Images render inline; charts, tables, and documents ") builder.WriteString("render as a card the user clicks to preview\n") builder.WriteString(" - Never reference a sandbox path (`/workspace/output/...`) ") builder.WriteString("or a bare file name directly — neither resolves in the browser\n") builder.WriteString(" - Prefer output file names without spaces or parentheses; ") builder.WriteString("they keep the reference unambiguous\n") return builder.String() } // renderPromptPlaceholdersWithStatus renders placeholders including web search status // Supported placeholders: // - {{knowledge_bases}} // - {{web_search_status}} -> "Enabled" or "Disabled" // - {{current_time}} -> current time string // - {{language}} -> user language name (e.g. "Chinese (Simplified)", "English") // - {{skills}} -> formatted skills metadata (if any) func renderPromptPlaceholdersWithStatus( template string, knowledgeBases []*KnowledgeBaseInfo, webSearchEnabled bool, currentTime string, language string, ) string { // Knowledge bases need special formatting, so handle it first result := renderPromptPlaceholders(template, knowledgeBases) status := "Disabled" if webSearchEnabled { status = "Enabled" } result = types.RenderPromptPlaceholders(result, types.PlaceholderValues{ "web_search_status": status, "current_time": currentTime, "language": language, "skills": "", // Remove {{skills}} placeholder; skills are appended separately if present }) return result } // BuildSystemPromptOptions contains optional parameters for BuildSystemPrompt type BuildSystemPromptOptions struct { SkillsMetadata []*skills.SkillMetadata ShellExecEnabled bool Language string // User language name for {{language}} placeholder (e.g. "Chinese (Simplified)") Config *config.Config // Config for reading prompt templates; nil falls back to hardcoded defaults } // BuildSystemPrompt builds the progressive RAG system prompt // This is the main function to use - it uses a unified template with dynamic web search status func BuildSystemPrompt( knowledgeBases []*KnowledgeBaseInfo, webSearchEnabled bool, systemPromptTemplate ...string, ) string { return BuildSystemPromptWithOptions(knowledgeBases, webSearchEnabled, nil, systemPromptTemplate...) } // BuildSystemPromptWithOptions builds the system prompt with additional options like skills func BuildSystemPromptWithOptions( knowledgeBases []*KnowledgeBaseInfo, webSearchEnabled bool, options *BuildSystemPromptOptions, systemPromptTemplate ...string, ) string { var basePrompt string var template string // Determine template to use if len(systemPromptTemplate) > 0 && systemPromptTemplate[0] != "" { template = systemPromptTemplate[0] } else if len(knowledgeBases) == 0 { var cfg *config.Config if options != nil { cfg = options.Config } template = GetPureAgentSystemPrompt(cfg) } else { var cfg *config.Config if options != nil { cfg = options.Config } template = GetProgressiveRAGSystemPrompt(cfg) } currentTime := time.Now().Format("2006-01-02") language := "" if options != nil { language = options.Language } basePrompt = renderPromptPlaceholdersWithStatus(template, knowledgeBases, webSearchEnabled, currentTime, language) // Append skills metadata if available (Level 1 - Progressive Disclosure) if options != nil && len(options.SkillsMetadata) > 0 { basePrompt += formatSkillsMetadata(options.SkillsMetadata, options.ShellExecEnabled) } return basePrompt } // GetPureAgentSystemPrompt returns the Pure Agent system prompt from config templates. // The template must be defined in config/prompt_templates/agent_system_prompt.yaml // with mode "pure". Returns empty string if config is nil or template not found. func GetPureAgentSystemPrompt(cfg *config.Config) string { if cfg != nil && cfg.PromptTemplates != nil { if t := config.DefaultTemplateByMode(cfg.PromptTemplates.AgentSystemPrompt, "pure"); t != nil && t.Content != "" { return t.Content } } return "" } // GetProgressiveRAGSystemPrompt returns the Progressive RAG Agent system prompt from config templates. // The template must be defined in config/prompt_templates/agent_system_prompt.yaml // with mode "rag". Returns empty string if config is nil or template not found. func GetProgressiveRAGSystemPrompt(cfg *config.Config) string { if cfg != nil && cfg.PromptTemplates != nil { if t := config.DefaultTemplateByMode(cfg.PromptTemplates.AgentSystemPrompt, "rag"); t != nil && t.Content != "" { return t.Content } } return "" }