feat(config): make representation order configurable

This commit is contained in:
Daniel Peng 2026-08-20 07:38:05 +08:00
parent 7bafee5de1
commit fdef895bb5
6 changed files with 425 additions and 119 deletions

View File

@ -11,6 +11,7 @@ LOG_LEVEL=INFO
PERFORMANCE_LOG_FORMAT=compact # compact|rich
# SESSION_OBSERVERS_LIMIT=10
# GET_CONTEXT_MAX_TOKENS=100000
# REPRESENTATION_INJECTION_ORDER=explicit,deductive,inductive,contradiction
# MAX_FILE_SIZE=5242880 # Bytes
# MAX_MESSAGE_SIZE=25000 # Characters

View File

@ -9,6 +9,7 @@ LOG_LEVEL = "INFO"
PERFORMANCE_LOG_FORMAT = "compact" # "compact" for single-line logs, "rich" for local panels
SESSION_OBSERVERS_LIMIT = 10
GET_CONTEXT_MAX_TOKENS = 100000
REPRESENTATION_INJECTION_ORDER = ["explicit", "deductive", "inductive", "contradiction"]
MAX_FILE_SIZE = 5242880 # 5MB
MAX_MESSAGE_SIZE = 25000 # Characters
EMBED_MESSAGES = true

View File

@ -522,6 +522,7 @@ DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit", "deductive"]
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
SESSION_OBSERVERS_LIMIT=10
GET_CONTEXT_MAX_TOKENS=100000
REPRESENTATION_INJECTION_ORDER=explicit,deductive,inductive,contradiction
MAX_MESSAGE_SIZE=25000
MAX_FILE_SIZE=5242880 # 5MB
EMBED_MESSAGES=true
@ -530,6 +531,15 @@ EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
NAMESPACE=honcho
```
`REPRESENTATION_INJECTION_ORDER` controls the order of the explicit, deductive,
inductive, and contradiction blocks injected into prompts and returned in rendered
representations. It must contain each section exactly once. The default preserves
the existing order; to put higher-level patterns first, use:
```bash
REPRESENTATION_INJECTION_ORDER=inductive,explicit,deductive,contradiction
```
**Optional Integrations:**
```bash
LANGFUSE_HOST=https://cloud.langfuse.com
@ -649,6 +659,7 @@ A complete config.toml with all defaults. Copy and modify what you need:
[app]
LOG_LEVEL = "INFO"
SESSION_OBSERVERS_LIMIT = 10
REPRESENTATION_INJECTION_ORDER = ["explicit", "deductive", "inductive", "contradiction"]
EMBED_MESSAGES = true
NAMESPACE = "honcho"

View File

@ -13,6 +13,7 @@ from pydantic_settings import (
BaseSettings,
DotEnvSettingsSource,
EnvSettingsSource,
NoDecode,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
@ -29,6 +30,13 @@ EmbeddingTransport = Literal["openai", "gemini"]
EmbeddingDimensionsMode = Literal["auto", "always", "never"]
EmbeddingEncodingFormat = Literal["float", "base64"]
EmbeddingEncodingFormatMode = Literal["auto", "float", "base64"]
RepresentationSection = Literal["explicit", "deductive", "inductive", "contradiction"]
REPRESENTATION_SECTIONS: tuple[RepresentationSection, ...] = (
"explicit",
"deductive",
"inductive",
"contradiction",
)
# OpenAI-compatible models that reject the `dimensions=` request parameter.
_EMBEDDING_KNOWN_REJECTING_MODELS: frozenset[str] = frozenset(
@ -1495,6 +1503,9 @@ class AppSettings(HonchoSettings):
GET_CONTEXT_MAX_TOKENS: Annotated[int, Field(default=100_000, gt=0, le=250_000)] = (
100_000
)
REPRESENTATION_INJECTION_ORDER: Annotated[
tuple[RepresentationSection, ...], NoDecode
] = REPRESENTATION_SECTIONS
MAX_MESSAGE_SIZE: Annotated[int, Field(default=25_000, gt=0)] = 25_000
EMBED_MESSAGES: bool = True
@ -1569,6 +1580,30 @@ class AppSettings(HonchoSettings):
raise ValueError(f"Invalid performance log format: {v}")
return log_format
@field_validator("REPRESENTATION_INJECTION_ORDER", mode="before")
@classmethod
def validate_representation_injection_order(
cls, value: Any
) -> tuple[RepresentationSection, ...]:
sections: tuple[Any, ...]
if isinstance(value, str):
sections = tuple(section.strip() for section in value.split(","))
elif isinstance(value, list | tuple):
sections = tuple(cast(list[Any] | tuple[Any, ...], value))
else:
sections = ()
if len(sections) != len(REPRESENTATION_SECTIONS) or set(sections) != set(
REPRESENTATION_SECTIONS
):
supported = ",".join(REPRESENTATION_SECTIONS)
raise ValueError(
"REPRESENTATION_INJECTION_ORDER must contain each supported "
+ f"section exactly once: {supported}"
)
return cast(tuple[RepresentationSection, ...], sections)
@model_validator(mode="after")
def propagate_namespace(self) -> "AppSettings":
"""Propagate top-level NAMESPACE to nested settings if not explicitly set."""

View File

@ -1,10 +1,11 @@
from collections.abc import Sequence
from collections.abc import Callable, Iterator, Sequence
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
from src import models
from src.config import RepresentationSection, settings
from src.utils.formatting import parse_datetime_iso
# Conclusion levels whose `session_name` stamp is trustworthy enough to scope on.
@ -306,6 +307,20 @@ class ContradictionObservation(ContradictionObservationBase, ObservationMetadata
)
RepresentationObservation = (
ExplicitObservation
| DeductiveObservation
| InductiveObservation
| ContradictionObservation
)
def _observation_without_timestamp(observation: RepresentationObservation) -> str:
if isinstance(observation, ExplicitObservation):
return observation.content
return observation.str_no_timestamps()
class Representation(BaseModel):
"""
A Representation is a traversable and diffable map of observations.
@ -406,6 +421,29 @@ class Representation(BaseModel):
self.inductive = self.inductive[-max_observations:]
self.contradiction = self.contradiction[-max_observations:]
def _iter_sections(
self,
) -> Iterator[tuple[RepresentationSection, Sequence[RepresentationObservation]]]:
sections: dict[RepresentationSection, Sequence[RepresentationObservation]] = {
"explicit": self.explicit,
"deductive": self.deductive,
"inductive": self.inductive,
"contradiction": self.contradiction,
}
for section in settings.REPRESENTATION_INJECTION_ORDER:
yield section, sections[section]
def _format_sections(
self, format_observation: Callable[[RepresentationObservation], str]
) -> str:
parts: list[str] = []
for section, observations in self._iter_sections():
parts.append(f"{section.upper()}:\n")
for index, observation in enumerate(observations, 1):
parts.append(f"{index}. {format_observation(observation)}")
parts.append("")
return "\n".join(parts)
def __str__(self) -> str:
"""
Format representation into a clean, readable string for LLM prompts.
@ -424,30 +462,7 @@ class Representation(BaseModel):
- The user's dog is 5 years old
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation}")
parts.append("")
parts.append("INDUCTIVE:\n")
for i, observation in enumerate(self.inductive, 1):
parts.append(f"{i}. {observation}")
parts.append("")
parts.append("CONTRADICTION:\n")
for i, observation in enumerate(self.contradiction, 1):
parts.append(f"{i}. {observation}")
parts.append("")
return "\n".join(parts)
return self._format_sections(str)
def str_with_ids(self) -> str:
"""
@ -468,29 +483,7 @@ class Representation(BaseModel):
- id:abc123
- id:def456
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation.str_with_id()}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation.str_with_id()}")
parts.append("")
parts.append("INDUCTIVE:\n")
for i, observation in enumerate(self.inductive, 1):
parts.append(f"{i}. {observation.str_with_id()}")
parts.append("")
parts.append("CONTRADICTION:\n")
for i, observation in enumerate(self.contradiction, 1):
parts.append(f"{i}. {observation.str_with_id()}")
parts.append("")
return "\n".join(parts)
return self._format_sections(lambda observation: observation.str_with_id())
def str_no_timestamps(self) -> str:
"""
@ -513,29 +506,7 @@ class Representation(BaseModel):
- id:def456
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation.content}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation.str_no_timestamps()}")
parts.append("")
parts.append("INDUCTIVE:\n")
for i, observation in enumerate(self.inductive, 1):
parts.append(f"{i}. {observation.str_no_timestamps()}")
parts.append("")
parts.append("CONTRADICTION:\n")
for i, observation in enumerate(self.contradiction, 1):
parts.append(f"{i}. {observation.str_no_timestamps()}")
parts.append("")
return "\n".join(parts)
return self._format_sections(_observation_without_timestamp)
def format_as_markdown(self, include_ids: bool = False) -> str:
"""
@ -551,60 +522,59 @@ class Representation(BaseModel):
parts: list[str] = []
# Add explicit observations
if self.explicit:
parts.append("## Explicit Observations\n")
for obs in self.explicit:
# Don't need IDs for explicit as these are the lowest level of reasoning.
# id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
parts.append(f"{obs}")
parts.append("")
for section, observations in self._iter_sections():
if not observations:
continue
# Add deductive observations
if self.deductive:
parts.append("## Deductive Observations\n")
for obs in self.deductive:
id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
timestamp = _strip_microseconds_and_timezone(obs.created_at)
parts.append(f"{id_prefix}[{timestamp}] {obs.conclusion}")
if obs.premises:
parts.append(" Premises:")
for premise in obs.premises:
parts.append(f" - {premise}")
if section == "explicit":
parts.append("## Explicit Observations\n")
for obs in self.explicit:
# IDs are unnecessary for the lowest reasoning level.
parts.append(f"{obs}")
parts.append("")
parts.append("")
# Add inductive observations
if self.inductive:
parts.append("## Inductive Observations\n")
for obs in self.inductive:
id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
parts.append(
f"{id_prefix} **Pattern** [{obs.confidence}]: {obs.conclusion}"
)
if obs.pattern_type:
parts.append(f" **Type**: {obs.pattern_type}")
if obs.sources:
parts.append(" **Sources**:")
for source in obs.sources[:5]:
parts.append(f" - {source}")
if len(obs.sources) > 5:
parts.append(f" - ... and {len(obs.sources) - 5} more")
elif section == "deductive":
parts.append("## Deductive Observations\n")
for obs in self.deductive:
id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
timestamp = _strip_microseconds_and_timezone(obs.created_at)
parts.append(f"{id_prefix}[{timestamp}] {obs.conclusion}")
if obs.premises:
parts.append(" Premises:")
for premise in obs.premises:
parts.append(f" - {premise}")
parts.append("")
parts.append("")
parts.append("")
# Add contradiction observations
if self.contradiction:
parts.append("## Contradictions\n")
for obs in self.contradiction:
id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
parts.append(f"{id_prefix} **CONTRADICTION**: {obs.content}")
if obs.sources:
parts.append(" **Conflicting statements**:")
for source in obs.sources:
parts.append(f" - {source}")
elif section == "inductive":
parts.append("## Inductive Observations\n")
for obs in self.inductive:
id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
parts.append(
f"{id_prefix} **Pattern** [{obs.confidence}]: {obs.conclusion}"
)
if obs.pattern_type:
parts.append(f" **Type**: {obs.pattern_type}")
if obs.sources:
parts.append(" **Sources**:")
for source in obs.sources[:5]:
parts.append(f" - {source}")
if len(obs.sources) > 5:
parts.append(f" - ... and {len(obs.sources) - 5} more")
parts.append("")
parts.append("")
else:
parts.append("## Contradictions\n")
for obs in self.contradiction:
id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else ""
parts.append(f"{id_prefix} **CONTRADICTION**: {obs.content}")
if obs.sources:
parts.append(" **Conflicting statements**:")
for source in obs.sources:
parts.append(f" - {source}")
parts.append("")
parts.append("")
parts.append("")
return "\n".join(parts)

View File

@ -0,0 +1,288 @@
import asyncio
from collections.abc import Callable
from datetime import datetime, timezone
from unittest.mock import AsyncMock
import pytest
from src import crud
from src.config import AppSettings
from src.embedding_client import embedding_client
from src.utils import agent_tools
from src.utils import representation as representation_module
from src.utils.agent_tools import ToolContext
from src.utils.representation import (
ContradictionObservation,
DeductiveObservation,
ExplicitObservation,
InductiveObservation,
Representation,
)
from src.utils.types import ToolResult
DEFAULT_ORDER = ("explicit", "deductive", "inductive", "contradiction")
PATTERNS_FIRST_ORDER = ("inductive", "explicit", "deductive", "contradiction")
def _full_representation() -> Representation:
created_at = datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
return Representation(
explicit=[
ExplicitObservation(
id="exp-1",
content="explicit fact",
created_at=created_at,
message_ids=[1],
)
],
deductive=[
DeductiveObservation(
id="ded-1",
conclusion="deductive fact",
premises=["explicit fact"],
created_at=created_at,
message_ids=[1],
)
],
inductive=[
InductiveObservation(
id="ind-1",
conclusion="inductive pattern",
confidence="high",
pattern_type="preference",
sources=["explicit fact"],
created_at=created_at,
message_ids=[1],
)
],
contradiction=[
ContradictionObservation(
id="con-1",
content="conflicting fact",
sources=["a", "b"],
created_at=created_at,
message_ids=[1],
)
],
)
def _assert_headers_in_order(rendered: str, headers: tuple[str, ...]) -> None:
positions = [rendered.index(header) for header in headers]
assert positions == sorted(positions)
def _render_with_ids(representation: Representation) -> str:
return representation.str_with_ids()
def _render_without_timestamps(representation: Representation) -> str:
return representation.str_no_timestamps()
def _render_as_markdown(representation: Representation) -> str:
return representation.format_as_markdown(include_ids=True)
def test_representation_injection_order_defaults_to_current_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("REPRESENTATION_INJECTION_ORDER", raising=False)
assert AppSettings().REPRESENTATION_INJECTION_ORDER == DEFAULT_ORDER
def test_representation_injection_order_accepts_comma_separated_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"REPRESENTATION_INJECTION_ORDER",
"inductive, explicit, deductive, contradiction",
)
assert AppSettings().REPRESENTATION_INJECTION_ORDER == PATTERNS_FIRST_ORDER
@pytest.mark.parametrize(
"configured_order",
[
"explicit,deductive,inductive",
"explicit,deductive,inductive,explicit",
"explicit,deductive,inductive,unknown",
],
)
def test_representation_injection_order_rejects_invalid_permutations(
monkeypatch: pytest.MonkeyPatch, configured_order: str
) -> None:
monkeypatch.setenv("REPRESENTATION_INJECTION_ORDER", configured_order)
with pytest.raises(
ValueError,
match="REPRESENTATION_INJECTION_ORDER must contain each supported section exactly once",
):
AppSettings()
def test_default_rendering_is_byte_for_byte_unchanged(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
representation_module,
"settings",
AppSettings(REPRESENTATION_INJECTION_ORDER=DEFAULT_ORDER),
raising=False,
)
representation = _full_representation()
assert str(representation) == (
"EXPLICIT:\n\n"
"1. [2025-01-02 03:04:05] explicit fact\n\n"
"DEDUCTIVE:\n\n"
"1. [2025-01-02 03:04:05] deductive fact\n"
" - explicit fact\n\n"
"INDUCTIVE:\n\n"
"1. [2025-01-02 03:04:05] [high] inductive pattern\n"
" - explicit fact\n\n"
"CONTRADICTION:\n\n"
"1. [2025-01-02 03:04:05] CONTRADICTION: conflicting fact\n"
" - a\n"
" - b\n"
)
assert representation.str_with_ids() == (
"EXPLICIT:\n\n"
"1. [id:exp-1] [2025-01-02 03:04:05] explicit fact\n\n"
"DEDUCTIVE:\n\n"
"1. [id:ded-1] [2025-01-02 03:04:05] deductive fact\n"
" - explicit fact\n\n"
"INDUCTIVE:\n\n"
"1. [id:ind-1] [2025-01-02 03:04:05] [high] inductive pattern\n"
" - explicit fact\n\n"
"CONTRADICTION:\n\n"
"1. [id:con-1] [2025-01-02 03:04:05] CONTRADICTION: conflicting fact\n"
" - a\n"
" - b\n"
)
assert representation.str_no_timestamps() == (
"EXPLICIT:\n\n"
"1. explicit fact\n\n"
"DEDUCTIVE:\n\n"
"1. deductive fact\n"
" - explicit fact\n\n"
"INDUCTIVE:\n\n"
"1. [high] inductive pattern\n"
" - explicit fact\n\n"
"CONTRADICTION:\n\n"
"1. CONTRADICTION: conflicting fact\n"
" - a\n"
" - b\n"
)
assert representation.format_as_markdown(include_ids=True) == (
"## Explicit Observations\n\n"
"[2025-01-02 03:04:05] explicit fact\n\n"
"## Deductive Observations\n\n"
"[id:ded-1] [2025-01-02 03:04:05] deductive fact\n"
" Premises:\n"
" - explicit fact\n\n\n"
"## Inductive Observations\n\n"
"[id:ind-1] **Pattern** [high]: inductive pattern\n"
" **Type**: preference\n"
" **Sources**:\n"
" - explicit fact\n\n\n"
"## Contradictions\n\n"
"[id:con-1] **CONTRADICTION**: conflicting fact\n"
" **Conflicting statements**:\n"
" - a\n"
" - b\n\n"
)
@pytest.mark.parametrize(
("renderer", "ordered_headers"),
[
(
str,
("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"),
),
(
_render_with_ids,
("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"),
),
(
_render_without_timestamps,
("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"),
),
(
_render_as_markdown,
(
"## Inductive Observations",
"## Explicit Observations",
"## Deductive Observations",
"## Contradictions",
),
),
],
)
def test_all_renderers_use_the_configured_section_order(
monkeypatch: pytest.MonkeyPatch,
renderer: Callable[[Representation], str],
ordered_headers: tuple[str, ...],
) -> None:
monkeypatch.setattr(
representation_module,
"settings",
AppSettings(REPRESENTATION_INJECTION_ORDER=PATTERNS_FIRST_ORDER),
raising=False,
)
rendered = renderer(_full_representation())
_assert_headers_in_order(rendered, ordered_headers)
@pytest.mark.asyncio
async def test_search_memory_prompt_uses_the_configured_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
representation = _full_representation()
monkeypatch.setattr(
representation_module,
"settings",
AppSettings(REPRESENTATION_INJECTION_ORDER=PATTERNS_FIRST_ORDER),
raising=False,
)
monkeypatch.setattr(
embedding_client,
"embed",
AsyncMock(return_value=[0.1]),
)
monkeypatch.setattr(
crud,
"query_documents",
AsyncMock(return_value=[object()]),
)
def return_representation(_documents: object) -> Representation:
return representation
monkeypatch.setattr(Representation, "from_documents", return_representation)
context = ToolContext(
workspace_name="workspace",
observer="observer",
observed="observed",
session_name=None,
current_messages=None,
include_observation_ids=False,
history_token_limit=8192,
db_lock=asyncio.Lock(),
)
result = await agent_tools._handle_search_memory( # pyright: ignore[reportPrivateUsage]
context,
{"query": "patterns", "top_k": 10},
)
assert isinstance(result, ToolResult)
_assert_headers_in_order(
result.content,
("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"),
)