agent: allow unlimited tool rounds for cloud models by default (#17217)
This commit is contained in:
+13
-8
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user