Ctrl K
ComunidadGuías…

internal/app/agent_instructions.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package app

import (
	"crypto/sha256"
	"encoding/hex"
	"log/slog"
	"os"
	"path/filepath"
	"strings"

	"github.com/yeomyeonggeori/blueclaw/internal/agentruntime"
	"github.com/yeomyeonggeori/blueclaw/internal/config"
	"github.com/yeomyeonggeori/blueclaw/internal/persona"
	"github.com/yeomyeonggeori/blueclaw/internal/skill"
	"github.com/yeomyeonggeori/blueprotocol/agentcontract"
)

// A skill states what it needs: the tools it calls, the environment it is given,
// the file it opens. One this host cannot satisfy cannot do what it says, and
// the agent would otherwise find out mid-task, so every reason is said once at
// start and carried in the skill inventory.
func logSkillsThisHostCannotSatisfy(logger *slog.Logger, unavailableSkills []skill.UnavailableSkill) {
	if logger == nil {
		return
	}
	for _, unavailableSkill := range unavailableSkills {
		if len(unavailableSkill.MissingToolNames) > 0 {
			logger.Error("skill.tools.missing", "skill", unavailableSkill.Name, "missingTools", strings.Join(unavailableSkill.MissingToolNames, ", "), "path", unavailableSkill.Path)
		}
		if len(unavailableSkill.MissingEnvironmentVariables) > 0 {
			logger.Error("skill.environment.missing", "skill", unavailableSkill.Name, "missingEnvironment", strings.Join(unavailableSkill.MissingEnvironmentVariables, ", "), "path", unavailableSkill.Path)
		}
		if len(unavailableSkill.MissingAnyFilePaths) > 0 {
			logger.Error("skill.file.missing", "skill", unavailableSkill.Name, "anyOfTheseWouldHaveDone", strings.Join(unavailableSkill.MissingAnyFilePaths, ", "), "path", unavailableSkill.Path)
		}
	}
}

func logRejectedPersonaDocuments(logger *slog.Logger, rejectedDocuments []instructionDocument) {
	for _, rejectedDocument := range rejectedDocuments {
		logger.Warn("application.persona_document_rejected", "path", rejectedDocument.Source.Path, "error", rejectedDocument.Error.Error())
	}
}

func loadAgentInstructionPrompt(runtimeConfiguration config.RuntimeConfiguration, capabilityRegistry *agentruntime.CapabilityRegistry) string {
	return loadAgentInstructionBundle(runtimeConfiguration, capabilityRegistry).Prompt
}

// A skill this host cannot satisfy is left out of the bundle: it is in the
// prompt only where it can run. The inventory carries it separately with what
// it lacks, so the omission is visible.
type agentInstructions struct {
	Bundle            agentcontract.InstructionBundle
	UnavailableSkills []skill.UnavailableSkill
	RejectedDocuments []instructionDocument
}

// What a skill may name: the tools a product's catalog offered this runtime, plus
// the ones the runtime and the kernel answer themselves.
func offeredToolNamesOf(capabilityRegistry *agentruntime.CapabilityRegistry) []string {
	offeredToolNames := append([]string{}, agentruntime.KernelToolNames()...)
	offeredToolNames = append(offeredToolNames, agentruntime.LocalToolNames()...)
	for _, toolDescriptor := range capabilityRegistry.ToolDescriptors() {
		offeredToolNames = append(offeredToolNames, strings.TrimSpace(toolDescriptor.Name))
	}
	return offeredToolNames
}

func loadAgentInstructionBundle(runtimeConfiguration config.RuntimeConfiguration, capabilityRegistry *agentruntime.CapabilityRegistry) agentcontract.InstructionBundle {
	return loadAgentInstructions(runtimeConfiguration, capabilityRegistry).Bundle
}

func loadAgentInstructions(runtimeConfiguration config.RuntimeConfiguration, capabilityRegistry *agentruntime.CapabilityRegistry) agentInstructions {
	parts := []string{}
	sources := []agentcontract.InstructionSource{}
	skillInstructions := []agentcontract.SkillInstruction{}
	unavailableSkills := []skill.UnavailableSkill{}
	rejectedDocuments := []instructionDocument{}
	includedSkillByName := map[string]bool{}
	offeredToolNames := offeredToolNamesOf(capabilityRegistry)
	for _, rootPath := range instructionRootPaths(runtimeConfiguration) {
		for _, instructionDocument := range readInstructionDocuments(rootPath) {
			if instructionDocument.Error != nil {
				rejectedDocuments = append(rejectedDocuments, instructionDocument)
				continue
			}
			if instructionDocument.Prompt == "" {
				continue
			}
			parts = append(parts, instructionDocument.Prompt)
			sources = append(sources, instructionDocument.Source)
		}
		if instructionDocument, instructionSource := readLegacyInstructionDocument(rootPath); instructionDocument != "" {
			parts = append(parts, instructionDocument)
			sources = append(sources, instructionSource)
		}
		discovered := readSkillInstructions(rootPath, agentruntime.BundledSkillRootPath(rootPath), offeredToolNames)
		for _, skillInstruction := range discovered.Selectable {
			skillName := strings.TrimSpace(skillInstruction.Name)
			if includedSkillByName[skillName] {
				continue
			}
			if skillName != "" {
				includedSkillByName[skillName] = true
			}
			skillInstructions = append(skillInstructions, skillInstruction)
		}
		unavailableSkills = append(unavailableSkills, discovered.Unavailable...)
	}
	if !includedSkillByName["agent-browser"] {
		sources = append(sources, agentcontract.InstructionSource{
			Path:      ".agents/skills/agent-browser/SKILL.md",
			SkillName: "agent-browser",
			Missing:   true,
		})
	}
	return agentInstructions{
		Bundle: agentcontract.InstructionBundle{
			Prompt:  strings.Join(parts, "\n\n"),
			Sources: sources,
			Skills:  skillInstructions,
		},
		UnavailableSkills: uniqueUnavailableSkills(unavailableSkills, includedSkillByName),
		RejectedDocuments: rejectedDocuments,
	}
}

func uniqueUnavailableSkills(unavailableSkills []skill.UnavailableSkill, includedSkillByName map[string]bool) []skill.UnavailableSkill {
	listedSkills := []skill.UnavailableSkill{}
	listedSkillByName := map[string]bool{}
	for _, unavailableSkill := range unavailableSkills {
		skillName := strings.TrimSpace(unavailableSkill.Name)
		if includedSkillByName[skillName] || listedSkillByName[skillName] {
			continue
		}
		listedSkillByName[skillName] = true
		listedSkills = append(listedSkills, unavailableSkill)
	}
	return listedSkills
}

func instructionRootPaths(runtimeConfiguration config.RuntimeConfiguration) []string {
	rootPathByPath := map[string]bool{}
	rootPaths := []string{}
	for _, rootPath := range []string{runtimeConfiguration.Terminal.WorkspaceRootPath, "/workspace", "."} {
		cleanRootPath := strings.TrimSpace(rootPath)
		if cleanRootPath == "" || rootPathByPath[cleanRootPath] {
			continue
		}
		rootPathByPath[cleanRootPath] = true
		rootPaths = append(rootPaths, cleanRootPath)
	}
	return rootPaths
}

type instructionDocument struct {
	Prompt string
	Source agentcontract.InstructionSource
	Error  error
}

func readInstructionDocuments(rootPath string) []instructionDocument {
	documents := []instructionDocument{}
	if identityPath, document, identity, errorValue := readIdentityDocument(rootPath); document != nil {
		if errorValue != nil {
			documents = append(documents, instructionDocument{Prompt: "", Source: instructionSource(identityPath, "", document), Error: errorValue})
		} else {
			documents = append(documents, instructionDocument{Prompt: persona.RenderIdentityInstruction(identity), Source: instructionSource(identityPath, "", document)})
		}
	}
	soulPath := filepath.Join(rootPath, persona.SoulFileName)
	if document, errorValue := persona.ReadSoulDocument(rootPath); errorValue == nil {
		soul, document, isRestored, parseError := persona.ParseWithBackup(persona.ParseSoul, document, persona.BackupPath(rootPath, persona.SoulFileName))
		if isRestored {
			restorePersonaDocument(soulPath, document)
		}
		if parseError != nil {
			documents = append(documents, instructionDocument{Source: instructionSource(soulPath, "", document), Error: parseError})
		} else {
			documents = append(documents, instructionDocument{Prompt: persona.RenderSoulInstruction(soul), Source: instructionSource(soulPath, "", document)})
		}
	}
	return documents
}

func readIdentityDocument(rootPath string) (string, []byte, persona.Identity, error) {
	identityPath := filepath.Join(rootPath, persona.IdentityFileName)
	document, errorValue := os.ReadFile(identityPath)
	if errorValue != nil {
		return identityPath, nil, persona.Identity{}, errorValue
	}
	identity, document, isRestored, errorValue := persona.ParseWithBackup(persona.ParseIdentity, document, persona.BackupPath(rootPath, persona.IdentityFileName))
	if isRestored {
		restorePersonaDocument(identityPath, document)
	}
	return identityPath, document, identity, errorValue
}

func restorePersonaDocument(livePath string, document []byte) {
	if errorValue := os.WriteFile(livePath, document, 0o644); errorValue != nil {
		slog.Warn("persona.document_restore_write_failed", "path", livePath, "error", errorValue.Error())
		return
	}
	slog.Warn("persona.document_restored_from_backup", "path", livePath)
}

func readLegacyInstructionDocument(rootPath string) (string, agentcontract.InstructionSource) {
	for _, fileName := range []string{"AGENTS.md", "CLAUDE.md"} {
		path := filepath.Join(rootPath, fileName)
		document, errorValue := os.ReadFile(path)
		if errorValue == nil && strings.TrimSpace(string(document)) != "" {
			return strings.TrimSpace(string(document)), instructionSource(path, "", document)
		}
	}
	return "", agentcontract.InstructionSource{}
}

type discoveredSkills struct {
	Selectable  []agentcontract.SkillInstruction
	Unavailable []skill.UnavailableSkill
}

func readSkillInstructions(rootPath string, bundledSkillsPath string, offeredToolNames []string) discoveredSkills {
	discovered := discoveredSkills{Selectable: []agentcontract.SkillInstruction{}, Unavailable: []skill.UnavailableSkill{}}
	skillRegistry := skill.NewSkillRegistry()
	for _, skillRoot := range []string{filepath.Join(rootPath, ".agents", "skills"), bundledSkillsPath} {
		discoveredSkillBundles, errorValue := skillRegistry.DiscoverSkill(skillRoot)
		if errorValue != nil {
			continue
		}
		for _, skillBundle := range discoveredSkillBundles {
			documentPath := filepath.Join(skillBundle.DirectoryPath, "SKILL.md")
			document, readError := os.ReadFile(documentPath)
			if readError != nil {
				continue
			}
			missingVariableNames := skillBundle.MissingEnvironmentVariables()
			missingToolNames := skillBundle.MissingToolNames(offeredToolNames)
			missingAnyFilePaths := skillBundle.MissingAnyFilePaths()
			if len(missingVariableNames) > 0 || len(missingToolNames) > 0 || len(missingAnyFilePaths) > 0 {
				discovered.Unavailable = append(discovered.Unavailable, skill.UnavailableSkill{
					Name:                        skillBundle.Name,
					Description:                 skillBundle.Description,
					Path:                        documentPath,
					MissingEnvironmentVariables: missingVariableNames,
					MissingToolNames:            missingToolNames,
					MissingAnyFilePaths:         missingAnyFilePaths,
				})
				continue
			}
			discovered.Selectable = append(discovered.Selectable, agentcontract.SkillInstruction{
				Name:           skillBundle.Name,
				Description:    skillBundle.Description,
				Prompt:         strings.TrimSpace((skill.SkillPromptBuilder{}).BuildSkillPrompt([]skill.SkillBundle{skillBundle})),
				ToolReferences: skillBundle.ReferencedToolNames(),
				Source:         instructionSource(documentPath, skillBundle.Name, document),
			})
		}
	}
	return discovered
}

func loadAgentIdentity(runtimeConfiguration config.RuntimeConfiguration) agentcontract.AgentIdentity {
	for _, rootPath := range instructionRootPaths(runtimeConfiguration) {
		_, document, identity, errorValue := readIdentityDocument(rootPath)
		if document == nil || errorValue != nil {
			continue
		}
		return persona.AgentIdentityOf(identity)
	}
	return agentcontract.AgentIdentity{}
}

func instructionSource(path string, skillName string, document []byte) agentcontract.InstructionSource {
	hash := sha256.Sum256(document)
	return agentcontract.InstructionSource{
		Path:      path,
		SkillName: skillName,
		ByteSize:  len(document),
		SHA256:    hex.EncodeToString(hash[:]),
	}
}

func skillIndexPath(runtimeConfiguration config.RuntimeConfiguration) string {
	workspaceRootPath := firstNonEmptyString(runtimeConfiguration.Terminal.WorkspaceRootPath, "/workspace")
	return filepath.Join(workspaceRootPath, ".blueclaw", "skill-index.json")
}