Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions research/arc-agi-3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The agent container only mounts the game workspace and, by default, has no netwo
prolong-swarm --suite all -m gpt-5.5 --max-actions 500
prolong-swarm --suite all --backend claude-code -m claude-opus-4-6
prolong-swarm --game ls20,ft09 -m gpt-5.5
prolong-swarm --suite all --no-log
```

Results are written to `evaluation_results/`.
Expand All @@ -64,16 +65,36 @@ Results are written to `evaluation_results/`.
| `--effort` | `high` | Effort level (claude-code backend) |
| `--reasoning-effort` | `none` | Reasoning effort (codex backend) |
| `--operation-mode` | `online` | `online` / `offline` / `normal` |
| `--no-log` | off | Interactive MCP baseline with no game log in the agent workspace |
| `--in-prompt` | off | Existing current-board-in-prompt baseline |
| `--log-window N` | full log | Expose only the latest N action sections |

### Memory conditions

The agent's access to game history is controlled by `--log-window`. These are the ablation conditions from the paper:
The harness has four explicit memory conditions:

| Condition | Flags | History available |
|-----------|-------|-------------------|
| prolong | (default) | Full game log |
| lw25 | `--log-window 25` | Last 25 action sections of the log |
| no-log (in-prompt) | `--log-window -1` | No log file; the current board is added to the prompt |
| full-log (PRO-LONG) | (default) | Full durable game log |
| windowed-log | `--log-window 25` | Last 25 action sections of the durable log |
| in-prompt | `--in-prompt` | Current board serialized into each prompt; no agent-visible log |
| mcp-no-log | `--no-log` | Live state and actions available only through authenticated MCP tools |

`--log-window -1` remains a deprecated alias for `--in-prompt`. The three
condition-selecting flags are mutually exclusive.

In the MCP no-log baseline, each game gets a short-lived, bearer-authenticated
[Streamable HTTP MCP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
endpoint with two tools: `current_board` and
`submit_actions`. Tool results enter the coding CLI's native resumed context,
but the runner does not expose an explicit durable game log, serialize an
observation into a prompt, or mount the private trace into the CLI workspace.
Normal coding tools and persistent helper files remain available. A private
host-side `logs.txt`, agent transcript, and usage record are retained for
evaluation and debugging. If a CLI process exits before the live game ends,
the runner resumes the same CLI session with a state-free prompt. In this
condition, `--retries` is the maximum number of consecutive resumed calls that
may execute zero actions before the run is marked `AGENT_STALLED`.

## Scorecards & logs

Expand All @@ -88,12 +109,15 @@ prolong_agent/
│ ├── codex_agent.py # Codex CLI backend
│ ├── claude_code_agent.py # Claude Code backend
│ ├── swarm.py # CLI entry point
│ ├── memory.py # explicit memory-condition resolution
│ ├── action_queue.py # action execution
│ ├── game_state.py # board/log formatting
│ └── prompts.py # prompts (~30 lines)
├── environment/
│ ├── arcagi3.py # ARC-AGI-3 API wrapper
│ ├── runner.py # per-game loop
│ ├── game_session.py # shared state, metrics, trace, and action execution
│ ├── mcp_game.py # authenticated per-game MCP tools
│ ├── runner.py # queued and interactive per-game loops
│ └── config.py
├── metrics/
└── utils/
Expand Down
3 changes: 3 additions & 0 deletions research/arc-agi-3/prolong_agent/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
from prolong_agent.agent.claude_code_agent import ClaudeCodeAgent
from prolong_agent.agent.action_queue import ActionQueue, QueueExhausted
from prolong_agent.agent.game_state import GameState
from prolong_agent.agent.memory import MemoryMode, resolve_memory_mode

__all__ = [
"CodexAgent",
"ClaudeCodeAgent",
"ActionQueue",
"QueueExhausted",
"GameState",
"MemoryMode",
"resolve_memory_mode",
]
46 changes: 41 additions & 5 deletions research/arc-agi-3/prolong_agent/agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@
HEX_COLOR_MAP,
INPROMPT_INITIAL_PROMPT,
INPROMPT_RESUME_PROMPT,
MCP_NO_LOG_INITIAL_PROMPT,
MCP_NO_LOG_RESUME_PROMPT,
MCP_NO_LOG_SYSTEM_PROMPT,
SYSTEM_PROMPT,
SYSTEM_PROMPT_INPROMPT,
format_actions_block,
)
from prolong_agent.agent.memory import MemoryMode

log = logging.getLogger(__name__)

Expand All @@ -27,9 +31,21 @@
class BaseAgent:
_ACTION6_RE = re.compile(r'^ACTION6\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)$')

def __init__(self, grid_mode="hex", log_window=None, action_cap=20, workspace="/workspace"):
def __init__(
self,
grid_mode="hex",
log_window=None,
action_cap=20,
workspace="/workspace",
memory_mode: MemoryMode | str | None = None,
):
self._grid_mode = grid_mode
self._log_window = log_window
self._memory_mode = MemoryMode(memory_mode) if memory_mode else (
MemoryMode.IN_PROMPT if log_window == -1
else MemoryMode.WINDOWED_LOG if log_window is not None
else MemoryMode.FULL_LOG
)
self._action_cap = max(1, int(action_cap))
self._workspace = workspace

Expand All @@ -39,7 +55,12 @@ def _workspace_text(self, text):
return text

def _build_system_prompt(self, available_actions=None):
template = SYSTEM_PROMPT_INPROMPT if self._log_window == -1 else SYSTEM_PROMPT
if self._memory_mode == MemoryMode.MCP_NO_LOG:
return self._workspace_text(
MCP_NO_LOG_SYSTEM_PROMPT.format(action_cap=self._action_cap)
+ (HEX_COLOR_MAP if self._grid_mode == "hex" else ASCII_COLOR_MAP)
)
template = SYSTEM_PROMPT_INPROMPT if self._memory_mode == MemoryMode.IN_PROMPT else SYSTEM_PROMPT
available_actions = available_actions or DEFAULT_ACTIONS

if self._log_window is None:
Expand Down Expand Up @@ -68,7 +89,11 @@ def _build_system_prompt(self, available_actions=None):
return self._workspace_text(prompt)

def _build_prompt(self, log_name, is_first, **values):
if self._log_window == -1:
if self._memory_mode == MemoryMode.MCP_NO_LOG:
return self._workspace_text(
MCP_NO_LOG_INITIAL_PROMPT if is_first else MCP_NO_LOG_RESUME_PROMPT
)
if self._memory_mode == MemoryMode.IN_PROMPT:
board = values.get("board_text", "") or "(board unavailable)"
if is_first:
prompt = INPROMPT_INITIAL_PROMPT.format(board=board)
Expand Down Expand Up @@ -102,7 +127,7 @@ def _build_prompt(self, log_name, is_first, **values):
return f"{description}\n\n{body}"

def _sync_history(self, log_path, sandbox):
if self._log_window == -1:
if self._memory_mode in (MemoryMode.IN_PROMPT, MemoryMode.MCP_NO_LOG):
return
destination = sandbox / log_path.name
if self._log_window is not None:
Expand All @@ -114,7 +139,7 @@ def _sync_history(self, log_path, sandbox):
shutil.copyfileobj(source, output)

def _add_current_board(self, log_path, values):
if self._log_window != -1 or values.get("board_text"):
if self._memory_mode != MemoryMode.IN_PROMPT or values.get("board_text"):
return
board_file = log_path.parent / "current_board.txt"
if not board_file.exists():
Expand All @@ -141,6 +166,17 @@ def _clear_files(sandbox, *names):
except OSError:
pass

@staticmethod
def _purge_interactive_game_artifacts(sandbox):
"""Remove state-bearing artifacts before every MCP-backed CLI call."""
for filename in ("logs.txt", "current_board.txt"):
for path in sandbox.rglob(filename):
try:
path.unlink()
except OSError:
pass
BaseAgent._clear_files(sandbox, "actions.json", "last_message.txt")

@staticmethod
def _split_response(text):
if "\n[PLAN]\n" not in text:
Expand Down
92 changes: 82 additions & 10 deletions research/arc-agi-3/prolong_agent/agent/claude_code_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from prolong_agent.agent.base import BaseAgent
from prolong_agent.agent.claude_events import ClaudeEventParser
from prolong_agent.agent.memory import MemoryMode
from prolong_agent.utils import sandbox_net

log = logging.getLogger(__name__)
Expand All @@ -40,7 +41,7 @@ def __init__(self, run_label: str = "", use_api_key: bool = False) -> None:
self._containers: dict[str, dict] = {}
self._lock = threading.Lock()

def get(self, key: str, workspace_dir: str) -> str:
def get(self, key: str, workspace_dir: str, *, allow_host: bool = False) -> str:
with self._lock:
if key in self._containers:
info = self._containers[key]
Expand All @@ -54,9 +55,9 @@ def get(self, key: str, workspace_dir: str) -> str:
subprocess.run(["docker", "rm", "-f", info["name"]],
capture_output=True, timeout=10)
del self._containers[key]
return self._create(key, workspace_dir)
return self._create(key, workspace_dir, allow_host=allow_host)

def _create(self, key: str, workspace_dir: str) -> str:
def _create(self, key: str, workspace_dir: str, *, allow_host: bool = False) -> str:
name = f"cc_{uuid.uuid4().hex[:12]}"

env_flags: list[str] = []
Expand Down Expand Up @@ -101,12 +102,16 @@ def _create(self, key: str, workspace_dir: str) -> str:
for _v in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy"):
net_flags += ["-e", f"{_v}={_proxy}"]
net_flags += ["-e", "NO_PROXY=localhost,127.0.0.1",
"-e", "no_proxy=localhost,127.0.0.1"]
bypass = "localhost,127.0.0.1"
if allow_host:
bypass += ",host.docker.internal"
net_flags += ["-e", f"NO_PROXY={bypass}",
"-e", f"no_proxy={bypass}"]

cmd = [
"docker", "run", "-d",
"--name", name,
*(["--add-host", "host.docker.internal:host-gateway"] if allow_host else []),
"--entrypoint", "sleep",
"--user", "1000:1000",
"--cap-drop=ALL",
Expand Down Expand Up @@ -162,8 +167,9 @@ def __init__(
log_window: Optional[int] = None,
effort: str = "high",
action_cap: int = 20,
memory_mode: MemoryMode | str | None = None,
) -> None:
super().__init__(grid_mode, log_window, action_cap)
super().__init__(grid_mode, log_window, action_cap, memory_mode=memory_mode)
self._model = model
self._timeout = timeout or 2400
self._effort = effort
Expand All @@ -190,6 +196,20 @@ def _session_args(self, path_key: str) -> tuple[list[str], str, bool]:
re.IGNORECASE | re.DOTALL,
)

@staticmethod
def _mcp_config(url: str) -> dict[str, Any]:
return {
"mcpServers": {
"prolong_game": {
"type": "http",
"url": url,
"headers": {
"Authorization": "Bearer ${PROLONG_MCP_TOKEN}",
},
},
},
}

@staticmethod
def _parse_quota_reset(text):
m = ClaudeCodeAgent._QUOTA_RE.search(text)
Expand Down Expand Up @@ -221,13 +241,19 @@ def analyze(self, log_path: Path, action_num: int, retry_nudge: str = "",
if not log_path.exists():
return None

mcp_url = kwargs.pop("_mcp_url", None)
mcp_token = kwargs.pop("_mcp_token", None)
interactive = bool(mcp_url and mcp_token)

path_key = str(log_path)
is_first = path_key not in self._call_count
self._call_count[path_key] = self._call_count.get(path_key, 0) + 1

sandbox = log_path.parent / "cc_sandbox"
sandbox.mkdir(parents=True, exist_ok=True)
container = self._pool.get(path_key, str(sandbox.resolve()))
container = self._pool.get(
path_key, str(sandbox.resolve()), allow_host=interactive
)

self._sync_history(log_path, sandbox)
self._add_current_board(log_path, kwargs)
Expand All @@ -237,14 +263,21 @@ def analyze(self, log_path: Path, action_num: int, retry_nudge: str = "",
claude_md.write_text(self._build_system_prompt(available_actions))

self._clear_files(sandbox, "actions.json")
if interactive:
self._purge_interactive_game_artifacts(sandbox)
(sandbox / ".mcp.json").write_text(
json.dumps(self._mcp_config(mcp_url), indent=2)
)

prompt = self._build_prompt(log_path.name, is_first,
action_num=action_num, **kwargs)
if retry_nudge:
prompt += f"\n\n{retry_nudge}"

cmd = [
"docker", "exec", "-i",
cmd = ["docker", "exec", "-i"]
if interactive:
cmd.extend(["-e", "PROLONG_MCP_TOKEN"])
cmd += [
"-w", "/workspace",
container,
"claude", "-p", "-",
Expand All @@ -254,8 +287,21 @@ def analyze(self, log_path: Path, action_num: int, retry_nudge: str = "",
"--max-turns", "50",
"--output-format", "stream-json",
"--verbose",
"--disallowedTools", "Agent,Task,TodoWrite,ToolSearch,WebSearch,WebFetch,mcp__*,NotebookEdit,AskUserQuestion,Skill,ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,ExitPlanMode,EnterWorktree,ExitWorktree",
"--disallowedTools", (
"Agent,Task,TodoWrite,ToolSearch,WebSearch,WebFetch,NotebookEdit,"
"AskUserQuestion,Skill,ScheduleWakeup,CronCreate,CronDelete,"
"CronList,EnterPlanMode,ExitPlanMode,EnterWorktree,ExitWorktree"
if interactive else
"Agent,Task,TodoWrite,ToolSearch,WebSearch,WebFetch,mcp__*,NotebookEdit,"
"AskUserQuestion,Skill,ScheduleWakeup,CronCreate,CronDelete,"
"CronList,EnterPlanMode,ExitPlanMode,EnterWorktree,ExitWorktree"
),
]
if interactive:
cmd.extend([
"--strict-mcp-config",
"--mcp-config", "/workspace/.mcp.json",
])

session_args, session_id, resuming = self._session_args(path_key)
cmd.extend(session_args)
Expand All @@ -274,8 +320,12 @@ def analyze(self, log_path: Path, action_num: int, retry_nudge: str = "",

call_started = time.monotonic()
try:
process_env = os.environ.copy()
if interactive:
process_env["PROLONG_MCP_TOKEN"] = mcp_token
proc = subprocess.Popen(
cmd,
env=process_env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
Expand Down Expand Up @@ -414,6 +464,8 @@ def heartbeat() -> None:
)
time.sleep(_quota_wait)
log.info("quota sleep done -- retrying analyze() for action=%d", action_num)
if interactive:
kwargs.update(_mcp_url=mcp_url, _mcp_token=mcp_token)
return self.analyze(log_path, action_num, retry_nudge=retry_nudge, **kwargs)

actions = self._read_actions_json(container, action_num, log_path)
Expand Down Expand Up @@ -454,6 +506,26 @@ def heartbeat() -> None:
except Exception:
pass

def interact(
self,
run_dir: Path,
action_num: int,
*,
mcp_url: str,
mcp_token: str,
retry_nudge: str = "",
) -> Optional[dict[str, Any]]:
"""Run or resume Claude Code against the live MCP game."""
session_key = run_dir / ".claude-mcp-session"
session_key.touch(exist_ok=True)
return self.analyze(
session_key,
action_num,
retry_nudge=retry_nudge,
_mcp_url=mcp_url,
_mcp_token=mcp_token,
)

def _read_actions_json(self, container: str, action_num: int,
log_path: Path) -> list[dict]:
try:
Expand Down
Loading