From b70f085194f943a6942bb357bbbb8d6fbf30b08d Mon Sep 17 00:00:00 2001 From: Troy Hernandez Date: Thu, 14 May 2026 13:06:49 -0500 Subject: [PATCH 1/2] Catch interrupts in the agent turn loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent loop in both corteza::chat() and the ~/bin/corteza CLI wrapped turn() in a tryCatch that only caught errors, so an R-level interrupt (Esc in RStudio, Ctrl+C in either) escaped the REPL and dropped the user out of the session mid-tool-call. There was no way to abort a multi-step loop without killing R. Catch the interrupt condition at the turn() boundary. In the CLI, inspect the callr worker's state: if it's "busy" a tool call was in flight, so send SIGINT to the child and drain the queued result; recycle the worker only if it doesn't return to "idle". When the interrupt arrived during the LLM round-trip the worker is already idle and we leave it alone so we don't drop worker-local state like the background-process registry. Inject a synthetic [Interrupted by user before completing.] marker into history (turn_session$history in chat(), the persistent session in the CLI) so the next turn's LLM sees the prior exchange was aborted instead of silently losing the prompt. Terminal Esc remains unsupported — terminals send raw \\033 for Esc, not a signal — and is documented as such in NEWS. --- NEWS.md | 19 +++++++++++++++++++ R/chat.R | 15 +++++++++++++++ inst/bin/corteza | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/NEWS.md b/NEWS.md index c79a674..e3d2187 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,22 @@ +# corteza 0.6.6.1 + +## Interrupt key + +* Pressing the interrupt key during an in-flight agent turn now aborts + the turn cleanly and returns control to the prompt instead of + escaping the REPL entirely. Both `corteza::chat()` and the + `~/bin/corteza` CLI catch the R-level interrupt. +* In the CLI, if the interrupt arrives while a tool call is running + inside the `callr` worker subprocess, the worker is sent SIGINT so + the in-flight tool (e.g. a long `bash` or `run_r` call) actually + stops. The worker is recycled only if it doesn't return to idle. +* The aborted exchange is recorded in history with an + `[Interrupted by user before completing.]` marker so the next turn's + model sees that the prior turn ended early. +* In `corteza::chat()` running under RStudio, Esc fires the interrupt + as expected. In the terminal CLI, only Ctrl+C is an interrupt — + terminals send raw `^[` for Esc, which is not a signal. + # corteza 0.6.6 ## Async subagent queries diff --git a/R/chat.R b/R/chat.R index b1b8c3f..730f4f0 100644 --- a/R/chat.R +++ b/R/chat.R @@ -698,6 +698,21 @@ chat <- function(provider = NULL, model = NULL, tools = NULL, session = NULL, pre_turn_len <- length(turn_session$history %||% list()) result <- tryCatch( turn(prompt, turn_session), + interrupt = function(c) { + cat(sprintf("\n%sInterrupted.%s\n", color$yellow, color$reset)) + # turn() didn't return, so its history update never landed. + # Stitch the user prompt and an interruption marker into + # turn_session$history so the next turn's LLM call sees + # this exchange was aborted instead of silently dropping it. + marker <- "[Interrupted by user before completing.]" + turn_session$history <- c( + turn_session$history %||% list(), + list(list(role = "user", content = prompt), + list(role = "assistant", content = marker)) + ) + transcript_append(disk_session$session, "assistant", marker) + NULL + }, error = function(e) { message(sprintf("%sError:%s %s", color$bright_magenta, color$reset, e$message)) diff --git a/inst/bin/corteza b/inst/bin/corteza index 292e407..8d9fc22 100755 --- a/inst/bin/corteza +++ b/inst/bin/corteza @@ -1760,6 +1760,44 @@ Tool output: history = turn_session$history, usage = r$usage ) + }, interrupt = function(c) { + cat(sprintf("\n%sInterrupted.%s\n", color$yellow, color$reset)) + # Only touch the worker if a tool call was actually in flight + # (state "busy"). When the interrupt arrived during the LLM + # round-trip the worker is "idle" and should be left alone — + # otherwise we'd needlessly drop worker-local state like the + # background-process registry. + if (!is.null(worker) && !is.null(worker$session)) { + state <- tryCatch(worker$session$get_state(), + error = function(e) "unknown") + if (identical(state, "busy")) { + tryCatch(worker$session$interrupt(), + error = function(e) NULL) + # Drain any queued result so the state machine returns to idle. + tryCatch(worker$session$read(), error = function(e) NULL) + state <- tryCatch(worker$session$get_state(), + error = function(e) "unknown") + if (!identical(state, "idle")) { + # Worker didn't recover from SIGINT; recycle it. + suppressWarnings(tryCatch(cli_worker_close(worker), + error = function(e) NULL)) + worker <<- tryCatch( + cli_worker_connect(opts$port, cwd = cwd, + tools_filter = opts$tools), + error = function(e2) NULL + ) + } + } + } + # Record the interruption against the persistent session so the + # next turn's api_history shows an assistant placeholder where + # the aborted reply would have been. The user message was + # already saved before turn() ran. + marker <- "[Interrupted by user before completing.]" + session <<- corteza:::session_add_message(session, "assistant", marker) + corteza:::transcript_append(session, "assistant", marker) + corteza:::session_save(session) + NULL }, error = function(e) { cat(sprintf("%sError: %s%s\n", color$bright_magenta, e$message, color$reset)) NULL From 977705abe9a2b6939632c01e5766b476425e0423 Mon Sep 17 00:00:00 2001 From: Troy Hernandez Date: Thu, 14 May 2026 13:07:03 -0500 Subject: [PATCH 2/2] Stop saber::briefing() leaking to terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_saber_briefing() wrapped saber::briefing() in capture.output(), which only grabs stdout. saber::briefing() emits its full text via message(), so the briefing leaked to the user's terminal every time session_setup() ran — including the subagents that archival spawns post-turn. That made interrupts look like they were restarting the session when they were just landing right before a normal archival cycle. Wrap the briefing call in suppressMessages() so message() is silenced while capture.output() continues to catch any stdout side effects. --- NEWS.md | 6 ++++++ R/context.R | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index e3d2187..a4912b7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -17,6 +17,12 @@ as expected. In the terminal CLI, only Ctrl+C is an interrupt — terminals send raw `^[` for Esc, which is not a signal. +## Other + +* `load_saber_briefing()` now wraps `saber::briefing()` in + `suppressMessages()` so the briefing text no longer leaks to the + user's terminal every time a subagent calls `session_setup()`. + # corteza 0.6.6 ## Async subagent queries diff --git a/R/context.R b/R/context.R index 6459b99..68cae09 100644 --- a/R/context.R +++ b/R/context.R @@ -181,9 +181,14 @@ load_saber_briefing <- function(cwd) { project <- basename(cwd) scan_dir <- dirname(cwd) tryCatch({ - # Suppress saber's cat() to stdout - we want the return value only + # saber::briefing() emits its full text via message(); without + # suppressMessages it leaks to the user's terminal every time a + # subagent calls session_setup, which masquerades as a session + # restart. capture.output() handles any stdout cat() too. utils::capture.output( - text <- saber::briefing(project = project, scan_dir = scan_dir) + text <- suppressMessages( + saber::briefing(project = project, scan_dir = scan_dir) + ) ) if (is.null(text) || nchar(trimws(text)) == 0L) { NULL