VernLLMVernLLM
Guides

Multi-turn Conversations

Learn how to maintain context across multiple LLM calls using conversation history.

VernLLM supports multi-turn conversations by allowing you to provide previous user and assistant messages through the history option.

This allows the model to continue a conversation while keeping context from earlier messages.

Basic usage

multi-turn-basic-usage.ts
await llm.call({
  systemPrompt: 'You are a helpful assistant.',
  history: [
    {
      role: 'user',
      content: 'What is TypeScript?',
    },
    {
      role: 'assistant',
      content: 'TypeScript is a typed superset of JavaScript created by Microsoft.',
    },
  ],
  userContent: 'Who created it?',
});

The model receives the conversation in this order:

  1. System prompt (if provided)
  2. Previous conversation history
  3. Current user message

The resulting message sequence is:

system

user: What is TypeScript?

assistant: TypeScript is a typed superset of JavaScript created by Microsoft.

user: Who created it?

History format

ConversationTurn is a discriminated union keyed on role, not a single flat shape. This is a breaking change from earlier versions, where every turn was { role: 'user' | 'assistant'; content: string } regardless of what it represented.

type ConversationTurn =
  | {
      role: 'user';
      content: string;
    }
  | {
      role: 'assistant';
      content?: string | JsonValue;
      toolCalls?: ToolCall[];
    }
  | {
      role: 'tool';
      toolResults: ToolResult[];
    };

user turns still require content. assistant turns no longer require it, since an assistant turn that only requested tools has no text of its own. Plain user/assistant history exactly like before still works unchanged, this only adds the tool role and the optional toolCalls field on top.

Assistant content also accepts a parsed JsonValue, so a prior jsonMode: true result can be pushed back into history directly. This is common in chat apps, where history is declared once and grows as the conversation continues:

assistant-json-content.ts
import type { ConversationTurn } from 'vern-llm';

const history: ConversationTurn[] = [];

const parsed = await llm.call({ userContent: 'Give me a JSON summary.', jsonMode: true });

history.push(
  { role: 'user', content: 'Give me a JSON summary.' },
  { role: 'assistant', content: parsed },
);

VernLLM's request construction stringifies non-string assistant content before it's sent as part of the request, so no manual JSON.stringify call is needed.

For plain conversations without tools, only the user and assistant cases apply:

FieldDescription
roleThe sender of the message (user or assistant)
contentThe message content

Example:

conversation-turn-example.ts
const history = [
  {
    role: 'user',
    content: 'Explain async functions.',
  },
  {
    role: 'assistant',
    content: 'Async functions allow asynchronous code to be written using promise-based syntax.',
  },
];

Continuing a conversation

A typical chat application declares history once, typed as ConversationTurn[], and pushes each turn as the conversation continues:

continuing-conversation.ts
import type { ConversationTurn } from 'vern-llm';

const history: ConversationTurn[] = [];

const first = await llm.call({ userContent: 'What is Rust?', jsonMode: false });
history.push({ role: 'user', content: 'What is Rust?' }, { role: 'assistant', content: first });

const response = await llm.call({
  history,
  userContent: 'What makes it memory safe?',
  jsonMode: false,
});
history.push(
  { role: 'user', content: 'What makes it memory safe?' },
  { role: 'assistant', content: response },
);

The model can answer each follow-up because the growing history array is sent with every request. Typing history as ConversationTurn[] up front also covers assistant content that's a parsed JsonValue, not just a string, so pushing a jsonMode: true result needs no extra casting.

History validation

VernLLM validates history before sending a request.

History must:

  • Only contain user, assistant, and tool roles.
  • Alternate between user and assistant turns, the same as before.
  • End with either an assistant turn without tool calls, or a completed tool turn.

The current userContent is always appended after the history, so the final history entry cannot be a user turn.

A tool turn has its own rules on top of the alternation check:

  • It must immediately follow an assistant turn that set toolCalls.
  • It must include exactly one ToolResult for every requested toolCallId, no more, no fewer.
  • An assistant turn that requested tools cannot be followed by anything except its matching tool turn, including the final entry in history itself.

Any violation throws LLMError('validation') before a request is sent. See Tool Calling for how this fits into a full request, execute, continue loop.

Valid history

valid-history-example.ts
history: [
  {
    role: 'user',
    content: 'What is Python?',
  },
  {
    role: 'assistant',
    content: 'Python is a programming language.',
  },
];

Invalid history

invalid-history-example.ts
history: [
  {
    role: 'user',
    content: 'What is Python?',
  },
];

The invalid example would create two consecutive user messages after userContent is added.

Empty history

history is optional.

Omitting it or passing an empty array behaves the same:

empty-history-example.ts
await llm.call({
  history: [],
  userContent: 'Hello!',
});

Provider support

Conversation history is handled by VernLLM's core layer and converted into the correct format for each provider adapter.

User, assistant, and tool turns are all preserved, allowing multi-turn conversations, including tool call continuations, to work consistently across supported LLM providers. Each adapter maps tool turns onto whatever shape that provider expects, for example Anthropic's tool_result content blocks or Gemini's functionResponse parts, so you write history once in VernLLM's shape regardless of which provider is behind llm.

On this page