refactor: enhance skill catalog configuration and update related tests
This commit is contained in:
parent
48f636e343
commit
ae3cd795a2
4 changed files with 115 additions and 40 deletions
|
|
@ -26,6 +26,7 @@ type ContextBuilder struct {
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
splitOnMarker bool
|
splitOnMarker bool
|
||||||
|
skillCatalogCfg config.SkillCatalogConfig
|
||||||
promptRegistry *PromptRegistry
|
promptRegistry *PromptRegistry
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
|
|
@ -66,6 +67,11 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) WithSkillCatalogConfig(cfg config.SkillCatalogConfig) *ContextBuilder {
|
||||||
|
cb.skillCatalogCfg = cfg
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
return config.GetHome()
|
return config.GetHome()
|
||||||
}
|
}
|
||||||
|
|
@ -690,16 +696,14 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov
|
||||||
}, &providers.CacheControl{Type: "ephemeral"}),
|
}, &providers.CacheControl{Type: "ephemeral"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject the skill catalog only when the LLM needs to (re)discover available skills:
|
// Determine whether to inject the skill catalog.
|
||||||
// - Turn 1: no history yet, LLM hasn't seen the catalog.
|
// Both skip behaviours are opt-in via config (default: always include).
|
||||||
// - After compaction: history was summarized; early turns (including the original
|
|
||||||
// catalog injection) are gone, so the LLM must see it again.
|
|
||||||
// Skip it on tool-call continuations (mid-turn round-trips) and on ordinary
|
|
||||||
// subsequent turns where the catalog is already in the LLM's context window.
|
|
||||||
isToolContinuation := len(req.History) > 0 && req.History[len(req.History)-1].Role == "tool"
|
isToolContinuation := len(req.History) > 0 && req.History[len(req.History)-1].Role == "tool"
|
||||||
isFirstTurn := len(req.History) == 0
|
isFirstTurn := len(req.History) == 0
|
||||||
isAfterCompaction := req.Summary != ""
|
isAfterCompaction := req.Summary != ""
|
||||||
if !isToolContinuation && (isFirstTurn || isAfterCompaction) {
|
skipForTools := cb.skillCatalogCfg.SkipOnTools && isToolContinuation
|
||||||
|
skipForSubsequent := cb.skillCatalogCfg.SkipOnSubsequent && !isFirstTurn && !isAfterCompaction && !isToolContinuation
|
||||||
|
if !skipForTools && !skipForSubsequent {
|
||||||
if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" {
|
if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" {
|
||||||
catalogPart := PromptPart{
|
catalogPart := PromptPart{
|
||||||
ID: "capability.skill_catalog",
|
ID: "capability.skill_catalog",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -614,15 +615,14 @@ description: delete-me-v1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSkillCatalogInjectionPolicy verifies that the catalog is included only
|
// TestSkillCatalogInjectionPolicy verifies catalog inclusion under various
|
||||||
// when the LLM needs to (re)discover skills: turn 1 and after compaction.
|
// config combinations.
|
||||||
func TestSkillCatalogInjectionPolicy(t *testing.T) {
|
func TestSkillCatalogInjectionPolicy(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
||||||
})
|
})
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
|
||||||
userMsg := providers.Message{Role: "user", Content: "hello"}
|
userMsg := providers.Message{Role: "user", Content: "hello"}
|
||||||
assistantMsg := providers.Message{Role: "assistant", Content: "hi"}
|
assistantMsg := providers.Message{Role: "assistant", Content: "hi"}
|
||||||
toolMsg := providers.Message{Role: "tool", Content: "result", ToolCallID: "tc1"}
|
toolMsg := providers.Message{Role: "tool", Content: "result", ToolCallID: "tc1"}
|
||||||
|
|
@ -631,32 +631,90 @@ func TestSkillCatalogInjectionPolicy(t *testing.T) {
|
||||||
return strings.Contains(systemPromptFromMessages(msgs), "demo skill")
|
return strings.Contains(systemPromptFromMessages(msgs), "demo skill")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turn 1: no history — catalog must appear.
|
newCB := func(skipOnTools, skipOnSubsequent bool) *ContextBuilder {
|
||||||
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) {
|
return NewContextBuilder(tmpDir).WithSkillCatalogConfig(config.SkillCatalogConfig{
|
||||||
t.Error("turn 1 (no history): catalog should be included")
|
SkipOnTools: skipOnTools,
|
||||||
|
SkipOnSubsequent: skipOnSubsequent,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool continuation: last message is a tool result — catalog must be skipped.
|
t.Run("default (both false): catalog always included", func(t *testing.T) {
|
||||||
|
cb := newCB(false, false)
|
||||||
|
for _, req := range []PromptBuildRequest{
|
||||||
|
{},
|
||||||
|
{History: []providers.Message{userMsg, assistantMsg}},
|
||||||
|
{History: []providers.Message{userMsg, assistantMsg, toolMsg}},
|
||||||
|
{History: []providers.Message{userMsg, assistantMsg}, Summary: "summary"},
|
||||||
|
} {
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(req)) {
|
||||||
|
t.Error("catalog should always be included when both flags are false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skip_on_tools: skips tool continuations only", func(t *testing.T) {
|
||||||
|
cb := newCB(true, false)
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) {
|
||||||
|
t.Error("turn 1: catalog should be included")
|
||||||
|
}
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
|
History: []providers.Message{userMsg, assistantMsg},
|
||||||
|
})) {
|
||||||
|
t.Error("turn > 1 (no tool): catalog should be included")
|
||||||
|
}
|
||||||
if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
History: []providers.Message{userMsg, assistantMsg, toolMsg},
|
History: []providers.Message{userMsg, assistantMsg, toolMsg},
|
||||||
})) {
|
})) {
|
||||||
t.Error("tool continuation: catalog should be skipped")
|
t.Error("tool continuation: catalog should be skipped")
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Turn > 1, no compaction: catalog must be skipped.
|
t.Run("skip_on_subsequent: skips turns > 1, re-injects after compaction", func(t *testing.T) {
|
||||||
|
cb := newCB(false, true)
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) {
|
||||||
|
t.Error("turn 1: catalog should be included")
|
||||||
|
}
|
||||||
if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
History: []providers.Message{userMsg, assistantMsg},
|
History: []providers.Message{userMsg, assistantMsg},
|
||||||
})) {
|
})) {
|
||||||
t.Error("turn > 1, no summary: catalog should be skipped")
|
t.Error("turn > 1, no summary: catalog should be skipped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// After compaction (summary present): catalog must be re-injected.
|
|
||||||
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
History: []providers.Message{userMsg, assistantMsg},
|
History: []providers.Message{userMsg, assistantMsg},
|
||||||
Summary: "prior conversation summary",
|
Summary: "prior conversation summary",
|
||||||
})) {
|
})) {
|
||||||
t.Error("after compaction (summary present): catalog should be re-injected")
|
t.Error("after compaction: catalog should be re-injected")
|
||||||
}
|
}
|
||||||
|
// tool continuation is NOT skipped when only skip_on_subsequent is set
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
|
History: []providers.Message{userMsg, assistantMsg, toolMsg},
|
||||||
|
})) {
|
||||||
|
t.Error("tool continuation (skip_on_subsequent only): catalog should be included")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("both true: skips tool turns and subsequent turns, re-injects after compaction", func(t *testing.T) {
|
||||||
|
cb := newCB(true, true)
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) {
|
||||||
|
t.Error("turn 1: catalog should be included")
|
||||||
|
}
|
||||||
|
if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
|
History: []providers.Message{userMsg, assistantMsg, toolMsg},
|
||||||
|
})) {
|
||||||
|
t.Error("tool continuation: catalog should be skipped")
|
||||||
|
}
|
||||||
|
if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
|
History: []providers.Message{userMsg, assistantMsg},
|
||||||
|
})) {
|
||||||
|
t.Error("turn > 1, no summary: catalog should be skipped")
|
||||||
|
}
|
||||||
|
if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||||
|
History: []providers.Message{userMsg, assistantMsg},
|
||||||
|
Summary: "prior conversation summary",
|
||||||
|
})) {
|
||||||
|
t.Error("after compaction: catalog should be re-injected")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,8 @@ func NewAgentInstance(
|
||||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||||
).
|
).
|
||||||
WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker)
|
WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker).
|
||||||
|
WithSkillCatalogConfig(cfg.Agents.Defaults.SkillCatalog)
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
agentName := ""
|
agentName := ""
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,17 @@ type ToolFeedbackConfig struct {
|
||||||
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
|
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkillCatalogConfig struct {
|
||||||
|
// SkipOnTools omits the skill catalog from tool-call continuation requests
|
||||||
|
// (mid-turn LLM round-trips). The LLM already received the catalog on the
|
||||||
|
// initial turn request. Default false (catalog always included).
|
||||||
|
SkipOnTools bool `json:"skip_on_tools" env:"PICOCLAW_AGENTS_DEFAULTS_SKILL_CATALOG_SKIP_ON_TOOLS"`
|
||||||
|
// SkipOnSubsequent omits the skill catalog on turns after the first in a
|
||||||
|
// session. The catalog is still re-injected after context compaction.
|
||||||
|
// Default false (catalog always included).
|
||||||
|
SkipOnSubsequent bool `json:"skip_on_subsequent" env:"PICOCLAW_AGENTS_DEFAULTS_SKILL_CATALOG_SKIP_ON_SUBSEQUENT"`
|
||||||
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
|
|
@ -276,6 +287,7 @@ type AgentDefaults struct {
|
||||||
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
||||||
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
||||||
|
SkillCatalog SkillCatalogConfig `json:"skill_catalog,omitempty"`
|
||||||
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
|
||||||
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
|
||||||
MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"`
|
MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue