From 64b76d9bf0d7fe1692a1e1561339297897b22e09 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sat, 15 Aug 2026 13:57:31 +0200 Subject: [PATCH] stabilize provider prompt prefix --- cmd/olliesrv/internal/agent/history.go | 3 + cmd/olliesrv/internal/agent/runtime.go | 38 ++++++---- cmd/olliesrv/internal/agent/runtime_test.go | 83 +++++++-------------- cmd/olliesrv/internal/agent/turn.go | 5 +- 4 files changed, 59 insertions(+), 70 deletions(-) diff --git a/cmd/olliesrv/internal/agent/history.go b/cmd/olliesrv/internal/agent/history.go index ae7e14d..c9c52cc 100644 --- a/cmd/olliesrv/internal/agent/history.go +++ b/cmd/olliesrv/internal/agent/history.go @@ -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...) diff --git a/cmd/olliesrv/internal/agent/runtime.go b/cmd/olliesrv/internal/agent/runtime.go index b527c86..932ed32 100644 --- a/cmd/olliesrv/internal/agent/runtime.go +++ b/cmd/olliesrv/internal/agent/runtime.go @@ -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. diff --git a/cmd/olliesrv/internal/agent/runtime_test.go b/cmd/olliesrv/internal/agent/runtime_test.go index 00eb3c1..a110781 100644 --- a/cmd/olliesrv/internal/agent/runtime_test.go +++ b/cmd/olliesrv/internal/agent/runtime_test.go @@ -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) } } diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index 303064b..6ebe00d 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -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