fix(core): support adaptive thinking on current Claude models (#609)

The Anthropic adapter now sends adaptive thinking when thinking is enabled without budgetTokens, except on models from before adaptive thinking, which keep the 1024-token default budget; Claude Opus 4.7, Sonnet 5, and later reject budget_tokens with HTTP 400. The task profiler omits temperature only for Claude models that reject non-default sampling parameters, and the cost-tiered pipeline example drops the temperatures that broke its Opus 4.7 path. Built-in tool descriptions now state the sandbox boundary and match glob's path output, the coordinator prompt reused for synthesis no longer carries an unscoped JSON-only instruction, and the structured-output instruction states its requirement once.
This commit is contained in:
Jack Chen authored and GitHub committed 2026-09-23 11:38:08 +08:00
1 parent 4107fb1446
commit 2f1d0ff5b0
17 files changed
+125 -39

No files matched your search

+1 -1
View File
@@ -263,7 +263,7 @@ const agent = {
}
```
- `budgetTokens` maps to Anthropic `thinking.budget_tokens` and Gemini `thinkingConfig.thinkingBudget`.
- `budgetTokens` maps to Anthropic `thinking.budget_tokens` and Gemini `thinkingConfig.thinkingBudget`. Claude Opus 4.7, Sonnet 5, and later models reject `budget_tokens`; omit `budgetTokens` and the Anthropic adapter sends adaptive thinking instead. Models from before adaptive thinking (Sonnet 3.7 through the 4.5 generation) still receive a 1024-token default budget.
- `effort` (`'low' | 'medium' | 'high'`) maps to OpenAI-compatible `reasoning_effort`. Values outside the framework union (such as `'minimal'` or `'none'`) can be passed via `extraBody: { reasoning_effort: '<value>' }`.
- DeepSeek additionally maps `enabled` to `thinking: { type: 'enabled' | 'disabled' }` and accepts `effort: 'max'`. DeepSeek V4 enables thinking by default at `high` effort when no framework-level thinking config is supplied. Other built-in OpenAI-family adapters ignore the DeepSeek-only `max` value. Explicit `extraBody` values take precedence.
- Adapters ignore fields they don't recognise, so one config is safe across a mixed-provider team.
@@ -279,28 +279,24 @@ function createAgents(assignments: PipelineAssignments): AgentConfig[] {
...assignments.researcher,
systemPrompt: RESEARCHER_PROMPT,
maxTurns: 2,
temperature: 0.2,
},
{
name: 'classifier',
...assignments.classifier,
systemPrompt: CLASSIFIER_PROMPT,
maxTurns: 2,
temperature: 0.2,
},
{
name: 'drafter',
...assignments.drafter,
systemPrompt: DRAFTER_PROMPT,
maxTurns: 2,
temperature: 0.3,
},
{
name: 'reviewer',
...assignments.reviewer,
systemPrompt: REVIEWER_PROMPT,
maxTurns: 2,
temperature: 0.2,
},
]
}
+1 -3
View File
@@ -23,9 +23,7 @@ export function buildStructuredOutputInstruction(schema: ZodSchema): string {
return [
'',
'## Output Format (REQUIRED)',
'You MUST respond with ONLY valid JSON that conforms to the following JSON Schema.',
'Do NOT include any text, markdown fences, or explanation outside the JSON object.',
'Do NOT wrap the JSON in ```json code fences.',
'Respond with only valid JSON that conforms to the following JSON Schema, with no text or code fence around it.',
'',
'```',
JSON.stringify(jsonSchema, null, 2),
+29
View File
@@ -0,0 +1,29 @@
/**
* @fileoverview Claude model-generation checks shared by the Anthropic adapter
* and framework-internal calls. Kept free of the SDK import so callers outside
* the adapter do not load it.
*
* Both lists are closed: they name model generations that will not grow, so a
* model ID they do not recognise is treated as current.
*/
/** Models from before adaptive thinking (Sonnet 3.7 through the 4.5 generation). */
const BUDGET_ONLY_THINKING_MODEL =
/^claude-(?:3-7-sonnet|sonnet-4(?:-0|-5)?|opus-4(?:-0|-1|-5)?|haiku-4-5)(?:-\d{8}|-latest)?$/
/** Models up to the 4.6 generation, which still accept non-default sampling parameters. */
const SAMPLING_MODEL =
/^claude-(?:3-|(?:sonnet|opus|haiku)-4(?:-[0-6])?(?:-\d{8}|-latest)?$)/
/** True when the model only accepts `thinking: {type: 'enabled', budget_tokens}`. */
export function requiresBudgetThinking(model: string): boolean {
return BUDGET_ONLY_THINKING_MODEL.test(model)
}
/**
* True when the model accepts a non-default `temperature`, `top_p`, or
* `top_k`. Claude Opus 4.7, Sonnet 5, and later reject them with HTTP 400.
*/
export function acceptsSamplingParams(model: string): boolean {
return SAMPLING_MODEL.test(model)
}
+15 -12
View File
@@ -62,6 +62,7 @@ import {
resolveReasoningOutboundMaxChars,
type ReasoningOutboundOptions,
} from './reasoning-fallback.js'
import { requiresBudgetThinking } from './anthropic-models.js'
import { assertValidMessages } from './validate.js'
import { createEgressFetch } from './egress.js'
import { UnsupportedContentBlockError, UnsupportedToolResultContentError } from '../errors.js'
@@ -342,15 +343,13 @@ function fromAnthropicContentBlock(
* value less than max_tokens"). Throws early with a clear message
* rather than letting Anthropic return a 400.
*
* Defaults `budgetTokens` to 1024 when enabled without an explicit value;
* combined with the second constraint, this means a caller passing
* `thinking.enabled = true` MUST also set `maxTokens > 1024`.
*
* Model compatibility: emits `{type: 'enabled', budget_tokens}` which is
* supported by Claude Sonnet 3.7 and all Claude 4.x models up to and
* including 4.6 (deprecated on 4.6 in favor of `adaptive`). Claude Opus 4.7+
* accepts only `{type: 'adaptive'}` and rejects this shape with HTTP 400.
* Adaptive thinking support is tracked as a follow-up to RFC #200's phase 1.
* Model compatibility: an explicit `budgetTokens` always emits
* `{type: 'enabled', budget_tokens}`, which Claude Opus 4.7+, Sonnet 5, and
* later reject with HTTP 400. Without `budgetTokens`, models that predate
* adaptive thinking (Sonnet 3.7 through the 4.5 generation) get a 1024-token
* budget, so a caller enabling thinking on them must set `maxTokens > 1024`;
* every other model gets `{type: 'adaptive'}`. The legacy list is closed, so
* new model IDs default to adaptive.
*
* The `interleaved-thinking-2025-05-14` beta header (which would relax the
* `budget_tokens < max_tokens` rule for Claude 4.x manual mode) is not yet
@@ -359,8 +358,12 @@ function fromAnthropicContentBlock(
function toAnthropicThinkingParam(
thinking: ThinkingConfig | undefined,
maxTokens: number,
): ThinkingConfigParam | undefined {
model: string,
): ThinkingConfigParam | { type: 'adaptive' } | undefined {
if (thinking === undefined || !thinking.enabled) return undefined
if (thinking.budgetTokens === undefined && !requiresBudgetThinking(model)) {
return { type: 'adaptive' }
}
const budget = thinking.budgetTokens ?? 1024
if (budget < 1024) {
throw new Error(
@@ -440,7 +443,7 @@ export class AnthropicAdapter implements LLMAdapter {
messages: anthropicMessages,
system: options.systemPrompt,
tools: options.tools ? toAnthropicTools(options.tools) : undefined,
thinking: toAnthropicThinkingParam(options.thinking, effectiveMaxTokens),
thinking: toAnthropicThinkingParam(options.thinking, effectiveMaxTokens, options.model),
// Cast covers arbitrary `extraBody` keys not declared by the SDK.
} as MessageCreateParamsNonStreaming,
{
@@ -499,7 +502,7 @@ export class AnthropicAdapter implements LLMAdapter {
messages: anthropicMessages,
system: options.systemPrompt,
tools: options.tools ? toAnthropicTools(options.tools) : undefined,
thinking: toAnthropicThinkingParam(options.thinking, effectiveMaxTokens),
thinking: toAnthropicThinkingParam(options.thinking, effectiveMaxTokens, options.model),
} as MessageStreamParams,
{
signal: options.abortSignal,
@@ -458,8 +458,6 @@ export function buildCoordinatorOutputFormatSection(hasVerifyJudges?: boolean):
' 2. Lean toward including a task only when the structured manifest describes X as needing that input.',
' 3. Avoid adding a dependency just because the information "would be useful" or matches general best practice; if the manifest gives no indication X consumes that input, prefer to leave it out.',
' 4. When uncertain, prefer fewer dependencies over more — extra parents cost parallelism and tokens.',
'',
'Return only the JSON array. Do not use Markdown code fences or extra text.',
)
return lines.join('\n')
}
@@ -23,6 +23,7 @@ import {
extractJSON,
validateOutput,
} from '../agent/structured-output.js'
import { acceptsSamplingParams } from '../llm/anthropic-models.js'
const MAX_PROFILE_REASONS = 5
const MAX_PROFILE_REASON_CHARS = 200
@@ -130,7 +131,12 @@ export class LLMTaskProfiler implements TaskProfiler {
const response = await this.adapter.chat(messages, {
model: this.model,
maxTokens: this.maxTokens,
temperature: 0,
// Claude Opus 4.7, Sonnet 5, and later reject a non-default temperature
// with HTTP 400, so those models get the model default. Earlier Claude
// models keep temperature 0 for repeatable routing decisions.
...(this.adapter.name === 'anthropic' && !acceptsSamplingParams(this.model)
? {}
: { temperature: 0 }),
// DeepSeek V4 enables thinking by default. Profiling is a bounded
// classification call whose only useful output is the JSON profile;
// leaving thinking enabled can spend its whole output budget on
+4 -3
View File
@@ -30,9 +30,10 @@ export const bashTool = defineTool({
name: 'bash',
consequential: true,
description:
'Execute a bash command and return its stdout and stderr. ' +
'Use this for file system operations, running scripts, installing packages, ' +
'and any task that requires shell access. ' +
'Execute a bash command and return its stdout and stderr, plus the exit code when it is non-zero. ' +
'Use this for running scripts, builds, tests, and package installs. ' +
'Unlike the file_* tools, grep, and glob, it is not confined to the agent\'s working ' +
'directory, so prefer those tools for reading, searching, and editing files when they are available. ' +
'The command runs in a non-interactive shell (bash -c). ' +
'Long-running commands should use the timeout parameter.',
+4 -1
View File
@@ -28,7 +28,10 @@ export const fileEditTool = defineTool({
inputSchema: z.object({
path: z
.string()
.describe('Absolute path to the file to edit.'),
.describe(
'Absolute path to the file to edit. Must be inside the agent\'s working directory ' +
'unless the sandbox is disabled; a path outside it returns an error.',
),
old_string: z
.string()
.describe(
+4 -1
View File
@@ -23,7 +23,10 @@ export const fileReadTool = defineTool({
'entire file into the context window.',
inputSchema: z.object({
path: z.string().describe('Absolute path to the file to read.'),
path: z.string().describe(
'Absolute path to the file to read. Must be inside the agent\'s working directory ' +
'unless the sandbox is disabled; a path outside it returns an error.',
),
offset: z
.number()
.int()
@@ -28,8 +28,8 @@ export const fileWriteTool = defineTool({
path: z
.string()
.describe(
'Absolute path to the file to write. ' +
'The path must be absolute (starting with /).',
'Absolute path to the file to write. Must be inside the agent\'s working directory ' +
'unless the sandbox is disabled; a path outside it returns an error.',
),
content: z.string().describe('The full content to write to the file.'),
}),
+4 -2
View File
@@ -21,7 +21,8 @@ export const globTool = defineTool({
'List file paths under a directory that match an optional filename glob. ' +
'Does not read file contents — use `grep` to search inside files. ' +
'Skips common bulky directories (node_modules, .git, dist, etc.). ' +
'Paths in the result are relative to the process working directory. ' +
'Paths in the result are relative to the agent\'s working directory, or to the ' +
'filesystem root when the sandbox is disabled. ' +
'Results are capped by `maxFiles`.',
inputSchema: z.object({
@@ -29,7 +30,8 @@ export const globTool = defineTool({
.string()
.optional()
.describe(
'Absolute directory or file path to list. Defaults to the tool working directory.',
'Absolute directory or file path to list, inside the agent\'s working directory ' +
'unless the sandbox is disabled. Defaults to that working directory.',
),
pattern: z
.string()
+2 -2
View File
@@ -44,8 +44,8 @@ export const grepTool = defineTool({
.string()
.optional()
.describe(
'Absolute directory or file path to search in. ' +
'Defaults to the tool working directory.',
'Absolute directory or file path to search in, inside the agent\'s working directory ' +
'unless the sandbox is disabled. Defaults to that working directory.',
),
glob: z
.string()
+3 -1
View File
@@ -819,7 +819,9 @@ export interface ToolDefinition<TInput = Record<string, unknown>, TData = string
* and {@link LLMChatOptions}.
*
* - `budgetTokens` maps to Anthropic's `thinking.budget_tokens` and Gemini's
* `thinkingConfig.thinkingBudget`.
* `thinkingConfig.thinkingBudget`. Omit it for Claude Opus 4.7, Sonnet 5,
* and later, which reject `budget_tokens`; the Anthropic adapter then sends
* adaptive thinking.
* - `effort` maps to OpenAI-compatible `reasoning_effort`, including
* DeepSeek's `high` / `max` levels. It is carried as an explicit value
* rather than derived from `budgetTokens` so the call site stays
+14 -3
View File
@@ -747,14 +747,14 @@ describe('AnthropicAdapter', () => {
})
})
it('defaults budget_tokens to 1024 when enabled without explicit value', async () => {
it('defaults budget_tokens to 1024 on a budget-only model when enabled without explicit value', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
// maxTokens must exceed the 1024 default budget — the API enforces
// budget_tokens < max_tokens.
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ maxTokens: 4096, thinking: { enabled: true } }),
chatOpts({ model: 'claude-haiku-4-5', maxTokens: 4096, thinking: { enabled: true } }),
)
expect(mockCreate.mock.calls[0][0].thinking).toEqual({
@@ -763,6 +763,17 @@ describe('AnthropicAdapter', () => {
})
})
it('sends adaptive thinking when enabled without a budget on a current model', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
await adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ model: 'claude-sonnet-5', maxTokens: 4096, thinking: { enabled: true } }),
)
expect(mockCreate.mock.calls[0][0].thinking).toEqual({ type: 'adaptive' })
})
it('omits thinking field when config is absent or disabled', async () => {
mockCreate.mockResolvedValue(makeAnthropicResponse())
@@ -803,7 +814,7 @@ describe('AnthropicAdapter', () => {
await expect(
adapter.chat(
[textMsg('user', 'Hi')],
chatOpts({ maxTokens: 1024, thinking: { enabled: true } }),
chatOpts({ model: 'claude-haiku-4-5', maxTokens: 1024, thinking: { enabled: true } }),
),
).rejects.toThrow(/budgetTokens \(1024\) must be < maxTokens \(1024\)/)
})
@@ -268,6 +268,40 @@ describe('LLMTaskProfiler', () => {
extraBody: { thinking: { type: 'disabled' } },
})
})
it.each([
['claude-sonnet-5', undefined],
['claude-opus-4-7', undefined],
['claude-sonnet-4-6', 0],
['claude-haiku-4-5', 0],
])('sends temperature only to Claude models that accept it (%s)', async (model, temperature) => {
const mockAdapter = {
...adapter(JSON.stringify({
evidenceSources: 'single',
independentReview: 'none',
conflictingObjectives: false,
sideEffectIntent: 'none',
permissionIsolation: 'none',
decomposable: false,
parallelizable: false,
complexity: 'low',
confidence: 0.95,
reasons: ['The task is a bounded classification request.'],
})),
name: 'anthropic',
}
const taskProfiler = new LLMTaskProfiler({
adapter: mockAdapter,
model,
})
await taskProfiler.profile({
goal: 'Summarize this note.',
roster: [{ name: 'alpha', model }],
})
expect(mockAdapter.chat.mock.calls[0]?.[1]?.temperature).toBe(temperature)
})
})
describe('hybrid runTeam routing', () => {
@@ -168,7 +168,7 @@ describe('buildStructuredOutputInstruction', () => {
expect(instruction).toContain('"type": "object"')
expect(instruction).toContain('"summary"')
expect(instruction).toContain('"score"')
expect(instruction).toContain('ONLY valid JSON')
expect(instruction).toContain('only valid JSON')
})
it('includes description from Zod schema', () => {