Files
EvoScientist/EvoScientist/middleware/memory_lifecycle.py
T
dinos f2f010a350 feat(memory): observation linking (#307)
* refactor(gateway): create module for launching async/bg agents

* refactor(memory): refactor worker launch around source context & output deltas

* refactor(gateway): generalize async/bg module

* refactor(memory): revamp worker launching

* feat(memory): add observation linking

* test(memory): remove redundant test branches

* fix(memory): make 'supersedes' relation directional

* fix(memory): don't create empty project observation dirs

* fix(memory): schedule direct observations for linking

* fix(cli): wait for observation linker before shutdown

* fix(memory): block arbitrary writes to /memories

* fix(linker): remove `linked_by` attribute from frontmatter

* refactor(linker): rename base relationship to `comlpements`

* fix(cli): bump worker wait to 2m

* feat(tools): catch malformed tool calls & retry

* feat(status): add linking result to statusbar

* fix(linker): don't launch linker when observations are disabled

* fix(memory): use posix paths

* fix(watcher): call abort hook on error status

* fix(watcher): delete thread on failed run creation

* fix(watcher): preserve url

* fix(observation): record session_id, drop unused fields

* fix(memory): reject unsupported worker source types

* refactor(backends): shared memory backend builder

* fix(scheduler): resolve linker inputs outside lock

* fix(memory): dont launch workers / record observations without thread_id

* feat(memory): include related observations in tool results

* fix(memory): skip malformed observation frontmatter

* revert(tools): drop tool error handling changes from this PR

* fix(memory): serialize observation link writes

* fix(memory): queue observations written by aborted workers

* fix(memory): track observation linker launch handoff

* fix(memory): resolve cross-project related observations

* fix(status): avoid recounting reason-only link updates

* fix(memory): avoid rereading file for content

* fix(linker): use neutral prose for bidirectional reasons

* test(memory): coverage for aborted/failed launches

* test(memory): cleanup & helpers

* feat(linker): add observations index hint
2026-06-26 22:20:52 +01:00

141 lines
4.6 KiB
Python

"""Middleware that schedules post-run EvoMemory workers."""
from __future__ import annotations
import asyncio
import logging
from functools import cache
from pathlib import Path
from langchain.agents.middleware.types import AgentMiddleware, AgentState
from langgraph.runtime import Runtime
from .. import paths as _paths
from ..memory.launch import (
alaunch_memory_worker,
launch_memory_worker,
launch_observation_linker,
)
from ..memory.scheduler import MemoryScheduler
from ..memory.source_context import build_memory_source_context
from ..memory.types import MemorySourceType
logger = logging.getLogger(__name__)
@cache
def default_memory_scheduler() -> MemoryScheduler:
return MemoryScheduler(launch_linker=launch_observation_linker)
class EvoMemoryLifecycleMiddleware(AgentMiddleware):
"""Schedule post-turn and post-subagent memory workers."""
name = "evomemory_lifecycle"
def __init__(
self,
*,
memory_dir: str | Path,
workspace_dir: str | Path | None = None,
project_id: str,
source_type: MemorySourceType,
source_agent: str,
memory_scheduler: MemoryScheduler | None = None,
) -> None:
self._memory_dir = Path(memory_dir).expanduser()
self._workspace_dir = Path(
_paths.WORKSPACE_ROOT if workspace_dir is None else workspace_dir
).expanduser()
self._project_id = project_id
self._source_type = source_type
self._source_agent = source_agent
self._memory_scheduler = (
memory_scheduler
if memory_scheduler is not None
else default_memory_scheduler()
)
def after_agent(
self,
state: AgentState[object],
runtime: Runtime,
) -> dict[str, object] | None:
context = build_memory_source_context(
state=state,
runtime=runtime,
memory_dir=self._memory_dir,
workspace_dir=self._workspace_dir,
project_id=self._project_id,
source_type=self._source_type,
source_agent=self._source_agent,
)
if context is not None:
try:
run = launch_memory_worker(
context,
on_worker_finished=self._memory_scheduler.record_worker_finished,
on_worker_aborted=self._memory_scheduler.record_worker_aborted,
)
if run is None:
self._memory_scheduler.flush_ready()
except Exception:
logger.warning("Failed to launch EvoMemory worker", exc_info=True)
self._memory_scheduler.flush_ready()
else:
self._memory_scheduler.flush_ready()
return None
async def aafter_agent(
self,
state: AgentState[object],
runtime: Runtime,
) -> dict[str, object] | None:
context = build_memory_source_context(
state=state,
runtime=runtime,
memory_dir=self._memory_dir,
workspace_dir=self._workspace_dir,
project_id=self._project_id,
source_type=self._source_type,
source_agent=self._source_agent,
)
if context is not None:
try:
run = await alaunch_memory_worker(
context,
on_worker_finished=self._memory_scheduler.record_worker_finished,
on_worker_aborted=self._memory_scheduler.record_worker_aborted,
)
if run is None:
await asyncio.to_thread(self._memory_scheduler.flush_ready)
except Exception:
logger.warning("Failed to launch EvoMemory worker", exc_info=True)
await asyncio.to_thread(self._memory_scheduler.flush_ready)
else:
await asyncio.to_thread(self._memory_scheduler.flush_ready)
return None
def create_memory_lifecycle_middleware(
memory_dir: str | None = None,
*,
workspace_dir: str | Path | None = None,
project_id: str,
source_type: MemorySourceType,
source_agent: str,
memory_scheduler: MemoryScheduler | None = None,
) -> EvoMemoryLifecycleMiddleware:
"""Build the post-run EvoMemory lifecycle middleware."""
if memory_dir is None:
memory_dir = str(_paths.MEMORIES_DIR)
return EvoMemoryLifecycleMiddleware(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id=project_id,
source_type=source_type,
source_agent=source_agent,
memory_scheduler=memory_scheduler,
)