Skip to content

GoalLoop

When your agent’s response needs to meet a quality bar before returning, GoalLoop handles the retry loop. It validates the response after each invocation, feeds feedback back as a user message on failure, and re-invokes the agent. This continues until validation passes, a max attempt count is reached, or a timeout elapses.

A single-pass agent response often misses the mark: too verbose, wrong format, incomplete reasoning, or failing a test suite. You could wrap the agent call in a manual retry loop, but that means reimplementing timeout logic, attempt tracking, feedback injection, and state management every time.

GoalLoop handles all of that as a plugin. Define what “done” means, attach it to your agent, and the retry loop runs automatically inside the existing hook lifecycle.

flowchart LR
A[invoke] --> B[Agent responds]
B --> C{Validate}
C -->|pass| D[Done]
C -->|fail| E[Inject feedback]
E --> B
  1. The agent processes the prompt and produces a response.
  2. GoalLoop extracts the last assistant message and runs the validator.
  3. If the validator passes, the loop terminates with a “satisfied” result.
  4. If the validator fails and budget remains, GoalLoop injects feedback as a new user message and re-invokes the agent.
  5. If the attempt limit or timeout is exhausted, the loop terminates without retrying.

Pass a natural-language goal string. GoalLoop builds an internal judge agent (using the host agent’s model) that grades each response against the goal and returns structured feedback on failure.

from strands import Agent
from strands.vended_plugins.goal import GoalLoop
concise = GoalLoop(
goal="At most 3 sentences, accessible to a 10-year-old, "
"no jargon.",
max_attempts=3,
)
agent = Agent(plugins=[concise])
result = agent("Explain how rainbows form.")
print(concise.last_result(agent))
# Typical output:
# GoalResult(passed=True, stop_reason='satisfied', attempts=[...])

For checks that don’t need a language model (word count, schema conformance, test suite exit codes), pass a function as goal. This skips the judge agent entirely.

A validator receives the last assistant message and the host agent. It returns:

  • true / false (shorthand: pass or fail with no feedback)
  • A dict/object with passed and optional feedback
  • A ValidationOutcome instance
from strands.vended_plugins.goal import GoalLoop
def word_count_validator(response, agent):
text = " ".join(
block["text"]
for block in response["content"]
if "text" in block
)
words = len(text.split())
if words <= 50:
return True
return {
"passed": False,
"feedback": f"Too long ({words} words). Cap at 50.",
}
plugin = GoalLoop(
goal=word_count_validator,
max_attempts=5,
timeout=30.0,
)

Async validators work too. Run a test suite, call an external API, or await any I/O inside the validator:

import asyncio
from strands.vended_plugins.goal import GoalLoop
async def tests_pass(response, agent):
proc = await asyncio.create_subprocess_exec(
"pytest", "--tb=short",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
return True
output = (stdout.decode() + stderr.decode())[-4000:]
return {
"passed": False,
"feedback": f"pytest exited {proc.returncode}.\n{output}",
}
plugin = GoalLoop(goal=tests_pass, max_attempts=10)
ParameterDefaultDescription
goal(required)Natural-language string (judged by internal agent) or callable validator
max_attemptsinfMaximum attempts before stopping
timeoutinfWall-clock budget in seconds for the entire run
judgeNoneJudgeConfig with optional model and system_prompt for the NL judge
preserve_contextTrueKeep conversation history across retries
resume_prompt_template(built-in)Callable[[str | None], str | list[ContentBlock]] that builds the retry message
name"strands:goal-loop"Plugin name (must be unique per agent)

When both the attempt limit and timeout are left unbounded (the defaults), the plugin warns at construction time. Set at least one bound in production to prevent runaway loops.

After an invocation completes, retrieve the result from the plugin to get the full attempt history:

result = plugin.last_result(agent)
if result and not result.passed:
print(f"Stopped after {len(result.attempts)} attempts")
print(f"Reason: {result.stop_reason}")
for attempt in result.attempts:
print(f" #{attempt.attempt}: {attempt.feedback}")

The result is None undefined before the first completed run and while a run is in-flight. It resets at the start of each new invocation.

By default, the agent sees its own prior attempts and the validator’s feedback, letting it build on previous work. Disable context preservation to restore the agent’s full session state (messages, system prompt, model state) to the snapshot captured immediately before the first model call. Each retry starts fresh, seeing only the original input plus the latest feedback. Use this when prior attempts would confuse the model rather than help it.

plugin = GoalLoop(
goal=tests_pass,
max_attempts=10,
preserve_context=False,
)

The snapshot excludes agent state ( state appState ) deliberately — other plugins (rate limiters, cost trackers) rely on their mutations persisting across attempts.

When goal is a string, GoalLoop builds a judge agent from the host agent’s model. Override the model or system prompt to tune cost and behavior:

from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop, JudgeConfig
plugin = GoalLoop(
goal="Response must cite at least two sources.",
max_attempts=3,
judge=JudgeConfig(
model=BedrockModel(model_id="us.amazon.nova-lite-v1:0"),
),
)

Override how feedback is injected before each retry. The template receives the trimmed feedback string (or None undefined when the validator gave none) and returns the user message content:

def start_over_prompt(feedback):
if not feedback:
return "That didn't pass. Start over from scratch with a different approach."
return (
f"Validation failed:\n{feedback}\n\n"
"Do NOT edit your previous response. Start over from scratch "
"and take a completely different approach."
)
plugin = GoalLoop(
goal="...",
max_attempts=3,
resume_prompt_template=start_over_prompt,
)

The judge primitives are exported for use in function validators. Build your own judge with a custom model or prompt while reusing the same transcript format:

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)
  • One GoalLoop per agent. Attaching a second instance throws at initialization. Compose multiple constraints in a single validator function instead.
  • Timeout is checked between attempts, not mid-stream. An in-flight model call runs to completion before timeout fires, so actual wall-clock may exceed the budget by one attempt’s duration.
  • NL judge cost. Each failed attempt spawns a fresh judge agent invocation. For cost-sensitive workloads, use a cheaper model via judge.model or switch to a programmatic validator.