diff --git a/external/eyrie b/external/eyrie index ed620222..2de6e53e 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit ed620222fee915e3ee3a9d628ce2b91bc154f5c5 +Subproject commit 2de6e53e84612df49cf70bb1b4f627074d45ed7d diff --git a/go.mod b/go.mod index a0b9c871..de6ece11 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 - github.com/GrayCodeAI/eyrie v0.2.2 + github.com/GrayCodeAI/eyrie v0.2.3-0.20260811021922-2de6e53e8461 github.com/GrayCodeAI/hawk-core-contracts v0.1.12 github.com/GrayCodeAI/inspect v0.0.0-20260726091806-08f3151d5738 github.com/GrayCodeAI/sight v0.0.0-20260726091804-84c96edfc589 diff --git a/go.sum b/go.sum index 6a41de4a..82e610db 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8 github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GrayCodeAI/eyrie v0.2.2 h1:iMURJ9lJ2MqVi1uvXEY4hOAD5oL8bS32X4nVh7NoACg= -github.com/GrayCodeAI/eyrie v0.2.2/go.mod h1:AW/UPuj+EWxMibiD+/Cy0TWd6RmTvth+KeGXSxU3t6I= +github.com/GrayCodeAI/eyrie v0.2.3-0.20260811021922-2de6e53e8461 h1:eERy9jYNIGUxx+QB9ksJA9Vz7HcQMjYZG87IvWNEcbU= +github.com/GrayCodeAI/eyrie v0.2.3-0.20260811021922-2de6e53e8461/go.mod h1:AW/UPuj+EWxMibiD+/Cy0TWd6RmTvth+KeGXSxU3t6I= github.com/GrayCodeAI/hawk-core-contracts v0.1.12 h1:percfsd771JLmO9gMkrQtENEPBA9ZN3dG1Nc1moN3ZQ= github.com/GrayCodeAI/hawk-core-contracts v0.1.12/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-mcpkit v0.1.6-0.20260729083555-85ac53f3ec84 h1:HzoXUYNNyt88IccaPBxSvOQ/5PZzJOcSHbvBjX3l2mQ= diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 79fa161f..2f05a06a 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -16,6 +16,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/hooks" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/plugin" + "github.com/GrayCodeAI/hawk/internal/prompt" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/ui/icons" @@ -217,11 +218,33 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } } + // Payload tiering: classify the latest user request once per turn. + // Early conversational turns get a minimal system prompt and no tool + // schemas; anything resembling work keeps the full prompt and the + // promoted tool surface. This keeps simple turns cheap on slow + // (local/remote) models without starving real requests of tools. + lastUserMsg := "" + for i := len(s.Persistence().RawMessages()) - 1; i >= 0; i-- { + m := s.Persistence().RawMessages()[i] + if m.Role == "user" && len(m.ToolResults) == 0 { + lastUserMsg = m.Content + break + } + } + smallTalk := lastUserMsg != "" && isSmallTalkPrompt(lastUserMsg) && !sessionHasToolUse(s.Persistence().RawMessages()) + // Build the LLM ChatOptions via the ChatService. The service owns // the GLMThinking toggle, output schema, anthropic caching flag, // and the active provider/model — building opts manually here // would duplicate that logic. - baseOpts := s.ChatLLM().BuildOptions(s.Persistence().System(), activeModel, maxTok, nil) + baseSystem := s.Persistence().System() + if smallTalk { + // The identity preamble already coaches the model to answer + // greetings without tools — the role/tool/practice sections + // below it only add prefill cost on this turn. + baseSystem = prompt.System() + } + baseOpts := s.ChatLLM().BuildOptions(baseSystem, activeModel, maxTok, nil) opts := baseOpts // Inject beliefs as ephemeral context (not persisted to s.Persistence().System()) if s.LifecycleSvc().Beliefs() != nil && s.LifecycleSvc().Beliefs().Size() > 0 { @@ -273,18 +296,12 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { // current request. This keeps the default schema compact while // making URL, verification, git, and code-intelligence requests // discoverable without requiring the model to guess ToolSearch. - lastUserMsg := "" - for i := len(s.Persistence().RawMessages()) - 1; i >= 0; i-- { - msg := s.Persistence().RawMessages()[i] - if msg.Role == "user" && len(msg.ToolResults) == 0 { - lastUserMsg = msg.Content - break - } - } if lastUserMsg != "" { s.Tools().Registry().PromoteForIntent(lastUserMsg) } - opts.Tools = s.Tools().Registry().EyrieTools() + if !smallTalk { + opts.Tools = s.Tools().Registry().EyrieTools() + } } // Inject memory metadata from yaad @@ -874,6 +891,37 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } } +// isSmallTalkPrompt reports whether prompt is a pure conversational +// exchange (greeting, pleasantry, identity question) that needs neither the +// full system prompt nor any tool schemas. The system prompt instructs the +// model to answer these directly, so sending the tool surface would only +// add prompt-prefill cost. +func isSmallTalkPrompt(prompt string) bool { + text := strings.ToLower(strings.TrimSpace(prompt)) + text = strings.Trim(text, " \t\r\n.,!?;:") + switch text { + case "hi", "hello", "hey", "how are you", "how are you doing", "how's it going", "what's up", + "who are you", "what can you do", "thanks", "thank you", "good morning", "good afternoon", + "good evening", "nice to meet you", "goodbye", "bye": + return true + default: + return false + } +} + +// sessionHasToolUse reports whether any message in the conversation already +// executed a tool. Once tools are in play, later turns keep the full prompt +// and tool surface even if they read like small talk ("thanks"), so the +// follow-up context is not lost. +func sessionHasToolUse(msgs []types.EyrieMessage) bool { + for _, m := range msgs { + if len(m.ToolResults) > 0 { + return true + } + } + return false +} + // extractDataURI extracts the first base64 data URI from a string. // Returns the full data URI (e.g., "data:image/png;base64,...") or empty string. func extractDataURI(s string) string { diff --git a/internal/engine/stream_prompt_test.go b/internal/engine/stream_prompt_test.go new file mode 100644 index 00000000..7c6a6b17 --- /dev/null +++ b/internal/engine/stream_prompt_test.go @@ -0,0 +1,46 @@ +package engine + +import ( + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestIsSmallTalkPrompt(t *testing.T) { + tests := []struct { + prompt string + skip bool + }{ + {prompt: "Hi", skip: true}, + {prompt: "Hello!", skip: true}, + {prompt: "what can you do?", skip: true}, + {prompt: "how's it going", skip: true}, + {prompt: "good morning", skip: true}, + {prompt: "thanks", skip: true}, + {prompt: "Hi, inspect this repository", skip: false}, + {prompt: "run the tests", skip: false}, + {prompt: "hello there, who fixes this bug?", skip: false}, + } + for _, tt := range tests { + t.Run(tt.prompt, func(t *testing.T) { + if got := isSmallTalkPrompt(tt.prompt); got != tt.skip { + t.Fatalf("isSmallTalkPrompt(%q) = %v, want %v", tt.prompt, got, tt.skip) + } + }) + } +} + +func TestSessionHasToolUse(t *testing.T) { + plain := []types.EyrieMessage{{Role: "user", Content: "hi"}} + used := []types.EyrieMessage{ + {Role: "user", Content: "read stream.go"}, + {Role: "assistant", ToolUse: []types.ToolCall{{Name: "Read"}}}, + {Role: "user", Content: "thanks", ToolResults: []types.ToolResult{{}}}, + } + if sessionHasToolUse(plain) { + t.Fatal("sessionHasToolUse(plain) = true, want false") + } + if !sessionHasToolUse(used) { + t.Fatal("sessionHasToolUse(used) = false, want true") + } +} diff --git a/internal/sandbox/sandbox.Dockerfile b/internal/sandbox/sandbox.Dockerfile index baa41e4d..949ce57f 100644 --- a/internal/sandbox/sandbox.Dockerfile +++ b/internal/sandbox/sandbox.Dockerfile @@ -4,6 +4,7 @@ RUN npm install --global npm@12.0.2 && \ npm cache clean --force && \ rm -rf /root/.npm && \ apt-get update && \ + apt-get upgrade -y --no-install-recommends && \ apt-get install -y --no-install-recommends \ bash \ ca-certificates \