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.
The Problem
Section titled “The Problem”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.
How It Works
Section titled “How It Works”flowchart LR A[invoke] --> B[Agent responds] B --> C{Validate} C -->|pass| D[Done] C -->|fail| E[Inject feedback] E --> B- The agent processes the prompt and produces a response.
- GoalLoop extracts the last assistant message and runs the validator.
- If the validator passes, the loop terminates with a “satisfied” result.
- If the validator fails and budget remains, GoalLoop injects feedback as a new user message and re-invokes the agent.
- If the attempt limit or timeout is exhausted, the loop terminates without retrying.
Getting Started
Section titled “Getting Started”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 Agentfrom 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=[...])import { Agent } from '@strands-agents/sdk'import { GoalLoop } from '@strands-agents/sdk/vended-plugins/goal'
const concise = new GoalLoop({ goal: 'At most 3 sentences, accessible to a 10-year-old, ' + 'no jargon.', maxAttempts: 3,})
const agent = new Agent({ plugins: [concise] })await agent.invoke('Explain how rainbows form.')console.log(concise.lastResult(agent))
// Typical output:// { passed: true, stopReason: 'satisfied', attempts: [...] }Programmatic Validators
Section titled “Programmatic Validators”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
passedand optionalfeedback - A
ValidationOutcomeinstance
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,)import { Message } from '@strands-agents/sdk'import { GoalLoop } from '@strands-agents/sdk/vended-plugins/goal'
function wordCountValidator(response: Message) { const text = response.content .flatMap((b) => (b.type === 'textBlock' ? [b.text] : [])) .join(' ') const words = text.trim().split(/\s+/).length if (words <= 50) return true return { passed: false, feedback: `Too long (${words} words). Cap at 50.` }}
const plugin = new GoalLoop({ goal: wordCountValidator, maxAttempts: 5, timeout: 30_000,})Async validators work too. Run a test suite, call an external API, or await any I/O inside the validator:
import asynciofrom 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)import { exec } from 'node:child_process'import { promisify } from 'node:util'import { GoalLoop } from '@strands-agents/sdk/vended-plugins/goal'
const plugin = new GoalLoop({ goal: async () => { try { await execAsync('npm test') return true } catch (err) { const e = err as { stdout?: string stderr?: string } const out = `${e.stdout ?? ''}${e.stderr ?? ''}`.slice(-4000) return { passed: false, feedback: `Tests failed.\n${out}`, } } }, maxAttempts: 10,})Configuration Reference
Section titled “Configuration Reference”| Parameter | Default | Description |
|---|---|---|
goal | (required) | Natural-language string (judged by internal agent) or callable validator |
max_attempts | inf | Maximum attempts before stopping |
timeout | inf | Wall-clock budget in seconds for the entire run |
judge | None | JudgeConfig with optional model and system_prompt for the NL judge |
preserve_context | True | Keep 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) |
| Parameter | Default | Description |
|---|---|---|
goal | (required) | Natural-language string (judged by internal agent) or Validator function |
maxAttempts | Infinity | Maximum attempts before stopping |
timeout | Infinity | Wall-clock budget in milliseconds for the entire run |
judge | undefined | JudgeConfig with optional model and systemPrompt for the NL judge |
preserveContext | true | Keep conversation history across retries |
resumePromptTemplate | (built-in) | (feedback: string | undefined) => string | 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.
Advanced Usage
Section titled “Advanced Usage”Inspecting Results
Section titled “Inspecting Results”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}")const result = plugin.lastResult(agent)if (result && !result.passed) { console.log( `Stopped after ${result.attempts.length} attempts` ) console.log(`Reason: ${result.stopReason}`) for (const attempt of result.attempts) { console.log(` #${attempt.attempt}: ${attempt.feedback}`) }}The result is None undefined
Stateless Retries
Section titled “Stateless Retries”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,)const plugin = new GoalLoop({ goal: testsPass, maxAttempts: 10, preserveContext: false,})The snapshot excludes agent state (state appState
Custom Judge Configuration
Section titled “Custom Judge Configuration”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 BedrockModelfrom 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"), ),)import { BedrockModel } from '@strands-agents/sdk'import { GoalLoop } from '@strands-agents/sdk/vended-plugins/goal'
const plugin = new GoalLoop({ goal: 'Response must cite at least two sources.', maxAttempts: 3, judge: { model: new BedrockModel({ modelId: 'us.amazon.nova-lite-v1:0', }), },})Custom Resume Prompt
Section titled “Custom Resume Prompt”Override how feedback is injected before each retry. The template receives the trimmed feedback string (or None undefined
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,)const plugin = new GoalLoop({ goal: '...', maxAttempts: 3, resumePromptTemplate: (feedback) => { if (!feedback) { return 'That didn\'t pass. Start over from scratch ' + 'with a different approach.' } return `Validation failed:\n${feedback}\n\n` + 'Do NOT edit your previous response. Start over ' + 'from scratch and take a completely different approach.' },})Building a Custom Judge
Section titled “Building a Custom Judge”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)import { Agent } from '@strands-agents/sdk'import { GoalLoop, buildJudgePrompt, JUDGE_SYSTEM_PROMPT, JUDGE_OUTCOME_SCHEMA,} from '@strands-agents/sdk/vended-plugins/goal'
const plugin = new GoalLoop({ goal: async (_response, agent) => { const judge = new Agent({ printer: false, systemPrompt: JUDGE_SYSTEM_PROMPT, }) const result = await judge.invoke( buildJudgePrompt('Be concise.', agent.messages), { structuredOutputSchema: JUDGE_OUTCOME_SCHEMA } ) return result.structuredOutput ?? { passed: false } }, maxAttempts: 3,})Limitations
Section titled “Limitations”- 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.modelor switch to a programmatic validator.