Skip to content

Commit 216f03f

Browse files
authored
Merge pull request #102 from fcenedes/working_memory_with_ts
Require messages in working memory to contain timestamps
2 parents 8a06b27 + dbd2325 commit 216f03f

File tree

10 files changed

+541
-37
lines changed

10 files changed

+541
-37
lines changed

agent-memory-client/agent_memory_client/models.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
For full model definitions, see the main agent_memory_server package.
66
"""
77

8-
from datetime import datetime, timezone
8+
import logging
9+
import threading
10+
from datetime import datetime, timedelta, timezone
911
from enum import Enum
10-
from typing import Any, Literal
12+
from typing import Any, ClassVar, Literal
1113

12-
from pydantic import BaseModel, Field
14+
from pydantic import BaseModel, Field, model_validator
1315
from ulid import ULID
1416

17+
logger = logging.getLogger(__name__)
18+
1519
# Model name literals for model-specific window sizes
1620
ModelNameLiteral = Literal[
1721
"gpt-3.5-turbo",
@@ -62,6 +66,15 @@ class MemoryStrategyConfig(BaseModel):
6266
class MemoryMessage(BaseModel):
6367
"""A message in the memory system"""
6468

69+
# Track message IDs that have been warned (in-memory, per-process)
70+
# Used to rate-limit deprecation warnings
71+
_warned_message_ids: ClassVar[set[str]] = set()
72+
_warned_message_ids_lock: ClassVar[threading.Lock] = threading.Lock()
73+
_max_warned_ids: ClassVar[int] = 10000 # Prevent unbounded growth
74+
75+
# Default tolerance for future timestamp validation (5 minutes)
76+
_max_future_seconds: ClassVar[int] = 300
77+
6578
role: str
6679
content: str
6780
id: str = Field(
@@ -70,7 +83,7 @@ class MemoryMessage(BaseModel):
7083
)
7184
created_at: datetime = Field(
7285
default_factory=lambda: datetime.now(timezone.utc),
73-
description="Timestamp when the message was created",
86+
description="Timestamp when the message was created (should be provided by client)",
7487
)
7588
persisted_at: datetime | None = Field(
7689
default=None,
@@ -81,6 +94,72 @@ class MemoryMessage(BaseModel):
8194
description="Whether memory extraction has run for this message",
8295
)
8396

97+
@model_validator(mode="before")
98+
@classmethod
99+
def validate_created_at(cls, data: Any) -> Any:
100+
"""
101+
Validate created_at timestamp:
102+
- Warn if not provided by client (will become required in future version)
103+
- Error if timestamp is in the future (beyond tolerance)
104+
"""
105+
if not isinstance(data, dict):
106+
return data
107+
108+
created_at_provided = "created_at" in data and data["created_at"] is not None
109+
110+
if not created_at_provided:
111+
# Rate-limit warnings by message ID (thread-safe)
112+
msg_id = data.get("id", "unknown")
113+
114+
with cls._warned_message_ids_lock:
115+
if msg_id not in cls._warned_message_ids:
116+
# Prevent unbounded memory growth
117+
if len(cls._warned_message_ids) >= cls._max_warned_ids:
118+
cls._warned_message_ids.clear()
119+
cls._warned_message_ids.add(msg_id)
120+
should_warn = True
121+
else:
122+
should_warn = False
123+
124+
if should_warn:
125+
logger.warning(
126+
"MemoryMessage created without explicit created_at timestamp. "
127+
"This will become required in a future version. "
128+
"Please provide created_at for accurate message ordering."
129+
)
130+
else:
131+
# Validate that created_at is not in the future
132+
created_at_value = data["created_at"]
133+
134+
# Parse string to datetime if needed
135+
if isinstance(created_at_value, str):
136+
try:
137+
# Handle ISO format with Z suffix
138+
created_at_value = datetime.fromisoformat(
139+
created_at_value.replace("Z", "+00:00")
140+
)
141+
except ValueError:
142+
# Let Pydantic handle the parsing error
143+
return data
144+
145+
if isinstance(created_at_value, datetime):
146+
# Ensure timezone-aware comparison
147+
now = datetime.now(timezone.utc)
148+
if created_at_value.tzinfo is None:
149+
# Assume UTC for naive datetimes
150+
created_at_value = created_at_value.replace(tzinfo=timezone.utc)
151+
152+
max_allowed = now + timedelta(seconds=cls._max_future_seconds)
153+
154+
if created_at_value > max_allowed:
155+
raise ValueError(
156+
f"created_at cannot be more than {cls._max_future_seconds} seconds in the future. "
157+
f"Received: {created_at_value.isoformat()}, "
158+
f"Max allowed: {max_allowed.isoformat()}"
159+
)
160+
161+
return data
162+
84163

85164
class MemoryRecord(BaseModel):
86165
"""A memory record"""

agent_memory_server/api.py

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Any
33

44
import tiktoken
5-
from fastapi import APIRouter, Depends, Header, HTTPException, Query
5+
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
66
from mcp.server.fastmcp.prompts import base
77
from mcp.types import TextContent
88

@@ -452,39 +452,28 @@ async def get_working_memory(
452452
return WorkingMemoryResponse(**working_mem_data)
453453

454454

455-
@router.put("/v1/working-memory/{session_id}", response_model=WorkingMemoryResponse)
456-
async def put_working_memory(
455+
async def put_working_memory_core(
457456
session_id: str,
458457
memory: UpdateWorkingMemory,
459458
background_tasks: HybridBackgroundTasks,
460459
model_name: ModelNameLiteral | None = None,
461460
context_window_max: int | None = None,
462-
current_user: UserInfo = Depends(get_current_user),
463-
):
461+
) -> WorkingMemoryResponse:
464462
"""
465-
Set working memory for a session. Replaces existing working memory.
466-
467-
The session_id comes from the URL path, not the request body.
468-
If the token count exceeds the context window threshold, messages will be summarized
469-
immediately and the updated memory state returned to the client.
463+
Core implementation of put_working_memory.
470464
471-
NOTE on context_percentage_* fields:
472-
The response includes `context_percentage_total_used` and `context_percentage_until_summarization`
473-
fields that show token usage. These fields will be `null` unless you provide either:
474-
- `model_name` query parameter (e.g., `?model_name=gpt-4o-mini`)
475-
- `context_window_max` query parameter (e.g., `?context_window_max=500`)
465+
This function contains the business logic for setting working memory and can be
466+
called from both the REST API endpoint and MCP tools.
476467
477468
Args:
478-
session_id: The session ID (from URL path)
479-
memory: Working memory data to save (session_id not required in body)
469+
session_id: The session ID
470+
memory: Working memory data to save
471+
background_tasks: Background tasks handler
480472
model_name: The client's LLM model name for context window determination
481-
context_window_max: Direct specification of context window max tokens (overrides model_name)
482-
background_tasks: DocketBackgroundTasks instance (injected automatically)
473+
context_window_max: Direct specification of context window max tokens
483474
484475
Returns:
485-
Updated working memory (potentially with summary if tokens were condensed).
486-
Includes context_percentage_total_used and context_percentage_until_summarization
487-
if model information is provided.
476+
Updated working memory response
488477
"""
489478
redis = await get_redis_conn()
490479

@@ -557,6 +546,61 @@ async def put_working_memory(
557546
return WorkingMemoryResponse(**updated_memory_data)
558547

559548

549+
@router.put("/v1/working-memory/{session_id}", response_model=WorkingMemoryResponse)
550+
async def put_working_memory(
551+
session_id: str,
552+
memory: UpdateWorkingMemory,
553+
background_tasks: HybridBackgroundTasks,
554+
response: Response,
555+
model_name: ModelNameLiteral | None = None,
556+
context_window_max: int | None = None,
557+
current_user: UserInfo = Depends(get_current_user),
558+
):
559+
"""
560+
Set working memory for a session. Replaces existing working memory.
561+
562+
The session_id comes from the URL path, not the request body.
563+
If the token count exceeds the context window threshold, messages will be summarized
564+
immediately and the updated memory state returned to the client.
565+
566+
NOTE on context_percentage_* fields:
567+
The response includes `context_percentage_total_used` and `context_percentage_until_summarization`
568+
fields that show token usage. These fields will be `null` unless you provide either:
569+
- `model_name` query parameter (e.g., `?model_name=gpt-4o-mini`)
570+
- `context_window_max` query parameter (e.g., `?context_window_max=500`)
571+
572+
Args:
573+
session_id: The session ID (from URL path)
574+
memory: Working memory data to save (session_id not required in body)
575+
model_name: The client's LLM model name for context window determination
576+
context_window_max: Direct specification of context window max tokens (overrides model_name)
577+
background_tasks: DocketBackgroundTasks instance (injected automatically)
578+
response: FastAPI Response object for setting headers
579+
580+
Returns:
581+
Updated working memory (potentially with summary if tokens were condensed).
582+
Includes context_percentage_total_used and context_percentage_until_summarization
583+
if model information is provided.
584+
"""
585+
# Check if any messages are missing created_at timestamps and add deprecation header
586+
messages_missing_timestamp = any(
587+
not getattr(msg, "_created_at_was_provided", True) for msg in memory.messages
588+
)
589+
if messages_missing_timestamp:
590+
response.headers["X-Deprecation-Warning"] = (
591+
"messages[].created_at will become required in the next major version. "
592+
"Please provide timestamps for all messages."
593+
)
594+
595+
return await put_working_memory_core(
596+
session_id=session_id,
597+
memory=memory,
598+
background_tasks=background_tasks,
599+
model_name=model_name,
600+
context_window_max=context_window_max,
601+
)
602+
603+
560604
@router.delete("/v1/working-memory/{session_id}", response_model=AckResponse)
561605
async def delete_working_memory(
562606
session_id: str,

agent_memory_server/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,13 @@ class Settings(BaseSettings):
314314
0.7 # Fraction of context window that triggers summarization
315315
)
316316

317+
# Message timestamp validation settings
318+
# If true, reject messages without created_at timestamp.
319+
# If false (default), auto-generate timestamp with deprecation warning.
320+
require_message_timestamps: bool = False
321+
# Maximum allowed clock skew for future timestamp validation (in seconds)
322+
max_future_timestamp_seconds: int = 300 # 5 minutes
323+
317324
# Working memory migration settings
318325
# Set to True to skip backward compatibility checks for old string-format keys.
319326
# Use this after running 'agent-memory migrate-working-memory' or for fresh installs.

agent_memory_server/mcp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
get_long_term_memory as core_get_long_term_memory,
1212
get_working_memory as core_get_working_memory,
1313
memory_prompt as core_memory_prompt,
14-
put_working_memory as core_put_working_memory,
14+
put_working_memory_core as core_put_working_memory,
1515
search_long_term_memory as core_search_long_term_memory,
1616
update_long_term_memory as core_update_long_term_memory,
1717
)

0 commit comments

Comments
 (0)