-
Notifications
You must be signed in to change notification settings - Fork 602
feat(agent): persist and restore conversation history across runs #1963
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: legacy-dev-dont-merge
Are you sure you want to change the base?
Changes from all commits
cf4af72
e64b296
fa02e43
fe89bc9
9092270
ae2ae62
240efd9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,132 @@ | ||||||||||||||||||||||||
| # Copyright 2026 Dimensional Inc. | ||||||||||||||||||||||||
| # | ||||||||||||||||||||||||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||||||||||||||||||||||||
| # you may not use this file except in compliance with the License. | ||||||||||||||||||||||||
| # You may obtain a copy of the License at | ||||||||||||||||||||||||
| # | ||||||||||||||||||||||||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||||||||||
| # | ||||||||||||||||||||||||
| # Unless required by applicable law or agreed to in writing, software | ||||||||||||||||||||||||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||||||||||||||||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||||||||||||
| # See the License for the specific language governing permissions and | ||||||||||||||||||||||||
| # limitations under the License. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| """Persistent storage for agent conversation history across dimos runs. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Layout on disk: | ||||||||||||||||||||||||
| ~/.local/state/dimos/sessions/ | ||||||||||||||||||||||||
| <blueprint>/ | ||||||||||||||||||||||||
| <timestamp>.json | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| from langchain_core.messages import messages_from_dict, messages_to_dict | ||||||||||||||||||||||||
| from langchain_core.messages.base import BaseMessage | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| from dimos.constants import STATE_DIR | ||||||||||||||||||||||||
| from dimos.utils.logging_config import setup_logger | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| logger = setup_logger() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| # run_id format: "20260502-143022-<blueprint>" | ||||||||||||||||||||||||
| _RUN_ID_RE = re.compile(r"^(\d{8}-\d{6})-(.*)") | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _parse_run_id(run_id: str) -> tuple[str, str]: | ||||||||||||||||||||||||
| """Return (timestamp, blueprint) from a run_id.""" | ||||||||||||||||||||||||
| m = _RUN_ID_RE.match(run_id) | ||||||||||||||||||||||||
| if not m: | ||||||||||||||||||||||||
| raise ValueError(f"Cannot parse run_id: {run_id!r}") | ||||||||||||||||||||||||
| return m.group(1), m.group(2) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _session_path(run_id: str) -> Path: | ||||||||||||||||||||||||
| timestamp, blueprint = _parse_run_id(run_id) | ||||||||||||||||||||||||
| return STATE_DIR / "sessions" / blueprint / f"{timestamp}.json" | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def save_session( | ||||||||||||||||||||||||
| run_id: str, | ||||||||||||||||||||||||
| blueprint: str, | ||||||||||||||||||||||||
| model: str, | ||||||||||||||||||||||||
| started_at: str | None, | ||||||||||||||||||||||||
| original_argv: list[str], | ||||||||||||||||||||||||
| history: list[BaseMessage], | ||||||||||||||||||||||||
| parent_session_id: str | None = None, | ||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||
| """Persist agent conversation history and session metadata to disk.""" | ||||||||||||||||||||||||
| path = _session_path(run_id) | ||||||||||||||||||||||||
| path.parent.mkdir(parents=True, exist_ok=True) | ||||||||||||||||||||||||
| data = { | ||||||||||||||||||||||||
| "run_id": run_id, | ||||||||||||||||||||||||
| "blueprint": blueprint, | ||||||||||||||||||||||||
| "model": model, | ||||||||||||||||||||||||
| "started_at": started_at, | ||||||||||||||||||||||||
| "ended_at": datetime.now(timezone.utc).isoformat(), | ||||||||||||||||||||||||
| "original_argv": original_argv, | ||||||||||||||||||||||||
| "parent_session_id": parent_session_id, | ||||||||||||||||||||||||
| "messages": messages_to_dict(history), | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| path.write_text(json.dumps(data, indent=2)) | ||||||||||||||||||||||||
| logger.info("Saved agent session.", run_id=run_id, n_messages=len(history)) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def load_session(run_id: str) -> tuple[list[BaseMessage], dict[str, object]]: | ||||||||||||||||||||||||
| """Load agent history and metadata by run_id. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Returns (messages, metadata) where metadata contains all non-message fields. | ||||||||||||||||||||||||
| Raises FileNotFoundError if the session does not exist. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| path = _session_path(run_id) | ||||||||||||||||||||||||
| data = json.loads(path.read_text()) | ||||||||||||||||||||||||
| messages = messages_from_dict(data["messages"]) | ||||||||||||||||||||||||
| metadata = {k: v for k, v in data.items() if k != "messages"} | ||||||||||||||||||||||||
| logger.info("Restored agent session.", run_id=run_id, n_messages=len(messages)) | ||||||||||||||||||||||||
| return messages, metadata | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def restore_session( | ||||||||||||||||||||||||
| blueprint: str, | ||||||||||||||||||||||||
| restore_session_id: str | None, | ||||||||||||||||||||||||
| no_restore: bool, | ||||||||||||||||||||||||
| ) -> tuple[list[BaseMessage], str | None]: | ||||||||||||||||||||||||
| """Restore history from a previous session. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Returns (history, parent_session_id). Returns ([], None) if nothing to restore. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| if no_restore or not blueprint: | ||||||||||||||||||||||||
| return [], None | ||||||||||||||||||||||||
| session_id = restore_session_id or find_latest_session(blueprint) | ||||||||||||||||||||||||
| if not session_id: | ||||||||||||||||||||||||
| return [], None | ||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||
| history, metadata = load_session(session_id) | ||||||||||||||||||||||||
| return history, str(metadata["run_id"]) | ||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||
| logger.warning("Failed to restore session, starting fresh.", session_id=session_id) | ||||||||||||||||||||||||
| return [], None | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def find_latest_session(blueprint: str) -> str | None: | ||||||||||||||||||||||||
| """Return the run_id of the most recent saved session for a blueprint, or None.""" | ||||||||||||||||||||||||
| bp_dir = STATE_DIR / "sessions" / blueprint | ||||||||||||||||||||||||
| if not bp_dir.exists(): | ||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||
| files = sorted(bp_dir.glob("*.json")) | ||||||||||||||||||||||||
| if not files: | ||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||
| data = json.loads(files[-1].read_text()) | ||||||||||||||||||||||||
| return str(data["run_id"]) | ||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||
| logger.warning("Failed to read latest session file.", path=str(files[-1])) | ||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||
|
Comment on lines
+125
to
+130
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # Copyright 2026 Dimensional Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Tests for session_store save/load/restore/find_latest.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
| from langchain_core.messages import AIMessage, HumanMessage | ||
|
|
||
| import dimos.core.session_store as session_store_mod | ||
| from dimos.core.session_store import ( | ||
| find_latest_session, | ||
| load_session, | ||
| restore_session, | ||
| save_session, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _isolated_state_dir(tmp_path, monkeypatch): | ||
| monkeypatch.setattr(session_store_mod, "STATE_DIR", tmp_path) | ||
|
|
||
|
|
||
| class TestSaveAndLoadSession: | ||
| def test_round_trip(self) -> None: | ||
| run_id = "20260503-120000-demo-agent" | ||
| history = [HumanMessage(content="hello"), AIMessage(content="hi")] | ||
| save_session( | ||
| run_id=run_id, | ||
| blueprint="demo-agent", | ||
| model="gpt-4", | ||
| started_at="2026-05-03T12:00:00+00:00", | ||
| original_argv=["dimos", "run", "demo-agent"], | ||
| history=history, | ||
| ) | ||
| messages, metadata = load_session(run_id) | ||
| assert len(messages) == 2 | ||
| assert messages[0].content == "hello" | ||
| assert messages[1].content == "hi" | ||
| assert metadata["run_id"] == run_id | ||
| assert metadata["blueprint"] == "demo-agent" | ||
|
|
||
|
|
||
| class TestFindLatestSession: | ||
| def test_returns_none_when_no_sessions(self) -> None: | ||
| assert find_latest_session("demo-agent") is None | ||
|
|
||
| def test_returns_latest_run_id(self) -> None: | ||
| for ts in ["20260503-100000", "20260503-120000"]: | ||
| save_session( | ||
| run_id=f"{ts}-demo-agent", | ||
| blueprint="demo-agent", | ||
| model="gpt-4", | ||
| started_at=None, | ||
| original_argv=[], | ||
| history=[HumanMessage(content="hi")], | ||
| ) | ||
| assert find_latest_session("demo-agent") == "20260503-120000-demo-agent" | ||
|
|
||
| def test_returns_none_on_corrupt_file(self, tmp_path) -> None: | ||
| bp_dir = tmp_path / "sessions" / "demo-agent" | ||
| bp_dir.mkdir(parents=True) | ||
| (bp_dir / "20260503-120000-demo-agent.json").write_text("not json") | ||
| assert find_latest_session("demo-agent") is None | ||
|
|
||
|
|
||
| class TestRestoreSession: | ||
| def test_returns_empty_when_no_restore_flag(self) -> None: | ||
| history, parent = restore_session( | ||
| blueprint="demo-agent", restore_session_id=None, no_restore=True | ||
| ) | ||
| assert history == [] | ||
| assert parent is None | ||
|
|
||
| def test_restores_latest_session(self) -> None: | ||
| run_id = "20260503-120000-demo-agent" | ||
| save_session( | ||
| run_id=run_id, | ||
| blueprint="demo-agent", | ||
| model="gpt-4", | ||
| started_at=None, | ||
| original_argv=[], | ||
| history=[HumanMessage(content="hello")], | ||
| ) | ||
| history, parent = restore_session( | ||
| blueprint="demo-agent", restore_session_id=None, no_restore=False | ||
| ) | ||
| assert len(history) == 1 | ||
| assert history[0].content == "hello" | ||
| assert parent == run_id | ||
|
|
||
| def test_returns_empty_on_corrupt_session(self, tmp_path) -> None: | ||
| bp_dir = tmp_path / "sessions" / "demo-agent" | ||
| bp_dir.mkdir(parents=True) | ||
| (bp_dir / "20260503-120000.json").write_text("not json") | ||
| history, parent = restore_session( | ||
| blueprint="demo-agent", | ||
| restore_session_id="20260503-120000-demo-agent", | ||
| no_restore=False, | ||
| ) | ||
| assert history == [] | ||
| assert parent is None | ||
|
Comment on lines
+104
to
+114
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
restore_sessiononly catchesFileNotFoundError. If the stored JSON is malformed,json.JSONDecodeErrorpropagates fromload_session; if the saved file is missing the"run_id"key, aKeyErrorpropagates fromstr(metadata["run_id"]). Either exception will crash the agent duringon_system_moduleswith no graceful fallback. The catch block should be broadened (e.g.,except Exception) and log a warning before returning([], None), matching the robustness level offind_latest_session.