Skip to content

Commit f8bdce7

Browse files
committed
Summarize tool calls
1 parent ea1c0b9 commit f8bdce7

File tree

3 files changed

+161
-7
lines changed

3 files changed

+161
-7
lines changed

R/ragnar-chat.R

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ RagnarChat <- R6::R6Class(
118118
)
119119
},
120120

121+
stream_async = function(..., tool_mode = c("concurrent", "sequential"), stream = c("text", "content")) {
122+
result <- private$callback_user_turn(...)
123+
do.call(
124+
super$stream_async,
125+
append(result, list(tool_mode = tool_mode, stream = stream))
126+
)
127+
},
128+
129+
stream = function(..., stream = c("text", "content")) {
130+
result <- private$callback_user_turn(...)
131+
do.call(super$stream, append(result, list(stream = stream)))
132+
},
133+
121134
#' @field ragnar_tool A function that retrieves relevant chunks from the store.
122135
#' This is the function that is registered as a tool in the chat.
123136
ragnar_tool = function(query) {
@@ -413,6 +426,34 @@ RagnarChat <- R6::R6Class(
413426
...,
414427
ContentRagnarDocuments(text = documents)
415428
)
429+
},
430+
431+
#' @description
432+
#' Summarizes the tools calls in the chat history. The assistant tool
433+
#' call request is redacted, and the chunks are summarized.
434+
#'
435+
#' The ellmer::ToolCallRequest becomes a `ellmer::ContentText` with:
436+
#' ```
437+
#' <Redacted by summarization tool: query={}>
438+
#' ```
439+
#'
440+
#' The `ellmer::ContentToolResult` becomes a `ellmer::ContentText` with
441+
#' a summary of the chunks given by the `summarize_chunks` callback.
442+
#' `summarize_chunks` takes a list of `chunks` as argument.
443+
#'
444+
turns_summarize_tool_calls = function(summarize_chunks) {
445+
turns <- self$get_turns() |>
446+
turns_modify_tool_calls(function(assistant_turn, user_turn) {
447+
summarize_tool_call(
448+
assistant_turn = assistant_turn,
449+
user_turn = user_turn,
450+
summarize_chunks,
451+
tool_name = self$ragnar_tool_def@name
452+
)
453+
},
454+
tool_name = self$ragnar_tool_def@name
455+
)
456+
self$set_turns(turns)
416457
}
417458
),
418459

@@ -457,3 +498,60 @@ content_set_chunks <- function(x, chunks) {
457498
}
458499
x
459500
}
501+
502+
#' Applies a function to pairs of turns that represent tool calls.
503+
#' Otherwise keep them unchanged.
504+
#' @noRd
505+
turns_modify_tool_calls <- function(turns, fn, tool_name = NULL) {
506+
i <- 1
507+
while (i < length(turns)) {
508+
if (turns[[i]]@role == "user") {
509+
i <- i + 1; next
510+
};
511+
if (!is_tool_call(turns[[i]], tool_name)) {
512+
i <- i + 1; next;
513+
}
514+
515+
turns[c(i, i+1)] <- fn(turns[[i]], turns[[i+1]])
516+
i <- i + 2
517+
}
518+
turns
519+
}
520+
521+
is_tool_call <- function(x, tool_name = NULL) {
522+
for (content in x@contents) {
523+
if (S7::S7_inherits(content, ellmer::ContentToolRequest)) {
524+
if (length(x@contents) > 1) {
525+
cli::cli_warn(
526+
"Tool call request found in turn with multiple contents, this may lead to unexpected results."
527+
)
528+
}
529+
if (!is.null(tool_name) && content@tool@name != tool_name) {
530+
return(FALSE)
531+
}
532+
533+
return(TRUE)
534+
}
535+
}
536+
FALSE
537+
}
538+
539+
summarize_tool_call <- function(assistant_turn, user_turn, summarize, tool_name) {
540+
assistant_turn@contents <- list(
541+
ellmer::ContentText(
542+
text = paste0(
543+
"<Redacted by summarization tool: query=",
544+
user_turn@contents[[1]]@request@arguments$query, ">"
545+
)
546+
)
547+
)
548+
549+
summary <- lapply(user_turn@contents, \(x) content_get_chunks(x, tool_name)) |>
550+
unlist(recursive = FALSE) |>
551+
summarize()
552+
553+
user_turn@contents <- list(ellmer::ContentText(text = summary))
554+
555+
list(assistant_turn, user_turn)
556+
}
557+

tests/testthat/test-ragnar-chat.R

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,31 @@ test_that("Can insert chunks premptively in the user chat", {
131131
expect_equal(length(chat$get_turns()), 2)
132132
expect_equal(length(chat$get_turns()[[1]]@contents), 1)
133133
})
134+
135+
test_that("Can summarize tool calls", {
136+
137+
store <- test_store()
138+
chat <- chat_ragnar(
139+
\() ellmer::chat_openai(model = "gpt-4.1-nano"),
140+
store = store
141+
)
142+
143+
x <- chat$chat("functional")
144+
145+
summarize_chunks <- function(chunks) {
146+
json <- jsonlite::toJSON(chunks)
147+
ellmer::chat_openai(
148+
model = "gpt-4.1-nano",
149+
system_prompt = "Summarize the following chunks of text.",
150+
echo = FALSE
151+
)$chat(json, echo = FALSE)
152+
}
153+
154+
chat$turns_summarize_tool_calls(summarize_chunks)
155+
156+
turns <- chat$get_turns()
157+
expect_equal(length(turns), 4) # 2 user + 2 assistant
158+
expect_equal(turns[[2]]@role, "assistant")
159+
expect_true(grepl("Redacted by summarization tool", turns[[2]]@contents[[1]]@text))
160+
expect_true(S7::S7_inherits(turns[[3]]@contents[[1]], ellmer::ContentText))
161+
})

vignettes/articles/ragnar-chat.Rmd

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ Now we create a `ragnar_chat` object that uses the `query_rewrite` function to r
6868

6969
```{r}
7070
chat <- chat_ragnar(
71-
ellmer::chat_openai,
72-
.store = store,
73-
.retrieve = function(self, query) {
71+
\() ellmer::chat_openai(),
72+
store = store,
73+
retrieve = function(self, query) {
7474
queries <- query_rewrite(query)
7575
cli::cli_inform(
7676
i = "Rewriten queries:",
@@ -104,13 +104,13 @@ Here's an example of how to implement this callback:
104104

105105
```{r}
106106
chat <- chat_ragnar(
107-
ellmer::chat_openai,
108-
.store = store,
107+
\() ellmer::chat_openai(),
108+
store = store,
109109
# We explicitly avoid registering the store so the LLM is not capable of making tool
110110
# calls to the store. Instead, it relies on the documents inserted at the top of
111111
# the chat history to answer the question.
112-
.register_store = FALSE,
113-
.on_user_turn = function(self, ...) {
112+
register_store = FALSE,
113+
on_user_turn = function(self, ...) {
114114
self$turns_prune_chunks()
115115
# Inserts documents relevant to the query at the top of the chat history.
116116
self$turns_insert_documents(
@@ -125,3 +125,31 @@ chat <- chat_ragnar(
125125
cat(chat$chat("What is the difference between a vector and a list in R?"))
126126
```
127127

128+
## Summarize tool calls
129+
130+
To save tokens, it's common to summarize the results of the tool calls after the chunks
131+
were used to produce the LLM response. With this, the next message in the chat won't need
132+
to use the full text of the chunks, but rather a summary of the results.
133+
134+
This can be acomplished by customizing the `on_user_turn` callback to summarise the
135+
results of the previous tool calls. For example:
136+
137+
```{r}
138+
summarize_chunks <- function(chunks) {
139+
json <- jsonlite::toJSON(chunks)
140+
ellmer::chat_openai(
141+
model = "gpt-4.1-nano",
142+
system_prompt = "Summarize the following chunks of text.",
143+
echo = FALSE
144+
)$chat(json, echo = FALSE)
145+
}
146+
147+
chat <- chat_ragnar(
148+
\() ellmer::chat_openai(),
149+
store = store,
150+
on_user_turn = function(self, ...) {
151+
self$turns_summarize_tool_calls(summarize_chunks)
152+
self$turns_insert_tool_call_request(..., query = paste(..., collapse = " "))
153+
}
154+
)
155+
```

0 commit comments

Comments
 (0)