agent: auto-nudge when model narrates intent without acting

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-04 11:28:11 +02:00
parent ab575ee385
commit 76c5e696d5
2 changed files with 60 additions and 0 deletions

View File

@ -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)

View File

@ -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 {