feat(core): persist v2 session context epochs (#30789)
This commit is contained in:
@@ -351,38 +351,29 @@ 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 splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
|
||||
const pending = new Set<string>()
|
||||
for (const message of messages.slice(0, index)) {
|
||||
for (const part of message.content) {
|
||||
if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true)
|
||||
pending.add(part.id)
|
||||
if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id)
|
||||
}
|
||||
}
|
||||
return pending.size > 0
|
||||
}
|
||||
|
||||
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
@@ -407,8 +398,9 @@ 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)
|
||||
if (splitsLocalToolResults(request.messages, index))
|
||||
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
|
||||
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -125,26 +125,65 @@ 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* 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* 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.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")
|
||||
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")
|
||||
expect(
|
||||
(yield* placementError([
|
||||
Message.user("Use the tool."),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.system("Too early."),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
])).message,
|
||||
).toContain("cannot appear between a local tool call and its tool result")
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
Message.user("Use the tool."),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.system("Too early."),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -123,7 +123,11 @@ describeRecordedGoldenScenarios([
|
||||
prefix: "gemini",
|
||||
model: gemini,
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }],
|
||||
scenarios: [
|
||||
{ id: "text", maxTokens: 80 },
|
||||
"tool-call",
|
||||
{ id: "image", maxTokens: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "xAI Grok 3 Mini",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
LLM,
|
||||
LLMEvent,
|
||||
|
||||
@@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
|
||||
import { 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,
|
||||
Model,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
Usage,
|
||||
} from "../src/schema"
|
||||
import { ProviderShared } from "../src/protocols/shared"
|
||||
|
||||
const model = new Model({
|
||||
@@ -43,17 +51,6 @@ describe("llm schema", () => {
|
||||
expect(decoded.model.route.id).toBe("openai-responses")
|
||||
})
|
||||
|
||||
test("decodes chronological system messages", () => {
|
||||
const decoded = decodeLLMRequest({
|
||||
model,
|
||||
system: [],
|
||||
messages: [{ role: "system", content: [{ type: "text", text: "Operator update." }] }],
|
||||
tools: [],
|
||||
})
|
||||
|
||||
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
|
||||
})
|
||||
|
||||
test("rejects invalid event type", () => {
|
||||
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user