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.