mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-19 01:29:57 -05:00
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:
1 parent
4107fb1446
commit
2f1d0ff5b0
17 files changed
+125
-39
No files matched your search
+1
-1
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.'),
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user