A long-horizon agent tackles goals that require dozens or hundreds of steps — researching a topic, drafting and revising a document, or working through a multi-stage workflow. The challenge is not the individual steps; it's keeping the agent coherent across them without blowing up your context window or your DA balance.
Every long-horizon agent shares the same skeleton: a loop that calls the model, processes the output, updates state, and decides whether to continue or stop.
import os
from openai import OpenAI
client = OpenAI(
base_url="https://opendunes.com/api/v1",
api_key=os.environ["OPENDUNES_API_KEY"],
)
def run_long_horizon_agent(goal: str, max_steps: int = 50) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": goal},
]
step = 0
while step < max_steps:
response = client.chat.completions.create(
model="anthropic/claude-sonnet-5",
messages=messages,
max_tokens=2048,
)
assistant_turn = response.choices[0].message
messages.append({"role": "assistant", "content": assistant_turn.content})
if "TASK_COMPLETE:" in (assistant_turn.content or ""):
return extract_final_answer(assistant_turn.content)
next_instruction = build_next_step_instruction(messages, step)
messages.append({"role": "user", "content": next_instruction})
step += 1
messages = maybe_compress(messages)
return "Max steps reached. Last output: " + (messages[-1]["content"] or "")
As the conversation grows, token costs rise and model quality degrades near the context limit. The maybe_compress function keeps the window manageable:
KEEP_FIRST = 2
KEEP_LAST = 10
def maybe_compress(messages: list[dict], token_budget: int = 60_000) -> list[dict]:
estimated_tokens = sum(len(m["content"].split()) * 1.3 for m in messages)
if estimated_tokens < token_budget:
return messages
head = messages[:KEEP_FIRST]
tail = messages[-KEEP_LAST:]
middle = messages[KEEP_FIRST:-KEEP_LAST]
summary_response = client.chat.completions.create(
model="meta-llama/llama-4-maverick",
messages=[
{"role": "system", "content": "Summarize the following conversation turns into a compact progress note."},
{"role": "user", "content": "\n".join(f"{m['role']}: {m['content']}" for m in middle)},
],
max_tokens=512,
)
summary = summary_response.choices[0].message.content or ""
progress_note = {"role": "user", "content": f"[Progress so far]\n{summary}"}
return head + [progress_note] + tail
Using a cheaper model like meta-llama/llama-4-maverick (a few DA per million tokens) for compression keeps summarization costs low while preserving the expensive model's capacity for actual reasoning.
Long jobs may span multiple process lifetimes. Serialize the message history after each step:
import json, pathlib
STATE_FILE = pathlib.Path("/tmp/agent_state.json")
def save_state(messages: list[dict], step: int) -> None:
STATE_FILE.write_text(json.dumps({"messages": messages, "step": step}))
def load_state() -> tuple[list[dict], int] | None:
if not STATE_FILE.exists():
return None
data = json.loads(STATE_FILE.read_text())
return data["messages"], data["step"]
On startup, check for saved state and resume from the last step instead of restarting.
Long-horizon agents need an explicit internal format so the loop controller can parse outputs reliably:
SYSTEM_PROMPT = """
You are a persistent research agent. Work step by step.
At each turn:
1. State what you know so far (one paragraph).
2. State what you will do next (one sentence).
3. Do it.
When the goal is fully achieved, output exactly:
TASK_COMPLETE: <final answer here>
Never output TASK_COMPLETE until the goal is genuinely met.
"""
A 50-step loop on anthropic/claude-sonnet-5 with ~1,500 tokens per turn costs roughly 80–120 DA end-to-end. If you compress every 20 turns with meta-llama/llama-4-maverick, compression adds about 5 DA total. Budget accordingly before starting long jobs.
At 60 requests per minute (the default per key), a 50-step loop with no delays finishes in under a minute — well within the limit. For very rapid loops, add a small sleep between iterations or request a higher rate limit from your dashboard.
Always define at least two stop conditions:
- Success signal — a string the model emits when done (
TASK_COMPLETE: above).
- Max steps — a hard cap so runaway loops don't drain your balance.
Optionally, add a balance check before each iteration using the X-Balance-Available response header:
remaining = int(response.headers.get("x-balance-available", "0"))
if remaining < 1_000_000:
raise RuntimeError("Balance too low to continue. Top up at https://opendunes.com/dashboard/billing")