From 52af9748a2f12b7413c91c66dfdd2a8cebcf1200 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Wed, 6 May 2026 12:26:19 +0300 Subject: [PATCH 1/8] refactor: move skill catalog handling to dynamic context and update tests --- pkg/agent/context.go | 47 ++++++++++++++++++++------------- pkg/agent/context_cache_test.go | 43 +++++++++++++++++++----------- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ecde7c33e..0b58193ff 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -202,24 +202,6 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { }) } - // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() - if skillsSummary != "" { - add(PromptPart{ - ID: "capability.skill_catalog", - Layer: PromptLayerCapability, - Slot: PromptSlotSkillCatalog, - Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, - Title: "skill catalog", - Content: fmt.Sprintf(`# Skills - -The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. - -%s`, skillsSummary), - Stable: true, - Cache: PromptCacheEphemeral, - }) - } // Memory context memoryContext := cb.memory.GetMemoryContext() @@ -314,6 +296,13 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars + // Skill catalog is no longer in the static prompt; add it to the estimate + // (EstimateSystemTokens assumes a non-continuation turn). + if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" { + totalChars += utf8.RuneCountInString(skillsSummary) + 80 // header overhead + totalChars += 7 // separator + } + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { totalChars += utf8.RuneCountInString(skillsText) totalChars += 7 // separator \n\n---\n\n @@ -701,6 +690,28 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov }, &providers.CacheControl{Type: "ephemeral"}), } + // Skip the skill catalog on tool-call continuations: the LLM already saw + // it in the initial turn request and doesn't need it re-sent for every + // intermediate tool round-trip. This saves significant tokens on providers + // without prompt caching (OpenAI-compat). + isToolContinuation := len(req.History) > 0 && req.History[len(req.History)-1].Role == "tool" + if !isToolContinuation { + if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" { + catalogPart := PromptPart{ + ID: "capability.skill_catalog", + Layer: PromptLayerCapability, + Slot: PromptSlotSkillCatalog, + Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, + Title: "skill catalog", + Content: fmt.Sprintf("# Skills\n\nThe following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.\n\n%s", skillsSummary), + Stable: true, + Cache: PromptCacheEphemeral, + } + stringParts = append(stringParts, catalogPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(catalogPart, &providers.CacheControl{Type: "ephemeral"})) + } + } + promptParts := append([]PromptPart(nil), req.Overlays...) promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...) if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index ef5e6c5de..ef8e45022 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -31,6 +31,16 @@ func setupWorkspace(t *testing.T, files map[string]string) string { return tmpDir } +// systemPromptFromMessages extracts the Content of the first system message. +func systemPromptFromMessages(msgs []providers.Message) string { + for _, m := range msgs { + if m.Role == "system" { + return m.Content + } + } + return "" +} + // TestSingleSystemMessage verifies that BuildMessages always produces exactly one // system message regardless of summary/history variations. // Fix: multiple system messages break Anthropic (top-level system param) and @@ -468,8 +478,9 @@ description: global-v1 } cb := NewContextBuilder(tmpDir) - sp1 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp1, "global-v1") { + // Skill catalog is injected per-request, not in the static cache; check via BuildMessagesFromPrompt. + sysMsg1 := systemPromptFromMessages(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) + if !strings.Contains(sysMsg1, "global-v1") { t.Fatal("expected initial prompt to contain global skill description") } @@ -493,11 +504,11 @@ description: global-v2 t.Fatal("sourceFilesChangedLocked() should detect global skill file content change") } - sp2 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp2, "global-v2") { + sysMsg2 := systemPromptFromMessages(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) + if !strings.Contains(sysMsg2, "global-v2") { t.Error("rebuilt prompt should contain updated global skill description") } - if sp1 == sp2 { + if sysMsg1 == sysMsg2 { t.Error("cache should be invalidated when global skill file content changes") } } @@ -528,8 +539,9 @@ description: builtin-v1 } cb := NewContextBuilder(tmpDir) - sp1 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp1, "builtin-v1") { + // Skill catalog is injected per-request, not in the static cache; check via BuildMessagesFromPrompt. + sysMsg1 := systemPromptFromMessages(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) + if !strings.Contains(sysMsg1, "builtin-v1") { t.Fatal("expected initial prompt to contain builtin skill description") } @@ -553,11 +565,11 @@ description: builtin-v2 t.Fatal("sourceFilesChangedLocked() should detect builtin skill file content change") } - sp2 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp2, "builtin-v2") { + sysMsg2 := systemPromptFromMessages(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) + if !strings.Contains(sysMsg2, "builtin-v2") { t.Error("rebuilt prompt should contain updated builtin skill description") } - if sp1 == sp2 { + if sysMsg1 == sysMsg2 { t.Error("cache should be invalidated when builtin skill file content changes") } } @@ -575,8 +587,9 @@ description: delete-me-v1 defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) - sp1 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp1, "delete-me-v1") { + // Skill catalog is injected per-request, not in the static cache; check via BuildMessagesFromPrompt. + sysMsg1 := systemPromptFromMessages(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) + if !strings.Contains(sysMsg1, "delete-me-v1") { t.Fatal("expected initial prompt to contain skill description") } @@ -592,11 +605,11 @@ description: delete-me-v1 t.Fatal("sourceFilesChangedLocked() should detect deleted skill file") } - sp2 := cb.BuildSystemPromptWithCache() - if strings.Contains(sp2, "delete-me-v1") { + sysMsg2 := systemPromptFromMessages(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) + if strings.Contains(sysMsg2, "delete-me-v1") { t.Error("rebuilt prompt should not contain deleted skill description") } - if sp1 == sp2 { + if sysMsg1 == sysMsg2 { t.Error("cache should be invalidated when skill file is deleted") } } From 48f636e343b876585647bd89066c6e4ea3893058 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Wed, 6 May 2026 12:34:42 +0300 Subject: [PATCH 2/8] refactor: update skill catalog injection logic and add corresponding tests --- pkg/agent/context.go | 14 ++++++---- pkg/agent/context_cache_test.go | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 0b58193ff..fc902bd5c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -690,12 +690,16 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov }, &providers.CacheControl{Type: "ephemeral"}), } - // Skip the skill catalog on tool-call continuations: the LLM already saw - // it in the initial turn request and doesn't need it re-sent for every - // intermediate tool round-trip. This saves significant tokens on providers - // without prompt caching (OpenAI-compat). + // Inject the skill catalog only when the LLM needs to (re)discover available skills: + // - Turn 1: no history yet, LLM hasn't seen the catalog. + // - 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" - if !isToolContinuation { + isFirstTurn := len(req.History) == 0 + isAfterCompaction := req.Summary != "" + if !isToolContinuation && (isFirstTurn || isAfterCompaction) { if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" { catalogPart := PromptPart{ ID: "capability.skill_catalog", diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index ef8e45022..86cb674e8 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -614,6 +614,51 @@ description: delete-me-v1 } } +// TestSkillCatalogInjectionPolicy verifies that the catalog is included only +// when the LLM needs to (re)discover skills: turn 1 and after compaction. +func TestSkillCatalogInjectionPolicy(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", + }) + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + userMsg := providers.Message{Role: "user", Content: "hello"} + assistantMsg := providers.Message{Role: "assistant", Content: "hi"} + toolMsg := providers.Message{Role: "tool", Content: "result", ToolCallID: "tc1"} + + contains := func(msgs []providers.Message) bool { + return strings.Contains(systemPromptFromMessages(msgs), "demo skill") + } + + // Turn 1: no history — catalog must appear. + if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) { + t.Error("turn 1 (no history): catalog should be included") + } + + // Tool continuation: last message is a tool result — catalog must be skipped. + if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ + History: []providers.Message{userMsg, assistantMsg, toolMsg}, + })) { + t.Error("tool continuation: catalog should be skipped") + } + + // Turn > 1, no compaction: catalog must be skipped. + if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ + History: []providers.Message{userMsg, assistantMsg}, + })) { + t.Error("turn > 1, no summary: catalog should be skipped") + } + + // After compaction (summary present): catalog must be re-injected. + if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ + History: []providers.Message{userMsg, assistantMsg}, + Summary: "prior conversation summary", + })) { + t.Error("after compaction (summary present): catalog should be re-injected") + } +} + // TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines // can safely call BuildSystemPromptWithCache concurrently without producing // empty results, panics, or data races. From ae3cd795a2ec89f3bbccfa6585304e2a99ac9d58 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Wed, 6 May 2026 12:52:30 +0300 Subject: [PATCH 3/8] refactor: enhance skill catalog configuration and update related tests --- pkg/agent/context.go | 28 +++++---- pkg/agent/context_cache_test.go | 108 ++++++++++++++++++++++++-------- pkg/agent/instance.go | 3 +- pkg/config/config.go | 16 ++++- 4 files changed, 115 insertions(+), 40 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index fc902bd5c..da5b49915 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -22,11 +22,12 @@ import ( ) type ContextBuilder struct { - workspace string - skillsLoader *skills.SkillsLoader - memory *MemoryStore - splitOnMarker bool - promptRegistry *PromptRegistry + workspace string + skillsLoader *skills.SkillsLoader + memory *MemoryStore + splitOnMarker bool + skillCatalogCfg config.SkillCatalogConfig + promptRegistry *PromptRegistry // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -66,6 +67,11 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { return cb } +func (cb *ContextBuilder) WithSkillCatalogConfig(cfg config.SkillCatalogConfig) *ContextBuilder { + cb.skillCatalogCfg = cfg + return cb +} + func getGlobalConfigDir() string { return config.GetHome() } @@ -690,16 +696,14 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov }, &providers.CacheControl{Type: "ephemeral"}), } - // Inject the skill catalog only when the LLM needs to (re)discover available skills: - // - Turn 1: no history yet, LLM hasn't seen the catalog. - // - 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. + // Determine whether to inject the skill catalog. + // Both skip behaviours are opt-in via config (default: always include). isToolContinuation := len(req.History) > 0 && req.History[len(req.History)-1].Role == "tool" isFirstTurn := len(req.History) == 0 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 != "" { catalogPart := PromptPart{ ID: "capability.skill_catalog", diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 86cb674e8..c561a13d5 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -614,15 +615,14 @@ description: delete-me-v1 } } -// TestSkillCatalogInjectionPolicy verifies that the catalog is included only -// when the LLM needs to (re)discover skills: turn 1 and after compaction. +// TestSkillCatalogInjectionPolicy verifies catalog inclusion under various +// config combinations. func TestSkillCatalogInjectionPolicy(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", }) defer os.RemoveAll(tmpDir) - cb := NewContextBuilder(tmpDir) userMsg := providers.Message{Role: "user", Content: "hello"} assistantMsg := providers.Message{Role: "assistant", Content: "hi"} 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") } - // Turn 1: no history — catalog must appear. - if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{})) { - t.Error("turn 1 (no history): catalog should be included") + newCB := func(skipOnTools, skipOnSubsequent bool) *ContextBuilder { + return NewContextBuilder(tmpDir).WithSkillCatalogConfig(config.SkillCatalogConfig{ + SkipOnTools: skipOnTools, + SkipOnSubsequent: skipOnSubsequent, + }) } - // Tool continuation: last message is a tool result — catalog must be skipped. - if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ - History: []providers.Message{userMsg, assistantMsg, toolMsg}, - })) { - t.Error("tool continuation: catalog should 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") + } + } + }) - // Turn > 1, no compaction: catalog must be skipped. - if contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ - History: []providers.Message{userMsg, assistantMsg}, - })) { - t.Error("turn > 1, no summary: catalog should be skipped") - } + 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{ + History: []providers.Message{userMsg, assistantMsg, toolMsg}, + })) { + t.Error("tool continuation: catalog should be skipped") + } + }) - // After compaction (summary present): catalog must be re-injected. - if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ - History: []providers.Message{userMsg, assistantMsg}, - Summary: "prior conversation summary", - })) { - t.Error("after compaction (summary present): catalog should be re-injected") - } + 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{ + 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") + } + // 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 diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index d0b25a0a8..1ca1443e5 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -127,7 +127,8 @@ func NewAgentInstance( mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, ). - WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker) + WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker). + WithSkillCatalogConfig(cfg.Agents.Defaults.SkillCatalog) agentID := routing.DefaultAgentID agentName := "" diff --git a/pkg/config/config.go b/pkg/config/config.go index acceee4d5..8305a6914 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -254,6 +254,17 @@ type ToolFeedbackConfig struct { 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 { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` @@ -274,8 +285,9 @@ type AgentDefaults struct { SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` - ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` - SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker + ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` + 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"` 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"` From cb176e16ff11a679db244276f7221a6c31aab361 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Wed, 6 May 2026 12:54:21 +0300 Subject: [PATCH 4/8] docs: add skill catalog token optimization details to configuration guide --- docs/guides/configuration.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 28fc7b775..e285bffdf 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -96,6 +96,41 @@ For advanced/test setups, you can override the builtin skills root with: export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Skill Catalog Token Optimization + +By default, the skill catalog (the list of available skills shown to the LLM) is included in every LLM request. On providers without prompt caching (most OpenAI-compatible APIs), this costs tokens on every call including intermediate tool round-trips within a single turn. + +Two opt-in flags under `agents.defaults.skill_catalog` reduce this cost: + +```json +"agents": { + "defaults": { + "skill_catalog": { + "skip_on_tools": false, + "skip_on_subsequent": false + } + } +} +``` + +| Field | Default | Effect when `true` | +|---|---|---| +| `skip_on_tools` | `false` | Omits the catalog from tool-call continuation requests (mid-turn LLM round-trips). The LLM already received the catalog on the initial turn request. | +| `skip_on_subsequent` | `false` | Omits the catalog on turns after the first in a session. The catalog is automatically re-injected after context compaction, so the LLM always rediscovers skills when its history is summarized. | + +Both flags default to `false` so existing deployments are unaffected. Enable both for maximum token savings: + +```json +"skill_catalog": { + "skip_on_tools": true, + "skip_on_subsequent": true +} +``` + +Environment variable equivalents: +- `PICOCLAW_AGENTS_DEFAULTS_SKILL_CATALOG_SKIP_ON_TOOLS=true` +- `PICOCLAW_AGENTS_DEFAULTS_SKILL_CATALOG_SKIP_ON_SUBSEQUENT=true` + ### Using Skills From Chat Channels Once skills are installed, and MCP servers are configured, you can inspect and force them directly from a chat channel: From c0bc4624be8adb4c400d0f2e4516e1bccba2417f Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Thu, 7 May 2026 06:58:46 +0300 Subject: [PATCH 5/8] fix: resolve lint errors for PR #2781 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix gci import grouping, golines long line, and misspell (behaviours → behaviors). Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/context.go | 34 ++++++++++++++++----------------- pkg/agent/context_cache_test.go | 4 ++-- pkg/config/config.go | 6 +++--- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index da5b49915..69dba7cae 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -22,12 +22,12 @@ import ( ) type ContextBuilder struct { - workspace string - skillsLoader *skills.SkillsLoader - memory *MemoryStore - splitOnMarker bool - skillCatalogCfg config.SkillCatalogConfig - promptRegistry *PromptRegistry + workspace string + skillsLoader *skills.SkillsLoader + memory *MemoryStore + splitOnMarker bool + skillCatalogCfg config.SkillCatalogConfig + promptRegistry *PromptRegistry // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -208,7 +208,6 @@ func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { }) } - // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { @@ -306,7 +305,7 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st // (EstimateSystemTokens assumes a non-continuation turn). if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" { totalChars += utf8.RuneCountInString(skillsSummary) + 80 // header overhead - totalChars += 7 // separator + totalChars += 7 // separator } if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { @@ -697,23 +696,24 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov } // Determine whether to inject the skill catalog. - // Both skip behaviours are opt-in via config (default: always include). + // Both skip behaviors are opt-in via config (default: always include). isToolContinuation := len(req.History) > 0 && req.History[len(req.History)-1].Role == "tool" isFirstTurn := len(req.History) == 0 isAfterCompaction := req.Summary != "" skipForTools := cb.skillCatalogCfg.SkipOnTools && isToolContinuation - skipForSubsequent := cb.skillCatalogCfg.SkipOnSubsequent && !isFirstTurn && !isAfterCompaction && !isToolContinuation + skipForSubsequent := cb.skillCatalogCfg.SkipOnSubsequent && + !isFirstTurn && !isAfterCompaction && !isToolContinuation if !skipForTools && !skipForSubsequent { if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" { catalogPart := PromptPart{ - ID: "capability.skill_catalog", - Layer: PromptLayerCapability, - Slot: PromptSlotSkillCatalog, - Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, - Title: "skill catalog", + ID: "capability.skill_catalog", + Layer: PromptLayerCapability, + Slot: PromptSlotSkillCatalog, + Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, + Title: "skill catalog", Content: fmt.Sprintf("# Skills\n\nThe following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.\n\n%s", skillsSummary), - Stable: true, - Cache: PromptCacheEphemeral, + Stable: true, + Cache: PromptCacheEphemeral, } stringParts = append(stringParts, catalogPart.Content) contentBlocks = append(contentBlocks, promptContentBlock(catalogPart, &providers.CacheControl{Type: "ephemeral"})) diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index c561a13d5..c54422d60 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -680,8 +680,8 @@ func TestSkillCatalogInjectionPolicy(t *testing.T) { t.Error("turn > 1, no summary: catalog should be skipped") } if !contains(cb.BuildMessagesFromPrompt(PromptBuildRequest{ - History: []providers.Message{userMsg, assistantMsg}, - Summary: "prior conversation summary", + History: []providers.Message{userMsg, assistantMsg}, + Summary: "prior conversation summary", })) { t.Error("after compaction: catalog should be re-injected") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 8305a6914..6ebdc533b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -285,9 +285,9 @@ type AgentDefaults struct { SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` - ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` - SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker - SkillCatalog SkillCatalogConfig `json:"skill_catalog,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 + SkillCatalog SkillCatalogConfig `json:"skill_catalog,omitempty"` 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"` MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"` From 21eb92673601f7e21b0b9fd25ef98dddca563fb1 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Thu, 7 May 2026 17:06:02 +0300 Subject: [PATCH 6/8] ci: trigger lint check From bc7afbee59c2cb614bc67675b59ca331bc446a79 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Thu, 7 May 2026 17:15:12 +0300 Subject: [PATCH 7/8] fix: apply golines formatting to context.go Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/context.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 69dba7cae..15cc47598 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -706,17 +706,23 @@ func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []prov if !skipForTools && !skipForSubsequent { if skillsSummary := cb.skillsLoader.BuildSkillsSummary(); skillsSummary != "" { catalogPart := PromptPart{ - ID: "capability.skill_catalog", - Layer: PromptLayerCapability, - Slot: PromptSlotSkillCatalog, - Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, - Title: "skill catalog", - Content: fmt.Sprintf("# Skills\n\nThe following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.\n\n%s", skillsSummary), - Stable: true, - Cache: PromptCacheEphemeral, + ID: "capability.skill_catalog", + Layer: PromptLayerCapability, + Slot: PromptSlotSkillCatalog, + Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, + Title: "skill catalog", + Content: fmt.Sprintf( + "# Skills\n\nThe following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.\n\n%s", + skillsSummary, + ), + Stable: true, + Cache: PromptCacheEphemeral, } stringParts = append(stringParts, catalogPart.Content) - contentBlocks = append(contentBlocks, promptContentBlock(catalogPart, &providers.CacheControl{Type: "ephemeral"})) + contentBlocks = append( + contentBlocks, + promptContentBlock(catalogPart, &providers.CacheControl{Type: "ephemeral"}), + ) } } From 51ce9595d883a6f6dddca089f3a34b57833939c8 Mon Sep 17 00:00:00 2001 From: Costin Stroie Date: Tue, 12 May 2026 16:37:19 +0300 Subject: [PATCH 8/8] build: comment out non-amd64 cross-compilation targets for local dev Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 3fa41bc24..48a9a8a85 100644 --- a/Makefile +++ b/Makefile @@ -242,14 +242,14 @@ build-whatsapp-native: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) - $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) - GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) + #GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + #GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + #GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + #GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + #GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + #$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) + #GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + #GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) ## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete" ## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)