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