From 76c5e696d55b1f38a655b2622d2fb21a038daf5d Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 4 Apr 2026 11:28:11 +0200 Subject: [PATCH] agent: auto-nudge when model narrates intent without acting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the model emits a text-only turn containing narration phrases ("let me", "i'll", "i will", etc.) with no tool calls, the loop now injects an ephemeral "Continue. Act now." user message and runs another step rather than treating the turn as completion. Capped at 2 nudges per run to prevent infinite loops. UI shows "[nudge: continuing…]" so the user can see what happened. Co-Authored-By: Claude Sonnet 4.6 --- agent/loop.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 4 ++++ 2 files changed, 60 insertions(+) diff --git a/agent/loop.go b/agent/loop.go index 0395962..5a05d99 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -12,6 +12,29 @@ import ( ) const maxRateLimitRetries = 3 +const maxNudges = 2 + +// nudgeMsg is injected as a user turn when the model narrates intent without +// acting. It is ephemeral — added to the history slice for the next ChatStream +// call but never persisted to State, so it won't appear in future sessions. +const nudgeMsg = "Continue. Use execute_code now — do not describe what you will do." + +// narrationPhrases are case-insensitive prefixes/substrings that indicate the +// model is narrating intent rather than acting. +var narrationPhrases = []string{ + "let me", + "i'll ", + "i will ", + "i'm going to", + "i am going to", + "i need to", + "first, i", + "first i'll", + "now i'll", + "now i will", + "to do this", + "i'll now", +} type ToolExecutor func(name string, args json.RawMessage) (string, error) type OutputFn func(msg OutputMsg) @@ -39,11 +62,19 @@ func Run(ctx context.Context, cfg Config, state State) error { maxSteps = 1 } + var pendingNudge string + nudgeCount := 0 + for step := range maxSteps { history := state.History() if cfg.SystemPrompt != "" { history = append([]backend.Message{{Role: "system", Content: cfg.SystemPrompt}}, history...) } + // Inject nudge from previous step if the model narrated instead of acted. + if pendingNudge != "" { + history = append(history, backend.Message{Role: "user", Content: pendingNudge}) + pendingNudge = "" + } // Stream the assistant's response, retrying on HTTP 429. var ch <-chan backend.StreamEvent @@ -130,6 +161,14 @@ func Run(ctx context.Context, cfg Config, state State) error { } if len(toolCalls) == 0 { + // If the model narrated intent without acting, nudge it to continue + // rather than treating the turn as completion. + if nudgeCount < maxNudges && isNarration(content.String()) { + nudgeCount++ + pendingNudge = nudgeMsg + emit(cfg, OutputMsg{Role: "nudge", Content: nudgeMsg}) + continue + } if err := state.MarkComplete(); err != nil { return fmt.Errorf("mark complete: %w", err) } @@ -143,6 +182,23 @@ func Run(ctx context.Context, cfg Config, state State) error { return nil } +// isNarration returns true when text looks like the model is describing what it +// is about to do rather than doing it. Only short responses are considered — +// a longer response likely contains actual content or analysis. +func isNarration(text string) bool { + text = strings.TrimSpace(text) + if text == "" || len(text) > 800 { + return false + } + lower := strings.ToLower(text) + for _, phrase := range narrationPhrases { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + func emit(cfg Config, msg OutputMsg) { if cfg.Output != nil { cfg.Output(msg) diff --git a/main.go b/main.go index 7c58398..9856a42 100644 --- a/main.go +++ b/main.go @@ -468,6 +468,10 @@ func (m *model) apply(am agentMsg) { } m.display = append(m.display, "= "+s) + case "nudge": + // Model narrated without acting; loop injected a continuation prompt. + m.display = append(m.display, "[nudge: continuing…]") + case "retry": m.state = agentRetrying if secs, err := strconv.Atoi(am.content); err == nil {