feat: add role filter to the {{MESSAGES}} template variable

The {{MESSAGES}} variable could select messages by position
(:START:n, :END:n, :MIDDLETRUNCATE:n) and truncate their content
(|middletruncate:n, |start:n, |end:n), but not select them by role, so
every selected message was rendered, system message included.

This is most visible in query generation: generate_queries() renders the
chat history for both the retrieval and web search paths, so the system
prompt was sent to the task model -- possibly a different, external
provider via TASK_MODEL_EXTERNAL -- and steered the queries that then go
to the vector store or to an external search engine.

The existing syntax could not express "keep several turns, drop the
system message": {{MESSAGES:END:1}} and {{prompt}} both exclude it but
discard the multi-turn context that makes query generation useful.

Add an optional |ROLES: filter, chainable with the content filters:

    {{MESSAGES:END:6|ROLES:user,assistant}}
    {{MESSAGES|ROLES:user|middletruncate:500}}

Roles are comma- or space-separated and matched case-insensitively, as
is the ROLES keyword. Role filtering runs before the positional
selector, so :END:6 counts only the messages that are actually rendered.
An empty or unparsable role list leaves the messages untouched, so a
typo degrades to the previous behaviour instead of emptying the history.

Use the filter in DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE so the
improvement applies out of the box; custom templates keep taking
precedence and are unaffected.

replace_messages_variable() is refactored from four alternated regex
branches with seven positional groups to a single pattern with named
groups and a repeatable filter suffix, to keep chained filters readable.
Templates without |ROLES render byte-identically to before.
This commit is contained in:
MarcPlatini 2026-08-21 11:25:43 +02:00
parent 7229fac0c4
commit dc152684d3
2 changed files with 72 additions and 41 deletions

View file

@ -2320,7 +2320,7 @@ Strictly return in JSON format:
### Chat History:
<chat_history>
{{MESSAGES:END:6}}
{{MESSAGES:END:6|ROLES:user,assistant}}
</chat_history>
"""

View file

@ -156,6 +156,21 @@ def truncate_content(content: str, max_chars: int, mode: str = 'middletruncate')
return f'{content[:half]}...{content[-(max_chars - half) :]}'
def apply_role_filter(messages: list[dict], roles_str: str) -> list[dict]:
"""Keep only the messages whose role is listed in roles_str.
roles_str is a comma separated list of roles, like 'user,assistant'.
Roles are matched case-insensitively and may also be separated by spaces.
An empty list leaves the messages untouched, so a typo degrades to the
previous behaviour instead of silently emptying the chat history.
"""
roles = {role.lower() for role in re.split(r'[,\s]+', roles_str) if role}
if not roles:
return messages
return [message for message in messages if (message.get('role') or '').lower() in roles]
def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
"""Apply a content filter to each message's content.
@ -197,59 +212,75 @@ def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
def replace_messages_variable(
template: str, messages: Optional[list[dict]] = None, variable_name: str = 'MESSAGES'
) -> str:
"""Expand a {{MESSAGES}} variable into a rendered chat history.
Supported forms, where each may carry zero or more `|filter` suffixes:
{{MESSAGES}} every message
{{MESSAGES:START:4}} the first 4 messages
{{MESSAGES:END:6}} the last 6 messages
{{MESSAGES:MIDDLETRUNCATE:6}} the first and last 3 messages
Two kinds of filter may be chained in any order:
|ROLES:user,assistant keep only these roles
|middletruncate:500 truncate each message's content
|start:200 |end:200
Role filters select *which* messages are rendered and are applied before
the positional selector, so `{{MESSAGES:END:6|ROLES:user}}` renders the
last 6 user messages rather than whatever user messages happen to sit in
the last 6 of the whole history. Content filters shorten each surviving
message and are applied last.
"""
def replacement_function(match):
# Groups: (1) filter for bare MESSAGES
# (2) START count, (3) filter for START
# (4) END count, (5) filter for END
# (6) MIDDLE count,(7) filter for MIDDLE
bare_filter = match.group(1)
start_length = match.group(2)
start_filter = match.group(3)
end_length = match.group(4)
end_filter = match.group(5)
middle_length = match.group(6)
middle_filter = match.group(7)
selector = match.group('selector')
length = match.group('length')
filters_str = match.group('filters') or ''
# If messages is None, handle it as an empty list
if messages is None:
return ''
# Select messages based on the variant
if start_length is not None:
selected = messages[: int(start_length)]
content_filter = start_filter
elif end_length is not None:
selected = messages[-int(end_length) :]
content_filter = end_filter
elif middle_length is not None:
mid = int(middle_length)
if len(messages) <= mid:
selected = messages
else:
half = mid // 2
start_msgs = messages[:half]
end_msgs = messages[-half:] if mid % 2 == 0 else messages[-(half + 1) :]
selected = start_msgs + end_msgs
content_filter = middle_filter
else:
# Bare {{MESSAGES}} or {{MESSAGES|filter}}
selected = messages
content_filter = bare_filter
filters = [f for f in filters_str.split('|') if f]
role_filters = [f for f in filters if f.split(':', 1)[0].lower() == 'roles']
content_filters = [f for f in filters if f not in role_filters]
# Apply content filter if present
if content_filter:
# Role filtering runs first so the positional selector below counts
# only the messages that are actually rendered.
selected = messages
for role_filter in role_filters:
_, _, roles_str = role_filter.partition(':')
selected = apply_role_filter(selected, roles_str)
selector = (selector or '').upper()
if selector == 'START':
selected = selected[: int(length)]
elif selector == 'END':
selected = selected[-int(length) :]
elif selector == 'MIDDLETRUNCATE':
mid = int(length)
if len(selected) > mid:
half = mid // 2
start_msgs = selected[:half]
end_msgs = selected[-half:] if mid % 2 == 0 else selected[-(half + 1) :]
selected = start_msgs + end_msgs
for content_filter in content_filters:
selected = apply_content_filter(selected, content_filter)
return get_messages_content(selected)
variable_pattern = re.escape(variable_name)
# A filter is either ROLES:<comma separated roles> or <mode>:<int>;
# any number of them may be chained with '|'.
filter_pattern = r'(?:(?i:ROLES):[\w,\s]+|\w+:\d+)'
template = re.sub(
r'(?:'
rf'\{{\{{{variable_pattern}(?:\|(\w+:\d+))?\}}\}}'
rf'|\{{\{{{variable_pattern}:START:(\d+)(?:\|(\w+:\d+))?\}}\}}'
rf'|\{{\{{{variable_pattern}:END:(\d+)(?:\|(\w+:\d+))?\}}\}}'
rf'|\{{\{{{variable_pattern}:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}}\}}'
r')',
rf'\{{\{{{variable_pattern}'
rf'(?::(?P<selector>START|END|MIDDLETRUNCATE):(?P<length>\d+))?'
rf'(?P<filters>(?:\|{filter_pattern})*)'
r'\}\}',
replacement_function,
template,
)