fix(honcho): stop clipping honcho_reasoning tool results to the injection budget

dialecticMaxChars (default 600) is documented as the budget for the dialectic
supplement auto-injected into the system prompt every turn — a small recurring
cost that is correct to bound tightly. But dialectic_query() applied that cap
unconditionally, so explicit honcho_reasoning tool calls — where the model
deliberately spends a turn asking for a synthesized answer — were silently
truncated mid-word to 600 chars with a trailing " …", no error surfaced. The
full answer is returned by Honcho server-side; the clip happens client-side.

The auto-injection path already has its own token-based budget (contextTokens,
enforced in prefetch() via _truncate_to_budget), so the char cap's real job is a
cheap always-on guardrail for that recurring injection. Explicit tool results
are already bounded server-side by Honcho's dialectic MAX_OUTPUT_TOKENS and don't
need the injection cap — sibling tools (honcho_search, honcho_context) don't
post-clip their results either.

Add apply_injection_cap (default True, preserving current behavior) to
dialectic_query(); the honcho_reasoning tool handler passes False so it returns
Honcho's full synthesized answer. Auto-injection is unchanged. Tests cover both
the capped injection path and the uncapped tool path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
vizi0uz 2026-07-06 04:50:22 -03:00 committed by Teknium
parent 29e0471708
commit 56816f4232
4 changed files with 75 additions and 3 deletions

View file

@ -1552,6 +1552,11 @@ class HonchoMemoryProvider(MemoryProvider):
self._session_key, query,
reasoning_level=reasoning_level,
peer=peer,
# Explicit tool call: return Honcho's full synthesized answer.
# The system-prompt injection cap (dialecticMaxChars) must not
# clip a result the model deliberately asked for; it's already
# bounded server-side by Honcho's MAX_OUTPUT_TOKENS.
apply_injection_cap=False,
)
# Update cadence tracker so auto-injection respects the gap after an explicit call
self._last_dialectic_turn = self._turn_count

View file

@ -331,7 +331,10 @@ class HonchoClientConfig:
# honcho_reasoning tool param (agentic). When false, always uses
# dialecticReasoningLevel and ignores model-provided overrides.
dialectic_dynamic: bool = True
# Max chars of dialectic result to inject into Hermes system prompt
# Max chars of the dialectic supplement auto-injected into the Hermes system
# prompt each turn. Applies ONLY to auto-injection — explicit honcho_reasoning
# tool results return in full (bounded server-side by Honcho's MAX_OUTPUT_TOKENS),
# via dialectic_query(apply_injection_cap=False).
dialectic_max_chars: int = 600
# Dialectic depth: how many .chat() calls per dialectic cycle (1-3).
# Depth 1: single call. Depth 2: self-audit + targeted synthesis.

View file

@ -602,6 +602,7 @@ class HonchoSessionManager:
self, session_key: str, query: str,
reasoning_level: str | None = None,
peer: str = "user",
apply_injection_cap: bool = True,
) -> str:
"""
Query Honcho's dialectic endpoint about a peer.
@ -617,6 +618,15 @@ class HonchoSessionManager:
Only honored when dialecticDynamic is true.
If None or dialecticDynamic is false, uses the configured default.
peer: Which peer to query "user" (default) or "ai".
apply_injection_cap: When True (default), clip the result to
``dialecticMaxChars`` the budget for the dialectic
supplement auto-injected into the system prompt every
turn. Set False for explicit ``honcho_reasoning`` tool
calls, where the model deliberately asked for a full
synthesized answer; that result is already bounded
server-side by Honcho's dialectic MAX_OUTPUT_TOKENS,
so clipping it to the injection budget silently
discards content the caller asked for.
Returns:
Honcho's synthesized answer, or empty string on failure.
@ -655,8 +665,17 @@ class HonchoSessionManager:
target_peer = self._get_or_create_peer(target_peer_id)
result = target_peer.chat(query, reasoning_level=level) or ""
# Apply Hermes-side char cap before caching
if result and self._dialectic_max_chars and len(result) > self._dialectic_max_chars:
# Apply the Hermes-side injection char cap before caching. This budget
# (dialecticMaxChars) bounds the dialectic supplement auto-injected into
# the system prompt every turn; it must NOT clip explicit
# honcho_reasoning tool results, which the model asked for in full and
# which Honcho already bounds server-side via MAX_OUTPUT_TOKENS.
if (
apply_injection_cap
and result
and self._dialectic_max_chars
and len(result) > self._dialectic_max_chars
):
result = result[:self._dialectic_max_chars].rsplit(" ", 1)[0] + ""
return result
except Exception as e:

View file

@ -564,6 +564,7 @@ class TestConcludeToolDispatch:
"who are you",
reasoning_level=None,
peer="hermes",
apply_injection_cap=False,
)
def test_honcho_conclude_missing_both_params_returns_error(self):
@ -1010,6 +1011,50 @@ class TestDialecticInputGuard:
assert len(actual_query) <= 100
class TestDialecticInjectionCap:
"""dialecticMaxChars must bound the auto-injected supplement but must NOT
clip explicit honcho_reasoning tool results, which the model asked for in
full (they are already bounded server-side by Honcho's MAX_OUTPUT_TOKENS)."""
def _manager_with_long_answer(self, answer):
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig(dialectic_max_chars=50)
mgr = HonchoSessionManager(config=cfg)
mgr._dialectic_max_chars = 50
session = HonchoSession(
key="test", user_peer_id="u", assistant_peer_id="a",
honcho_session_id="s",
)
mgr._cache["test"] = session
mock_peer = MagicMock()
mock_peer.chat.return_value = answer
mgr._get_or_create_peer = MagicMock(return_value=mock_peer)
return mgr
def test_injection_path_truncates(self):
"""Default (auto-injection) path clips to dialecticMaxChars with an ellipsis."""
answer = "fact " * 100 # 500 chars, well over the 50-char cap
mgr = self._manager_with_long_answer(answer)
result = mgr.dialectic_query("test", "summarize")
assert len(result) <= 60 # cap + word-boundary slack + ellipsis
assert result.endswith("")
def test_tool_path_returns_full_answer(self):
"""Explicit tool call (apply_injection_cap=False) returns the full answer."""
answer = "fact " * 100 # 500 chars, well over the 50-char cap
mgr = self._manager_with_long_answer(answer)
result = mgr.dialectic_query("test", "summarize", apply_injection_cap=False)
assert result == answer
assert not result.endswith("")
# ---------------------------------------------------------------------------