agent: allow unlimited tool rounds for cloud models by default (#17217)

This commit is contained in:
Parth Sareen
2026-07-16 19:07:01 -07:00
committed by GitHub
parent 61e1b1ba5e
commit 714b6fc2a4
4 changed files with 119 additions and 12 deletions
+13 -8
View File
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/internal/modelref"
)
type ChatClient interface {
@@ -39,9 +40,10 @@ type RunOptions struct {
Options map[string]any
Think *api.ThinkValue
KeepAlive *api.Duration
// MaxToolRounds limits consecutive model/tool cycles.
// Zero uses the default guard; negative disables the guard for tests or
// special callers.
// MaxToolRounds limits consecutive model/tool cycles. A positive value is
// an explicit limit. Zero selects the model-specific default: local models
// use the default guard and cloud models are unlimited. A negative value
// disables the guard for tests or special callers.
MaxToolRounds int
}
@@ -163,7 +165,7 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
opts: opts,
phase: runPhaseModel,
messages: messages,
maxToolRounds: resolvedMaxToolRounds(opts.MaxToolRounds),
maxToolRounds: resolvedMaxToolRounds(opts.Model, opts.MaxToolRounds),
}
for {
switch st.phase {
@@ -850,11 +852,14 @@ func CompactionSkippedMessage(reason string) string {
return reason
}
func resolvedMaxToolRounds(value int) int {
if value == 0 {
return defaultMaxToolRounds
func resolvedMaxToolRounds(model string, value int) int {
if value != 0 {
return value
}
return value
if modelref.HasExplicitCloudSource(model) {
return -1
}
return defaultMaxToolRounds
}
// toolMessageWithBudget sizes a tool result message to fit within a token
+51 -4
View File
@@ -883,7 +883,7 @@ func TestSessionToolLoopAllowsRoundsUnderDefaultCap(t *testing.T) {
}
}
func TestSessionToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
func TestSessionLocalToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
firstArgs := api.NewToolCallFunctionArguments()
firstArgs.Set("value", "first")
secondArgs := api.NewToolCallFunctionArguments()
@@ -932,7 +932,7 @@ func TestSessionToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
result, err := session.Run(context.Background(), RunOptions{
ChatID: "chat-1",
Model: "model",
Model: "test:local",
NewMessages: []api.Message{{Role: "user", Content: "hit cap"}},
MaxToolRounds: 1,
})
@@ -956,7 +956,7 @@ func TestSessionToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
}
}
func TestSessionToolLoopStopsAtDefaultRoundCap(t *testing.T) {
func TestSessionLocalToolLoopStopsAtDefaultRoundCap(t *testing.T) {
responses := make([][]api.ChatResponse, 0, defaultMaxToolRounds+1)
for range defaultMaxToolRounds + 1 {
args := api.NewToolCallFunctionArguments()
@@ -982,7 +982,7 @@ func TestSessionToolLoopStopsAtDefaultRoundCap(t *testing.T) {
}
_, err := session.Run(context.Background(), RunOptions{
Model: "model",
Model: "test:local",
NewMessages: []api.Message{{Role: "user", Content: "keep going"}},
})
if err == nil || !strings.Contains(err.Error(), "tool round limit reached after 100 rounds") {
@@ -993,6 +993,53 @@ func TestSessionToolLoopStopsAtDefaultRoundCap(t *testing.T) {
}
}
func TestSessionCloudToolLoopHonorsExplicitRoundCap(t *testing.T) {
args := api.NewToolCallFunctionArguments()
args.Set("value", "hello")
client := &fakeClient{responses: [][]api.ChatResponse{
{{
Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
ID: "call-1",
Function: api.ToolCallFunction{
Name: "echo_tool",
Arguments: args,
},
}}},
}},
{{
Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
ID: "call-2",
Function: api.ToolCallFunction{
Name: "echo_tool",
Arguments: args,
},
}}},
}},
}}
registry := &Registry{}
registry.Register(staticTool{})
session := &Session{
Client: client,
Tools: registry,
ApprovalState: approvalStateForTest(true, nil),
}
result, err := session.Run(context.Background(), RunOptions{
Model: "test:cloud",
NewMessages: []api.Message{{Role: "user", Content: "keep going"}},
MaxToolRounds: 1,
})
if err == nil || !strings.Contains(err.Error(), "tool round limit reached after 1 rounds") {
t.Fatalf("error = %v, want explicit tool-round limit", err)
}
if result == nil {
t.Fatal("expected partial result with skipped tool message")
}
if client.calls != 2 {
t.Fatalf("client calls = %d, want 2", client.calls)
}
}
func TestSessionToolLoopNegativeLimitIsUnlimited(t *testing.T) {
responses := make([][]api.ChatResponse, 0, defaultMaxToolRounds+2)
for range defaultMaxToolRounds + 1 {
+29
View File
@@ -419,6 +419,35 @@ func TestChatInputAcceptsSpace(t *testing.T) {
}
}
func TestChatCloudModelDefaultToolRoundsAreUnlimited(t *testing.T) {
const formerDefaultLimit = 100
client := &chatToolLoopClient{toolRounds: formerDefaultLimit + 1}
registry := &coreagent.Registry{}
registry.Register(chatTestTool{})
m := chatModel{
ctx: context.Background(),
opts: Options{
Model: "test:cloud",
Client: client,
Tools: registry,
AllowAllTools: true,
},
}
updated, cmd := m.startRun("keep going")
m = updated.(chatModel)
if cmd == nil {
t.Fatal("startRun should start a cloud model run")
}
done := waitForRunDone(t, m.events)
if done.err != nil {
t.Fatalf("cloud run returned error: %v", done.err)
}
if client.calls != formerDefaultLimit+2 {
t.Fatalf("client calls = %d, want %d", client.calls, formerDefaultLimit+2)
}
}
func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) {
pasted := strings.Repeat("line\n", pastedTextPlaceholderMinLines-1) + "line"
m := chatModel{
+26
View File
@@ -2,6 +2,7 @@ package chat
import (
"context"
"fmt"
"regexp"
"testing"
"time"
@@ -20,6 +21,11 @@ type chatCaptureClient struct {
requests []*api.ChatRequest
}
type chatToolLoopClient struct {
calls int
toolRounds int
}
func (chatTestClient) Chat(ctx context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
if err := ctx.Err(); err != nil {
return err
@@ -41,6 +47,26 @@ func (c *chatCaptureClient) Chat(ctx context.Context, req *api.ChatRequest, fn a
})
}
func (c *chatToolLoopClient) Chat(ctx context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
if err := ctx.Err(); err != nil {
return err
}
c.calls++
if c.calls > c.toolRounds {
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", Content: "done"}, Done: true})
}
args := api.NewToolCallFunctionArguments()
args.Set("value", "keep going")
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
ID: fmt.Sprintf("call-%d", c.calls),
Function: api.ToolCallFunction{
Name: "fake_tool",
Arguments: args,
},
}}}})
}
func (chatTestTool) Name() string {
return "fake_tool"
}