Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export(chat_session_anthropic)
export(chat_session_ollama)
export(chat_session_openai)
export(create_agent)
export(history_count_tool_calls)
export(history_tool_calls)
export(list_ollama_models)
export(llm_base)
export(llm_key)
Expand Down
18 changes: 18 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# llm.api 0.1.3 (development)

* New exported helpers `history_tool_calls(history)` and
`history_count_tool_calls(history, completed_only = FALSE)` for
walking the message history `agent()` returns. Provider history
must stay native (it's the input format on the next API call), but
consumers now get a single canonical record list instead of having
to know that Anthropic uses `content` blocks (`tool_use` /
`tool_result`) while OpenAI / moonshot / ollama use a separate
`tool_calls` field plus `role = "tool"` result messages. Each
record carries `id`, `name`, `arguments`, `result`, `completed`,
`call_message_index`, `result_message_index`, and `provider_shape`.
* `agent()` now writes the synthesized tool-call id back into the
Ollama assistant message when the upstream response omits one.
Previously `assistant.tool_calls[i].id` and the corresponding
`role = "tool"` message's `tool_call_id` could disagree, breaking
history walks that paired calls with results.

# llm.api 0.1.2.1

* New exported helper `provider_default_model(provider)`. Returns the
Expand Down
12 changes: 10 additions & 2 deletions R/agent.R
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,22 @@ agent <- function(

tool_calls <- list()
if (!is.null(msg$tool_calls)) {
for (tc in msg$tool_calls) {
for (i in seq_along(msg$tool_calls)) {
tc <- msg$tool_calls[[i]]
# Parse arguments from JSON string (same as OpenAI)
args <- tryCatch(
jsonlite::fromJSON(tc$`function`$arguments, simplifyVector = FALSE),
error = function(e) list()
)
# Ollama sometimes omits tc$id; synthesize one and write it back
# into the assistant message so the corresponding role="tool"
# result message can reference the same id. Without this the
# canonical tool_calls list and the on-the-wire history disagree
# on the call id, which breaks history walks.
synthesized_id <- tc$id %||% paste0("call_", sample(1e9, 1))
msg$tool_calls[[i]]$id <- synthesized_id
tool_calls[[length(tool_calls) + 1]] <- list(
id = tc$id %||% paste0("call_", sample(1e9, 1)),
id = synthesized_id,
name = tc$`function`$name,
arguments = args
)
Expand Down
205 changes: 205 additions & 0 deletions R/history-tool-calls.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Helpers for walking the message history that agent() returns.
#
# `history` is provider-native by design: it's the sequence of messages
# we send back into the next API call, so the shape has to match what
# each provider expects on input. That means consumers walking history
# for tool-call introspection have to handle two distinct shapes:
#
# Anthropic
# assistant role, content = list of blocks, with
# {type = "tool_use", id, name, input}
# user role, content = list of blocks, with
# {type = "tool_result", tool_use_id, content}
#
# OpenAI / moonshot / ollama
# assistant role, content = "" (or text), tool_calls = list of
# {id, type = "function", function = {name, arguments}}
# tool role, tool_call_id, name, content
#
# These helpers pair calls with their results and return a single
# canonical record list so consumers stop reinventing the walk.

#' Walk a history list and return paired tool-call / tool-result records.
#'
#' Accepts either a `history` list as returned by [agent()] (in which
#' case every entry is treated as a message) or any list-of-messages
#' that follows the same shape. Returns a list of records, one per
#' tool call, each with:
#'
#' \describe{
#' \item{id}{Canonical call id (synthesized for Ollama responses
#' that omit one).}
#' \item{name}{Tool name.}
#' \item{arguments}{Argument list. Parsed from JSON for OpenAI-style
#' shapes; passed through for Anthropic.}
#' \item{result}{Tool result text, or `NULL` if the call has no
#' matching result yet.}
#' \item{completed}{`TRUE` when `result` is non-`NULL`.}
#' \item{call_message_index}{1-based index of the assistant message
#' that issued the call.}
#' \item{result_message_index}{1-based index of the message
#' carrying the result, or `NA_integer_` for unfinished calls.}
#' \item{provider_shape}{`"anthropic"` or `"openai"`. Useful when a
#' consumer needs to branch on shape (rare).}
#' }
#'
#' @param history List of messages, typically the `history` element
#' from an [agent()] return value.
#' @return A list of tool-call records (possibly empty). Records are
#' returned in the order calls were issued.
#' @export
history_tool_calls <- function(history) {
if (!is.list(history) || length(history) == 0L) {
return(list())
}

# Pass 1: collect calls.
calls <- list()
for (i in seq_along(history)) {
entry <- history[[i]]
if (!is.list(entry)) next
role <- entry$role %||% ""
if (!identical(role, "assistant")) next

cnt <- entry$content
# Anthropic shape: content is a list of typed blocks.
if (is.list(cnt)) {
for (block in cnt) {
if (identical(block$type %||% "", "tool_use")) {
calls[[length(calls) + 1L]] <- list(
id = block$id %||% "",
name = block$name %||% "",
arguments = block$input %||% list(),
result = NULL,
completed = FALSE,
call_message_index = i,
result_message_index = NA_integer_,
provider_shape = "anthropic"
)
}
}
}
# OpenAI shape: separate tool_calls field on the assistant message.
if (!is.null(entry$tool_calls)) {
for (tc in entry$tool_calls) {
fn <- tc$`function` %||% list()
args_raw <- fn$arguments %||% list()
args <- if (is.character(args_raw) && length(args_raw) == 1L) {
tryCatch(
jsonlite::fromJSON(args_raw, simplifyVector = FALSE),
error = function(e) list()
)
} else {
args_raw
}
calls[[length(calls) + 1L]] <- list(
id = tc$id %||% "",
name = fn$name %||% "",
arguments = args,
result = NULL,
completed = FALSE,
call_message_index = i,
result_message_index = NA_integer_,
provider_shape = "openai"
)
}
}
}

if (length(calls) == 0L) {
return(list())
}

# Pass 2: pair results back to calls.
for (i in seq_along(history)) {
entry <- history[[i]]
if (!is.list(entry)) next
role <- entry$role %||% ""

# Anthropic: tool_result blocks live in user-role messages.
if (identical(role, "user")) {
cnt <- entry$content
if (is.list(cnt)) {
for (block in cnt) {
if (identical(block$type %||% "", "tool_result")) {
target_id <- block$tool_use_id %||% block$id %||% ""
if (!nzchar(target_id)) next
result_text <- .history_block_result_text(block)
for (j in seq_along(calls)) {
if (!calls[[j]]$completed &&
identical(calls[[j]]$id, target_id)) {
calls[[j]]$result <- result_text
calls[[j]]$completed <- TRUE
calls[[j]]$result_message_index <- i
break
}
}
}
}
}
next
}

# OpenAI: each tool result is its own role="tool" message.
if (identical(role, "tool")) {
target_id <- entry$tool_call_id %||% entry$id %||% ""
if (!nzchar(target_id)) next
result_text <- as.character(entry$content %||% "")
for (j in seq_along(calls)) {
if (!calls[[j]]$completed &&
identical(calls[[j]]$id, target_id)) {
calls[[j]]$result <- result_text
calls[[j]]$completed <- TRUE
calls[[j]]$result_message_index <- i
break
}
}
}
}

calls
}

#' Count tool calls in a history list.
#'
#' Thin convenience wrapper over [history_tool_calls()]. Pass
#' `completed_only = TRUE` to count only calls that have matching
#' results (i.e., to skip mid-flight calls at the end of a turn that
#' got cut off).
#'
#' @param history List of messages.
#' @param completed_only Logical. When `TRUE`, count only calls whose
#' result is present in the history. Default `FALSE`.
#' @return Single integer.
#' @export
history_count_tool_calls <- function(history, completed_only = FALSE) {
calls <- history_tool_calls(history)
if (length(calls) == 0L) {
return(0L)
}
if (isTRUE(completed_only)) {
completed <- vapply(calls, function(c) isTRUE(c$completed), logical(1))
return(as.integer(sum(completed)))
}
length(calls)
}

# ---- Internal helpers ----

# Anthropic tool_result blocks may be a flat string or a list of inner
# {type: "text"} blocks. Render to a single string.
.history_block_result_text <- function(block) {
cnt <- block$content
if (is.character(cnt)) {
return(paste(cnt, collapse = "\n"))
}
if (is.list(cnt)) {
parts <- vapply(cnt, function(b) {
as.character(b$text %||% "")
}, character(1))
return(paste(parts, collapse = "\n"))
}
as.character(cnt %||% "")
}

# Note: `%||%` is defined in chat.R for the package; reused here.
86 changes: 86 additions & 0 deletions inst/tinytest/test_agent_ollama_id_writeback.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Verifies that .agent_ollama() writes a synthesized tool-call id back
# into the assistant_message stored in history, not just into the
# canonical $tool_calls return field. Without the writeback the history
# walk pairs assistant.tool_calls[i].id with role="tool".tool_call_id
# and they disagree, breaking history_tool_calls().
#
# Stubs llm.api:::.post_json so the test is offline.

ns <- asNamespace("llm.api")

# Snapshot original .post_json so we can restore it at the end.
orig <- get(".post_json", envir = ns, inherits = FALSE)
on_exit_called <- FALSE

# Stub: return an Ollama-shaped chat response with a tool call that
# OMITS the id field. Some Ollama models don't return an id; the bug
# we're guarding against was that the synthesized id leaked only into
# the canonical tool_calls list.
stub <- function(url, body, headers) {
list(
choices = list(
list(
message = list(
role = "assistant",
content = "",
tool_calls = list(
list(
type = "function",
# NO id field — that's the case being tested.
`function` = list(
name = "echo",
arguments = "{\"x\":1}"
)
)
)
),
finish_reason = "tool_calls"
)
),
usage = list(prompt_tokens = 1, completion_tokens = 1)
)
}

assignInNamespace(".post_json", stub, ns = "llm.api")

result <- llm.api:::.agent_ollama(
messages = list(list(role = "user", content = "go")),
tools = list(),
system = NULL,
model = "test-model",
config = list(api_key = "x",
base_url = "http://stub",
chat_path = "/api/chat")
)

# Restore the real .post_json so other tests don't see the stub.
assignInNamespace(".post_json", orig, ns = "llm.api")

# Canonical tool_calls list got a synthesized id.
expect_equal(length(result$tool_calls), 1L)
synthesized_id <- result$tool_calls[[1]]$id
expect_true(is.character(synthesized_id) && length(synthesized_id) == 1L)
expect_true(nzchar(synthesized_id))

# The fix: the same id is now also on the assistant_message that's
# about to be appended to history. Before this fix, the id field on
# the assistant_message's tool_calls was NULL.
am_calls <- result$assistant_message$tool_calls
expect_equal(length(am_calls), 1L)
expect_equal(am_calls[[1]]$id, synthesized_id)

# Cross-check: history_tool_calls() can pair this call with a result
# message that uses the same id (the agent loop builds these via
# .add_tool_results, so we simulate the result message here).
fake_history <- list(
list(role = "user", content = "go"),
result$assistant_message,
list(role = "tool",
tool_call_id = synthesized_id,
name = "echo",
content = "ok")
)
calls <- llm.api::history_tool_calls(fake_history)
expect_equal(length(calls), 1L)
expect_true(calls[[1]]$completed)
expect_equal(calls[[1]]$result, "ok")
Loading
Loading