Skip to main content

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.all behavior 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​

ScenarioBehavior
Neither StopWhen nor MaxSteps setai.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 setConverts to StopWhen{IsStepCount(MaxSteps)}
Both setStopWhen 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:

FieldTypeDescription
StepNumberintZero-based index of the upcoming step
SystemstringSystem prompt for this call
Messages[]types.MessageConversation history so far
Tools[]types.ToolTool set for this call
Temperature*float64Sampling temperature override
MaxTokens*intToken limit override
AccumulatedUsagetypes.UsageTotal tokens used so far
CustomDatainterface{}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​