
I tried out the GoalLoop plugin for Strands Agents that had been added
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!
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.
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.
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."
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!
uv run python nl_goal.py
--- 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.
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.
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.
uv run python validator_goal.py
--- 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.
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.
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)}")
--- 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.
=== 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.
[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.
agent = Agent(
model=model,
plugins=[plugin],
conversation_manager=SlidingWindowConversationManager(window_size=4),
)
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.
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.
# 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.
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.
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.
(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.
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.
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.