Files
2026-07-13 12:39:17 +08:00

1468 lines
51 KiB
Python

from __future__ import annotations
import io
import json
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import pytest
from openai.types.responses import ResponseCustomToolCall, ResponseFunctionToolCall
from openai.types.responses.response_output_message import ResponseOutputMessage
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
import agents.sandbox.capabilities.memory as memory_module
import agents.sandbox.memory.manager as memory_manager_module
import agents.sandbox.memory.phase_one as phase_one_module
from agents import (
Agent,
ReasoningItem,
RunConfig,
Runner,
ShellTool,
SQLiteSession,
TResponseInputItem,
)
from agents.exceptions import UserError
from agents.items import (
CompactionItem,
MessageOutputItem,
ToolApprovalItem,
TResponseOutputItem,
)
from agents.result import RunResultStreaming
from agents.run import _sandbox_memory_input
from agents.run_context import RunContextWrapper
from agents.sandbox import (
Manifest,
MemoryGenerateConfig,
MemoryLayoutConfig,
MemoryReadConfig,
SandboxAgent,
SandboxRunConfig,
)
from agents.sandbox.capabilities import Memory
from agents.sandbox.memory.manager import (
_rollout_file_name_for_rollout_id,
get_or_create_memory_generation_manager,
)
from agents.sandbox.memory.phase_one import render_phase_one_prompt
from agents.sandbox.memory.prompts import (
render_memory_consolidation_prompt,
render_rollout_extraction_prompt,
)
from agents.sandbox.memory.rollouts import (
RolloutTerminalMetadata,
build_rollout_payload,
build_rollout_payload_from_result,
dump_rollout_json,
)
from agents.sandbox.memory.storage import (
PhaseTwoInputSelection,
PhaseTwoSelectionItem,
SandboxMemoryStorage,
_updated_at_sort_key,
)
from agents.sandbox.runtime import _stream_memory_input_override
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
from tests.fake_model import FakeModel
from tests.test_responses import get_final_output_message, get_text_message
from tests.utils.hitl import make_shell_call
class _DeleteTrackingUnixLocalSandboxClient(UnixLocalSandboxClient):
def __init__(self) -> None:
super().__init__()
self.deleted_roots: list[Path] = []
async def delete(self, session: Any) -> Any:
self.deleted_roots.append(Path(session.state.manifest.root))
return await super().delete(session)
def _phase_one_message(
*,
slug: str = "task_memory",
summary: str = "# Task summary\n",
raw_memory: str = "raw memory entry\n",
) -> Any:
return get_final_output_message(
json.dumps(
{
"rollout_slug": slug,
"rollout_summary": summary,
"raw_memory": raw_memory,
}
)
)
def test_rollout_file_name_for_rollout_id_uses_file_safe_id_directly() -> None:
assert _rollout_file_name_for_rollout_id("chat-session.2026_04") == "chat-session.2026_04.jsonl"
def test_rollout_file_name_for_rollout_id_rejects_path_like_ids() -> None:
with pytest.raises(ValueError, match="file-safe ID"):
_rollout_file_name_for_rollout_id("../chat-session")
def test_rollout_file_name_for_rollout_id_rejects_empty_ids() -> None:
with pytest.raises(ValueError, match="file-safe ID"):
_rollout_file_name_for_rollout_id(" ")
def _patch_update_call(call_id: str, path: str, text: str) -> Any:
diff = "@@\n" + "".join(f"+{line}\n" for line in text.splitlines())
return ResponseCustomToolCall(
type="custom_tool_call",
name="apply_patch",
call_id=call_id,
input=json.dumps({"type": "update_file", "path": path, "diff": diff}),
)
def _memory_config(
*,
max_raw_memories_for_consolidation: int = 256,
extra_prompt: str | None = None,
layout: MemoryLayoutConfig | None = None,
read: MemoryReadConfig | None = None,
phase_one_model: FakeModel | None = None,
phase_two_model: FakeModel | None = None,
) -> Memory:
return Memory(
layout=layout or MemoryLayoutConfig(),
read=read,
generate=MemoryGenerateConfig(
max_raw_memories_for_consolidation=max_raw_memories_for_consolidation,
extra_prompt=extra_prompt,
phase_one_model=phase_one_model or FakeModel(initial_output=[_phase_one_message()]),
phase_two_model=phase_two_model
or FakeModel(
initial_output=[
_patch_update_call("memory-md", "memories/MEMORY.md", "memory entry"),
_patch_update_call(
"memory-summary", "memories/memory_summary.md", "summary entry"
),
]
),
),
)
def _run_config_for_session(session: Any) -> RunConfig:
return RunConfig(sandbox=SandboxRunConfig(session=session))
def _extract_user_text(fake_model: FakeModel) -> str:
assert fake_model.first_turn_args is not None
return _extract_user_text_from_turn_args(fake_model.first_turn_args)
def _extract_user_text_from_turn_args(turn_args: dict[str, Any]) -> str:
input_items = turn_args["input"]
assert isinstance(input_items, list)
first_item = cast(dict[str, Any], input_items[0])
content = first_item["content"]
if isinstance(content, str):
return content
first_content = cast(dict[str, Any], content[0])
return cast(str, first_content["text"])
def _empty_phase_two_selection() -> PhaseTwoInputSelection:
return PhaseTwoInputSelection(selected=[], retained_rollout_ids=set(), removed=[])
def _raw_memory_record(
*,
rollout_id: str,
updated_at: str,
rollout_summary_file: str,
raw_memory: str,
) -> str:
return (
f"rollout_id: {rollout_id}\n"
f"updated_at: {updated_at}\n"
f"rollout_path: sessions/{rollout_id}.jsonl\n"
f"rollout_summary_file: {rollout_summary_file}\n"
"terminal_state: completed\n\n"
f"{raw_memory.rstrip()}\n"
)
async def _cleanup_session(
client: UnixLocalSandboxClient,
session: Any,
*,
close: bool = True,
) -> None:
try:
if close:
await session.aclose()
finally:
await client.delete(session)
def test_build_rollout_payload_filters_developer_and_noisy_items() -> None:
agent = Agent(name="test")
assistant_message = cast(ResponseOutputMessage, get_text_message("assistant"))
reasoning_item = ReasoningItem(
agent=agent,
raw_item=ResponseReasoningItem(id="rs_1", summary=[], type="reasoning"),
)
compaction_item = CompactionItem(
agent=agent,
raw_item=cast(
TResponseInputItem,
{
"type": "compaction",
"summary": "compact",
"encrypted_content": "encrypted",
},
),
)
message_item = MessageOutputItem(
agent=agent,
raw_item=assistant_message,
)
payload = build_rollout_payload(
input=[
{"role": "developer", "content": "debug"},
{"role": "system", "content": "system"},
{"role": "user", "content": "hello"},
cast(TResponseInputItem, {"type": "reasoning", "summary": []}),
cast(
TResponseInputItem,
{
"type": "compaction",
"summary": "compact",
"encrypted_content": "encrypted",
},
),
],
new_items=[reasoning_item, compaction_item, message_item],
final_output="done",
interruptions=[],
terminal_metadata=RolloutTerminalMetadata(
terminal_state="completed",
has_final_output=True,
),
)
updated_at = cast(str, payload.pop("updated_at"))
assert datetime.fromisoformat(updated_at)
assert list(payload) == ["input", "generated_items", "terminal_metadata", "final_output"]
assert payload["input"] == [
{"role": "user", "content": "hello"},
]
assert payload["generated_items"] == [
assistant_message.model_dump(exclude_unset=True),
]
assert payload["final_output"] == "done"
def test_build_rollout_payload_serializes_model_interruptions_as_dicts() -> None:
agent = Agent(name="test")
raw = ResponseFunctionToolCall(
id="fc_1",
call_id="call_1",
name="get_weather",
arguments='{"city":"Paris"}',
type="function_call",
)
approval = ToolApprovalItem(agent=agent, raw_item=raw)
payload = build_rollout_payload(
input="hello",
new_items=[],
final_output=None,
interruptions=[approval],
terminal_metadata=RolloutTerminalMetadata(terminal_state="interrupted"),
)
interruption = payload["interruptions"][0]
assert isinstance(interruption, dict)
assert interruption == raw.model_dump(exclude_unset=True)
assert interruption["type"] == "function_call"
assert interruption["call_id"] == "call_1"
assert interruption["name"] == "get_weather"
assert interruption["arguments"] == '{"city":"Paris"}'
def test_render_phase_one_prompt_truncates_large_rollout_contents() -> None:
payload = {
"input": [{"role": "user", "content": f"start{'a' * 700_000}middle{'z' * 700_000}end"}],
"generated_items": [],
"terminal_metadata": {"terminal_state": "completed", "has_final_output": False},
}
prompt = render_phase_one_prompt(rollout_contents=dump_rollout_json(payload))
assert "start" in prompt
assert "end" in prompt
assert "middle" not in prompt
assert "tokens truncated" in prompt
assert "rollout content omitted" in prompt
assert "Do not assume the rendered rollout below is complete" in prompt
def test_sandbox_memory_input_preserves_empty_session_delta() -> None:
assert (
_sandbox_memory_input(
memory_input_items_for_persistence=[],
original_user_input=[{"content": "old turn", "role": "user"}],
original_input=[{"content": "old turn", "role": "user"}],
)
== []
)
def test_sandbox_memory_input_uses_saved_session_delta_after_persistence() -> None:
assert _sandbox_memory_input(
memory_input_items_for_persistence=[{"content": "current turn", "role": "user"}],
original_user_input=[{"content": "old turn", "role": "user"}],
original_input=[{"content": "old turn", "role": "user"}],
) == [{"content": "current turn", "role": "user"}]
def test_streaming_memory_payload_preserves_empty_input_override() -> None:
agent = Agent(name="test")
result = RunResultStreaming(
input=[{"content": "old turn", "role": "user"}],
new_items=[],
raw_responses=[],
final_output="done",
input_guardrail_results=[],
output_guardrail_results=[],
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
context_wrapper=RunContextWrapper(context=None),
current_agent=agent,
current_turn=0,
max_turns=1,
_current_agent_output_schema=None,
trace=None,
is_complete=True,
)
assert result._original_input_for_persistence is None
result._original_input_for_persistence = []
assert _stream_memory_input_override(result) == []
payload = build_rollout_payload_from_result(
result,
input_override=_stream_memory_input_override(result),
)
assert payload["input"] == []
@pytest.mark.parametrize(
("conversation_id", "previous_response_id", "auto_previous_response_id"),
[
("conversation-123", None, False),
(None, "resp_123", False),
(None, None, True),
],
)
def test_streaming_memory_payload_uses_result_input_for_server_managed_conversation(
conversation_id: str | None,
previous_response_id: str | None,
auto_previous_response_id: bool,
) -> None:
agent = Agent(name="test")
result = RunResultStreaming(
input=[{"content": "current turn", "role": "user"}],
new_items=[],
raw_responses=[],
final_output="done",
input_guardrail_results=[],
output_guardrail_results=[],
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
context_wrapper=RunContextWrapper(context=None),
current_agent=agent,
current_turn=0,
max_turns=1,
_current_agent_output_schema=None,
trace=None,
is_complete=True,
)
result._conversation_id = conversation_id
result._previous_response_id = previous_response_id
result._auto_previous_response_id = auto_previous_response_id
result._original_input_for_persistence = []
assert _stream_memory_input_override(result) is None
payload = build_rollout_payload_from_result(
result,
input_override=_stream_memory_input_override(result),
)
assert payload["input"] == [{"content": "current turn", "role": "user"}]
def test_render_memory_prompts_omit_extra_prompt_section_by_default() -> None:
rollout_prompt = render_rollout_extraction_prompt()
consolidation_prompt = render_memory_consolidation_prompt(
memory_root="memory",
selection=_empty_phase_two_selection(),
)
assert "{{ extra_prompt_section }}" not in rollout_prompt
assert "{{ extra_prompt_section }}" not in consolidation_prompt
assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" not in rollout_prompt
assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" not in consolidation_prompt
def test_render_memory_prompts_include_extra_prompt_section() -> None:
rollout_prompt = render_rollout_extraction_prompt(extra_prompt="Focus on user preferences.")
consolidation_prompt = render_memory_consolidation_prompt(
memory_root="memory",
selection=_empty_phase_two_selection(),
extra_prompt="Focus on user preferences.",
)
assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" in rollout_prompt
assert "Focus on user preferences." in rollout_prompt
assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" in consolidation_prompt
assert "Focus on user preferences." in consolidation_prompt
def test_render_memory_consolidation_prompt_lists_removed_rollouts() -> None:
selection = PhaseTwoInputSelection(
selected=[],
retained_rollout_ids=set(),
removed=[
PhaseTwoSelectionItem(
rollout_id="old-rollout",
updated_at="",
rollout_path="sessions/old-rollout.jsonl",
rollout_summary_file="memories/rollout_summaries/old.md",
terminal_state="completed",
)
],
)
prompt = render_memory_consolidation_prompt(memory_root="memory", selection=selection)
assert "- removed from the last successful Phase 2 run: 1" in prompt
assert "rollout_id=old-rollout" in prompt
assert "updated_at=unknown" in prompt
def test_updated_at_sort_key_places_unknown_timestamps_last() -> None:
assert _updated_at_sort_key("updated_at: 2025-03-01T00:00:00Z\n") > _updated_at_sort_key(
"updated_at: unknown\n"
)
assert _updated_at_sort_key("updated_at: unknown\n") == _updated_at_sort_key("updated_at:\n")
assert _updated_at_sort_key("updated_at: unknown\n") == _updated_at_sort_key("no metadata\n")
@pytest.mark.asyncio
async def test_phase_two_selection_tracks_added_retained_and_removed_rollouts() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
try:
storage = SandboxMemoryStorage(session=session, layout=MemoryLayoutConfig())
await storage.ensure_layout()
old_item = PhaseTwoSelectionItem(
rollout_id="old-rollout",
updated_at="2025-03-01T00:00:00Z",
rollout_path="sessions/old-rollout.jsonl",
rollout_summary_file="rollout_summaries/old-rollout.md",
terminal_state="completed",
)
await storage.write_text(
storage.raw_memories_dir / "old-rollout.md",
_raw_memory_record(
rollout_id=old_item.rollout_id,
updated_at=old_item.updated_at,
rollout_summary_file=old_item.rollout_summary_file,
raw_memory="old raw",
),
)
await storage.write_text(
storage.raw_memories_dir / "new-rollout.md",
_raw_memory_record(
rollout_id="new-rollout",
updated_at="2025-03-02T00:00:00Z",
rollout_summary_file="rollout_summaries/new-rollout.md",
raw_memory="new raw",
),
)
await storage.write_phase_two_selection(selected_items=[old_item])
selection = await storage.build_phase_two_input_selection(
max_raw_memories_for_consolidation=1
)
assert [item.rollout_id for item in selection.selected] == ["new-rollout"]
assert selection.retained_rollout_ids == set()
assert [item.rollout_id for item in selection.removed] == ["old-rollout"]
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_runner_memory_generation_sanitizes_and_truncates_phase_one_prompt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(phase_one_module, "_PHASE_ONE_ROLLOUT_TOKEN_LIMIT", 1000)
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_one_model = FakeModel(initial_output=[_phase_one_message()])
memory = _memory_config(phase_one_model=phase_one_model)
agent = SandboxAgent(
name="worker",
model=FakeModel(
initial_output=[
ResponseReasoningItem(id="rs_1", summary=[], type="reasoning"),
cast(
TResponseOutputItem,
{
"id": "compaction_1",
"type": "compaction",
"summary": "compacted-so-far",
"encrypted_content": "encrypted",
},
),
get_text_message("done"),
]
),
instructions="Worker.",
capabilities=[memory],
)
closed = False
try:
result = await Runner.run(
agent,
[
{"role": "developer", "content": "developer debug"},
{"role": "system", "content": "system note"},
{"role": "user", "content": f"start{'a' * 20_000}middle{'z' * 20_000}end"},
cast(TResponseInputItem, {"type": "reasoning", "summary": []}),
cast(
TResponseInputItem,
{
"type": "compaction",
"summary": "input-compact",
"encrypted_content": "encrypted",
},
),
],
run_config=_run_config_for_session(session),
)
assert result.final_output == "done"
assert phase_one_model.first_turn_args is None
await session.aclose()
closed = True
prompt = _extract_user_text(phase_one_model)
assert "developer debug" not in prompt
assert "system note" not in prompt
assert "reasoning" not in prompt
assert "encrypted_content" not in prompt
assert "input-compact" not in prompt
assert "compacted-so-far" not in prompt
assert "start" in prompt
assert "middle" not in prompt
assert "end" in prompt
assert "tokens truncated" in prompt
assert "rollout content omitted" in prompt
finally:
await _cleanup_session(client, session, close=not closed)
@pytest.mark.asyncio
async def test_sandbox_agent_without_memory_capability_skips_memory_generation() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[get_final_output_message("done")]),
instructions="Worker.",
)
try:
result = await Runner.run(
agent,
"hello",
run_config=_run_config_for_session(session),
)
root = Path(session.state.manifest.root)
assert result.final_output == "done"
assert not (root / "sessions").exists()
assert not (root / "memories").exists()
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_memory_capability_returns_none_without_memory_summary() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
capability = Memory(generate=None)
try:
async with session:
capability.bind(session)
assert await capability.instructions(session.state.manifest) is None
await session.mkdir("memories", parents=True)
await session.write(
Path("memories/memory_summary.md"),
io.BytesIO(b""),
)
assert await capability.instructions(session.state.manifest) is None
finally:
await client.delete(session)
@pytest.mark.parametrize(
("memories_dir", "match"),
[
("/memory", "memories_dir must be relative"),
("../memory", "memories_dir must not escape root"),
("", "memories_dir must be non-empty"),
(".", "memories_dir must be non-empty"),
],
)
def test_memory_capability_rejects_invalid_memories_dir(
memories_dir: str,
match: str,
) -> None:
with pytest.raises(ValueError, match=match):
Memory(layout=MemoryLayoutConfig(memories_dir=memories_dir), generate=None)
@pytest.mark.parametrize(
("sessions_dir", "match"),
[
("/sessions", "sessions_dir must be relative"),
("../sessions", "sessions_dir must not escape root"),
("", "sessions_dir must be non-empty"),
(".", "sessions_dir must be non-empty"),
],
)
def test_memory_capability_rejects_invalid_sessions_dir(
sessions_dir: str,
match: str,
) -> None:
with pytest.raises(ValueError, match=match):
Memory(layout=MemoryLayoutConfig(sessions_dir=sessions_dir), generate=None)
def test_memory_capability_requires_read_or_generate() -> None:
with pytest.raises(ValueError, match="Memory requires at least one of `read` or `generate`"):
Memory(read=None, generate=None)
@pytest.mark.asyncio
async def test_memory_capability_instructions_requires_bound_session() -> None:
capability = Memory(generate=None)
with pytest.raises(ValueError, match="Memory capability is not bound to a SandboxSession"):
await capability.instructions(Manifest())
def test_memory_generate_config_rejects_non_positive_recent_rollout_limit() -> None:
with pytest.raises(
ValueError,
match=("MemoryGenerateConfig.max_raw_memories_for_consolidation must be greater than 0"),
):
MemoryGenerateConfig(max_raw_memories_for_consolidation=0)
def test_memory_layout_config_defaults_match_codex_names() -> None:
config = MemoryLayoutConfig()
assert config.memories_dir == "memories"
assert config.sessions_dir == "sessions"
def test_memory_generate_config_accepts_renamed_limit_field() -> None:
config = MemoryGenerateConfig(max_raw_memories_for_consolidation=123)
assert config.max_raw_memories_for_consolidation == 123
def test_memory_generate_config_rejects_too_many_raw_memories() -> None:
with pytest.raises(
ValueError,
match=(
"MemoryGenerateConfig.max_raw_memories_for_consolidation "
"must be less than or equal to 4096"
),
):
MemoryGenerateConfig(max_raw_memories_for_consolidation=4097)
@pytest.mark.asyncio
async def test_memory_capability_injects_truncated_memory_summary(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
capability = Memory(generate=None)
try:
async with session:
monkeypatch.setattr(memory_module, "_MEMORY_SUMMARY_MAX_TOKENS", 1)
await session.mkdir("memories", parents=True)
await session.write(
Path("memories/memory_summary.md"),
io.BytesIO(b"abcdefg"),
)
capability.bind(session)
instructions = await capability.instructions(session.state.manifest)
assert instructions is not None
assert (
"memories/memory_summary.md (already provided below; do NOT open again)"
in instructions
)
assert "MEMORY_SUMMARY BEGINS" in instructions
assert "tokens truncated" in instructions
finally:
await client.delete(session)
@pytest.mark.asyncio
async def test_memory_capability_live_update_instructions() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
capability = Memory(generate=None)
try:
async with session:
await session.mkdir("memories", parents=True)
await session.write(
Path("memories/memory_summary.md"),
io.BytesIO(b"summary entry"),
)
capability.bind(session)
instructions = await capability.instructions(session.state.manifest)
assert instructions is not None
assert "Memory is writable." in instructions
assert "memories/MEMORY.md" in instructions
assert "same turn" in instructions
assert "Never update memories." not in instructions
finally:
await client.delete(session)
@pytest.mark.asyncio
async def test_sandbox_memory_writes_rollouts_and_memory_files() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_one_model = FakeModel(initial_output=[_phase_one_message()])
phase_two_model = FakeModel(
initial_output=[
_patch_update_call("memory-md", "memories/MEMORY.md", "memory entry"),
_patch_update_call("memory-summary", "memories/memory_summary.md", "summary entry"),
]
)
phase_two_model.set_next_output([get_final_output_message("consolidated")])
memory = _memory_config(
extra_prompt="Track durable user preferences.",
phase_one_model=phase_one_model,
phase_two_model=phase_two_model,
)
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[get_final_output_message("done")]),
instructions="Worker.",
capabilities=[memory],
)
closed = False
try:
result = await Runner.run(
agent,
"hello",
run_config=_run_config_for_session(session),
)
root = Path(session.state.manifest.root)
rollouts = sorted((root / "sessions").glob("*.jsonl"))
assert result.final_output == "done"
assert len(rollouts) == 1
assert phase_one_model.first_turn_args is None
await session.aclose()
closed = True
raw_memories = sorted((root / "memories" / "raw_memories").glob("*.md"))
rollout_summaries = sorted((root / "memories" / "rollout_summaries").glob("*.md"))
assert len(raw_memories) == 1
assert len(rollout_summaries) == 1
assert (root / "memories" / "MEMORY.md").read_text() == "memory entry\n"
assert (root / "memories" / "memory_summary.md").read_text() == "summary entry\n"
assert "rollout_id: " in (root / "memories" / "raw_memories.md").read_text()
assert "updated_at: " in (root / "memories" / "raw_memories.md").read_text()
assert "rollout_path: sessions/" in (root / "memories" / "raw_memories.md").read_text()
assert (
"rollout_summary_file: rollout_summaries/"
in (root / "memories" / "raw_memories.md").read_text()
)
assert "terminal_state: completed" in (root / "memories" / "raw_memories.md").read_text()
assert "session_id: " in rollout_summaries[0].read_text()
assert "updated_at: " in rollout_summaries[0].read_text()
assert "rollout_path: sessions/" in rollout_summaries[0].read_text()
assert "terminal_state: completed" in rollout_summaries[0].read_text()
assert '"terminal_state":"completed"' in _extract_user_text(phase_one_model)
assert phase_one_model.first_turn_args is not None
assert (
"DEVELOPER-SPECIFIC EXTRA GUIDANCE"
in phase_one_model.first_turn_args["system_instructions"]
)
assert (
"Track durable user preferences."
in phase_one_model.first_turn_args["system_instructions"]
)
assert phase_two_model.first_turn_args is not None
assert "DEVELOPER-SPECIFIC EXTRA GUIDANCE" in _extract_user_text(phase_two_model)
assert "Track durable user preferences." in _extract_user_text(phase_two_model)
finally:
await _cleanup_session(client, session, close=not closed)
@pytest.mark.asyncio
async def test_sandbox_memory_uses_custom_layout() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_two_model = FakeModel(
initial_output=[
_patch_update_call("memory-md", "agent_memory/MEMORY.md", "memory entry"),
_patch_update_call("memory-summary", "agent_memory/memory_summary.md", "summary entry"),
]
)
phase_two_model.set_next_output([get_final_output_message("consolidated")])
memory = Memory(
layout=MemoryLayoutConfig(memories_dir="agent_memory", sessions_dir="agent_sessions"),
read=None,
generate=MemoryGenerateConfig(
phase_one_model=FakeModel(initial_output=[_phase_one_message()]),
phase_two_model=phase_two_model,
),
)
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[get_final_output_message("done")]),
instructions="Worker.",
capabilities=[memory],
)
closed = False
try:
await Runner.run(
agent,
"hello",
run_config=_run_config_for_session(session),
)
root = Path(session.state.manifest.root)
assert len(list((root / "agent_sessions").glob("*.jsonl"))) == 1
await session.aclose()
closed = True
assert (root / "agent_memory" / "MEMORY.md").read_text() == "memory entry\n"
assert (root / "agent_memory" / "memory_summary.md").read_text() == "summary entry\n"
finally:
await _cleanup_session(client, session, close=not closed)
@pytest.mark.asyncio
async def test_sandbox_memory_supports_multiple_generating_layouts_in_one_session() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_two_model_a = FakeModel(
initial_output=[
_patch_update_call("a-memory", "agent_a_memory/MEMORY.md", "agent a entry"),
_patch_update_call(
"a-summary",
"agent_a_memory/memory_summary.md",
"agent a summary",
),
]
)
phase_two_model_a.set_next_output([get_final_output_message("agent a consolidated")])
phase_two_model_b = FakeModel(
initial_output=[
_patch_update_call("b-memory", "agent_b_memory/MEMORY.md", "agent b entry"),
_patch_update_call(
"b-summary",
"agent_b_memory/memory_summary.md",
"agent b summary",
),
]
)
phase_two_model_b.set_next_output([get_final_output_message("agent b consolidated")])
memory_a = _memory_config(
layout=MemoryLayoutConfig(memories_dir="agent_a_memory", sessions_dir="agent_a_sessions"),
phase_one_model=FakeModel(initial_output=[_phase_one_message(raw_memory="agent a raw\n")]),
phase_two_model=phase_two_model_a,
)
memory_b = _memory_config(
layout=MemoryLayoutConfig(memories_dir="agent_b_memory", sessions_dir="agent_b_sessions"),
phase_one_model=FakeModel(initial_output=[_phase_one_message(raw_memory="agent b raw\n")]),
phase_two_model=phase_two_model_b,
)
agent_a = SandboxAgent(
name="agent-a",
model=FakeModel(initial_output=[get_final_output_message("a done")]),
instructions="Agent A.",
capabilities=[memory_a],
)
agent_b = SandboxAgent(
name="agent-b",
model=FakeModel(initial_output=[get_final_output_message("b done")]),
instructions="Agent B.",
capabilities=[memory_b],
)
closed = False
try:
await Runner.run(agent_a, "first", run_config=_run_config_for_session(session))
await Runner.run(agent_b, "second", run_config=_run_config_for_session(session))
root = Path(session.state.manifest.root)
assert len(list((root / "agent_a_sessions").glob("*.jsonl"))) == 1
assert len(list((root / "agent_b_sessions").glob("*.jsonl"))) == 1
await session.aclose()
closed = True
assert (root / "agent_a_memory" / "MEMORY.md").read_text() == "agent a entry\n"
assert (root / "agent_b_memory" / "MEMORY.md").read_text() == "agent b entry\n"
finally:
await _cleanup_session(client, session, close=not closed)
@pytest.mark.asyncio
async def test_sandbox_memory_rejects_different_generate_configs_for_same_layout() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
memory = _memory_config()
different_memory = _memory_config(
phase_one_model=FakeModel(initial_output=[_phase_one_message(raw_memory="different\n")])
)
try:
get_or_create_memory_generation_manager(session=session, memory=memory)
with pytest.raises(UserError, match="different Memory generation config"):
get_or_create_memory_generation_manager(session=session, memory=different_memory)
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_rollout_payload_uses_validated_rollout_id() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
memory = _memory_config()
try:
manager = get_or_create_memory_generation_manager(session=session, memory=memory)
await manager.enqueue_rollout_payload(
{
"updated_at": "2026-04-15T00:00:00+00:00",
"rollout_id": "payload-id",
"input": [],
"generated_items": [],
"terminal_metadata": {"terminal_state": "completed", "has_final_output": False},
},
rollout_id="canonical-id",
)
root = Path(session.state.manifest.root)
rollout_path = root / "sessions" / "canonical-id.jsonl"
payload = json.loads(rollout_path.read_text())
assert payload["rollout_id"] == "canonical-id"
finally:
await client.delete(session)
@pytest.mark.asyncio
async def test_sandbox_memory_rejects_different_sessions_dirs_for_same_memories_dir() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
first_memory = _memory_config(
layout=MemoryLayoutConfig(memories_dir="shared_memory", sessions_dir="sessions_a")
)
second_memory = _memory_config(
layout=MemoryLayoutConfig(memories_dir="shared_memory", sessions_dir="sessions_b")
)
try:
get_or_create_memory_generation_manager(session=session, memory=first_memory)
with pytest.raises(UserError, match="already has a Memory generation capability"):
get_or_create_memory_generation_manager(session=session, memory=second_memory)
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_rejects_shared_sessions_dir_for_different_memories_dirs() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
first_memory = _memory_config(
layout=MemoryLayoutConfig(memories_dir="memory_a", sessions_dir="shared_sessions")
)
second_memory = _memory_config(
layout=MemoryLayoutConfig(memories_dir="memory_b", sessions_dir="shared_sessions")
)
try:
get_or_create_memory_generation_manager(session=session, memory=first_memory)
with pytest.raises(UserError, match="sessions_dir='shared_sessions'"):
get_or_create_memory_generation_manager(session=session, memory=second_memory)
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_groups_segments_by_sdk_session_until_close() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_one_model = FakeModel(initial_output=[_phase_one_message(raw_memory="joined raw\n")])
phase_two_model = FakeModel(
initial_output=[
_patch_update_call("memory-md", "memories/MEMORY.md", "joined entry"),
_patch_update_call("memory-summary", "memories/memory_summary.md", "joined summary"),
]
)
phase_two_model.set_next_output([get_final_output_message("joined")])
memory = _memory_config(
phase_one_model=phase_one_model,
phase_two_model=phase_two_model,
)
first_agent = SandboxAgent(
name="first-worker",
model=FakeModel(initial_output=[get_final_output_message("first done")]),
instructions="Worker.",
capabilities=[memory],
)
second_agent = SandboxAgent(
name="second-worker",
model=FakeModel(initial_output=[get_final_output_message("second done")]),
instructions="Worker.",
capabilities=[memory],
)
closed = False
try:
chat_session = SQLiteSession("chat-session")
run_config = _run_config_for_session(session)
first = await Runner.run(
first_agent,
"first",
session=chat_session,
run_config=run_config,
)
second = await Runner.run(
second_agent,
"second",
session=chat_session,
run_config=run_config,
)
root = Path(session.state.manifest.root)
rollouts = sorted((root / "sessions").glob("*.jsonl"))
assert first.final_output == "first done"
assert second.final_output == "second done"
assert len(rollouts) == 1
assert rollouts[0].name == "chat-session.jsonl"
assert len(rollouts[0].read_text().splitlines()) == 2
segments = [json.loads(line) for line in rollouts[0].read_text().splitlines()]
assert list(segments[0])[:4] == [
"updated_at",
"rollout_id",
"input",
"generated_items",
]
assert segments[0]["input"] == [{"content": "first", "role": "user"}]
assert segments[1]["input"] == [{"content": "second", "role": "user"}]
assert phase_one_model.first_turn_args is None
await session.aclose()
closed = True
prompt = _extract_user_text(phase_one_model)
assert "first" in prompt
assert "second" in prompt
assert '"segment_count":2' in prompt
raw_memory_files = list((root / "memories" / "raw_memories").glob("*.md"))
assert len(raw_memory_files) == 1
assert f"updated_at: {segments[-1]['updated_at']}\n" in raw_memory_files[0].read_text()
assert (root / "memories" / "MEMORY.md").read_text() == "joined entry\n"
finally:
await _cleanup_session(client, session, close=not closed)
@pytest.mark.asyncio
async def test_sandbox_memory_fallback_does_not_mutate_run_config() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
agent_model = FakeModel()
agent_model.add_multiple_turn_outputs(
[
[get_final_output_message("first done")],
[get_final_output_message("second done")],
]
)
agent = SandboxAgent(
name="worker",
model=agent_model,
instructions="Worker.",
capabilities=[_memory_config()],
)
try:
run_config = _run_config_for_session(session)
await Runner.run(
agent,
"first",
session=SQLiteSession("first-chat"),
run_config=run_config,
)
await Runner.run(
agent,
"second",
session=SQLiteSession("second-chat"),
run_config=run_config,
)
root = Path(session.state.manifest.root)
rollouts = sorted(path.name for path in (root / "sessions").glob("*.jsonl"))
assert rollouts == ["first-chat.jsonl", "second-chat.jsonl"]
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_uses_conversation_id_when_sdk_session_is_absent() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[get_final_output_message("done")]),
instructions="Worker.",
capabilities=[_memory_config()],
)
try:
result = await Runner.run(
agent,
"remember this conversation",
conversation_id="conversation-123",
run_config=_run_config_for_session(session),
)
root = Path(session.state.manifest.root)
rollouts = sorted((root / "sessions").glob("*.jsonl"))
assert result.final_output == "done"
assert len(rollouts) == 1
assert rollouts[0].name == "conversation-123.jsonl"
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_uses_group_id_when_sdk_session_is_absent() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
agent_model = FakeModel()
agent_model.add_multiple_turn_outputs(
[
[get_final_output_message("first done")],
[get_final_output_message("second done")],
]
)
agent = SandboxAgent(
name="worker",
model=agent_model,
instructions="Worker.",
capabilities=[_memory_config()],
)
try:
run_config = RunConfig(
sandbox=SandboxRunConfig(session=session),
group_id="trace-thread-123",
)
first = await Runner.run(agent, "first", run_config=run_config)
second = await Runner.run(agent, "second", run_config=run_config)
root = Path(session.state.manifest.root)
rollouts = sorted((root / "sessions").glob("*.jsonl"))
assert first.final_output == "first done"
assert second.final_output == "second done"
assert len(rollouts) == 1
assert rollouts[0].name == "trace-thread-123.jsonl"
assert len(rollouts[0].read_text().splitlines()) == 2
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_uses_per_run_conversation_when_no_conversation_id() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
agent_model = FakeModel()
agent_model.add_multiple_turn_outputs(
[
[get_final_output_message("first done")],
[get_final_output_message("second done")],
]
)
agent = SandboxAgent(
name="worker",
model=agent_model,
instructions="Worker.",
capabilities=[_memory_config()],
)
try:
run_config = _run_config_for_session(session)
first = await Runner.run(agent, "first", run_config=run_config)
second = await Runner.run(agent, "second", run_config=run_config)
root = Path(session.state.manifest.root)
rollouts = sorted(path.name for path in (root / "sessions").glob("*.jsonl"))
assert first.final_output == "first done"
assert second.final_output == "second done"
assert len(rollouts) == 2
assert all(name.startswith("run-") and name.endswith(".jsonl") for name in rollouts)
finally:
await _cleanup_session(client, session)
@pytest.mark.asyncio
async def test_sandbox_memory_caps_phase_two_selection_and_surfaces_removed_rollouts() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_one_model = FakeModel()
phase_one_model.add_multiple_turn_outputs(
[
[_phase_one_message(slug="first", raw_memory="first raw\n")],
[_phase_one_message(slug="second", raw_memory="second raw\n")],
]
)
phase_two_model = FakeModel(
initial_output=[
_patch_update_call("memory-md", "memories/MEMORY.md", "first entry"),
_patch_update_call("memory-summary", "memories/memory_summary.md", "first summary"),
]
)
phase_two_model.set_next_output([get_final_output_message("consolidated")])
memory = _memory_config(
max_raw_memories_for_consolidation=1,
phase_one_model=phase_one_model,
phase_two_model=phase_two_model,
)
agent_model = FakeModel()
agent_model.add_multiple_turn_outputs(
[
[get_final_output_message("first done")],
[get_final_output_message("second done")],
]
)
agent = SandboxAgent(
name="worker",
model=agent_model,
instructions="Worker.",
capabilities=[memory],
)
closed = False
try:
root = Path(session.state.manifest.root)
await Runner.run(
agent,
"first",
run_config=RunConfig(
sandbox=SandboxRunConfig(session=session),
group_id="first-chat",
),
)
await Runner.run(
agent,
"second",
run_config=RunConfig(
sandbox=SandboxRunConfig(session=session),
group_id="second-chat",
),
)
assert len(list((root / "sessions").glob("*.jsonl"))) == 2
await session.aclose()
closed = True
selection_payload = json.loads((root / "memories" / "phase_two_selection.json").read_text())
selected_rollout_ids = [
cast(str, item["rollout_id"]) for item in selection_payload["selected"]
]
assert len(selected_rollout_ids) == 1
merged_raw_memories = (root / "memories" / "raw_memories.md").read_text()
assert "second raw" in merged_raw_memories
assert "first raw" not in merged_raw_memories
assert phase_two_model.first_turn_args is not None
prompt = _extract_user_text_from_turn_args(phase_two_model.first_turn_args)
assert "newly added since the last successful Phase 2 run: 1" in prompt
assert f"rollout_id={selected_rollout_ids[0]}" in prompt
finally:
await _cleanup_session(client, session, close=not closed)
@pytest.mark.asyncio
async def test_sandbox_memory_runs_phase_one_and_phase_two_on_session_close() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_one_model = FakeModel(initial_output=[_phase_one_message()])
phase_two_model = FakeModel(
initial_output=[
_patch_update_call("memory-md", "memories/MEMORY.md", "shutdown entry"),
_patch_update_call("memory-summary", "memories/memory_summary.md", "shutdown summary"),
]
)
phase_two_model.set_next_output([get_final_output_message("shutdown")])
memory = _memory_config(
phase_one_model=phase_one_model,
phase_two_model=phase_two_model,
)
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[get_final_output_message("done")]),
instructions="Worker.",
capabilities=[memory],
)
root = Path(session.state.manifest.root)
try:
await Runner.run(agent, "hello", run_config=_run_config_for_session(session))
manager = get_or_create_memory_generation_manager(session=session, memory=memory)
await manager._queue.join()
assert (root / "memories" / "MEMORY.md").read_text() == ""
await session.aclose()
assert (root / "memories" / "MEMORY.md").read_text() == "shutdown entry\n"
assert (root / "memories" / "memory_summary.md").read_text() == "shutdown summary\n"
finally:
await client.delete(session)
@pytest.mark.asyncio
async def test_sandbox_memory_unregisters_manager_on_session_close() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
memory = _memory_config()
try:
manager = get_or_create_memory_generation_manager(session=session, memory=memory)
managers_by_layout = memory_manager_module._MEMORY_GENERATION_MANAGERS.get(session)
assert managers_by_layout is not None
assert manager in managers_by_layout.values()
await session.aclose()
assert memory_manager_module._MEMORY_GENERATION_MANAGERS.get(session) is None
finally:
await client.delete(session)
@pytest.mark.asyncio
async def test_sandbox_memory_enqueue_failure_still_cleans_up_owned_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _raise_write_rollout(*args: Any, **kwargs: Any) -> Path:
_ = args, kwargs
raise RuntimeError("write_rollout failed")
monkeypatch.setattr(memory_manager_module, "write_rollout", _raise_write_rollout)
client = _DeleteTrackingUnixLocalSandboxClient()
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[get_final_output_message("done")]),
instructions="Worker.",
capabilities=[_memory_config()],
)
result = await Runner.run(
agent,
"hello",
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
)
assert result.final_output == "done"
assert len(client.deleted_roots) == 1
assert not client.deleted_roots[0].exists()
@pytest.mark.asyncio
async def test_sandbox_memory_marks_interrupted_runs_in_phase_one_prompt() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
phase_one_model = FakeModel(initial_output=[_phase_one_message()])
phase_two_model = FakeModel(
initial_output=[
_patch_update_call("memory-md", "memories/MEMORY.md", "interrupted entry"),
_patch_update_call(
"memory-summary", "memories/memory_summary.md", "interrupted summary"
),
]
)
phase_two_model.set_next_output([get_final_output_message("done")])
memory = _memory_config(
phase_one_model=phase_one_model,
phase_two_model=phase_two_model,
)
agent = SandboxAgent(
name="worker",
model=FakeModel(initial_output=[make_shell_call("approval-call")]),
instructions="Worker.",
tools=[ShellTool(executor=lambda _request: "ok", needs_approval=True)],
capabilities=[memory],
)
closed = False
try:
result = await Runner.run(
agent,
"interrupt me",
run_config=_run_config_for_session(session),
)
assert result.interruptions
await session.aclose()
closed = True
assert '"terminal_state":"interrupted"' in _extract_user_text(phase_one_model)
finally:
await _cleanup_session(client, session, close=not closed)