Files
xinmi/internal/application/service/agent_service.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1465 lines
53 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"strings"
"github.com/Tencent/WeKnora/internal/agent"
"github.com/Tencent/WeKnora/internal/agent/approval"
"github.com/Tencent/WeKnora/internal/agent/skills"
"github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/mcp"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"gorm.io/gorm"
)
const MAX_ITERATIONS = 100 // Max iterations for agent execution
// dedupStrings removes duplicate strings while preserving the first occurrence order.
func dedupStrings(in []string) []string {
if len(in) == 0 {
return in
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// withoutString returns the slice with every occurrence of drop removed,
// preserving order.
func withoutString(in []string, drop string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if s != drop {
out = append(out, s)
}
}
return out
}
// agentHasKnowledgeScope reports whether the agent has any KB retrieval scope for
// this turn. Tag-only @mentions populate SearchTargets (with TagIDs) but leave
// KnowledgeBases / KnowledgeIDs empty — those must still count as in-scope.
func agentHasKnowledgeScope(config *types.AgentConfig) bool {
if config == nil {
return false
}
return types.HasKnowledgeRetrievalScope(
config.SearchTargets,
config.KnowledgeBases,
config.KnowledgeIDs,
)
}
// knowledgeBaseScopesForPrompt returns the KB IDs to show in runtime_context
// metadata, together with the tenant each KB should be queried under.
//
// The tenant map always comes from SearchTargets, which buildSearchTargets has
// already resolved (and authorized) per KB: a directly shared KB carries its
// source tenant there. KnowledgeBases alone cannot tell own from shared KBs —
// both KBSelectionMode="all" and an @mention put shared KB IDs into it.
// KBs missing from the map fall back to the caller's tenant.
func knowledgeBaseScopesForPrompt(config *types.AgentConfig) ([]string, map[string]uint64) {
if config == nil {
return nil, nil
}
kbTenantMap := config.SearchTargets.GetKBTenantMap()
if len(config.KnowledgeBases) > 0 {
return config.KnowledgeBases, kbTenantMap
}
return config.SearchTargets.GetAllKnowledgeBaseIDs(), kbTenantMap
}
// agentService implements agent-related business logic
type agentService struct {
cfg *config.Config
modelService interfaces.ModelService
mcpServiceService interfaces.MCPServiceService
mcpManager *mcp.MCPManager
eventBus *event.EventBus
db *gorm.DB
webSearchService interfaces.WebSearchService
knowledgeBaseService interfaces.KnowledgeBaseService
knowledgeService interfaces.KnowledgeService
fileService interfaces.FileService
chunkService interfaces.ChunkService
duckdb *sql.DB
webSearchStateService interfaces.WebSearchStateService
wikiPageService interfaces.WikiPageService
tenantService interfaces.TenantService
messageService interfaces.MessageService
memoryService interfaces.MemoryService
storageResolver interfaces.StorageBackendResolver
toolApprovalGate approval.MCPApproval
sandboxMgr sandbox.Manager
sandboxResolver sandbox.TenantSandboxResolver
sandboxPinner *SessionSandboxPinner
sandboxPolicy WorkspaceSandboxPolicy
}
// NewAgentService creates a new agent service
func NewAgentService(
cfg *config.Config,
modelService interfaces.ModelService,
knowledgeBaseService interfaces.KnowledgeBaseService,
knowledgeService interfaces.KnowledgeService,
fileService interfaces.FileService,
chunkService interfaces.ChunkService,
mcpServiceService interfaces.MCPServiceService,
mcpManager *mcp.MCPManager,
eventBus *event.EventBus,
db *gorm.DB,
webSearchService interfaces.WebSearchService,
duckdb *sql.DB,
webSearchStateService interfaces.WebSearchStateService,
wikiPageService interfaces.WikiPageService,
tenantService interfaces.TenantService,
messageService interfaces.MessageService,
memoryService interfaces.MemoryService,
storageResolver interfaces.StorageBackendResolver,
toolApprovalGate approval.MCPApproval,
sandboxMgr sandbox.Manager,
sandboxResolver sandbox.TenantSandboxResolver,
sandboxPinner *SessionSandboxPinner,
sandboxPolicy WorkspaceSandboxPolicy,
) interfaces.AgentService {
return &agentService{
cfg: cfg,
modelService: modelService,
knowledgeBaseService: knowledgeBaseService,
knowledgeService: knowledgeService,
fileService: fileService,
chunkService: chunkService,
mcpServiceService: mcpServiceService,
mcpManager: mcpManager,
eventBus: eventBus,
db: db,
webSearchService: webSearchService,
duckdb: duckdb,
webSearchStateService: webSearchStateService,
wikiPageService: wikiPageService,
tenantService: tenantService,
messageService: messageService,
memoryService: memoryService,
storageResolver: storageResolver,
toolApprovalGate: toolApprovalGate,
sandboxMgr: sandboxMgr,
sandboxResolver: sandboxResolver,
sandboxPinner: sandboxPinner,
sandboxPolicy: sandboxPolicy,
}
}
// CreateAgentEngine creates an agent engine with the given configuration and EventBus.
// History is loaded once per turn by the caller (see service.LoadAgentHistory)
// and handed to AgentEngine.Execute as llmContext; the engine is stateless across turns.
func (s *agentService) CreateAgentEngine(
ctx context.Context,
config *types.AgentConfig,
chatModel chat.Chat,
rerankModel rerank.Reranker,
eventBus *event.EventBus,
sessionID, assistantMessageID string,
) (interfaces.AgentEngine, error) {
logger.Infof(ctx, "Creating agent engine with custom EventBus")
// 1. Validate config
if err := s.ValidateConfig(config); err != nil {
return nil, fmt.Errorf("invalid agent config: %w", err)
}
if chatModel == nil {
return nil, fmt.Errorf("chat model is nil after initialization")
}
// 2. Build tool registry
toolRegistry := tools.NewToolRegistry()
if config.MaxToolOutputChars > 0 {
toolRegistry.SetMaxToolOutputSize(config.MaxToolOutputChars)
}
if err := s.registerTools(ctx, toolRegistry, config, rerankModel, chatModel, sessionID); err != nil {
return nil, fmt.Errorf("failed to register tools: %w", err)
}
s.registerMCPTools(ctx, toolRegistry, config, eventBus, sessionID, assistantMessageID)
// File tools are a pure sandbox capability independent of the
// skill switch: register them whenever the workspace sandbox supports a
// session filesystem, even when skills are disabled.
s.registerSandboxFileTools(ctx, toolRegistry, sessionID, config)
// shell_exec follows SkillsEnabled (or install mode), not the presence
// of a ready skill. Register it independently of the skills manager so a
// fresh or still-installing sandbox still has a shell.
s.registerSandboxShellIfAllowed(ctx, toolRegistry, sessionID, config)
// 3. Resolve knowledge base and selected document metadata
kbInfos, selectedDocs := s.resolveKBAndDocInfos(ctx, config)
// 4. Resolve system prompt template
systemPromptTemplate := ""
if config.UseCustomSystemPrompt || config.SystemPrompt != "" {
systemPromptTemplate = config.ResolveSystemPrompt(config.WebSearchEnabled)
}
// 5. Create engine
engine := agent.NewAgentEngine(
config, chatModel, toolRegistry, eventBus,
kbInfos, selectedDocs, sessionID,
systemPromptTemplate,
)
engine.SetAppConfig(s.cfg)
pinnedMCP := s.resolvePinnedMCPServiceInfos(ctx, config)
s.attachPinnedMCPToolNames(toolRegistry, pinnedMCP)
engine.SetPinnedMentions(
pinnedMCP,
s.resolvePinnedSkillInfos(config),
)
// Set VLM image describer for MCP tool result image analysis.
// When an MCP tool returns images, the engine uses VLM to generate text descriptions
// and appends them to the tool result content (since Chat Completions API does not
// reliably support images in tool role messages across providers).
if config.VLMModelID != "" {
if vlmModel, err := s.modelService.GetVLMModel(ctx, config.VLMModelID); err == nil {
engine.SetImageDescriber(func(ctx context.Context, imgBytes []byte, prompt string) (string, error) {
return vlmModel.Predict(ctx, [][]byte{imgBytes}, prompt)
})
logger.Infof(ctx, "VLM image describer set for MCP tool result analysis (model: %s)", config.VLMModelID)
} else {
logger.Warnf(ctx, "Failed to load VLM model %s for MCP image fallback: %v", config.VLMModelID, err)
}
}
// TenantSkills is the sandbox image. SkillDirs is the host
// skills/preloaded tree and is no longer filled on the QA path; it
// remains so tests (and any caller that still points at a host
// directory) can construct a manager.
//
// The shell is registered above by registerSandboxShellIfAllowed and
// follows SkillsEnabled rather than requiring a ready skill to already
// exist. offerSkills only gates the skills manager that feeds the model
// the installed-skill list (and the read_skill / execute_skill_script
// tools that need it). A sandbox whose skills are still installing —
// or that simply has none yet — therefore gets a shell without an
// empty skills manager or skill tools that cannot succeed.
offerSkills := config.SkillsEnabled &&
(len(config.SkillDirs) > 0 || len(config.TenantSkills) > 0)
if offerSkills {
skillsManager, err := s.initializeSkillsManager(ctx, sessionID, config, toolRegistry)
if err != nil {
logger.Warnf(ctx, "Failed to initialize skills manager: %v", err)
} else if skillsManager != nil {
engine.SetSkillsManager(skillsManager)
logger.Infof(ctx, "Skills manager initialized with %d skills",
len(skillsManager.GetAllMetadata()))
}
}
return engine, nil
}
// registerMCPTools registers MCP tools from enabled services for this tenant.
func (s *agentService) registerMCPTools(
ctx context.Context,
toolRegistry *tools.ToolRegistry,
config *types.AgentConfig,
eventBus *event.EventBus,
sessionID, assistantMessageID string,
) {
tenantID := uint64(0)
if tid, ok := types.TenantIDFromContext(ctx); ok {
tenantID = tid
}
if tenantID == 0 || s.mcpServiceService == nil || s.mcpManager == nil {
return
}
mcpMode := config.MCPSelectionMode
if mcpMode == "" {
mcpMode = "all"
}
if mcpMode == "none" {
logger.Infof(ctx, "MCP services disabled by agent config (mode: none)")
return
}
var mcpServices []*types.MCPService
var err error
if mcpMode == "selected" {
if len(config.MCPServices) == 0 {
logger.Infof(ctx, "MCP services disabled by agent config (mode: selected, no services)")
return
}
mcpServices, err = s.mcpServiceService.ListMCPServicesByIDs(ctx, tenantID, config.MCPServices)
if err != nil {
logger.Warnf(ctx, "Failed to list selected MCP services: %v", err)
return
}
logger.Infof(ctx, "Using %d selected MCP services from agent config", len(mcpServices))
} else {
mcpServices, err = s.mcpServiceService.ListMCPServices(ctx, tenantID)
if err != nil {
logger.Warnf(ctx, "Failed to list MCP services: %v", err)
return
}
}
enabledServices := make([]*types.MCPService, 0)
for _, svc := range mcpServices {
if svc != nil && svc.Enabled {
enabledServices = append(enabledServices, svc)
}
}
if len(enabledServices) > 0 {
var regCtx *tools.MCPOAuthSession
if eventBus != nil && sessionID != "" && assistantMessageID != "" {
regCtx = &tools.MCPOAuthSession{
EventBus: eventBus,
SessionID: sessionID,
AssistantMessageID: assistantMessageID,
ApprovalCtx: ctx,
AuthWaitTimeoutSeconds: config.MCPAuthWaitTimeout,
}
}
registered, err := tools.RegisterMCPTools(
ctx, toolRegistry, enabledServices, s.mcpManager, s.toolApprovalGate, regCtx,
)
if err != nil {
logger.Warnf(ctx, "Failed to register MCP tools: %v", err)
} else if registered == 0 {
logger.Warnf(ctx, "No MCP tools registered from %d enabled service(s)", len(enabledServices))
} else {
logger.Infof(ctx, "Registered %d MCP tool(s) from %d enabled service(s)", registered, len(enabledServices))
}
}
}
// resolveKBAndDocInfos loads knowledge base metadata and selected document info for prompt.
func (s *agentService) resolveKBAndDocInfos(
ctx context.Context,
config *types.AgentConfig,
) ([]*agent.KnowledgeBaseInfo, []*agent.SelectedDocumentInfo) {
kbIDs, kbTenantMap := knowledgeBaseScopesForPrompt(config)
kbInfos, err := s.getKnowledgeBaseInfos(ctx, kbIDs, kbTenantMap)
if err != nil {
logger.Warnf(ctx, "Failed to get knowledge base details, using IDs only: %v", err)
kbInfos = make([]*agent.KnowledgeBaseInfo, 0, len(kbIDs))
for _, kbID := range kbIDs {
kbInfos = append(kbInfos, &agent.KnowledgeBaseInfo{
ID: kbID,
Name: kbID,
Description: "",
DocCount: 0,
})
}
}
selectedDocs, err := s.getSelectedDocumentInfos(ctx, config.KnowledgeIDs)
if err != nil {
logger.Warnf(ctx, "Failed to get selected document details: %v", err)
selectedDocs = []*agent.SelectedDocumentInfo{}
}
return kbInfos, selectedDocs
}
// registerSandboxFileTools registers list_sandbox_files / read_sandbox_file /
// write_sandbox_file / edit_sandbox_file.
//
// These expose per-session filesystem access and are a pure sandbox
// capability, not a skill capability. They therefore do NOT follow
// SkillsEnabled: an agent with skills disabled must still be able to read
// staged attachments out of /workspace/input, and to write generated files
// under /workspace. The tools themselves allow those directories (input is
// read-only). Registration only requires a non-disabled sandbox whose
// manager advertises a SessionFileStore.
//
// The skill installer is the exception: write_sandbox_file only accepts
// /workspace, the installer must write .weknora/requirements.json under
// /opt/weknora/tenant/skills, and its prompt forbids touching /workspace
// (that tree is wiped before the snapshot). Offering the file tools made
// the first write of every install fail, then fall back to a shell heredoc.
// Install mode gets write_skill_file / edit_skill_file instead, which write
// the image tree and are scoped to the one skill being installed —
// see registerSkillFileTools.
func (s *agentService) registerSandboxFileTools(
ctx context.Context,
toolRegistry *tools.ToolRegistry,
sessionID string,
config *types.AgentConfig,
) {
if config != nil && config.SkillInstallMode() {
logger.Infof(ctx, "Skipping session file tools in skill install mode")
s.registerSkillFileTools(ctx, toolRegistry, sessionID, config)
return
}
sandboxMgr, err := s.resolveWorkspaceSandbox(ctx, sessionID, config)
if err != nil {
logger.Warnf(ctx, "Failed to resolve sandbox for file tools: %v", err)
return
}
if sandboxMgr == nil {
return
}
if store := sessionSandboxFileStore(sandboxMgr); store != nil {
toolRegistry.RegisterTool(tools.NewListSandboxFilesTool(store))
toolRegistry.RegisterTool(tools.NewReadSandboxFileTool(store))
// The per-round completion budget, not a fixed byte count, is what
// bounds one write; the tool advertises a limit derived from it.
toolRegistry.RegisterTool(tools.NewWriteSandboxFileTool(
store,
types.AgentRoundMaxCompletionTokensFor(config.MaxCompletionTokens, config.SandboxConfigID),
))
toolRegistry.RegisterTool(tools.NewEditSandboxFileTool(store))
logger.Infof(ctx, "Registered list_sandbox_files, read_sandbox_file, write_sandbox_file, and edit_sandbox_file tools")
} else {
logger.Infof(ctx, "Sandbox backend does not advertise session filesystem capability; "+
"list_sandbox_files/read_sandbox_file/write_sandbox_file/edit_sandbox_file not registered")
}
}
// registerSkillFileTools registers write_skill_file / edit_skill_file for the
// built-in skill installer, and for nothing else.
//
// The installer's shell already runs as root inside the skills image root, so
// these tools grant no reach it does not have. They exist because its only
// writer was `cat` with a heredoc: every file went through the shell's
// command-length cap and two levels of quoting, which truncated or mangled
// anything larger than a few lines.
//
// Two conditions gate registration, and both are checked here rather than
// trusted from the caller. SkillInstallMode is settable only through
// EnableSkillInstallMode, which refuses every agent but the built-in
// installer. The skill directory is re-validated against the image path rules,
// so a run that somehow carried a bad scope gets no writer at all instead of
// one pointed somewhere unintended.
func (s *agentService) registerSkillFileTools(
ctx context.Context,
toolRegistry *tools.ToolRegistry,
sessionID string,
config *types.AgentConfig,
) {
if config == nil || !config.SkillInstallMode() {
return
}
skillDir, ok := sandbox.ValidatedImageSkillDir(config.SkillInstallDir())
if !ok {
logger.Warnf(ctx, "Install mode carries no valid skill directory (%q); "+
"write_skill_file/edit_skill_file not registered", config.SkillInstallDir())
return
}
sandboxMgr, err := s.resolveWorkspaceSandbox(ctx, sessionID, config)
if err != nil {
logger.Warnf(ctx, "Failed to resolve sandbox for skill file tools: %v", err)
return
}
if sandboxMgr == nil {
return
}
store, ok := sandboxMgr.(tools.SkillFileStore)
if !ok {
logger.Infof(ctx, "Sandbox backend cannot write the skills image root; "+
"write_skill_file/edit_skill_file not registered")
return
}
toolRegistry.RegisterTool(tools.NewWriteSkillFileTool(store, skillDir))
toolRegistry.RegisterTool(tools.NewEditSkillFileTool(store, skillDir))
logger.Infof(ctx, "Registered write_skill_file and edit_skill_file scoped to %s", skillDir)
}
// registerSandboxShellIfAllowed registers shell_exec when this run is
// entitled to a sandbox shell: SkillsEnabled, or the built-in skill
// installer. It does not require a ready skill to already exist, so a
// fresh sandbox still has a way to inspect its environment.
func (s *agentService) registerSandboxShellIfAllowed(
ctx context.Context,
toolRegistry *tools.ToolRegistry,
sessionID string,
config *types.AgentConfig,
) {
if config == nil || (!config.SkillsEnabled && !config.SkillInstallMode()) {
return
}
sandboxMgr, err := s.resolveWorkspaceSandbox(ctx, sessionID, config)
if err != nil {
logger.Warnf(ctx, "Failed to resolve sandbox for shell_exec: %v", err)
return
}
if sandboxMgr == nil {
return
}
s.registerSandboxShellTool(ctx, toolRegistry, sandboxMgr, config)
}
// resolveWorkspaceSandbox returns the session's remote sandbox manager, or
// nil when the workspace is disabled / unresolved. Callers that only need
// a capability (file store, shell) use this so they do not have to stand
// up a skills manager.
func (s *agentService) resolveWorkspaceSandbox(
ctx context.Context,
sessionID string,
config *types.AgentConfig,
) (sandbox.Manager, error) {
if s == nil {
return nil, nil
}
configID := ""
if config != nil {
configID = config.SandboxConfigID
}
tenantID, _ := types.TenantIDFromContext(ctx)
sandboxMgr, _, err := resolveSandboxForExecution(
ctx, s.sandboxResolver, s.sandboxMgr, s.sandboxPinner,
tenantID, sessionID, configID, s.sandboxPolicy,
)
if err != nil {
return nil, err
}
if sandboxMgr == nil || sandboxMgr.GetType() == sandbox.SandboxTypeDisabled {
return nil, nil
}
return sandboxMgr, nil
}
// initializeSkillsManager creates and initializes the skills manager.
//
// The sandbox manager is resolved per workspace: backends differ in
// capability (remote MicroVMs expose a session file store, local does not), so tool
// registration below must inspect this workspace's real manager rather than a
// process-wide singleton. Workspaces without a selected configuration resolve
// to the disabled manager.
func (s *agentService) initializeSkillsManager(
ctx context.Context,
sessionID string,
config *types.AgentConfig,
toolRegistry *tools.ToolRegistry,
) (*skills.Manager, error) {
tenantID, _ := types.TenantIDFromContext(ctx)
sandboxMgr, configID, err := resolveSandboxForExecution(
ctx, s.sandboxResolver, s.sandboxMgr, s.sandboxPinner,
tenantID, sessionID, config.SandboxConfigID, s.sandboxPolicy,
)
if err != nil {
return nil, fmt.Errorf("resolve sandbox config for session %s: %w", sessionID, err)
}
if sandboxMgr == nil {
sandboxMgr = sandbox.NewDisabledManager()
}
logger.Infof(ctx, "Workspace sandbox in use: config=%s type=%s", configID, sandboxMgr.GetType())
// Create skills manager
skillsConfig := &skills.ManagerConfig{
SkillDirs: config.SkillDirs,
AllowedSkills: config.AllowedSkills,
Enabled: config.SkillsEnabled,
}
skillsManager := skills.NewManager(skillsConfig, sandboxMgr)
if source := s.tenantSkillSource(ctx, config); source != nil {
skillsManager.WithTenantSource(source)
}
if resolver := s.userEnvResolver(ctx, config); resolver != nil {
skillsManager.WithEnvResolver(resolver)
}
// Initialize (discover skills)
if err := skillsManager.Initialize(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize skills: %w", err)
}
// Skill tools follow SkillsEnabled, not merely sandbox availability: the
// skill installer agent must have shell_exec WITHOUT execute_skill_script,
// since it is the thing installing skills. shell_exec itself is registered
// by registerSandboxShellIfAllowed, not here.
if config.SkillsEnabled {
toolRegistry.RegisterTool(tools.NewReadSkillTool(skillsManager))
logger.Infof(ctx, "Registered read_skill tool")
}
if sandboxMgr.GetType() != sandbox.SandboxTypeDisabled && config.SkillsEnabled {
toolRegistry.RegisterTool(tools.NewExecuteSkillScriptTool(skillsManager))
logger.Infof(ctx, "Registered execute_skill_script tool")
}
return skillsManager, nil
}
// tenantSkillSource builds the source for the skills installed into this run's
// sandbox image, or nil when the run has none. The set was already narrowed to
// what this run can invoke when the config was built; nothing here re-decides
// it.
func (s *agentService) tenantSkillSource(
ctx context.Context, config *types.AgentConfig,
) skills.SkillSource {
rows := config.TenantSkills
if len(rows) == 0 {
return nil
}
// The rows were fetched under the caller's workspace, so this is the
// caller's ID; it is read off the row rather than the context so the
// bundle download cannot resolve a different workspace's storage than the
// one the rows came from.
ownerTenantID := rows[0].TenantID
// The closure captures the engine-creation context because loadBundle
// takes no context of its own. That is the turn's context today -
// CreateAgentEngine and engine.Execute are called back to back with the
// same ctx - so it stays live for as long as read_skill can be called. If
// a caller ever creates the engine under a shorter-lived context, bundle
// downloads start failing for installed skills only, and loadBundle needs
// a ctx parameter.
return skills.NewTenantSkillSource(rows, func(row *types.TenantSkillEntity) ([]byte, error) {
return s.loadInstalledSkillBundle(ctx, ownerTenantID, row)
})
}
// loadInstalledSkillBundle reads the archive one installed skill was built
// from. An object named by the row itself is that archive. A row that only
// names a catalog is answered from the definition's zip, but the definition is
// mutable — registering the same skill again stores a new object and updates
// the catalog ref, while this sandbox goes on running the image built from the
// previous bytes. read_skill is documented as serving what was installed, so a
// definition that has moved on is reported rather than substituted.
func (s *agentService) loadInstalledSkillBundle(
ctx context.Context, tenantID uint64, row *types.TenantSkillEntity,
) ([]byte, error) {
if row == nil {
return nil, errors.New("skill is required")
}
if ref := strings.TrimSpace(row.BundleRef); ref != "" {
return s.readSkillBundle(ctx, tenantID, ref)
}
cid := strings.TrimSpace(row.CatalogID)
if cid == "" || s.db == nil {
return nil, fmt.Errorf("skill %s has no stored bundle; its files cannot be read", row.Name)
}
cat, err := repository.NewTenantSkillRepository(s.db).GetCatalog(ctx, tenantID, cid)
if err != nil {
return nil, err
}
if cat == nil || strings.TrimSpace(cat.BundleRef) == "" {
return nil, fmt.Errorf("skill %s has no stored bundle; its files cannot be read", row.Name)
}
archive, err := s.readSkillBundle(ctx, tenantID, strings.TrimSpace(cat.BundleRef))
if err != nil {
return nil, err
}
// The bytes are checked, not the catalog's recorded digest: a reference and
// a digest that disagree is exactly the case a stamped-but-unstored archive
// leaves behind.
if want := strings.TrimSpace(row.BundleSHA256); want != "" && !archiveMatchesSHA(archive, want) {
return nil, fmt.Errorf(
"skill %s was registered again from a different archive; "+
"reinstall it on this sandbox before reading its files", row.Name)
}
return archive, nil
}
// userEnvResolver builds the per-caller environment resolver for this run, or
// nil when there is no sandbox config to resolve against.
//
// It is built even when the run has no installed skills: the caller's
// config-wide variables are injected into every execution, skills or not.
//
// The repository is constructed from s.db here rather than injected:
// NewAgentService already takes 23 parameters, and this is the only place in
// the agent path that touches the user env table.
func (s *agentService) userEnvResolver(
ctx context.Context, config *types.AgentConfig,
) skills.SkillEnvResolver {
configID := config.SandboxConfigID
if configID == "" {
return nil
}
rows := config.TenantSkills
if s.db == nil {
// Unreachable in production, but a wiring regression here would drop
// every admin and user value silently and present to a member as "my
// key stopped working" with nothing in the logs.
logger.Warnf(ctx,
"[skill] no database handle: sandbox config %s will run without "+
"any configured environment variable", configID)
return nil
}
// The workspace is read off a row for the same reason tenantSkillSource
// does it: the rows were fetched under the caller's workspace, and a value
// must never be looked up in a different one. With no rows there is nothing
// to disagree with the context.
tenantID, _ := types.TenantIDFromContext(ctx)
if len(rows) > 0 {
tenantID = rows[0].TenantID
}
if tenantID == 0 {
return nil
}
return NewUserEnvResolver(rows, repository.NewTenantSkillRepository(s.db), tenantID, configID)
}
// skillEnvCapture writes declared skill credentials a successful shell_exec
// already used, for the current principal. Nil when there is nothing to write
// against. Errors stay inside the callback so a failed persist cannot change
// the tool result the model already received.
func (s *agentService) skillEnvCapture(config *types.AgentConfig) tools.SkillEnvCapture {
if s.db == nil || config == nil || config.SandboxConfigID == "" {
return nil
}
configID := config.SandboxConfigID
return func(ctx context.Context, skillName string, pairs map[string]string) {
svc := NewUserEnvService(
repository.NewTenantSkillRepository(s.db),
repository.NewTenantSandboxConfigRepository(s.db),
)
if err := svc.CaptureSkillEnv(ctx, configID, skillName, pairs); err != nil {
logger.Warnf(ctx, "[skill] capture env for %s failed: %v", skillName, err)
}
}
}
// readSkillBundle downloads one uploaded skill archive. It backs read_skill for
// installed skills: the image holds the executable copy, but reading a file out
// of it would need a live sandbox, and the archive is byte-identical to what
// was installed.
func (s *agentService) readSkillBundle(
ctx context.Context, tenantID uint64, ref string,
) ([]byte, error) {
if s.storageResolver == nil {
return nil, errors.New("storage resolver is not configured")
}
fs, _, err := s.storageResolver.ResolveFileService(
ctx, &types.Tenant{ID: tenantID}, "", "", "",
)
if err != nil {
return nil, err
}
if fs == nil {
return nil, errors.New("file service is not configured")
}
reader, err := fs.GetFile(ctx, ref)
if err != nil {
return nil, err
}
defer func() { _ = reader.Close() }()
// Bounded because the object is read into memory: the upload limit is the
// only thing that says how large a legitimate archive can be.
archive, err := io.ReadAll(io.LimitReader(reader, maxSkillBundleTotalBytes+1))
if err != nil {
return nil, err
}
if len(archive) > maxSkillBundleTotalBytes {
return nil, fmt.Errorf("skill bundle %s is larger than the upload limit", ref)
}
return archive, nil
}
// registerSandboxShellTool registers the one shell_exec variant this run is
// entitled to.
//
// shell_exec is a remote-only capability: the capability accessors yield nil
// for backends that cannot run session-scoped shell, so the same check works
// for every provider.
//
// The skill installer agent gets the install-mode variant, which runs as root
// and may work inside the skills image root — it exists to install
// dependencies into the image, which the ordinary contract forbids on both
// counts. Every other agent keeps the non-root, /workspace-only executor.
// AgentConfig.SkillInstallMode is settable only through
// EnableSkillInstallMode, which refuses every agent but the built-in
// installer, so no tenant agent can reach this branch.
func (s *agentService) registerSandboxShellTool(
ctx context.Context,
toolRegistry *tools.ToolRegistry,
sandboxMgr sandbox.Manager,
config *types.AgentConfig,
) {
if config.SkillInstallMode() {
if executor := sessionSandboxInstallShellExecutor(sandboxMgr); executor != nil {
skillDir := config.SkillInstallDir()
toolRegistry.RegisterTool(tools.NewInstallShellExecTool(executor, skillDir))
logger.Infof(ctx, "Registered install-mode shell_exec tool (work_dir defaults to %s)",
skillDir)
} else {
logger.Warnf(ctx, "Sandbox backend does not advertise install-mode shell; skill install cannot run")
}
return
}
if executor := sessionSandboxShellExecutor(sandboxMgr); executor != nil {
resolver := s.userEnvResolver(ctx, config)
toolRegistry.RegisterTool(
tools.NewShellExecTool(executor, resolver).WithEnvCapture(s.skillEnvCapture(config)),
)
logger.Infof(ctx, "Registered shell_exec tool")
} else {
logger.Infof(ctx, "Sandbox backend does not advertise remote shell capability; shell_exec not registered")
}
}
// registerTools registers tools based on the agent configuration
func (s *agentService) registerTools(
ctx context.Context,
registry *tools.ToolRegistry,
config *types.AgentConfig,
rerankModel rerank.Reranker,
chatModel chat.Chat,
sessionID string,
) error {
// Source of truth policy:
// - `config.AllowedTools` is the explicit, user-editable whitelist —
// populated by the agent-type preset on create and freely editable
// afterwards.
// - We never silently *inject* tools the user didn't pick.
// - We still *filter out* tools whose capability prerequisites are missing
// (no KB in scope, no Wiki-capable KB, etc.) so the LLM can't call tools
// that would error at runtime.
// - Legacy agents without AllowedTools fall back to DefaultAllowedTools().
var allowedTools []string
if len(config.AllowedTools) > 0 {
allowedTools = make([]string, len(config.AllowedTools))
copy(allowedTools, config.AllowedTools)
logger.Infof(ctx, "Using custom allowed tools from config: %v", allowedTools)
} else {
allowedTools = tools.DefaultAllowedTools()
logger.Infof(ctx, "Using default allowed tools: %v", allowedTools)
}
if config.SharedAgentReadOnly {
allowedTools = filterSharedAgentWriteTools(allowedTools)
}
// ---- Capability detection from SearchTargets ----
var hasVectorKB bool
var wikiKBIDs []string
wikiRoutes := tools.NewWikiRouteResolver()
for _, target := range config.SearchTargets {
if target == nil || target.KnowledgeBaseID == "" {
continue
}
kb, err := s.knowledgeBaseService.GetKnowledgeBaseByIDOnly(ctx, target.KnowledgeBaseID)
if err != nil {
continue
}
if kb.IsVectorEnabled() || kb.IsKeywordEnabled() {
hasVectorKB = true
}
if kb.IsWikiEnabled() {
wikiKBIDs = append(wikiKBIDs, kb.ID)
}
}
wikiKBIDs = dedupStrings(wikiKBIDs)
wikiScopes := tools.NewWikiScopesFromSearchTargets(config.SearchTargets, wikiKBIDs)
// Narrow to the KBs that survived scope resolution. Build a fresh slice
// rather than truncating in place, so the argument passed above can never
// be overwritten through a shared backing array.
scopedWikiKBIDs := make([]string, 0, len(wikiScopes))
for _, scope := range wikiScopes {
scopedWikiKBIDs = append(scopedWikiKBIDs, scope.KnowledgeBaseID)
}
wikiKBIDs = scopedWikiKBIDs
hasWikiKB := len(wikiKBIDs) > 0
// Filter out knowledge base tools if no knowledge scope is configured for this turn.
hasKnowledge := agentHasKnowledgeScope(config)
if !hasKnowledge {
filteredTools := make([]string, 0)
kbTools := map[string]bool{
tools.ToolKnowledgeSearch: true,
tools.ToolGrepChunks: true,
tools.ToolListKnowledgeChunks: true,
tools.ToolQueryKnowledgeGraph: true,
tools.ToolGetDocumentInfo: true,
tools.ToolDatabaseQuery: true,
tools.ToolDataAnalysis: true,
tools.ToolDataSchema: true,
// Wiki tools also require at least one KB in scope.
tools.ToolWikiReadPage: true,
tools.ToolWikiSearch: true,
tools.ToolWikiReadSourceDoc: true,
tools.ToolWikiFlagIssue: true,
tools.ToolWikiWritePage: true,
tools.ToolWikiReplaceText: true,
tools.ToolWikiRenamePage: true,
tools.ToolWikiDeletePage: true,
tools.ToolWikiReadIssue: true,
tools.ToolWikiUpdateIssue: true,
}
// If no knowledge and no web search, also disable todo_write (not useful for simple chat)
if !config.WebSearchEnabled {
kbTools[tools.ToolTodoWrite] = true
}
for _, toolName := range allowedTools {
if !kbTools[toolName] {
filteredTools = append(filteredTools, toolName)
}
}
allowedTools = filteredTools
logger.Infof(ctx, "Pure Agent Mode: Knowledge base tools filtered out, remaining: %v", allowedTools)
}
// If web search is enabled, add web_search to allowedTools
if config.WebSearchEnabled {
allowedTools = append(allowedTools, tools.ToolWebSearch)
allowedTools = append(allowedTools, tools.ToolWebFetch)
}
// Long-term memory search follows the memory switches, not the tool list.
// Being able to read memory is already a decision the workspace, the user
// and the agent each get a say in; asking for it a fourth time as a tool
// checkbox would only produce configurations where memory is on but the
// agent cannot reach past what each turn injects for it.
//
// The tool is dropped before it is re-added so that an allowlist which
// still names it — a preset, an API caller, or a config saved while memory
// was on — cannot outlive the switch being turned off.
allowedTools = withoutString(allowedTools, tools.ToolSearchMemory)
if s.memoryService != nil &&
s.memoryService.MemoryAvailable(types.ApplyAgentMemoryPreference(ctx, config.MemoryEnabled)) {
allowedTools = append(allowedTools, tools.ToolSearchMemory)
} else {
logger.Infof(ctx, "search_memory not registered: long-term memory is off for this request")
}
// Tool capability sets — used by the hard safety nets below to drop tools
// whose runtime prerequisite (a matching KB surface) is missing.
//
// NOTE: ragToolSet must stay in sync with frontend `knowledgeBaseTools`
// in AgentEditorModal.vue. These are *all* tools that retrieve/inspect
// content from RAG-style knowledge bases.
ragToolSet := map[string]bool{
tools.ToolKnowledgeSearch: true,
tools.ToolGrepChunks: true,
tools.ToolListKnowledgeChunks: true,
tools.ToolQueryKnowledgeGraph: true,
tools.ToolGetDocumentInfo: true,
tools.ToolDatabaseQuery: true,
}
allWikiToolSet := map[string]bool{
tools.ToolWikiReadPage: true,
tools.ToolWikiSearch: true,
tools.ToolWikiReadSourceDoc: true,
tools.ToolWikiFlagIssue: true,
tools.ToolWikiWritePage: true,
tools.ToolWikiReplaceText: true,
tools.ToolWikiRenamePage: true,
tools.ToolWikiDeletePage: true,
tools.ToolWikiReadIssue: true,
tools.ToolWikiUpdateIssue: true,
}
// Hard safety nets: drop tools whose runtime prerequisite is missing.
// This guards against stale configs where e.g. the user ticked wiki tools
// earlier but later swapped in a non-wiki KB (or vice versa for RAG).
if !hasWikiKB {
filtered := make([]string, 0, len(allowedTools))
dropped := make([]string, 0)
for _, t := range allowedTools {
if allWikiToolSet[t] {
dropped = append(dropped, t)
continue
}
filtered = append(filtered, t)
}
allowedTools = filtered
if len(dropped) > 0 {
logger.Warnf(ctx, "Dropped wiki tools %v because no wiki-capable KB is in scope", dropped)
}
}
if !hasVectorKB {
filtered := make([]string, 0, len(allowedTools))
dropped := make([]string, 0)
for _, t := range allowedTools {
if ragToolSet[t] {
dropped = append(dropped, t)
continue
}
filtered = append(filtered, t)
}
allowedTools = filtered
if len(dropped) > 0 {
logger.Warnf(ctx, "Dropped RAG tools %v because no RAG-capable KB is in scope", dropped)
}
}
// Deduplicate while preserving original order.
allowedTools = dedupStrings(allowedTools)
// logger.Infof(ctx, "Registering tools: %v, webSearchEnabled: %v", allowedTools, config.WebSearchEnabled)
// Register each allowed tool
for _, toolName := range allowedTools {
var toolToRegister types.Tool
switch toolName {
case tools.ToolThinking:
toolToRegister = tools.NewSequentialThinkingTool()
case tools.ToolTodoWrite:
toolToRegister = tools.NewTodoWriteTool()
case tools.ToolKnowledgeSearch:
toolToRegister = tools.NewKnowledgeSearchTool(
s.knowledgeBaseService,
s.knowledgeService,
s.chunkService,
config.SearchTargets,
rerankModel,
s.cfg,
)
case tools.ToolGrepChunks:
toolToRegister = tools.NewGrepChunksTool(s.db, config.SearchTargets)
logger.Infof(ctx, "Registered grep_chunks tool with searchTargets: %d targets", len(config.SearchTargets))
case tools.ToolListKnowledgeChunks:
toolToRegister = tools.NewListKnowledgeChunksTool(s.knowledgeService, s.chunkService, config.SearchTargets)
case tools.ToolQueryKnowledgeGraph:
toolToRegister = tools.NewQueryKnowledgeGraphTool(s.knowledgeBaseService, config.SearchTargets).
WithKnowledgeScope(s.knowledgeService)
case tools.ToolGetDocumentInfo:
toolToRegister = tools.NewGetDocumentInfoTool(s.knowledgeService, s.chunkService, config.SearchTargets)
case tools.ToolSearchConversations:
// The owner is captured from the caller's identity here, not read
// from the model's arguments, so no prompt can redirect the search
// at somebody else's conversations.
toolToRegister = tools.NewSearchConversationsTool(
s.messageService, types.SessionOwnerIDFromContext(ctx), sessionID)
case tools.ToolSearchMemory:
// Reaching this case means the memory switches were already
// checked above, where the tool is injected. Which memory space is
// read is resolved from the request context inside the service, so
// this tool needs no owner argument and none can be supplied.
toolToRegister = tools.NewSearchMemoryTool(s.memoryService)
case tools.ToolDatabaseQuery:
toolToRegister = tools.NewDatabaseQueryTool(s.db, config.SearchTargets)
case tools.ToolWebSearch:
toolToRegister = tools.NewWebSearchTool(
s.webSearchService,
s.knowledgeBaseService,
s.knowledgeService,
s.webSearchStateService,
sessionID,
config.WebSearchMaxResults,
config.WebSearchProviderID,
)
logger.Infof(ctx, "Registered web_search tool for session: %s, maxResults: %d, providerID: %s", sessionID, config.WebSearchMaxResults, config.WebSearchProviderID)
case tools.ToolWebFetch:
toolToRegister = tools.NewWebFetchTool(chatModel)
logger.Infof(ctx, "Registered web_fetch tool for session: %s", sessionID)
case tools.ToolDataAnalysis:
toolToRegister = tools.NewDataAnalysisTool(s.knowledgeBaseService, s.knowledgeService, s.tenantService, s.fileService, s.duckdb, sessionID, s.storageResolver).
WithSearchTargets(config.SearchTargets)
logger.Infof(ctx, "Registered data_analysis tool for session: %s", sessionID)
case tools.ToolDataSchema:
toolToRegister = tools.NewDataSchemaTool(s.knowledgeService, s.chunkService.GetRepository()).
WithSearchTargets(config.SearchTargets)
logger.Infof(ctx, "Registered data_schema tool")
// Wiki tools — only registered when wiki KBs are detected
case tools.ToolWikiReadPage:
toolToRegister = tools.NewWikiReadPageTool(s.wikiPageService, s.knowledgeService, wikiScopes, wikiRoutes)
case tools.ToolWikiSearch:
toolToRegister = tools.NewWikiSearchTool(s.wikiPageService, s.knowledgeService, wikiScopes, wikiRoutes)
case tools.ToolWikiReadSourceDoc:
toolToRegister = tools.NewWikiReadSourceDocTool(s.knowledgeService, s.chunkService, config.SearchTargets)
case tools.ToolWikiFlagIssue:
toolToRegister = tools.NewWikiFlagIssueTool(s.wikiPageService, wikiKBIDs, wikiRoutes).
WithKnowledgeScope(s.knowledgeService, config.SearchTargets)
case tools.ToolWikiReadIssue:
toolToRegister = tools.NewWikiReadIssueTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiUpdateIssue:
toolToRegister = tools.NewWikiUpdateIssueTool(s.wikiPageService, wikiKBIDs)
case tools.ToolWikiWritePage:
toolToRegister = tools.NewWikiWritePageTool(s.wikiPageService, wikiKBIDs, s.knowledgeService, wikiRoutes).
WithSearchTargets(config.SearchTargets)
case tools.ToolWikiReplaceText:
toolToRegister = tools.NewWikiReplaceTextTool(s.wikiPageService, wikiKBIDs, s.knowledgeService, wikiRoutes).
WithSearchTargets(config.SearchTargets)
case tools.ToolWikiRenamePage:
toolToRegister = tools.NewWikiRenamePageTool(s.wikiPageService, wikiKBIDs, wikiRoutes)
case tools.ToolWikiDeletePage:
toolToRegister = tools.NewWikiDeletePageTool(s.wikiPageService, wikiKBIDs, wikiRoutes)
case tools.ToolShellExec, tools.ToolReadSkill, tools.ToolExecuteSkillScript,
tools.ToolListSandboxFiles, tools.ToolReadSandboxFile, tools.ToolWriteSandboxFile,
tools.ToolEditSandboxFile:
// Bound to the resolved sandbox manager in registerSandboxFileTools
// / registerSandboxShellIfAllowed / initializeSkillsManager.
// Listing them here would warn "Unknown tool: shell_exec" on every
// skill install, then register the real tool a few lines later.
continue
default:
logger.Warnf(ctx, "Unknown tool: %s", toolName)
}
if toolToRegister != nil {
if toolToRegister.Name() != toolName {
logger.Warnf(ctx, "Tool name mismatch: expected %s, got %s", toolName, toolToRegister.Name())
}
registry.RegisterTool(toolToRegister)
}
}
logger.Infof(ctx, "Registered %d tools", len(registry.ListTools()))
return nil
}
// filterSharedAgentWriteTools enforces the read-only contract of AgentShare.
// These tools write source-workspace Wiki state and otherwise bypass the HTTP
// KB permission middleware because they execute inside the agent engine.
func filterSharedAgentWriteTools(allowed []string) []string {
sourceWorkspaceWrites := map[string]bool{
tools.ToolWikiFlagIssue: true,
tools.ToolWikiUpdateIssue: true,
tools.ToolWikiWritePage: true,
tools.ToolWikiReplaceText: true,
tools.ToolWikiRenamePage: true,
tools.ToolWikiDeletePage: true,
}
filtered := make([]string, 0, len(allowed))
for _, name := range allowed {
if !sourceWorkspaceWrites[name] {
filtered = append(filtered, name)
}
}
return filtered
}
// ValidateConfig validates the agent configuration
func (s *agentService) ValidateConfig(config *types.AgentConfig) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
if config.MaxIterations < 0 {
config.MaxIterations = types.UnlimitedMaxIterations
} else if config.MaxIterations == 0 {
config.MaxIterations = 5
} else if config.MaxIterations > MAX_ITERATIONS {
return fmt.Errorf("max iterations too high: %d (max %d)", config.MaxIterations, MAX_ITERATIONS)
}
return nil
}
// getKnowledgeBaseInfos retrieves detailed information for knowledge bases.
// kbTenantMap carries the tenant each KB should be queried under (source tenant
// for directly shared KBs); a missing entry falls back to the request tenant.
func (s *agentService) getKnowledgeBaseInfos(ctx context.Context, kbIDs []string, kbTenantMap map[string]uint64) ([]*agent.KnowledgeBaseInfo, error) {
if len(kbIDs) == 0 {
return []*agent.KnowledgeBaseInfo{}, nil
}
kbInfos := make([]*agent.KnowledgeBaseInfo, 0, len(kbIDs))
for _, kbID := range kbIDs {
// Get knowledge base details
kb, err := s.knowledgeBaseService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Warnf(ctx, "Failed to get knowledge base %s: %v", secutils.SanitizeForLog(kbID), err)
kbInfos = append(kbInfos, &agent.KnowledgeBaseInfo{
ID: kbID,
Name: kbID,
Type: "document",
Description: "",
DocCount: 0,
RecentDocs: []agent.RecentDocInfo{},
})
continue
}
// Skip hidden/system-managed knowledge bases (e.g., __chat_history__)
if kb.IsTemporary {
logger.Debugf(ctx, "Skipping temporary knowledge base %s (%s) from prompt", kb.ID, kb.Name)
continue
}
// Document/FAQ listing below is tenant-scoped, so a directly shared KB
// must be queried under its source tenant — the request context belongs
// to the receiving tenant and would silently yield doc_count=0. The
// tenant comes from the SearchTarget that buildSearchTargets already
// authorized; this only widens the metadata query, never the KB set.
metaCtx := ctx
if scopeTenantID := kbTenantMap[kbID]; scopeTenantID != 0 {
metaCtx = context.WithValue(ctx, types.TenantIDContextKey, scopeTenantID)
}
// Get document count and recent documents
docCount := 0
recentDocs := []agent.RecentDocInfo{}
if kb.Type == types.KnowledgeBaseTypeFAQ {
pageResult, err := s.knowledgeService.ListFAQEntries(metaCtx, kbID, &types.Pagination{
Page: 1,
PageSize: 10,
}, nil, 0, "", "", "")
if err == nil && pageResult != nil {
docCount = int(pageResult.Total)
if entries, ok := pageResult.Data.([]*types.FAQEntry); ok {
for _, entry := range entries {
if len(recentDocs) >= 10 {
break
}
recentDocs = append(recentDocs, agent.RecentDocInfo{
ChunkID: entry.ChunkID,
KnowledgeID: entry.KnowledgeID,
KnowledgeBaseID: entry.KnowledgeBaseID,
Title: entry.StandardQuestion,
Type: string(types.ChunkTypeFAQ),
CreatedAt: entry.CreatedAt.Format("2006-01-02"),
FAQStandardQuestion: entry.StandardQuestion,
FAQSimilarQuestions: entry.SimilarQuestions,
FAQAnswers: entry.Answers,
})
}
}
} else if err != nil {
logger.Warnf(ctx, "Failed to list FAQ entries for %s: %v", kbID, err)
}
}
// Fallback to generic knowledge listing when not FAQ or FAQ retrieval failed
if kb.Type != types.KnowledgeBaseTypeFAQ || len(recentDocs) == 0 {
pageResult, err := s.knowledgeService.ListPagedKnowledgeByKnowledgeBaseID(metaCtx, kbID, &types.Pagination{
Page: 1,
PageSize: 10,
}, types.KnowledgeListFilter{
ParseStatus: types.ParseStatusCompleted,
})
if err == nil && pageResult != nil {
docCount = int(pageResult.Total)
// Convert to Knowledge slice
if knowledges, ok := pageResult.Data.([]*types.Knowledge); ok {
for _, k := range knowledges {
if len(recentDocs) >= 10 {
break
}
recentDocs = append(recentDocs, agent.RecentDocInfo{
KnowledgeID: k.ID,
Title: k.Title,
Description: k.Description,
FileName: k.FileName,
Type: k.FileType,
CreatedAt: k.CreatedAt.Format("2006-01-02"),
FileSize: k.FileSize,
})
}
}
}
}
kbType := kb.Type
if kbType == "" {
kbType = "document" // Default type
}
kbInfos = append(kbInfos, &agent.KnowledgeBaseInfo{
ID: kb.ID,
Name: kb.Name,
Type: kbType,
Description: kb.Description,
DocCount: docCount,
Capabilities: kbRetrievalCapabilities(kb),
RecentDocs: recentDocs,
})
}
return kbInfos, nil
}
// kbRetrievalCapabilities reports which retrieval surfaces a KB exposes.
// Surfaces are the static facts the hybrid agent prompt consults to pick its
// retrieval strategy — the agent should NOT need to probe this via search.
//
// Returned values are a subset of {"wiki", "chunks"}:
// - "wiki" → the KB has wiki ingestion enabled (wiki_search / wiki_read_page)
// - "chunks" → the KB has vector and/or keyword (BM25) indexing enabled
// (knowledge_search / grep_chunks)
func kbRetrievalCapabilities(kb *types.KnowledgeBase) []string {
if kb == nil {
return nil
}
caps := make([]string, 0, 2)
if kb.IsWikiEnabled() {
caps = append(caps, "wiki")
}
if kb.IsVectorEnabled() || kb.IsKeywordEnabled() {
caps = append(caps, "chunks")
}
return caps
}
// getSelectedDocumentInfos retrieves detailed information for user-selected documents (via @ mention)
// This loads the actual content of the documents to include in the system prompt
func (s *agentService) getSelectedDocumentInfos(ctx context.Context, knowledgeIDs []string) ([]*agent.SelectedDocumentInfo, error) {
if len(knowledgeIDs) == 0 {
return []*agent.SelectedDocumentInfo{}, nil
}
// Get tenant ID from context
tenantID := uint64(0)
if tid, ok := types.TenantIDFromContext(ctx); ok {
tenantID = tid
}
// Fetch knowledge metadata (include docs from shared KBs the user has access to)
knowledges, err := s.knowledgeService.GetKnowledgeBatchWithSharedAccess(ctx, tenantID, knowledgeIDs)
if err != nil {
return nil, fmt.Errorf("failed to get knowledge batch: %w", err)
}
// Build map for quick lookup
knowledgeMap := make(map[string]*types.Knowledge)
for _, k := range knowledges {
if k != nil {
knowledgeMap[k.ID] = k
}
}
selectedDocs := make([]*agent.SelectedDocumentInfo, 0, len(knowledgeIDs))
for _, kid := range knowledgeIDs {
k, ok := knowledgeMap[kid]
if !ok {
logger.Warnf(ctx, "Selected knowledge %s not found", kid)
continue
}
docInfo := &agent.SelectedDocumentInfo{
KnowledgeID: k.ID,
KnowledgeBaseID: k.KnowledgeBaseID,
Title: k.Title,
FileName: k.FileName,
FileType: k.FileType,
}
selectedDocs = append(selectedDocs, docInfo)
}
logger.Infof(ctx, "Loaded %d selected documents metadata for prompt", len(selectedDocs))
return selectedDocs, nil
}
func (s *agentService) resolvePinnedMCPServiceInfos(
ctx context.Context,
config *types.AgentConfig,
) []*agent.PinnedMCPServiceInfo {
if len(config.PinnedMCPServiceIDs) == 0 || s.mcpServiceService == nil {
return nil
}
tenantID := uint64(0)
if tid, ok := types.TenantIDFromContext(ctx); ok {
tenantID = tid
}
if tenantID == 0 {
return fallbackPinnedMCPInfos(config.PinnedMCPServiceIDs)
}
services, err := s.mcpServiceService.ListMCPServicesByIDs(ctx, tenantID, config.PinnedMCPServiceIDs)
if err != nil {
logger.Warnf(ctx, "Failed to resolve pinned MCP services: %v", err)
return fallbackPinnedMCPInfos(config.PinnedMCPServiceIDs)
}
byID := make(map[string]*types.MCPService, len(services))
for _, svc := range services {
if svc != nil {
byID[svc.ID] = svc
}
}
result := make([]*agent.PinnedMCPServiceInfo, 0, len(config.PinnedMCPServiceIDs))
for _, id := range config.PinnedMCPServiceIDs {
if id == "" {
continue
}
if svc, ok := byID[id]; ok {
result = append(result, &agent.PinnedMCPServiceInfo{
ID: svc.ID,
Name: svc.Name,
Description: svc.Description,
})
continue
}
result = append(result, &agent.PinnedMCPServiceInfo{ID: id, Name: id})
}
return result
}
func (s *agentService) attachPinnedMCPToolNames(
registry *tools.ToolRegistry,
pinned []*agent.PinnedMCPServiceInfo,
) {
if registry == nil || len(pinned) == 0 {
return
}
byService := tools.MCPToolNamesByServiceID(registry)
for _, info := range pinned {
if info == nil || info.ID == "" {
continue
}
info.ToolNames = append([]string(nil), byService[info.ID]...)
}
}
func fallbackPinnedMCPInfos(ids []string) []*agent.PinnedMCPServiceInfo {
result := make([]*agent.PinnedMCPServiceInfo, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
result = append(result, &agent.PinnedMCPServiceInfo{ID: id, Name: id})
}
return result
}
func (s *agentService) resolvePinnedSkillInfos(config *types.AgentConfig) []*agent.PinnedSkillInfo {
if len(config.PinnedSkillNames) == 0 {
return nil
}
descByName := make(map[string]string)
if len(config.SkillDirs) > 0 {
loader := skills.NewLoader(config.SkillDirs)
if metadata, err := loader.DiscoverSkills(); err == nil {
for _, meta := range metadata {
if meta != nil {
descByName[meta.Name] = meta.Description
}
}
}
}
for _, row := range config.TenantSkills {
if row != nil && row.Name != "" {
descByName[row.Name] = row.Description
}
}
result := make([]*agent.PinnedSkillInfo, 0, len(config.PinnedSkillNames))
for _, name := range config.PinnedSkillNames {
if name == "" {
continue
}
result = append(result, &agent.PinnedSkillInfo{
Name: name,
Description: descByName[name],
})
}
return result
}