From f76591a6161ea1e9ee05e57c4896f20f02e84147 Mon Sep 17 00:00:00 2001
From: Oxygen <1391083091@qq.com>
Date: Sat, 6 Jun 2026 12:40:59 +0800
Subject: [PATCH 01/10] fix: pass custom_instructions to session summaries
Session summaries were ignoring workspace custom_instructions (e.g.,
language preferences), defaulting to English output regardless of the
conversation language.
The deriver already handles custom_instructions correctly via
_custom_instructions_section in src/deriver/prompts.py. This fix
applies the same pattern to the summarizer by threading
custom_instructions from configuration.reasoning.custom_instructions
through create_short_summary, create_long_summary, _create_summary,
and ultimately to short_summary_prompt and long_summary_prompt.
Fixes #748
---
src/utils/summarizer.py | 31 +++++++++++++++++++++++++++++--
1 file changed, 29 insertions(+), 2 deletions(-)
diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py
index 42d18bcc..7561be5d 100644
--- a/src/utils/summarizer.py
+++ b/src/utils/summarizer.py
@@ -14,6 +14,7 @@ from src.cache.client import cache as cache_client
from src.config import ConfiguredModelSettings, settings
from src.crud.session import session_cache_key
from src.dependencies import tracked_db
+from src.deriver.prompts import _custom_instructions_section
from src.exceptions import ResourceNotFoundException
from src.llm import HonchoLLMCallResponse, honcho_llm_call
from src.llm.types import LLMTelemetryContext
@@ -101,8 +102,10 @@ def short_summary_prompt(
formatted_messages: str,
output_words: int,
previous_summary_text: str,
+ custom_instructions: str | None = None,
) -> str:
"""Generate the short summary prompt."""
+ custom_instructions_section = _custom_instructions_section(custom_instructions)
return c(f"""
You are a system that summarizes parts of a conversation to create a concise and accurate summary. Focus on capturing:
@@ -117,6 +120,7 @@ Provide a concise, factual summary that captures the essence of the conversation
Return only the summary without any explanation or meta-commentary.
+{custom_instructions_section}
{previous_summary_text}
@@ -133,8 +137,10 @@ def long_summary_prompt(
formatted_messages: str,
output_words: int,
previous_summary_text: str,
+ custom_instructions: str | None = None,
) -> str:
"""Generate the long summary prompt."""
+ custom_instructions_section = _custom_instructions_section(custom_instructions)
return c(f"""
You are a system that creates thorough, comprehensive summaries of conversations. Focus on capturing:
@@ -151,6 +157,7 @@ Provide a thorough and detailed summary that captures the essence of the convers
Return only the summary without any explanation or meta-commentary.
+{custom_instructions_section}
{previous_summary_text}
@@ -172,6 +179,7 @@ def estimate_short_summary_prompt_tokens() -> int:
formatted_messages="",
output_words=0,
previous_summary_text="",
+ custom_instructions=None,
)
)
except Exception:
@@ -188,6 +196,7 @@ def estimate_long_summary_prompt_tokens() -> int:
formatted_messages="",
output_words=0,
previous_summary_text="",
+ custom_instructions=None,
)
)
except Exception:
@@ -200,6 +209,7 @@ async def create_short_summary(
formatted_messages: str,
input_tokens: int,
previous_summary: str | None = None,
+ custom_instructions: str | None = None,
*,
workspace_name: str | None = None,
) -> HonchoLLMCallResponse[str]:
@@ -216,7 +226,10 @@ async def create_short_summary(
previous_summary_text = "There is no previous summary -- the messages are the beginning of the conversation."
prompt = short_summary_prompt(
- formatted_messages, output_words, previous_summary_text
+ formatted_messages,
+ output_words,
+ previous_summary_text,
+ custom_instructions=custom_instructions,
)
return await honcho_llm_call(
@@ -235,6 +248,7 @@ async def create_short_summary(
async def create_long_summary(
formatted_messages: str,
previous_summary: str | None = None,
+ custom_instructions: str | None = None,
*,
workspace_name: str | None = None,
) -> HonchoLLMCallResponse[str]:
@@ -248,7 +262,10 @@ async def create_long_summary(
previous_summary_text = "There is no previous summary -- the messages are the beginning of the conversation."
prompt = long_summary_prompt(
- formatted_messages, output_words, previous_summary_text
+ formatted_messages,
+ output_words,
+ previous_summary_text,
+ custom_instructions=custom_instructions,
)
return await honcho_llm_call(
@@ -439,6 +456,12 @@ async def _create_and_save_summary(
previous_summary_tokens = latest_summary["token_count"] if latest_summary else 0
input_tokens = messages_tokens + previous_summary_tokens
+ # Extract custom_instructions from configuration for the summarizer prompt.
+ # This mirrors the deriver pattern in src/deriver/prompts.py.
+ custom_instructions: str | None = None
+ if configuration.reasoning and configuration.reasoning.custom_instructions:
+ custom_instructions = configuration.reasoning.custom_instructions
+
(
new_summary,
is_fallback,
@@ -453,6 +476,7 @@ async def _create_and_save_summary(
last_message_id=last_message_id,
last_message_content_preview=last_message_content_preview,
message_count=message_count,
+ custom_instructions=custom_instructions,
workspace_name=workspace_name,
)
@@ -552,6 +576,7 @@ async def _create_summary(
last_message_id: int,
last_message_content_preview: str,
message_count: int,
+ custom_instructions: str | None = None,
*,
workspace_name: str | None = None,
) -> tuple[Summary, bool, int, int]:
@@ -585,12 +610,14 @@ async def _create_summary(
formatted_messages,
input_tokens,
previous_summary_text,
+ custom_instructions=custom_instructions,
workspace_name=workspace_name,
)
else:
response = await create_long_summary(
formatted_messages,
previous_summary_text,
+ custom_instructions=custom_instructions,
workspace_name=workspace_name,
)
From e9613359e24f20e8525144682aba8f1e001a972f Mon Sep 17 00:00:00 2001
From: Oxygen <1391083091@qq.com>
Date: Sat, 6 Jun 2026 20:15:29 +0800
Subject: [PATCH 02/10] docs: add missing docstrings to achieve 80% coverage
threshold
---
src/utils/summarizer.py | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py
index 7561be5d..53ac3757 100644
--- a/src/utils/summarizer.py
+++ b/src/utils/summarizer.py
@@ -58,6 +58,7 @@ class Summary(TypedDict):
def to_schema_summary(s: Summary) -> schemas.Summary:
+ """Convert a Summary TypedDict to a Pydantic Summary schema object."""
return schemas.Summary(
content=s["content"],
message_id=s["message_id"],
@@ -82,6 +83,7 @@ __all__ = [
def _get_summary_model_config() -> ConfiguredModelSettings:
+ """Return the configured model settings for summary generation."""
return settings.SUMMARY.MODEL_CONFIG
@@ -213,6 +215,19 @@ async def create_short_summary(
*,
workspace_name: str | None = None,
) -> HonchoLLMCallResponse[str]:
+ """
+ Generate a short summary via an LLM call.
+
+ Args:
+ formatted_messages: Pre-formatted conversation messages.
+ input_tokens: Token count of the input (messages + previous summary).
+ previous_summary: Previous summary text for continuity, if any.
+ custom_instructions: Optional custom instructions from configuration.
+ workspace_name: Workspace name for telemetry attribution.
+
+ Returns:
+ The LLM response containing the short summary text and token counts.
+ """
# input_tokens indicates how many tokens the message list + previous summary take up
# we want to optimize short summaries to be smaller than the actual content being summarized
# so we ask the agent to produce a word count roughly equal to either the input, or the max
@@ -252,6 +267,18 @@ async def create_long_summary(
*,
workspace_name: str | None = None,
) -> HonchoLLMCallResponse[str]:
+ """
+ Generate a comprehensive long summary via an LLM call.
+
+ Args:
+ formatted_messages: Pre-formatted conversation messages.
+ previous_summary: Previous summary text for continuity, if any.
+ custom_instructions: Optional custom instructions from configuration.
+ workspace_name: Workspace name for telemetry attribution.
+
+ Returns:
+ The LLM response containing the long summary text and token counts.
+ """
# the word/token ratio is roughly 4:3 so we multiply by 0.75.
# LLMs *seem* to respond better to getting asked for a word count but should workshop this.
output_words = int(settings.SUMMARY.MAX_TOKENS_LONG * 0.75)
From 6525a6b6c974e823b966155868d7b2c59409bc04 Mon Sep 17 00:00:00 2001
From: Oxygen <1391083091@qq.com>
Date: Wed, 10 Jun 2026 17:41:09 +0800
Subject: [PATCH 03/10] =?UTF-8?q?fix:=20address=20review=20feedback=20?=
=?UTF-8?q?=E2=80=94=20add=20custom=5Finstructions=20to=20SummaryConfigura?=
=?UTF-8?q?tion?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changes requested by @Rajat-Ahuja1997:
1. Add custom_instructions field to SummaryConfiguration (api_types.py)
— Summary now has its own custom_instructions, separate from
ReasoningConfiguration. Workspace operators can configure
different styles for summaries vs deriver output.
2. Change source from configuration.reasoning to configuration.summary
— _create_and_save_summary() now reads from the summarizer's own
configuration field instead of borrowing the deriver's.
3. Add parameterized token estimation functions (deriver pattern)
— estimate_short_summary_prompt_tokens_with_custom_instructions()
— estimate_long_summary_prompt_tokens_with_custom_instructions()
— Falls back to the cached base estimate when custom_instructions
is None, matching the existing estimate_minimal_deriver_prompt_tokens
/ estimate_deriver_prompt_tokens pattern in src/deriver/prompts.py.
— Call sites in _create_and_save_summary() now pass the actual
custom_instructions value for accurate telemetry.
4. Add missing custom_instructions and workspace_name to
_create_summary() docstring.
Co-authored-by: CodeRabbit
---
sdks/python/src/honcho/api_types.py | 1 +
src/utils/summarizer.py | 57 +++++++++++++++++++++++++----
uv.lock | 4 --
3 files changed, 50 insertions(+), 12 deletions(-)
diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py
index 897a86fe..a3eb4b79 100644
--- a/sdks/python/src/honcho/api_types.py
+++ b/sdks/python/src/honcho/api_types.py
@@ -41,6 +41,7 @@ class SummaryConfiguration(BaseModel):
enabled: bool | None = None
messages_per_short_summary: int | None = None
messages_per_long_summary: int | None = None
+ custom_instructions: str | None = None
class DreamConfiguration(BaseModel):
diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py
index 53ac3757..bba6d612 100644
--- a/src/utils/summarizer.py
+++ b/src/utils/summarizer.py
@@ -174,7 +174,7 @@ Hard limit: {output_words} words maximum. If needed, drop lower-priority detail
@cache
def estimate_short_summary_prompt_tokens() -> int:
- """Estimate tokens for the short summary prompt (without messages/previous_summary)."""
+ """Estimate tokens for the short summary prompt (without messages/previous_summary or custom instructions)."""
try:
return estimate_tokens(
short_summary_prompt(
@@ -189,9 +189,26 @@ def estimate_short_summary_prompt_tokens() -> int:
return 200
+def estimate_short_summary_prompt_tokens_with_custom_instructions(
+ custom_instructions: str | None,
+) -> int:
+ """Estimate short summary prompt tokens, including custom instructions if present."""
+ if custom_instructions is None:
+ return estimate_short_summary_prompt_tokens()
+
+ return estimate_tokens(
+ short_summary_prompt(
+ formatted_messages="",
+ output_words=0,
+ previous_summary_text="",
+ custom_instructions=custom_instructions,
+ )
+ )
+
+
@cache
def estimate_long_summary_prompt_tokens() -> int:
- """Estimate tokens for the long summary prompt (without messages/previous_summary)."""
+ """Estimate tokens for the long summary prompt (without messages/previous_summary or custom instructions)."""
try:
return estimate_tokens(
long_summary_prompt(
@@ -206,6 +223,23 @@ def estimate_long_summary_prompt_tokens() -> int:
return 200
+def estimate_long_summary_prompt_tokens_with_custom_instructions(
+ custom_instructions: str | None,
+) -> int:
+ """Estimate long summary prompt tokens, including custom instructions if present."""
+ if custom_instructions is None:
+ return estimate_long_summary_prompt_tokens()
+
+ return estimate_tokens(
+ long_summary_prompt(
+ formatted_messages="",
+ output_words=0,
+ previous_summary_text="",
+ custom_instructions=custom_instructions,
+ )
+ )
+
+
@conditional_observe(name="Create Short Summary")
async def create_short_summary(
formatted_messages: str,
@@ -483,11 +517,12 @@ async def _create_and_save_summary(
previous_summary_tokens = latest_summary["token_count"] if latest_summary else 0
input_tokens = messages_tokens + previous_summary_tokens
- # Extract custom_instructions from configuration for the summarizer prompt.
- # This mirrors the deriver pattern in src/deriver/prompts.py.
+ # Extract custom_instructions from the summarizer's own configuration.
+ # This is separate from reasoning custom_instructions — workspace
+ # operators may want summaries in a different style than deriver output.
custom_instructions: str | None = None
- if configuration.reasoning and configuration.reasoning.custom_instructions:
- custom_instructions = configuration.reasoning.custom_instructions
+ if configuration.summary and configuration.summary.custom_instructions:
+ custom_instructions = configuration.summary.custom_instructions
(
new_summary,
@@ -511,9 +546,13 @@ async def _create_and_save_summary(
# save-summary path and the telemetry emit below can use it
# without basedpyright tripping on a possibly-unbound name.
if summary_type == SummaryType.SHORT:
- prompt_tokens = estimate_short_summary_prompt_tokens()
+ prompt_tokens = estimate_short_summary_prompt_tokens_with_custom_instructions(
+ custom_instructions
+ )
else:
- prompt_tokens = estimate_long_summary_prompt_tokens()
+ prompt_tokens = estimate_long_summary_prompt_tokens_with_custom_instructions(
+ custom_instructions
+ )
# Step 3: Save to database with new transaction
if not is_fallback:
@@ -619,6 +658,8 @@ async def _create_summary(
last_message_id: ID of the last message
last_message_content_preview: Preview of last message content for fallback
message_count: Number of messages for fallback
+ custom_instructions: Optional workspace-level custom instructions for prompt
+ workspace_name: Optional workspace name for telemetry
Returns:
A tuple of (Summary, is_fallback, llm_input_tokens, llm_output_tokens)
diff --git a/uv.lock b/uv.lock
index 25279af9..2c504207 100644
--- a/uv.lock
+++ b/uv.lock
@@ -7,10 +7,6 @@ resolution-markers = [
"python_full_version < '3.13'",
]
-[options]
-exclude-newer = "2026-05-28T15:27:36.945866Z"
-exclude-newer-span = "P5D"
-
[manifest]
members = [
"honcho",
From b6178cc73921f73b9cdcd0d5ee272056b9b6aa9f Mon Sep 17 00:00:00 2001
From: Oxygen <1391083091@qq.com>
Date: Wed, 10 Jun 2026 23:26:25 +0800
Subject: [PATCH 04/10] =?UTF-8?q?fix:=20address=20review=20feedback=20?=
=?UTF-8?q?=E2=80=94=20add=20custom=5Finstructions=20to=20server=20config;?=
=?UTF-8?q?=20merge=20estimate=20helpers;=20revert=20uv.lock?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/schemas/configuration.py | 15 +++++++++++
src/utils/summarizer.py | 52 +++++++-----------------------------
uv.lock | 4 +++
3 files changed, 28 insertions(+), 43 deletions(-)
diff --git a/src/schemas/configuration.py b/src/schemas/configuration.py
index b8291cab..22822e6f 100644
--- a/src/schemas/configuration.py
+++ b/src/schemas/configuration.py
@@ -61,6 +61,15 @@ class SummaryConfiguration(BaseModel):
ge=20,
description="Number of messages per long summary. Must be positive, greater than or equal to 20, and greater than messages_per_short_summary.",
)
+ custom_instructions: str | None = Field(
+ default=None,
+ description="Optional custom instructions for session summaries. Rejected if they exceed the summarizer custom-instruction token cap.",
+ )
+
+ @field_validator("custom_instructions")
+ @classmethod
+ def validate_custom_instructions(cls, value: str | None) -> str | None:
+ return _validate_custom_instructions_budget(value)
@model_validator(mode="after")
def validate_summary_thresholds(self) -> Self:
@@ -172,6 +181,12 @@ class ResolvedSummaryConfiguration(BaseModel):
enabled: bool
messages_per_short_summary: int
messages_per_long_summary: int
+ custom_instructions: str | None = None
+
+ @field_validator("custom_instructions")
+ @classmethod
+ def validate_custom_instructions(cls, value: str | None) -> str | None:
+ return _validate_custom_instructions_budget(value)
class ResolvedDreamConfiguration(BaseModel):
diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py
index bba6d612..da684ab3 100644
--- a/src/utils/summarizer.py
+++ b/src/utils/summarizer.py
@@ -14,6 +14,7 @@ from src.cache.client import cache as cache_client
from src.config import ConfiguredModelSettings, settings
from src.crud.session import session_cache_key
from src.dependencies import tracked_db
+# TODO: move _custom_instructions_section to shared utility
from src.deriver.prompts import _custom_instructions_section
from src.exceptions import ResourceNotFoundException
from src.llm import HonchoLLMCallResponse, honcho_llm_call
@@ -173,42 +174,24 @@ Hard limit: {output_words} words maximum. If needed, drop lower-priority detail
@cache
-def estimate_short_summary_prompt_tokens() -> int:
- """Estimate tokens for the short summary prompt (without messages/previous_summary or custom instructions)."""
+def estimate_short_summary_prompt_tokens(custom_instructions: str | None = None) -> int:
+ """Estimate tokens for the short summary prompt, optionally including custom instructions."""
try:
return estimate_tokens(
short_summary_prompt(
formatted_messages="",
output_words=0,
previous_summary_text="",
- custom_instructions=None,
+ custom_instructions=custom_instructions,
)
)
except Exception:
- # Return a rough estimate if estimation fails
return 200
-def estimate_short_summary_prompt_tokens_with_custom_instructions(
- custom_instructions: str | None,
-) -> int:
- """Estimate short summary prompt tokens, including custom instructions if present."""
- if custom_instructions is None:
- return estimate_short_summary_prompt_tokens()
-
- return estimate_tokens(
- short_summary_prompt(
- formatted_messages="",
- output_words=0,
- previous_summary_text="",
- custom_instructions=custom_instructions,
- )
- )
-
-
@cache
-def estimate_long_summary_prompt_tokens() -> int:
- """Estimate tokens for the long summary prompt (without messages/previous_summary or custom instructions)."""
+def estimate_long_summary_prompt_tokens(custom_instructions: str | None = None) -> int:
+ """Estimate tokens for the long summary prompt, optionally including custom instructions."""
try:
return estimate_tokens(
long_summary_prompt(
@@ -219,26 +202,9 @@ def estimate_long_summary_prompt_tokens() -> int:
)
)
except Exception:
- # Return a rough estimate if estimation fails
return 200
-def estimate_long_summary_prompt_tokens_with_custom_instructions(
- custom_instructions: str | None,
-) -> int:
- """Estimate long summary prompt tokens, including custom instructions if present."""
- if custom_instructions is None:
- return estimate_long_summary_prompt_tokens()
-
- return estimate_tokens(
- long_summary_prompt(
- formatted_messages="",
- output_words=0,
- previous_summary_text="",
- custom_instructions=custom_instructions,
- )
- )
-
@conditional_observe(name="Create Short Summary")
async def create_short_summary(
@@ -521,7 +487,7 @@ async def _create_and_save_summary(
# This is separate from reasoning custom_instructions — workspace
# operators may want summaries in a different style than deriver output.
custom_instructions: str | None = None
- if configuration.summary and configuration.summary.custom_instructions:
+ if configuration.summary and configuration.summary.custom_instructions is not None:
custom_instructions = configuration.summary.custom_instructions
(
@@ -546,11 +512,11 @@ async def _create_and_save_summary(
# save-summary path and the telemetry emit below can use it
# without basedpyright tripping on a possibly-unbound name.
if summary_type == SummaryType.SHORT:
- prompt_tokens = estimate_short_summary_prompt_tokens_with_custom_instructions(
+ prompt_tokens = estimate_short_summary_prompt_tokens(
custom_instructions
)
else:
- prompt_tokens = estimate_long_summary_prompt_tokens_with_custom_instructions(
+ prompt_tokens = estimate_long_summary_prompt_tokens(
custom_instructions
)
diff --git a/uv.lock b/uv.lock
index 2c504207..25279af9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -7,6 +7,10 @@ resolution-markers = [
"python_full_version < '3.13'",
]
+[options]
+exclude-newer = "2026-05-28T15:27:36.945866Z"
+exclude-newer-span = "P5D"
+
[manifest]
members = [
"honcho",
From 3cc787eb7520768affc00dbff30ed240ac5f9757 Mon Sep 17 00:00:00 2001
From: Oxygen <1391083091@qq.com>
Date: Wed, 10 Jun 2026 23:41:47 +0800
Subject: [PATCH 05/10] fix: forward custom_instructions in estimate_long;
switch to lru_cache
---
src/utils/summarizer.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py
index da684ab3..1a4359a2 100644
--- a/src/utils/summarizer.py
+++ b/src/utils/summarizer.py
@@ -2,7 +2,7 @@ import asyncio
import logging
import time
from enum import Enum
-from functools import cache
+from functools import cache, lru_cache
from inspect import cleandoc as c
from typing import TypedDict
@@ -173,7 +173,7 @@ Hard limit: {output_words} words maximum. If needed, drop lower-priority detail
""")
-@cache
+@lru_cache(maxsize=128)
def estimate_short_summary_prompt_tokens(custom_instructions: str | None = None) -> int:
"""Estimate tokens for the short summary prompt, optionally including custom instructions."""
try:
@@ -189,7 +189,7 @@ def estimate_short_summary_prompt_tokens(custom_instructions: str | None = None)
return 200
-@cache
+@lru_cache(maxsize=128)
def estimate_long_summary_prompt_tokens(custom_instructions: str | None = None) -> int:
"""Estimate tokens for the long summary prompt, optionally including custom instructions."""
try:
@@ -198,7 +198,7 @@ def estimate_long_summary_prompt_tokens(custom_instructions: str | None = None)
formatted_messages="",
output_words=0,
previous_summary_text="",
- custom_instructions=None,
+ custom_instructions=custom_instructions,
)
)
except Exception:
From edc7daf76d8f23299d57c73ca694044820f82f7e Mon Sep 17 00:00:00 2001
From: Oxygen <1391083091@qq.com>
Date: Wed, 10 Jun 2026 23:58:02 +0800
Subject: [PATCH 06/10] docs: clarify SummaryConfiguration uses deriver token
cap
---
src/schemas/configuration.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/schemas/configuration.py b/src/schemas/configuration.py
index 22822e6f..c11543a3 100644
--- a/src/schemas/configuration.py
+++ b/src/schemas/configuration.py
@@ -63,7 +63,7 @@ class SummaryConfiguration(BaseModel):
)
custom_instructions: str | None = Field(
default=None,
- description="Optional custom instructions for session summaries. Rejected if they exceed the summarizer custom-instruction token cap.",
+ description="Optional custom instructions for session summaries. Validated against DERIVER.MAX_CUSTOM_INSTRUCTIONS_TOKENS.",
)
@field_validator("custom_instructions")
From 055d8f1aa7cf01598854ef479fbd778524bb77ff Mon Sep 17 00:00:00 2001
From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com>
Date: Thu, 11 Jun 2026 10:17:35 +0800
Subject: [PATCH 07/10] style: wrap long function signatures to comply with
88-char line limit
---
src/utils/summarizer.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py
index 1a4359a2..0ca33a0f 100644
--- a/src/utils/summarizer.py
+++ b/src/utils/summarizer.py
@@ -174,7 +174,9 @@ Hard limit: {output_words} words maximum. If needed, drop lower-priority detail
@lru_cache(maxsize=128)
-def estimate_short_summary_prompt_tokens(custom_instructions: str | None = None) -> int:
+def estimate_short_summary_prompt_tokens(
+ custom_instructions: str | None = None,
+) -> int:
"""Estimate tokens for the short summary prompt, optionally including custom instructions."""
try:
return estimate_tokens(
@@ -190,7 +192,9 @@ def estimate_short_summary_prompt_tokens(custom_instructions: str | None = None)
@lru_cache(maxsize=128)
-def estimate_long_summary_prompt_tokens(custom_instructions: str | None = None) -> int:
+def estimate_long_summary_prompt_tokens(
+ custom_instructions: str | None = None,
+) -> int:
"""Estimate tokens for the long summary prompt, optionally including custom instructions."""
try:
return estimate_tokens(
From 5e601ab47c0075a3a9f91831a38795092088a188 Mon Sep 17 00:00:00 2001
From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:24:31 +0800
Subject: [PATCH 08/10] fix: preserve summary custom instruction clears
---
src/utils/config_helpers.py | 25 +++++++++++++++++++++----
1 file changed, 21 insertions(+), 4 deletions(-)
diff --git a/src/utils/config_helpers.py b/src/utils/config_helpers.py
index b4afa078..6c1ae124 100644
--- a/src/utils/config_helpers.py
+++ b/src/utils/config_helpers.py
@@ -12,18 +12,34 @@ from src.schemas import (
logger = logging.getLogger(__name__)
+_NONE_OVERRIDE_PATHS: set[tuple[str, ...]] = {
+ ("summary", "custom_instructions"),
+}
-def deep_update(base: dict[str, Any], update: dict[str, Any]) -> None:
+
+def deep_update(
+ base: dict[str, Any],
+ update: dict[str, Any],
+ path: tuple[str, ...] = (),
+) -> None:
"""
Recursive update of a dictionary.
- Skips None values in the update dictionary.
+ Skips None values unless None explicitly clears a nullable field.
"""
for key, value in update.items():
+ current_path = (*path, key)
+
if value is None:
+ if current_path in _NONE_OVERRIDE_PATHS:
+ base[key] = None
continue
if isinstance(value, dict) and key in base and isinstance(base[key], dict):
- deep_update(cast(dict[str, Any], base[key]), cast(dict[str, Any], value))
+ deep_update(
+ cast(dict[str, Any], base[key]),
+ cast(dict[str, Any], value),
+ current_path,
+ )
else:
base[key] = value
@@ -113,6 +129,7 @@ def get_configuration(
"enabled": settings.SUMMARY.ENABLED,
"messages_per_short_summary": settings.SUMMARY.MESSAGES_PER_SHORT_SUMMARY,
"messages_per_long_summary": settings.SUMMARY.MESSAGES_PER_LONG_SUMMARY,
+ "custom_instructions": None,
},
"dream": {"enabled": settings.DREAM.ENABLED},
}
@@ -130,7 +147,7 @@ def get_configuration(
deep_update(
config_dict,
normalize_configuration_dict(
- message_configuration.model_dump(exclude_none=True)
+ message_configuration.model_dump(exclude_unset=True)
),
)
From e31a9eee2b95abc51c82c9d93ec7ae87fd87aa86 Mon Sep 17 00:00:00 2001
From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:26:01 +0800
Subject: [PATCH 09/10] fix: allow message summary configuration overrides
---
src/schemas/configuration.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/schemas/configuration.py b/src/schemas/configuration.py
index c11543a3..ecd12857 100644
--- a/src/schemas/configuration.py
+++ b/src/schemas/configuration.py
@@ -135,7 +135,7 @@ class WorkspaceConfiguration(BaseModel):
)
dream: DreamConfiguration | None = Field(
default=None,
- description="Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored.",
+ description="Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and this setting will be ignored.",
)
@@ -160,6 +160,10 @@ class MessageConfiguration(BaseModel):
default=None,
description="Configuration for reasoning functionality.",
)
+ summary: SummaryConfiguration | None = Field(
+ default=None,
+ description="Configuration for summary functionality.",
+ )
class ResolvedReasoningConfiguration(BaseModel):
From 93e3f2941a9b1991c45e8b7aca5dd302978d8893 Mon Sep 17 00:00:00 2001
From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:26:30 +0800
Subject: [PATCH 10/10] test: cover summary custom instruction clears
---
tests/test_config_helpers.py | 51 ++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
create mode 100644 tests/test_config_helpers.py
diff --git a/tests/test_config_helpers.py b/tests/test_config_helpers.py
new file mode 100644
index 00000000..79231a1a
--- /dev/null
+++ b/tests/test_config_helpers.py
@@ -0,0 +1,51 @@
+from types import SimpleNamespace
+from typing import Any
+
+from src.schemas import MessageConfiguration, SummaryConfiguration
+from src.utils.config_helpers import deep_update, get_configuration
+
+
+def _configured_node(configuration: dict[str, Any]) -> Any:
+ return SimpleNamespace(configuration=configuration)
+
+
+class TestDeepUpdate:
+ def test_summary_custom_instructions_none_clears_inherited_value(self) -> None:
+ base = {
+ "summary": {
+ "enabled": True,
+ "custom_instructions": "Write summaries in German.",
+ }
+ }
+
+ deep_update(
+ base,
+ {"summary": {"enabled": None, "custom_instructions": None}},
+ )
+
+ assert base["summary"]["enabled"] is True
+ assert base["summary"]["custom_instructions"] is None
+
+
+class TestGetConfiguration:
+ def test_session_can_clear_workspace_summary_custom_instructions(self) -> None:
+ workspace = _configured_node(
+ {"summary": {"custom_instructions": "Write summaries in German."}}
+ )
+ session = _configured_node({"summary": {"custom_instructions": None}})
+
+ configuration = get_configuration(None, session, workspace)
+
+ assert configuration.summary.custom_instructions is None
+
+ def test_message_can_clear_session_summary_custom_instructions(self) -> None:
+ session = _configured_node(
+ {"summary": {"custom_instructions": "Write summaries in German."}}
+ )
+ message_configuration = MessageConfiguration(
+ summary=SummaryConfiguration(custom_instructions=None)
+ )
+
+ configuration = get_configuration(message_configuration, session)
+
+ assert configuration.summary.custom_instructions is None