feat(agent): add structured prompt layering

This commit is contained in:
Hoshina
2026-04-24 18:14:28 +08:00
parent 293477b02a
commit 2e65b1be83
13 changed files with 1184 additions and 74 deletions
+211 -43
View File
@@ -1,6 +1,7 @@
package agent
import (
"context"
"errors"
"fmt"
"io/fs"
@@ -27,6 +28,7 @@ type ContextBuilder struct {
toolDiscoveryBM25 bool
toolDiscoveryRegex bool
splitOnMarker bool
promptRegistry *PromptRegistry
// Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context.
@@ -73,15 +75,30 @@ func NewContextBuilder(workspace string) *ContextBuilder {
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
promptRegistry: NewPromptRegistry(),
}
}
func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error {
return cb.promptRegistryOrDefault().RegisterSource(desc)
}
func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error {
return cb.promptRegistryOrDefault().RegisterContributor(contributor)
}
func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry {
if cb.promptRegistry == nil {
cb.promptRegistry = NewPromptRegistry()
}
return cb.promptRegistry
}
func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
toolDiscovery := cb.getDiscoveryRule()
version := config.FormatVersion()
return fmt.Sprintf(
@@ -103,10 +120,8 @@ Your workspace is at: %s
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
%s`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
}
func (cb *ContextBuilder) getDiscoveryRule() string {
@@ -129,43 +144,116 @@ func (cb *ContextBuilder) getDiscoveryRule() string {
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
parts := []string{}
return renderPromptPartsLegacy(cb.BuildSystemPromptParts())
}
func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
stack := NewPromptStack(cb.promptRegistryOrDefault())
add := func(part PromptPart) {
if err := stack.Add(part); err != nil {
logger.WarnCF("agent", "Skipping invalid prompt part", map[string]any{
"id": part.ID,
"layer": part.Layer,
"slot": part.Slot,
"source": part.Source.ID,
"error": err.Error(),
})
}
}
// Core identity section
parts = append(parts, cb.getIdentity())
add(PromptPart{
ID: "kernel.identity",
Layer: PromptLayerKernel,
Slot: PromptSlotIdentity,
Source: PromptSource{ID: PromptSourceKernel, Name: "identity"},
Title: "picoclaw identity",
Content: cb.getIdentity(),
Stable: true,
Cache: PromptCacheEphemeral,
})
if toolDiscovery := cb.getDiscoveryRule(); toolDiscovery != "" {
add(PromptPart{
ID: "capability.tool_discovery",
Layer: PromptLayerCapability,
Slot: PromptSlotTooling,
Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"},
Title: "tool discovery",
Content: toolDiscovery,
Stable: true,
Cache: PromptCacheEphemeral,
})
}
// Bootstrap files
bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" {
parts = append(parts, bootstrapContent)
add(PromptPart{
ID: "instruction.workspace",
Layer: PromptLayerInstruction,
Slot: PromptSlotWorkspace,
Source: PromptSource{ID: PromptSourceWorkspace, Name: "workspace"},
Title: "workspace instructions",
Content: bootstrapContent,
Stable: true,
Cache: PromptCacheEphemeral,
})
}
// Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills
add(PromptPart{
ID: "capability.skill_catalog",
Layer: PromptLayerCapability,
Slot: PromptSlotSkillCatalog,
Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"},
Title: "skill catalog",
Content: fmt.Sprintf(`# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
%s`, skillsSummary))
%s`, skillsSummary),
Stable: true,
Cache: PromptCacheEphemeral,
})
}
// Memory context
memoryContext := cb.memory.GetMemoryContext()
if memoryContext != "" {
parts = append(parts, "# Memory\n\n"+memoryContext)
add(PromptPart{
ID: "context.memory",
Layer: PromptLayerContext,
Slot: PromptSlotMemory,
Source: PromptSource{ID: PromptSourceMemory, Name: "memory:workspace"},
Title: "memory",
Content: "# Memory\n\n" + memoryContext,
Stable: true,
Cache: PromptCacheEphemeral,
})
}
// Multi-Message Sending (if enabled)
if cb.splitOnMarker {
parts = append(parts, `# MULTI-MESSAGE OUTPUT
add(PromptPart{
ID: "context.output_policy.split_on_marker",
Layer: PromptLayerContext,
Slot: PromptSlotOutput,
Source: PromptSource{ID: PromptSourceOutputPolicy, Name: "split_on_marker"},
Title: "multi-message output policy",
Content: `# MULTI-MESSAGE OUTPUT
You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3
Each part separated by the marker will be sent as an independent message.`)
Each part separated by the marker will be sent as an independent message.`,
Stable: true,
Cache: PromptCacheEphemeral,
})
}
// Join with "---" separator
return strings.Join(parts, "\n\n---\n\n")
stack.Seal()
return stack.Parts()
}
// BuildSystemPromptWithCache returns the cached system prompt if available
@@ -548,6 +636,20 @@ func (cb *ContextBuilder) BuildMessages(
channel, chatID, senderID, senderDisplayName string,
activeSkills ...string,
) []providers.Message {
return cb.BuildMessagesFromPrompt(PromptBuildRequest{
History: history,
Summary: summary,
CurrentMessage: currentMessage,
Media: media,
Channel: channel,
ChatID: chatID,
SenderID: senderID,
SenderDisplayName: senderDisplayName,
ActiveSkills: append([]string(nil), activeSkills...),
})
}
func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message {
messages := []providers.Message{}
// The static part (identity, bootstrap, skills, memory) is cached locally to
@@ -562,7 +664,7 @@ func (cb *ContextBuilder) BuildMessages(
staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request
dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName)
dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName)
// Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can
@@ -573,25 +675,77 @@ func (cb *ContextBuilder) BuildMessages(
// cache-aware adapters (Anthropic) can set per-block cache_control.
// The static block is marked "ephemeral" — its prefix hash is stable
// across requests, enabling LLM-side KV cache reuse.
stringParts := []string{staticPrompt, dynamicCtx}
stringParts := []string{staticPrompt}
contentBlocks := []providers.ContentBlock{
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
{Type: "text", Text: dynamicCtx},
promptContentBlock(PromptPart{
ID: "kernel.static",
Layer: PromptLayerKernel,
Slot: PromptSlotIdentity,
Source: PromptSource{ID: PromptSourceKernel, Name: "static"},
Content: staticPrompt,
}, &providers.CacheControl{Type: "ephemeral"}),
}
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
stringParts = append(stringParts, skillsText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
promptParts := append([]PromptPart(nil), req.Overlays...)
promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...)
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil {
logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{
"error": err.Error(),
})
} else {
promptParts = append(promptParts, contributedParts...)
}
if summary != "" {
summaryText := fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
summary)
stringParts = append(stringParts, summaryText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
if len(promptParts) > 0 {
for _, overlay := range sortPromptParts(promptParts) {
if strings.TrimSpace(overlay.Content) == "" {
continue
}
if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil {
logger.WarnCF("agent", "Skipping invalid prompt overlay", map[string]any{
"id": overlay.ID,
"layer": overlay.Layer,
"slot": overlay.Slot,
"source": overlay.Source.ID,
"error": err.Error(),
})
continue
}
stringParts = append(stringParts, overlay.Content)
contentBlocks = append(contentBlocks, promptContentBlock(overlay, nil))
}
}
runtimePart := PromptPart{
ID: "context.runtime",
Layer: PromptLayerContext,
Slot: PromptSlotRuntime,
Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"},
Title: "runtime context",
Content: dynamicCtx,
Stable: false,
Cache: PromptCacheNone,
}
stringParts = append(stringParts, dynamicCtx)
contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil))
if req.Summary != "" {
summaryPart := PromptPart{
ID: "context.summary",
Layer: PromptLayerContext,
Slot: PromptSlotSummary,
Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"},
Title: "context summary",
Content: fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
req.Summary),
Stable: false,
Cache: PromptCacheNone,
}
stringParts = append(stringParts, summaryPart.Content)
contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil))
}
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
@@ -608,7 +762,8 @@ func (cb *ContextBuilder) BuildMessages(
"static_chars": len(staticPrompt),
"dynamic_chars": len(dynamicCtx),
"total_chars": len(fullSystemPrompt),
"has_summary": summary != "",
"has_summary": req.Summary != "",
"overlays": len(req.Overlays),
"cached": isCached,
})
@@ -619,7 +774,7 @@ func (cb *ContextBuilder) BuildMessages(
"preview": preview,
})
history = sanitizeHistoryForProvider(history)
history := sanitizeHistoryForProvider(req.History)
// Single system message containing all context — compatible with all providers.
// SystemParts enables cache-aware adapters to set per-block cache_control;
@@ -636,15 +791,8 @@ func (cb *ContextBuilder) BuildMessages(
// Add current user message. Media-only turns must still be preserved so
// multimodal providers receive the uploaded image even when the user sends
// no accompanying text.
if strings.TrimSpace(currentMessage) != "" || len(media) > 0 {
msg := providers.Message{
Role: "user",
Content: currentMessage,
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
messages = append(messages, msg)
if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
}
return messages
@@ -870,6 +1018,26 @@ The following skills are active for this request. Follow them when relevant.
%s`, content)
}
func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
skillsText := cb.buildActiveSkillsContext(skillNames)
if strings.TrimSpace(skillsText) == "" {
return nil
}
return []PromptPart{
{
ID: "capability.active_skills",
Layer: PromptLayerCapability,
Slot: PromptSlotActiveSkill,
Source: PromptSource{ID: PromptSourceActiveSkills, Name: "skill:active"},
Title: "active skills",
Content: skillsText,
Stable: false,
Cache: PromptCacheNone,
},
}
}
func (cb *ContextBuilder) ListSkillNames() []string {
if cb.skillsLoader == nil {
return nil
+46
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"reflect"
"sort"
"sync"
"time"
@@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM
switch decision.normalizedAction() {
case HookActionContinue, HookActionModify:
if next != nil {
next = hm.applyBeforeLLMControls(reg.Name, current, next)
current = next
}
case HookActionAbortTurn, HookActionHardAbort:
@@ -367,6 +369,50 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL
return current, HookDecision{Action: HookActionContinue}
}
func (hm *HookManager) applyBeforeLLMControls(
hookName string,
current *LLMHookRequest,
next *LLMHookRequest,
) *LLMHookRequest {
if next == nil || current == nil {
return next
}
if llmHookSystemMessagesUnchanged(current.Messages, next.Messages) {
return next
}
logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{
"hook": hookName,
})
next.Messages = cloneProviderMessages(current.Messages)
return next
}
func llmHookSystemMessagesUnchanged(before, after []providers.Message) bool {
beforeSystem := systemMessageFingerprints(before)
afterSystem := systemMessageFingerprints(after)
return reflect.DeepEqual(beforeSystem, afterSystem)
}
type systemMessageFingerprint struct {
Index int
Message providers.Message
}
func systemMessageFingerprints(messages []providers.Message) []systemMessageFingerprint {
var fingerprints []systemMessageFingerprint
for i, msg := range messages {
if msg.Role != "system" {
continue
}
fingerprints = append(fingerprints, systemMessageFingerprint{
Index: i,
Message: cloneProviderMessages([]providers.Message{msg})[0],
})
}
return fingerprints
}
func (hm *HookManager) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
+95
View File
@@ -149,6 +149,101 @@ func (h *llmObserverHook) AfterLLM(
return next, HookDecision{Action: HookActionModify}, nil
}
type llmSystemRewriteHook struct{}
func (h *llmSystemRewriteHook) BeforeLLM(
ctx context.Context,
req *LLMHookRequest,
) (*LLMHookRequest, HookDecision, error) {
next := req.Clone()
next.Model = "changed-model"
next.Messages[0].Content = "rewritten system"
return next, HookDecision{Action: HookActionModify}, nil
}
func (h *llmSystemRewriteHook) AfterLLM(
ctx context.Context,
resp *LLMHookResponse,
) (*LLMHookResponse, HookDecision, error) {
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
}
type llmUserAppendHook struct{}
func (h *llmUserAppendHook) BeforeLLM(
ctx context.Context,
req *LLMHookRequest,
) (*LLMHookRequest, HookDecision, error) {
next := req.Clone()
next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "extra user context"})
return next, HookDecision{Action: HookActionModify}, nil
}
func (h *llmUserAppendHook) AfterLLM(
ctx context.Context,
resp *LLMHookResponse,
) (*LLMHookResponse, HookDecision, error) {
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
}
func TestHookManager_BeforeLLMControlsSystemPromptMutation(t *testing.T) {
hm := NewHookManager(nil)
if err := hm.Mount(NamedHook("rewrite-system", &llmSystemRewriteHook{})); err != nil {
t.Fatalf("Mount() error = %v", err)
}
req := &LLMHookRequest{
Model: "original-model",
Messages: []providers.Message{
{
Role: "system",
Content: "original system",
SystemParts: []providers.ContentBlock{
{Type: "text", Text: "original system"},
},
},
{Role: "user", Content: "hello"},
},
}
got, decision := hm.BeforeLLM(context.Background(), req)
if decision.normalizedAction() != HookActionContinue {
t.Fatalf("decision = %v, want continue", decision)
}
if got.Model != "changed-model" {
t.Fatalf("model = %q, want changed-model", got.Model)
}
if got.Messages[0].Content != "original system" {
t.Fatalf("system content = %q, want original system", got.Messages[0].Content)
}
if got.Messages[1].Content != "hello" {
t.Fatalf("user content = %q, want hello", got.Messages[1].Content)
}
}
func TestHookManager_BeforeLLMAllowsNonSystemMessageMutation(t *testing.T) {
hm := NewHookManager(nil)
if err := hm.Mount(NamedHook("append-user", &llmUserAppendHook{})); err != nil {
t.Fatalf("Mount() error = %v", err)
}
req := &LLMHookRequest{
Model: "model",
Messages: []providers.Message{
{Role: "system", Content: "system"},
{Role: "user", Content: "hello"},
},
}
got, _ := hm.BeforeLLM(context.Background(), req)
if len(got.Messages) != 3 {
t.Fatalf("messages len = %d, want 3", len(got.Messages))
}
if got.Messages[2].Role != "user" || got.Messages[2].Content != "extra user context" {
t.Fatalf("appended message = %#v, want extra user context", got.Messages[2])
}
}
func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
+2 -2
View File
@@ -260,7 +260,7 @@ toolLoop:
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
msg := subTurnResultPromptMessage(content)
messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
}
@@ -631,7 +631,7 @@ toolLoop:
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
msg := subTurnResultPromptMessage(content)
messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
}
+2 -4
View File
@@ -319,10 +319,8 @@ func (p *Pipeline) CallLLM(
exec.history = asmResp.History
exec.summary = asmResp.Summary
}
exec.messages = ts.agent.ContextBuilder.BuildMessages(
exec.history, exec.summary, "",
nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
)
exec.callMessages = exec.messages
if exec.gracefulTerminal {
+5 -20
View File
@@ -31,16 +31,8 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
}
ts.captureRestorePoint(history, summary)
messages := ts.agent.ContextBuilder.BuildMessages(
history,
summary,
ts.userMessage,
ts.media,
ts.channel,
ts.chatID,
ts.opts.Dispatch.SenderID(),
ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
@@ -69,22 +61,15 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
history = resp.History
summary = resp.Summary
}
messages = ts.agent.ContextBuilder.BuildMessages(
history, summary, ts.userMessage,
ts.media, ts.channel, ts.chatID,
ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
}
}
if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) {
rootMsg := providers.Message{
Role: "user",
Content: ts.userMessage,
Media: append([]string(nil), ts.media...),
}
rootMsg := userPromptMessage(ts.userMessage, ts.media)
if len(rootMsg.Media) > 0 {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg)
} else {
+483
View File
@@ -0,0 +1,483 @@
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"
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: 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")
}
if err := r.RegisterSource(contributor.PromptSource()); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
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
}
}
+207
View File
@@ -0,0 +1,207 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
)
func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) {
registry := NewPromptRegistry()
if err := registry.RegisterSource(PromptSourceDescriptor{
ID: "test:source",
Owner: "test",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
}); err != nil {
t.Fatalf("RegisterSource() error = %v", err)
}
err := registry.ValidatePart(PromptPart{
ID: "wrong.placement",
Layer: PromptLayerContext,
Slot: PromptSlotRuntime,
Source: PromptSource{ID: "test:source"},
Content: "runtime text",
})
if err == nil {
t.Fatal("ValidatePart() error = nil, want placement error")
}
}
func TestPromptRegistry_AllowsUnregisteredSourceInCompatibilityMode(t *testing.T) {
registry := NewPromptRegistry()
err := registry.ValidatePart(PromptPart{
ID: "unregistered.part",
Layer: PromptLayerCapability,
Slot: PromptSlotMCP,
Source: PromptSource{ID: "mcp:dynamic-server"},
Content: "dynamic MCP prompt",
})
if err != nil {
t.Fatalf("ValidatePart() error = %v, want nil for unregistered source", err)
}
}
func TestRenderPromptPartsLegacy_UsesLayerAndSlotOrder(t *testing.T) {
parts := []PromptPart{
{
ID: "context.runtime",
Layer: PromptLayerContext,
Slot: PromptSlotRuntime,
Source: PromptSource{ID: PromptSourceRuntime},
Content: "runtime",
},
{
ID: "kernel.identity",
Layer: PromptLayerKernel,
Slot: PromptSlotIdentity,
Source: PromptSource{ID: PromptSourceKernel},
Content: "kernel",
},
{
ID: "capability.skill",
Layer: PromptLayerCapability,
Slot: PromptSlotActiveSkill,
Source: PromptSource{ID: "skill:test"},
Content: "skill",
},
{
ID: "instruction.workspace",
Layer: PromptLayerInstruction,
Slot: PromptSlotWorkspace,
Source: PromptSource{ID: PromptSourceWorkspace},
Content: "workspace",
},
}
got := renderPromptPartsLegacy(parts)
want := strings.Join([]string{"kernel", "workspace", "skill", "runtime"}, "\n\n---\n\n")
if got != want {
t.Fatalf("renderPromptPartsLegacy() = %q, want %q", got, want)
}
}
func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
CurrentMessage: "do child task",
Overlays: promptOverlaysForOptions(processOptions{
SystemPromptOverride: "Use child-only system instructions.",
}),
})
if len(messages) < 2 {
t.Fatalf("messages len = %d, want at least 2", len(messages))
}
if messages[0].Role != "system" {
t.Fatalf("messages[0].Role = %q, want system", messages[0].Role)
}
if !strings.Contains(messages[0].Content, "Use child-only system instructions.") {
t.Fatalf("system prompt missing overlay: %q", messages[0].Content)
}
if messages[1].Role != "user" || messages[1].Content != "do child task" {
t.Fatalf("messages[1] = %#v, want user task", messages[1])
}
}
func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
CurrentMessage: "hello",
Summary: "prior context",
})
if len(messages) != 2 {
t.Fatalf("messages len = %d, want 2", len(messages))
}
system := messages[0]
if len(system.SystemParts) < 3 {
t.Fatalf("system parts len = %d, want at least 3", len(system.SystemParts))
}
if system.SystemParts[0].PromptLayer != string(PromptLayerKernel) ||
system.SystemParts[0].PromptSlot != string(PromptSlotIdentity) ||
system.SystemParts[0].PromptSource != string(PromptSourceKernel) {
t.Fatalf("static system metadata = %#v, want kernel identity", system.SystemParts[0])
}
var hasRuntime, hasSummary bool
for _, part := range system.SystemParts {
switch part.PromptSource {
case string(PromptSourceRuntime):
hasRuntime = true
case string(PromptSourceSummary):
hasSummary = true
}
}
if !hasRuntime {
t.Fatal("system parts missing runtime prompt metadata")
}
if !hasSummary {
t.Fatal("system parts missing summary prompt metadata")
}
user := messages[1]
if user.PromptLayer != string(PromptLayerTurn) ||
user.PromptSlot != string(PromptSlotMessage) ||
user.PromptSource != string(PromptSourceUserMessage) {
t.Fatalf("user message metadata = %#v, want turn message", user)
}
data, err := json.Marshal(messages)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if strings.Contains(string(data), "PromptSource") ||
strings.Contains(string(data), "PromptLayer") ||
strings.Contains(string(data), "PromptSlot") {
t.Fatalf("internal prompt metadata leaked into JSON: %s", data)
}
}
type testPromptContributor struct {
desc PromptSourceDescriptor
part PromptPart
}
func (c testPromptContributor) PromptSource() PromptSourceDescriptor {
return c.desc
}
func (c testPromptContributor) ContributePrompt(_ context.Context, _ PromptBuildRequest) ([]PromptPart, error) {
return []PromptPart{c.part}, nil
}
func TestContextBuilder_CollectsRegisteredPromptContributors(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
sourceID := PromptSourceID("test:contributor")
err := cb.RegisterPromptContributor(testPromptContributor{
desc: PromptSourceDescriptor{
ID: sourceID,
Owner: "test",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}},
},
part: PromptPart{
ID: "capability.mcp.test",
Layer: PromptLayerCapability,
Slot: PromptSlotMCP,
Source: PromptSource{ID: sourceID, Name: "test"},
Content: "registered contributor prompt",
},
})
if err != nil {
t.Fatalf("RegisterPromptContributor() error = %v", err)
}
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
if !strings.Contains(messages[0].Content, "registered contributor prompt") {
t.Fatalf("system prompt missing contributor content: %q", messages[0].Content)
}
}
+117
View File
@@ -0,0 +1,117 @@
package agent
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
)
func promptBuildRequestForTurn(
ts *turnState,
history []providers.Message,
summary string,
currentMessage string,
media []string,
) PromptBuildRequest {
return PromptBuildRequest{
History: history,
Summary: summary,
CurrentMessage: currentMessage,
Media: append([]string(nil), media...),
Channel: ts.channel,
ChatID: ts.chatID,
SenderID: ts.opts.Dispatch.SenderID(),
SenderDisplayName: ts.opts.SenderDisplayName,
ActiveSkills: activeSkillNames(ts.agent, ts.opts),
Overlays: promptOverlaysForOptions(ts.opts),
}
}
func promptOverlaysForOptions(opts processOptions) []PromptPart {
systemPrompt := strings.TrimSpace(opts.SystemPromptOverride)
if systemPrompt == "" {
return nil
}
return []PromptPart{
{
ID: "instruction.subturn_profile",
Layer: PromptLayerInstruction,
Slot: PromptSlotWorkspace,
Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"},
Title: "SubTurn System Instructions",
Content: systemPrompt,
Stable: false,
Cache: PromptCacheNone,
},
}
}
func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock {
return providers.ContentBlock{
Type: "text",
Text: part.Content,
CacheControl: cache,
PromptLayer: string(part.Layer),
PromptSlot: string(part.Slot),
PromptSource: string(part.Source.ID),
}
}
func promptMessageWithMetadata(
msg providers.Message,
layer PromptLayer,
slot PromptSlot,
source PromptSourceID,
) providers.Message {
msg.PromptLayer = string(layer)
msg.PromptSlot = string(slot)
msg.PromptSource = string(source)
return msg
}
func promptMessageWithDefaultMetadata(
msg providers.Message,
layer PromptLayer,
slot PromptSlot,
source PromptSourceID,
) providers.Message {
if strings.TrimSpace(msg.PromptSource) != "" {
return msg
}
return promptMessageWithMetadata(msg, layer, slot, source)
}
func userPromptMessage(content string, media []string) providers.Message {
msg := providers.Message{
Role: "user",
Content: content,
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
}
func steeringPromptMessage(msg providers.Message) providers.Message {
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
}
func subTurnResultPromptMessage(content string) providers.Message {
return promptMessageWithMetadata(
providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)},
PromptLayerTurn,
PromptSlotSubTurn,
PromptSourceSubTurnResult,
)
}
func interruptPromptMessage(content string) providers.Message {
return promptMessageWithMetadata(
providers.Message{Role: "user", Content: content},
PromptLayerTurn,
PromptSlotInterrupt,
PromptSourceInterrupt,
)
}
+1
View File
@@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers
return fmt.Errorf("steering queue is not initialized")
}
msg = steeringPromptMessage(msg)
if err := al.steering.pushScope(scope, msg); err != nil {
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
"error": err.Error(),
+1 -1
View File
@@ -111,7 +111,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
msg := subTurnResultPromptMessage(content)
pendingMessages = append(pendingMessages, msg)
}
default:
+1 -4
View File
@@ -527,10 +527,7 @@ func (ts *turnState) interruptHintMessage() providers.Message {
if hint != "" {
content += "\n\nInterrupt hint: " + hint
}
return providers.Message{
Role: "user",
Content: content,
}
return interruptPromptMessage(content)
}
// =============================================================================
+13
View File
@@ -61,6 +61,13 @@ type ContentBlock struct {
Type string `json:"type"` // "text"
Text string `json:"text"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
// Prompt metadata is internal to the agent runtime. It records which
// structured prompt segment produced this block without changing provider
// JSON.
PromptLayer string `json:"-"`
PromptSlot string `json:"-"`
PromptSource string `json:"-"`
}
type Attachment struct {
@@ -80,6 +87,12 @@ type Message struct {
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
// Prompt metadata is internal to the agent runtime. It records where a
// message or system part came from without changing provider/session JSON.
PromptLayer string `json:"-"`
PromptSlot string `json:"-"`
PromptSource string `json:"-"`
}
type ToolDefinition struct {