AWS Builder Center

Let an agent do ML research while you sleep: autoresearch on Amazon Bedrock AgentCore

Turn a GPU into an overnight ML researcher: an Amazon Bedrock AgentCore agent edits a training script, runs five-minute experiments on EC2, keeps only what improves, and remembers what worked across nights.

Let an agent do ML research while you sleep: autoresearch on Amazon Bedrock AgentCore

Move an overnight ML experiment loop off your workstation and onto a managed agent runtime, a GPU with no SSH, and a memory that lasts longer than one night.

TL;DR

The open-source  autoresearch  project has an AI agent edit a small GPT training script, train for five minutes, keep the change if validation loss improves, and repeat until morning. This post rebuilds that loop on AWS:
  • Amazon Bedrock AgentCore Runtime hosts a LangGraph agent that runs for up to eight hours per session.
  • Claude on Amazon Bedrock proposes each experiment as a small, exact edit to  train.py .
  • An EC2 GPU instance runs the training, driven only through SSM Run Command: no SSH, no inbound ports.
  • AgentCore Memory turns every experiment into lasting insights, so Tuesday's run builds on Monday's.
  • DynamoDB, S3 and CloudWatch hold results, logs and metrics. EventBridge Scheduler and an AWS Budget keep the GPU from running up a bill.
The walkthrough below covers the key code for each piece.

What autoresearch is

The autoresearch repo is a compact, single-GPU GPT training setup, plus instructions that let a coding agent run research unattended. Three files matter.  prepare.py  holds the data, tokenizer and evaluation, and is never edited.  train.py  holds the model, the optimizer (Muon + AdamW) and the training loop; it's the only file the agent edits.  program.md  holds the agent's instructions; it's the file the human edits.
Training always stops after five minutes. The score is  val_bpb  (validation bits per byte; lower is better), which doesn't depend on vocabulary size, so architecture and optimizer changes compare fairly. The agent edits, commits, trains, keeps the commit if  val_bpb  improved or resets it if not, and goes again: about 12 experiments an hour, roughly 100 a night.
Upstream, the agent is a coding assistant on your own machine. That ties you to one workstation, and the agent's memory ends when its context window does.

What changes on AWS


autoresearch, local
This build
Agent
Coding-agent CLI on a workstation
LangGraph agent on AgentCore Runtime, Claude via Bedrock
GPU
Whatever is under your desk
An EC2 GPU instance, reached only through SSM
Instructions
 program.md 
Built-in default, overridable by uploading  program.md  to S3
Start and stop
Manual
CLI call or nightly schedule, plus a STOP flag
Results
 results.tsv 
DynamoDB,  results.tsv  in S3, CloudWatch metrics
Lessons learned
Gone with the session
AgentCore Memory, carried across nights
Cost controls
None
Auto-stop, a morning safety stop, an AWS Budget

Architecture

Plain text
EventBridge Scheduler (nightly, optional) EventBridge Scheduler (07:30 safety stop)
│ │
▼ ▼
Operator CLI ──► Kickoff Lambda ──► AgentCore Runtime (LangGraph agent) ──SSM Run Command──► EC2 GPU box
│ │ │ (uv, git,
│ │ └──► Amazon Bedrock (Claude) train.py)
│ └──► AgentCore Memory (insights) │
└──► DynamoDB (results) · S3 (train.py, logs, results.tsv) ◄┘
Four design choices shape the code:
  1. The model proposes; code decides. Upstream, the coding agent runs git, reads the log and judges the result itself. Here the model only proposes the next edit. Committing, training, parsing  val_bpb  and keep-or-discard are ordinary code, so every decision is reproducible and the model can't talk itself into a worse number.
  1. Edits, not rewrites. The model returns search-and-replace blocks, and each  <old>  block must match  train.py  exactly once. Edits are cheap in output tokens and easy to review. When one doesn't apply, the error goes back to the model.
  1. A GPU with no front door. The instance has no inbound rules. Every action is a shell script sent through SSM and run as the unprivileged  ubuntu  user. SSM truncates output at 24,000 characters, so  train.py  and  run.log  move through S3.
  1. Everything is resumable. Results and the branch tip go to DynamoDB after every experiment, so a session restarted with the same run tag picks up where the last one stopped.

Walkthrough

1. The rules, as plain functions

The keep-or-discard rules are pure, unit-tested functions. A run counts as a crash unless it prints a numeric  val_bpb . The first successful run becomes the baseline, and after that only a strictly lower  val_bpb  is kept. An edit is rejected unless its  <old>  text appears exactly once.
 agent/autoresearch/core.py (excerpt) 
Python
def parse_run_output(text: str) -> RunResult:
"""Parse the output of the remote experiment script.

The script prints `===EXIT=== <code>`, then the summary metrics grepped from
run.log, then (only when val_bpb is missing) the tail of run.log after
`===TAIL===`. A run without a numeric val_bpb counts as a crash.
"""
exit_match = re.search(r"===EXIT===\s+(-?\d+)", text)
exit_code = int(exit_match.group(1)) if exit_match else None
metrics = {name: float(value) for name, value in METRIC_RE.findall(text)}
tail = text.split("===TAIL===", 1)[1].strip() if "===TAIL===" in text else ""
if "val_bpb" not in metrics:
return RunResult(crashed=True, exit_code=exit_code, log_tail=tail[-4000:])
return RunResult(
crashed=False,
val_bpb=metrics["val_bpb"],
peak_vram_mb=metrics.get("peak_vram_mb", 0.0),
exit_code=exit_code,
)

def decide(result: RunResult, best_bpb: float | None) -> str:
"""Return 'keep', 'discard' or 'crash'.

The first successful run becomes the baseline. After that, a change is kept
only if it strictly lowers val_bpb.
"""
if result.crashed:
return "crash"
if best_bpb is None:
return "keep"
return "keep" if result.val_bpb < best_bpb else "discard"

def parse_proposal(text: str) -> dict:
"""Parse the model's response into {'description', 'hypothesis', 'edits'}."""
descriptions = _tag("description", text)
if not descriptions or not descriptions[0].strip():
raise ProposalError("missing <description>")
hypotheses = _tag("hypothesis", text)
edits = []
for i, block in enumerate(_tag("edit", text)):
old, new = _tag("old", block), _tag("new", block)
if len(old) != 1 or len(new) != 1:
raise ProposalError(f"edit {i}: needs exactly one <old> and one <new>")
edits.append({"old": old[0], "new": new[0]})
if not edits:
raise ProposalError("no <edit> blocks found")
return {
"description": _one_line(descriptions[0], 100),
"hypothesis": _one_line(hypotheses[0], 600) if hypotheses else "",
"edits": edits,
}

def apply_edits(source: str, edits: list[dict]) -> str:
"""Apply exact search/replace edits. Each `old` must match exactly once."""
if not edits:
raise EditError("no edits to apply")
updated = source
for i, edit in enumerate(edits):
old, new = edit.get("old"), edit.get("new")
if not isinstance(old, str) or not old.strip() or not isinstance(new, str):
raise EditError(f"edit {i}: 'old' must be non-empty text and 'new' must be text")
count = updated.count(old)
if count == 0:
raise EditError(f"edit {i}: the <old> text was not found in train.py")
if count > 1:
raise EditError(f"edit {i}: the <old> text matches {count} places; include more context")
updated = updated.replace(old, new, 1)
if updated == source:
raise EditError("the edits did not change train.py")
return updated

2. The loop

The loop is a three-node LangGraph state machine: propose → execute → evaluate, with a conditional edge back to  propose . It ends on the time budget, the experiment budget, ten failures in a row, or a STOP flag in S3. The first experiment is always the unmodified baseline. On keep, the branch advances; on discard or crash, the box resets to the last kept commit. If the model can't produce an edit that applies in three tries, the experiment is logged as  invalid  and never runs.
LangGraph stops a graph after 25 steps by default, and a night takes hundreds, so the runner invokes the graph with a much higher  recursion_limit .
 agent/autoresearch/graph.py (excerpt) 
Python
def build_graph(deps: Deps):
def propose(state: ResearchState) -> dict:
if not state["history"]:
log.info("experiment 1: baseline")
baseline = {"description": "baseline", "hypothesis": "unmodified train.py", "edits": []}
return {"proposal": baseline, "candidate": state["head"]}

source = deps.box.read_train_py(state["run_tag"])
insights = deps.memory.recall(RECALL_QUERY)
feedback = ""
for attempt in range(1, MAX_PROPOSAL_ATTEMPTS + 1):
prompt = build_user_prompt(
gpu=deps.gpu,
best_bpb=state.get("best_bpb"),
history=state["history"],
insights=insights,
last_crash=state.get("last_crash", ""),
train_py=source,
feedback=feedback,
)
text = deps.researcher.propose(deps.program, prompt)
try:
proposal = parse_proposal(text)
updated = apply_edits(source, proposal["edits"])
except (ProposalError, EditError) as err:
log.warning("proposal attempt %d rejected: %s", attempt, err)
feedback = (
f"Your previous response was rejected: {err}. Follow the response format "
"exactly and copy each <old> block verbatim from the current train.py."
)
continue
seq = state["seq"] + 1
commit = deps.box.commit_train_py(state["run_tag"], seq, updated, proposal["description"])
log.info("experiment %d: %s (%s)", seq, proposal["description"], commit)
return {"proposal": proposal, "candidate": commit}
return {"proposal": None, "candidate": None}

def execute(state: ResearchState) -> dict:
if not state.get("proposal"):
return {"result": None}
output = deps.box.run_experiment(state["run_tag"], state["seq"] + 1, state["candidate"])
return {"result": parse_run_output(output).to_dict()}

def evaluate(state: ResearchState) -> dict:
seq = state["seq"] + 1
prev_best = state.get("best_bpb")
head, best = state["head"], prev_best
proposal = state.get("proposal")
if proposal is None: # the model never produced an applicable edit
result = RunResult(crashed=True)
status, commit = "invalid", head
proposal = {"description": "(no valid edit produced)", "hypothesis": ""}
else:
result = RunResult(**state["result"])
status, commit = decide(result, prev_best), state["candidate"]
if status == "keep":
head, best = commit, result.val_bpb
elif commit != head:
deps.box.reset_to(head) # discard: rewind the branch

row = {
"seq": seq,
"commit": commit,
"status": status,
"val_bpb": result.val_bpb,
"memory_gb": result.memory_gb,
"description": proposal["description"],
"hypothesis": proposal.get("hypothesis", ""),
"ts": int(time.time()),
}
history = [*state["history"], row]
_persist(deps, state["run_tag"], row, history, head, best, prev_best, result)
log.info("experiment %d -> %s val_bpb=%.6f best=%s", seq, status, result.val_bpb, best)
return {
"seq": seq,
"head": head,
"best_bpb": best,
"history": history,
"last_crash": result.log_tail if status == "crash" else "",
"proposal": None,
"candidate": None,
"result": None,
}

def route(state: ResearchState) -> str:
reason = stop_reason(deps, state)
if reason:
log.info("stopping: %s", reason)
return END
deps.store.refresh_lock(deps.lock_owner)
return "propose"

graph = StateGraph(ResearchState)
graph.add_node("propose", propose)
graph.add_node("execute", execute)
graph.add_node("evaluate", evaluate)
graph.set_entry_point("propose")
graph.add_edge("propose", "execute")
graph.add_edge("execute", "evaluate")
graph.add_conditional_edges("evaluate", route, {"propose": "propose", END: END})
return graph.compile()

3. Driving the GPU through SSM

Every action on the box goes through one helper that base64-encodes the script, so quoting can't break it, and runs it as  ubuntu  inside the checkout. Training runs under  timeout . Its summary lines come back between markers that  parse_run_output  reads, and the full log goes to S3.
 agent/autoresearch/gpu.py (excerpt) 
Python
def _as_user(self, body: str) -> str:
script = (
"set -euo pipefail\n"
'export PATH="$HOME/.local/bin:$PATH"\n'
f"cd {shlex.quote(self.cfg.repo_dir)}\n" + body
)
encoded = base64.b64encode(script.encode()).decode()
return f'sudo -u {self.cfg.gpu_user} -H bash -lc "$(echo {encoded} | base64 -d)"'

def run(self, body: str, timeout_s: int = 300, as_root: bool = False) -> str:
"""Run a script on the box and return stdout. Raises on non-zero exit."""
resp = self.ssm.send_command(
InstanceIds=[self.cfg.instance_id],
DocumentName="AWS-RunShellScript",
Parameters={
"commands": [body if as_root else self._as_user(body)],
"executionTimeout": [str(timeout_s)],
},
TimeoutSeconds=300,
Comment="autoresearch",
)
command_id = resp["Command"]["CommandId"]
deadline = time.time() + timeout_s + 300
time.sleep(2)
while time.time() < deadline:
try:
inv = self.ssm.get_command_invocation(
CommandId=command_id, InstanceId=self.cfg.instance_id
)
except self.ssm.exceptions.InvocationDoesNotExist:
time.sleep(3)
continue
if inv["Status"] in _PENDING:
time.sleep(10)
continue
if inv["Status"] != "Success":
raise RemoteCommandError(
f"{inv['Status']} (exit {inv.get('ResponseCode')}): "
f"{inv.get('StandardErrorContent', '')[-2000:]}"
)
return inv.get("StandardOutputContent", "")
raise RemoteCommandError(f"command {command_id} did not finish in time")

def run_experiment(self, run_tag: str, seq: int, commit: str) -> str:
"""Run one fixed-budget training job; return the marker-delimited summary."""
timeout_s = self.cfg.experiment_timeout_s
log_key = f"runs/{run_tag}/logs/{seq:04d}-{commit}.log"
body = (
# Kill leftovers from an interrupted run. The [p] trick stops pkill
# from matching this script's own command line.
"pkill -f '[p]ython.*train\\.py' || true\n"
"rm -f run.log\n"
"set +e\n"
f"timeout --kill-after=30 {timeout_s} uv run train.py > run.log 2>&1\n"
"code=$?\n"
"set -e\n"
'echo "===EXIT=== $code"\n'
'echo "===METRICS==="\n'
"grep -E '^(val_bpb|peak_vram_mb|training_seconds|num_params_M):' run.log || true\n"
'echo "===TAIL==="\n'
"grep -q '^val_bpb:' run.log || tail -n 40 run.log\n"
+ self._cp("run.log", self._s3_uri(log_key)).rstrip("\n")
+ " || true\n"
)
return self.run(body, timeout_s=timeout_s + 300)

4. Memory that outlives the night

After each experiment, the agent writes a two-turn event to AgentCore Memory: what it tried and why, then what happened. The memory's semantic strategy turns those events into insight records under  /autoresearch/{actorId}/insights/ . Before each proposal, the agent retrieves the most relevant insights and adds them to the prompt. A session on a new branch next week still knows which learning-rate change crashed on this GPU.
 agent/autoresearch/store.py (excerpt) 
Python
class ExperimentMemory:
"""Long-term memory of what worked, backed by AgentCore Memory.

Each finished experiment is written as a short conversational event. The
memory's semantic strategy distils those events into insight records that
later sessions (on any night) retrieve before proposing a change.
"""

def __init__(self, cfg: Config):
self.client = boto3.client("bedrock-agentcore", region_name=cfg.region)
self.memory_id = cfg.memory_id
self.actor_id = cfg.actor_id
self.namespace = f"/autoresearch/{cfg.actor_id}/insights/"

def record(self, run_tag: str, experiment: str, outcome: str) -> None:
try:
self.client.create_event(
memoryId=self.memory_id,
actorId=self.actor_id,
sessionId=run_tag,
eventTimestamp=datetime.now(timezone.utc),
payload=[
{"conversational": {"role": "USER", "content": {"text": experiment}}},
{"conversational": {"role": "ASSISTANT", "content": {"text": outcome}}},
],
)
except ClientError as err:
log.warning("memory write failed: %s", err)

def recall(self, query: str, top_k: int = 8) -> list[str]:
try:
resp = self.client.retrieve_memory_records(
memoryId=self.memory_id,
namespace=self.namespace,
searchCriteria={"searchQuery": query, "topK": top_k},
)
except ClientError as err:
log.warning("memory read failed: %s", err)
return []
texts = []
for record in resp.get("memoryRecordSummaries", []):
text = (record.get("content") or {}).get("text", "").strip()
if text:
texts.append(text)
return texts

5. The prompt

The system prompt plays the part of  program.md . It restates the upstream rules and adds two things for the cloud: a strict response format, and an instruction to make  train.py  run on this GPU first when no baseline has succeeded. Upload your own  program.md  to the bucket to replace it without redeploying.
 agent/autoresearch/prompts.py (excerpt) 
Python
DEFAULT_PROGRAM = """\
You are an autonomous ML researcher. You improve a small single-GPU GPT
training script, train.py, one experiment at a time.

Objective: the lowest val_bpb (validation bits per byte; lower is better).
Training always stops after a fixed 5-minute budget, so you are optimising
what this GPU can learn in 5 minutes.

You may change anything in train.py: architecture, optimizer, learning rates
and schedules, batch size, model size, the training loop. You may not change
prepare.py or the evaluation, and you may not add dependencies.

Constraints and taste:
- The script must run without crashing and finish within the time budget.
- Some extra VRAM is fine for a real gain; don't exceed the GPU's memory.
- Prefer simplicity. A tiny gain that adds a lot of hacky code is not worth
it; the same result with less code is a win.
- Learn from the history and the insights. Don't repeat ideas that already
failed unless your variant is meaningfully different. Combine near-misses
and try bolder ideas when progress stalls.
- If there is no successful run yet, your only goal is to make train.py run
on this GPU (for example: lower DEPTH or batch size, shorten sequences, or
replace a kernel the GPU does not support with a PyTorch-native one).
- If the last experiment crashed because of a simple bug, you may fix it.

Respond with ONE experiment in exactly this format:
<description>one line, at most 80 characters</description>
<hypothesis>why this should lower val_bpb</hypothesis>
<edit>
<old>
text copied verbatim from train.py; it must match exactly one place
</old>
<new>
the replacement text
</new>
</edit>
You may use several <edit> blocks. Keep each <old> short but unique.
"""

6. A long-running agent on AgentCore Runtime

AgentCore Runtime ends a session that reports itself idle for 15 minutes and caps every session at 8 hours. The entrypoint returns right away and runs the loop in a background thread registered with  add_async_task . The SDK then reports  HealthyBusy  on  /ping  until  complete_async_task  is called. A DynamoDB lease stops two sessions from sharing the GPU, and the runner stops the instance when the session ends.
 agent/main.py (excerpt) 
Python
@app.entrypoint
def invoke(payload: dict) -> dict:
payload = payload or {}
action = payload.get("action", "start")
try:
params = RunParams.from_payload(payload)
except ValueError as err:
return {"error": str(err)}
cfg = Config.from_env()
store = ResultsStore(cfg)

if action == "status":
state, rows = store.load(params.run_tag)
return {"run_tag": params.run_tag, "state": state, "recent": rows[-10:]}
if action != "start":
return {"error": f"unknown action: {action}"}

owner = f"{params.run_tag}-{uuid.uuid4().hex[:8]}"
if not store.acquire_lock(owner):
return {"status": "busy", "message": "another research session is using the GPU"}

task_id = app.add_async_task("autoresearch_session", {"run_tag": params.run_tag})

def worker() -> None:
try:
run_session(cfg, params, owner, store)
except Exception:
log.exception("research session %s failed", params.run_tag)
finally:
app.complete_async_task(task_id)

threading.Thread(target=worker, name=f"autoresearch-{params.run_tag}", daemon=True).start()
return {
"status": "started",
"run_tag": params.run_tag,
"max_hours": params.max_hours,
"max_experiments": params.max_experiments,
}
 agent/autoresearch/runner.py (excerpt) 
Python
def run_session(cfg: Config, params: RunParams, lock_owner: str, store: ResultsStore) -> dict:
box = GpuBox(cfg)
try:
box.ensure_ready()
box.clear_stop(params.run_tag)
saved, rows = store.load(params.run_tag)
head = box.checkout(params.run_tag, reset_to=saved["head"] if saved else None)
done = saved["experiments"] if saved else 0
researcher = Researcher(cfg.model_id, cfg.region)
deps = Deps(
box=box,
store=store,
memory=ExperimentMemory(cfg),
researcher=researcher,
metrics=Metrics(cfg),
program=box.load_program(DEFAULT_PROGRAM),
gpu=cfg.gpu_description,
lock_owner=lock_owner,
)
state = {
"run_tag": params.run_tag,
"head": head,
"best_bpb": saved.get("best_bpb") if saved else None,
"seq": done,
"history": rows,
"last_crash": "",
"deadline": time.time() + params.max_hours * 3600 - SAFETY_MARGIN_S,
"max_experiments": done + params.max_experiments,
}
log.info("session %s: resuming after %d experiments at %s", params.run_tag, done, head)
final = build_graph(deps).invoke(state, config={"recursion_limit": 100_000})
log.info(
"session %s finished: %d experiments, best val_bpb=%s, tokens in/out=%d/%d",
params.run_tag,
final["seq"],
final.get("best_bpb"),
researcher.input_tokens,
researcher.output_tokens,
)
return final
finally:
store.release_lock(lock_owner)
if cfg.stop_instance_on_finish:
box.stop_instance()

7. Infrastructure

One AWS CDK stack deploys everything:
  • a VPC with public subnets and no NAT gateway
  • the GPU instance on the AWS Deep Learning Base GPU AMI, with a first-boot script that installs  uv , clones autoresearch and runs  prepare.py 
  • the S3 bucket and the DynamoDB table
  • the agent image, built for  linux/arm64 
  • a least-privilege execution role, scoped to one instance, one SSM document and one memory
  • a kickoff Lambda function, a morning safety-stop schedule, an optional nightly schedule and an optional AWS Budget
AgentCore Memory and Runtime are declared as CloudFormation resources:
 infra/autoresearch_stack.py (excerpt) 
Python
memory = CfnResource(
self,
"Memory",
type="AWS::BedrockAgentCore::Memory",
properties={
"Name": "autoresearch_memory",
"Description": "What the autoresearch agent learned across nights",
"EventExpiryDuration": 90,
"MemoryStrategies": [
{
"SemanticMemoryStrategy": {
"Name": "experiment_insights",
"Description": "Facts about which train.py changes helped, hurt or crashed",
"NamespaceTemplates": ["/autoresearch/{actorId}/insights/"],
}
}
],
},
)

# ...

runtime = CfnResource(
self,
"Runtime",
type="AWS::BedrockAgentCore::Runtime",
properties={
"AgentRuntimeName": AGENT_NAME,
"Description": "Autonomous train.py research loop (autoresearch)",
"AgentRuntimeArtifact": {"ContainerConfiguration": {"ContainerUri": image.image_uri}},
"NetworkConfiguration": {"NetworkMode": "PUBLIC"},
"ProtocolConfiguration": "HTTP",
"RoleArn": agent_role.role_arn,
"LifecycleConfiguration": {"IdleRuntimeSessionTimeout": 900, "MaxLifetime": 28800},
"EnvironmentVariables": {
"AUTORESEARCH_REGION": r,
"GPU_INSTANCE_ID": instance.instance_id,
"GPU_DESCRIPTION": gpu_description,
"ARTIFACT_BUCKET": bucket.bucket_name,
"RESULTS_TABLE": table.table_name,
"MEMORY_ID": memory.get_att("MemoryId").to_string(),
"MODEL_ID": model_id,
"REPO_DIR": REPO_DIR,
"BASE_REF": repo_ref,
"EXPERIMENT_TIMEOUT_S": "600",
"STOP_INSTANCE_ON_FINISH": "true",
},
},
)

Running a night of research

An operator starts a session with a run tag, such as  nov05 , and a time budget. A small Lambda function starts the GPU instance and invokes the runtime, which replies at once and keeps working in the background. The agent creates the branch  autoresearch/nov05  on the box, runs the baseline, and then loops until the budget runs out. Progress is visible throughout: each experiment lands in DynamoDB and as CloudWatch metrics, and  results.tsv  in S3 is rewritten after every run in the same format upstream uses.
Stopping is just as simple. Writing a  STOP  object to the bucket makes the agent finish the experiment in flight, record it, release the GPU lock and stop the instance. Starting again with the same tag resumes from the last kept commit. For recurring research, an EventBridge schedule starts a session every evening with a date-based tag. Each night gets its own branch, and all nights share one memory.

Lessons for building your own

  • Size the GPU before the first night. autoresearch is tuned for an H100, and its defaults may not run on a 24 GB A10G ( g5.xlarge ). This build turns that into the model's first task: if the baseline crashes, it must make  train.py  fit, and the first successful run becomes the baseline. For numbers you can compare with upstream, use a single-H100  p5.4xlarge  and check capacity in your Region first. The upstream README lists the right knobs for smaller GPUs.
  • Measure your cadence. Five minutes of training plus startup, evaluation and a model call usually comes to fewer than 12 experiments an hour.
  • Plan around the eight-hour session cap. One runtime session covers one night. Use a schedule for recurring runs rather than trying to stretch a session.
  • Check quotas early. Confirm your EC2 vCPU quota for the instance family and your Bedrock throughput for the model before you depend on either.

Cost considerations

Prices are on-demand list prices in  us-west-2  at the time of writing; check the pricing pages before relying on them.
Model inference. Each experiment is one model call. With Claude Sonnet 4.5 at $3 per million input tokens and $15 per million output tokens, a turn of 4K in and 2K out costs about $0.042. This code sends the full  train.py  and recent history every turn, so real input is higher. The runner logs token totals at the end of each session so you can measure yours.
GPU compute is the biggest cost:
Instance
GPU
On-demand per hour
Per 8-hour night
g6.xlarge
1x L4 (24 GB)
$0.805
$6.44
g5.xlarge
1x A10G (24 GB)
$1.006
$8.05
g5.2xlarge
1x A10G (24 GB)
$1.212
$9.70
Everything else. AgentCore Runtime bills per second for the vCPU and memory a session uses (about $0.0895 per vCPU-hour and $0.00945 per GB-hour at the time of writing), and this agent spends most of its time waiting on SSM. Memory, DynamoDB, S3, CloudWatch and Lambda stay small at about 100 experiments a night.
Levers that matter: stop the GPU (the agent stops it after each session, and a morning schedule stops it regardless); use Spot capacity, since a resumable session loses one experiment, not the night; use a smaller model for routine proposals; cache the stable parts of the prompt; and use Savings Plans if the GPU runs nightly.

Where this goes next

The autoresearch README hints at the next step: more agents. A supervisor hands out ideas, workers each own a GPU and a branch, a reviewer checks wins for flukes, and all of them share one AgentCore Memory. Four workers take you from about a dozen experiments an hour to about fifty. That's the next post.

Clean up

Because everything lives in one CloudFormation stack, deleting the stack removes the instance, runtime, memory, bucket and table. Set the table's removal policy to  RETAIN  first if you want to keep the experiment history.


Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article