mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
497 lines
14 KiB
Go
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
|
|
}
|
|
}
|