mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-06 07:51:11 +00:00
feat(sandbox): add explicit memory history checkpoints for daily notes
Add commit_memory_history() so the host controls when daily-memory changes are checkpointed into the runtime workspace's local Git repo. Session ingestion no longer commits implicitly; the host groups one or many sessions into an explicit boundary with a chosen message. - Initialize a Git repo in the runtime workspace on case setup - Validate the configured daily_dir and stage only that path - Allow empty commits so boundaries are always recorded - Reset removes the old Git repo and reinitializes for the next case - Export retains .git for offline history inspection
This commit is contained in:
parent
7f3ac6a762
commit
3322fe469b
4 changed files with 180 additions and 1 deletions
|
|
@ -71,6 +71,7 @@ async def main():
|
|||
session_id=case.case_id,
|
||||
messages=[{"role": "user", "content": "Remember this."}],
|
||||
)
|
||||
await case.commit_memory_history(f"session: {case.case_id}")
|
||||
answer = await case.answer(query="What should you remember?")
|
||||
await case.judge(
|
||||
query="What should you remember?",
|
||||
|
|
@ -136,6 +137,16 @@ under the configured `session_dir`/`dialog_dir` are included. Use
|
|||
case tree. Environment variable names are recorded, but their secret values
|
||||
are never written to the manifest.
|
||||
|
||||
The runtime workspace is also a local Git repository. Session ingestion never
|
||||
commits implicitly: the host chooses checkpoint boundaries by calling
|
||||
`commit_memory_history(message)`. It may checkpoint after every session or
|
||||
after any batch of sessions, and controls the commit message. Each checkpoint
|
||||
commits only the configured `daily_dir`; empty commits are retained as explicit
|
||||
boundaries. The exported workspace includes `.git`, so the daily-memory
|
||||
construction history can be inspected offline without a remote or push. A
|
||||
`reset_case()` removes this repository together with the old runtime workspace
|
||||
and initializes a fresh repository for the next case.
|
||||
|
||||
For an ephemeral Docker workspace, always call `export()` or `export_full()`
|
||||
before `close()`.
|
||||
|
||||
|
|
@ -150,11 +161,13 @@ uploading the next case's sessions:
|
|||
case = await factory.create_case("session-001")
|
||||
try:
|
||||
await case.ingest_session(session_id="session-001", messages=[...])
|
||||
await case.commit_memory_history("session: session-001")
|
||||
first = await case.answer(query="...")
|
||||
await case.export("artifacts/session-001.tar.gz")
|
||||
|
||||
await case.reset_case("session-002")
|
||||
await case.ingest_session(session_id="session-002", messages=[...])
|
||||
await case.commit_memory_history("session: session-002")
|
||||
second = await case.answer(query="...")
|
||||
finally:
|
||||
await case.close()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ SOURCE_ARCHIVE = "/workspace/candidate.tar.gz"
|
|||
EXPORT_ARCHIVE = "/tmp/reme-case-export.tar.gz"
|
||||
RUNTIME_LAYOUT = f"{CASE_ROOT}/runtime-layout.json"
|
||||
_CASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
||||
_GIT_AUTHOR_NAME = "ReMe Sandbox"
|
||||
_GIT_AUTHOR_EMAIL = "reme-sandbox@localhost"
|
||||
|
||||
|
||||
class Backend(Protocol):
|
||||
|
|
@ -238,6 +240,11 @@ class DockerReMeSandbox:
|
|||
HARNESS_ROOT,
|
||||
],
|
||||
)
|
||||
await self._exec_checked(
|
||||
"initialize_memory_history",
|
||||
["git", "init", "--quiet"],
|
||||
cwd=self.runtime_workspace,
|
||||
)
|
||||
|
||||
async def _install_source_candidate(self) -> None:
|
||||
assert self.backend is not None
|
||||
|
|
@ -358,6 +365,70 @@ class DockerReMeSandbox:
|
|||
return index_result
|
||||
return result
|
||||
|
||||
async def commit_memory_history(self, message: str) -> None:
|
||||
"""Create a host-requested checkpoint containing only daily-memory changes."""
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
raise ValueError("memory history commit message must be a non-empty string")
|
||||
async with self._operation_lock:
|
||||
await self._commit_memory_history(message)
|
||||
|
||||
async def _daily_history_path(self) -> str:
|
||||
"""Return the validated workspace-relative daily directory from the runtime layout."""
|
||||
assert self.backend is not None
|
||||
try:
|
||||
payload = json.loads((await self.backend.read_file(RUNTIME_LAYOUT)).decode("utf-8"))
|
||||
except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SandboxCommandError(f"cannot resolve daily history path from runtime layout: {exc}") from exc
|
||||
|
||||
expected_root = PurePosixPath(self.runtime_workspace).relative_to(CASE_ROOT)
|
||||
configured_paths = payload.get("configured_paths") if isinstance(payload, dict) else None
|
||||
if not isinstance(configured_paths, dict):
|
||||
raise SandboxCommandError("sandbox runtime layout has invalid configured_paths")
|
||||
case_relative = self._validated_workspace_path(
|
||||
configured_paths.get("daily_dir"),
|
||||
expected_root,
|
||||
allow_root=False,
|
||||
)
|
||||
return PurePosixPath(case_relative).relative_to(expected_root).as_posix()
|
||||
|
||||
async def _commit_memory_history(self, message: str) -> None:
|
||||
"""Commit one host-selected boundary while tracking only the configured daily directory."""
|
||||
daily_path = await self._daily_history_path()
|
||||
await self._exec_checked(
|
||||
"stage_memory_history",
|
||||
["git", "add", "-A", "--", daily_path],
|
||||
cwd=self.runtime_workspace,
|
||||
)
|
||||
diff_result = await self._exec(
|
||||
"check_memory_history",
|
||||
["git", "diff", "--cached", "--quiet", "--", daily_path],
|
||||
cwd=self.runtime_workspace,
|
||||
)
|
||||
if diff_result.exit_code not in (0, 1):
|
||||
raise SandboxCommandError(
|
||||
f"cannot inspect staged daily-memory changes: {_decode(diff_result.stderr)}",
|
||||
)
|
||||
commit_command = [
|
||||
"git",
|
||||
"-c",
|
||||
f"user.name={_GIT_AUTHOR_NAME}",
|
||||
"-c",
|
||||
f"user.email={_GIT_AUTHOR_EMAIL}",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"--allow-empty",
|
||||
"--only",
|
||||
"-m",
|
||||
message,
|
||||
]
|
||||
if diff_result.exit_code == 1:
|
||||
commit_command.extend(["--", daily_path])
|
||||
await self._exec_checked(
|
||||
"commit_memory_history",
|
||||
commit_command,
|
||||
cwd=self.runtime_workspace,
|
||||
)
|
||||
|
||||
async def answer(self, *, query: str, query_time: str = "") -> JobResult:
|
||||
"""Generate an answer with the candidate's agentic_answer job."""
|
||||
async with self._operation_lock:
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ async def test_first_longmemeval_case_analysis_export(tmp_path):
|
|||
date=session_dt.strftime("%Y-%m-%d"),
|
||||
)
|
||||
assert result.success, result.error or result.answer
|
||||
await case.commit_memory_history(f"session: {session_id}")
|
||||
|
||||
answer = await case.answer(
|
||||
query=item["question"],
|
||||
|
|
@ -124,6 +125,8 @@ async def test_first_longmemeval_case_analysis_export(tmp_path):
|
|||
assert layout["configured_paths"]["mem_session_dir"] == "reme_workspace/mem_session"
|
||||
|
||||
assert any(name.startswith("reme_workspace/daily/") and name.endswith(".md") for name in names)
|
||||
assert "reme_workspace/.git/HEAD" in names
|
||||
assert any(name.startswith("reme_workspace/.git/objects/") for name in names)
|
||||
assert any(name.startswith("reme_workspace/mem_session/") for name in names)
|
||||
assert any(name.startswith("reme_workspace/session/dialog/") and name.endswith(".jsonl") for name in names)
|
||||
excluded_prefixes = (
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ class FakeBackend:
|
|||
for path in list(self.files):
|
||||
if path in command[2:] or path.startswith(roots):
|
||||
del self.files[path]
|
||||
if command[:2] == ["git", "init"]:
|
||||
self.files[f"{cwd}/.git/HEAD"] = b"ref: refs/heads/master\n"
|
||||
if command[:2] == ["python3", "-c"] or (len(command) > 2 and command[1:3] == ["-c", command[2]]):
|
||||
stdout = b'{"reme": "/workspace/candidate/reme/__init__.py", "agentscope": "2.0.4.post1"}\n'
|
||||
return FakeExecResult(stdout=stdout)
|
||||
|
|
@ -215,6 +217,84 @@ async def test_image_candidate_skips_source_upload_and_runs_jobs(tmp_path):
|
|||
assert "--exclude=reme_workspace/session" not in export_command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_selects_when_to_commit_configured_daily_directory():
|
||||
"""Several ingested sessions can be grouped into one explicit host checkpoint."""
|
||||
workspace = FakeWorkspace("case-1", "candidate:test", {})
|
||||
factory = DockerReMeSandboxFactory(
|
||||
ImageCandidate("candidate:test"),
|
||||
workspace_builder=lambda *_args: workspace,
|
||||
)
|
||||
case = await factory.create_case("case-1")
|
||||
|
||||
first = await case.ingest_session(messages=[], session_id="session-001", update_index=False)
|
||||
second = await case.ingest_session(messages=[], session_id="session-002", update_index=False)
|
||||
|
||||
assert first.success is second.success is True
|
||||
commands = workspace.backend.commands
|
||||
assert not any("commit" in command for command, _, _ in commands)
|
||||
|
||||
await case.commit_memory_history("sessions: session-001, session-002")
|
||||
|
||||
assert (["git", "init", "--quiet"], case.runtime_workspace, case.command_timeout) in commands
|
||||
assert (["git", "add", "-A", "--", "daily"], case.runtime_workspace, case.command_timeout) in commands
|
||||
commit = next(command for command, cwd, _ in commands if "commit" in command and cwd == case.runtime_workspace)
|
||||
assert "--allow-empty" in commit
|
||||
assert "--only" in commit
|
||||
assert commit[-2:] == ["-m", "sessions: session-001, session-002"]
|
||||
assert all(path not in commit for path in ("metadata", "session", "resource", "."))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_history_uses_resolved_custom_daily_directory():
|
||||
"""Git pathspecs follow the candidate's validated daily_dir configuration."""
|
||||
workspace = FakeWorkspace("case-1", "candidate:test", {})
|
||||
factory = DockerReMeSandboxFactory(
|
||||
ImageCandidate("candidate:test"),
|
||||
workspace_builder=lambda *_args: workspace,
|
||||
)
|
||||
case = await factory.create_case("case-1")
|
||||
workspace.backend.files["/workspace/case/runtime-layout.json"] = json.dumps(
|
||||
{
|
||||
"workspace_root": "reme_workspace",
|
||||
"configured_paths": {"daily_dir": "reme_workspace/memories/daily-notes"},
|
||||
"analysis_excludes": [],
|
||||
},
|
||||
).encode()
|
||||
|
||||
assert await case._daily_history_path() == "memories/daily-notes" # pylint: disable=protected-access
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_ingest_session_does_not_create_history_commit():
|
||||
"""Failed memory construction must not be represented as a successful session boundary."""
|
||||
|
||||
class FailingJobBackend(FakeBackend):
|
||||
"""Return a failed response for every emulated ReMe job."""
|
||||
|
||||
async def exec_shell(self, command, *, cwd=None, timeout=None):
|
||||
result = await super().exec_shell(command, cwd=cwd, timeout=timeout)
|
||||
if "/workspace/harness/worker.py" in command:
|
||||
response_path = command[command.index("--response") + 1]
|
||||
self.files[response_path] = json.dumps(
|
||||
{"success": False, "answer": "failed", "metadata": {}, "error": "failed"},
|
||||
).encode()
|
||||
return result
|
||||
|
||||
backend = FailingJobBackend()
|
||||
workspace = FakeWorkspace("case-1", "candidate:test", {}, backend=backend)
|
||||
factory = DockerReMeSandboxFactory(
|
||||
ImageCandidate("candidate:test"),
|
||||
workspace_builder=lambda *_args: workspace,
|
||||
)
|
||||
case = await factory.create_case("case-1")
|
||||
|
||||
result = await case.ingest_session(messages=[], session_id="session-001", update_index=False)
|
||||
|
||||
assert result.success is False
|
||||
assert not any("commit" in command for command, _, _ in backend.commands)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_container_can_reset_and_run_another_case(tmp_path):
|
||||
"""Reset removes case state while retaining the installed candidate and worker."""
|
||||
|
|
@ -234,6 +314,8 @@ async def test_one_container_can_reset_and_run_another_case(tmp_path):
|
|||
backend = workspaces[0].backend
|
||||
worker_bytes = backend.files["/workspace/harness/worker.py"]
|
||||
backend.files["/workspace/candidate/reme/__init__.py"] = b"candidate-code"
|
||||
old_git_object = "/workspace/case/reme_workspace/.git/objects/old-case-object"
|
||||
backend.files[old_git_object] = b"old case history"
|
||||
assert any(path.startswith("/workspace/case/") for path in backend.files)
|
||||
|
||||
await case.reset_case("case-2")
|
||||
|
|
@ -242,8 +324,18 @@ async def test_one_container_can_reset_and_run_another_case(tmp_path):
|
|||
assert len(workspaces) == 1
|
||||
assert backend.files["/workspace/harness/worker.py"] == worker_bytes
|
||||
assert backend.files["/workspace/candidate/reme/__init__.py"] == b"candidate-code"
|
||||
assert not any(path.startswith("/workspace/case/") for path in backend.files)
|
||||
assert old_git_object not in backend.files
|
||||
assert backend.files["/workspace/case/reme_workspace/.git/HEAD"].startswith(b"ref:")
|
||||
assert not any(path.startswith("/workspace/case/results/") for path in backend.files)
|
||||
assert "/tmp/reme-case-export.tar.gz" not in backend.files
|
||||
git_init_positions = [
|
||||
index for index, (command, _, _) in enumerate(backend.commands) if command == ["git", "init", "--quiet"]
|
||||
]
|
||||
clear_position = next(
|
||||
index for index, (command, _, _) in enumerate(backend.commands) if command[:2] == ["rm", "-rf"]
|
||||
)
|
||||
assert len(git_init_positions) == 2
|
||||
assert git_init_positions[0] < clear_position < git_init_positions[1]
|
||||
|
||||
result = await case.run_job("agentic_answer", {"query": "second"})
|
||||
await case.export(tmp_path / "case-2.tar.gz")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue