stabilize provider prompt prefix

This commit is contained in:
Ollie Agent 2026-08-15 13:57:31 +02:00
parent 5dbfa3060f
commit 64b76d9bf0
4 changed files with 59 additions and 70 deletions

View File

@ -414,6 +414,9 @@ func (s *History) injectPreamble(preamble string) {
}
msg := backend.Message{Role: "system", Content: preamble}
if len(s.messages) > 0 && s.messages[0].Role == "system" {
if s.messages[0].Content == preamble {
return
}
s.messages[0] = msg
} else {
s.messages = append([]backend.Message{msg}, s.messages...)

View File

@ -5,8 +5,8 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/prompts"
@ -75,15 +75,16 @@ const (
// --- Environment block ---
// EnvironmentBlock renders the environment context section for the preamble.
// EnvironmentBlock renders stable environment context for the preamble.
// Volatile values such as the current date do not belong in the provider-cached
// prefix and are supplied separately when needed.
func EnvironmentBlock(cwd, platform string, isGitRepo bool, sandbox string) string {
date := time.Now().Format("2006-01-02")
git := "false"
if isGitRepo {
git = "true"
}
return fmt.Sprintf("# Environment\n\n- Working directory: %s\n- Platform: %s\n- Current date: %s\n- Is git repo: %s\n- Sandbox: %s",
cwd, platform, date, git, sandbox)
return fmt.Sprintf("# Environment\n\n- Working directory: %s\n- Platform: %s\n- Is git repo: %s\n- Sandbox: %s",
cwd, platform, git, sandbox)
}
// --- Tool rendering ---
@ -92,6 +93,13 @@ func EnvironmentBlock(cwd, platform string, isGitRepo bool, sandbox string) stri
// followed immediately by its documentation (if any). This keeps the listing
// and usage instructions adjacent so models don't lose context between them.
func RenderTools(infos []toolsrv.ToolInfo) string {
infos = append([]toolsrv.ToolInfo(nil), infos...)
sort.SliceStable(infos, func(i, j int) bool {
if infos[i].Name == infos[j].Name {
return infos[i].Server < infos[j].Server
}
return infos[i].Name < infos[j].Name
})
var sb strings.Builder
for _, ti := range infos {
if ti.Server != "" {
@ -129,17 +137,17 @@ func RenderTools(infos []toolsrv.ToolInfo) string {
// agent. The agent struct stores a pointer to the active Runtime; switching
// agents replaces it atomically.
type Runtime struct {
Backend backend.Backend
ToolServer *toolsrv.Conn
Preamble *Preamble
Tools []backend.Tool
ToolMeta map[string]toolsrv.ToolInfo
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
Backend backend.Backend
ToolServer *toolsrv.Conn
Preamble *Preamble
Tools []backend.Tool
ToolMeta map[string]toolsrv.ToolInfo
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
CompactionModel string
UserPrompt string // resolved user prompts, prepended to each turn's input
Messages []string
UserPrompt string // resolved user prompts, prepended to each turn's input
Messages []string
}
// PreambleString returns the fully rendered system prompt.

View File

@ -7,75 +7,57 @@ import (
"ollie/toolsrv"
)
func TestEnvironmentBlockStable(t *testing.T) {
got := EnvironmentBlock("/work", "linux", true, "default")
want := "# Environment\n\n- Working directory: /work\n- Platform: linux\n- Is git repo: true\n- Sandbox: default"
if got != want {
t.Fatalf("EnvironmentBlock() = %q, want %q", got, want)
}
if strings.Contains(got, "Current date") {
t.Fatal("environment block contains volatile current date")
}
}
func TestPreambleSections(t *testing.T) {
toolInfos := []toolsrv.ToolInfo{
{
Name: "file_read",
Description: "Read a file with line numbers",
Prompt: "**file_read**\n\nRead a file with line numbers...",
InputSchema: []byte(`{"type":"object"}`),
Server: "",
},
{
Name: "file_write",
Description: "Create or overwrite a file",
Prompt: "**file_write**\n\nCreate or overwrite a file...",
InputSchema: []byte(`{"type":"object"}`),
Server: "remote", // should be excluded
},
{
Name: "shell",
Description: "Execute shell command",
Prompt: "",
InputSchema: []byte(`{}`),
Server: "",
},
{Name: "file_read", Description: "Read a file with line numbers", Prompt: "**file_read**\n\nRead a file with line numbers...", InputSchema: []byte(`{"type":"object"}`)},
{Name: "file_write", Description: "Create or overwrite a file", Prompt: "**file_write**\n\nCreate or overwrite a file...", InputSchema: []byte(`{"type":"object"}`), Server: "remote"},
{Name: "shell", Description: "Execute shell command", InputSchema: []byte(`{}`)},
}
p := &Preamble{}
p.Set(SectionSystem, "# Core System Prompt\nTest")
p.Set(SectionEnv, "# Environment\n- Test env")
p.Set(SectionAgent, "# Agent Prompt\nTest instructions")
p.Set(SectionTools, RenderTools(toolInfos))
preamble := p.String()
// Must contain core sections
for _, want := range []string{
"# Core System Prompt\nTest",
"# Environment\n- Test env",
"# Agent Prompt\nTest instructions",
"# Tools",
"## file_read",
"**file_read**\n\nRead a file with line numbers...",
"## shell",
"Execute shell command",
} {
for _, want := range []string{"# Core System Prompt\nTest", "# Environment\n- Test env", "# Agent Prompt\nTest instructions", "# Tools", "## file_read", "**file_read**\n\nRead a file with line numbers...", "## shell", "Execute shell command"} {
if !strings.Contains(preamble, want) {
t.Errorf("preamble missing: %q", want)
}
}
// Remote server tools excluded
if strings.Contains(preamble, "file_write") {
t.Error("preamble should exclude tools with Server != \"\"")
}
}
func TestRenderToolsStableOrder(t *testing.T) {
infos := []toolsrv.ToolInfo{{Name: "z_tool", Description: "z"}, {Name: "a_tool", Description: "a"}}
first := RenderTools(infos)
second := RenderTools([]toolsrv.ToolInfo{infos[1], infos[0]})
if first != second || strings.Index(first, "a_tool") > strings.Index(first, "z_tool") {
t.Fatalf("tool rendering is not deterministic: %q", first)
}
}
func TestPreambleNoTools(t *testing.T) {
p := &Preamble{}
p.Set(SectionSystem, "# Core System Prompt\nTest")
p.Set(SectionEnv, "# Environment\n- Test env")
p.Set(SectionAgent, "# Agent Prompt\nTest instructions")
p.Set(SectionTools, RenderTools(nil))
preamble := p.String()
if strings.Contains(preamble, "# Tools") {
t.Error("no tools → no Tools section")
}
if !strings.Contains(preamble, "# Core System Prompt") {
t.Error("core section missing")
if strings.Contains(preamble, "# Tools") || !strings.Contains(preamble, "# Core System Prompt") {
t.Fatal("unexpected no-tools preamble")
}
}
@ -84,13 +66,9 @@ func TestPreambleSetReplaces(t *testing.T) {
p.Set(SectionSystem, "first")
p.Set(SectionEnv, "env")
p.Set(SectionSystem, "second")
got := p.String()
if strings.Contains(got, "first") {
t.Error("Set should replace, not append")
}
if !strings.Contains(got, "second") {
t.Error("Set should store new value")
if strings.Contains(got, "first") || !strings.Contains(got, "second") {
t.Fatalf("preamble replacement failed: %q", got)
}
}
@ -99,10 +77,7 @@ func TestPreambleEmptySectionsOmitted(t *testing.T) {
p.Set(SectionSystem, "system")
p.Set(SectionEnv, "")
p.Set(SectionAgent, "agent")
got := p.String()
// Should be "system\nagent" with no extra newlines from empty env
if strings.Contains(got, "\n\n") {
if got := p.String(); strings.Contains(got, "\n\n") {
t.Errorf("empty sections should be omitted, got: %q", got)
}
}

View File

@ -135,7 +135,10 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
meta[info.Name] = info
}
ag.runtime.ToolMeta = meta
ag.runtime.Preamble.Set(SectionTools, RenderTools(ti))
toolPreamble := RenderTools(ti)
if ag.runtime.Preamble.Get(SectionTools) != toolPreamble {
ag.runtime.Preamble.Set(SectionTools, toolPreamble)
}
}
// Install turn-scoped output handler to intercept events for reply