Files
picoclaw/pkg/agent/prompt.go
T

497 lines
14 KiB
Go

package agent
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
type PromptLayer string
const (
PromptLayerKernel PromptLayer = "kernel"
PromptLayerInstruction PromptLayer = "instruction"
PromptLayerCapability PromptLayer = "capability"
PromptLayerContext PromptLayer = "context"
PromptLayerTurn PromptLayer = "turn"
)
type PromptSlot string
const (
PromptSlotIdentity PromptSlot = "identity"
PromptSlotHierarchy PromptSlot = "hierarchy"
PromptSlotWorkspace PromptSlot = "workspace"
PromptSlotTooling PromptSlot = "tooling"
PromptSlotMCP PromptSlot = "mcp"
PromptSlotSkillCatalog PromptSlot = "skill_catalog"
PromptSlotActiveSkill PromptSlot = "active_skill"
PromptSlotMemory PromptSlot = "memory"
PromptSlotRuntime PromptSlot = "runtime"
PromptSlotSummary PromptSlot = "summary"
PromptSlotMessage PromptSlot = "message"
PromptSlotSteering PromptSlot = "steering"
PromptSlotSubTurn PromptSlot = "subturn"
PromptSlotInterrupt PromptSlot = "interrupt"
PromptSlotOutput PromptSlot = "output"
)
type PromptSourceID string
const (
PromptSourceKernel PromptSourceID = "runtime.kernel"
PromptSourceHierarchy PromptSourceID = "runtime.hierarchy"
PromptSourceWorkspace PromptSourceID = "workspace.definition"
PromptSourceRuntime PromptSourceID = "runtime.context"
PromptSourceSummary PromptSourceID = "context.summary"
PromptSourceMemory PromptSourceID = "memory:workspace"
PromptSourceSkillCatalog PromptSourceID = "skill:index"
PromptSourceActiveSkills PromptSourceID = "skill:active"
PromptSourceToolRegistry PromptSourceID = "tool_registry:native"
PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery"
PromptSourceOutputPolicy PromptSourceID = "runtime.output"
PromptSourceSubTurnProfile PromptSourceID = "subturn.profile"
PromptSourceUserMessage PromptSourceID = "turn:user_message"
PromptSourceSteering PromptSourceID = "turn:steering"
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
)
type PromptCachePolicy string
const (
PromptCacheDefault PromptCachePolicy = ""
PromptCacheEphemeral PromptCachePolicy = "ephemeral"
PromptCacheNone PromptCachePolicy = "none"
)
type PromptPlacement struct {
Layer PromptLayer
Slot PromptSlot
}
type PromptSourceDescriptor struct {
ID PromptSourceID
Owner string
Description string
Allowed []PromptPlacement
StableByDefault bool
}
type PromptSource struct {
ID PromptSourceID
Name string
Path string
}
type PromptPart struct {
ID string
Layer PromptLayer
Slot PromptSlot
Source PromptSource
Title string
Content string
Stable bool
Cache PromptCachePolicy
}
type PromptBuildRequest struct {
History []providers.Message
Summary string
CurrentMessage string
Media []string
Channel string
ChatID string
SenderID string
SenderDisplayName string
ActiveSkills []string
Overlays []PromptPart
}
type PromptContributor interface {
PromptSource() PromptSourceDescriptor
ContributePrompt(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error)
}
type PromptRegistry struct {
mu sync.RWMutex
sources map[PromptSourceID]PromptSourceDescriptor
contributors []PromptContributor
warned map[PromptSourceID]struct{}
}
func NewPromptRegistry() *PromptRegistry {
r := &PromptRegistry{
sources: make(map[PromptSourceID]PromptSourceDescriptor),
warned: make(map[PromptSourceID]struct{}),
}
for _, desc := range builtinPromptSources() {
if err := r.RegisterSource(desc); err != nil {
logger.WarnCF("agent", "Failed to register builtin prompt source", map[string]any{
"source": desc.ID,
"error": err.Error(),
})
}
}
return r
}
func builtinPromptSources() []PromptSourceDescriptor {
return []PromptSourceDescriptor{
{
ID: PromptSourceKernel,
Owner: "agent",
Description: "Core picoclaw identity and hard rules",
Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotIdentity}},
StableByDefault: true,
},
{
ID: PromptSourceHierarchy,
Owner: "agent",
Description: "Prompt hierarchy rules",
Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotHierarchy}},
StableByDefault: true,
},
{
ID: PromptSourceWorkspace,
Owner: "workspace",
Description: "Workspace and agent definition files",
Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}},
StableByDefault: true,
},
{
ID: PromptSourceToolDiscovery,
Owner: "tools",
Description: "Tool discovery instructions",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: true,
},
{
ID: PromptSourceToolRegistry,
Owner: "tools",
Description: "Native provider tool definitions",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: true,
},
{
ID: PromptSourceSkillCatalog,
Owner: "skills",
Description: "Installed skill catalog",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotSkillCatalog}},
StableByDefault: true,
},
{
ID: PromptSourceActiveSkills,
Owner: "skills",
Description: "Active skill instructions for the current request",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}},
StableByDefault: false,
},
{
ID: PromptSourceMemory,
Owner: "memory",
Description: "Workspace memory context",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotMemory}},
StableByDefault: true,
},
{
ID: PromptSourceRuntime,
Owner: "agent",
Description: "Per-request runtime context",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotRuntime}},
StableByDefault: false,
},
{
ID: PromptSourceSummary,
Owner: "context_manager",
Description: "Conversation summary context",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotSummary}},
StableByDefault: false,
},
{
ID: PromptSourceOutputPolicy,
Owner: "agent",
Description: "Output formatting policy",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotOutput}},
StableByDefault: true,
},
{
ID: PromptSourceSubTurnProfile,
Owner: "subturn",
Description: "Child agent profile instructions",
Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}},
StableByDefault: false,
},
{
ID: PromptSourceUserMessage,
Owner: "turn",
Description: "Current user message for this turn",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotMessage}},
StableByDefault: false,
},
{
ID: PromptSourceSteering,
Owner: "turn",
Description: "Steering message injected into a running turn",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSteering}},
StableByDefault: false,
},
{
ID: PromptSourceSubTurnResult,
Owner: "turn",
Description: "SubTurn result injected into a parent turn",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSubTurn}},
StableByDefault: false,
},
{
ID: PromptSourceInterrupt,
Owner: "turn",
Description: "Graceful interrupt hint injected into the terminal LLM call",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotInterrupt}},
StableByDefault: false,
},
}
}
func (r *PromptRegistry) RegisterSource(desc PromptSourceDescriptor) error {
if r == nil {
return fmt.Errorf("prompt registry is nil")
}
desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID)))
if desc.ID == "" {
return fmt.Errorf("prompt source id is required")
}
if len(desc.Allowed) == 0 {
return fmt.Errorf("prompt source %q must declare at least one placement", desc.ID)
}
r.mu.Lock()
defer r.mu.Unlock()
r.sources[desc.ID] = clonePromptSourceDescriptor(desc)
return nil
}
func (r *PromptRegistry) RegisterContributor(contributor PromptContributor) error {
if r == nil {
return fmt.Errorf("prompt registry is nil")
}
if contributor == nil {
return fmt.Errorf("prompt contributor is nil")
}
desc := contributor.PromptSource()
desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID)))
if err := r.RegisterSource(desc); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
r.contributors = slices.DeleteFunc(r.contributors, func(existing PromptContributor) bool {
return PromptSourceID(strings.TrimSpace(string(existing.PromptSource().ID))) == desc.ID
})
r.contributors = append(r.contributors, contributor)
return nil
}
func (r *PromptRegistry) Collect(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) {
if r == nil {
return nil, nil
}
r.mu.RLock()
contributors := append([]PromptContributor(nil), r.contributors...)
r.mu.RUnlock()
var parts []PromptPart
for _, contributor := range contributors {
contributed, err := contributor.ContributePrompt(ctx, req)
if err != nil {
return nil, err
}
for _, part := range contributed {
if err := r.ValidatePart(part); err != nil {
return nil, err
}
parts = append(parts, part)
}
}
return parts, nil
}
func (r *PromptRegistry) ValidatePart(part PromptPart) error {
if r == nil {
return nil
}
sourceID := PromptSourceID(strings.TrimSpace(string(part.Source.ID)))
if sourceID == "" {
return fmt.Errorf("prompt part %q has empty source id", part.ID)
}
r.mu.Lock()
defer r.mu.Unlock()
desc, ok := r.sources[sourceID]
if !ok {
if _, warned := r.warned[sourceID]; !warned {
r.warned[sourceID] = struct{}{}
logger.WarnCF("agent", "Unregistered prompt source allowed in compatibility mode", map[string]any{
"source": sourceID,
"layer": part.Layer,
"slot": part.Slot,
"part": part.ID,
})
}
return nil
}
if promptPlacementAllowed(desc.Allowed, PromptPlacement{Layer: part.Layer, Slot: part.Slot}) {
return nil
}
return fmt.Errorf("prompt source %q cannot write to %s/%s", sourceID, part.Layer, part.Slot)
}
func promptPlacementAllowed(allowed []PromptPlacement, placement PromptPlacement) bool {
return slices.ContainsFunc(allowed, func(candidate PromptPlacement) bool {
return candidate.Layer == placement.Layer && candidate.Slot == placement.Slot
})
}
func clonePromptSourceDescriptor(desc PromptSourceDescriptor) PromptSourceDescriptor {
desc.Allowed = append([]PromptPlacement(nil), desc.Allowed...)
return desc
}
type PromptStack struct {
registry *PromptRegistry
parts []PromptPart
sealed bool
}
func NewPromptStack(registry *PromptRegistry) *PromptStack {
return &PromptStack{registry: registry}
}
func (s *PromptStack) Add(part PromptPart) error {
if s == nil {
return fmt.Errorf("prompt stack is nil")
}
if s.sealed {
return fmt.Errorf("prompt stack is sealed")
}
if strings.TrimSpace(part.Content) == "" {
return nil
}
if strings.TrimSpace(part.ID) == "" {
return fmt.Errorf("prompt part id is required")
}
if s.registry != nil {
if err := s.registry.ValidatePart(part); err != nil {
return err
}
}
s.parts = append(s.parts, part)
return nil
}
func (s *PromptStack) Seal() {
if s != nil {
s.sealed = true
}
}
func (s *PromptStack) Parts() []PromptPart {
if s == nil || len(s.parts) == 0 {
return nil
}
return append([]PromptPart(nil), s.parts...)
}
func renderPromptPartsLegacy(parts []PromptPart) string {
textParts := make([]string, 0, len(parts))
for _, part := range sortPromptParts(parts) {
if strings.TrimSpace(part.Content) == "" {
continue
}
textParts = append(textParts, part.Content)
}
return strings.Join(textParts, "\n\n---\n\n")
}
func sortPromptParts(parts []PromptPart) []PromptPart {
sorted := append([]PromptPart(nil), parts...)
slices.SortStableFunc(sorted, func(a, b PromptPart) int {
if d := layerPriority(b.Layer) - layerPriority(a.Layer); d != 0 {
return d
}
if d := slotPriority(b.Slot) - slotPriority(a.Slot); d != 0 {
return d
}
if a.Source.ID != b.Source.ID {
return strings.Compare(string(a.Source.ID), string(b.Source.ID))
}
return strings.Compare(a.ID, b.ID)
})
return sorted
}
func layerPriority(layer PromptLayer) int {
switch layer {
case PromptLayerKernel:
return 100
case PromptLayerInstruction:
return 80
case PromptLayerCapability:
return 60
case PromptLayerContext:
return 40
case PromptLayerTurn:
return 20
default:
return 0
}
}
func slotPriority(slot PromptSlot) int {
switch slot {
case PromptSlotIdentity:
return 1000
case PromptSlotHierarchy:
return 990
case PromptSlotWorkspace:
return 900
case PromptSlotTooling:
return 800
case PromptSlotMCP:
return 790
case PromptSlotSkillCatalog:
return 780
case PromptSlotActiveSkill:
return 770
case PromptSlotMemory:
return 700
case PromptSlotOutput:
return 695
case PromptSlotRuntime:
return 690
case PromptSlotSummary:
return 680
case PromptSlotMessage:
return 600
case PromptSlotSteering:
return 590
case PromptSlotSubTurn:
return 580
case PromptSlotInterrupt:
return 570
default:
return 0
}
}