From 3bb47069abe11a5e43417bb9f6a64fb548645b6e Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 11:48:22 +0800 Subject: [PATCH 1/7] Extract runtime facade compatibility layer from PR 188 Split the non-governance portion of PR 188 into a standalone change that keeps public package facades off direct _internal imports. Add explicit defaults/adapters modules for embedding, event, hook, plan, and transport, restore the historical permissive DefaultSecurityBoundary export, and document the min-surface facade rule. Also add checkpoint facade compatibility helpers plus regression tests for checkpoint message round-trips, security boundary behavior, and package initializer import hygiene, while aligning the transport facade exports with the current main-branch transport API. --- dare_framework/checkpoint/defaults.py | 200 ++++++++++++++++++ dare_framework/embedding/__init__.py | 6 +- dare_framework/embedding/defaults.py | 5 + dare_framework/event/__init__.py | 2 +- dare_framework/event/defaults.py | 5 + dare_framework/hook/__init__.py | 2 +- dare_framework/hook/defaults.py | 5 + dare_framework/plan/__init__.py | 3 +- dare_framework/plan/defaults.py | 6 + dare_framework/security/__init__.py | 7 +- .../impl/default_security_boundary.py | 7 +- dare_framework/transport/__init__.py | 14 +- dare_framework/transport/adapters.py | 15 ++ docs/design/Framework_MinSurface_Review.md | 5 + tests/unit/test_checkpoint_defaults.py | 78 +++++++ ...est_package_initializers_facade_pattern.py | 52 +++++ tests/unit/test_security_boundary.py | 21 ++ 17 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 dare_framework/checkpoint/defaults.py create mode 100644 dare_framework/embedding/defaults.py create mode 100644 dare_framework/event/defaults.py create mode 100644 dare_framework/hook/defaults.py create mode 100644 dare_framework/plan/defaults.py create mode 100644 dare_framework/transport/adapters.py create mode 100644 tests/unit/test_checkpoint_defaults.py diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py new file mode 100644 index 00000000..6c58557b --- /dev/null +++ b/dare_framework/checkpoint/defaults.py @@ -0,0 +1,200 @@ +"""Supported default checkpoint implementations and contributors. + +This module keeps legacy checkpoint symbols importable after checkpoint internals +were consolidated into ``dare_framework.checkpoint.kernel``. +""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any +from uuid import uuid4 + +from dare_framework.context.types import Message + +STM = "stm" +SESSION_STATE = "session_state" +SESSION_CONTEXT = "session_context" +WORKSPACE_FILES = "workspace_files" + + +class MemoryCheckpointStore: + """In-memory checkpoint payload store kept for facade compatibility.""" + + def __init__(self) -> None: + self._store: dict[str, dict[str, Any]] = {} + + def put(self, checkpoint_id: str, payload: dict[str, Any]) -> None: + self._store[checkpoint_id] = dict(payload) + + def get(self, checkpoint_id: str) -> dict[str, Any] | None: + if checkpoint_id not in self._store: + return None + return dict(self._store[checkpoint_id]) + + def delete(self, checkpoint_id: str) -> bool: + if checkpoint_id in self._store: + del self._store[checkpoint_id] + return True + return False + + +def _scope_keys(scope: Any, method_name: str) -> list[str]: + method = getattr(scope, method_name, None) + if callable(method): + values = method() + if isinstance(values, (list, tuple, set)): + return [str(v) for v in values] + if isinstance(scope, (list, tuple, set)): + return [str(v) for v in scope] + return [] + + +class DefaultCheckpointSaveRestore: + """Legacy save/restore coordinator over contributor payload components.""" + + def __init__(self, store: MemoryCheckpointStore, contributors: list[Any]) -> None: + self._store = store + self._contributors = { + str(c.component_key): c + for c in contributors + if getattr(c, "component_key", None) is not None + } + + def save(self, scope: Any, ctx: Any) -> str: + payload: dict[str, Any] = {} + for key in _scope_keys(scope, "keys_for_save"): + contributor = self._contributors.get(key) + if contributor is None: + continue + payload[key] = contributor.serialize(ctx) + checkpoint_id = uuid4().hex[:16] + self._store.put(checkpoint_id, payload) + return checkpoint_id + + def restore(self, checkpoint_id: str, scope: Any, ctx: Any) -> None: + payload = self._store.get(checkpoint_id) + if payload is None: + raise LookupError(f"Checkpoint not found: {checkpoint_id!r}") + for key in _scope_keys(scope, "keys_for_restore"): + if key not in payload: + continue + contributor = self._contributors.get(key) + if contributor is None: + continue + contributor.deserialize_and_apply(payload[key], ctx) + + +class StmContributor: + """Serialize/restore short-term memory messages.""" + + component_key = STM + + def serialize(self, ctx: Any) -> list[dict[str, Any]]: + context = getattr(ctx, "context", None) + if context is None: + return [] + messages = context.stm_get() + return [ + { + "role": m.role, + "text": m.text, + "name": m.name, + "metadata": dict(getattr(m, "metadata", {}) or {}), + } + for m in messages + ] + + def deserialize_and_apply(self, payload: list[Any], ctx: Any) -> None: + context = getattr(ctx, "context", None) + if context is None: + return + context.stm_clear() + for item in payload or []: + if not isinstance(item, dict): + continue + context.stm_add( + Message( + role=item.get("role", "user"), + text=item.get("text") or item.get("content", ""), + name=item.get("name"), + metadata=dict(item.get("metadata") or {}), + ) + ) + + +class SessionStateContributor: + """Serialize/restore minimal session-state fields.""" + + component_key = SESSION_STATE + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + state = getattr(ctx, "session_state", None) + if state is None: + return None + try: + return asdict(state) + except Exception: + return { + "current_milestone_idx": getattr(state, "current_milestone_idx", None), + "task_id": getattr(state, "task_id", None), + "run_id": getattr(state, "run_id", None), + } + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + state = getattr(ctx, "session_state", None) + if state is None or not isinstance(payload, dict): + return + if "current_milestone_idx" in payload and hasattr(state, "current_milestone_idx"): + state.current_milestone_idx = payload["current_milestone_idx"] + + +class SessionContextContributor: + """Serialize session context for audit trails (restore intentionally no-op).""" + + component_key = SESSION_CONTEXT + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + session_context = getattr(ctx, "session_context", None) + if session_context is None: + return None + try: + serialized = asdict(session_context) + except Exception: + return { + "session_id": getattr(session_context, "session_id", None), + "task_id": getattr(session_context, "task_id", None), + } + config_value = serialized.get("config") + if config_value is not None and not isinstance(config_value, dict): + try: + serialized["config"] = asdict(config_value) + except Exception: + serialized["config"] = None + return serialized + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + # SessionContext is construction-time state; legacy restore path keeps this as no-op. + + +class WorkspaceGitContributor: + """Compatibility no-op contributor after workspace-git checkpoint removal.""" + + component_key = WORKSPACE_FILES + + def serialize(self, ctx: Any) -> dict[str, Any]: + _ = ctx + return {} + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + +__all__ = [ + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", +] diff --git a/dare_framework/embedding/__init__.py b/dare_framework/embedding/__init__.py index 8d1de770..60d4b447 100644 --- a/dare_framework/embedding/__init__.py +++ b/dare_framework/embedding/__init__.py @@ -1,8 +1,8 @@ """embedding domain facade.""" -from dare_framework.embedding.interfaces import IEmbeddingAdapter -from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult -from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter +from dare_framework.embedding.interfaces import IEmbeddingAdapter +from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult +from dare_framework.embedding.defaults import OpenAIEmbeddingAdapter __all__ = [ "IEmbeddingAdapter", diff --git a/dare_framework/embedding/defaults.py b/dare_framework/embedding/defaults.py new file mode 100644 index 00000000..88e38524 --- /dev/null +++ b/dare_framework/embedding/defaults.py @@ -0,0 +1,5 @@ +"""Supported default embedding implementations.""" + +from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter + +__all__ = ["OpenAIEmbeddingAdapter"] diff --git a/dare_framework/event/__init__.py b/dare_framework/event/__init__.py index a7ac3831..703a2740 100644 --- a/dare_framework/event/__init__.py +++ b/dare_framework/event/__init__.py @@ -1,6 +1,6 @@ """event domain facade.""" -from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog +from dare_framework.event.defaults import DefaultEventLog, SQLiteEventLog from dare_framework.event.kernel import IEventLog from dare_framework.event.types import Event, RuntimeSnapshot diff --git a/dare_framework/event/defaults.py b/dare_framework/event/defaults.py new file mode 100644 index 00000000..d5eee182 --- /dev/null +++ b/dare_framework/event/defaults.py @@ -0,0 +1,5 @@ +"""Supported default event-log implementations.""" + +from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog + +__all__ = ["SQLiteEventLog", "DefaultEventLog"] diff --git a/dare_framework/hook/__init__.py b/dare_framework/hook/__init__.py index 3621b4f1..1d1d3c25 100644 --- a/dare_framework/hook/__init__.py +++ b/dare_framework/hook/__init__.py @@ -2,7 +2,7 @@ from dare_framework.hook.interfaces import IHookManager from dare_framework.hook.kernel import IExtensionPoint, IHook, HookFn -from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint +from dare_framework.hook.defaults import HookExtensionPoint from dare_framework.hook.types import HookDecision, HookEnvelope, HookPhase, HookResult __all__ = [ diff --git a/dare_framework/hook/defaults.py b/dare_framework/hook/defaults.py new file mode 100644 index 00000000..2037a192 --- /dev/null +++ b/dare_framework/hook/defaults.py @@ -0,0 +1,5 @@ +"""Supported default extension-point implementation.""" + +from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint + +__all__ = ["HookExtensionPoint"] diff --git a/dare_framework/plan/__init__.py b/dare_framework/plan/__init__.py index 9daa9392..59716d99 100644 --- a/dare_framework/plan/__init__.py +++ b/dare_framework/plan/__init__.py @@ -23,8 +23,7 @@ ValidatedStep, VerifyResult, ) -from dare_framework.plan._internal.default_planner import DefaultPlanner -from dare_framework.plan._internal.default_remediator import DefaultRemediator +from dare_framework.plan.defaults import DefaultPlanner, DefaultRemediator __all__ = [ # Interfaces diff --git a/dare_framework/plan/defaults.py b/dare_framework/plan/defaults.py new file mode 100644 index 00000000..6f0edc33 --- /dev/null +++ b/dare_framework/plan/defaults.py @@ -0,0 +1,6 @@ +"""Supported default planner/remediator implementations.""" + +from dare_framework.plan._internal.default_planner import DefaultPlanner +from dare_framework.plan._internal.default_remediator import DefaultRemediator + +__all__ = ["DefaultPlanner", "DefaultRemediator"] diff --git a/dare_framework/security/__init__.py b/dare_framework/security/__init__.py index add8eb8a..ade0d6f6 100644 --- a/dare_framework/security/__init__.py +++ b/dare_framework/security/__init__.py @@ -1,6 +1,5 @@ """Security domain facade.""" -from dare_framework.security._internal.default_security_boundary import DefaultSecurityBoundary from dare_framework.security.errors import ( SECURITY_APPROVAL_MANAGER_MISSING, SECURITY_POLICY_CHECK_FAILED, @@ -8,7 +7,11 @@ SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, ) -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + DefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.kernel import ISecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput diff --git a/dare_framework/security/impl/default_security_boundary.py b/dare_framework/security/impl/default_security_boundary.py index 46b17f4b..e723783e 100644 --- a/dare_framework/security/impl/default_security_boundary.py +++ b/dare_framework/security/impl/default_security_boundary.py @@ -5,6 +5,9 @@ import inspect from typing import Any, Callable, Iterable +from dare_framework.security._internal.default_security_boundary import ( + DefaultSecurityBoundary as LegacyDefaultSecurityBoundary, +) from dare_framework.security.errors import ( SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, @@ -259,8 +262,8 @@ async def execute_safe( return await _execute_callable(fn) -# Backward-compatible alias for legacy references. -DefaultSecurityBoundary = PolicySecurityBoundary +# Preserve historical facade behavior where `DefaultSecurityBoundary` is permissive. +DefaultSecurityBoundary = LegacyDefaultSecurityBoundary __all__ = [ diff --git a/dare_framework/transport/__init__.py b/dare_framework/transport/__init__.py index 8be5b978..9057aa9e 100644 --- a/dare_framework/transport/__init__.py +++ b/dare_framework/transport/__init__.py @@ -4,27 +4,27 @@ from dare_framework.transport.serialization import jsonify_transport_value from dare_framework.transport.types import ( ActionPayload, + AttachmentRef, ControlPayload, - EnvelopePayload, EnvelopeKind, - MessageRole, + EnvelopePayload, MessageKind, MessagePayload, - AttachmentRef, + MessageRole, + Receiver, SelectDomain, SelectKind, SelectPayload, + Sender, TransportEnvelope, new_envelope_id, - Receiver, - Sender, ) from dare_framework.transport.interaction import ( - AgentControl, ActionHandlerDispatcher, + AgentControl, ResourceAction, ) -from dare_framework.transport._internal import ( +from dare_framework.transport.adapters import ( DefaultAgentChannel, DirectClientChannel, StdioClientChannel, diff --git a/dare_framework/transport/adapters.py b/dare_framework/transport/adapters.py new file mode 100644 index 00000000..9dadb2c9 --- /dev/null +++ b/dare_framework/transport/adapters.py @@ -0,0 +1,15 @@ +"""Supported default transport channel adapters.""" + +from dare_framework.transport._internal import ( + DefaultAgentChannel, + DirectClientChannel, + StdioClientChannel, + WebSocketClientChannel, +) + +__all__ = [ + "DefaultAgentChannel", + "DirectClientChannel", + "StdioClientChannel", + "WebSocketClientChannel", +] diff --git a/docs/design/Framework_MinSurface_Review.md b/docs/design/Framework_MinSurface_Review.md index 4d80b193..bc8ec18a 100644 --- a/docs/design/Framework_MinSurface_Review.md +++ b/docs/design/Framework_MinSurface_Review.md @@ -268,3 +268,8 @@ MCP adapters, etc. - Tight coupling to concrete classes prevents safe refactors. - External users rely on internal DTOs that must then be supported forever. - Increased likelihood of breaking changes with each internal improvement. + +## 5. Implementation Note (2026-03-04) +- T0-4 facade compliance batch moved public-domain `__init__.py` imports away from direct `._internal` references into explicit public export layers (`defaults.py`, `impl`, `adapters`). +- The following facades now import only public modules directly: `checkpoint`, `embedding`, `event`, `hook`, `plan`, `security`, `transport`. +- A regression rule was added in `tests/unit/test_package_initializers_facade_pattern.py` to block future direct `._internal` imports from public facades. diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py new file mode 100644 index 00000000..d41af2ef --- /dev/null +++ b/tests/unit/test_checkpoint_defaults.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import importlib +from dataclasses import dataclass + +from dare_framework.config import Config +from dare_framework.context import Context, Message + + +def test_checkpoint_defaults_module_exports_are_importable() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + + for symbol in ( + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", + ): + assert hasattr(defaults, symbol), f"missing checkpoint default symbol: {symbol}" + + +@dataclass +class _DummyConfig: + model: str + + +@dataclass +class _DummySessionContext: + session_id: str + task_id: str + config: _DummyConfig | None + + +@dataclass +class _DummyCtx: + session_context: _DummySessionContext | None + + +def test_session_context_contributor_preserves_serialized_config_dict() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.SessionContextContributor() + ctx = _DummyCtx( + session_context=_DummySessionContext( + session_id="s-1", + task_id="t-1", + config=_DummyConfig(model="gpt-4.1"), + ) + ) + + payload = contributor.serialize(ctx) + + assert payload is not None + assert payload["config"] == {"model": "gpt-4.1"} + + +def test_stm_contributor_roundtrip_uses_current_message_text_shape() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message(role="user", text="hello", name="alice", metadata={"trace_id": "t-1"}) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 1 + assert restored_messages[0].text == "hello" + assert restored_messages[0].name == "alice" + assert restored_messages[0].metadata == {"trace_id": "t-1"} diff --git a/tests/unit/test_package_initializers_facade_pattern.py b/tests/unit/test_package_initializers_facade_pattern.py index 3990969b..1bc66235 100644 --- a/tests/unit/test_package_initializers_facade_pattern.py +++ b/tests/unit/test_package_initializers_facade_pattern.py @@ -4,6 +4,42 @@ from pathlib import Path +def _is_internal_module_path(module: str) -> bool: + return module == "_internal" or module.startswith("_internal.") or "._internal" in module + + +def _find_direct_internal_import(node: ast.stmt) -> str | None: + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module and _is_internal_module_path(module): + return f"{'.' * node.level}{module}" + + # `from . import _internal` uses `module is None` and stores names in aliases. + for alias in node.names: + if _is_internal_module_path(alias.name): + return f"{'.' * node.level}{alias.name}" + return None + + if isinstance(node, ast.Import): + for alias in node.names: + if _is_internal_module_path(alias.name): + return alias.name + return None + + +def test_internal_import_detection_covers_relative_syntax() -> None: + cases = { + "from ._internal.foo import Bar": "._internal.foo", + "from .._internal import Foo": ".._internal", + "from . import _internal": "._internal", + "from dare_framework.tool._internal.tools import EchoTool": "dare_framework.tool._internal.tools", + "from dare_framework.tool.defaults import EchoTool": None, + } + for source, expected in cases.items(): + node = ast.parse(source).body[0] + assert _find_direct_internal_import(node) == expected + + def test_package_initializers_follow_facade_pattern() -> None: """Asserts that all dare_framework initializers use the facade pattern. @@ -12,6 +48,8 @@ def test_package_initializers_follow_facade_pattern() -> None: 2. No class or function definitions. 3. Assignments allowed only for metadata (e.g., __all__, __version__). 4. Imports allowed for re-exporting. + 5. Star imports are prohibited. + 6. Public facades must not import from `._internal` directly. """ repo_root = Path(__file__).resolve().parents[2] package_root = repo_root / "dare_framework" @@ -21,6 +59,7 @@ def test_package_initializers_follow_facade_pattern() -> None: violations: list[str] = [] for path in init_files: source = path.read_text(encoding="utf-8") + is_public_facade = "_internal" not in path.parts if not source.strip(): # Empty files are okay if they are just placeholders, # but the user wanted docstrings. @@ -68,6 +107,19 @@ def test_package_initializers_follow_facade_pattern() -> None: violations.append(f"{path.relative_to(repo_root)}: Prohibited assignment (only __all__/__version__ etc allowed)") # Rule 4: Imports are allowed (for re-exporting) + if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names): + violations.append(f"{path.relative_to(repo_root)}: Prohibited star import in package initializer") + continue + + # Rule 6: Public facades must not directly import internal modules. + if is_public_facade: + direct_internal_import = _find_direct_internal_import(node) + if direct_internal_import is not None: + violations.append( + f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({direct_internal_import})" + ) + continue + if isinstance(node, (ast.Import, ast.ImportFrom)): continue diff --git a/tests/unit/test_security_boundary.py b/tests/unit/test_security_boundary.py index 029c7a19..ef6776c0 100644 --- a/tests/unit/test_security_boundary.py +++ b/tests/unit/test_security_boundary.py @@ -2,6 +2,7 @@ import pytest +from dare_framework.security import DefaultSecurityBoundary from dare_framework.security.errors import SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel @@ -44,6 +45,26 @@ async def test_policy_security_boundary_requires_approval_for_high_risk() -> Non assert decision is PolicyDecision.APPROVE_REQUIRED +@pytest.mark.asyncio +async def test_default_security_boundary_remains_permissive_for_high_risk() -> None: + boundary = DefaultSecurityBoundary() + trusted = await boundary.verify_trust( + input={"command": "rm -rf /tmp/foo"}, + context={ + "capability_id": "run_command", + "risk_level": RiskLevel.NON_IDEMPOTENT_EFFECT.value, + "requires_approval": True, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="run_command", + context={"trusted_input": trusted, "capability_id": "run_command", "requires_approval": True}, + ) + + assert decision is PolicyDecision.ALLOW + + @pytest.mark.asyncio async def test_policy_security_boundary_denies_blocked_capability() -> None: boundary = PolicySecurityBoundary(deny_capability_ids={"run_command"}) From 130b6cc87b63ec8d77e0fddd4a679e413c513fee Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 14:09:55 +0800 Subject: [PATCH 2/7] Address PR 211 review findings Preserve structured tool-call payloads during deduplication by incorporating message kind, name, attachments, and data into the compression identity key, so auto-compression no longer drops distinct assistant tool-call history with empty text. Also re-append the transient SmartContext reflection prompt after re-assembling messages for auto-compression, and add regression coverage for both review findings. --- dare_framework/agent/react_agent.py | 2 + dare_framework/compression/core.py | 37 ++++++++++--- tests/unit/test_context_compression.py | 50 +++++++++++++++++ .../test_react_agent_gateway_injection.py | 53 +++++++++++++++++++ 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/dare_framework/agent/react_agent.py b/dare_framework/agent/react_agent.py index d0f2c531..0a28f568 100644 --- a/dare_framework/agent/react_agent.py +++ b/dare_framework/agent/react_agent.py @@ -444,6 +444,8 @@ async def _execute_with_smart_context( else None ) messages = self._context.order_messages_for_llm(messages, sys_prompt_message) + if injected_reflection_prompt is not None: + messages.append(injected_reflection_prompt) # Inject critical_block from plan_provider (maintained by plan tools) # Disabled: skip injection to observe plan agent behavior without it diff --git a/dare_framework/compression/core.py b/dare_framework/compression/core.py index 744e65ee..531e4775 100644 --- a/dare_framework/compression/core.py +++ b/dare_framework/compression/core.py @@ -21,6 +21,25 @@ _UNCHANGED = object() +def _freeze_value(value: Any) -> Any: + """Build a hashable structural key for nested message payloads.""" + if isinstance(value, dict): + return tuple(sorted((str(key), _freeze_value(item)) for key, item in value.items())) + if isinstance(value, list): + return tuple(_freeze_value(item) for item in value) + if isinstance(value, tuple): + return tuple(_freeze_value(item) for item in value) + if hasattr(value, "kind") and hasattr(value, "uri"): + return ( + getattr(value.kind, "value", value.kind), + value.uri, + value.mime_type, + value.filename, + _freeze_value(getattr(value, "metadata", {})), + ) + return value + + def _copy_message( message: Message, *, @@ -41,18 +60,24 @@ def _copy_message( def _dedup_messages(messages: List[Message]) -> Tuple[List[Message], int]: - """Lightweight de-duplication on (role, text).""" - seen: set[int] = set() + """De-duplicate only when the full public message payload matches.""" + seen: set[Any] = set() result: List[Message] = [] removed = 0 for msg in messages: - key = (msg.role, msg.text) - digest = hash(key) - if digest in seen: + key = ( + msg.role, + msg.kind, + msg.text, + msg.name, + _freeze_value(msg.attachments), + _freeze_value(msg.data), + ) + if key in seen: removed += 1 continue - seen.add(digest) + seen.add(key) result.append(msg) return result, removed diff --git a/tests/unit/test_context_compression.py b/tests/unit/test_context_compression.py index 1576fdad..73c626b8 100644 --- a/tests/unit/test_context_compression.py +++ b/tests/unit/test_context_compression.py @@ -142,6 +142,56 @@ def test_compress_context_tool_pair_safe_preserves_assistant_id_and_mark_when_fi } +def test_compress_context_dedup_preserves_distinct_tool_call_payloads() -> None: + ctx = Context(config=Config()) + ctx.stm_add( + Message( + role="assistant", + kind=MessageKind.TOOL_CALL, + text="", + data={"tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}]}, + ) + ) + ctx.stm_add( + Message( + role="tool", + kind=MessageKind.TOOL_RESULT, + name="tc_1", + text='{"success": true}', + data={"success": True}, + ) + ) + ctx.stm_add( + Message( + role="assistant", + kind=MessageKind.TOOL_CALL, + text="", + data={"tool_calls": [{"id": "tc_2", "name": "demo_tool", "arguments": {"x": 2}}]}, + ) + ) + ctx.stm_add( + Message( + role="tool", + kind=MessageKind.TOOL_RESULT, + name="tc_2", + text='{"success": true}', + data={"success": True}, + ) + ) + + compress_context(ctx, strategy="dedup_then_truncate", max_messages=10, tool_pair_safe=True) + + assistant_tool_ids = [ + _tool_ids(message) + for message in ctx.stm_get() + if message.role == "assistant" and message.kind == MessageKind.TOOL_CALL + ] + tool_result_ids = [message.name for message in ctx.stm_get() if message.role == "tool"] + + assert assistant_tool_ids == [["tc_1"], ["tc_2"]] + assert tool_result_ids == ["tc_1", "tc_2"] + + def test_compress_context_target_tokens_trims_long_history() -> None: ctx = Context(config=Config()) for idx in range(8): diff --git a/tests/unit/test_react_agent_gateway_injection.py b/tests/unit/test_react_agent_gateway_injection.py index ff90142e..07ebc2f1 100644 --- a/tests/unit/test_react_agent_gateway_injection.py +++ b/tests/unit/test_react_agent_gateway_injection.py @@ -7,6 +7,7 @@ from dare_framework.agent.react_agent import ReactAgent from dare_framework.config import Config from dare_framework.context import Context +from dare_framework.context.manage_context import MANAGE_CONTEXT_TOOL_NAME from dare_framework.context.types import MessageKind from dare_framework.context.smartcontext import SmartContext from dare_framework.model.types import ModelInput, ModelResponse @@ -178,6 +179,16 @@ async def generate(self, model_input: ModelInput, *, options: Any | None = None) return ModelResponse(content="final", tool_calls=[]) +class _CapturingFinalModel: + def __init__(self) -> None: + self.last_messages: list[Any] | None = None + + async def generate(self, model_input: ModelInput, *, options: Any | None = None) -> ModelResponse: + _ = options + self.last_messages = list(model_input.messages) + return ModelResponse(content="final", tool_calls=[]) + + class _NonConvergingToolModel: def __init__(self) -> None: self._idx = 0 @@ -197,6 +208,21 @@ async def generate(self, model_input: ModelInput, *, options: Any | None = None) ) +class _ManageContextGateway(_RecordingGateway): + def __init__(self) -> None: + super().__init__("manage-context") + self._capabilities = [ + CapabilityDescriptor( + id=MANAGE_CONTEXT_TOOL_NAME, + type=CapabilityType.TOOL, + name=MANAGE_CONTEXT_TOOL_NAME, + description="manage context", + input_schema={"type": "object"}, + output_schema={"type": "object"}, + ) + ] + + @pytest.mark.asyncio async def test_react_agent_prefers_injected_gateway_over_context_gateway() -> None: context_gateway = _RecordingGateway("context") @@ -456,6 +482,33 @@ async def test_react_agent_auto_compress_triggers_in_smart_context_path() -> Non assert context.compress_calls[0].get("tool_pair_safe") is True +@pytest.mark.asyncio +async def test_react_agent_auto_compress_reappends_reflection_prompt_in_smart_context_path() -> None: + context = _CompressionRecordingSmartContext(config=Config()) + context.budget.max_tokens = 100 + context.update_task_complete(True) + gateway = _ManageContextGateway() + model = _CapturingFinalModel() + agent = ReactAgent( + name="react-test-smartcontext-reflection-prompt", + model=model, + context=context, + tool_gateway=gateway, + auto_compress=True, + compress_trigger_ratio=0.01, + compress_target_ratio=0.5, + ) + + result = await agent("smart context compress") + + assert result.success is True + assert model.last_messages is not None + assert any( + (message.text or "") == "【提示】请先调用 manage_context 根据任务初始化 context 状态。" + for message in model.last_messages + ) + + @pytest.mark.asyncio async def test_react_agent_loop_guard_emits_terminal_message_event() -> None: context = Context(config=Config()) From 22cb1da13e93f5b7984b50d20b9cf9a4da850b99 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 14:11:30 +0800 Subject: [PATCH 3/7] Address PR 212 review findings Preserve typed STM checkpoint round-trips by serializing message kind, attachments, data, mark, and id in the compatibility contributor instead of downgrading everything to plain chat messages on restore. Also restore the config-driven constructor contract on dare_framework.security.impl.DefaultSecurityBoundary so callers using from_config(...) still receive a policy-configured boundary, and add regression coverage for both compatibility paths. --- dare_framework/checkpoint/defaults.py | 25 +++++++++ .../impl/default_security_boundary.py | 9 ++- tests/unit/test_checkpoint_defaults.py | 56 ++++++++++++++++++- tests/unit/test_security_boundary.py | 31 +++++++++- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index 6c58557b..faa6abab 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -6,6 +6,7 @@ from __future__ import annotations +from copy import deepcopy from dataclasses import asdict from typing import Any from uuid import uuid4 @@ -50,6 +51,11 @@ def _scope_keys(scope: Any, method_name: str) -> list[str]: return [] +def _clone_payload(value: Any) -> Any: + """Clone nested checkpoint payloads so save/restore stays side-effect free.""" + return deepcopy(value) + + class DefaultCheckpointSaveRestore: """Legacy save/restore coordinator over contributor payload components.""" @@ -98,9 +104,23 @@ def serialize(self, ctx: Any) -> list[dict[str, Any]]: return [ { "role": m.role, + "kind": m.kind, "text": m.text, + "attachments": [ + { + "kind": attachment.kind, + "uri": attachment.uri, + "mime_type": attachment.mime_type, + "filename": attachment.filename, + "metadata": _clone_payload(getattr(attachment, "metadata", {}) or {}), + } + for attachment in m.attachments + ], + "data": _clone_payload(m.data), "name": m.name, "metadata": dict(getattr(m, "metadata", {}) or {}), + "mark": getattr(m, "mark", None), + "id": getattr(m, "id", None), } for m in messages ] @@ -116,9 +136,14 @@ def deserialize_and_apply(self, payload: list[Any], ctx: Any) -> None: context.stm_add( Message( role=item.get("role", "user"), + kind=item.get("kind", "chat"), text=item.get("text") or item.get("content", ""), + attachments=_clone_payload(item.get("attachments")) or [], + data=_clone_payload(item.get("data")), name=item.get("name"), metadata=dict(item.get("metadata") or {}), + mark=item.get("mark", "temporary"), + id=item.get("id"), ) ) diff --git a/dare_framework/security/impl/default_security_boundary.py b/dare_framework/security/impl/default_security_boundary.py index e723783e..e2c0f456 100644 --- a/dare_framework/security/impl/default_security_boundary.py +++ b/dare_framework/security/impl/default_security_boundary.py @@ -262,8 +262,13 @@ async def execute_safe( return await _execute_callable(fn) -# Preserve historical facade behavior where `DefaultSecurityBoundary` is permissive. -DefaultSecurityBoundary = LegacyDefaultSecurityBoundary +class DefaultSecurityBoundary(LegacyDefaultSecurityBoundary): + """Permissive default boundary with legacy `from_config()` compatibility.""" + + @classmethod + def from_config(cls, config: dict[str, Any] | None) -> PolicySecurityBoundary: + """Preserve the historical config-driven constructor contract.""" + return PolicySecurityBoundary.from_config(config) __all__ = [ diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py index d41af2ef..15a09f92 100644 --- a/tests/unit/test_checkpoint_defaults.py +++ b/tests/unit/test_checkpoint_defaults.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from dare_framework.config import Config -from dare_framework.context import Context, Message +from dare_framework.context import AttachmentKind, AttachmentRef, Context, Message, MessageKind, MessageMark def test_checkpoint_defaults_module_exports_are_importable() -> None: @@ -76,3 +76,57 @@ def test_stm_contributor_roundtrip_uses_current_message_text_shape() -> None: assert restored_messages[0].text == "hello" assert restored_messages[0].name == "alice" assert restored_messages[0].metadata == {"trace_id": "t-1"} + + +def test_stm_contributor_roundtrip_preserves_typed_message_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="see attachment", + attachments=[ + AttachmentRef( + kind=AttachmentKind.IMAGE, + uri="https://example.com/a.png", + metadata={"source": "camera"}, + ) + ], + mark=MessageMark.PERSISTENT, + id="user-1", + ) + ) + source_context.stm_add( + Message( + role="assistant", + kind=MessageKind.TOOL_CALL, + text="", + data={"tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}]}, + mark=MessageMark.IMMUTABLE, + id="assistant-1", + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 2 + + assert restored_messages[0].attachments[0].uri == "https://example.com/a.png" + assert restored_messages[0].attachments[0].metadata == {"source": "camera"} + assert restored_messages[0].mark == MessageMark.PERSISTENT + assert restored_messages[0].id == "user-1" + + assert restored_messages[1].kind == MessageKind.TOOL_CALL + assert restored_messages[1].data == { + "tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}] + } + assert restored_messages[1].mark == MessageMark.IMMUTABLE + assert restored_messages[1].id == "assistant-1" diff --git a/tests/unit/test_security_boundary.py b/tests/unit/test_security_boundary.py index ef6776c0..b95859e3 100644 --- a/tests/unit/test_security_boundary.py +++ b/tests/unit/test_security_boundary.py @@ -4,7 +4,11 @@ from dare_framework.security import DefaultSecurityBoundary from dare_framework.security.errors import SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + DefaultSecurityBoundary as ImplDefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.types import PolicyDecision, RiskLevel @@ -65,6 +69,31 @@ async def test_default_security_boundary_remains_permissive_for_high_risk() -> N assert decision is PolicyDecision.ALLOW +@pytest.mark.asyncio +async def test_impl_default_security_boundary_from_config_preserves_policy_constructor() -> None: + boundary = ImplDefaultSecurityBoundary.from_config( + { + "approval_required_risk_levels": [RiskLevel.READ_ONLY.value], + "default_decision": PolicyDecision.DENY.value, + } + ) + trusted = await boundary.verify_trust( + input={"path": "README.md"}, + context={ + "capability_id": "read_file", + "risk_level": RiskLevel.READ_ONLY.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="read_file", + context={"trusted_input": trusted, "capability_id": "read_file"}, + ) + + assert isinstance(boundary, PolicySecurityBoundary) + assert decision is PolicyDecision.APPROVE_REQUIRED + + @pytest.mark.asyncio async def test_policy_security_boundary_denies_blocked_capability() -> None: boundary = PolicySecurityBoundary(deny_capability_ids={"run_command"}) From 7c78e1fb64be9b11f7b8676f5a7dd2b449dd8510 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 14:41:45 +0800 Subject: [PATCH 4/7] Fix zero-message backend compression semantics Make the default in-memory STM treat max_messages=0 as full eviction instead of relying on Python's -0 slice behavior, which previously retained the entire history. Add a Context-level regression test so the canonical basic compression path guarantees explicit zero-message requests clear the default STM. --- dare_framework/memory/in_memory_stm.py | 8 +++++++- tests/unit/test_context_implementation.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/dare_framework/memory/in_memory_stm.py b/dare_framework/memory/in_memory_stm.py index a5e95c62..8035d1f4 100644 --- a/dare_framework/memory/in_memory_stm.py +++ b/dare_framework/memory/in_memory_stm.py @@ -50,7 +50,13 @@ def compress(self, max_messages: int | None = None, **kwargs) -> int: Returns: Number of messages removed. """ - if max_messages is None or len(self._messages) <= max_messages: + if max_messages is None: + return 0 + if max_messages <= 0: + removed_count = len(self._messages) + self._messages = [] + return removed_count + if len(self._messages) <= max_messages: return 0 removed_count = len(self._messages) - max_messages diff --git a/tests/unit/test_context_implementation.py b/tests/unit/test_context_implementation.py index ad74dcdc..2ae37a08 100644 --- a/tests/unit/test_context_implementation.py +++ b/tests/unit/test_context_implementation.py @@ -515,6 +515,16 @@ def _unexpected_compress_context(*args: object, **kwargs: object) -> None: assert [message.text for message in ctx.stm_get()] == ["m1", "m2"] +def test_context_compress_zero_max_messages_clears_default_stm() -> None: + ctx = Context(config=Config()) + ctx.stm_add(Message(role="user", text="m0")) + ctx.stm_add(Message(role="assistant", text="m1")) + + ctx.compress(max_messages=0) + + assert ctx.stm_get() == [] + + def test_context_compress_advanced_path_preserves_backend_semantics(monkeypatch: pytest.MonkeyPatch) -> None: stm = _CompressionRecordingSTM( [ From f32e736e559041ed21285585e548f04a08e50a6a Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 14:43:08 +0800 Subject: [PATCH 5/7] Address additional PR 212 review findings Normalize non-JSON-native payload values such as sets and unhashable dataclass-like objects before deduplication so auto-compression cannot raise TypeError while building message identity keys. Also deep-copy nested checkpoint metadata during STM serialization to preserve point-in-time snapshot semantics, and add regressions for both behaviors. --- dare_framework/checkpoint/defaults.py | 2 +- dare_framework/compression/core.py | 12 ++++++++++ tests/unit/test_checkpoint_defaults.py | 32 ++++++++++++++++++++++++++ tests/unit/test_context_compression.py | 12 ++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index faa6abab..97da438d 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -118,7 +118,7 @@ def serialize(self, ctx: Any) -> list[dict[str, Any]]: ], "data": _clone_payload(m.data), "name": m.name, - "metadata": dict(getattr(m, "metadata", {}) or {}), + "metadata": _clone_payload(getattr(m, "metadata", {}) or {}), "mark": getattr(m, "mark", None), "id": getattr(m, "id", None), } diff --git a/dare_framework/compression/core.py b/dare_framework/compression/core.py index 531e4775..2273eef2 100644 --- a/dare_framework/compression/core.py +++ b/dare_framework/compression/core.py @@ -7,6 +7,7 @@ from __future__ import annotations +from dataclasses import asdict, is_dataclass from typing import TYPE_CHECKING, Any, List, Tuple from dare_framework.context.types import Message as CtxMessage, MessageKind, MessageMark @@ -29,6 +30,11 @@ def _freeze_value(value: Any) -> Any: return tuple(_freeze_value(item) for item in value) if isinstance(value, tuple): return tuple(_freeze_value(item) for item in value) + if isinstance(value, (set, frozenset)): + frozen_items = [_freeze_value(item) for item in value] + return tuple(sorted(frozen_items, key=repr)) + if is_dataclass(value) and not isinstance(value, type): + return ("dataclass", type(value).__qualname__, _freeze_value(asdict(value))) if hasattr(value, "kind") and hasattr(value, "uri"): return ( getattr(value.kind, "value", value.kind), @@ -37,6 +43,12 @@ def _freeze_value(value: Any) -> Any: value.filename, _freeze_value(getattr(value, "metadata", {})), ) + try: + hash(value) + except TypeError: + if hasattr(value, "__dict__"): + return (type(value).__qualname__, _freeze_value(vars(value))) + return (type(value).__qualname__, repr(value)) return value diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py index 15a09f92..c172ad76 100644 --- a/tests/unit/test_checkpoint_defaults.py +++ b/tests/unit/test_checkpoint_defaults.py @@ -130,3 +130,35 @@ def test_stm_contributor_roundtrip_preserves_typed_message_payloads() -> None: } assert restored_messages[1].mark == MessageMark.IMMUTABLE assert restored_messages[1].id == "assistant-1" + + +def test_checkpoint_save_snapshot_deep_copies_nested_message_metadata() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + store = defaults.MemoryCheckpointStore() + saver = defaults.DefaultCheckpointSaveRestore(store, [defaults.StmContributor()]) + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="hello", + metadata={"trace": {"step": 1}}, + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + scope = type( + "Scope", + (), + {"keys_for_save": lambda self: ["stm"], "keys_for_restore": lambda self: ["stm"]}, + )() + + checkpoint_id = saver.save(scope, source_ctx) + + source_context.stm_get()[0].metadata["trace"]["step"] = 99 + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + saver.restore(checkpoint_id, scope, restored_ctx) + + restored_messages = restored_context.stm_get() + assert restored_messages[0].metadata == {"trace": {"step": 1}} diff --git a/tests/unit/test_context_compression.py b/tests/unit/test_context_compression.py index 73c626b8..b0206476 100644 --- a/tests/unit/test_context_compression.py +++ b/tests/unit/test_context_compression.py @@ -192,6 +192,18 @@ def test_compress_context_dedup_preserves_distinct_tool_call_payloads() -> None: assert tool_result_ids == ["tc_1", "tc_2"] +def test_compress_context_dedup_handles_unhashable_payload_values() -> None: + ctx = Context(config=Config()) + ctx.stm_add(Message(role="assistant", text="tool result", data={"values": {1, 2, 3}})) + ctx.stm_add(Message(role="assistant", text="tool result", data={"values": {3, 2, 1}})) + + compress_context(ctx, strategy="dedup_then_truncate", max_messages=10) + + messages = ctx.stm_get() + assert len(messages) == 1 + assert messages[0].data == {"values": {1, 2, 3}} + + def test_compress_context_target_tokens_trims_long_history() -> None: ctx = Context(config=Config()) for idx in range(8): From 35a867019962a7b06ae25d47ca07fcee06bc0711 Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 15:22:19 +0800 Subject: [PATCH 6/7] Harden compression and retrieval budget edge cases Make budget_remaining treat zero-valued limits as finite instead of falling back to infinity, so strict zero-token runs correctly degrade retrieval instead of fetching LTM/knowledge under an exhausted budget. Also make compression dedup keys robust for unhashable payload values, and add regressions for both the zero-budget retrieval path and non-JSON-native payload deduplication. --- dare_framework/context/context.py | 12 ++++++++---- tests/unit/test_context_implementation.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/dare_framework/context/context.py b/dare_framework/context/context.py index 6c05a139..a6b5ebbf 100644 --- a/dare_framework/context/context.py +++ b/dare_framework/context/context.py @@ -176,13 +176,17 @@ def budget_remaining(self, resource: str) -> float: """Get remaining budget for a resource.""" b = self._budget if resource == "tokens": - return (b.max_tokens - b.used_tokens) if b.max_tokens else float("inf") + return (b.max_tokens - b.used_tokens) if b.max_tokens is not None else float("inf") elif resource == "cost": - return (b.max_cost - b.used_cost) if b.max_cost else float("inf") + return (b.max_cost - b.used_cost) if b.max_cost is not None else float("inf") elif resource == "tool_calls": - return (b.max_tool_calls - b.used_tool_calls) if b.max_tool_calls else float("inf") + return (b.max_tool_calls - b.used_tool_calls) if b.max_tool_calls is not None else float("inf") elif resource == "time_seconds": - return (b.max_time_seconds - b.used_time_seconds) if b.max_time_seconds else float("inf") + return ( + (b.max_time_seconds - b.used_time_seconds) + if b.max_time_seconds is not None + else float("inf") + ) return float("inf") # ========== Tool Methods ========== diff --git a/tests/unit/test_context_implementation.py b/tests/unit/test_context_implementation.py index 2ae37a08..43488ef4 100644 --- a/tests/unit/test_context_implementation.py +++ b/tests/unit/test_context_implementation.py @@ -201,6 +201,26 @@ def test_context_assemble_degrades_when_token_budget_low(): assert assembled.metadata["retrieval"]["degrade_reason"] == "token_budget_low" +def test_context_assemble_zero_token_budget_skips_retrieval() -> None: + ltm = _FakeRetrieval([Message(role="assistant", text="ltm-hit")]) + knowledge = _FakeRetrieval([Message(role="assistant", text="knowledge-hit")]) + ctx = Context( + config=Config(), + budget=Budget(max_tokens=0), + long_term_memory=ltm, + knowledge=knowledge, + ) + ctx.stm_add(Message(role="user", text="query")) + + assembled = ctx.assemble() + + assert [message.text for message in assembled.messages] == ["query"] + assert assembled.metadata["retrieval"]["degraded"] is True + assert assembled.metadata["retrieval"]["degrade_reason"] == "token_budget_low" + assert ltm.calls == [] + assert knowledge.calls == [] + + def test_context_assemble_handles_retrieval_exception_gracefully(): ltm = _FakeRetrieval([Message(role="assistant", text="ltm-hit")], fail=True) knowledge = _FakeRetrieval([Message(role="assistant", text="knowledge-hit")]) From 5362ee5a8aa5516095163d96d8d1d737912d00ed Mon Sep 17 00:00:00 2001 From: mindfn Date: Tue, 10 Mar 2026 15:23:26 +0800 Subject: [PATCH 7/7] Deep-copy checkpoint store payload snapshots Clone payloads on both write and read in MemoryCheckpointStore so nested checkpoint state cannot be mutated through restored objects or repeated reads. Add a regression that restores the same checkpoint twice and verifies mutating the first restored context does not affect the second restore. --- dare_framework/checkpoint/defaults.py | 4 +-- tests/unit/test_checkpoint_defaults.py | 34 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py index 97da438d..8abfdf5f 100644 --- a/dare_framework/checkpoint/defaults.py +++ b/dare_framework/checkpoint/defaults.py @@ -26,12 +26,12 @@ def __init__(self) -> None: self._store: dict[str, dict[str, Any]] = {} def put(self, checkpoint_id: str, payload: dict[str, Any]) -> None: - self._store[checkpoint_id] = dict(payload) + self._store[checkpoint_id] = _clone_payload(payload) def get(self, checkpoint_id: str) -> dict[str, Any] | None: if checkpoint_id not in self._store: return None - return dict(self._store[checkpoint_id]) + return _clone_payload(self._store[checkpoint_id]) def delete(self, checkpoint_id: str) -> bool: if checkpoint_id in self._store: diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py index c172ad76..cb6854e9 100644 --- a/tests/unit/test_checkpoint_defaults.py +++ b/tests/unit/test_checkpoint_defaults.py @@ -162,3 +162,37 @@ def test_checkpoint_save_snapshot_deep_copies_nested_message_metadata() -> None: restored_messages = restored_context.stm_get() assert restored_messages[0].metadata == {"trace": {"step": 1}} + + +def test_checkpoint_store_get_returns_deep_copied_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + store = defaults.MemoryCheckpointStore() + saver = defaults.DefaultCheckpointSaveRestore(store, [defaults.StmContributor()]) + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="hello", + metadata={"trace": {"step": 1}}, + ) + ) + scope = type( + "Scope", + (), + {"keys_for_save": lambda self: ["stm"], "keys_for_restore": lambda self: ["stm"]}, + )() + source_ctx = type("Ctx", (), {"context": source_context})() + + checkpoint_id = saver.save(scope, source_ctx) + + restored_context_a = Context(config=Config()) + restored_ctx_a = type("Ctx", (), {"context": restored_context_a})() + saver.restore(checkpoint_id, scope, restored_ctx_a) + restored_context_a.stm_get()[0].metadata["trace"]["step"] = 77 + + restored_context_b = Context(config=Config()) + restored_ctx_b = type("Ctx", (), {"context": restored_context_b})() + saver.restore(checkpoint_id, scope, restored_ctx_b) + + assert restored_context_b.stm_get()[0].metadata == {"trace": {"step": 1}}