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 {