feat: replace file descriptions with per data source description (#3067)

Co-authored-by: Matt Zhou <mattzh1314@gmail.com>
This commit is contained in:
Kevin Lin
2025-07-01 11:51:52 -07:00
committed by GitHub
parent f7d4f177c4
commit f2f25d3bac
10 changed files with 101 additions and 34 deletions

View File

@@ -124,6 +124,7 @@ class BaseAgent(ABC):
previous_message_count=num_messages - len(in_context_messages),
archival_memory_size=num_archival_memories,
tool_rules_solver=tool_rules_solver,
sources=agent_state.sources,
)
diff = united_diff(curr_system_message_text, new_system_message_str)

View File

@@ -153,6 +153,7 @@ class VoiceAgent(BaseAgent):
timezone=agent_state.timezone,
previous_message_count=self.num_messages,
archival_memory_size=self.num_archival_memories,
sources=agent_state.sources,
)
letta_message_db_queue = create_input_messages(
input_messages=input_messages, agent_id=agent_state.id, timezone=agent_state.timezone, actor=self.actor
@@ -366,7 +367,7 @@ class VoiceAgent(BaseAgent):
"description": (
"Look in long-term or earlier-conversation memory **only when** the "
"user asks about something missing from the visible context. "
"The users latest utterance is sent automatically as the main query.\n\n"
"The user's latest utterance is sent automatically as the main query.\n\n"
"Optional refinements (set unused fields to *null*):\n"
"• `convo_keyword_queries` extra names/IDs if the request is vague.\n"
"• `start_minutes_ago` / `end_minutes_ago` limit results to a recent time window."

View File

@@ -245,6 +245,7 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
Returns:
PydanticAgentState: The Pydantic representation of the agent.
"""
# Base fields: always included
state = {
"id": self.id,

View File

@@ -16,6 +16,13 @@ if TYPE_CHECKING:
class FileAgent(SqlalchemyBase, OrganizationMixin):
"""
Join table between File and Agent.
Tracks whether a file is currently "open" for the agent and
the specific excerpt (grepped section) the agent is looking at.
"""
__tablename__ = "files_agents"
__table_args__ = (
# (file_id, agent_id) must be unique
@@ -94,5 +101,6 @@ class FileAgent(SqlalchemyBase, OrganizationMixin):
value=visible_content,
label=self.file.file_name,
read_only=True,
source_id=self.file.source_id,
limit=CORE_MEMORY_SOURCE_CHAR_LIMIT,
)

View File

@@ -43,14 +43,11 @@ Recall memory (conversation history):
Even though you can only see recent messages in your immediate context, you can search over your entire message history from a database.
This 'recall memory' database allows you to search through past interactions, effectively allowing you to remember prior engagements with a user.
Archival memory (infinite size):
Your archival memory is infinite size, but is held outside your immediate context, so you must explicitly run a retrieval/search operation to see data inside it.
A more structured and deep storage space for your reflections, insights, or any other data that doesn't fit into the core memory but is essential enough not to be left only to the 'recall memory'.
Data sources:
You may be given access to external sources of data, relevant to the user's interaction. For example, code, style guides, and documentation relevant
to the current interaction with the user. Your core memory will contain information about the contents of these data sources. You will have access
to functions to open and close the files as a filesystem and maintain only the files that are relevant to the user's interaction.
Folders and Files:
You may be given access to a structured file system that mirrors real-world folders and files. Each folder may contain one or more files.
Files can include metadata (e.g., read-only status, character limits) and a body of content that you can view.
You will have access to functions that let you open and search these files, and your core memory will reflect the contents of any files currently open.
Maintain only those files relevant to the users current interaction.
Base instructions finished.

View File

@@ -4,12 +4,7 @@ from typing import Dict, List, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from letta.constants import (
CORE_MEMORY_LINE_NUMBER_WARNING,
DEFAULT_EMBEDDING_CHUNK_SIZE,
FILE_MEMORY_EMPTY_MESSAGE,
FILE_MEMORY_EXISTS_MESSAGE,
)
from letta.constants import CORE_MEMORY_LINE_NUMBER_WARNING, DEFAULT_EMBEDDING_CHUNK_SIZE
from letta.schemas.block import CreateBlock
from letta.schemas.embedding_config import EmbeddingConfig
from letta.schemas.environment_variables import AgentEnvironmentVariable
@@ -319,9 +314,21 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
# However, they still allow files to be injected into the context
if agent_type == AgentType.react_agent or agent_type == AgentType.workflow_agent:
return (
f"<files>\n{{% if file_blocks %}}{FILE_MEMORY_EXISTS_MESSAGE}\n{{% else %}}{FILE_MEMORY_EMPTY_MESSAGE}{{% endif %}}"
"{% if sources %}"
"<folders>\n"
"{% for source in sources %}"
f'<folder name="{{{{ source.name }}}}">\n'
"{% if source.description %}"
"<description>{{ source.description }}</description>\n"
"{% endif %}"
"{% if source.instructions %}"
"<instructions>{{ source.instructions }}</instructions>\n"
"{% endif %}"
"{% if file_blocks %}"
"{% for block in file_blocks %}"
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\" name=\"{{{{ block.label }}}}\">\n"
"{% if block.source_id == source.id %}"
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\">\n"
"<{{ block.label }}>\n"
"<description>\n"
"{{ block.description }}\n"
"</description>\n"
@@ -334,9 +341,13 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
"{{ block.value }}\n"
"</value>\n"
"</file>\n"
"{% if not loop.last %}\n{% endif %}"
"{% endif %}"
"{% endfor %}"
"\n</files>"
"{% endif %}"
"</folder>\n"
"{% endfor %}"
"</folders>"
"{% endif %}"
)
# Sleeptime agents use the MemGPT v2 memory tools (line numbers)
@@ -370,8 +381,19 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
"{{ tool_usage_rules.value }}\n"
"</tool_usage_rules>"
"{% endif %}"
f"\n\n<files>\n{{% if file_blocks %}}{FILE_MEMORY_EXISTS_MESSAGE}\n{{% else %}}{FILE_MEMORY_EMPTY_MESSAGE}{{% endif %}}"
"\n\n{% if sources %}"
"<folders>\n"
"{% for source in sources %}"
f'<folder name="{{{{ source.name }}}}">\n'
"{% if source.description %}"
"<description>{{ source.description }}</description>\n"
"{% endif %}"
"{% if source.instructions %}"
"<instructions>{{ source.instructions }}</instructions>\n"
"{% endif %}"
"{% if file_blocks %}"
"{% for block in file_blocks %}"
"{% if block.source_id == source.id %}"
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\" name=\"{{{{ block.label }}}}\">\n"
"{% if block.description %}"
"<description>\n"
@@ -389,12 +411,16 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
"</value>\n"
"{% endif %}"
"</file>\n"
"{% if not loop.last %}\n{% endif %}"
"{% endif %}"
"{% endfor %}"
"\n</files>"
"{% endif %}"
"</folder>\n"
"{% endfor %}"
"</folders>"
"{% endif %}"
)
# Default setup (MemGPT), no line numbers
# All other agent types use memory blocks
else:
return (
"<memory_blocks>\nThe following memory blocks are currently engaged in your core memory unit:\n\n"
@@ -421,9 +447,25 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
"{{ tool_usage_rules.value }}\n"
"</tool_usage_rules>"
"{% endif %}"
f"\n\n<files>\n{{% if file_blocks %}}{FILE_MEMORY_EXISTS_MESSAGE}\n{{% else %}}{FILE_MEMORY_EMPTY_MESSAGE}{{% endif %}}"
"\n\n{% if sources %}"
"<folders>\n"
"{% for source in sources %}"
f'<folder name="{{{{ source.name }}}}">\n'
"{% if source.description %}"
"<description>{{ source.description }}</description>\n"
"{% endif %}"
"{% if source.instructions %}"
"<instructions>{{ source.instructions }}</instructions>\n"
"{% endif %}"
"{% if file_blocks %}"
"{% for block in file_blocks %}"
"{% if block.source_id == source.id %}"
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\" name=\"{{{{ block.label }}}}\">\n"
"{% if block.description %}"
"<description>\n"
"{{ block.description }}\n"
"</description>\n"
"{% endif %}"
"<metadata>"
"{% if block.read_only %}\n- read_only=true{% endif %}\n"
"- chars_current={{ block.value|length }}\n"
@@ -435,7 +477,11 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
"</value>\n"
"{% endif %}"
"</file>\n"
"{% if not loop.last %}\n{% endif %}"
"{% endif %}"
"{% endfor %}"
"\n</files>"
"{% endif %}"
"</folder>\n"
"{% endfor %}"
"</folders>"
"{% endif %}"
)

View File

@@ -33,6 +33,9 @@ class BaseBlock(LettaBase, validate_assignment=True):
description: Optional[str] = Field(None, description="Description of the block.")
metadata: Optional[dict] = Field({}, description="Metadata of the block.")
# source association (for file blocks)
source_id: Optional[str] = Field(None, description="The source ID associated with this block (for file blocks).")
# def __len__(self):
# return len(self.value)

View File

@@ -124,7 +124,7 @@ class Memory(BaseModel, validate_assignment=True):
Template(prompt_template)
# Validate compatibility with current memory structure
Template(prompt_template).render(blocks=self.blocks, file_blocks=self.file_blocks)
Template(prompt_template).render(blocks=self.blocks, file_blocks=self.file_blocks, sources=[])
# If we get here, the template is valid and compatible
self.prompt_template = prompt_template
@@ -133,10 +133,10 @@ class Memory(BaseModel, validate_assignment=True):
except Exception as e:
raise ValueError(f"Prompt template is not compatible with current memory structure: {str(e)}")
def compile(self, tool_usage_rules=None) -> str:
def compile(self, tool_usage_rules=None, sources=None) -> str:
"""Generate a string representation of the memory in-context using the Jinja2 template"""
template = Template(self.prompt_template)
return template.render(blocks=self.blocks, file_blocks=self.file_blocks, tool_usage_rules=tool_usage_rules)
return template.render(blocks=self.blocks, file_blocks=self.file_blocks, tool_usage_rules=tool_usage_rules, sources=sources)
def list_block_labels(self) -> List[str]:
"""Return a list of the block names held inside the memory object"""

View File

@@ -1111,6 +1111,7 @@ class AgentManager:
include_relationships: Optional[List[str]] = None,
) -> PydanticAgentState:
"""Fetch an agent by its ID."""
async with db_registry.async_session() as session:
agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
return await agent.to_pydantic_async(include_relationships=include_relationships)
@@ -1461,6 +1462,7 @@ class AgentManager:
timezone=agent_state.timezone,
previous_message_count=num_messages - len(agent_state.message_ids),
archival_memory_size=num_archival_memories,
sources=agent_state.sources,
)
diff = united_diff(curr_system_message_openai["content"], new_system_message_str)
@@ -1493,7 +1495,8 @@ class AgentManager:
Updates to the memory header should *not* trigger a rebuild, since that will simply flood recall storage with excess messages
"""
agent_state = await self.get_agent_by_id_async(agent_id=agent_id, include_relationships=["memory"], actor=actor)
# Get the current agent state
agent_state = await self.get_agent_by_id_async(agent_id=agent_id, include_relationships=["memory", "sources"], actor=actor)
if not tool_rules_solver:
tool_rules_solver = ToolRulesSolver(agent_state.tool_rules)
@@ -1529,6 +1532,7 @@ class AgentManager:
num_archival_memories = await self.passage_manager.agent_passage_size_async(actor=actor, agent_id=agent_id)
# update memory (TODO: potentially update recall/archival stats separately)
new_system_message_str = compile_system_message(
system_prompt=agent_state.system,
in_context_memory=agent_state.memory,
@@ -1537,6 +1541,7 @@ class AgentManager:
previous_message_count=num_messages - len(agent_state.message_ids),
archival_memory_size=num_archival_memories,
tool_rules_solver=tool_rules_solver,
sources=agent_state.sources,
)
diff = united_diff(curr_system_message_openai["content"], new_system_message_str)
@@ -1654,7 +1659,7 @@ class AgentManager:
# Update agent to only keep the system message
agent.message_ids = [system_message_id]
await agent.update_async(db_session=session, actor=actor)
agent_state = await agent.to_pydantic_async()
agent_state = await agent.to_pydantic_async(include_relationships=["sources"])
# Optionally add default initial messages after the system message
if add_default_initial_messages:
@@ -1774,8 +1779,7 @@ class AgentManager:
relationship_name="sources",
model_class=SourceModel,
item_ids=[source_id],
allow_partial=False,
replace=False, # Extend existing sources rather than replace
allow_partial=False, # Extend existing sources rather than replace
)
# Commit the changes

View File

@@ -251,6 +251,7 @@ def compile_system_message(
previous_message_count: int = 0,
archival_memory_size: int = 0,
tool_rules_solver: Optional[ToolRulesSolver] = None,
sources: Optional[List] = None,
) -> str:
"""Prepare the final/full system message that will be fed into the LLM API
@@ -259,6 +260,7 @@ def compile_system_message(
The following are reserved variables:
- CORE_MEMORY: the in-context memory of the LLM
"""
# Add tool rule constraints if available
tool_constraint_block = None
if tool_rules_solver is not None:
@@ -281,13 +283,16 @@ def compile_system_message(
archival_memory_size=archival_memory_size,
timezone=timezone,
)
full_memory_string = in_context_memory.compile(tool_usage_rules=tool_constraint_block) + "\n\n" + memory_metadata_string
memory_with_sources = in_context_memory.compile(tool_usage_rules=tool_constraint_block, sources=sources)
full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
# Add to the variables list to inject
variables[IN_CONTEXT_MEMORY_KEYWORD] = full_memory_string
if template_format == "f-string":
memory_variable_string = "{" + IN_CONTEXT_MEMORY_KEYWORD + "}"
# Catch the special case where the system prompt is unformatted
if append_icm_if_missing:
if memory_variable_string not in system_prompt:
@@ -330,6 +335,7 @@ def initialize_message_sequence(
append_icm_if_missing=True,
previous_message_count=previous_message_count,
archival_memory_size=archival_memory_size,
sources=agent_state.sources,
)
first_user_message = get_login_event(agent_state.timezone) # event letting Letta know the user just logged in