Merge pull request #2656 from alexhoshina/prompt-layering
Prompt layering
This commit is contained in:
commit
caaad601af
22 changed files with 1813 additions and 86 deletions
|
|
@ -135,6 +135,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
|
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
|
||||||
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
|
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
|
||||||
|
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok || agent.ContextBuilder == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
|
||||||
|
serverName: serverName,
|
||||||
|
toolCount: len(conn.Tools),
|
||||||
|
deferred: registerAsHidden,
|
||||||
|
}); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to register MCP prompt contributor",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agentID,
|
||||||
|
"server": serverName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, tool := range conn.Tools {
|
for _, tool := range conn.Tools {
|
||||||
for _, agentID := range agentIDs {
|
for _, agentID := range agentIDs {
|
||||||
agent, ok := al.registry.GetAgent(agentID)
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
@ -21,12 +22,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
toolDiscoveryBM25 bool
|
splitOnMarker bool
|
||||||
toolDiscoveryRegex bool
|
promptRegistry *PromptRegistry
|
||||||
splitOnMarker bool
|
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||||
|
|
@ -48,8 +48,16 @@ type ContextBuilder struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
||||||
cb.toolDiscoveryBM25 = useBM25
|
if useBM25 || useRegex {
|
||||||
cb.toolDiscoveryRegex = useRegex
|
if err := cb.RegisterPromptContributor(toolDiscoveryPromptContributor{
|
||||||
|
useBM25: useBM25,
|
||||||
|
useRegex: useRegex,
|
||||||
|
}); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,15 +81,38 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||||
|
|
||||||
return &ContextBuilder{
|
return &ContextBuilder{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||||
memory: NewMemoryStore(workspace),
|
memory: NewMemoryStore(workspace),
|
||||||
|
promptRegistry: NewPromptRegistry(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error {
|
||||||
|
err := cb.promptRegistryOrDefault().RegisterSource(desc)
|
||||||
|
if err == nil {
|
||||||
|
cb.InvalidateCache()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error {
|
||||||
|
err := cb.promptRegistryOrDefault().RegisterContributor(contributor)
|
||||||
|
if err == nil {
|
||||||
|
cb.InvalidateCache()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry {
|
||||||
|
if cb.promptRegistry == nil {
|
||||||
|
cb.promptRegistry = NewPromptRegistry()
|
||||||
|
}
|
||||||
|
return cb.promptRegistry
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
toolDiscovery := cb.getDiscoveryRule()
|
|
||||||
version := config.FormatVersion()
|
version := config.FormatVersion()
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
|
|
@ -103,22 +134,20 @@ Your workspace is at: %s
|
||||||
|
|
||||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
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.
|
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)
|
||||||
%s`,
|
|
||||||
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getDiscoveryRule() string {
|
func formatToolDiscoveryRule(useBM25, useRegex bool) string {
|
||||||
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
|
if !useBM25 && !useRegex {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolNames []string
|
var toolNames []string
|
||||||
if cb.toolDiscoveryBM25 {
|
if useBM25 {
|
||||||
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
|
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
|
||||||
}
|
}
|
||||||
if cb.toolDiscoveryRegex {
|
if useRegex {
|
||||||
toolNames = append(toolNames, `"tool_search_tool_regex"`)
|
toolNames = append(toolNames, `"tool_search_tool_regex"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,43 +158,103 @@ func (cb *ContextBuilder) getDiscoveryRule() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildSystemPrompt() 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
|
// 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,
|
||||||
|
})
|
||||||
|
|
||||||
// Bootstrap files
|
// Bootstrap files
|
||||||
bootstrapContent := cb.LoadBootstrapFiles()
|
bootstrapContent := cb.LoadBootstrapFiles()
|
||||||
if bootstrapContent != "" {
|
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
|
// Skills - show summary, AI can read full content with read_file tool
|
||||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||||
if skillsSummary != "" {
|
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.
|
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
|
// Memory context
|
||||||
memoryContext := cb.memory.GetMemoryContext()
|
memoryContext := cb.memory.GetMemoryContext()
|
||||||
if memoryContext != "" {
|
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)
|
// Multi-Message Sending (if enabled)
|
||||||
if cb.splitOnMarker {
|
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
|
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
|
stack.Seal()
|
||||||
return strings.Join(parts, "\n\n---\n\n")
|
return stack.Parts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSystemPromptWithCache returns the cached system prompt if available
|
// BuildSystemPromptWithCache returns the cached system prompt if available
|
||||||
|
|
@ -230,6 +319,19 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st
|
||||||
totalChars += 7 // separator \n\n---\n\n
|
totalChars += 7 // separator \n\n---\n\n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), PromptBuildRequest{
|
||||||
|
Summary: summary,
|
||||||
|
ActiveSkills: append([]string(nil), activeSkills...),
|
||||||
|
}); err == nil {
|
||||||
|
for _, part := range contributedParts {
|
||||||
|
if strings.TrimSpace(part.Content) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalChars += utf8.RuneCountInString(part.Content)
|
||||||
|
totalChars += 7 // separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if summary != "" {
|
if summary != "" {
|
||||||
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
|
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
|
||||||
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
|
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
|
||||||
|
|
@ -548,6 +650,20 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
channel, chatID, senderID, senderDisplayName string,
|
channel, chatID, senderID, senderDisplayName string,
|
||||||
activeSkills ...string,
|
activeSkills ...string,
|
||||||
) []providers.Message {
|
) []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{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
// The static part (identity, bootstrap, skills, memory) is cached locally to
|
// The static part (identity, bootstrap, skills, memory) is cached locally to
|
||||||
|
|
@ -562,7 +678,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
// Build short dynamic context (time, runtime, session) — changes per request
|
// 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.
|
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||||
// Keeping all system content in one message ensures every provider adapter can
|
// Keeping all system content in one message ensures every provider adapter can
|
||||||
|
|
@ -573,25 +689,77 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
||||||
// The static block is marked "ephemeral" — its prefix hash is stable
|
// The static block is marked "ephemeral" — its prefix hash is stable
|
||||||
// across requests, enabling LLM-side KV cache reuse.
|
// across requests, enabling LLM-side KV cache reuse.
|
||||||
stringParts := []string{staticPrompt, dynamicCtx}
|
stringParts := []string{staticPrompt}
|
||||||
|
|
||||||
contentBlocks := []providers.ContentBlock{
|
contentBlocks := []providers.ContentBlock{
|
||||||
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
|
promptContentBlock(PromptPart{
|
||||||
{Type: "text", Text: dynamicCtx},
|
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 != "" {
|
promptParts := append([]PromptPart(nil), req.Overlays...)
|
||||||
stringParts = append(stringParts, skillsText)
|
promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...)
|
||||||
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
|
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 != "" {
|
if len(promptParts) > 0 {
|
||||||
summaryText := fmt.Sprintf(
|
for _, overlay := range sortPromptParts(promptParts) {
|
||||||
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
if strings.TrimSpace(overlay.Content) == "" {
|
||||||
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
|
continue
|
||||||
summary)
|
}
|
||||||
stringParts = append(stringParts, summaryText)
|
if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil {
|
||||||
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
|
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")
|
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||||
|
|
@ -608,7 +776,8 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
"static_chars": len(staticPrompt),
|
"static_chars": len(staticPrompt),
|
||||||
"dynamic_chars": len(dynamicCtx),
|
"dynamic_chars": len(dynamicCtx),
|
||||||
"total_chars": len(fullSystemPrompt),
|
"total_chars": len(fullSystemPrompt),
|
||||||
"has_summary": summary != "",
|
"has_summary": req.Summary != "",
|
||||||
|
"overlays": len(req.Overlays),
|
||||||
"cached": isCached,
|
"cached": isCached,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -619,7 +788,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
})
|
})
|
||||||
|
|
||||||
history = sanitizeHistoryForProvider(history)
|
history := sanitizeHistoryForProvider(req.History)
|
||||||
|
|
||||||
// Single system message containing all context — compatible with all providers.
|
// Single system message containing all context — compatible with all providers.
|
||||||
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
||||||
|
|
@ -636,15 +805,8 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
// Add current user message. Media-only turns must still be preserved so
|
// Add current user message. Media-only turns must still be preserved so
|
||||||
// multimodal providers receive the uploaded image even when the user sends
|
// multimodal providers receive the uploaded image even when the user sends
|
||||||
// no accompanying text.
|
// no accompanying text.
|
||||||
if strings.TrimSpace(currentMessage) != "" || len(media) > 0 {
|
if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
|
||||||
msg := providers.Message{
|
messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
|
||||||
Role: "user",
|
|
||||||
Content: currentMessage,
|
|
||||||
}
|
|
||||||
if len(media) > 0 {
|
|
||||||
msg.Media = append([]string(nil), media...)
|
|
||||||
}
|
|
||||||
messages = append(messages, msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
@ -870,6 +1032,26 @@ The following skills are active for this request. Follow them when relevant.
|
||||||
%s`, content)
|
%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 {
|
func (cb *ContextBuilder) ListSkillNames() []string {
|
||||||
if cb.skillsLoader == nil {
|
if cb.skillsLoader == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM
|
||||||
switch decision.normalizedAction() {
|
switch decision.normalizedAction() {
|
||||||
case HookActionContinue, HookActionModify:
|
case HookActionContinue, HookActionModify:
|
||||||
if next != nil {
|
if next != nil {
|
||||||
|
next = hm.applyBeforeLLMControls(reg.Name, current, next)
|
||||||
current = next
|
current = next
|
||||||
}
|
}
|
||||||
case HookActionAbortTurn, HookActionHardAbort:
|
case HookActionAbortTurn, HookActionHardAbort:
|
||||||
|
|
@ -367,6 +369,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL
|
||||||
return current, HookDecision{Action: HookActionContinue}
|
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) {
|
||||||
|
logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{
|
||||||
|
"hook": hookName,
|
||||||
|
})
|
||||||
|
next.Messages = cloneProviderMessages(current.Messages)
|
||||||
|
}
|
||||||
|
if !llmHookToolDefinitionsUnchanged(current.Tools, next.Tools) {
|
||||||
|
logger.WarnCF("hooks", "Hook attempted to modify tool definitions; preserving original tools", map[string]any{
|
||||||
|
"hook": hookName,
|
||||||
|
})
|
||||||
|
next.Tools = cloneToolDefinitions(current.Tools)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
msg = providerVisibleMessage(msg)
|
||||||
|
fingerprints = append(fingerprints, systemMessageFingerprint{
|
||||||
|
Index: i,
|
||||||
|
Message: cloneProviderMessages([]providers.Message{msg})[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func llmHookToolDefinitionsUnchanged(before, after []providers.ToolDefinition) bool {
|
||||||
|
return reflect.DeepEqual(providerVisibleToolDefinitions(before), providerVisibleToolDefinitions(after))
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerVisibleMessage(msg providers.Message) providers.Message {
|
||||||
|
msg.PromptLayer = ""
|
||||||
|
msg.PromptSlot = ""
|
||||||
|
msg.PromptSource = ""
|
||||||
|
if len(msg.SystemParts) > 0 {
|
||||||
|
msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
|
||||||
|
for i := range msg.SystemParts {
|
||||||
|
msg.SystemParts[i].PromptLayer = ""
|
||||||
|
msg.SystemParts[i].PromptSlot = ""
|
||||||
|
msg.SystemParts[i].PromptSource = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerVisibleToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition {
|
||||||
|
cloned := cloneToolDefinitions(defs)
|
||||||
|
for i := range cloned {
|
||||||
|
cloned[i].PromptLayer = ""
|
||||||
|
cloned[i].PromptSlot = ""
|
||||||
|
cloned[i].PromptSource = ""
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func (hm *HookManager) BeforeTool(
|
func (hm *HookManager) BeforeTool(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
call *ToolCallHookRequest,
|
call *ToolCallHookRequest,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -149,6 +150,268 @@ func (h *llmObserverHook) AfterLLM(
|
||||||
return next, HookDecision{Action: HookActionModify}, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type llmJSONRoundTripUserAppendHook struct{}
|
||||||
|
|
||||||
|
type jsonRoundTripLLMHookRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []providers.Message `json:"messages,omitempty"`
|
||||||
|
Tools []providers.ToolDefinition `json:"tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *llmJSONRoundTripUserAppendHook) BeforeLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
req *LLMHookRequest,
|
||||||
|
) (*LLMHookRequest, HookDecision, error) {
|
||||||
|
payload := jsonRoundTripLLMHookRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
Messages: req.Messages,
|
||||||
|
Tools: req.Tools,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, HookDecision{}, err
|
||||||
|
}
|
||||||
|
var decoded jsonRoundTripLLMHookRequest
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return nil, HookDecision{}, err
|
||||||
|
}
|
||||||
|
next := req.Clone()
|
||||||
|
next.Model = decoded.Model
|
||||||
|
next.Messages = decoded.Messages
|
||||||
|
next.Tools = decoded.Tools
|
||||||
|
next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "json extra user context"})
|
||||||
|
return next, HookDecision{Action: HookActionModify}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *llmJSONRoundTripUserAppendHook) AfterLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
resp *LLMHookResponse,
|
||||||
|
) (*LLMHookResponse, HookDecision, error) {
|
||||||
|
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type llmToolRewriteHook struct{}
|
||||||
|
|
||||||
|
func (h *llmToolRewriteHook) BeforeLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
req *LLMHookRequest,
|
||||||
|
) (*LLMHookRequest, HookDecision, error) {
|
||||||
|
next := req.Clone()
|
||||||
|
next.Model = "changed-model"
|
||||||
|
next.Tools[0].Function.Description = "rewritten tool"
|
||||||
|
next.Tools = append(next.Tools, providers.ToolDefinition{
|
||||||
|
Type: "function",
|
||||||
|
Function: providers.ToolFunctionDefinition{
|
||||||
|
Name: "hook_tool",
|
||||||
|
Description: "hook tool",
|
||||||
|
Parameters: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
PromptLayer: string(PromptLayerCapability),
|
||||||
|
PromptSlot: string(PromptSlotTooling),
|
||||||
|
PromptSource: "hook:test",
|
||||||
|
})
|
||||||
|
return next, HookDecision{Action: HookActionModify}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *llmToolRewriteHook) 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 TestHookManager_BeforeLLMAllowsJSONRoundTripNonSystemMessageMutation(t *testing.T) {
|
||||||
|
hm := NewHookManager(nil)
|
||||||
|
if err := hm.Mount(NamedHook("json-append-user", &llmJSONRoundTripUserAppendHook{})); err != nil {
|
||||||
|
t.Fatalf("Mount() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &LLMHookRequest{
|
||||||
|
Model: "model",
|
||||||
|
Messages: []providers.Message{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "system",
|
||||||
|
PromptLayer: string(PromptLayerKernel),
|
||||||
|
PromptSlot: string(PromptSlotIdentity),
|
||||||
|
PromptSource: string(PromptSourceKernel),
|
||||||
|
SystemParts: []providers.ContentBlock{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: "system",
|
||||||
|
CacheControl: &providers.CacheControl{Type: "ephemeral"},
|
||||||
|
PromptLayer: string(PromptLayerKernel),
|
||||||
|
PromptSlot: string(PromptSlotIdentity),
|
||||||
|
PromptSource: string(PromptSourceKernel),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
Tools: []providers.ToolDefinition{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: providers.ToolFunctionDefinition{
|
||||||
|
Name: "mcp_github_create_issue",
|
||||||
|
Description: "create issue",
|
||||||
|
Parameters: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
PromptLayer: string(PromptLayerCapability),
|
||||||
|
PromptSlot: string(PromptSlotMCP),
|
||||||
|
PromptSource: "mcp:github",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != "json extra user context" {
|
||||||
|
t.Fatalf("appended message = %#v, want json extra user context", got.Messages[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHookManager_BeforeLLMControlsToolDefinitionMutation(t *testing.T) {
|
||||||
|
hm := NewHookManager(nil)
|
||||||
|
if err := hm.Mount(NamedHook("rewrite-tool", &llmToolRewriteHook{})); err != nil {
|
||||||
|
t.Fatalf("Mount() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &LLMHookRequest{
|
||||||
|
Model: "original-model",
|
||||||
|
Messages: []providers.Message{
|
||||||
|
{Role: "system", Content: "system"},
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
Tools: []providers.ToolDefinition{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: providers.ToolFunctionDefinition{
|
||||||
|
Name: "mcp_github_create_issue",
|
||||||
|
Description: "create issue",
|
||||||
|
Parameters: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
PromptLayer: string(PromptLayerCapability),
|
||||||
|
PromptSlot: string(PromptSlotMCP),
|
||||||
|
PromptSource: "mcp:github",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 len(got.Tools) != 1 {
|
||||||
|
t.Fatalf("tools len = %d, want original 1", len(got.Tools))
|
||||||
|
}
|
||||||
|
if got.Tools[0].Function.Description != "create issue" {
|
||||||
|
t.Fatalf("tool description = %q, want original", got.Tools[0].Function.Description)
|
||||||
|
}
|
||||||
|
if got.Tools[0].PromptSource != "mcp:github" || got.Tools[0].PromptSlot != string(PromptSlotMCP) {
|
||||||
|
t.Fatalf("tool prompt metadata = %#v, want original mcp metadata", got.Tools[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
||||||
provider := &llmHookTestProvider{}
|
provider := &llmHookTestProvider{}
|
||||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ toolLoop:
|
||||||
case result, ok := <-ts.pendingResults:
|
case result, ok := <-ts.pendingResults:
|
||||||
if ok && result != nil && result.ForLLM != "" {
|
if ok && result != nil && result.ForLLM != "" {
|
||||||
content := al.cfg.FilterSensitiveData(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)
|
messages = append(messages, msg)
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||||
}
|
}
|
||||||
|
|
@ -631,7 +631,7 @@ toolLoop:
|
||||||
case result, ok := <-ts.pendingResults:
|
case result, ok := <-ts.pendingResults:
|
||||||
if ok && result != nil && result.ForLLM != "" {
|
if ok && result != nil && result.ForLLM != "" {
|
||||||
content := al.cfg.FilterSensitiveData(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)
|
messages = append(messages, msg)
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -319,10 +319,8 @@ func (p *Pipeline) CallLLM(
|
||||||
exec.history = asmResp.History
|
exec.history = asmResp.History
|
||||||
exec.summary = asmResp.Summary
|
exec.summary = asmResp.Summary
|
||||||
}
|
}
|
||||||
exec.messages = ts.agent.ContextBuilder.BuildMessages(
|
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
||||||
exec.history, exec.summary, "",
|
promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
|
||||||
nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
|
|
||||||
activeSkillNames(ts.agent, ts.opts)...,
|
|
||||||
)
|
)
|
||||||
exec.callMessages = exec.messages
|
exec.callMessages = exec.messages
|
||||||
if exec.gracefulTerminal {
|
if exec.gracefulTerminal {
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,8 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
}
|
}
|
||||||
ts.captureRestorePoint(history, summary)
|
ts.captureRestorePoint(history, summary)
|
||||||
|
|
||||||
messages := ts.agent.ContextBuilder.BuildMessages(
|
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
||||||
history,
|
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
|
||||||
summary,
|
|
||||||
ts.userMessage,
|
|
||||||
ts.media,
|
|
||||||
ts.channel,
|
|
||||||
ts.chatID,
|
|
||||||
ts.opts.Dispatch.SenderID(),
|
|
||||||
ts.opts.SenderDisplayName,
|
|
||||||
activeSkillNames(ts.agent, ts.opts)...,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||||
|
|
@ -69,22 +61,15 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
history = resp.History
|
history = resp.History
|
||||||
summary = resp.Summary
|
summary = resp.Summary
|
||||||
}
|
}
|
||||||
messages = ts.agent.ContextBuilder.BuildMessages(
|
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
||||||
history, summary, ts.userMessage,
|
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
|
||||||
ts.media, ts.channel, ts.chatID,
|
|
||||||
ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
|
|
||||||
activeSkillNames(ts.agent, ts.opts)...,
|
|
||||||
)
|
)
|
||||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) {
|
if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) {
|
||||||
rootMsg := providers.Message{
|
rootMsg := userPromptMessage(ts.userMessage, ts.media)
|
||||||
Role: "user",
|
|
||||||
Content: ts.userMessage,
|
|
||||||
Media: append([]string(nil), ts.media...),
|
|
||||||
}
|
|
||||||
if len(rootMsg.Media) > 0 {
|
if len(rootMsg.Media) > 0 {
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
496
pkg/agent/prompt.go
Normal file
496
pkg/agent/prompt.go
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
139
pkg/agent/prompt_contributors.go
Normal file
139
pkg/agent/prompt_contributors.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolDiscoveryPromptContributor struct {
|
||||||
|
useBM25 bool
|
||||||
|
useRegex bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor {
|
||||||
|
return PromptSourceDescriptor{
|
||||||
|
ID: PromptSourceToolDiscovery,
|
||||||
|
Owner: "tools",
|
||||||
|
Description: "Tool discovery instructions",
|
||||||
|
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
|
||||||
|
StableByDefault: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c toolDiscoveryPromptContributor) ContributePrompt(
|
||||||
|
_ context.Context,
|
||||||
|
_ PromptBuildRequest,
|
||||||
|
) ([]PromptPart, error) {
|
||||||
|
content := formatToolDiscoveryRule(c.useBM25, c.useRegex)
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []PromptPart{
|
||||||
|
{
|
||||||
|
ID: "capability.tool_discovery",
|
||||||
|
Layer: PromptLayerCapability,
|
||||||
|
Slot: PromptSlotTooling,
|
||||||
|
Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"},
|
||||||
|
Title: "tool discovery",
|
||||||
|
Content: content,
|
||||||
|
Stable: true,
|
||||||
|
Cache: PromptCacheEphemeral,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mcpServerPromptContributor struct {
|
||||||
|
serverName string
|
||||||
|
toolCount int
|
||||||
|
deferred bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor {
|
||||||
|
return PromptSourceDescriptor{
|
||||||
|
ID: mcpPromptSourceID(c.serverName),
|
||||||
|
Owner: "mcp",
|
||||||
|
Description: fmt.Sprintf("MCP server %q capability prompt", c.serverName),
|
||||||
|
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}},
|
||||||
|
StableByDefault: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c mcpServerPromptContributor) ContributePrompt(
|
||||||
|
_ context.Context,
|
||||||
|
_ PromptBuildRequest,
|
||||||
|
) ([]PromptPart, error) {
|
||||||
|
serverName := strings.TrimSpace(c.serverName)
|
||||||
|
if serverName == "" || c.toolCount <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := "available as native tools"
|
||||||
|
if c.deferred {
|
||||||
|
availability = "hidden behind tool discovery until unlocked"
|
||||||
|
}
|
||||||
|
|
||||||
|
return []PromptPart{
|
||||||
|
{
|
||||||
|
ID: "capability.mcp." + promptSourceComponent(serverName),
|
||||||
|
Layer: PromptLayerCapability,
|
||||||
|
Slot: PromptSlotMCP,
|
||||||
|
Source: PromptSource{ID: mcpPromptSourceID(serverName), Name: "mcp:" + serverName},
|
||||||
|
Title: "MCP server capability",
|
||||||
|
Content: fmt.Sprintf(
|
||||||
|
"MCP server `%s` is connected. It contributes %d tool(s), currently %s.",
|
||||||
|
serverName,
|
||||||
|
c.toolCount,
|
||||||
|
availability,
|
||||||
|
),
|
||||||
|
Stable: true,
|
||||||
|
Cache: PromptCacheEphemeral,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpPromptSourceID(serverName string) PromptSourceID {
|
||||||
|
return PromptSourceID("mcp:" + promptSourceComponent(serverName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptSourceComponent(value string) string {
|
||||||
|
const maxLen = 64
|
||||||
|
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "unnamed"
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
lastWasSep := false
|
||||||
|
for _, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastWasSep = false
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastWasSep = false
|
||||||
|
case r == '-' || r == '_':
|
||||||
|
if !lastWasSep && b.Len() > 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastWasSep = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !lastWasSep && b.Len() > 0 {
|
||||||
|
b.WriteRune('_')
|
||||||
|
lastWasSep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.Trim(b.String(), "_")
|
||||||
|
if result == "" {
|
||||||
|
return "unnamed"
|
||||||
|
}
|
||||||
|
if len(result) > maxLen {
|
||||||
|
return result[:maxLen]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
275
pkg/agent/prompt_test.go
Normal file
275
pkg/agent/prompt_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
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
|
||||||
|
if part.CacheControl != nil {
|
||||||
|
t.Fatalf("runtime cache control = %#v, want nil", part.CacheControl)
|
||||||
|
}
|
||||||
|
case string(PromptSourceSummary):
|
||||||
|
hasSummary = true
|
||||||
|
if part.CacheControl != nil {
|
||||||
|
t.Fatalf("summary cache control = %#v, want nil", part.CacheControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||||
|
cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false)
|
||||||
|
|
||||||
|
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
|
||||||
|
system := messages[0]
|
||||||
|
if !strings.Contains(system.Content, "tool_search_tool_bm25") {
|
||||||
|
t.Fatalf("system prompt missing tool discovery rule: %q", system.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, part := range system.SystemParts {
|
||||||
|
if part.PromptSource == string(PromptSourceToolDiscovery) {
|
||||||
|
found = true
|
||||||
|
if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotTooling) {
|
||||||
|
t.Fatalf("tool discovery metadata = %#v, want capability/tooling", part)
|
||||||
|
}
|
||||||
|
if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" {
|
||||||
|
t.Fatalf("tool discovery cache control = %#v, want ephemeral", part.CacheControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("system parts missing tool discovery prompt metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
|
||||||
|
cb := NewContextBuilder(t.TempDir())
|
||||||
|
err := cb.RegisterPromptContributor(mcpServerPromptContributor{
|
||||||
|
serverName: "GitHub Server",
|
||||||
|
toolCount: 3,
|
||||||
|
deferred: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPromptContributor() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
|
||||||
|
system := messages[0]
|
||||||
|
if !strings.Contains(system.Content, "MCP server `GitHub Server` is connected") {
|
||||||
|
t.Fatalf("system prompt missing MCP contributor content: %q", system.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, part := range system.SystemParts {
|
||||||
|
if part.PromptSource == "mcp:github_server" {
|
||||||
|
found = true
|
||||||
|
if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotMCP) {
|
||||||
|
t.Fatalf("mcp metadata = %#v, want capability/mcp", part)
|
||||||
|
}
|
||||||
|
if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" {
|
||||||
|
t.Fatalf("mcp cache control = %#v, want ephemeral", part.CacheControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("system parts missing MCP prompt metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
pkg/agent/prompt_turn.go
Normal file
129
pkg/agent/prompt_turn.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
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 {
|
||||||
|
if cache == nil {
|
||||||
|
cache = cacheControlForPromptPart(part)
|
||||||
|
}
|
||||||
|
return providers.ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: part.Content,
|
||||||
|
CacheControl: cache,
|
||||||
|
PromptLayer: string(part.Layer),
|
||||||
|
PromptSlot: string(part.Slot),
|
||||||
|
PromptSource: string(part.Source.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheControlForPromptPart(part PromptPart) *providers.CacheControl {
|
||||||
|
switch part.Cache {
|
||||||
|
case PromptCacheEphemeral:
|
||||||
|
return &providers.CacheControl{Type: "ephemeral"}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers
|
||||||
return fmt.Errorf("steering queue is not initialized")
|
return fmt.Errorf("steering queue is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg = steeringPromptMessage(msg)
|
||||||
if err := al.steering.pushScope(scope, msg); err != nil {
|
if err := al.steering.pushScope(scope, msg); err != nil {
|
||||||
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
|
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
case result, ok := <-ts.pendingResults:
|
case result, ok := <-ts.pendingResults:
|
||||||
if ok && result != nil && result.ForLLM != "" {
|
if ok && result != nil && result.ForLLM != "" {
|
||||||
content := al.cfg.FilterSensitiveData(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)
|
pendingMessages = append(pendingMessages, msg)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -527,10 +527,7 @@ func (ts *turnState) interruptHintMessage() providers.Message {
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
content += "\n\nInterrupt hint: " + hint
|
content += "\n\nInterrupt hint: " + hint
|
||||||
}
|
}
|
||||||
return providers.Message{
|
return interruptPromptMessage(content)
|
||||||
Role: "user",
|
|
||||||
Content: content,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,13 @@ type ContentBlock struct {
|
||||||
Type string `json:"type"` // "text"
|
Type string `json:"type"` // "text"
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
CacheControl *CacheControl `json:"cache_control,omitempty"`
|
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 {
|
type Attachment struct {
|
||||||
|
|
@ -80,11 +87,24 @@ type Message struct {
|
||||||
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,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 {
|
type ToolDefinition struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Function ToolFunctionDefinition `json:"function"`
|
Function ToolFunctionDefinition `json:"function"`
|
||||||
|
|
||||||
|
// Prompt metadata is internal to the agent runtime. Tool definitions are
|
||||||
|
// model-visible capability prompts even though providers send them outside
|
||||||
|
// the system message.
|
||||||
|
PromptLayer string `json:"-"`
|
||||||
|
PromptSlot string `json:"-"`
|
||||||
|
PromptSource string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolFunctionDefinition struct {
|
type ToolFunctionDefinition struct {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MCPManager defines the interface for MCP manager operations
|
// MCPManager defines the interface for MCP manager operations
|
||||||
|
|
@ -161,6 +162,14 @@ func (t *MCPTool) Description() string {
|
||||||
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
|
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata {
|
||||||
|
return toolshared.PromptMetadata{
|
||||||
|
Layer: toolshared.ToolPromptLayerCapability,
|
||||||
|
Slot: toolshared.ToolPromptSlotMCP,
|
||||||
|
Source: "mcp:" + sanitizeIdentifierComponent(t.serverName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parameters returns the tool parameters schema
|
// Parameters returns the tool parameters schema
|
||||||
func (t *MCPTool) Parameters() map[string]any {
|
func (t *MCPTool) Parameters() map[string]any {
|
||||||
// The InputSchema is already a JSON Schema object
|
// The InputSchema is already a JSON Schema object
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockMCPManager is a mock implementation of MCPManager interface for testing
|
// MockMCPManager is a mock implementation of MCPManager interface for testing
|
||||||
|
|
@ -104,6 +105,22 @@ func TestMCPTool_Name(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMCPTool_PromptMetadata(t *testing.T) {
|
||||||
|
manager := &MockMCPManager{}
|
||||||
|
tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"})
|
||||||
|
|
||||||
|
metadata := tool.PromptMetadata()
|
||||||
|
if metadata.Layer != toolshared.ToolPromptLayerCapability {
|
||||||
|
t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability)
|
||||||
|
}
|
||||||
|
if metadata.Slot != toolshared.ToolPromptSlotMCP {
|
||||||
|
t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP)
|
||||||
|
}
|
||||||
|
if metadata.Source != "mcp:github_server" {
|
||||||
|
t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestMCPTool_Description verifies tool description generation
|
// TestMCPTool_Description verifies tool description generation
|
||||||
func TestMCPTool_Description(t *testing.T) {
|
func TestMCPTool_Description(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||||
name, _ := fn["name"].(string)
|
name, _ := fn["name"].(string)
|
||||||
desc, _ := fn["description"].(string)
|
desc, _ := fn["description"].(string)
|
||||||
params, _ := fn["parameters"].(map[string]any)
|
params, _ := fn["parameters"].(map[string]any)
|
||||||
|
metadata := promptMetadataForTool(entry.Tool)
|
||||||
|
|
||||||
definitions = append(definitions, providers.ToolDefinition{
|
definitions = append(definitions, providers.ToolDefinition{
|
||||||
Type: "function",
|
Type: "function",
|
||||||
|
|
@ -360,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||||
Description: desc,
|
Description: desc,
|
||||||
Parameters: params,
|
Parameters: params,
|
||||||
},
|
},
|
||||||
|
PromptLayer: metadata.Layer,
|
||||||
|
PromptSlot: metadata.Slot,
|
||||||
|
PromptSource: metadata.Source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return definitions
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptMetadataForTool(tool Tool) PromptMetadata {
|
||||||
|
metadata := PromptMetadata{
|
||||||
|
Layer: ToolPromptLayerCapability,
|
||||||
|
Slot: ToolPromptSlotTooling,
|
||||||
|
Source: ToolPromptSourceRegistry,
|
||||||
|
}
|
||||||
|
if provider, ok := tool.(PromptMetadataProvider); ok {
|
||||||
|
provided := provider.PromptMetadata()
|
||||||
|
if provided.Layer != "" {
|
||||||
|
metadata.Layer = provided.Layer
|
||||||
|
}
|
||||||
|
if provided.Slot != "" {
|
||||||
|
metadata.Slot = provided.Slot
|
||||||
|
}
|
||||||
|
if provided.Source != "" {
|
||||||
|
metadata.Source = provided.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
// List returns a list of all registered tool names.
|
// List returns a list of all registered tool names.
|
||||||
func (r *ToolRegistry) List() []string {
|
func (r *ToolRegistry) List() []string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T
|
||||||
return m.result
|
return m.result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockPromptMetadataTool struct {
|
||||||
|
mockRegistryTool
|
||||||
|
metadata PromptMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata {
|
||||||
|
return m.metadata
|
||||||
|
}
|
||||||
|
|
||||||
type mockAsyncRegistryTool struct {
|
type mockAsyncRegistryTool struct {
|
||||||
mockRegistryTool
|
mockRegistryTool
|
||||||
lastCB AsyncCallback
|
lastCB AsyncCallback
|
||||||
|
|
@ -375,6 +384,47 @@ func TestToolToSchema(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(newMockTool("native", "native tool"))
|
||||||
|
r.Register(&mockPromptMetadataTool{
|
||||||
|
mockRegistryTool: mockRegistryTool{
|
||||||
|
name: "mcp_demo",
|
||||||
|
desc: "mcp tool",
|
||||||
|
params: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
metadata: PromptMetadata{
|
||||||
|
Layer: ToolPromptLayerCapability,
|
||||||
|
Slot: ToolPromptSlotMCP,
|
||||||
|
Source: "mcp:demo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defs := r.ToProviderDefs()
|
||||||
|
if len(defs) != 2 {
|
||||||
|
t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs))
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := make(map[string]providers.ToolDefinition, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
byName[def.Function.Name] = def
|
||||||
|
}
|
||||||
|
|
||||||
|
native := byName["native"]
|
||||||
|
if native.PromptLayer != ToolPromptLayerCapability ||
|
||||||
|
native.PromptSlot != ToolPromptSlotTooling ||
|
||||||
|
native.PromptSource != ToolPromptSourceRegistry {
|
||||||
|
t.Fatalf("native prompt metadata = %#v, want default tooling source", native)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcp := byName["mcp_demo"]
|
||||||
|
if mcp.PromptLayer != ToolPromptLayerCapability ||
|
||||||
|
mcp.PromptSlot != ToolPromptSlotMCP ||
|
||||||
|
mcp.PromptSource != "mcp:demo" {
|
||||||
|
t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolRegistry_Clone(t *testing.T) {
|
func TestToolRegistry_Clone(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
r.Register(newMockTool("read_file", "reads files"))
|
r.Register(newMockTool("read_file", "reads files"))
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string {
|
||||||
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
|
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *RegexSearchTool) PromptMetadata() PromptMetadata {
|
||||||
|
return PromptMetadata{
|
||||||
|
Layer: ToolPromptLayerCapability,
|
||||||
|
Slot: ToolPromptSlotTooling,
|
||||||
|
Source: ToolPromptSourceDiscovery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *RegexSearchTool) Parameters() map[string]any {
|
func (t *RegexSearchTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string {
|
||||||
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
|
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *BM25SearchTool) PromptMetadata() PromptMetadata {
|
||||||
|
return PromptMetadata{
|
||||||
|
Layer: ToolPromptLayerCapability,
|
||||||
|
Slot: ToolPromptSlotTooling,
|
||||||
|
Source: ToolPromptSourceDiscovery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *BM25SearchTool) Parameters() map[string]any {
|
func (t *BM25SearchTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,24 @@ type Tool interface {
|
||||||
Execute(ctx context.Context, args map[string]any) *ToolResult
|
Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ToolPromptLayerCapability = "capability"
|
||||||
|
ToolPromptSlotTooling = "tooling"
|
||||||
|
ToolPromptSlotMCP = "mcp"
|
||||||
|
ToolPromptSourceRegistry = "tool_registry:native"
|
||||||
|
ToolPromptSourceDiscovery = "tool_registry:discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PromptMetadata struct {
|
||||||
|
Layer string
|
||||||
|
Slot string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptMetadataProvider interface {
|
||||||
|
PromptMetadata() PromptMetadata
|
||||||
|
}
|
||||||
|
|
||||||
// --- Request-scoped tool context (channel / chatID) ---
|
// --- Request-scoped tool context (channel / chatID) ---
|
||||||
//
|
//
|
||||||
// Carried via context.Value so that concurrent tool calls each receive
|
// Carried via context.Value so that concurrent tool calls each receive
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,20 @@ type (
|
||||||
Tool = toolshared.Tool
|
Tool = toolshared.Tool
|
||||||
AsyncCallback = toolshared.AsyncCallback
|
AsyncCallback = toolshared.AsyncCallback
|
||||||
AsyncExecutor = toolshared.AsyncExecutor
|
AsyncExecutor = toolshared.AsyncExecutor
|
||||||
|
PromptMetadata = toolshared.PromptMetadata
|
||||||
|
PromptMetadataProvider = toolshared.PromptMetadataProvider
|
||||||
ToolResult = toolshared.ToolResult
|
ToolResult = toolshared.ToolResult
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
handledToolLLMNote = toolshared.HandledToolLLMNote
|
handledToolLLMNote = toolshared.HandledToolLLMNote
|
||||||
artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote
|
artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote
|
||||||
|
|
||||||
|
ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability
|
||||||
|
ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling
|
||||||
|
ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP
|
||||||
|
ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry
|
||||||
|
ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery
|
||||||
)
|
)
|
||||||
|
|
||||||
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
|
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue