diff --git a/coding-agent/src/coding_agent/agent.toml b/coding-agent/src/coding_agent/agent.toml index b7c4b8133..f1b833d3f 100644 --- a/coding-agent/src/coding_agent/agent.toml +++ b/coding-agent/src/coding_agent/agent.toml @@ -48,6 +48,10 @@ enabled = true read_enabled = true write_enabled = true +[memory.semantic] +enabled = false +backend = "fake" + [kb] db_path = "kb" embedding_model = "text-embedding-3-small" diff --git a/coding-agent/src/coding_agent/core/app.py b/coding-agent/src/coding_agent/core/app.py index 714b8f65a..5138e7013 100644 --- a/coding-agent/src/coding_agent/core/app.py +++ b/coding-agent/src/coding_agent/core/app.py @@ -5,7 +5,7 @@ import uuid from collections.abc import Mapping from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -39,16 +39,36 @@ from coding_agent.plugins.summarizer import SummarizerPlugin from coding_agent.subagents.coordinator import ChildWorkerCoordinator from coding_agent.topics.memory import MemoryReviewStore +from coding_agent.topics.semantic_backends import ( + FAKE_SEMANTIC_INDEX_SCHEMA, + FakeSemanticMemoryBackend, + SemanticIndexSchema, +) +from coding_agent.topics.semantic_index import SafeSemanticMemoryIndex from coding_agent.tools.web_search import create_web_search_backend ToolFilter = Any +@dataclass(frozen=True) +class SemanticMemoryConfig: + enabled: bool = False + backend: str = "fake" + schema: SemanticIndexSchema = FAKE_SEMANTIC_INDEX_SCHEMA + + def to_config_dict(self) -> dict[str, object]: + return { + "enabled": self.enabled, + "backend": self.backend, + } + + @dataclass(frozen=True) class MemorySwitchConfig: enabled: bool = True read_enabled: bool = True write_enabled: bool = True + semantic: SemanticMemoryConfig = field(default_factory=SemanticMemoryConfig) @property def effective_read_enabled(self) -> bool: @@ -58,13 +78,14 @@ def effective_read_enabled(self) -> bool: def effective_write_enabled(self) -> bool: return self.enabled and self.write_enabled - def to_config_dict(self) -> dict[str, bool]: + def to_config_dict(self) -> dict[str, object]: return { "enabled": self.enabled, "read_enabled": self.read_enabled, "write_enabled": self.write_enabled, "effective_read_enabled": self.effective_read_enabled, "effective_write_enabled": self.effective_write_enabled, + "semantic": self.semantic.to_config_dict(), } @@ -135,6 +156,7 @@ def _parse_memory_switch_config(extra: Mapping[str, Any]) -> MemorySwitchConfig: enabled=_memory_bool(raw_memory_cfg, "enabled"), read_enabled=_memory_bool(raw_memory_cfg, "read_enabled"), write_enabled=_memory_bool(raw_memory_cfg, "write_enabled"), + semantic=_parse_semantic_memory_config(raw_memory_cfg), ) @@ -145,6 +167,28 @@ def _memory_bool(config: Mapping[str, Any], field: str) -> bool: return value +def _parse_semantic_memory_config( + memory_config: Mapping[str, Any], +) -> SemanticMemoryConfig: + raw_semantic_cfg = memory_config.get("semantic", {}) + if raw_semantic_cfg is None: + raw_semantic_cfg = {} + if not isinstance(raw_semantic_cfg, Mapping): + raise ValueError("[memory.semantic] config must be a table") + enabled = raw_semantic_cfg.get("enabled", False) + if type(enabled) is not bool: + raise ValueError("[memory.semantic].enabled must be a boolean") + backend = raw_semantic_cfg.get("backend", "fake") + if not isinstance(backend, str): + raise ValueError("[memory.semantic].backend must be a string") + if enabled and backend != "fake": + raise ValueError(f"unknown semantic memory backend: {backend}") + return SemanticMemoryConfig( + enabled=enabled, + backend=backend, + ) + + def _child_system_prompt_suffix(tool_filter: ToolFilter) -> str: if tool_filter is None: return "" @@ -340,6 +384,13 @@ def create_child_pipeline( data_dir / kb_db_path / "reviewed_memory.jsonl", candidate_writes_enabled=memory_cfg.effective_write_enabled, ) + semantic_memory_backend = None + semantic_memory_index = None + if memory_cfg.semantic.enabled: + semantic_memory_backend = FakeSemanticMemoryBackend( + schema=memory_cfg.semantic.schema + ) + semantic_memory_index = SafeSemanticMemoryIndex(semantic_memory_backend) observability_cfg = cfg.extra.get("observability", {}) if not isinstance(observability_cfg, dict): raise ValueError("[observability] config must be a table") @@ -533,6 +584,10 @@ async def _memory_handler(directive: Any) -> None: ctx_config["observation_sink"] = observation_sink if local_workspace_root is not None: ctx_config["workspace_root"] = str(local_workspace_root) + if semantic_memory_backend is not None: + ctx_config["semantic_memory_backend"] = semantic_memory_backend + if semantic_memory_index is not None: + ctx_config["semantic_memory_index"] = semantic_memory_index ctx = PipelineContext( tape=tape_fork, diff --git a/coding-agent/src/coding_agent/topics/semantic_backends/__init__.py b/coding-agent/src/coding_agent/topics/semantic_backends/__init__.py new file mode 100644 index 000000000..48a425e0b --- /dev/null +++ b/coding-agent/src/coding_agent/topics/semantic_backends/__init__.py @@ -0,0 +1,20 @@ +"""Semantic memory backend contracts and local test implementations.""" + +from __future__ import annotations + +from .base import ( + SemanticBackendScope, + SemanticIndexSchema, + SemanticMemoryBackend, + SemanticSchemaMismatch, +) +from .fake import FAKE_SEMANTIC_INDEX_SCHEMA, FakeSemanticMemoryBackend + +__all__ = [ + "FAKE_SEMANTIC_INDEX_SCHEMA", + "FakeSemanticMemoryBackend", + "SemanticBackendScope", + "SemanticIndexSchema", + "SemanticMemoryBackend", + "SemanticSchemaMismatch", +] diff --git a/coding-agent/src/coding_agent/topics/semantic_backends/base.py b/coding-agent/src/coding_agent/topics/semantic_backends/base.py new file mode 100644 index 000000000..20e8ea15e --- /dev/null +++ b/coding-agent/src/coding_agent/topics/semantic_backends/base.py @@ -0,0 +1,227 @@ +"""Coding Agent semantic memory backend contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Protocol, runtime_checkable + +from agentkit.storage.protocols import MemoryHit, MemoryIndex +from coding_agent.topics.semantic_index import ( + SemanticDocId, + SemanticDocKind, + SemanticSourceKind, + SemanticSourceRef, +) + +_SEMANTIC_SCOPE_PART_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") + + +@dataclass(frozen=True, slots=True) +class SemanticIndexSchema: + schema_version: int + embedding_provider_id: str + embedding_model: str + embedding_dim: int + backend_adapter_id: str + backend_schema_version: int + distance_metric: str + score_normalization: str + + def __post_init__(self) -> None: + _require_positive_int("semantic schema version", self.schema_version) + _require_non_empty_token( + "semantic embedding provider id", self.embedding_provider_id + ) + _require_non_empty_token("semantic embedding model", self.embedding_model) + _require_positive_int("semantic embedding dimension", self.embedding_dim) + _require_non_empty_token( + "semantic backend adapter id", self.backend_adapter_id + ) + _require_positive_int( + "semantic backend schema version", self.backend_schema_version + ) + _require_non_empty_token("semantic distance metric", self.distance_metric) + _require_non_empty_token( + "semantic score normalization", self.score_normalization + ) + + +class SemanticSchemaMismatch(ValueError): + """Raised when a backend already contains a different semantic schema.""" + + def __init__( + self, + *, + existing: SemanticIndexSchema, + requested: SemanticIndexSchema, + ) -> None: + super().__init__( + "semantic memory schema mismatch: " + f"existing={existing!r} requested={requested!r}" + ) + self.existing = existing + self.requested = requested + + +@dataclass(frozen=True, slots=True) +class SemanticBackendScope: + source_kind: SemanticSourceKind | str + source_id: str + session_id: str | None = None + profile: str | None = None + + def __post_init__(self) -> None: + source_kind = _coerce_source_kind(self.source_kind) + _require_safe_scope_part("semantic backend scope source id", self.source_id) + if self.session_id is not None: + _require_safe_scope_part( + "semantic backend scope session id", self.session_id + ) + if self.profile is not None: + _require_safe_scope_part("semantic backend scope profile", self.profile) + object.__setattr__(self, "source_kind", source_kind) + + @classmethod + def for_source_ref( + cls, + source_ref: SemanticSourceRef | str, + *, + session_id: str | None = None, + profile: str | None = None, + ) -> SemanticBackendScope: + ref = SemanticSourceRef.parse(source_ref) + return cls( + source_kind=ref.kind, + source_id=ref.source_id, + session_id=session_id, + profile=profile, + ) + + @property + def source_ref(self) -> str: + if not isinstance(self.source_kind, SemanticSourceKind): + raise TypeError("semantic backend scope source kind was not normalized") + return str( + SemanticSourceRef(kind=self.source_kind, source_id=self.source_id) + ) + + def matches( + self, + *, + memory_id: str, + metadata: dict[str, object], + source_refs: tuple[str, ...], + ) -> bool: + refs = source_refs or (_primary_source_ref_for_memory_id(memory_id),) + if self.source_ref not in refs: + return False + if self.session_id is not None and metadata.get("session_id") != self.session_id: + return False + if self.profile is not None: + metadata_profile = metadata.get("profile", metadata.get("profile_id")) + if metadata_profile != self.profile: + return False + return True + + +@runtime_checkable +class SemanticMemoryBackend(MemoryIndex, Protocol): + """Coding Agent operational extension for semantic memory backends.""" + + async def ensure_schema( + self, + schema: SemanticIndexSchema, + *, + allow_rebuild: bool = False, + ) -> None: ... + + async def list_ids( + self, + *, + scope: SemanticBackendScope | None = None, + ) -> list[str]: ... + + async def delete_scope(self, scope: SemanticBackendScope) -> int: ... + + +def normalize_source_refs( + memory_id: str, + metadata: dict[str, object], +) -> tuple[str, ...]: + raw_refs = metadata.get("source_refs", ()) + if raw_refs is None: + return (_primary_source_ref_for_memory_id(memory_id),) + if isinstance(raw_refs, str): + refs = (raw_refs,) + elif isinstance(raw_refs, list | tuple): + refs = tuple(raw_refs) + else: + raise TypeError("semantic memory metadata source_refs must be a string list") + normalized = tuple( + sorted({str(SemanticSourceRef.parse(ref)) for ref in refs}) + ) + if not normalized: + return (_primary_source_ref_for_memory_id(memory_id),) + return normalized + + +def memory_hit_from_document( + *, + memory_id: str, + text: str, + score: float, + metadata: dict[str, object], +) -> MemoryHit: + return MemoryHit( + memory_id=memory_id, + text=text, + score=score, + metadata=dict(metadata), + source_refs=normalize_source_refs(memory_id, metadata), + ) + + +def _primary_source_ref_for_memory_id(memory_id: str) -> str: + document_id = SemanticDocId.parse(memory_id) + if document_id.kind is SemanticDocKind.TOPIC_SUMMARY: + return str( + SemanticSourceRef( + kind=SemanticSourceKind.TOPIC, + source_id=document_id.source_id, + ) + ) + if document_id.kind is SemanticDocKind.ACCEPTED_REVIEWED_MEMORY: + return str( + SemanticSourceRef( + kind=SemanticSourceKind.ACCEPTED_MEMORY, + source_id=document_id.source_id, + ) + ) + raise ValueError("semantic document kind is not supported") + + +def _coerce_source_kind(value: SemanticSourceKind | str) -> SemanticSourceKind: + try: + return SemanticSourceKind(value) + except ValueError as exc: + raise ValueError( + f"semantic backend scope source kind is not supported: {value}" + ) from exc + + +def _require_safe_scope_part(field_name: str, value: str) -> None: + if _SEMANTIC_SCOPE_PART_RE.fullmatch(value) is None: + raise ValueError( + f"{field_name} must contain only letters, numbers, dot, underscore, or dash" + ) + + +def _require_non_empty_token(field_name: str, value: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _require_positive_int(field_name: str, value: int) -> None: + if type(value) is not int or value <= 0: + raise ValueError(f"{field_name} must be a positive integer") diff --git a/coding-agent/src/coding_agent/topics/semantic_backends/fake.py b/coding-agent/src/coding_agent/topics/semantic_backends/fake.py new file mode 100644 index 000000000..e7ba9c536 --- /dev/null +++ b/coding-agent/src/coding_agent/topics/semantic_backends/fake.py @@ -0,0 +1,137 @@ +"""In-memory semantic memory backend for contract tests and local config wiring.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re + +from agentkit.storage.protocols import MemoryHit +from coding_agent.topics.semantic_index import SemanticDocId + +from .base import ( + SemanticBackendScope, + SemanticIndexSchema, + SemanticSchemaMismatch, + memory_hit_from_document, + normalize_source_refs, +) + +FAKE_SEMANTIC_INDEX_SCHEMA = SemanticIndexSchema( + schema_version=1, + embedding_provider_id="fake", + embedding_model="fake-semantic-memory-v0", + embedding_dim=8, + backend_adapter_id="fake", + backend_schema_version=1, + distance_metric="cosine", + score_normalization="overlap_high_is_better_v1", +) + +_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+") + + +@dataclass(frozen=True, slots=True) +class _StoredSemanticDocument: + memory_id: str + text: str + metadata: dict[str, object] + source_refs: tuple[str, ...] + + +class FakeSemanticMemoryBackend: + """Deterministic in-memory backend implementing the semantic backend contract.""" + + def __init__(self, schema: SemanticIndexSchema | None = None) -> None: + self._schema = schema + self._documents: dict[str, _StoredSemanticDocument] = {} + + @property + def schema(self) -> SemanticIndexSchema | None: + return self._schema + + async def ensure_schema( + self, + schema: SemanticIndexSchema, + *, + allow_rebuild: bool = False, + ) -> None: + if self._schema is None: + self._schema = schema + return + if self._schema == schema: + return + if not allow_rebuild: + raise SemanticSchemaMismatch(existing=self._schema, requested=schema) + self._schema = schema + self._documents.clear() + + async def upsert( + self, + memory_id: str, + text: str, + metadata: dict[str, object], + ) -> None: + document_id = SemanticDocId.parse(memory_id) + stored_metadata = dict(metadata) + self._documents[str(document_id)] = _StoredSemanticDocument( + memory_id=str(document_id), + text=text, + metadata=stored_metadata, + source_refs=normalize_source_refs(str(document_id), stored_metadata), + ) + + async def search(self, query: str, limit: int = 10) -> list[MemoryHit]: + if limit <= 0: + raise ValueError("limit must be positive") + scored_hits = [ + memory_hit_from_document( + memory_id=document.memory_id, + text=document.text, + score=_overlap_score(query, document.text), + metadata=document.metadata, + ) + for document in self._documents.values() + ] + return sorted( + scored_hits, + key=lambda hit: (-hit.score, hit.memory_id), + )[:limit] + + async def delete(self, memory_id: str) -> None: + document_id = SemanticDocId.parse(memory_id) + self._documents.pop(str(document_id), None) + + async def list_ids( + self, + *, + scope: SemanticBackendScope | None = None, + ) -> list[str]: + if scope is None: + return sorted(self._documents) + return sorted( + document.memory_id + for document in self._documents.values() + if scope.matches( + memory_id=document.memory_id, + metadata=document.metadata, + source_refs=document.source_refs, + ) + ) + + async def delete_scope(self, scope: SemanticBackendScope) -> int: + matching_ids = await self.list_ids(scope=scope) + for memory_id in matching_ids: + del self._documents[memory_id] + return len(matching_ids) + + +def _overlap_score(query: str, text: str) -> float: + query_tokens = set(_tokens(query)) + if not query_tokens: + return 0.0 + text_tokens = set(_tokens(text)) + return len(query_tokens & text_tokens) / len(query_tokens) + + +def _tokens(value: str) -> tuple[str, ...]: + return tuple(match.group(0).lower() for match in _TOKEN_RE.finditer(value)) diff --git a/coding-agent/tests/coding_agent/test_memory_index_backend_contract.py b/coding-agent/tests/coding_agent/test_memory_index_backend_contract.py new file mode 100644 index 000000000..ca2770243 --- /dev/null +++ b/coding-agent/tests/coding_agent/test_memory_index_backend_contract.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import pytest + +from agentkit.storage.protocols import MemoryHit +from coding_agent.topics.semantic_backends import ( + FakeSemanticMemoryBackend, + SemanticBackendScope, + SemanticIndexSchema, + SemanticSchemaMismatch, +) + + +def _schema(**overrides: object) -> SemanticIndexSchema: + values = { + "schema_version": 1, + "embedding_provider_id": "fake", + "embedding_model": "fake-memory-v0", + "embedding_dim": 8, + "backend_adapter_id": "fake", + "backend_schema_version": 1, + "distance_metric": "cosine", + "score_normalization": "overlap_high_is_better_v1", + } + values.update(overrides) + return SemanticIndexSchema(**values) + + +@pytest.mark.asyncio +async def test_fake_backend_satisfies_memory_index_contract() -> None: + backend = FakeSemanticMemoryBackend() + await backend.ensure_schema(_schema()) + await backend.upsert( + "topic-summary:topic-auth:1-3", + "authentication middleware jwt", + { + "source_refs": ("topic:topic-auth",), + "session_id": "session-1", + "profile": "ops", + }, + ) + await backend.upsert( + "topic-summary:topic-cache:1-3", + "cache ttl redis", + { + "source_refs": ("topic:topic-cache",), + "session_id": "session-1", + "profile": "ops", + }, + ) + + hits = await backend.search("jwt authentication", limit=2) + + assert hits == [ + MemoryHit( + memory_id="topic-summary:topic-auth:1-3", + text="authentication middleware jwt", + score=1.0, + metadata={ + "source_refs": ("topic:topic-auth",), + "session_id": "session-1", + "profile": "ops", + }, + source_refs=("topic:topic-auth",), + ), + MemoryHit( + memory_id="topic-summary:topic-cache:1-3", + text="cache ttl redis", + score=0.0, + metadata={ + "source_refs": ("topic:topic-cache",), + "session_id": "session-1", + "profile": "ops", + }, + source_refs=("topic:topic-cache",), + ), + ] + assert await backend.list_ids() == [ + "topic-summary:topic-auth:1-3", + "topic-summary:topic-cache:1-3", + ] + + await backend.delete("topic-summary:topic-cache:1-3") + + assert await backend.list_ids() == ["topic-summary:topic-auth:1-3"] + + +@pytest.mark.asyncio +async def test_schema_identity_mismatch_fails_clearly() -> None: + backend = FakeSemanticMemoryBackend() + await backend.ensure_schema(_schema()) + await backend.upsert( + "topic-summary:topic-auth:1-3", + "authentication middleware jwt", + {"source_refs": ("topic:topic-auth",)}, + ) + + with pytest.raises(SemanticSchemaMismatch, match="semantic memory schema mismatch"): + await backend.ensure_schema(_schema(embedding_dim=16)) + + assert await backend.list_ids() == ["topic-summary:topic-auth:1-3"] + + +@pytest.mark.asyncio +async def test_schema_identity_mismatch_requires_explicit_rebuild_and_clears_stale_docs() -> None: + backend = FakeSemanticMemoryBackend() + await backend.ensure_schema(_schema()) + await backend.upsert( + "topic-summary:topic-auth:1-3", + "authentication middleware jwt", + {"source_refs": ("topic:topic-auth",)}, + ) + + await backend.ensure_schema(_schema(embedding_dim=16), allow_rebuild=True) + + assert await backend.list_ids() == [] + await backend.upsert( + "topic-summary:topic-cache:1-3", + "cache ttl redis", + {"source_refs": ("topic:topic-cache",)}, + ) + assert await backend.list_ids() == ["topic-summary:topic-cache:1-3"] + + +def test_source_scope_grammar_rejects_invalid_scopes() -> None: + with pytest.raises(ValueError, match="semantic backend scope source kind"): + SemanticBackendScope(source_kind="run", source_id="topic-auth") + + with pytest.raises(ValueError, match="semantic backend scope source id"): + SemanticBackendScope(source_kind="topic", source_id="topic:auth") + + with pytest.raises(ValueError, match="semantic backend scope session id"): + SemanticBackendScope( + source_kind="topic", + source_id="topic-auth", + session_id="../session", + ) + + +@pytest.mark.asyncio +async def test_scoped_delete_cannot_delete_across_scopes() -> None: + backend = FakeSemanticMemoryBackend() + await backend.ensure_schema(_schema()) + await backend.upsert( + "topic-summary:topic-auth:1-3", + "authentication middleware jwt", + { + "source_refs": ("topic:topic-auth",), + "session_id": "session-1", + "profile": "ops", + }, + ) + await backend.upsert( + "topic-summary:topic-auth:4-9", + "authentication token refresh", + { + "source_refs": ("topic:topic-auth",), + "session_id": "session-2", + "profile": "ops", + }, + ) + await backend.upsert( + "topic-summary:topic-cache:1-3", + "cache ttl redis", + { + "source_refs": ("topic:topic-cache",), + "session_id": "session-1", + "profile": "ops", + }, + ) + + deleted = await backend.delete_scope( + SemanticBackendScope( + source_kind="topic", + source_id="topic-auth", + session_id="session-1", + profile="ops", + ) + ) + + assert deleted == 1 + assert await backend.list_ids() == [ + "topic-summary:topic-auth:4-9", + "topic-summary:topic-cache:1-3", + ] + assert await backend.list_ids( + scope=SemanticBackendScope(source_kind="topic", source_id="topic-auth") + ) == ["topic-summary:topic-auth:4-9"] + + +@pytest.mark.asyncio +async def test_memory_hit_score_is_normalized_high_is_better_by_result_ordering() -> None: + backend = FakeSemanticMemoryBackend() + await backend.ensure_schema(_schema()) + await backend.upsert( + "topic-summary:topic-low:1-3", + "authentication", + {"source_refs": ("topic:topic-low",)}, + ) + await backend.upsert( + "topic-summary:topic-high:1-3", + "authentication middleware jwt", + {"source_refs": ("topic:topic-high",)}, + ) + await backend.upsert( + "topic-summary:topic-tie:1-3", + "jwt middleware authentication", + {"source_refs": ("topic:topic-tie",)}, + ) + + hits = await backend.search("authentication middleware jwt", limit=3) + + assert [(hit.memory_id, hit.score) for hit in hits] == [ + ("topic-summary:topic-high:1-3", 1.0), + ("topic-summary:topic-tie:1-3", 1.0), + ("topic-summary:topic-low:1-3", pytest.approx(1 / 3)), + ] diff --git a/coding-agent/tests/coding_agent/test_memory_switch.py b/coding-agent/tests/coding_agent/test_memory_switch.py index 0067f4102..82258c89b 100644 --- a/coding-agent/tests/coding_agent/test_memory_switch.py +++ b/coding-agent/tests/coding_agent/test_memory_switch.py @@ -198,6 +198,10 @@ def test_missing_memory_section_defaults_to_read_and_write_enabled( "write_enabled": True, "effective_read_enabled": True, "effective_write_enabled": True, + "semantic": { + "enabled": False, + "backend": "fake", + }, } assert ctx.config["topic_recall"]["enabled"] is True @@ -503,6 +507,10 @@ def test_memory_switch_truth_table( "write_enabled": write_enabled, "effective_read_enabled": effective_read, "effective_write_enabled": effective_write, + "semantic": { + "enabled": False, + "backend": "fake", + }, } @@ -564,6 +572,135 @@ def test_kb_toggle_is_independent_from_memory_toggle(tmp_path: Path) -> None: assert fake_kb.queries == ["how does auth work?"] +def test_nested_semantic_config_preserves_existing_memory_switch_defaults( + tmp_path: Path, +) -> None: + config_path = _config( + tmp_path, + memory=""" + +[memory] + +[memory.semantic] +enabled = false +backend = "fake" +""", + ) + + _pipeline, ctx = create_agent( + config_path=config_path, + data_dir=tmp_path / "data", + api_key="sk-test", + ) + + assert ctx.config["memory"] == { + "enabled": True, + "read_enabled": True, + "write_enabled": True, + "effective_read_enabled": True, + "effective_write_enabled": True, + "semantic": { + "enabled": False, + "backend": "fake", + }, + } + assert ctx.config["topic_recall"]["enabled"] is True + assert "semantic_memory_backend" not in ctx.config + assert "semantic_memory_index" not in ctx.config + + +def test_disabled_semantic_config_does_not_initialize_provider_or_backend( + tmp_path: Path, +) -> None: + config_path = _config( + tmp_path, + memory=""" + +[memory] + +[memory.semantic] +enabled = false +backend = "unknown" +""", + ) + + _pipeline, ctx = create_agent( + config_path=config_path, + data_dir=tmp_path / "data", + ) + + assert ctx.config["memory"]["semantic"] == { + "enabled": False, + "backend": "unknown", + } + assert "semantic_memory_backend" not in ctx.config + assert "semantic_memory_index" not in ctx.config + + +def test_semantic_enabled_unknown_backend_fails_clearly(tmp_path: Path) -> None: + config_path = _config( + tmp_path, + memory=""" + +[memory] + +[memory.semantic] +enabled = true +backend = "unknown" +""", + ) + + with pytest.raises(ValueError, match="unknown semantic memory backend: unknown"): + create_agent( + config_path=config_path, + data_dir=tmp_path / "data", + api_key="sk-test", + ) + + +def test_semantic_enabled_fake_backend_exposes_backend_and_index_without_changing_defaults( + tmp_path: Path, +) -> None: + config_path = _config( + tmp_path, + memory=""" + +[memory] +read_enabled = true +write_enabled = true + +[memory.semantic] +enabled = true +backend = "fake" +""", + ) + + _pipeline, ctx = create_agent( + config_path=config_path, + data_dir=tmp_path / "data", + api_key="sk-test", + ) + + assert ctx.config["memory"] == { + "enabled": True, + "read_enabled": True, + "write_enabled": True, + "effective_read_enabled": True, + "effective_write_enabled": True, + "semantic": { + "enabled": True, + "backend": "fake", + }, + } + assert ctx.config["topic_recall"]["enabled"] is True + assert ctx.config["semantic_memory_backend"].__class__.__name__ == ( + "FakeSemanticMemoryBackend" + ) + assert ctx.config["semantic_memory_index"].__class__.__name__ == ( + "SafeSemanticMemoryIndex" + ) + + def test_non_bool_memory_config_rejected(tmp_path: Path) -> None: config_path = _config( tmp_path,