fix(core): fit Claude output to remaining context

This commit is contained in:
rekram1-node
2026-09-24 06:11:50 +00:00
parent 9242a7bab3
commit 6085a10148
6 changed files with 130 additions and 10 deletions
@@ -1064,6 +1064,16 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
const maxTokens = generation?.maxTokens ?? DEFAULT_MAX_TOKENS
// A context-fitted Claude output limit can fall below a configured thinking budget.
// Anthropic requires budget_tokens < max_tokens, with a minimum budget of 1,024.
const thinking =
/(?:^|[./])claude-/.test(request.model.id.toLowerCase()) &&
options.thinking?.type === "enabled" &&
maxTokens > 1_024 &&
options.thinking.budget_tokens >= maxTokens
? { ...options.thinking, budget_tokens: maxTokens - 1 }
: options.thinking
const body = {
model: request.model.id,
system,
@@ -1071,12 +1081,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: applyThinkingBindingDefault(request.model, thinking),
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
@@ -181,6 +181,34 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("fits Claude's explicit thinking budget below a context-fitted output limit", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
generation: { maxTokens: 5_000 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 32_000 } },
}),
)
expect(prepared.body.max_tokens).toBe(5_000)
expect(prepared.body.thinking).toMatchObject({ type: "enabled", budget_tokens: 4_999 })
const minimal = yield* compileRequest(
LLMRequest.update(request, {
generation: { maxTokens: 1_025 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 32_000 } },
}),
)
expect(minimal.body.thinking).toMatchObject({ type: "enabled", budget_tokens: 1_024 })
const otherModel = yield* compileRequest(
LLMRequest.update(request, {
model: AnthropicMessages.route.model({ id: "kimi-k2" }),
generation: { maxTokens: 5_000 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 32_000 } },
}),
)
expect(otherModel.body.thinking).toMatchObject({ type: "enabled", budget_tokens: 32_000 })
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(
+16 -8
View File
@@ -46,6 +46,9 @@ const IMAGE_REMOVED =
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
const CLAUDE_OUTPUT_CAP = 128_000
const ANTHROPIC_DEFAULT_OUTPUT = 32_000
const CONTEXT_MARGIN = 4_096
// Manual Anthropic thinking requires at least 1,024 tokens and must be strictly below max_tokens.
const MIN_OUTPUT = 1_025
/** Tool errors, plus the user declining a permission or dismissing a question. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -71,8 +74,13 @@ export interface Input {
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/** Only the durable runner may use a stateful WebSocket. */
readonly webSocket?: "session"
/** Estimated prompt size for fitting Claude's requested output into its context window. */
readonly inputTokens?: number
}
export const isClaudeMessages = (model: SessionRunnerModel.Resolved) =>
model.model.route.protocol === "anthropic-messages" && /(?:^|[./])claude-/.test(model.model.id.toLowerCase())
export const baseTranscript = (input: {
readonly agent: Agent.Info
readonly model: SessionRunnerModel.Resolved
@@ -220,21 +228,21 @@ export const layer = Layer.effect(
const given = new Map(
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
)
// Temporary Claude Messages limit until the context-aware policy in #51021 lands.
// Temporary Claude Messages limit until the provider-wide policy in #51021 lands.
// Leave other models and request kinds on their existing provider defaults.
const claudeMessages =
kind === "primary" &&
model.model.route.protocol === "anthropic-messages" &&
/(?:^|[./])claude-/.test(model.model.id.toLowerCase())
const claudeMessages = kind === "primary" && isClaudeMessages(model)
const outputLimit = Math.min(model.limit.output || ANTHROPIC_DEFAULT_OUTPUT, CLAUDE_OUTPUT_CAP)
const maxTokens =
input.inputTokens === undefined || model.limit.context <= 0
? outputLimit
: Math.min(outputLimit, Math.max(MIN_OUTPUT, model.limit.context - input.inputTokens - CONTEXT_MARGIN))
const shaped = yield* shape(
{
sessionID: session.id,
model: model.ref,
system: input.system,
messages: input.messages,
options: claudeMessages
? { maxTokens: Math.min(model.limit.output || ANTHROPIC_DEFAULT_OUTPUT, CLAUDE_OUTPUT_CAP) }
: {},
options: claudeMessages ? { maxTokens } : {},
},
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
+9
View File
@@ -244,6 +244,15 @@ const layer = Layer.effect(
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
...(SessionModelRequest.isClaudeMessages(loaded.model)
? {
inputTokens: SessionCompaction.estimateTokens({
messages: loaded.messages,
resolved: loaded.model,
context: loaded,
}),
}
: {}),
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
@@ -69,6 +69,40 @@ describe("SessionModelRequest Claude output limit", () => {
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("leaves space for the prompt near Claude's context window", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
for (const context of [200_000, 1_000_000]) {
const prepared = yield* requests.primary({
session,
agent: Agent.ID.make("build"),
model: SessionRunnerModel.resolved(AnthropicMessages.route.model({ id: "claude-sonnet-4-5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context, output: 128_000 },
}),
system: [],
messages: [Message.user("hello")],
inputTokens: context - 100_000,
})
expect((yield* compileRequest(prepared.request)).body.max_tokens).toBe(95_904)
}
const minimal = yield* requests.primary({
session,
agent: Agent.ID.make("build"),
model: SessionRunnerModel.resolved(AnthropicMessages.route.model({ id: "claude-opus-4-8" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 128_000 },
}),
system: [],
messages: [],
inputTokens: 196_000,
})
expect(minimal.request.generation?.maxTokens).toBe(1_025)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("does not change non-primary requests or non-Claude models", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
+31
View File
@@ -2971,6 +2971,37 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.context).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
})
scenario("fits Claude's output request when compaction has not yet triggered", function* (s) {
const id = "claude-sonnet-4-5-context-fixture"
s.currentModel = AnthropicMessages.route.model({ id })
modelLimits.set(id, { context: 100_000, output: 80_000 })
yield* s.llm.push(TestLLM.text("Done", "claude-context-response"))
yield* s.runPrompt("x".repeat(160_000))
expect(s.requests).toHaveLength(1)
expect(s.requests[0]?.generation?.maxTokens).toBeGreaterThan(10_000)
expect(s.requests[0]?.generation?.maxTokens).toBeLessThan(60_000)
expect(yield* s.context).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
})
scenario("records context-window truncation as a length finish without auto-continuation", function* (s) {
s.currentModel = AnthropicMessages.route.model({ id: "claude-sonnet-4-5" })
yield* s.llm.push(
TestLLM.complete(
{ reason: { normalized: "length", raw: "model_context_window_exceeded" } },
LLMEvent.textStart({ id: "truncated" }),
LLMEvent.textDelta({ id: "truncated", text: "Partial answer" }),
LLMEvent.textEnd({ id: "truncated" }),
),
)
yield* s.runPrompt("Long conversation")
expect(s.requests).toHaveLength(1)
expect(yield* s.context).toContainEqual(
expect.objectContaining({ type: "assistant", finish: "length", rawFinish: "model_context_window_exceeded" }),
)
})
scenario("stops after required automatic compaction fails", function* (s) {
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950))
yield* s.runPrompt("Earlier question ".repeat(180))