I tried out the GoalLoop plugin for Strands Agents that had been added

I tried out the GoalLoop plugin for Strands Agents that had been added

I tried out the GoalLoop plugin recently added to Strands Agents!
2026.07.07

This page has been translated by machine translation. View original

Introduction

Hello, I'm Jinno from the consulting department, and I love supermarkets.

Recently, I found out that Strands Agents has a plugin called GoalLoop. Claude, Codex, and others have a /goal command too. I was intrigued to discover that Strands has something similar!

https://strandsagents.com/docs/user-guide/concepts/plugins/goal-loop/

This GoalLoop is a plugin that validates the agent's response against a goal (target condition), and if it fails, injects feedback and automatically re-executes. It loops until either passing or reaching the maximum number of attempts or time limit.

This time, I want to actually try it out hands-on with 3 patterns: natural language goals, programmatic validators, and behavior when limits are reached!

Prerequisites

The verification environment for this time is as follows.

Item Version/Setting
Python 3.12.12
strands-agents 1.45.0
Model Claude Haiku 4.5 (Amazon Bedrock)
Region us-east-1 (cross-region inference)

I'll use uv for package management. Create a project and install strands-agents in advance.

Setup
uv init --bare --name goal-strands
uv add strands-agents

Also, this assumes that Claude Haiku 4.5 is available on Amazon Bedrock. Please make sure your AWS credentials are configured as well.

How GoalLoop Works

Before getting into the implementation, let me organize the GoalLoop operation flow.

When the agent generates a response, that response is validated against the goal. If it passes, it ends there; if it fails, feedback is injected into the conversation and re-executed, and this loop repeats until it passes or reaches the max_attempts / timeout limit.

There are two ways to specify goals.

  • If specified as a natural language string, an LLM judge (evaluation agent) determines pass/fail
  • If specified as a Python function, programmatic validation without using an LLM

For things that can be mechanically determined, such as character count checks, schema compliance, and test execution result verification, using a function-based approach is also advantageous in terms of cost.

Let's actually try it out starting from the next section!

What Use Cases Can It Be Used For?

The image is similar to the /goal command in Claude Code. /goal is a feature where you set achievement conditions, a separate evaluation model judges pass/fail at each turn, and the agent continues working until the conditions are met. GoalLoop can be thought of as a way to incorporate this exact "goal setting + pass/fail judgment by a separate model + automatic loop" structure into the Strands agents you build yourself, in just a few lines.

https://code.claude.com/docs/en/goal

Specifically, the following use cases can be considered.

  • Ensuring output format and quality
    • Cases where you want to guarantee format before passing to subsequent processing, such as character limits, JSON schema compliance, and presence of required items
  • Ensuring code generation quality
    • Cases where you run pytest or lint as validators against generated code and have it fix until they pass
  • Compliance with response policies
    • Cases where you want to meet quality standards expressed in natural language, such as citing at least two sources or responding in a specific tone

Until now, we had to work hard with system prompts and skill instructions for such requirements, or implement the validation and retry loop ourselves. With GoalLoop, you just write the validation logic, and the plugin handles managing retry counts and injecting feedback.

Try It ① Natural Language Goal

First, the simplest pattern: specifying a goal in natural language. Let's have it explain why rainbows form, with the conditions "within 3 sentences, using words even elementary school students can understand, no technical terms."

nl_goal.py
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop

model = BedrockModel(
    model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
    region_name="us-east-1",
)

concise = GoalLoop(
    goal="Explain in 3 sentences or less, using words that even elementary school students can understand. Do not use technical terms.",
    max_attempts=3,
)

agent = Agent(model=model, plugins=[concise])
result = agent("Why do rainbows form?")

goal_result = concise.last_result(agent)
print("\n--- GoalResult ---")
print(f"passed: {goal_result.passed}")
print(f"stop_reason: {goal_result.stop_reason}")
print(f"attempts: {len(goal_result.attempts)}")
for attempt in goal_result.attempts:
    print(f"  #{attempt.attempt}: feedback={attempt.feedback}")

Just create a GoalLoop and pass it to Agent's plugins, and the retry loop becomes active. When you pass a natural language string to goal, a judge agent is internally called for each response to determine pass/fail. After execution, you can retrieve detailed validation results (pass/fail, stop reason, and feedback for each attempt) with last_result().

Let's run it!

Run command
uv run python nl_goal.py
Execution result (excerpt)
--- GoalResult ---
passed: True
stop_reason: satisfied
attempts: 2
  #1: feedback=All three requirements of the goal are not met. (1) The answer far exceeds 3 sentences, containing multiple sections, bullet points, and detailed explanations. (2) Many technical terms are used such as "refraction," "reflection," "wavelength," and "gradient," which are difficult for elementary school students to understand. To meet the requirements, please change to a very simple explanation within 3 sentences, such as "Light hits raindrops and bounces back and bends, causing colors to separate and become visible."
  #2: feedback=None

Oh, it's properly retrying!!

The first response was a long explanation using headings and bullet points, and the judge specifically pointed out that it "far exceeds 3 sentences" and "many technical terms are used," resulting in a failure. As a result of that feedback being injected into the conversation and re-executed, the second attempt returned the following simple answer.

Second response
When sunlight hits water droplets from rain, the light bounces back and bends.
At that time, because the colors contained in the light each bounce back at different angles,
colors like red, yellow, and blue separate and become visible.
That's why when you look away from the sun on a rainy day, you can see a rainbow.

It's within 3 sentences, has no technical terms, and is an explanation that even elementary school students can understand! I think the judge's feedback went so far as to point out what was wrong and how to fix it, which is why it passed on the second attempt.

Try It ② Programmatic Validator

Next is the pattern of validating with a Python function without using an LLM. Let's implement a validator that checks whether the answer is within 100 characters.

validator_goal.py
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop

model = BedrockModel(
    model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
    region_name="us-east-1",
)

def char_count_validator(response, agent):
    text = "".join(
        block["text"] for block in response["content"] if "text" in block
    )
    chars = len(text)
    if chars <= 100:
        return True
    return {
        "passed": False,
        "feedback": f"The answer is too long ({chars} characters). Please keep it within 100 characters.",
    }

plugin = GoalLoop(
    goal=char_count_validator,
    max_attempts=5,
    timeout=60.0,
)

agent = Agent(model=model, plugins=[plugin])
result = agent("Please explain what a generative AI agent is.")

goal_result = plugin.last_result(agent)
print("\n--- GoalResult ---")
print(f"passed: {goal_result.passed}")
print(f"stop_reason: {goal_result.stop_reason}")
print(f"attempts: {len(goal_result.attempts)}")
for attempt in goal_result.attempts:
    print(f"  #{attempt.attempt}: feedback={attempt.feedback}")

The validation function takes 2 arguments, response and agent, returns True if passing, or a dictionary with passed and feedback if failing. Since the feedback text is passed directly to the model as a hint for the next attempt, including specific information like "how many characters over" helps convergence happen faster. A timeout is also specified, and in this case, a time budget of 60 seconds is set for the entire process. Note that validation functions can also be written as async def, so time-consuming validations like running pytest can be incorporated as-is.

Run command
uv run python validator_goal.py
Execution result (excerpt)
--- GoalResult ---
passed: True
stop_reason: satisfied
attempts: 3
  #1: feedback=The answer is too long (576 characters). Please keep it within 100 characters.
  #2: feedback=The answer is too long (131 characters). Please keep it within 100 characters.
  #3: feedback=None

We could confirm the answer getting progressively shorter: 576 characters → 131 characters → pass! The final answer is as follows, and it fits neatly within 100 characters.

Third response
What is a generative AI agent?

It is an AI system that autonomously combines multiple tools and automatically executes tasks.
It judges and plans on its own in response to user instructions and operates until reaching the goal.

Unlike LLM judges, the validation itself has zero LLM inference cost, so for mechanical conditions like character counts, it seems best to choose this approach without hesitation.

Try It ③ Behavior When the Limit Is Reached

Finally, let's confirm what happens when max_attempts is reached without passing. I prepared a validator that will never pass for operation verification.

fail_case.py
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop

model = BedrockModel(
    model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
    region_name="us-east-1",
)

def impossible_validator(response, agent):
    return {
        "passed": False,
        "feedback": "This validation will never pass (for operation verification).",
    }

plugin = GoalLoop(goal=impossible_validator, max_attempts=2)

agent = Agent(model=model, plugins=[plugin])
result = agent("Hello!")

goal_result = plugin.last_result(agent)
print("\n--- GoalResult ---")
print(f"passed: {goal_result.passed}")
print(f"stop_reason: {goal_result.stop_reason}")
print(f"attempts: {len(goal_result.attempts)}")
Execution result
--- GoalResult ---
passed: False
stop_reason: max_attempts
attempts: 2

Rather than throwing an exception, a result with passed=False and stop_reason='max_attempts' is returned. On the application side, it seems we can design fallback processing for failures (such as escalating to humans or re-executing with a different model) by checking stop_reason.

Something I Wondered About ① Doesn't the Context Grow with Retries?

What I wondered about here is that with each retry, feedback and re-responses accumulate in the conversation history, which could make the context enormous. Looking at the source, retries work by injecting feedback on the failed response as a user message and re-executing, so with the default (preserve_context=True), all failed attempts remain in the history.

I tried comparing conversation histories with preserve_context True / False to see how much they grow. The validation content is the same 100-character or less check as in Try It ②, and both passed in 3 attempts.

Execution result
=== preserve_context=True ===
attempts: 3
Number of messages in final conversation history: 6
Total characters in conversation history: 1393

=== preserve_context=False ===
attempts: 3
Number of messages in final conversation history: 3
Total characters in conversation history: 402

With preserve_context=True, all 6 messages remain: initial prompt → failed response → feedback → failed response → feedback → passing response. On the other hand, setting it to False rolls back to the snapshot right before the first model call each time and retries with only the immediately preceding feedback, resulting in 3 messages.

What I want to be conscious of here is that even with False, the feedback from failures is properly passed. Looking at the actual history, what gets rolled back is only the body of the failed response, and the feedback for the immediately preceding attempt is injected as a user message for retry each time.

Final conversation history with preserve_context=False
[0] user: Please explain what a generative AI agent is.
[1] user: Your previous attempt did not satisfy the goal. ...(feedback for the immediately preceding attempt)
[2] assistant: (passing response)

In other words, True means improving while looking at "all past attempts + all feedback," while False means rebuilding each time with only "the original prompt + 1 immediately preceding feedback."

I also tried combining it with Strands' ConversationManager. SlidingWindowConversationManager is a mechanism that deletes old messages when the conversation history exceeds a specified number of messages. Let's set this with window_size=4 and run with preserve_context=True.

window_test.py (excerpt)
agent = Agent(
    model=model,
    plugins=[plugin],
    conversation_manager=SlidingWindowConversationManager(window_size=4),
)
Conversation history after execution
Number of messages in final conversation history: 4
[0] user: Your previous attempt did not satisfy the goal. ...(feedback for attempt 1)
[1] assistant: (2nd failed response)
[2] user: Your previous attempt did not satisfy the goal. ...(feedback for attempt 2)
[3] assistant: (passing response)

While normally 6 messages would remain, the 2 old messages (initial prompt and 1st failed response) fell outside the window and were deleted, keeping it at 4 messages. It coexists without interfering with GoalLoop's retries!

You can use them in the following ways: leave it as True when you want it to improve based on previous attempts; use False or combine with ConversationManager when there are likely to be many attempts or when you don't want to pollute the history.

Something I Wondered About ② Is Streaming Possible?

Another thing I wondered about was streaming. I tried whether it can be used together with stream_async() given that retries are involved.

streaming_goal.py (excerpt)
async def main():
    async for event in agent.stream_async("Please explain what a generative AI agent is."):
        if "data" in event:
            print(event["data"], end="", flush=True)

As a result, streaming worked without any issues! As a characteristic, all retry portions are returned in the same stream. This time it passed in 3 attempts, but roughly as shown below, the text of all 3 attempts arrived continuously as one stream without interruption.

How the stream appears (conceptual image)
# What is a Generative AI Agent?
## Basic Definition
A generative AI agent, with generative AI (LLM, etc.) at its core...
(↑ The first long response flows through to the end)
# What is a Generative AI Agent?
Equipped with generative AI (LLM), in response to user instructions...
(↑ The second response flows without any break)
# What is a Generative AI Agent?
Equipped with generative AI, autonomously in response to user instructions...
(↑ The third passing response continues to flow)

If you stream this as-is in an end-user-facing UI, even the intermediate responses that failed will all be visible. There may be cases where you only want to display the final result.

How to Detect Attempt Boundaries

So how should we implement this? I investigated whether there are markers in the stream that can detect attempt boundaries by checking what types of events flow through.

Order of major events (case where it passed in 3 attempts)
init_event_loop    ← Start of attempt 1
(data events flow)
message(assistant) ← Attempt 1 response confirmed
init_event_loop    ← Start of attempt 2
(data events flow)
message(assistant) ← Attempt 2 response confirmed
init_event_loop    ← Start of attempt 3
(data events flow)
message(assistant) ← Attempt 3 response confirmed
result             ← Overall completion

Since an init_event_loop event flows with each retry, we can implement displaying only the final passing attempt's response by using this as a boundary to reset the buffer.

streaming_buffer.py (excerpt)
async def main():
    buffer = ""
    attempt = 0
    async for event in agent.stream_async("Please explain what a generative AI agent is."):
        if "init_event_loop" in event:
            if buffer:
                print(f"(Attempt {attempt} failed and discarded. Retrying.)")
            attempt += 1
            buffer = ""
        if "data" in event:
            buffer += event["data"]

    print(f"\n=== Displaying only the response from passing attempt {attempt} ===")
    print(buffer)

It's a simple implementation that just clears the buffer when init_event_loop arrives and accumulates the text from data events. Let's run it.

Execution result
(Attempt 1 failed and discarded. Retrying.)
(Attempt 2 failed and discarded. Retrying.)

=== Displaying only the response from passing attempt 3 ===
# What is a Generative AI Agent?

An AI system that judges and acts autonomously. It receives user instructions, makes its own plan, combines multiple tools, and proactively achieves the goal.

We were able to discard the failed attempts and display only the passing response! For cases where you definitely want to show only the final response, it's safest to buffer it on the server or application side like this and display it only after the pass is confirmed.

If you want to show the agent's process even during failures, you could stream everything and make the UI show something that makes it clear retrying is happening when init_event_loop arrives.

Summary of Configuration Parameters

The main configuration parameters for GoalLoop are as follows.

Parameter Default Description
goal Required Natural language string or validation function
max_attempts Unlimited Upper limit on number of attempts
timeout Unlimited Overall time budget in seconds
judge None Model configuration for natural language goal judgment (JudgeConfig)
preserve_context True Whether to preserve conversation history during retries
resume_prompt_template Built-in Function that generates the retry message

By specifying judge, you can specify a model to use for pass/fail judgment that is separate from the main agent.

Example of replacing the judgment model
from strands.vended_plugins.goal import GoalLoop, JudgeConfig

plugin = GoalLoop(
    goal="Cite at least two sources.",
    max_attempts=3,
    judge=JudgeConfig(
        model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0"),
    ),
)

Since judgment occurs with each failure, you can reduce costs by assigning a lightweight model like Nova Lite or Haiku like this. Also, setting preserve_context to False results in a stateless retry that discards the history of failed responses, reverts to the state before the initial call, and retries with only the immediately preceding feedback.

Conclusion

It feels like parts that previously required building agent implementations from scratch or adding instructions to prompts are now completely covered, and it's great to be able to use it just by inserting a plugin!

I'd like to try using it for long-running tasks or when I want to ensure a certain level of quality.

I hope this article is at least a little helpful. Thank you for reading to the end!

Supplement: You Can Also Build Custom Judges

I didn't cover this in the article, but the internal system prompts and prompt generation functions used for natural language goal judgment are public, and you can also build the judge itself from scratch. The official documentation has examples, which I'll quote here.

Quoted from official documentation
from strands.vended_plugins.goal import (
    GoalLoop,
    build_judge_prompt,
    JUDGE_SYSTEM_PROMPT,
    JudgeOutcome,
)

async def custom_judge(response, agent):
    from strands import Agent as JudgeAgent
    from strands.models.bedrock import BedrockModel

    judge = JudgeAgent(
        model=BedrockModel(model_id="us.amazon.nova-lite-v1:0"),
        callback_handler=None,
        system_prompt=JUDGE_SYSTEM_PROMPT,
        structured_output_model=JudgeOutcome,
    )
    prompt = build_judge_prompt("Be concise.", agent.messages)
    result = await judge.invoke_async(prompt)
    outcome = result.structured_output
    return {"passed": outcome.passed, "feedback": outcome.feedback}

plugin = GoalLoop(goal=custom_judge, max_attempts=3)

This is a form that reuses the same components as the built-in judge (JUDGE_SYSTEM_PROMPT / build_judge_prompt) while completely replacing the judge agent's configuration. If you want to customize the judgment prompt to Japanese, this method should work.

https://strandsagents.com/docs/user-guide/concepts/plugins/goal-loop/

Share this article