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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-11 22:25:37 +02:00
parent 52999c8560
commit ae82e0eff0
2 changed files with 35 additions and 8 deletions

View File

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

View File

@ -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<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
}
}
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<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
}