Skip to content

Commit 6b98c25

Browse files
committed
Merge branch 'main' into summaries
2 parents 3412954 + 216f03f commit 6b98c25

File tree

13 files changed

+637
-47
lines changed

13 files changed

+637
-47
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
from ulid import ULID
@@ -470,39 +470,28 @@ async def get_working_memory(
470470
return WorkingMemoryResponse(**working_mem_data)
471471

472472

473-
@router.put("/v1/working-memory/{session_id}", response_model=WorkingMemoryResponse)
474-
async def put_working_memory(
473+
async def put_working_memory_core(
475474
session_id: str,
476475
memory: UpdateWorkingMemory,
477476
background_tasks: HybridBackgroundTasks,
478477
model_name: ModelNameLiteral | None = None,
479478
context_window_max: int | None = None,
480-
current_user: UserInfo = Depends(get_current_user),
481-
):
479+
) -> WorkingMemoryResponse:
482480
"""
483-
Set working memory for a session. Replaces existing working memory.
484-
485-
The session_id comes from the URL path, not the request body.
486-
If the token count exceeds the context window threshold, messages will be summarized
487-
immediately and the updated memory state returned to the client.
481+
Core implementation of put_working_memory.
488482
489-
NOTE on context_percentage_* fields:
490-
The response includes `context_percentage_total_used` and `context_percentage_until_summarization`
491-
fields that show token usage. These fields will be `null` unless you provide either:
492-
- `model_name` query parameter (e.g., `?model_name=gpt-4o-mini`)
493-
- `context_window_max` query parameter (e.g., `?context_window_max=500`)
483+
This function contains the business logic for setting working memory and can be
484+
called from both the REST API endpoint and MCP tools.
494485
495486
Args:
496-
session_id: The session ID (from URL path)
497-
memory: Working memory data to save (session_id not required in body)
487+
session_id: The session ID
488+
memory: Working memory data to save
489+
background_tasks: Background tasks handler
498490
model_name: The client's LLM model name for context window determination
499-
context_window_max: Direct specification of context window max tokens (overrides model_name)
500-
background_tasks: DocketBackgroundTasks instance (injected automatically)
491+
context_window_max: Direct specification of context window max tokens
501492
502493
Returns:
503-
Updated working memory (potentially with summary if tokens were condensed).
504-
Includes context_percentage_total_used and context_percentage_until_summarization
505-
if model information is provided.
494+
Updated working memory response
506495
"""
507496
redis = await get_redis_conn()
508497

@@ -575,6 +564,61 @@ async def put_working_memory(
575564
return WorkingMemoryResponse(**updated_memory_data)
576565

577566

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