Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sdk-playground/python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/agent-framework-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "supermemory-agent-framework"
version = "1.0.1"
version = "1.0.2"
description = "Memory tools and middleware for Microsoft Agent Framework with supermemory"
readme = "README.md"
license = "MIT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from typing import Any, Literal

from agent_framework import Message

try:
from agent_framework import BaseContextProvider # type: ignore[attr-defined]
except ImportError:
Expand Down Expand Up @@ -149,12 +151,12 @@ async def before_run(

# Use extend_instructions to add memory context
if hasattr(context, "extend_instructions"):
context.extend_instructions(full_text, source=self.source_id)
context.extend_instructions(self.source_id, full_text)
elif hasattr(context, "extend_messages"):
# Fallback: add as a system message
context.extend_messages(
[{"role": "system", "content": full_text}],
source=self.source_id,
self.source_id,
[Message("system", [full_text])],
)

async def after_run(
Expand Down Expand Up @@ -217,8 +219,8 @@ async def _fetch_memories(self, query_text: str = "") -> str:
)

deduplicated = deduplicate_memories(
static=static,
dynamic=dynamic,
static=static if self._mode != "query" else [],
dynamic=dynamic if self._mode != "query" else [],
search_results=search_results_raw,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Any, Awaitable, Callable, Literal, Optional

import supermemory
from agent_framework import ChatMiddleware, Message
from agent_framework import ChatMiddleware, Content, Message

from .connection import AgentSupermemory
from .exceptions import (
Expand All @@ -21,6 +21,8 @@
convert_profile_to_markdown,
create_logger,
deduplicate_memories,
replace_memory_injection,
strip_memory_injection,
wrap_memory_injection,
)

Expand Down Expand Up @@ -152,8 +154,8 @@ async def _build_memories_text(
)

deduplicated = deduplicate_memories(
static=static,
dynamic=dynamic,
static=static if mode != "query" else [],
dynamic=dynamic if mode != "query" else [],
search_results=search_results_raw,
)

Expand Down Expand Up @@ -272,6 +274,9 @@ async def process(
call_next: Callable[[], Awaitable[None]],
) -> None:
"""Process the chat request by injecting memories and optionally saving conversations."""
# Remove stale SDK-owned context before every lifecycle path. A failed,
# empty, or skipped lookup must never leak memories from a prior run.
_inject_memories(context, "")
messages = context.messages

# Save conversation memory in background if configured
Expand Down Expand Up @@ -386,36 +391,228 @@ async def wait_for_background_tasks(
raise


def _update_structured_content(
content: Any,
memories: str,
*,
inject: bool,
) -> tuple[Any, bool, bool]:
"""Clear owned blocks from string/dict content and optionally inject one."""
if isinstance(content, str):
updated = (
replace_memory_injection(content, memories)
if inject
else strip_memory_injection(content)
)
return updated, inject, updated != content

if isinstance(content, (list, tuple)):
updated_parts: list[Any] = []
removed_owned_block = False
for part in content:
if isinstance(part, str):
cleaned = strip_memory_injection(part)
removed_owned_block = removed_owned_block or cleaned != part
if cleaned or cleaned == part:
updated_parts.append(cleaned)
continue

if isinstance(part, dict) and isinstance(part.get("text"), str):
original_text = part["text"]
cleaned_text = strip_memory_injection(original_text)
removed_owned_block = (
removed_owned_block or cleaned_text != original_text
)
if cleaned_text or cleaned_text == original_text:
if cleaned_text == original_text:
updated_parts.append(part)
else:
updated_parts.append({**part, "text": cleaned_text})
continue

updated_parts.append(part)

if inject:
updated_parts.append(
{"type": "text", "text": wrap_memory_injection(memories)}
)

if isinstance(content, tuple):
return tuple(updated_parts), inject, removed_owned_block
return updated_parts, inject, removed_owned_block

if content is None and inject:
return wrap_memory_injection(memories), True, False

return content, False, False


def _update_framework_message(
msg: Any,
memories: str,
*,
inject: bool,
) -> tuple[bool, bool]:
"""Update real Agent Framework Message contents without assigning .text."""
try:
contents = list(msg.contents or [])
except (AttributeError, TypeError):
return False, False

updated_contents = []
removed_owned_block = False
for content in contents:
text = getattr(content, "text", None)
if getattr(content, "type", None) == "text" and isinstance(text, str):
cleaned = strip_memory_injection(text)
removed_owned_block = removed_owned_block or cleaned != text
if cleaned or cleaned == text:
if cleaned != text:
content.text = cleaned
updated_contents.append(content)
continue

updated_contents.append(content)

if inject:
updated_contents.append(Content.from_text(wrap_memory_injection(memories)))

try:
msg.contents = updated_contents
except (AttributeError, TypeError):
try:
msg.contents[:] = updated_contents
except (AttributeError, TypeError):
return False, False

return inject, removed_owned_block and not updated_contents


def _is_empty_content(content: Any) -> bool:
"""Return whether stripping an owned block left no message content."""
return (
content is None
or content == ""
or (isinstance(content, (list, tuple)) and not content)
)


def _inject_memories(context: Any, memories: str) -> None:
"""Inject memories into the chat context messages.

Handles both object-based and dict-based message formats used by
different Agent Framework providers.
"""
messages = context.messages
memory_text = f"\n\n{wrap_memory_injection(memories)}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale memory blocks not cleared

Medium Severity

_inject_memories now replaces owned <supermemory> blocks, but process only calls it when memories is truthy. On a later turn with an empty retrieval, prior SDK blocks stay in context.messages, so stale memory context keeps going to the model. The OpenAI path in this same PR still runs replace/strip when a system message exists even if new memories are empty.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 42f308b. Configure here.

should_inject = bool(memories.strip())
memory_text = wrap_memory_injection(memories) if should_inject else ""

# Try to find and augment existing system message
for i, msg in enumerate(messages):
# Replace prior SDK blocks in every system message and inject once.
injected = False
messages_to_remove: list[Any] = []
for msg in list(messages):
role = None
if hasattr(msg, "role"):
role = msg.role
elif isinstance(msg, dict):
role = msg.get("role")

if role == "system":
if hasattr(msg, "text"):
msg.text = (msg.text or "") + memory_text
elif hasattr(msg, "content"):
msg.content = (msg.content or "") + memory_text
inject_here = should_inject and not injected
injected_here = False
remove_here = False

if hasattr(msg, "contents"):
injected_here, remove_here = _update_framework_message(
msg,
memories,
inject=inject_here,
)
elif isinstance(msg, dict):
msg["content"] = (msg.get("content", "") or "") + memory_text
return
content_key = "content" if "content" in msg else "text"
updated, injected_here, removed_owned_block = (
_update_structured_content(
msg.get(content_key),
memories,
inject=inject_here,
)
)
msg[content_key] = updated
remove_here = (
not inject_here
and removed_owned_block
and _is_empty_content(updated)
)
elif hasattr(msg, "content"):
updated, injected_here, removed_owned_block = (
_update_structured_content(
msg.content,
memories,
inject=inject_here,
)
)
try:
msg.content = updated
except (AttributeError, TypeError):
injected_here = False
else:
remove_here = (
not inject_here
and removed_owned_block
and _is_empty_content(updated)
)
elif hasattr(msg, "text"):
updated, injected_here, removed_owned_block = (
_update_structured_content(
msg.text,
memories,
inject=inject_here,
)
)
try:
msg.text = updated
except (AttributeError, TypeError):
injected_here = False
else:
remove_here = (
not inject_here
and removed_owned_block
and _is_empty_content(updated)
)

injected = injected or injected_here
if remove_here:
messages_to_remove.append(msg)

if messages_to_remove:
retained_messages = [
msg
for msg in messages
if not any(msg is removed for removed in messages_to_remove)
]
try:
messages[:] = retained_messages
except (AttributeError, TypeError):
try:
context.messages = retained_messages
messages = context.messages
except (AttributeError, TypeError):
pass

if injected or not should_inject:
return

# No system message found - prepend one
new_message: Any
if any(isinstance(msg, dict) for msg in messages):
new_message = {"role": "system", "content": memory_text}
else:
new_message = Message("system", [memory_text])

try:
if isinstance(messages, list):
messages.insert(0, Message("system", [memories]))
except Exception:
# If messages is immutable, log a warning
pass
messages.insert(0, new_message)
except (AttributeError, TypeError):
try:
context.messages = [new_message, *list(messages)]
except (AttributeError, TypeError):
pass
Loading
Loading