mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 22:55:56 +08:00
fix(ai-core): keep native tool loops going (#14481)
### What this PR does Before this PR: Cherry Studio could stop after the first tool execution in native tool-call flows, because `stopWhen` was only set when the max-tool-calls switch was enabled. With the switch off, the AI SDK defaulted to a single step, so the follow-up request after tool results never happened. After this PR: Cherry Studio always passes an explicit tool-call cap, so native tool-use conversations continue across steps and can send the next model request after tool execution. Fixes #14478 ### Why we need it and why it was done in this way The following tradeoffs were made: - Kept the existing max-tool-calls setting as the cap source, but no longer use the switch as the gate for passing `stopWhen`. - Limited the change to the stream parameter builder and a focused regression test. The following alternatives were considered: - Leaving `stopWhen` unset when the switch is off. That keeps the AI SDK default at one step and reproduces the bug. - Reworking the downstream tool pipeline. That would be broader than necessary for this hotfix. Links to places where the discussion took place: - https://github.com/CherryHQ/cherry-studio/issues/14478 ### Breaking changes None. ### Special notes for your reviewer None. ### Checklist This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR. Approvers are expected to review this list. - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [x] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note Native tool-call conversations now continue past the first tool execution because Cherry Studio always passes an explicit AI SDK step cap instead of relying on the default one-step stop condition. ``` Co-authored-by: SuYao <sy20010504@gmail.com>
This commit is contained in:
@@ -1,225 +1,45 @@
|
||||
/**
|
||||
* Tests for parameterBuilder maxToolCalls functionality
|
||||
* These tests verify the maxToolCalls calculation and validation logic in isolation
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Mirror the constants from parameterBuilder.ts
|
||||
const MIN_TOOL_CALLS = 1
|
||||
const MAX_TOOL_CALLS = 100
|
||||
const DEFAULT_MAX_TOOL_CALLS = 20
|
||||
const DEFAULT_ENABLE_MAX_TOOL_CALLS = true
|
||||
import { getEffectiveMaxToolCalls } from '../parameterBuilder'
|
||||
|
||||
/**
|
||||
* Validates and clamps maxToolCalls to valid range
|
||||
* Mirrors the logic in parameterBuilder.ts
|
||||
*/
|
||||
function validateMaxToolCalls(value: number | undefined): number {
|
||||
if (value === undefined || value < MIN_TOOL_CALLS || value > MAX_TOOL_CALLS) {
|
||||
return DEFAULT_MAX_TOOL_CALLS
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the effective max tool calls based on assistant settings
|
||||
* Mirrors the logic in parameterBuilder.ts
|
||||
*/
|
||||
function calculateEffectiveMaxToolCalls(settings?: { maxToolCalls?: number; enableMaxToolCalls?: boolean }): {
|
||||
stopWhen: number | null
|
||||
maxToolCalls: number
|
||||
} {
|
||||
const enableMaxToolCalls = settings?.enableMaxToolCalls ?? DEFAULT_ENABLE_MAX_TOOL_CALLS
|
||||
|
||||
if (!enableMaxToolCalls) {
|
||||
// When disabled, don't pass stopWhen (return null to indicate no stopWhen)
|
||||
return { stopWhen: null, maxToolCalls: DEFAULT_MAX_TOOL_CALLS }
|
||||
}
|
||||
|
||||
// When enabled, validate and use user-defined value
|
||||
const maxToolCalls = validateMaxToolCalls(settings?.maxToolCalls)
|
||||
return { stopWhen: maxToolCalls, maxToolCalls }
|
||||
}
|
||||
|
||||
describe('validateMaxToolCalls', () => {
|
||||
it('returns valid values as-is', () => {
|
||||
expect(validateMaxToolCalls(1)).toBe(1)
|
||||
expect(validateMaxToolCalls(50)).toBe(50)
|
||||
expect(validateMaxToolCalls(100)).toBe(100)
|
||||
describe('getEffectiveMaxToolCalls', () => {
|
||||
it('uses the default cap when settings are missing', () => {
|
||||
expect(getEffectiveMaxToolCalls()).toBe(20)
|
||||
})
|
||||
|
||||
it('clamps values above 100 to default', () => {
|
||||
const result = validateMaxToolCalls(999)
|
||||
expect(result).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('clamps zero to default', () => {
|
||||
const result = validateMaxToolCalls(0)
|
||||
expect(result).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('clamps negative values to default', () => {
|
||||
const result = validateMaxToolCalls(-5)
|
||||
expect(result).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('returns default when value is undefined', () => {
|
||||
const result = validateMaxToolCalls(undefined)
|
||||
expect(result).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxToolCalls calculation logic', () => {
|
||||
describe('default behavior', () => {
|
||||
it('uses default value 20 when settings are undefined', () => {
|
||||
const result = calculateEffectiveMaxToolCalls(undefined)
|
||||
expect(result.maxToolCalls).toBe(20)
|
||||
expect(result.stopWhen).toBe(20)
|
||||
})
|
||||
|
||||
it('uses default value 20 when settings is empty object', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({})
|
||||
expect(result.maxToolCalls).toBe(20)
|
||||
expect(result.stopWhen).toBe(20)
|
||||
})
|
||||
|
||||
it('uses default value 20 when maxToolCalls is undefined', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true
|
||||
// maxToolCalls is undefined
|
||||
})
|
||||
expect(result.maxToolCalls).toBe(20)
|
||||
expect(result.stopWhen).toBe(20)
|
||||
})
|
||||
|
||||
it('uses custom value when maxToolCalls is set and enabled', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 50
|
||||
})
|
||||
expect(result.maxToolCalls).toBe(50)
|
||||
expect(result.stopWhen).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom values when enabled', () => {
|
||||
it('uses custom value when enableMaxToolCalls is true and maxToolCalls is set', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 50
|
||||
})
|
||||
expect(result.stopWhen).toBe(50)
|
||||
})
|
||||
|
||||
it('uses custom value at minimum boundary (1)', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 1
|
||||
})
|
||||
expect(result.stopWhen).toBe(1)
|
||||
})
|
||||
|
||||
it('uses custom value at maximum boundary (100)', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 100
|
||||
})
|
||||
expect(result.stopWhen).toBe(100)
|
||||
})
|
||||
|
||||
it('clamps values above 100 to default when enabled', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 999
|
||||
})
|
||||
expect(result.stopWhen).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('clamps zero to default when enabled', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 0
|
||||
})
|
||||
expect(result.stopWhen).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('clamps negative values to default when enabled', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: -5
|
||||
})
|
||||
expect(result.stopWhen).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled behavior', () => {
|
||||
it('does not pass stopWhen when enableMaxToolCalls is false', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
it('uses the default cap when the switch is off', () => {
|
||||
expect(
|
||||
getEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: false,
|
||||
maxToolCalls: 50
|
||||
})
|
||||
// When disabled, stopWhen should be null (indicating no stopWhen passed)
|
||||
expect(result.stopWhen).toBeNull()
|
||||
})
|
||||
|
||||
it('does not pass stopWhen when both enableMaxToolCalls is false and maxToolCalls is undefined', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: false
|
||||
})
|
||||
expect(result.stopWhen).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to default when disabled with invalid maxToolCalls', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: false,
|
||||
maxToolCalls: 999
|
||||
})
|
||||
// When disabled, maxToolCalls should still be default (for reference)
|
||||
expect(result.maxToolCalls).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
expect(result.stopWhen).toBeNull()
|
||||
})
|
||||
).toBe(20)
|
||||
})
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('maintains backward compatibility - existing assistants without new fields use default', () => {
|
||||
// Simulate an old assistant without the new fields
|
||||
const oldSettings = {
|
||||
// Old assistants don't have enableMaxToolCalls or maxToolCalls
|
||||
it('uses a custom cap when enabled', () => {
|
||||
expect(
|
||||
getEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 50
|
||||
})
|
||||
).toBe(50)
|
||||
})
|
||||
|
||||
it('clamps invalid custom values back to the default cap', () => {
|
||||
expect(
|
||||
getEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 999
|
||||
})
|
||||
).toBe(20)
|
||||
})
|
||||
|
||||
it('uses the default cap for old assistants without the new fields', () => {
|
||||
expect(
|
||||
getEffectiveMaxToolCalls({
|
||||
temperature: 0.7,
|
||||
contextCount: 10
|
||||
}
|
||||
const result = calculateEffectiveMaxToolCalls(
|
||||
oldSettings as { maxToolCalls?: number; enableMaxToolCalls?: boolean }
|
||||
)
|
||||
// Should default to enabled with 20 for backward compatibility
|
||||
expect(result.maxToolCalls).toBe(20)
|
||||
expect(result.stopWhen).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('security - invalid values from imported/migrated settings', () => {
|
||||
it('validates extremely large values from imported settings', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 999999
|
||||
})
|
||||
expect(result.stopWhen).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('validates negative values from imported settings', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: -100
|
||||
})
|
||||
expect(result.stopWhen).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
|
||||
it('validates zero from imported settings', () => {
|
||||
const result = calculateEffectiveMaxToolCalls({
|
||||
enableMaxToolCalls: true,
|
||||
maxToolCalls: 0
|
||||
})
|
||||
expect(result.stopWhen).toBe(DEFAULT_MAX_TOOL_CALLS)
|
||||
})
|
||||
} as { maxToolCalls?: number; enableMaxToolCalls?: boolean })
|
||||
).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,6 +59,16 @@ function validateMaxToolCalls(value: number | undefined): number {
|
||||
return value
|
||||
}
|
||||
|
||||
export function getEffectiveMaxToolCalls(settings?: { maxToolCalls?: number; enableMaxToolCalls?: boolean }): number {
|
||||
const enableMaxToolCalls = settings?.enableMaxToolCalls ?? DEFAULT_ASSISTANT_SETTINGS.enableMaxToolCalls
|
||||
|
||||
if (!enableMaxToolCalls) {
|
||||
return DEFAULT_ASSISTANT_SETTINGS.maxToolCalls
|
||||
}
|
||||
|
||||
return validateMaxToolCalls(settings?.maxToolCalls)
|
||||
}
|
||||
|
||||
function mapVertexAIGatewayModelToProviderId(model: Model): AppProviderId | undefined {
|
||||
if (isAnthropicModel(model)) {
|
||||
return 'anthropic'
|
||||
@@ -192,10 +202,9 @@ export async function buildStreamTextParams(
|
||||
// are extracted from custom parameters and passed directly to streamText()
|
||||
// instead of being placed in providerOptions
|
||||
|
||||
// Get max tool calls from assistant settings
|
||||
// When enabled, validate and use user-defined value (1-100)
|
||||
// When disabled, don't pass stopWhen - let AI SDK use its own default
|
||||
const enableMaxToolCalls = assistant.settings?.enableMaxToolCalls ?? DEFAULT_ASSISTANT_SETTINGS.enableMaxToolCalls
|
||||
// AI SDK defaults to stepCountIs(1), which would stop after the first tool call.
|
||||
// Always pass an explicit cap so native tool use can continue across steps.
|
||||
const maxToolCalls = getEffectiveMaxToolCalls(assistant.settings)
|
||||
|
||||
const params: StreamTextParams = {
|
||||
messages: sdkMessages,
|
||||
@@ -210,12 +219,7 @@ export async function buildStreamTextParams(
|
||||
maxRetries: 0
|
||||
}
|
||||
|
||||
// Only add stopWhen when explicitly enabled and validated
|
||||
if (enableMaxToolCalls) {
|
||||
const maxToolCalls = validateMaxToolCalls(assistant.settings?.maxToolCalls)
|
||||
params.stopWhen = stepCountIs(maxToolCalls)
|
||||
}
|
||||
// When disabled, don't pass stopWhen - let AI SDK use its own default
|
||||
params.stopWhen = stepCountIs(maxToolCalls)
|
||||
|
||||
if (tools) {
|
||||
params.tools = tools
|
||||
|
||||
Reference in New Issue
Block a user