Iteration Loop
Opening Explanation
This stage exists to give the agent a controlled place to think, act, observe results, and decide whether to continue. It is the core work loop of Terminus 2. A single prompt is not enough for real terminal work, because the agent often needs several rounds: inspect the machine, ask the LLM (the language model), run commands, read outputs, and revise the plan. This loop owns that repeat-until-done behavior. It sits after run setup and before final result packaging. Its job is to keep progress moving across iterations while carrying just enough state from one turn to the next: completion confirmation, summary handoff when context gets too large, and the growing trajectory record of what happened.
Main Flow
run()(the top-level entry point for one task) hands control to_run_agent_loop()because this is where actual task progress happens, not setup._run_agent_loop()runs the agent in repeated episodes. Each episode is one full pass through the in-iteration stages: check whether the session is still usable, gather context, query the LLM, record what happened, and decide whether to stop.- The loop exists because terminal work is incremental. The agent rarely knows the full answer up front. It needs a place to react to new command output and keep adjusting.
- This stage also owns the stop conditions. It can end because:
- the task looks complete twice in a row, which is a guard against false finishes,
- the tmux session (a terminal you can drive remotely) dies,
- or the maximum number of episodes is reached.
- It carries cross-iteration memory in a few small registers instead of recomputing everything each time. That is important for two reasons:
- completion needs a two-step confirmation,
- and long runs may need a summary handoff when the LLM context gets too full.
_record_asciinema_marker()(records timestamped labels for terminal replay) supports observability, not decision-making. It helps later stages and humans understand where important moments happened in the run._dump_trajectory(), and_dump_trajectory_with_continuation_index()(write the run history to disk, with support for split or continued histories), exist so the loop leaves behind a durable record. That matters because the loop is the main source of truth about what the agent tried, saw, and concluded.- In short, this stage is the agent’s heartbeat. Without it, the system would have setup and teardown, but no place where real multi-step work happens.
State Flow
- writes:
reg-n-episodes— incremented at loop entry so the system knows which iteration it is on and can report that later in metadata - writes:
reg-pending-completion— set when the agent first believes the task is complete, so the next iteration can confirm before returning - writes:
reg-pending-handoff-prompt— set when the run needs a summary handoff because context is getting too large; this preserves continuity into the next iteration - writes:
reg-pending-subagent-refs— set alongside the handoff prompt so later stages can record references to summarization subagents in the trajectory - writes:
reg-trajectory-steps— appended throughout the loop as the canonical history of system notes, user-facing handoff material, assistant actions, and completion checks - writes:
reg-asciinema-markers— updated when markers are recorded so terminal replay can later show notable points in the run - writes:
reg-api-request-times— appended during LLM calls so the final run metadata can describe API timing - reads:
reg-pending-completion— checked to tell the difference between “first time we think we are done” and “confirmed done, now return” - reads:
reg-pending-handoff-prompt— consumed on the next iteration to inject the summary handoff into the working history - reads:
reg-pending-subagent-refs— consumed on the next iteration to log the summarization subagent outputs into the trajectory - reads:
reg-trajectory-steps— used when dumping the trajectory so the current run history is persisted - clears:
reg-pending-handoff-prompt— cleared after the handoff is consumed so the same summary is not replayed again - clears:
reg-pending-subagent-refs— cleared after those refs are recorded so they are not duplicated - triggers downstream:
stage-4.1 Loop Entry Gates— each new episode enters the per-iteration pipeline - triggers downstream: [stage-5](stage-5.html) — when the loop ends by confirmed completion, dead session, or episode limit, final result packaging takes over
Pipeline Hand-Off
Upstream, stage 3 gives this loop a clean per-run state and a live task context. This stage turns that starting state into the full run history, stop decision, and carry-over registers that the inner sub-stages use each iteration, then hands the finished trajectory and metadata-ready state to downstream finalization in stage 5.
Terminus2.runterminus_2.py:1633–1639 ↗
enter core agent iteration loop
Stage context: This region hands execution from
Terminus2.runinto stage 4's main loop. It runs whenrunreaches thetryblock that wraps the core work phase, and it delegates to_run_agent_loopwith the prepared prompt, active chat object, logging directory, and instruction text. As the first translated entry in this stage, it is the stage boundary handoff rather than an in-loop step.
What this code does
This region awaits self._run_agent_loop(...) to start the main agent iteration loop. It passes four named inputs: local initial_prompt, self._chat, self.logs_dir, and local instruction. The shown code does not use any immediate return value from _run_agent_loop; its visible effect is the transfer of control into that coroutine inside a surrounding try block.
Interface · params / IO
(self, instruction) -> ?
- params:
instruction:?— original user instruction fromTerminus2.run - reads:
self._chat,self.logs_dir - returns: The shown region does not return a value; it awaits
_run_agent_loop(...)and ignores any immediate result in these lines. - effects: enters
self._run_agent_loop; passes control into the stage-4 core loop within atryblock
Execution flow
- Enter the
tryblock inTerminus2.runand prepare to delegate to the core loop. - Await
self._run_agent_loopusing keyword arguments:initial_prompt=initial_prompt,chat=self._chat,logging_dir=self.logs_dir, andoriginal_instruction=instruction. - Do not capture or use any direct return value from
_run_agent_loopin the shown region.
Source
try:
await self._run_agent_loop(
initial_prompt=initial_prompt,
chat=self._chat,
logging_dir=self.logs_dir,
original_instruction=instruction,
)
Non-obvious design decisions
- The call uses explicit keyword arguments, not positional arguments. That makes the handoff contract visible at the call site:
_run_agent_loopreceives four separately named inputs. - The code passes both local
initial_promptand localinstructionas distinct arguments (initial_promptandoriginal_instruction). The region preserves them as separate channels instead of collapsing them into one value. - The delegation sits inside a
tryblock. This region shows that exceptions from_run_agent_loopare meant to flow to later handlers inrun, rather than being handled at the call expression itself.
Relations
- Callers: Terminus2.run
- Core callees: Terminus2._run_agent_loop
- Config / state sources: local
initial_promptfrom earlier setup inTerminus2.run;self._chat;self.logs_dir; method parameterinstruction - Results to: control transfers into
Terminus2._run_agent_loop; exceptions propagate to latertryhandlers inTerminus2.runthat are not shown here
Terminus2._run_agent_loopterminus_2.py:1361–1373 ↗
Agent message-content selection branch
Stage context: This region sits inside
Terminus2._run_agent_loop's core iteration logic and prepares the local text payload stored inmessage_content. It runs after earlier code has producedllm_response,analysis, andplan, and before later code in the same loop can use that local result. Relative to the already translatedTerminus2.run, this is one internal branch within the loop thatrunhands control to.
What this code does
This region chooses the local message_content value from two sources. If self._save_raw_content_in_trajectory is true, it copies llm_response.content verbatim. Otherwise, it builds labeled text from truthy analysis and plan, joining present parts with "\n", or falling back to "" when neither value is present.
Interface · params / IO
region locals: reads self._save_raw_content_in_trajectory, llm_response.content, analysis, plan; writes local message_content (message_parts only in the non-raw branch); no return in this snippet
- params:
llm_response.content:?— raw LLM text copied directly whenself._save_raw_content_in_trajectoryis enabled;analysis:?— optional local text included asAnalysis: ...in the structured branch when truthy;plan:?— optional local text included asPlan: ...in the structured branch when truthy - reads:
self._save_raw_content_in_trajectory - returns: none; this snippet's product is the local assignment to
message_content - effects: writes local
message_content; writes localmessage_partsonly whenself._save_raw_content_in_trajectoryis false
Execution flow
- Check
self._save_raw_content_in_trajectoryto select raw-versus-structured message construction. - In the raw branch, assign
message_content = llm_response.contentwith no parsing, relabeling, or fallback handling. - In the structured branch, create local
message_parts = []and appendf"Analysis: {analysis}"only ifanalysisis truthy. - Still in the structured branch, append
f"Plan: {plan}"only ifplanis truthy, then assignmessage_contentto"\n".join(message_parts)when any parts exist, else"".
Source
# Create message content from analysis and plan, or use raw response if raw_content is enabled
if self._save_raw_content_in_trajectory:
# Use the raw LLM response content for SFT data export
message_content = llm_response.content
else:
# Parse into structured format (analysis + plan)
message_parts = []
if analysis:
message_parts.append(f"Analysis: {analysis}")
if plan:
message_parts.append(f"Plan: {plan}")
message_content = "\n".join(message_parts) if message_parts else ""
Non-obvious design decisions
- The
self._save_raw_content_in_trajectoryswitch preservesllm_response.contentexactly in one mode. That avoids any loss or reshaping of the model output that would happen if the code always rebuilt text fromanalysisandplanlabels. - The structured branch includes
analysisandplanonly when each value is truthy. That prevents empty labeled lines such asAnalysis:orPlan:from appearing when one field is missing. - The structured branch falls back to
""whenmessage_partsstays empty. That makes the absence of bothanalysisandplanexplicit without inventing placeholder text.
Relations
- Callers:
Terminus2._run_agent_loopenclosing iteration body;Terminus2.runindirectly, by awaiting_run_agent_loop - Core callees:
message_parts.append;"\n".join - Config / state sources:
self._save_raw_content_in_trajectory - Results to: local
message_contentin the enclosing_run_agent_loopscope; localmessage_partsin the structured branch - Related siblings:
Terminus2.run: transfers control into_run_agent_loop
Terminus2._record_asciinema_markerterminus_2.py:1990–1991 ↗
No-op asciinema marker hook
Stage context: This helper sits in stage 4 as the marker-recording hook that
_run_agent_loopcan call when an iteration reaches a point worth labeling in the terminal recording. In the current code, it does not participate in the loop's state machine and produces no marker output. It relates to its siblings by preserving the call shape that stage-4.7 uses, while leaving the rest of_run_agent_loopunchanged.
What this code does
_record_asciinema_marker accepts marker_text: str and immediately returns None. It does not inspect marker_text, read any self state, or write any instance or external state. Its visible behavior is therefore a deliberate no-op at the marker insertion points used by _run_agent_loop.
Interface · params / IO
(self, marker_text: str) -> None
- params:
marker_text:str— Requested marker label from the caller - returns: Returns
None; it produces no side effect.
Execution flow
- Receive the
marker_textargument from the caller. - Return immediately without using
marker_textand without touching anyself._*state.
Source
def _record_asciinema_marker(self, marker_text: str) -> None:
return
Non-obvious design decisions
- The method keeps a dedicated
_record_asciinema_marker(...)hook even though the body is onlyreturn. This preserves a stable call site for_run_agent_loopinstead of forcing marker-related conditionals into the loop itself. - The implementation ignores
marker_texton purpose. That choice avoids partial or hidden marker behavior; any real recording logic would need to be added explicitly rather than inferred from the method name.
Relations
- Callers:
Terminus2._run_agent_loopat stage-4.7 marker points - Core callees: none; the body only executes
return - Config / state sources: none; it reads no
self._*attributes;marker_textparameter supplied by the caller - Results to: Returns
Noneto the immediate caller; Leavesreg-asciinema-markersunchanged; Leaves the surrounding_run_agent_loopcontrol flow to continue normally - Related siblings:
Terminus2._run_agent_loopuses this hook inside the main iteration loop;Terminus2.runreaches this helper only indirectly by entering_run_agent_loop
Terminus2._dump_trajectoryterminus_2.py:1985–1987 ↗
Trajectory dump wrapper using summarization count
Stage context: This entry is a thin wrapper around
_dump_trajectory_with_continuation_index. In the visible code, its role is only to forward one piece of instance state,self._summarization_count, into that helper and returnNone.
What this code does
_dump_trajectory takes only self and performs no work of its own beyond one delegated call. It reads self._summarization_count, passes that value to _dump_trajectory_with_continuation_index(...), and returns None. Any JSON creation, file output, or other persistence effects happen inside the callee, not in this wrapper.
Interface · params / IO
(self) -> None
- params:
self:?— Terminus2 instance providing_summarization_countand the dump helper - reads:
self._summarization_count - returns: Returns
None; its visible product is delegating the dump request to_dump_trajectory_with_continuation_index. - effects: Invokes
self._dump_trajectory_with_continuation_index(self._summarization_count); Has no direct state writes of its own; any external effects are delegated to the callee
Execution flow
- Read the current
self._summarization_countvalue from instance state. - Call
self._dump_trajectory_with_continuation_index(...)and passself._summarization_countas the single argument. - Return
Nonewithout any additional local processing.
Source
def _dump_trajectory(self) -> None:
"""Dump trajectory data to JSON file following ATIF format."""
self._dump_trajectory_with_continuation_index(self._summarization_count)
Non-obvious design decisions
- The wrapper hardcodes
self._summarization_countas the argument source instead of accepting a continuation index parameter. The visible effect is that callers of_dump_trajectorycannot supply a different index through this wrapper. - The function delegates all dump work to
_dump_trajectory_with_continuation_indexrather than implementing serialization logic inline. In this snippet,_dump_trajectoryacts only as a forwarding entry point.
Relations
- Callers: Unspecified callers that want the default continuation-indexed trajectory dump
- Core callees: Terminus2._dump_trajectory_with_continuation_index
- Config / state sources: self._summarization_count
- Results to: _dump_trajectory_with_continuation_index receives the forwarded continuation index; Any persisted trajectory output is produced by the callee, not by this wrapper
reg-summarization-count— forwards current count to dump helper
Terminus2._dump_trajectory_with_continuation_indexterminus_2.py:1917–1983 ↗
trajectory JSON writer with continuation metadata
Stage context: This helper is the concrete write path behind the stage's trajectory-dump checkpoints.
Terminus2._dump_trajectoryforwardsself._summarization_countinto it, while this function handles the actual JSON assembly, filename choice, and disk write. It sits beside the no-op marker recorder and provides the durable artifact for the loop's in-memory trajectory state.
What this code does
_dump_trajectory_with_continuation_index serializes the current trajectory into a JSON file, using the continuation_index argument plus runtime state from self. It reads token and cost totals from _context, builds Agent, FinalMetrics, and Trajectory models, chooses either trajectory.json or trajectory.cont-<n>.json, and writes the formatted JSON to disk. If _context is falsy, it logs a warning and returns without writing. It does not mutate agent state and returns None.
Interface · params / IO
(self, continuation_index: int) -> None
- params:
continuation_index:int— continuation number used in metadata and filename selection - reads:
self._context.n_input_tokens,self._context.n_output_tokens,self._context.n_cache_tokens,self._context.cost_usd,self._parser_name,self._temperature,self._llm_kwargs,self._linear_history,self._summarization_count,self._session_id,self._model_name,self._trajectory_steps,self.logs_dir,self.logger - returns: Returns
None; its real product is a trajectory JSON file written toself.logs_dir, or only log output if it skips or fails. - effects: logs a warning through
self.logger.warning(...)and returns early whenself._contextis falsy; opens the chosen trajectory path withopen(trajectory_path, "w"), which overwrites any existing file; writes formatted JSON withf.write(json_str)inside awith open(..., "w") as fblock; logs success throughself.logger.debug(...)after a successful write; logs failure throughself.logger.error(...)if any exception is raised in the write block
Execution flow
- It first checks
if not self._context:. On that branch, it emitsself.logger.warning("No context available, skipping trajectory dump")and returns immediately. - On the write path, it builds
FinalMetricsfrom_context.n_input_tokens,_context.n_output_tokens,_context.n_cache_tokens or 0, and_context.cost_usd if self._context.cost_usd else None. - It builds
agent_extrastarting with{"parser": self._parser_name}and then conditionally addstemperature,llm_kwargs, andcontinuation_indexonly whenself._temperature is not None,self._llm_kwargsis truthy, andself._linear_history and continuation_index > 0respectively. - It computes
continued_trajectory_refasNoneby default, then changes it tof"trajectory.cont-{continuation_index + 1}.json"only whenself._linear_historyis true andcontinuation_index < self._summarization_count. - It constructs a
Trajectorymodel fromself._session_id, anAgentbuilt fromself.name(),self.version() or "unknown",self._model_name, andagent_extra, plusself._trajectory_steps, theFinalMetrics, and the computedcontinued_trajectory_ref. - It chooses the output path:
self.logs_dir / f"trajectory.cont-{continuation_index}.json"whenself._linear_history and continuation_index > 0, otherwiseself.logs_dir / "trajectory.json". - Inside
try:, it enterswith open(trajectory_path, "w") as f:, callsformat_trajectory_json(trajectory.to_json_dict())to producejson_str, and then writes that string withf.write(json_str). - If the write succeeds, it logs
self.logger.debug(f"Trajectory dumped to {trajectory_path}"); if any exception is raised in thetryblock, it catches it and logsself.logger.error(f"Failed to dump trajectory: {e}").
Source
def _dump_trajectory_with_continuation_index(self, continuation_index: int) -> None:
"""Dump trajectory data to JSON file with specified continuation index.
Args:
continuation_index: The continuation index to use for filename and metadata.
For the initial trajectory, use 0.
For the first continuation, use 1, etc.
"""
if not self._context:
self.logger.warning("No context available, skipping trajectory dump")
return
# Construct the trajectory using Pydantic models for validation
final_metrics = FinalMetrics(
total_prompt_tokens=self._context.n_input_tokens,
total_completion_tokens=self._context.n_output_tokens,
total_cached_tokens=self._context.n_cache_tokens or 0,
total_cost_usd=self._context.cost_usd if self._context.cost_usd else None,
)
agent_extra: dict[str, Any] = {
"parser": self._parser_name,
}
if self._temperature is not None:
agent_extra["temperature"] = self._temperature
if self._llm_kwargs:
agent_extra["llm_kwargs"] = self._llm_kwargs
if self._linear_history and continuation_index > 0:
agent_extra["continuation_index"] = continuation_index
# Determine if this trajectory will be continued
# In linear_history mode, when saving during summarization (i.e., continuation_index < _summarization_count),
# this trajectory will have a continuation
continued_trajectory_ref = None
if self._linear_history and continuation_index < self._summarization_count:
# This trajectory segment will be continued in the next file
next_continuation_index = continuation_index + 1
continued_trajectory_ref = f"trajectory.cont-{next_continuation_index}.json"
trajectory = Trajectory(
session_id=self._session_id,
agent=Agent(
name=self.name(),
version=self.version() or "unknown",
model_name=self._model_name,
extra=agent_extra,
),
steps=self._trajectory_steps,
final_metrics=final_metrics,
continued_trajectory_ref=continued_trajectory_ref,
)
# Determine trajectory filename based on continuation index
if self._linear_history and continuation_index > 0:
trajectory_path = (
self.logs_dir / f"trajectory.cont-{continuation_index}.json"
)
else:
trajectory_path = self.logs_dir / "trajectory.json"
try:
with open(trajectory_path, "w") as f:
json_str = format_trajectory_json(trajectory.to_json_dict())
f.write(json_str)
self.logger.debug(f"Trajectory dumped to {trajectory_path}")
except Exception as e:
self.logger.error(f"Failed to dump trajectory: {e}")
Non-obvious design decisions
- The function gates all serialization work on
if not self._context:. That branch makes the missing-context case visible through a warning log and prevents any attempt to read_contextfields. - It uses conditional inserts into
agent_extrarather than populating every possible key. As written,temperature,llm_kwargs, andcontinuation_indexare absent unless their branch conditions pass. - It uses two separate continuation-related conditions: one controls whether
agent_extra["continuation_index"]appears (_linear_history and continuation_index > 0), and another controls whethercontinued_trajectory_refpoints at the next file (_linear_history and continuation_index < self._summarization_count). - The file write sits inside a broad
except Exception as e:block that only logs the error. In this function, write failures do not propagate to the caller.
Relations
- Callers:
Terminus2._dump_trajectory - Core callees:
FinalMetrics(...);Agent(...);Trajectory(...);self.name();self.version();format_trajectory_json(...);trajectory.to_json_dict();open(...);f.write(...) - Config / state sources:
self._parser_name;self._temperature;self._llm_kwargs;self._linear_history;self._model_name;self.logs_dir - Results to: writes
self.logs_dir / "trajectory.json"; writesself.logs_dir / f"trajectory.cont-{continuation_index}.json"when_linear_historyandcontinuation_index > 0; emits debug/warning/error messages throughself.logger - Related siblings:
Terminus2._dump_trajectoryis the thin wrapper that passesself._summarization_countinto this function
reg-summarization-count— compares againstcontinuation_indexfor continuation refreg-trajectory-steps— serializes current step list intoTrajectory