Skip to content

Per-turn context cannot be attached to a message sent by chat_server() #375

Description

@nbenn

An app has no way to attach content to the message chat_server() sends. The module reads the input value and hands it straight to the client, so the only thing that reaches stream_async() is what the user typed.

Two common needs run into this. A RAG app searches its own documents for each question and wants the matching excerpts sent in the same turn as the question that produced them. An app whose state the model is acting on — a document, a dashboard, a canvas being edited — wants each message to carry that state as it stood when the message was sent.

The system prompt is not a substitute

A conversation has exactly one system prompt, and it is mutable: ellmer::Chat$set_system_prompt() drops the existing system turn and prepends a replacement. Rewriting it between messages therefore does not add context to the next turn — it retroactively replaces the context of every turn already in the history.

The result is a transcript that describes a world the prompt no longer shows. If the assistant said "I've added a summary section" at turn 3 and that section is gone by turn 10, the model sees both the claim and a prompt describing a document without it, and has nothing to reconcile them with. A RAG app hits the same wall from the other side: each question's excerpts overwrite the previous question's, so the model can no longer see the text it just answered from, and per-question evidence ends up sharing a slot with the app's standing instructions.

Content carried in the turn does not have either problem. Each message keeps the context it was sent with, and the history stays a truthful record.

There is a cost argument as well, though it is the smaller one. Prompt caching is a prefix match and system renders ahead of messages, so a system prompt rewritten between messages invalidates the whole conversation behind it. For Anthropic, ellmer marks the system prompt with cache_control and defaults to cache = "5m", so the re-send is billed at the cache-write rate — making a dynamic system prompt worse than no caching at all.

Why an app on chat_server() cannot do this

The module owns the send:

shiny::observeEvent(
  session$input[[paste0(id, "_user_input")]],
  label = "on_chat_user_input",
  {
    last_input(session$input[[paste0(id, "_user_input")]])
    append_stream_task$invoke(
      client, id, session$input[[paste0(id, "_user_input")]], controller = ctrl
    )
  }
)

That is pkg-r/R/chat_app.R#L427. An app can observe the value afterwards through last_input(), but by then it has been sent. There is no way in from outside either: ellmer::Chat is unexported so the client cannot be subclassed, its instances are binding-locked (assigning to ch$stream_async fails with cannot change value of locked binding), and the returned module environment is sealed with lockEnvironment(ret).

Why this is not #10 again

Issue #10 asked for both input and response transforms and was closed as completed on a worked example: own the observer, change the text, then call chat$stream_async() yourself. That was correct for the API as it stood.

It is not available here. The chat_server() module is new (#264), it owns the stream_async() call, and it is where multi-conversation history, attachments and slash commands now live — so taking the #10 route today means giving up the whole module to gain one line.

The scepticism recorded in #10 was about the response side, where streaming, tool-call UI and bookmarking all pull against arbitrary transformation. None of that applies outbound, and this request leaves the response direction alone.

Proposed shape

A submit callback, registered the way mod$history$on_save() already is:

mod <- chat_server("chat", client)

mod$on_submit(function(contents) {
  c(list(retrieved_context()), contents)
})

The function would take and return the value in the shape input$<id>_user_input already has — a character string, or a list of ellmer::Content objects when attachments are enabled — and apply only to what is sent, leaving last_input() reporting what the user typed.

The contract would not be a new one for the package. A slash-command handler already receives a ContentSlashCommand and is expected to shape it before passing it to client$stream(); this asks for the same latitude on the path where the module does the sending.

Happy to open a PR if the shape looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: MediumValid bug or well-defined request with moderate impact or a workaround.ai-triage:doneMarks an issue whose AI triage workflow is complete.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions