Files
HaiDuongandmor-phongdt b39f0decb9 fix(skills): five defects in slash activation, grants, subagent tool policy, preview and import (#1534)
* fix(agent): filter skill slash commands by the agent's grants

The inline <available_skills> block was already filtered by visibility and
agent grants, but slash activation read the loader's full skill list. A
/<slug> command could therefore activate a skill the agent was never granted,
and the not-found suggestions disclosed that such skills existed.

Resolve slash commands against the agent's allow list via FilterSkills. The
list is filtered before matching, so /list-skills, /help and the near-match
suggestions are all covered by the same change.

The allow-list convention is unchanged: nil means every skill (the fallback
when the access store errors), an empty slice means none, and a populated
slice is an explicit set of slugs.

* fix(agent): split slash commands on any whitespace

The tokenizer separated the skill name from the rest of the message with
strings.Cut(after, " ") — a literal space. A user who typed the command and
pressed Enter before the rest of the message sent "/ck:git\nreview the diff",
which parsed to the target "ck:git\nreview" and matched no skill. The reply
was "skill not found" followed by near-matches that included the skill they
had just named, because the similarity fallback searched the mangled string
and still landed beside it. Multi-line messages are ordinary in Slack and
Telegram, so this was reachable in normal use.

Introduce cutFirstField, which splits on the first run of whitespace, and use
it everywhere the literal-space split appeared. The match loop had the same
assumption in strings.HasPrefix(raw, value+" "); hasFieldPrefix replaces it
and decodes the following rune properly, so a multi-byte skill name is not
truncated. That also stops a plain string prefix from matching: a command for
"frontend-design-extra" no longer resolves to "frontend-design".

* fix(tools): inherit the parent agent's tool policy in spawned subagents

buildSubagentToolsRegistry clones the parent registry, then overwrites exec,
read_file, write_file and list_files with freshly constructed tools. A fresh
tool carries none of the hardening the gateway applies to the parent's
instances at startup: exec path denials and their exemptions, the shell
deny-group toggles, the command keyword allowlist, and the read/write/list
deny prefixes covering config.json, the internal databases and delegate/.

Spawning a subagent therefore widened what an agent could reach — the parent
was blocked from the data dir, the subagent was not. Verified by disabling
the new call: the subagent's exec read config.json out of the denied data
dir, read_file did the same, and write_file overwrote it.

Copy the policy from the live parent instances rather than re-deriving it, so
there is one source of truth and a deny path reloaded later through config
pub/sub reaches subagents without a second wiring site that can drift.

Allow-prefixes are inherited alongside the denials: they are what make the
denied roots usable at all, since the skills store sits under the denied data
dir. Copying denials without them would leave a subagent unable to read the
skills it is told to use.

* fix(agent): use one inline-vs-search decision for prompt and preview

The system-prompt preview exists to show the prompt an agent actually gets,
but it decided between inline skills and search mode on its own terms: it
counted tokens with the fallback counter over the fully rendered XML — tags
and <location> paths included, at roughly runes/2 — while the prompt builder
estimated name+description at chars/4. On the same eighteen skills that read
3087 against 944, so the preview reported search mode for an agent that was
running inline.

Extract shouldInlineSkills and call it from both paths. The estimate keeps
the prompt builder's rule, mirroring BuildSummary's 200-rune description
truncation, so the decision still costs no rendering.

The preview's loader interface gains FilterSkills to feed it. Its behaviour
on an access-store error is unchanged: an empty allow list yields no skills
rather than falling back to showing every skill.

* fix(http): keep reference files when importing skills

Export archives the whole skill directory, but import recognised only
metadata.json, SKILL.md and grants.jsonl. The switch had no default, so every
other file was discarded without a log line. A skill whose SKILL.md cites
references/*.md arrived without them, the import reported success, and the
skill failed at the first read.

Collect the remaining entries and write them under the skill directory with
their structure intact. Archive entry names are attacker-controlled, so each
path goes through sanitizeRelPath: a traversal attempt collapses to a
relative path and the write stays inside the skill directory.

* test(agent): make the inline-decision table exercise both gates

The "100 skills, 200-char descriptions" case claimed to cover the token
ceiling, but 100 exceeds the count ceiling so the count check short-circuited
and the token branch never ran — deleting that branch would not have failed
the test.

Split the table so each case isolates one gate: tiny descriptions for the
count rows, a count safely under the ceiling for the token rows. Removing the
token comparison now fails the over-the-ceiling case and nothing else.

* fix(agent): gate slash commands on the managed tier only

The allow list comes from a query over the `skills` table, so filesystem-tier
skills — the workspace, .agents and ~/.agents directories of the five-tier
loader — have no row in it. Filtering every skill against the list made those
four tiers unreachable by slash while skill_search still found them
unfiltered, which turned a security fix into a functional regression.

Apply the list to managed skills only. Builtins are seeded with is_system and
returned unconditionally, so they stay reachable either way.

This closes slash activation of ungranted managed skills. It does not close
skill_search and use_skill, which still read the loader's full list; that is a
wider change and wants its own review.

* fix(http): stop imported skill files from replacing guarded ones

Two ways the auxiliary write could go wrong.

GuardSkillContent scans SKILL.md before anything reaches disk, but the switch
that routes archive entries compares the raw path. An entry named
"./SKILL.md" does not match it, so it fell through to the auxiliary set —
where sanitizeRelPath collapses it back to "SKILL.md" and the write replaced
the file that had just been scanned. Reject any auxiliary path that resolves
to a name the import handles by itself, and log the attempt.

Tar directory entries carry a trailing separator and no content. Written as
files they took the name a real directory needed, so everything beneath was
dropped — and whether that happened depended on map iteration order, making
it intermittent. Skip them; MkdirAll creates what is needed.

Also cap the number of auxiliary files per skill and log when the cap trims
an archive, so a truncated import is visible rather than silent.

---------

Co-authored-by: mor-phongdt <phong.dangtuan@mor.com.vn>
2026-09-01 02:42:47 +07:00

533 lines
21 KiB
Go

package agent
import (
"context"
"encoding/json"
"log/slog"
"path/filepath"
"slices"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// MCPPreviewLister returns MCP tool info from store configuration without
// requiring live MCP server connections. Satisfied by *mcp.Manager.
type MCPPreviewLister interface {
ListToolsForAgent(ctx context.Context, agentID uuid.UUID, userID string) ([]MCPToolPreviewInfo, error)
}
// MCPToolPreviewInfo describes an MCP tool for preview purposes.
// Mirrors mcp.MCPToolPreviewInfo but declared here to avoid an import cycle.
type MCPToolPreviewInfo struct {
// RegisteredName is the tool name including mcp_ prefix.
RegisteredName string
// Description is the tool description from server tool hints, if any.
Description string
// Parameters is the tool's cached JSON Schema for input parameters, if
// one was captured at connect-time. nil when no schema is cached yet
// (e.g. server never connected, or cached before schema capture existed).
Parameters json.RawMessage
}
// PreviewDeps holds optional dependencies for building a preview system prompt.
// All fields are nil-safe — missing deps simply skip resolution for that section.
type PreviewDeps struct {
AgentStore store.AgentStore
TeamStore store.TeamStore
AgentLinks store.AgentLinkStore
ProviderReg *providers.Registry
SkillAccessStore store.SkillAccessStore
ToolLister interface {
List() []string
Get(name string) (tools.Tool, bool)
Aliases() map[string]string
}
// ToolPolicy is the gateway's live PolicyEngine, used to resolve the full
// allow/deny/alsoAllow pipeline (including global deny) for preview tool
// names. nil = skip policy filtering (only skill_manage gating and alias
// exclusion apply).
ToolPolicy *tools.PolicyEngine
SkillsLoader interface {
BuildPinnedSummary(ctx context.Context, names []string) string
BuildSummary(ctx context.Context, allowList []string) string
// FilterSkills feeds shouldInlineSkills, so the preview reaches the same
// inline-vs-search verdict as the live prompt builder.
FilterSkills(ctx context.Context, allowList []string) []skills.Info
}
// MCPLister provides store-based MCP tool info for preview (nil = skip).
// When set, MCP tool descriptions are populated from configured servers
// even if those servers are not currently loaded in the tool registry.
MCPLister MCPPreviewLister
// DisabledTools is the per-tenant set of disabled tool names (nil/empty = none disabled).
DisabledTools map[string]bool
DataDir string // for team workspace path construction
}
// PreviewResult holds the output of BuildPreviewPrompt.
type PreviewResult struct {
Prompt string
ToolDefs []providers.ToolDefinition // tool definitions (schemas) as sent to the LLM
}
// BuildPreviewPrompt builds a system prompt for preview purposes.
// Reuses the same BuildSystemPrompt() as the LLM pipeline, resolving as many
// fields as possible from agent data + DB stores. Runtime-only fields
// (channel, peer kind, session context, credentials) are left at zero values —
// BuildSystemPrompt already nil-checks every field.
func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMode, userID string, deps PreviewDeps) PreviewResult {
slog.Debug("BuildPreviewPrompt called", "agent_id", ag.ID, "mode", mode, "user_id", userID, "mcp_lister_nil", deps.MCPLister == nil)
// --- Context files ---
var contextFiles []bootstrap.ContextFile
if deps.AgentStore != nil {
agentFiles, _ := deps.AgentStore.GetAgentContextFiles(ctx, ag.ID)
for _, f := range agentFiles {
if f.Content == "" {
continue
}
// Mode-aware context file filtering
if allowlist := bootstrap.ModeAllowlist(string(mode)); allowlist != nil {
if !allowlist[f.FileName] {
continue
}
}
contextFiles = append(contextFiles, bootstrap.ContextFile{Path: f.FileName, Content: f.Content})
}
// Merge per-user overrides if user_id provided.
if userID != "" {
contextFiles = mergePreviewUserFiles(ctx, deps.AgentStore, ag.ID, contextFiles, userID, mode)
}
}
// --- Tool names ---
var toolNames []string
if deps.ToolLister != nil {
toolNames = deps.ToolLister.List()
} else {
toolNames = fallbackPreviewToolNames
}
// --- skill_manage gating (matches loop_history.go:124-131) ---
if !ag.ParseSkillEvolve() {
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if n != "skill_manage" {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
// --- Resolve concrete *tools.Registry (needed by WouldAllow to expand
// group:* specs like "group:mcp" in Allow/AlsoAllow). Falls back to nil
// when deps.ToolLister is a narrower test mock that doesn't fully
// implement tools.ToolExecutor — WouldAllow tolerates a nil registry by
// skipping group expansion, matching prior behavior for such mocks.
var toolRegistry *tools.Registry
if executor, ok := deps.ToolLister.(tools.ToolExecutor); ok {
toolRegistry = tools.ResolveConcreteRegistry(executor)
}
// --- Full agent tool policy (profile→allow→deny→alsoAllow, including global deny) ---
// Uses the gateway's live PolicyEngine via WouldAllow, mirroring the real chat
// loop's resolution exactly, modulo channel-specific filtering (genuinely
// unavailable here — preview has no channel/session context).
if deps.ToolPolicy != nil {
toolPolicy := ag.ParseToolsConfig()
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if deps.ToolPolicy.WouldAllow(toolRegistry, n, ag.Provider, toolPolicy, nil) {
filtered = append(filtered, n)
}
}
toolNames = filtered
} else if toolPolicy := ag.ParseToolsConfig(); toolPolicy != nil && len(toolPolicy.Deny) > 0 {
// Fallback when no PolicyEngine is wired (e.g. caller didn't set
// ToolPolicy): degrade to per-agent deny-only, matching the
// pre-PolicyEngine preview behavior. Global deny is unavailable in
// this fallback since it requires the PolicyEngine's global policy.
denySet := make(map[string]bool, len(toolPolicy.Deny))
for _, d := range toolPolicy.Deny {
denySet[d] = true
}
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if !denySet[n] {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
// --- Alias exclusion (matches loop_history.go:136-146) ---
if deps.ToolLister != nil {
if aliasSet := deps.ToolLister.Aliases(); len(aliasSet) > 0 {
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if _, isAlias := aliasSet[n]; !isAlias {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
}
// --- Per-tenant disabled tools (matches loop_tool_filter.go:105-117) ---
if len(deps.DisabledTools) > 0 {
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if !deps.DisabledTools[n] {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
// --- MCP tool descriptions (matches loop_history_supplement.go:44-58) ---
// mcpToolParams tracks real parameter schemas alongside descriptions, keyed
// by the same RegisteredName. Populated when a real schema is available
// (live registry tool, or cached schema from a prior live connection).
// First populate from live registry (tools currently loaded in active sessions).
var mcpToolDescs map[string]string
var mcpToolParams map[string]map[string]any
if deps.ToolLister != nil {
descs := make(map[string]string)
params := make(map[string]map[string]any)
for _, name := range toolNames {
if !strings.HasPrefix(name, "mcp_") || name == "mcp_tool_search" {
continue
}
if tool, ok := deps.ToolLister.Get(name); ok {
descs[name] = tool.Description()
if p := tool.Parameters(); len(p) > 0 {
params[name] = p
}
}
}
if len(descs) > 0 {
mcpToolDescs = descs
}
if len(params) > 0 {
mcpToolParams = params
}
}
slog.Debug("preview_prompt.mcp_from_registry", "agent_id", ag.ID, "live_mcp_tools", len(mcpToolDescs))
// Then supplement (or replace) with store-based MCP tools so that configured
// MCP servers appear in the preview even when not currently loaded in the registry.
slog.Debug("preview_prompt.mcp_lister_check", "agent_id", ag.ID, "mcp_lister_nil", deps.MCPLister == nil)
if deps.MCPLister != nil {
slog.Debug("preview_prompt: calling MCPLister.ListToolsForAgent", "agent_id", ag.ID, "user_id", userID)
storeMCPTools, err := deps.MCPLister.ListToolsForAgent(ctx, ag.ID, userID)
slog.Debug("preview_prompt.mcp_lister_result", "agent_id", ag.ID, "user_id", userID, "store_tools_count", len(storeMCPTools), "error", err)
if err == nil && len(storeMCPTools) > 0 {
if mcpToolDescs == nil {
mcpToolDescs = make(map[string]string, len(storeMCPTools))
}
toolPolicyCfg := ag.ParseToolsConfig()
for _, mt := range storeMCPTools {
// MCP tool grant/access is already fully resolved by
// ListToolsForAgent's own tool_allow/tool_deny per-server
// logic above. Here we only need to catch the narrower case
// of a literal-name deny (global or per-agent tools.deny
// listing this exact tool name). We deliberately do NOT run
// the full WouldAllow group-expansion pipeline: MCP tools are
// only ever registered into ephemeral per-agent registry
// clones at live-connection time (internal/mcp/manager_connect.go),
// never into the shared/global registry used here, so
// "group:mcp" (and any other group spec) can never resolve
// correctly against toolRegistry in this connection-free
// preview context. Passing reg=nil forces IsDenied to fall
// back to a plain literal-name match with no group
// expansion, which is exactly what's needed and is immune to
// this class of registry-dependency bug.
if deps.ToolPolicy != nil && deps.ToolPolicy.IsDenied(nil, mt.RegisteredName, toolPolicyCfg) {
slog.Debug("preview_prompt.mcp_tool_denied_by_policy", "tool", mt.RegisteredName)
continue
}
if _, alreadyPresent := mcpToolDescs[mt.RegisteredName]; !alreadyPresent {
mcpToolDescs[mt.RegisteredName] = mt.Description
slog.Debug("preview_prompt.mcp_tool_added", "tool", mt.RegisteredName, "has_desc", mt.Description != "")
} else {
slog.Debug("preview_prompt.mcp_tool_already_present", "tool", mt.RegisteredName)
}
// Cached parameter schema, when present, is only used when the
// live registry did not already supply a real schema above.
if _, alreadyHasParams := mcpToolParams[mt.RegisteredName]; !alreadyHasParams && len(mt.Parameters) > 0 {
var schema map[string]any
if jsonErr := json.Unmarshal(mt.Parameters, &schema); jsonErr == nil {
if mcpToolParams == nil {
mcpToolParams = make(map[string]map[string]any, len(storeMCPTools))
}
mcpToolParams[mt.RegisteredName] = schema
} else {
slog.Debug("preview_prompt.mcp_tool_params_unmarshal_failed", "tool", mt.RegisteredName, "error", jsonErr)
}
}
}
} else if err != nil {
slog.Debug("preview_prompt.mcp_lister_error", "agent_id", ag.ID, "error", err)
}
}
slog.Debug("preview_prompt.mcp_tool_descs_final", "agent_id", ag.ID, "total_mcp_tools", len(mcpToolDescs), "total_mcp_params", len(mcpToolParams))
// --- Sandbox ---
sandboxCfg := ag.ParseSandboxConfig()
sandboxEnabled := sandboxCfg != nil && sandboxCfg.Mode != "" && sandboxCfg.Mode != "off"
var sandboxContainerDir string
if sandboxEnabled {
sandboxContainerDir = "/workspace"
}
// --- Pinned skills ---
var pinnedSummary string
if pinnedSkills := ag.ParsePinnedSkills(); len(pinnedSkills) > 0 && deps.SkillsLoader != nil {
pinnedSummary = deps.SkillsLoader.BuildPinnedSummary(ctx, pinnedSkills)
}
// --- Skills summary (BuildSummary + token count) ---
var skillsSummary string
if deps.SkillsLoader != nil {
var skillAllowList []string
if deps.SkillAccessStore != nil {
if accessible, err := deps.SkillAccessStore.ListAccessible(ctx, ag.ID, userID); err == nil {
skillAllowList = make([]string, 0, len(accessible))
for _, sk := range accessible {
skillAllowList = append(skillAllowList, sk.Slug)
}
} else {
// On error: empty list (no skills). Preview is diagnostic; safer than showing all.
skillAllowList = []string{}
}
}
// Same decision function as the live prompt builder — see shouldInlineSkills.
// Over threshold → search-only mode (skillsSummary stays empty).
if shouldInlineSkills(deps.SkillsLoader.FilterSkills(ctx, skillAllowList)) {
skillsSummary = deps.SkillsLoader.BuildSummary(ctx, skillAllowList)
}
}
// --- Provider contribution ---
var providerContrib *providers.PromptContribution
if deps.ProviderReg != nil && ag.Provider != "" {
if p, err := deps.ProviderReg.Get(ctx, ag.Provider); err == nil {
if pc, ok := p.(providers.PromptContributor); ok {
providerContrib = pc.PromptContribution()
}
}
}
// --- Team + Delegation (none mode skips team entirely) ---
orchMode := ResolveOrchestrationMode(ctx, ag.ID, deps.TeamStore, deps.AgentLinks)
var isTeamCtx bool
var teamMembers []store.TeamMemberData
var teamWorkspace, teamGuidance string
if mode != PromptNone && deps.TeamStore != nil {
if team, err := deps.TeamStore.GetTeamForAgent(ctx, ag.ID); err == nil && team != nil {
isTeamCtx = true
if deps.DataDir != "" {
teamWorkspace = filepath.Join(deps.DataDir, "teams", team.ID.String())
}
teamGuidance = defaultTeamGuidance()
if members, err := deps.TeamStore.ListMembers(ctx, team.ID); err == nil {
teamMembers = members
// Inject virtual TEAM.md (same as pipeline resolver.go:190)
contextFiles = append(contextFiles, bootstrap.ContextFile{
Path: bootstrap.TeamFile,
Content: buildTeamMD(team, members, ag.ID),
})
}
}
}
var delegateTargets []DelegateTargetEntry
if deps.AgentLinks != nil && orchMode != ModeSpawn {
if links, err := deps.AgentLinks.DelegateTargets(ctx, ag.ID); err == nil {
for _, link := range links {
delegateTargets = append(delegateTargets, DelegateTargetEntry{
AgentKey: link.TargetAgentKey,
DisplayName: link.TargetDisplayName,
Description: link.Description,
})
}
}
}
// --- Tool definitions (schemas sent to LLM alongside the system prompt) ---
var toolDefs []providers.ToolDefinition
if deps.ToolLister != nil {
for _, name := range toolNames {
if tool, ok := deps.ToolLister.Get(name); ok {
toolDefs = append(toolDefs, tools.ToProviderDef(tool))
}
}
// Include alias definitions (LLM receives both canonical + aliases),
// but only for aliases whose canonical tool survived the deny/gating
// filters above (toolNames). Without this check, denied tools would
// reappear in the schema via their alias name even though they were
// correctly excluded from ToolNames/the main loop.
toolNameSet := make(map[string]bool, len(toolNames))
for _, n := range toolNames {
toolNameSet[n] = true
}
for alias, canonical := range deps.ToolLister.Aliases() {
if !toolNameSet[canonical] {
continue
}
if tool, ok := deps.ToolLister.Get(canonical); ok {
toolDefs = append(toolDefs, providers.ToolDefinition{
Type: "function",
Function: &providers.ToolFunctionSchema{
Name: alias,
Description: tool.Description(),
Parameters: tool.Parameters(),
},
})
}
}
}
// --- MCP tool schemas (store-based preview, no live connection) ---
// mcpToolParams carries the REAL cached parameter schema for a tool when
// one is available (from a live registry tool, or from the connect-time
// tool_cache captured in internal/mcp/manager_connect.go). This makes
// preview parity with the live conversation path, which always sends
// real schemas via BridgeTool.Parameters() (internal/mcp/bridge_tool.go).
// Skip names already emitted via the main toolNames loop above (those
// come from a live-loaded registry tool and already have a real schema).
if len(mcpToolDescs) > 0 {
alreadyEmitted := make(map[string]bool, len(toolNames))
for _, n := range toolNames {
alreadyEmitted[n] = true
}
mcpNames := make([]string, 0, len(mcpToolDescs))
for name := range mcpToolDescs {
if alreadyEmitted[name] {
continue
}
mcpNames = append(mcpNames, name)
}
slices.Sort(mcpNames)
for _, name := range mcpNames {
params, ok := mcpToolParams[name]
if !ok {
// Genuine unknown-schema fallback: no live connection has ever
// cached a real schema for this tool (e.g. server never
// connected yet, or cache predates schema capture). This is
// NOT the routine case — most tools reach here with a real
// cached schema once the server has connected at least once.
params = map[string]any{"type": "object"}
}
toolDefs = append(toolDefs, providers.ToolDefinition{
Type: "function",
Function: &providers.ToolFunctionSchema{
Name: name,
Description: mcpToolDescs[name],
Parameters: params,
},
})
}
}
// --- Build system prompt (same function as LLM pipeline) ---
prompt := BuildSystemPrompt(SystemPromptConfig{
AgentID: ag.AgentKey,
AgentUUID: ag.ID.String(),
DisplayName: ag.DisplayName,
Model: ag.Model,
Mode: mode,
ToolNames: toolNames,
ContextFiles: contextFiles,
AgentType: ag.AgentType,
Workspace: ag.Workspace,
HasMemory: true,
HasSpawn: slices.Contains(toolNames, "spawn"),
HasSkillSearch: slices.Contains(toolNames, "skill_search"),
HasSkillManage: ag.ParseSkillEvolve() && slices.Contains(toolNames, "skill_manage"),
HasMCPToolSearch: slices.Contains(toolNames, "mcp_tool_search"),
HasKnowledgeGraph: slices.Contains(toolNames, "knowledge_graph_search"),
HasMemoryExpand: slices.Contains(toolNames, "memory_expand"),
SelfEvolve: ag.ParseSelfEvolve(),
ProviderType: ag.Provider,
ProviderContribution: providerContrib,
ShellDenyGroups: ag.ParseShellDenyGroups(),
SandboxEnabled: sandboxEnabled,
SandboxContainerDir: sandboxContainerDir,
PinnedSkillsSummary: pinnedSummary,
SkillsSummary: skillsSummary,
MCPToolDescs: mcpToolDescs,
IsTeamContext: isTeamCtx,
TeamWorkspace: teamWorkspace,
TeamMembers: teamMembers,
TeamGuidance: teamGuidance,
DelegateTargets: delegateTargets,
OrchMode: orchMode,
// Runtime-only fields left at zero: Channel, ChannelType, ChatTitle,
// PeerKind, OwnerIDs, ExtraPrompt, CredentialCLIContext, IsBootstrap,
// SandboxWorkspaceAccess
})
slog.Debug("BuildPreviewPrompt: prompt preview built", "agent_id", ag.ID, "mcp_tools", len(mcpToolDescs), "prompt_len", len(prompt), "tool_defs", len(toolDefs))
return PreviewResult{Prompt: prompt, ToolDefs: toolDefs}
}
// mergePreviewUserFiles overlays per-user files onto base agent-level files.
func mergePreviewUserFiles(ctx context.Context, as store.AgentStore, agentID uuid.UUID, base []bootstrap.ContextFile, userID string, mode PromptMode) []bootstrap.ContextFile {
userFiles, err := as.GetUserContextFiles(ctx, agentID, userID)
if err != nil || len(userFiles) == 0 {
return base
}
userMap := make(map[string]string, len(userFiles))
for _, uf := range userFiles {
if uf.Content != "" {
userMap[uf.FileName] = uf.Content
}
}
if len(userMap) == 0 {
return base
}
seen := make(map[string]bool, len(base))
var result []bootstrap.ContextFile
for _, f := range base {
name := filepath.Base(f.Path)
if uc, ok := userMap[name]; ok {
result = append(result, bootstrap.ContextFile{Path: f.Path, Content: uc})
} else {
result = append(result, f)
}
seen[name] = true
}
for _, uf := range userFiles {
if seen[uf.FileName] || uf.Content == "" {
continue
}
if allowlist := bootstrap.ModeAllowlist(string(mode)); allowlist != nil {
if !allowlist[uf.FileName] {
continue
}
}
result = append(result, bootstrap.ContextFile{Path: uf.FileName, Content: uf.Content})
}
return result
}
// defaultTeamGuidance returns team member guidance for preview.
func defaultTeamGuidance() string {
return "Use comment(type='blocker') to escalate blockers to the leader. " +
"Use review to submit work for approval. " +
"Use progress to report incremental status updates."
}
// fallbackPreviewToolNames used when tool registry is not available.
var fallbackPreviewToolNames = []string{
"read_file", "write_file", "list_files", "edit", "exec",
"memory_search", "memory_get", "spawn",
"web_search", "web_fetch", "skill_search", "use_skill",
"datetime", "cron",
}