diff --git a/agent/llmagent/agent.go b/agent/llmagent/agent.go index c6e38b0..9934021 100644 --- a/agent/llmagent/agent.go +++ b/agent/llmagent/agent.go @@ -14,8 +14,6 @@ import ( "github.com/sylumi/agentkit/tool" ) -const defaultMaxModelCalls = 10 - // Config describes an LLM agent. type Config struct { Name string @@ -29,7 +27,7 @@ type Config struct { Toolsets []tool.Toolset // GenerateConfig and its nested data must remain unchanged during Run. GenerateConfig *model.GenerateConfig - // MaxModelCalls limits model calls per Run; zero defaults to 10. + // MaxModelCalls limits model calls per Run and must be positive. MaxModelCalls int } @@ -52,11 +50,8 @@ func New(cfg Config) (agent.Agent, error) { if cfg.Model == nil { return nil, fmt.Errorf("llmagent: model is required") } - if cfg.MaxModelCalls < 0 { - return nil, fmt.Errorf("llmagent: max model calls must not be negative") - } - if cfg.MaxModelCalls == 0 { - cfg.MaxModelCalls = defaultMaxModelCalls + if cfg.MaxModelCalls <= 0 { + return nil, fmt.Errorf("llmagent: max model calls must be positive") } return &llmAgent{ name: cfg.Name, diff --git a/agent/llmagent/agent_test.go b/agent/llmagent/agent_test.go index 2260d70..45bd786 100644 --- a/agent/llmagent/agent_test.go +++ b/agent/llmagent/agent_test.go @@ -85,7 +85,7 @@ func TestStreamingEvents(t *testing.T) { }}}}, nil) } }) - a, err := llmagent.New(llmagent.Config{Name: "streaming-agent", Model: llm}) + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "streaming-agent", Model: llm}) if err != nil { t.Fatal(err) } @@ -167,7 +167,8 @@ func TestNew(t *testing.T) { return nil }) for _, cfg := range []llmagent.Config{ - {Model: llm}, {Name: " \t", Model: llm}, {Name: "chat"}, + {Model: llm, MaxModelCalls: 1}, {Name: " \t", Model: llm, MaxModelCalls: 1}, {Name: "chat", MaxModelCalls: 1}, + {Name: "chat", Model: llm, MaxModelCalls: 0}, {Name: "chat", Model: llm, MaxModelCalls: -1}, } { if _, err := llmagent.New(cfg); err == nil { @@ -175,7 +176,7 @@ func TestNew(t *testing.T) { } } for _, description := range []string{"", "Answers questions."} { - a, err := llmagent.New(llmagent.Config{Name: "chat", Description: description, Model: llm}) + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Description: description, Model: llm}) if err != nil { t.Fatal(err) } @@ -183,7 +184,7 @@ func TestNew(t *testing.T) { t.Fatalf("agent identity: %q, %q", a.Name(), a.Description()) } } - for _, limit := range []int{0, 1, 20} { + for _, limit := range []int{1, 20} { if _, err := llmagent.New(llmagent.Config{Name: "chat", Model: llm, MaxModelCalls: limit}); err != nil { t.Fatalf("rejected model call limit %d: %v", limit, err) } @@ -227,7 +228,8 @@ func TestRunToolsAndGenerateConfig(t *testing.T) { } modelCalls := 0 a, err := llmagent.New(llmagent.Config{ - Name: "chat", Instruction: wantRequest.Instructions, + MaxModelCalls: 10, + Name: "chat", Instruction: wantRequest.Instructions, Tools: configuredTools, GenerateConfig: tc.config, Model: modelFunc(func(_ context.Context, req model.Request, stream bool) iter.Seq2[model.Event, error] { modelCalls++ @@ -290,7 +292,8 @@ func TestRunRejectsInvalidTools(t *testing.T) { wantError = `duplicate tool name "weather"` } a, err := llmagent.New(llmagent.Config{ - Name: "chat", Tools: configuredTools, + MaxModelCalls: 10, + Name: "chat", Tools: configuredTools, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { t.Fatal("invalid tools reached the model") return nil @@ -356,7 +359,8 @@ func TestRunHistoryAndPersistence(t *testing.T) { } }) a, err := llmagent.New(llmagent.Config{ - Name: "chat", Description: "Not a prompt.", Model: llm, Instruction: "Keep {placeholders} literal.", + MaxModelCalls: 10, + Name: "chat", Description: "Not a prompt.", Model: llm, Instruction: "Keep {placeholders} literal.", }) if err != nil { t.Fatal(err) @@ -482,7 +486,7 @@ func TestRunModelErrors(t *testing.T) { Partial: model.PartialOutput{Parts: []model.PartialPart{{Kind: model.PartText, Text: &partialText}}}, } closed := false - a, err := llmagent.New(llmagent.Config{Name: "chat", Model: modelFunc( + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Model: modelFunc( func(modelCtx context.Context, _ model.Request, _ bool) iter.Seq2[model.Event, error] { return func(yield func(model.Event, error) bool) { defer func() { closed = true }() @@ -516,7 +520,7 @@ func TestRunModelErrors(t *testing.T) { func TestRunCanceledBeforeConsumption(t *testing.T) { _, invocation := newInvocation(t) configuredTool := &stubTool{t: t, definition: model.ToolDefinition{Name: "weather"}} - a, err := llmagent.New(llmagent.Config{Name: "chat", Tools: []tool.Tool{configuredTool}, Model: modelFunc( + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Tools: []tool.Tool{configuredTool}, Model: modelFunc( func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { t.Fatal("canceled invocation called the model") return nil @@ -546,7 +550,7 @@ func TestRunCanceledBeforeConsumption(t *testing.T) { func TestRunEarlyExit(t *testing.T) { _, invocation := newInvocation(t) closed, stopped := false, false - a, err := llmagent.New(llmagent.Config{Name: "chat", Model: modelFunc( + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Model: modelFunc( func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { return func(yield func(model.Event, error) bool) { defer func() { closed = true }() @@ -705,7 +709,8 @@ func TestRunMultipleToolsAndErrors(t *testing.T) { } modelCalls := 0 a, err := llmagent.New(llmagent.Config{ - Name: "chat", Tools: []tool.Tool{second, first}, + MaxModelCalls: 10, + Name: "chat", Tools: []tool.Tool{second, first}, Model: modelFunc(func(_ context.Context, req model.Request, _ bool) iter.Seq2[model.Event, error] { modelCalls++ steps = append(steps, fmt.Sprintf("model-%d", modelCalls)) @@ -752,7 +757,7 @@ func TestRunModelCallLimit(t *testing.T) { limit int want int }{ - {name: "default", want: 10}, + {name: "ten calls", limit: 10, want: 10}, {name: "one call", limit: 1, want: 1}, {name: "two calls", limit: 2, want: 2}, } { @@ -828,7 +833,8 @@ func TestRunStopsBeforeFurtherWork(t *testing.T) { return "done", nil }} a, err := llmagent.New(llmagent.Config{ - Name: "chat", Tools: []tool.Tool{lookup}, + MaxModelCalls: 10, + Name: "chat", Tools: []tool.Tool{lookup}, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { modelCalls++ if modelCalls != 1 { @@ -900,7 +906,8 @@ func TestRunToolCancellation(t *testing.T) { }} modelCalls := 0 a, err := llmagent.New(llmagent.Config{ - Name: "chat", Tools: []tool.Tool{lookup}, + MaxModelCalls: 10, + Name: "chat", Tools: []tool.Tool{lookup}, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { modelCalls++ if modelCalls != 1 { @@ -945,7 +952,8 @@ func TestRunPersistenceFailureStops(t *testing.T) { return "done", nil }} a, err := llmagent.New(llmagent.Config{ - Name: "chat", Tools: []tool.Tool{lookup}, + MaxModelCalls: 10, + Name: "chat", Tools: []tool.Tool{lookup}, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { modelCalls++ if modelCalls != 1 { @@ -999,7 +1007,8 @@ func TestRunModelErrorAfterTool(t *testing.T) { }} closed := false a, err := llmagent.New(llmagent.Config{ - Name: "chat", Tools: []tool.Tool{lookup}, + MaxModelCalls: 10, + Name: "chat", Tools: []tool.Tool{lookup}, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { modelCalls++ if modelCalls == 1 { diff --git a/agent/llmagent/toolsets_test.go b/agent/llmagent/toolsets_test.go index ea45e20..a13d51b 100644 --- a/agent/llmagent/toolsets_test.go +++ b/agent/llmagent/toolsets_test.go @@ -76,7 +76,8 @@ func TestToolsetsLifecycle(t *testing.T) { sets := []tool.Toolset{first, plain, emptySet} modelCalls := 0 a, err := llmagent.New(llmagent.Config{ - Name: "chat", Instruction: "base", Tools: []tool.Tool{newTool("static")}, + MaxModelCalls: 10, + Name: "chat", Instruction: "base", Tools: []tool.Tool{newTool("static")}, Toolsets: sets, GenerateConfig: &model.GenerateConfig{}, Model: modelFunc(func(_ context.Context, req model.Request, _ bool) iter.Seq2[model.Event, error] { modelCalls++ @@ -154,7 +155,8 @@ func TestEmptyToolsetDiscoveredOnce(t *testing.T) { store, invocation := newInvocation(t) discoveries, modelCalls := 0, 0 a, err := llmagent.New(llmagent.Config{ - Name: "empty", Toolsets: []tool.Toolset{toolsetFunc{"empty", func(context.Context) ([]tool.Tool, error) { + MaxModelCalls: 10, + Name: "empty", Toolsets: []tool.Toolset{toolsetFunc{"empty", func(context.Context) ([]tool.Tool, error) { discoveries++ return empty, nil }}}, @@ -204,7 +206,7 @@ func TestToolsetRegistrationFailures(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { _, invocation := newInvocation(t) - a, err := llmagent.New(llmagent.Config{Name: "chat", Tools: tc.tools, Toolsets: tc.sets, + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Tools: tc.tools, Toolsets: tc.sets, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { t.Fatal("invalid registration reached model") return nil @@ -263,7 +265,7 @@ func TestToolsetFailureAndCancellation(t *testing.T) { toolsetFunc: toolsetFunc{"later", func(context.Context) ([]tool.Tool, error) { laterDiscoveries++; return nil, nil }}, process: func(context.Context, *model.Request) error { t.Fatal("processing continued after failure"); return nil }, } - a, err := llmagent.New(llmagent.Config{Name: "chat", Toolsets: []tool.Toolset{first, later}, + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Toolsets: []tool.Toolset{first, later}, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { t.Fatal("model called after failure") return nil @@ -318,7 +320,7 @@ func TestToolsetsConcurrentRuns(t *testing.T) { return nil }, } - a, err := llmagent.New(llmagent.Config{Name: "chat", Instruction: "base", Toolsets: []tool.Toolset{set}, + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Instruction: "base", Toolsets: []tool.Toolset{set}, Model: modelFunc(func(ctx context.Context, req model.Request, _ bool) iter.Seq2[model.Event, error] { id := ctx.Value(runKey{}).(string) if req.Instructions != "base|"+id { @@ -384,7 +386,7 @@ func TestToolsetProcessingStopsWithRun(t *testing.T) { return cause }, } - a, err := llmagent.New(llmagent.Config{Name: "chat", Toolsets: []tool.Toolset{set}, + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Toolsets: []tool.Toolset{set}, Model: modelFunc(func(context.Context, model.Request, bool) iter.Seq2[model.Event, error] { modelCalls++ return resultStream(toolCallResult(model.ToolCallPart{ID: "call", Name: "lookup", Arguments: json.RawMessage(`{}`)})) diff --git a/cmd/launcher/web/web_test.go b/cmd/launcher/web/web_test.go index d5e43b0..a34263e 100644 --- a/cmd/launcher/web/web_test.go +++ b/cmd/launcher/web/web_test.go @@ -129,7 +129,7 @@ func TestWebInfersPublicModelLabel(t *testing.T) { if err != nil { t.Fatal(err) } - a, err := llmagent.New(llmagent.Config{Name: "chat", Model: llm}) + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "chat", Model: llm}) if err != nil { t.Fatal(err) } diff --git a/examples/assistant/main.go b/examples/assistant/main.go index 9590503..1876929 100644 --- a/examples/assistant/main.go +++ b/examples/assistant/main.go @@ -40,10 +40,11 @@ func main() { thinking, maxOutput := true, int64(4096) a, err := llmagent.New(llmagent.Config{ - Name: "assistant", - Model: llm, - Instruction: "You are a helpful assistant. Answer in the user's language. Use get_current_time when you need the current date or time.", - Tools: []tool.Tool{currentTime}, + MaxModelCalls: 10, + Name: "assistant", + Model: llm, + Instruction: "You are a helpful assistant. Answer in the user's language. Use get_current_time when you need the current date or time.", + Tools: []tool.Tool{currentTime}, GenerateConfig: &model.GenerateConfig{ MaxOutputTokens: &maxOutput, Reasoning: &model.ReasoningConfig{Enabled: &thinking, Effort: "high"}, diff --git a/runner/runner_test.go b/runner/runner_test.go index e63f908..f83e98b 100644 --- a/runner/runner_test.go +++ b/runner/runner_test.go @@ -132,7 +132,8 @@ func TestRunToolLoopAndNextTurn(t *testing.T) { } var requests []model.Request a, err := llmagent.New(llmagent.Config{ - Name: "weather-agent", Tools: []tool.Tool{weather}, + MaxModelCalls: 10, + Name: "weather-agent", Tools: []tool.Tool{weather}, Model: modelFunc(func(ctx context.Context, req model.Request, stream bool) iter.Seq2[model.Event, error] { modelCalls++ if ctx != t.Context() || stream != streaming || modelCalls > 3 { diff --git a/runner/skills_test.go b/runner/skills_test.go index ed46f1c..741db6e 100644 --- a/runner/skills_test.go +++ b/runner/skills_test.go @@ -32,7 +32,8 @@ func TestRunSkillsToolset(t *testing.T) { calls := 0 var firstInstructions string a, err := llmagent.New(llmagent.Config{ - Name: "skills", Instruction: "Help the user.", Toolsets: []tool.Toolset{skills}, + MaxModelCalls: 10, + Name: "skills", Instruction: "Help the user.", Toolsets: []tool.Toolset{skills}, Model: modelFunc(func(_ context.Context, req model.Request, stream bool) iter.Seq2[model.Event, error] { calls++ if stream != streaming { diff --git a/server/server_test.go b/server/server_test.go index 7dd7614..69c46ab 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -335,7 +335,7 @@ func TestLLMAgentToolsAcrossTurns(t *testing.T) { yield(model.ResultEvent{Result: result}, nil) } }) - a, err := llmagent.New(llmagent.Config{Name: "calculator", Model: llm, Tools: []tool.Tool{add}}) + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "calculator", Model: llm, Tools: []tool.Tool{add}}) if err != nil { t.Fatal(err) } @@ -418,7 +418,7 @@ func TestLiveStreamArrivesBeforeFinalCommit(t *testing.T) { }}}}, nil) } }) - a, err := llmagent.New(llmagent.Config{Name: "live", Model: llm}) + a, err := llmagent.New(llmagent.Config{MaxModelCalls: 10, Name: "live", Model: llm}) if err != nil { t.Fatal(err) }