Loop Control
The Go AI SDK provides several ways to control how long an agent's tool-calling loop runs.
The recommended approach is StopWhen — a declarative list of stop conditions evaluated after
each step that contains tool results. For lower-level control, PrepareCall lets you modify
each generation call dynamically. Manual loops using ai.GenerateText directly give you full
control for advanced use cases.
Stop Conditions
StopWhen accepts a slice of StopCondition functions. After every step that produces tool
results, the SDK evaluates the conditions and stops the loop as soon as one returns a non-empty
reason string. The reason is available as result.StopReason.
IsStepCount
The most common condition: stop after n steps.
result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: model,
Prompt: "Analyze this dataset and create a summary report",
Tools: tools,
StopWhen: []ai.StopCondition{
ai.IsStepCount(5),
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Text)
fmt.Printf("Stopped because: %s\n", result.StopReason)
// → "Stopped because: maximum number of steps (5) reached"
IsStepCount(n) stops the loop once exactly n steps have completed. Because it is
evaluated after every step, it is checked against every step count from 1 up to n and
never skips the threshold.
HasToolCall
Stop when the model calls a specific tool — useful for semantic completion signals where the model is expected to invoke a "finish" or "submit" tool when it's done.
result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: model,
Prompt: "Research the topic and call the finish tool when done",
Tools: []types.Tool{
researchTool,
finishTool, // model calls this to signal completion
},
StopWhen: []ai.StopCondition{
ai.HasToolCall("finish"),
ai.IsStepCount(10), // safety ceiling
},
})
Combining Conditions
Conditions are evaluated OR — the loop stops on the first match. Place conditions you care
about most, or that have side effects, before safety ceilings like IsStepCount.
StopWhen: []ai.StopCondition{
ai.HasToolCall("submit"), // semantic completion
ai.IsStepCount(20), // hard limit
},
Custom Closure
Any function with signature func(ai.StopConditionState) string qualifies as a StopCondition.
Return a non-empty string to stop, or an empty string to continue.
// Token-budget guard — placed BEFORE IsStepCount so it always runs.
// Because evaluation is eager (all conditions run before the first match is
// returned), this condition fires even if IsStepCount would also match.
tokenBudget := func(state ai.StopConditionState) string {
if state.Usage.GetTotalTokens() > 5000 {
return fmt.Sprintf("token budget exceeded (%d tokens)", state.Usage.GetTotalTokens())
}
return ""
}
result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: model,
Prompt: "Perform a deep analysis",
Tools: tools,
StopWhen: []ai.StopCondition{
tokenBudget, // side-effectful or priority condition first
ai.IsStepCount(10), // safety ceiling last
},
})
StopConditionState gives you full context:
type StopConditionState struct {
// Steps completed so far; the step that just finished is the last element.
Steps []types.StepResult
// Full message history including the latest tool-result messages.
Messages []types.Message
// Accumulated token usage across all steps.
Usage types.Usage
}
Condition Evaluation Order and Side Effects
Important: All conditions are always evaluated before the first match is returned. This matches the TypeScript SDK's
Promise.allbehavior and ensures side-effectful conditions (e.g. recording metrics, sending alerts) always execute regardless of their position in the slice.
Place conditions you want to guarantee run before conditions that might pre-empt them, and rely on the evaluation guarantee rather than ordering for correctness.
StopReason
result.StopReason (on GenerateTextResult) and agentResult.StopReason (on AgentResult)
contain the reason string returned by whichever condition fired. Empty if the loop ended
naturally (no tool calls) or no StopWhen was set.
result, err := myAgent.Execute(ctx, "Complete the task")
if err != nil {
log.Fatal(err)
}
switch {
case result.StopReason != "":
fmt.Printf("Stopped by condition: %s\n", result.StopReason)
case result.FinishReason == types.FinishReasonStop:
fmt.Println("Agent finished naturally")
}
Defaults
| Scenario | Behavior |
|---|---|
Neither StopWhen nor MaxSteps set | ai.GenerateText / ai.StreamText default to IsStepCount(1): one model call, whose tool calls still execute (as in TS). agent.NewToolLoopAgent defaults to IsStepCount(20). |
MaxSteps set, StopWhen not set | Converts to StopWhen{IsStepCount(MaxSteps)} |
| Both set | StopWhen takes precedence; MaxSteps is ignored |
MaxSteps (Deprecated)
MaxSteps is shorthand for StopWhen: []ai.StopCondition{ai.IsStepCount(n)}. It is still
supported for backwards compatibility but new code should use StopWhen directly.
// Deprecated — works but prefer StopWhen
maxSteps := 5
ai.GenerateTextOptions{MaxSteps: &maxSteps}
// Preferred
ai.GenerateTextOptions{StopWhen: []ai.StopCondition{ai.IsStepCount(5)}}
PrepareCall
PrepareCall is called immediately before each generation step. Use it to adjust the system
prompt, modify available tools, or change model parameters based on how many steps have already
run or how many tokens have been consumed.
myAgent := agent.NewToolLoopAgent(agent.AgentConfig{
Model: model,
Tools: tools,
StopWhen: []ai.StopCondition{ai.IsStepCount(10)},
PrepareCall: func(ctx context.Context, config agent.PrepareCallConfig) agent.PrepareCallConfig {
// Switch to a more concise prompt after the first few steps
if config.StepNumber > 3 {
config.System = "Be concise. Finalize your answer."
}
// Restrict available tools in later steps
if config.StepNumber > 7 {
config.Tools = finalizeTools
}
return config
},
})
PrepareCallConfig exposes:
| Field | Type | Description |
|---|---|---|
StepNumber | int | Zero-based index of the upcoming step |
System | string | System prompt for this call |
Messages | []types.Message | Conversation history so far |
Tools | []types.Tool | Tool set for this call |
Temperature | *float64 | Sampling temperature override |
MaxTokens | *int | Token limit override |
AccumulatedUsage | types.Usage | Total tokens used so far |
CustomData | interface{} | Pass-through state between invocations |
Manual Loop
For cases where you need complete control outside the agent abstraction — dynamic model
selection, custom context trimming, interleaved human input — you can implement the loop
yourself using ai.GenerateText.
func manualAgentLoop(ctx context.Context, prompt string, tools []types.Tool) (string, error) {
provider := openai.New(openai.Config{APIKey: os.Getenv("OPENAI_API_KEY")})
model, _ := provider.LanguageModel("gpt-4")
messages := []types.Message{
{
Role: types.RoleUser,
Content: []types.ContentPart{types.TextContent{Text: prompt}},
},
}
for step := 0; step < 10; step++ {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
result, err := ai.GenerateText(ctx, ai.GenerateTextOptions{
Model: model,
Messages: messages,
Tools: tools,
})
if err != nil {
return "", fmt.Errorf("step %d failed: %w", step, err)
}
// Natural completion — no tool calls
if len(result.ToolCalls) == 0 {
return result.Text, nil
}
// Append assistant turn
messages = append(messages, types.Message{
Role: types.RoleAssistant,
Content: []types.ContentPart{types.TextContent{Text: result.Text}},
})
// Execute tools and append results
for _, toolCall := range result.ToolCalls {
var toolResult interface{}
var toolErr error
for _, tool := range tools {
if tool.Name == toolCall.ToolName {
toolResult, toolErr = tool.Execute(ctx, toolCall.Arguments, types.ToolExecutionOptions{})
break
}
}
resultContent := map[string]interface{}{"result": toolResult}
if toolErr != nil {
resultContent = map[string]interface{}{"error": toolErr.Error()}
}
messages = append(messages, types.Message{
Role: types.RoleTool,
Content: []types.ContentPart{
types.ToolResultContent{
ToolCallID: toolCall.ID,
ToolName: toolCall.ToolName,
Result: resultContent,
},
},
})
}
}
return "", fmt.Errorf("reached maximum steps without completion")
}
When to Use a Manual Loop
Use a manual loop when you need:
- Dynamic model selection — swap models between steps based on complexity
- Context trimming — prune the message history to stay within token limits
- Tool phase control — expose different tool sets in different phases
- Interleaved human input — pause for approval between steps
For standard tool-calling agents, StopWhen + PrepareCall cover most needs without the
boilerplate.
See Also
- Stop-When Example — focused example showing all three stop patterns
- Configuring Call Options — temperature, max tokens, and other options
- Workflow Patterns — structured agent designs
- Error Handling — robust error handling