From ae82e0eff0af9be31a54e8dda3224ebeb27ccd28 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 11 Apr 2026 22:25:37 +0200 Subject: [PATCH] agent: inject mid-turn prompts at tool boundaries instead of queueing - Submit routes to Inject when a turn is already running, keeping the routing decision in core rather than the frontend or 9p layer - Inject no longer cancels the current action; the pending prompt is stored and consumed at the next tool result boundary (success or failure), matching goq interrupt semantics - If an inject is already pending, fall back to the FIFO so nothing is lost; /irw (interrupt-rewrite) explicitly overwrites the pending inject - PopInject is now called after every tool result, not just the last one - Injected prompts are echoed to the chat via the running turn's output Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/core.go | 35 +++++++++++++++++++++++++++++------ pkg/agent/loop.go | 8 ++++++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/pkg/agent/core.go b/pkg/agent/core.go index b93e8aa..bee6728 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -389,12 +389,20 @@ func (s *agentCore) Interrupt(cause error) bool { } func (s *agentCore) Inject(prompt string) { - s.pendingInject.Store(&prompt) - // Cancel the current action so blocking tools (execute_code, etc.) - // are killed immediately and the model sees the interruption. - if cancel := s.getActionCancel(); cancel != nil { - cancel(ErrInterrupted) + // If an inject is already pending, fall back to the normal FIFO so nothing + // is lost. Use CompareAndSwap to avoid a race between the nil check and store. + if !s.pendingInject.CompareAndSwap(nil, &prompt) { + s.fifo.Push(prompt) + return } + emit(s.loopcfg, Event{Role: "info", Content: "\n"}) + emit(s.loopcfg, Event{Role: "user", Content: prompt}) +} + +func (s *agentCore) injectRewrite(prompt string) { + s.pendingInject.Store(&prompt) + emit(s.loopcfg, Event{Role: "info", Content: "\n"}) + emit(s.loopcfg, Event{Role: "user", Content: prompt}) } func (s *agentCore) Queue(prompt string) { @@ -487,12 +495,18 @@ func firstSentence(s string) string { // Submit implements Core. It processes one line of user input: slash commands // and shell shortcuts are dispatched immediately via handler; any other input -// starts an agent turn that streams events to handler. +// starts an agent turn that streams events to handler. If a turn is already +// in progress the prompt is queued as an in-stream interruption instead. func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandler) { if s.handleCommand(ctx, input, handler) { return } + if s.IsRunning() { + s.Inject(input) + return + } + handler(Event{Role: "user", Content: input}) hookResult := s.hooks.Run(ctx, HookUserPromptSubmit, map[string]string{ @@ -635,6 +649,15 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve args := parts[1:] switch cmd { + case "/irw": + prompt := strings.Join(args, " ") + if prompt == "" { + handler(infoEvent("error: /irw requires a prompt")) + return true + } + s.injectRewrite(prompt) + return true + case "/backend": if len(args) == 0 { handler(infoEvent("error: /backend requires an argument (e.g., /backend ollama)")) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e743245..c1eb572 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -151,6 +151,11 @@ func run(ctx context.Context, cfg loopConfig, state state) error { isErr = true // If cancelled during execution, fill remaining and break. if ctx.Err() != nil { + if cfg.PopInject != nil { + if injected := cfg.PopInject(); injected != "" { + result += "\n\n\n" + injected + "\n" + } + } results = append(results, toolResult{ ToolCallID: tc.ID, Name: tc.Name, @@ -177,8 +182,7 @@ func run(ctx context.Context, cfg loopConfig, state state) error { } if !interrupted { - // Append any pending user interruption to the last tool result. - if i == len(toolCalls)-1 && cfg.PopInject != nil { + if cfg.PopInject != nil { if injected := cfg.PopInject(); injected != "" { result += "\n\n\n" + injected + "\n" }