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:
404-Page-Found
2026-04-22 23:02:27 +10:00
committed by GitHub
parent e8997a7a7b
commit 5d98273d86
2 changed files with 45 additions and 221 deletions

View File

@@ -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)
})
})

View File

@@ -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