feat(core): persist v2 session context epochs

This commit is contained in:
Kit Langton 2026-06-04 14:38:31 -04:00
commit 83916f667d
39 changed files with 9302 additions and 79 deletions

View file

@ -351,38 +351,17 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted !== true
}
const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSystemUpdate")(function* (
messages: LLMRequest["messages"],
index: number,
) {
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
const previous = messages[index - 1]
const next = messages[index + 1]
if (!previous)
return yield* invalid(
"Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system",
)
if (previous.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (endsInLocalToolUse(previous))
return yield* invalid(
"Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result",
)
if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous))
return yield* invalid(
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use",
)
if (next?.role === "system")
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
if (next && next.role !== "assistant")
return yield* invalid(
"Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message",
)
})
return (
previous !== undefined &&
previous.role !== "system" &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
next?.role !== "system" &&
(next === undefined || next.role === "assistant")
)
}
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
message: LLMRequest["messages"][number],
@ -407,8 +386,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
if (supportsNativeSystemUpdates(request)) {
yield* validateNativeSystemUpdate(request.messages, index)
yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages[index - 1])
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}

View file

@ -292,8 +292,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
) {
const messages: BedrockMessage[] = []
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
const content = textWithCache(breakpoints, part.text, part.cache)
const previous = messages.at(-1)

View file

@ -200,8 +200,9 @@ const lowerToolCall = (part: ToolCallPart) => ({
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
if (previous?.role === "user")

View file

@ -252,8 +252,9 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
const previous = messages.at(-1)
if (previous?.role === "user")

View file

@ -338,8 +338,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const input: OpenAIResponsesInputItem[] = [...system]
const store = OpenAIOptions.store(request)
for (const message of request.messages) {
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages[index - 1])
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")

View file

@ -177,6 +177,20 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache }
})
export const guardSystemUpdatePlacement = Effect.fn("ProviderShared.guardSystemUpdatePlacement")(function* (
route: string,
previous: LLMRequest["messages"][number] | undefined,
) {
if (
previous?.role === "assistant" &&
previous.content.some((part) => part.type === "tool-call" && part.providerExecuted !== true)
)
return yield* invalidRequest(
`${route} chronological system updates cannot appear between a local tool call and its tool result`,
)
return yield* Effect.void
})
/**
* Parse the streamed JSON input of a tool call. Treats an empty string as
* `"{}"` providers occasionally finish a tool call without ever emitting

View file

@ -125,22 +125,56 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects invalid native chronological system update placement", () =>
it.effect("falls back for unsupported native chronological system update placement", () =>
Effect.gen(function* () {
const placementError = (messages: Parameters<typeof LLM.request>[0]["messages"]) =>
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip)
expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message")
expect(
(yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message,
).toContain("cannot be consecutive")
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
cache: "none",
}),
)).body.messages,
).toEqual([
{ role: "assistant", content: [{ type: "text", text: "Plain." }] },
{
role: "user",
content: [{ type: "text", text: "<system-update>\nAfter plain assistant.\n</system-update>" }],
},
])
expect(
(yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message,
).toContain("must follow a user message, tool result, or assistant server tool use")
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
)).body.messages,
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
expect(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
cache: "none",
}),
)).body.messages,
).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Before." },
{ type: "text", text: "<system-update>\nOne.\n</system-update>" },
{ type: "text", text: "<system-update>\nTwo.\n</system-update>" },
],
},
])
expect(
(yield* placementError([
Message.user("Use the tool."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
{ type: "text", text: "Waiting." },
]),
Message.system("Too early."),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
])).message,

View file

@ -1,8 +1,18 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema"
import {
ContentPart,
LLMEvent,
LLMRequest,
Message,
Model,
ModelID,
ProviderID,
ToolCallPart,
Usage,
} from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new Model({
@ -54,6 +64,17 @@ describe("llm schema", () => {
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
})
test("rejects chronological system updates between a local tool call and its result", async () => {
const previous = Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
{ type: "text", text: "Waiting." },
])
await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", previous))).rejects.toThrow(
"Test chronological system updates cannot appear between a local tool call and its tool result",
)
})
test("rejects invalid event type", () => {
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
})