Files
GUI-Agent/SystemTest-Agent/app/agent_core/memory.py
T

125 lines
4.2 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@dataclass
class MemoryItem:
kind: str
content: dict[str, Any]
created_at: str = field(default_factory=utc_now)
task_id: str | None = None
step: int | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"content": self.content,
"created_at": self.created_at,
"task_id": self.task_id,
"step": self.step,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "MemoryItem":
return cls(
kind=str(payload.get("kind") or "event"),
content=payload.get("content") if isinstance(payload.get("content"), dict) else {},
created_at=str(payload.get("created_at") or utc_now()),
task_id=str(payload.get("task_id")) if payload.get("task_id") is not None else None,
step=int(payload["step"]) if isinstance(payload.get("step"), int) else None,
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
class Blackboard:
"""
Lightweight file-backed memory shared by the local Host/App agent layers.
It intentionally mirrors UFO's blackboard shape without pulling in the full
dependency stack: requests, trajectories, observations, screenshots and facts.
"""
DEFAULT_KINDS = ("requests", "trajectories", "observations", "screenshots", "facts")
def __init__(self, path: Path, max_items_per_kind: int = 200):
self.path = path
self.max_items_per_kind = max_items_per_kind
self._content: dict[str, list[MemoryItem]] = {kind: [] for kind in self.DEFAULT_KINDS}
self.load()
def load(self) -> None:
if not self.path.exists():
return
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
if not isinstance(raw, dict):
return
for kind in self.DEFAULT_KINDS:
entries = raw.get(kind)
if isinstance(entries, list):
self._content[kind] = [
MemoryItem.from_dict(item)
for item in entries
if isinstance(item, dict)
][-self.max_items_per_kind :]
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {
kind: [item.to_dict() for item in items[-self.max_items_per_kind :]]
for kind, items in self._content.items()
}
self.path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
encoding="utf-8",
)
def append(
self,
kind: str,
content: dict[str, Any],
*,
task_id: str | None = None,
step: int | None = None,
metadata: dict[str, Any] | None = None,
) -> MemoryItem:
if kind not in self._content:
self._content[kind] = []
item = MemoryItem(
kind=kind,
content=content,
task_id=task_id,
step=step,
metadata=metadata or {},
)
self._content[kind].append(item)
self._content[kind] = self._content[kind][-self.max_items_per_kind :]
self.save()
return item
def recent(self, kind: str, limit: int = 10) -> list[dict[str, Any]]:
return [item.to_dict() for item in self._content.get(kind, [])[-limit:]]
def snapshot(self, limit: int = 5) -> dict[str, list[dict[str, Any]]]:
return {kind: self.recent(kind, limit) for kind in self._content}
def summary(self) -> dict[str, Any]:
return {
"path": str(self.path),
"counts": {kind: len(items) for kind, items in self._content.items()},
"latest_trajectory": self.recent("trajectories", 1),
}