diff --git a/NEWS.md b/NEWS.md index c79a674..a4912b7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,28 @@ +# 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. + +## 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/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/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 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