Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion internal/core/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
"the skill's whitelist and prepends the skill's system prompt).\n" +
"- `/scope` — restrict the agent to a namespace or context " +
"(`/scope ns=payments` or `/scope ctx=prod-east`); `/scope reset` clears.\n" +
"- `/use` — switch the active kubeconfig context (e.g. `/use prod-east`).\n" +
"- `/tools` — list the tool groups currently wired plus the reason " +
"any skipped group was skipped.\n" +
"- `/clear` — wipe the stream output (Ctrl+L is the shortcut).\n" +
Expand Down Expand Up @@ -91,6 +92,14 @@ const (
"than saying you don't know — that information IS your context."
)

// BasePreamble exposes the compiled self-knowledge preamble. The TUI
// package uses it to drift-guard its slash-command palette against the
// command list documented here: the two are hand-maintained in separate
// packages (palette.builtinItems vs this string), and a command offered by
// the palette but absent here makes the agent claim it does not know a
// command cloudy actually supports.
func BasePreamble() string { return basePreamble }

// ErrMaxSteps is returned when the agent exhausts its step budget without
// reaching a final (tool-call-free) response.
var ErrMaxSteps = errors.New("agent: maximum steps reached without final response")
Expand Down Expand Up @@ -624,7 +633,13 @@ func truncateMiddle(s string, max int) string {
func (a *Agent) buildSystemPrompt(reg *tools.Registry, skill resolvedSkill) string {
var sb strings.Builder
sb.WriteString(basePreamble)
if a.opts.Skills != nil {
// Skill catalog (all skills by name + description) is listed ONLY when no
// skill is active. Once the operator has switched into a skill — its full
// body is injected just below — the catalog of the OTHER skills is noise
// that only inflates the per-request token floor, which matters on small
// models. With no active skill the catalog lets the model answer "what
// skills do you have?".
if a.opts.Skills != nil && skill.prompt == "" {
all := a.opts.Skills.List()
if len(all) > 0 {
sb.WriteString("\n\n## Available skills\n")
Expand Down
49 changes: 49 additions & 0 deletions internal/core/agent/preamble_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,55 @@ func TestSystemPreamble_InjectsSkillCatalog(t *testing.T) {
}
}

// TestSystemPreamble_DropsCatalogWhenSkillActive pins the token-economy
// rule: once a skill is active its full body is injected, so the catalog of
// every OTHER skill is redundant noise and must be omitted. The catalog
// only appears in the no-active-skill case (covered above).
func TestSystemPreamble_DropsCatalogWhenSkillActive(t *testing.T) {
prov := &capturingProvider{}
reg := tools.New()
skillReg := skills.New([]*skills.Skill{
{
Name: "fake-skill-one",
Description: "first fake skill description",
AllowedTools: []string{"x.y"},
SystemPrompt: "irrelevant",
},
})
active := skills.NewStaticSkill(&skills.Skill{
Name: "active-skill",
Description: "the active one",
AllowedTools: []string{"x.y"},
SystemPrompt: "ACTIVE-SKILL-BODY-MARKER",
})
ag, err := agent.New(agent.Options{
Provider: prov,
Model: "test-model",
Registry: reg,
Skills: skillReg,
Skill: active,
})
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := ag.Run(context.Background(), "hi", render.NewStream(discardWriter{}, render.NewTheme(true))); err != nil {
t.Fatalf("Run: %v", err)
}

sys := systemMessage(prov.captured)
// Assert on catalog CONTENT (the registry skill's name), not the
// "## Available skills" header — basePreamble's prose self-references
// that header phrase, so the header is present even when the catalog
// section is not emitted. The skill name only appears if the catalog
// was actually written.
if strings.Contains(sys, "fake-skill-one") {
t.Errorf("skill catalog must be dropped when a skill is active\n--- prompt ---\n%s\n--- end ---", sys)
}
if !strings.Contains(sys, "## Active skill: active-skill") || !strings.Contains(sys, "ACTIVE-SKILL-BODY-MARKER") {
t.Errorf("active skill body must still be injected\n--- prompt ---\n%s\n--- end ---", sys)
}
}

type discardWriter struct{}

func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
31 changes: 31 additions & 0 deletions internal/ui/tui/preamble_parity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package tui

import (
"strings"
"testing"

"github.com/rlaope/cloudy/internal/core/agent"
)

// TestPalette_CommandsDocumentedInPreamble guards against drift between the
// two hand-maintained slash-command lists that live in separate packages:
//
// - palette.builtinItems (this package) — what the operator can pick, and
// - agent.BasePreamble() — what the LLM is told cloudy supports.
//
// A command offered by the palette but absent from the preamble makes the
// agent answer "I don't know that command" for something cloudy actually
// does — the exact regression the preamble exists to prevent. This test
// caught /use missing from the preamble when the native-scrollback work
// landed; keep it green by documenting every palette command in the
// preamble's "## cloudy slash commands" section.
func TestPalette_CommandsDocumentedInPreamble(t *testing.T) {
preamble := agent.BasePreamble()
for _, item := range builtinItems {
if !strings.Contains(preamble, "/"+item.title) {
t.Errorf("slash command /%s is offered by the palette but not documented in "+
"agent.BasePreamble(); add it to the preamble's slash-command list so the "+
"agent knows the command exists", item.title)
}
}
}
Loading